← back to Tk 10867 Gb Safe Plan

scripts/add-sample-variants.mjs

103 lines

#!/usr/bin/env node
// TK-10867 — ADD the DW-spec "Sample" variant to the 81 ACTIVE Graham & Brown products
// that lack one. Cohort authoritative source = data/live-missing-sample.json (built by the
// read-only enumerator scripts/live-missing-sample-enumerate.mjs). Steve in-session GO 2026-08-31.
//
// DW Sample-variant spec (standing rule): every product carries a Sample variant —
//   option1 = "Sample", SKU = {base_dw_sku}-Sample, price $4.25, NO inventory tracking.
//
// Method: REST POST /products/{id}/variants.json (adds a variant to the existing single-option
// product; the existing option gets a second value "Sample"). Idempotent — re-checks live for an
// existing sample variant per product and SKIPS if present. Reversibility FIRST: records the
// new variant id to a restore-map JSONL before/after each create (undo = DELETE that variant).
// Each create is ledgered to the executed-reversible ledger. DRY-RUN default; --apply to write.
import fs from 'node:fs';

const SECRETS = '/Users/macstudio3/Projects/secrets-manager/.env';
const STORE = 'designer-laboratory-sandbox.myshopify.com';
const env = fs.readFileSync(SECRETS, 'utf8');
const TOKEN = env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)[1].trim(); // full-access (write_products)
const REST = `https://${STORE}/admin/api/2024-10`;
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const APPLY = process.argv.includes('--apply');
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);

const COHORT = JSON.parse(fs.readFileSync(new URL('../data/live-missing-sample.json', import.meta.url), 'utf8'));
const TS = new Date().toISOString().replace(/[:.]/g, '-');
const RESTORE = new URL(`../data/add-sample-restore-map-${TS}.jsonl`, import.meta.url).pathname;
const AUDIT = new URL(`../data/add-sample-audit-${TS}.jsonl`, import.meta.url).pathname;
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function rest(method, url, body) {
  for (let a = 0; a < 4; a++) {
    const r = await fetch(url, { method, headers: H, body: body ? JSON.stringify(body) : undefined });
    if (r.status === 429) { await sleep(2000); continue; }
    if ((r.status === 502 || r.status === 503) && a < 3) { await sleep(1500 * (a + 1)); continue; }
    const txt = await r.text();
    let j; try { j = JSON.parse(txt); } catch { j = { _raw: txt }; }
    return { status: r.status, ok: r.ok, j };
  }
  return { status: 0, ok: false, j: {} };
}

// Read live variants for a product; return {baseSku, hasSample}
async function liveState(pid) {
  const { j } = await rest('GET', `${REST}/products/${pid}.json?fields=id,status,variants`);
  const p = j.product;
  if (!p) return null;
  const hasSample = p.variants.some(v => /-sample$/i.test(v.sku || '') || /sample/i.test(v.option1 || ''));
  const base = p.variants.map(v => v.sku).find(s => s && !/-sample$/i.test(s));
  return { status: p.status, hasSample, baseSku: base, variantCount: p.variants.length };
}

async function main() {
  const items = COHORT.missing_sample.slice(0, LIMIT > 0 ? LIMIT : COHORT.missing_sample.length);
  console.log(`add-sample-variants: cohort ${COHORT.missing_sample.length}, processing ${items.length} · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
  const restoreOut = APPLY ? fs.createWriteStream(RESTORE, { flags: 'a' }) : null;
  const auditOut = APPLY ? fs.createWriteStream(AUDIT, { flags: 'a' }) : null;
  const ledgerOut = APPLY ? fs.createWriteStream(LEDGER, { flags: 'a' }) : null;
  let added = 0, skipped = 0, failed = 0;
  for (const it of items) {
    const pid = it.id;
    const st = await liveState(pid);
    if (!st) { console.error(`  ERR ${pid}: product not found live`); failed++; continue; }
    if (st.status.toUpperCase() !== 'ACTIVE') { console.error(`  SKIP ${pid}: not ACTIVE (${st.status})`); skipped++; continue; }
    if (st.hasSample) { console.log(`  skip ${pid}: already has Sample`); skipped++; continue; }
    const base = st.baseSku;
    if (!base) { console.error(`  ERR ${pid}: no base SKU — refusing (never mint)`); failed++; continue; }
    const sampleSku = `${base}-Sample`;
    // DW spec: Sample variant, $4.25, NO inventory tracking (inventory_management=null),
    // inventory_policy=continue so it never blocks checkout.
    const variant = { option1: 'Sample', sku: sampleSku, price: '4.25',
      inventory_management: null, inventory_policy: 'continue', requires_shipping: true, taxable: true };
    if (!APPLY) {
      console.log(`  ${pid} ${it.title.slice(0, 42)} -> +Sample ${sampleSku} $4.25 (untracked)`);
      continue;
    }
    const r = await rest('POST', `${REST}/products/${pid}/variants.json`, { variant });
    if (!r.ok || !r.j.variant) {
      console.error(`  FAIL ${pid} ${sampleSku}: ${r.status} ${JSON.stringify(r.j).slice(0, 160)}`);
      auditOut.write(JSON.stringify({ pid, sampleSku, action: 'ERR', status: r.status, body: r.j }) + '\n');
      failed++; await sleep(500); continue;
    }
    const nv = r.j.variant;
    // Reversibility recorded AFTER create (undo = delete this exact new variant).
    restoreOut.write(JSON.stringify({ pid, new_variant_id: nv.id, sku: nv.sku, created_at: new Date().toISOString() }) + '\n');
    auditOut.write(JSON.stringify({ pid, title: it.title, new_variant_id: nv.id, sku: nv.sku, price: nv.price,
      inv_mgmt: nv.inventory_management, inv_policy: nv.inventory_policy, action: 'ADDED' }) + '\n');
    ledgerOut.write(JSON.stringify({ ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10867',
      action: `add Sample variant ${nv.sku} to GB product ${pid}`, blast_radius: 1,
      undo_cmd: `curl -s -X DELETE "https://${STORE}/admin/api/2024-10/products/${pid}/variants/${nv.id}.json" -H "X-Shopify-Access-Token: $SHOPIFY_FULL_ACCESS_TOKEN"`,
      verify: `variant ${nv.id} sku=${nv.sku} price=${nv.price} tracked=${nv.inventory_management === null ? 'no' : 'yes'}` }) + '\n');
    console.log(`  + ${pid} ${nv.sku} $${nv.price} (untracked, policy=${nv.inventory_policy}) [vid ${nv.id}]`);
    added++;
    await sleep(400); // ≥ throttle-safe; daily variant cap unaffected (81 < 1k)
  }
  if (auditOut) { auditOut.end(); restoreOut.end(); ledgerOut.end(); }
  console.log(`\nDONE: added=${added} skipped=${skipped} failed=${failed} of ${items.length}` +
    (APPLY ? `\nrestore-map: ${RESTORE}\naudit: ${AUDIT}` : ' · DRY-RUN'));
  if (failed) process.exit(2);
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });