← back to Hollywood Import

hollywood-create.mjs

206 lines

// Create genuinely-absent Hollywood products on Shopify — officer-gated, sample-SAFE by
// construction (both variants in ONE POST /products.json → no productVariantsBulkCreate, so
// the sample-overwrite bug cannot fire). Then go-live: track→activate→set 2026→publish 13
// channels→ACTIVE (artmura-proven GraphQL). DRY-RUN by default; --apply --limit=N to write.
// Private-label scrub: titles/tags carry NO Momentum/Versa/Innovations; image is the cloudinary
// transient src Shopify rehosts (never stored customer-facing). Audit JSONL, idempotent.
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_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
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');

// ── SKU SHAPE-GUARD (TK-11054, 2026-08-31) ────────────────────────────────
// Hollywood Wallcoverings is a DW private-label BRAND, not an external vendor.
// The customer-facing variant SKU + dw_sku MUST be a real ORIGINAL line code
// (XWH-52359, NOC-105, HWC-61000, XJP-####, …) — NEVER a fabricated DW-vendor
// code (DWHD-*, DWHW2-*, DWHV-*, DWYX-* …) and NEVER a bare Momentum supplier
// number (raw digits leak the private-label vendor). These three regexes are
// kept BYTE-FOR-BYTE identical to the canary's canonical detection at
// ~/.claude/skills/dw-hollywood-sku-canary/lib.mjs (FABRICATED, ORIG) + scan.mjs
// (isBareNumber) — the canary is the consumer; drift here would let a bad code
// slip past it. If a real original code ever falls outside ORIG, widen it in
// BOTH places in the same change.
const FABRICATED = /^DW[A-Z]{1,4}\d?-\d/i;               // any DW-prefixed base = fabricated for this brand
const ORIG = /^([A-Z]{2,5})-?(\d{2,})$/i;                // a valid original private-label line code
const BARE_NUMBER = /^\d{5,}$/;                           // raw Momentum number → private-label leak
// A dw_sku passes ONLY if it is a valid original code, not fabricated, not a bare number.
// Positive allow-list (not a deny-list): a brand-new DWxx- family can never slip through.
function skuShapeOk(dwSku) {
  const base = String(dwSku || '').replace(/-(sample|yard|roll)$/i, '').trim();
  return ORIG.test(base) && !FABRICATED.test(base) && !BARE_NUMBER.test(base);
}
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const AUDIT = new URL('hollywood-create-audit.jsonl', import.meta.url).pathname;
const MANIFEST = (process.argv.find(a => a.startsWith('--manifest=')) || '').split('=')[1] || '';

const LOCATION_ID = 'gid://shopify/Location/5795643504';     // 15442 Ventura Blvd
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));
// scrub: never expose the upstream maker in customer-facing text
const scrub = s => String(s || '').replace(/\b(momentum(\s+textiles( and walls)?)?|versa(’s)?(\s+designed\s+surfaces)?|innovations(\s+usa)?)\b/gi, '').replace(/\s{2,}/g, ' ').replace(/\s+\|/g, ' |').trim();
const unitWord = u => ({ SR: 'Roll', EA: 'Panel', BOX: 'Box', PANEL: 'Panel', SY: 'Sq Yard', YD: 'Yard' }[u] || 'Yard');

