← back to Sanderson Onboard
scripts/create_sdg.mjs
254 lines
// create_sdg.mjs — create genuinely-absent SDG products on the LIVE DW store, both variants in ONE POST
// (no productVariantsBulkCreate -> sample-overwrite bug cannot fire), then go-live via artmura-proven GraphQL
// (track -> activate -> set 2026 -> publish 13 channels -> ACTIVE). Adapted from the proven hollywood-create.mjs.
// DRY-RUN by default; --apply --limit=N to write. Reads pilot/manifest-<brand>.json (settlement-clean).
// Per-item FINAL 6-gate re-check before activation: sample+sellable+price+desc+>=2tags+image (else SKIP-HELD).
// Records restore-map for reversibility; logs each activation to the executed-reversible ledger.
import fs from 'node:fs';
const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
const TOKEN = (env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m) || [])[1].trim(); // TK-10979 DTD-A: full-access for write_inventory (bridge; target=capability-scoped token)
const REST = `https://${STORE}/admin/api/2024-10`;
const GQL = `${REST}/graphql.json`;
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 MANIFEST = (process.argv.find(a => a.startsWith('--manifest=')) || '').split('=')[1];
const AUDIT = new URL('../pilot/create-audit.jsonl', import.meta.url).pathname;
const RESTORE = new URL('../pilot/restore-map.jsonl', import.meta.url).pathname;
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const LOCATION_ID = 'gid://shopify/Location/5795643504';
const TARGET_QTY = 2026;
const PUBLICATIONS = [22208643184,22497296496,29646651457,29739483201,29776969793,37904089153,
43657658419,44234276915,44317474867,44317507635,71898464307,115856375859,140027723827].map(n => `gid://shopify/Publication/${n}`);
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Title Case that also capitalizes the first letter after a "/" (color names: "Ivory/Duck Egg", "Blue/Pink")
const titleCase = s => String(s || '')
.split('/').map(seg => seg.replace(/\b\w[\w']*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase())).join('/');
// 6-gate: sample+sellable are structural; check price/desc/tags/image + title-source
function gate(it) {
const r = [];
if (!(parseFloat(it.retail_usd) > 0)) r.push('no-price');
if (!it.image) r.push('no-image');
if (!it.description || it.description.length < 10) r.push('no-description');
if (!it.tags || it.tags.length < 2) r.push('lt2-tags');
if (!it.pattern && !it.color) r.push('no-title-source');
if (it.settlement && it.settlement.verdict === 'BLOCK') r.push('settlement-block');
return r;
}
function payload(it) {
// Title format: "Pattern Name Real Color Name | Brand Name", Title Case, never "Unknown"/"Wallpaper".
const colorPart = it.color && it.color.trim() && it.color.trim() !== it.pattern.trim() ? ` ${it.color.trim()}` : '';
let title = titleCase(`${it.pattern}${colorPart}`).replace(/\bwallpapers?\b/gi, 'Wallcovering');
title = `${title} | ${it.vendor}`;
const tags = [...new Set(it.tags.map(t => String(t).trim()).filter(Boolean))];
const dw = it.dw_sku, unit = it.unit;
const row = (l, v) => (v ? `<tr><td>${l}</td><td>${v}</td></tr>` : '');
const body = `<p>${String(it.description).replace(/\bwallpapers?\b/gi, 'wallcovering')}</p>` +
`<table>` + row('Pattern', it.pattern) + row('Color', it.color) + row('Collection', it.collection) +
row('Width', it.width) + row('Length', it.length) + `<tr><td>Sold Per</td><td>${unit}</td></tr></table>`;
const sl = 'single_line_text_field', ml = 'multi_line_text_field';
const metafields = [];
const mf = (ns, k, v, type = sl) => { let x = String(v || '').trim(); if (type === sl) x = x.replace(/\s+/g, ' '); if (x) metafields.push({ namespace: ns, key: k, type, value: x }); };
if (it.width) mf('global', 'width', it.width);
mf('global', 'unit_of_measure', `Sold Per ${unit}`);
mf('custom', 'pattern_name', it.pattern);
mf('custom', 'supplier_name', it.vendor);
mf('custom', 'manufacturer_sku', it.mfr_sku);
mf('dwc', 'manufacturer_sku', it.mfr_sku);
mf('dwc', 'dw_sku', it.dw_sku);
mf('global', 'dw_sku', it.dw_sku);
return { product: {
title, vendor: it.vendor, product_type: it.product_type, status: 'draft',
tags: tags.join(', '), body_html: body, metafields, images: [{ src: it.image }],
options: [{ name: 'Size' }],
variants: [
{ sku: dw, price: String(parseFloat(it.retail_usd).toFixed(2)), option1: `Sold Per ${unit}`, taxable: true, requires_shipping: true, weight: defaultWeightLb({ sku: dw, option1: `Sold Per ${unit}` }, { productType: it.product_type }), weight_unit: 'lb' }, // TK-11414
{ sku: `${dw}-Sample`, price: '4.25', option1: 'Sample', taxable: true, requires_shipping: true, weight: SAMPLE_WEIGHT_LB, weight_unit: 'lb' }, // TK-11414
],
} };
}
async function gql(query, variables) {
for (let a = 0; a < 4; a++) {
const r = await fetch(GQL, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
const j = await r.json();
if (j.errors) { if (a === 3) throw new Error(JSON.stringify(j.errors)); await sleep(900 * (a + 1)); continue; }
return j.data;
}
}
const Q_V = `query($id:ID!){product(id:$id){status vendor tags productType variants(first:10){edges{node{id sku title price inventoryItem{id measurement{weight{value}}}}}}}}`;
const M_WEIGHT = `mutation($id:ID!,$w:Float!){inventoryItemUpdate(id:$id,input:{measurement:{weight:{value:$w,unit:POUNDS}}}){userErrors{message}}}`; // TK-11414
import { safeStampQuantity } from './lib/inventory-stamp-guard.mjs'; // GUARD TK-11357 (shared guard)
import { defaultWeightLb, hasZeroWeight, SAMPLE_WEIGHT_LB } from './lib/weight-guard.mjs'; // GUARD TK-11414 (weight go-live)
// ── GUARD TK-11357 BEGIN ─ do not edit without re-running the fixture proof ──────────
// A $0 / quote-only sellable variant must NEVER receive positive stock: positive stock is what
// flips availableForSale=true, making it checkout-orderable at $0 (lineage TK-10825 -> 10965 ->
// 11140 -> 11299 -> 11301 -> 11357). $0 is the LIVE theme's deliberate quote-only SENTINEL
// (snippets/product-form-content.liquid renders the "Contact Us" button iff variant.price == 0),
// so the remedy is NEVER to write a placeholder price - it is "do not stock it".
// Steve's 2026-06-20 "active products are never out of stock" rule is PRESERVED for PRICED goods:
// a priced variant still gets `desired`. The quote-only tag/vendor decision is delegated to the
// shared guard (lib/inventory-stamp-guard.mjs); this adds one strictly-safer rule of its own -
// price <= 0 / NaN is ALWAYS 0, even on a variant labelled "Sample" (a $0 "sample" is the same
// $0-orderable defect). The real $4.25 memo sample is unaffected and keeps its existing quantity.
function safeQuantities(product, variants, locationId, desired) {
return (variants || []).map(v => ({
inventoryItemId: v.inventoryItem.id,
locationId,
quantity: Number(v.price) > 0
? safeStampQuantity({ title: v.title, price: v.price }, product, desired)
: 0,
}));
}
// ── GUARD TK-11357 END ────────────────────────────────────────────
const M_TRACK = `mutation($id:ID!){inventoryItemUpdate(id:$id,input:{tracked:true}){userErrors{message}}}`;
const M_ACT = `mutation($iid:ID!,$loc:ID!){inventoryActivate(inventoryItemId:$iid,locationId:$loc){userErrors{message}}}`;
const M_QTY = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{message}}}`;
const M_PUB = `mutation($id:ID!,$pubs:[PublicationInput!]!){publishablePublish(id:$id,input:$pubs){userErrors{message}}}`;
const M_ACTIVE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{status}userErrors{message}}}`;
async function goLive(pid) {
const gid = `gid://shopify/Product/${pid}`;
const d = await gql(Q_V, { id: gid });
const vnodes = d.product.variants.edges.map(e => e.node);
const items = vnodes.map(v => v.inventoryItem.id);
// GUARD TK-11357 (DEFENCE-IN-DEPTH, not a live-risk closure). This script is already
// effectively guarded upstream: gate() pushes 'no-price' when !(parseFloat(retail_usd) > 0)
// and the caller `continue`s on SKIP-HELD BEFORE any create or inventory mutation, and it is
// CREATE-ONLY + manifest-bounded (harlequin/morris/sanderson/zoffany — zero Phillipe Romano),
// so it cannot re-inflate a pre-existing cohort. What it lacked was the guarantee AT THE WRITE
// SITE: goLive() re-reads the product and stamped TARGET_QTY on every variant, and the
// finish-pending path calls goLive() on drafts created by an EARLIER run whose live prices it
// never re-checks. Routing the stamp through the shared guard makes the invariant hold at the
// mutation itself rather than only in an upstream manifest field.
const quantities = safeQuantities(d.product, vnodes, LOCATION_ID, TARGET_QTY);
// TK-11414: never let a product go live at zero weight. Set the default on any zero-weight
// variant BEFORE activating — enforces the rule AND self-heals finish-pending drafts built
// before weight was wired (freight gate: zero-weight collapses orders into the 0.5lb tier).
// TK-11471: the heal must be VERIFIED, not fired-and-forgotten. This block previously did
// `await gql(M_WEIGHT, ...)` and DISCARDED the result — no userErrors check, no re-query —
// then fell straight through to M_ACTIVE. A heal that silently failed (userError, a token
// without write_inventory, a throttle) therefore published the product ACTIVE at zero weight
// anyway, which is precisely the leak this guard was added to stop. The mutation's own 200 is
// not evidence that the weight is set; only re-reading it is.
const healFailures = [];
for (const v of vnodes) {
if (!hasZeroWeight(v)) continue;
const w = defaultWeightLb(v, { productType: d.product.productType });
const hr = await gql(M_WEIGHT, { id: v.inventoryItem.id, w });
const ue = hr?.inventoryItemUpdate?.userErrors || [];
if (ue.length) { healFailures.push(`${v.sku || v.title}: ${ue.map(e => e.message).join('; ')}`); }
}
// RE-VERIFY from the live product rather than trusting the mutations above.
{
const chk = await gql(Q_V, { id: gid });
const rechecked = chk?.product?.variants?.edges?.map(e => e.node) ?? [];
const still = rechecked.filter(hasZeroWeight);
if (still.length) healFailures.push(`still zero after heal: ${still.map(v => v.sku || v.title || '?').join(', ')}`);
if (!rechecked.length) healFailures.push('re-verify returned no variants — weight could not be asserted');
}
if (healFailures.length) {
// HOLD as draft. Never flip ACTIVE at zero weight (Steve's TK-11414 rule).
return { gated: 'weight>0', reasons: healFailures };
}
for (const iid of items) { await gql(M_TRACK, { id: iid }); await gql(M_ACT, { iid, loc: LOCATION_ID }); }
await gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities } });
await gql(M_PUB, { id: gid, pubs: PUBLICATIONS.map(p => ({ publicationId: p })) });
const r = await gql(M_ACTIVE, { id: gid });
return { status: r.productUpdate?.product?.status, varCount: items.length };
}
async function main() {
if (!MANIFEST) { console.error('need --manifest=path'); process.exit(1); }
const items = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
const createdSet = new Set(); // already fully created+activated -> skip entirely
const pendingInv = new Map(); // created as draft, needs inventory only -> finish go-live when scope present
if (fs.existsSync(AUDIT)) for (const l of fs.readFileSync(AUDIT, 'utf8').trim().split('\n').filter(Boolean)) {
try { const r = JSON.parse(l); if (r.action === 'CREATED') createdSet.add(r.dw); else if (r.action === 'CREATED_DRAFT_PENDING_INVENTORY') pendingInv.set(r.dw, r.pid); } catch {}
}
// FINISH-PENDING: if scope is now present, complete go-live for products created draft-only earlier.
if (APPLY && pendingInv.size) {
console.log(`finish-pending: ${pendingInv.size} draft products awaiting inventory/activation`);
const outP = fs.createWriteStream(AUDIT, { flags: 'a' });
let fixed = 0;
for (const [dw, pid] of pendingInv) {
if (createdSet.has(dw)) continue;
try { const gl = await goLive(pid); if (gl.status === 'ACTIVE') { outP.write(JSON.stringify({ dw, pid, action: 'CREATED', status: gl.status, resumedFromDraft: true, varCount: gl.varCount }) + '\n'); createdSet.add(dw); fixed++; console.log(` ✓ finished ${dw} -> ${pid} ${gl.status}`); } }
catch (e) { console.error(` still-blocked ${dw}: ${e.message.slice(0, 80)}`); }
await sleep(500);
}
outP.end();
console.log(`finish-pending: activated ${fixed}/${pendingInv.size}`);
if (fixed === 0 && pendingInv.size) console.log(' (scope still missing — write_inventory not yet granted)');
}
const remaining = items.filter(it => !createdSet.has(it.dw_sku));
const todo = LIMIT > 0 ? remaining.slice(0, LIMIT) : remaining;
if (createdSet.size) console.log(`resume: ${createdSet.size} created, ${remaining.length} remain`);
const out = APPLY ? fs.createWriteStream(AUDIT, { flags: 'a' }) : null;
const restoreOut = APPLY ? fs.createWriteStream(RESTORE, { flags: 'a' }) : null;
const ledgerOut = APPLY ? fs.createWriteStream(LEDGER, { flags: 'a' }) : null;
console.log(`create_sdg: ${todo.length} items · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
let created = 0, err = 0, held = 0;
for (const it of todo) {
const g = gate(it);
if (g.length) { console.error(` SKIP-HELD ${it.dw_sku}: ${g.join(',')}`); held++; if (out) out.write(JSON.stringify({ dw: it.dw_sku, action: 'HELD', reasons: g }) + '\n'); continue; }
const pl = payload(it);
if (!APPLY) {
console.log(`\n${it.dw_sku} "${pl.product.title}"`);
console.log(` variants: [${pl.product.variants.map(v => v.option1 + ' ' + v.sku + ' $' + v.price).join(' | ')}] img:${pl.product.images[0]?.src ? 'yes' : 'NONE'} tags:${pl.product.tags.split(',').length}`);
continue;
}
try {
const r = await fetch(`${REST}/products.json`, { method: 'POST', headers: H, body: JSON.stringify(pl) });
const txt = await r.text();
if (r.status === 429 && /daily variant creation limit/i.test(txt)) {
console.error(` ⛔ daily variant cap at ${created} — stopping (resume next run).`);
out.write(JSON.stringify({ action: 'CAP_ABORT', created }) + '\n'); out.end();
process.exit(3);
}
let j; try { j = JSON.parse(txt); } catch { j = {}; }
if (!r.ok || !j.product) { console.error(` ERR ${it.dw_sku}: ${r.status} ${txt.slice(0,120)}`); out.write(JSON.stringify({ dw: it.dw_sku, action: 'ERR_CREATE', status: r.status }) + '\n'); err++; await sleep(700); continue; }
const pid = j.product.id;
const variants = j.product.variants.map(v => ({ sku: v.sku, price: v.price, opt: v.option1, iid: v.inventory_item_id }));
const sampleOk = variants.some(v => /sample/i.test(v.sku) && v.price === '4.25');
let gl;
try { gl = await goLive(pid); }
catch (glErr) {
// scope-missing (write_inventory): product exists as a valid DRAFT; record pending so a later
// run finishes go-live once the scope is granted. NEVER leave it ACTIVE without inventory.
if (/write_inventory|merchant approval/i.test(glErr.message)) {
out.write(JSON.stringify({ dw: it.dw_sku, pid, vendor: it.vendor, action: 'CREATED_DRAFT_PENDING_INVENTORY', status: 'DRAFT', reason: 'write_inventory scope missing' }) + '\n');
console.error(` ⏸ ${it.dw_sku} → ${pid} created DRAFT, awaiting write_inventory scope`);
err++; await sleep(300); continue;
}
throw glErr;
}
// TK-11471: goLive() can now HOLD (weight>0 gate). A hold is NOT a success — without this
// branch the hold fell through and was recorded action:'CREATED' with status:undefined and
// logged as a ✓, counting a held product as created. That is the same defect class the
// weight gate exists to prevent, one level up.
if (gl.gated) {
out.write(JSON.stringify({ dw: it.dw_sku, mfr: it.mfr_sku, vendor: it.vendor, pid,
action: 'HELD_DRAFT', status: 'DRAFT', gate: gl.gated, reasons: gl.reasons, variants, sampleOk }) + '\n');
console.error(` ⏸ HOLD ${it.dw_sku} → ${pid} left DRAFT (${gl.gated}): ${(gl.reasons || []).join(' | ')}`);
held++; await sleep(300); continue;
}
out.write(JSON.stringify({ dw: it.dw_sku, mfr: it.mfr_sku, vendor: it.vendor, pid, action: 'CREATED', status: gl.status, variants, sampleOk, varCount: gl.varCount }) + '\n');
restoreOut.write(JSON.stringify({ dw: it.dw_sku, pid, variant_ids: variants.map(v => v.sku), vendor: it.vendor, created_at: new Date().toISOString() }) + '\n');
ledgerOut.write(JSON.stringify({ ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10881', action: `activate SDG product ${it.dw_sku} (${it.vendor})`, blast_radius: 1, undo_cmd: `node scripts/rollback_sdg.mjs --pid=${pid}`, verify: `product ${pid} status=${gl.status} sampleOk=${sampleOk}` }) + '\n');
console.log(` ✓ ${it.dw_sku} → ${pid} ${gl.status} vars=${variants.length} sampleOk=${sampleOk}`);
created++; await sleep(500);
} catch (e) { console.error(` ERR ${it.dw_sku}: ${e.message.slice(0,120)}`); err++; await sleep(700); }
}
if (out) { out.end(); restoreOut.end(); ledgerOut.end(); }
console.log(`\nDONE: created=${created} held=${held} err=${err} of ${todo.length}` + (APPLY ? ` · audit ${AUDIT}` : ' · DRY-RUN'));
}
main();