← 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); });