function payload(it) {
  const title = scrub(`${it.pattern}${it.color ? ' ' + it.color : ''} | Hollywood Wallcoverings`);
  const tags = scrub(it.tags).split(',').map(t => t.trim()).filter(Boolean);
  if (!tags.some(t => /hollywood/i.test(t))) tags.unshift('Hollywood Wallcoverings');
  const dw = it.dw_sku;
  const unit = unitWord(it.uom);
  const row = (label, val) => { const v = scrub(val); return v ? `<tr><td>${label}</td><td>${v}</td></tr>` : ''; };
  const body = (it.description ? `<p>${scrub(it.description)}</p>` : '') +
    `<table><tr><td>Width</td><td>${it.width || ''}"</td></tr>` +
    row('Weight', it.weight) +
    row('Content', it.content) +
    row('Finish', it.finish) +
    row('Repeat', it.repeat) +
    row('Fire Rating', it.fire_rating) +
    row('Cleaning', it.cleaning) +
    row('Panel Spec', it.spec) +
    `<tr><td>Sold Per</td><td>${unit}</td></tr></table>`;
  // spec metafields — same schema as the healthy line (global.* + custom/dwc identity keys);
  // 376 products shipped metafield-less before 2026-07-15 because this step didn't exist.
  const sl = 'single_line_text_field', ml = 'multi_line_text_field';
  const metafields = [];
  // single_line_text_field metafields reject newlines (Shopify 422 "must be a single line text
  // string") — collapse whitespace for sl values; ml (multi_line) keeps its newlines. TK-00109
  // canary caught this on fire_rating cert text (8/20 creates failed before this).
  const mf = (namespace, key, value, type = sl) => {
    let v = scrub(value);
    if (type === sl) v = v.replace(/\s+/g, ' ').trim();
    if (v) metafields.push({ namespace, key, type, value: v });
  };
  mf('global', 'width', it.width ? `${it.width}"` : '');
  if (/lb\b/i.test(String(it.weight || ''))) mf('global', 'v_prods_weight', (String(it.weight).match(/[\d.]+/) || [])[0]);
  mf('global', 'unit_of_measure', `Sold Per ${unit}`);
  mf('global', 'repeat', it.repeat);
  mf('global', 'fire_rating', it.fire_rating);
  mf('global', 'lead_time', 'Usually in Stock');
  mf('custom', 'pattern_name', it.pattern);
  mf('custom', 'supplier_name', 'Hollywood Wallcoverings');
  mf('custom', 'manufacturer_sku', it.mfr_sku);
  mf('custom', 'material', it.content, ml);
  // real_vendor = the TRUE upstream vendor (internal purchasing field, never theme-rendered).
  // Hollywood Wallcoverings is the BRAND, not the vendor — Steve 2026-07-15. Pushed directly
  // because scrub() would erase "Momentum".
  metafields.push({ namespace: 'dwc', key: 'real_vendor', type: sl, value: 'Momentum' });
  mf('dwc', 'manufacturer_sku', it.mfr_sku);
  return { product: {
    title, vendor: 'Hollywood Wallcoverings', product_type: it.product_type || 'Wallcovering', status: 'draft',
    tags: tags.join(', '), body_html: body, metafields, images: it.image ? [{ src: it.image }] : [],
    options: [{ name: 'Size' }],
    variants: [
      { sku: dw, price: String(parseFloat(it.hw).toFixed(2)), option1: `Sold Per ${unit}`, taxable: true, requires_shipping: true },
      { sku: `${dw}-Sample`, price: '4.25', option1: 'Sample', taxable: true, requires_shipping: true },
    ],
  } };
}

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 variants(first:10){edges{node{id sku inventoryItem{id}}}}}}`;
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 items = d.product.variants.edges.map(e => e.node.inventoryItem.id);
  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: items.map(iid => ({ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY })) } });
  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 };
}

const items = JSON.parse(fs.readFileSync(MANIFEST || new URL('the67_full.json', import.meta.url).pathname, 'utf8'));
// resume-skip: don't recreate items already CREATED in the audit (idempotent drip)
const createdSet = new Set();
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); } catch {}
}
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} already created, ${remaining.length} remain`);
const out = APPLY ? fs.createWriteStream(AUDIT, { flags: 'a' }) : null;
console.log(`hollywood-create: ${todo.length} items · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
let created = 0, err = 0;
for (const it of todo) {
  const pl = payload(it);
  // Leak-guard scans customer-facing text + metafields, but EXCLUDES the one field that
  // intentionally carries the upstream maker for internal purchasing (dwc.real_vendor='Momentum',
  // never theme-rendered — added 2026-07-15). Without this exclusion every item false-flags LEAK
  // and --apply skips 100% of creates (TK-00109 canary caught this). Genuine leaks elsewhere still trip.
  const leakText = pl.product.title + ' ' + pl.product.tags + ' ' + pl.product.body_html + ' ' +
    pl.product.metafields.filter(m => !(m.namespace === 'dwc' && m.key === 'real_vendor')).map(m => m.value).join(' ');
  const leak = /\b(momentum|versa|innovations|muratto)\b/i.test(leakText);
  const shapeOk = skuShapeOk(it.dw_sku);
  // $0-ORDERABLE GUARD (TK-11357 class): a missing/0 per-yard price it.hw becomes a $0.00 sellable
  // variant that goLive() then stamps on_hand=2026, publishes to 13 channels, and flips to ACTIVE —
  // a live customer-facing $0 orderable product. Refuse to create/publish it (checked before L172).
  const priceOk = Number.isFinite(Number(it.hw)) && Number(it.hw) > 0;
  if (!APPLY) {
    console.log(`\n${it.dw_sku}  "${pl.product.title}"  ${leak ? '⚠LEAK' : 'clean'}  ${shapeOk ? 'shape✓' : '⛔BAD-SKU-SHAPE (would SKIP)'}  ${priceOk ? 'price✓' : `⛔ZERO-PRICE hw=${it.hw} (would SKIP)`}`);
    console.log(`  variants: [${pl.product.variants.map(v => v.option1 + ' ' + v.sku + ' $' + v.price).join('  |  ')}]`);
    console.log(`  tags: ${pl.product.tags.slice(0, 90)} · img: ${pl.product.images[0]?.src ? 'yes' : 'NONE'}`);
    continue;
  }
  if (leak) { console.error(`  SKIP LEAK ${it.dw_sku}`); err++; continue; }
  // TK-11054 SKU shape-guard (supersedes the narrow TK-10679 /^(DWHW2?|DWHD)-/ reject).
  // Hollywood is a private-label BRAND, never a vendor. POSITIVE allow-list: write ONLY when the
  // dw_sku is a valid ORIGINAL line code (not a fabricated DWxx- code, not a bare Momentum number).
  // Fail-loud SKIP — never mint, never allocate a new DWHD/DWHW sequential. This is the ^DWHD
  // shape-guard the fleet kill-switch (~/.dw-fixer-stop) requires; the kill-switch stays too.
  if (!shapeOk) {
    console.error(`  SKIP BAD-SKU-SHAPE ${it.dw_sku} (not a valid original line code — TK-11054 guard; never mint a DW-vendor code)`);
    if (out) out.write(JSON.stringify({ dw: it.dw_sku, action: 'SKIP_BAD_SKU_SHAPE' }) + '\n');
    err++; continue;
  }
  // TK-11357 $0-ORDERABLE GUARD: never create+goLive a $0 sellable product. goLive() stamps
  // on_hand=2026, publishes 13 channels, and flips ACTIVE, so a blank/0 per-yard price would
  // ship a live $0 orderable product. Fail-loud SKIP before the POST /products.json below.
  if (!priceOk) {
    console.error(`  SKIP ZERO-PRICE ${it.dw_sku} (hw=${it.hw}) — would create + publish a $0 orderable ACTIVE product (TK-11357 guard)`);
    if (out) out.write(JSON.stringify({ dw: it.dw_sku, action: 'SKIP_ZERO_PRICE', hw: it.hw }) + '\n');
    err++; continue;
  }
  try {
    const r = await fetch(`${REST}/products.json`, { method: 'POST', headers: H, body: JSON.stringify(pl) });
    const txt = await r.text();
    // CAP-ABORT: Shopify daily variant-creation limit → stop cleanly, resume next drip (idempotent via audit).
    if (r.status === 429 && /daily variant creation limit/i.test(txt)) {
      console.error(`  ⛔ daily variant-creation cap reached at ${created} created — stopping (resume next run).`);
      out.write(JSON.stringify({ dw: it.dw_sku, action: 'CAP_ABORT', created }) + '\n'); out.end();
      console.log(`\nCAP-ABORT: created=${created} this run; ${todo.length - created - err} remain. Exit 3.`);
      process.exit(3);
    }
    let j; try { j = JSON.parse(txt); } catch { j = {}; }
    if (!r.ok || !j.product) { console.error(`  ERR create ${it.dw_sku}: ${r.status}`); out.write(JSON.stringify({ dw: it.dw_sku, action: 'ERR_CREATE', status: r.status, body: txt.slice(0,120) }) + '\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 }));
    const sampleOk = variants.some(v => /sample/i.test(v.sku) && v.price === '4.25');
    const gl = await goLive(pid);
    out.write(JSON.stringify({ dw: it.dw_sku, pid, action: 'CREATED', status: gl.status, variants, sampleOk, varCount: gl.varCount }) + '\n');
    console.log(`  ✓ ${it.dw_sku} → ${pid} ${gl.status} variants=${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();
console.log(`\nDONE: created=${created} err=${err} of ${todo.length}` + (APPLY ? ` · audit ${AUDIT}` : ' · DRY-RUN, no writes'));