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