← back to Pf Sku Renumber 2026

renumber-shopify.mjs

58 lines

#!/usr/bin/env node
// Renumber Pierre Frey variant SKUs on live Shopify: DWPF-15xxxx/20xxxx -> DWPF-600xxx
// (preserves the -Sample suffix). Dry-run by default; --commit writes; --limit N caps.
// Reads worklist.tsv (pid<TAB>old_base<TAB>new_base<TAB>status). Rollback log per variant.
import fs from 'node:fs';
import readline from 'node:readline';

const ENV = fs.readFileSync(new URL('file://' + process.env.HOME + '/Projects/Designer-Wallcoverings/.env'), 'utf8');
const g = (k) => (ENV.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.trim();
const STORE = g('SHOPIFY_STORE_DOMAIN') || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = g('SHOPIFY_ADMIN_ACCESS_TOKEN') || g('SHOPIFY_ADMIN_TOKEN');
const API = g('SHOPIFY_ADMIN_API_VERSION') || '2024-01';
const COMMIT = process.argv.includes('--commit');
const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i + 1], 10) : Infinity; })();
if (!TOKEN) { console.error('no Shopify token'); process.exit(1); }

const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const RB = `rollback-${COMMIT ? 'commit' : 'dryrun'}.jsonl`;
let calls = 0, updated = 0, skipped = 0, errors = 0, prod = 0;

async function api(method, path, body) {
  for (let attempt = 0; attempt < 6; attempt++) {
    calls++;
    const res = await fetch(`https://${STORE}/admin/api/${API}${path}`, { method, headers: H, body: body ? JSON.stringify(body) : undefined });
    if (res.status === 429) { await sleep(2000 * (attempt + 1)); continue; }
    await sleep(550); // ~2 req/s REST budget
    if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text()}`);
    return res.json();
  }
  throw new Error(`${method} ${path} -> 429 exhausted`);
}

const rl = readline.createInterface({ input: fs.createReadStream('worklist.tsv') });
const rows = [];
for await (const line of rl) { if (line.trim()) rows.push(line.split('\t')); }

console.log(`${COMMIT ? 'COMMIT' : 'DRY-RUN'} · ${STORE} · ${Math.min(rows.length, LIMIT)}/${rows.length} products`);
for (const [pid, oldBase, newBase, status] of rows) {
  if (prod >= LIMIT) break;
  prod++;
  try {
    const { product } = await api('GET', `/products/${pid}.json?fields=id,variants`);
    if (!product) { skipped++; continue; }
    for (const v of product.variants) {
      const sku = (v.sku || '');
      if (!sku.toUpperCase().startsWith(oldBase.toUpperCase())) { skipped++; continue; }
      const newSku = newBase + sku.slice(oldBase.length); // preserve -Sample suffix
      fs.appendFileSync(RB, JSON.stringify({ pid, vid: v.id, old: sku, new: newSku, status }) + '\n');
      if (COMMIT) { await api('PUT', `/variants/${v.id}.json`, { variant: { id: v.id, sku: newSku } }); }
      updated++;
      if (updated <= 6 || updated % 500 === 0) console.log(`  ${status} ${sku} -> ${newSku}`);
    }
  } catch (e) { errors++; console.error(`  ERR pid=${pid}: ${e.message.slice(0, 160)}`); }
  if (prod % 250 === 0) console.log(`… ${prod}/${Math.min(rows.length, LIMIT)} products · ${updated} variants · ${errors} err · ${calls} calls`);
}
console.log(`DONE ${COMMIT ? '(committed)' : '(dry-run)'} · products=${prod} variants=${updated} skipped=${skipped} errors=${errors} calls=${calls} · rollback=${RB}`);