← back to Dw Unbuyable Recovery Pilot
tk11041-innovations-reconcile/rescrape/golive-shopify.mjs
170 lines
#!/usr/bin/env node
/**
* TK-11041 — Innovations (private-label Phillipe Romano) go-live on Shopify.
* Make the 39 live-but-unbuyable sample-only products BUYABLE by adding a priced
* sellable variant (per-yard, or per-ROLL for Skylark), preserving the $4.25 sample,
* then ensure each is published to Online Store + Google & YouTube.
*
* Mechanism is the LIVE-PROVEN one from scripts/justin-david-pricing/build-jd-yard-variants.mjs
* (2026-06-17): productVariantsBulkCreate + strategy:REMOVE_STANDALONE_VARIANT, RE-ASSERTING the
* sample AND the sellable variant in the SAME call so the $4.25 sample is preserved, plus a
* self-heal verify (re-query, confirm sample AND sellable survived) before marking done.
*
* DEFAULT = DRY-RUN. Requires --execute AND --i-am-steve to write. (Steve APPROVED go-live 2026-09-15.)
*
* node golive-shopify.mjs # dry-run all 39
* node golive-shopify.mjs --execute --i-am-steve --limit=1 # LIVE canary (1 product)
* node golive-shopify.mjs --execute --i-am-steve # LIVE all 39
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v === undefined ? true : v]; }));
const EXECUTE = args.execute === true && args['i-am-steve'] === true;
const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
const ONLY_PID = args.pid ? String(args.pid) : null;
const TABLE = JSON.parse(fs.readFileSync(path.join(HERE, 'data/tk11041-authoritative-table.json'), 'utf8'));
const TOKEN = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
const SHOP_URL = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const PUB_ONLINE = 'gid://shopify/Publication/22208643184'; // Online Store
const PUB_GOOGLE = 'gid://shopify/Publication/29646651457'; // Google & YouTube
const UNDO = path.join(HERE, 'undo');
const ROLLBACK = path.join(UNDO, 'tk11041-golive-shopify-rollback.jsonl');
const SNAPDIR = path.join(UNDO, 'shopify-prestate');
fs.mkdirSync(SNAPDIR, { recursive: true });
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gql(q, v) {
for (let i = 0; i < 8; i++) {
let r; const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 25000);
try { r = await fetch(SHOP_URL, { method: 'POST', signal: ac.signal, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); }
catch (e) { clearTimeout(to); await sleep(1500 * (i + 1)); continue; }
clearTimeout(to);
if (r.status === 429 || r.status >= 500) { await sleep(2000 * (i + 1)); continue; }
let j; try { j = await r.json(); } catch (e) { await sleep(2000 * (i + 1)); continue; }
if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (i + 1)); continue; }
if (j.errors) return { __err: j.errors };
const t = j.extensions?.cost?.throttleStatus; if (t && t.currentlyAvailable < 400) await sleep(1200);
return j.data;
}
throw new Error('exhausted retries');
}
const LOOKUP = `query($id:ID!){ node(id:$id){ ... on Product {
id title status handle vendor
featuredImage{url}
widthMeta: metafield(namespace:"custom", key:"width"){value}
options{ id name values }
variants(first:25){ nodes{ id sku title price inventoryPolicy inventoryItem{ id tracked measurement{weight{value unit}} } selectedOptions{name value} } }
resourcePublicationsV2(first:30){ nodes{ publication{ id name } isPublished } }
} } }`;
const CREATE = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkCreate(productId:$pid,variants:$variants,strategy:REMOVE_STANDALONE_VARIANT){
productVariants{ id sku title price inventoryPolicy } userErrors{ field message } } }`;
const PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){
publishablePublish(id:$id, input:$input){ userErrors{ field message } } }`;
const DELETE = `mutation($pid:ID!,$ids:[ID!]!){
productVariantsBulkDelete(productId:$pid,variantsIds:$ids){ userErrors{ field message } } }`;
function buildVariants({ optName, sampleSku, sellSku, sellValue, sellPrice, sellWeight }) {
return [
{ price: '4.25', optionValues: [{ optionName: optName, name: 'Sample' }], inventoryPolicy: 'CONTINUE', inventoryItem: { sku: sampleSku, tracked: false, measurement: { weight: { value: 0.25, unit: 'POUNDS' } } } },
{ price: String(sellPrice), optionValues: [{ optionName: optName, name: sellValue }], inventoryPolicy: 'CONTINUE', inventoryItem: { sku: sellSku, tracked: false, measurement: { weight: { value: sellWeight, unit: 'POUNDS' } } } },
];
}
(async () => {
const rows = TABLE.rows
.map(r => ({ ...r, pidNum: String(r.shopify_product_id).replace('gid://shopify/Product/', '') }))
.filter(r => !ONLY_PID || r.pidNum === ONLY_PID)
.slice(0, LIMIT === Infinity ? undefined : LIMIT);
console.log(`TK-11041 golive-shopify — ${EXECUTE ? '⚠️ LIVE writes' : 'DRY-RUN (no writes)'} — ${rows.length} product(s)`);
const rb = EXECUTE ? fs.createWriteStream(ROLLBACK, { flags: 'a' }) : null;
let ok = 0, skipped = 0, errored = 0, healFail = 0;
for (const r of rows) {
const gid = `gid://shopify/Product/${r.pidNum}`;
const lk = await gql(LOOKUP, { id: gid });
const p = lk && !lk.__err && lk.node;
if (!p) { skipped++; console.log(`SKIP ${r.mfr_sku}: product-not-found ${JSON.stringify(lk?.__err||'').slice(0,120)}`); continue; }
// snapshot BEFORE any write (memo requirement)
fs.writeFileSync(path.join(SNAPDIR, `${r.mfr_sku}.json`), JSON.stringify({ mfr_sku: r.mfr_sku, snapshot_at: new Date().toISOString(), product: p }, null, 2));
const vs = p.variants.nodes;
// gate: DW rule — never make buyable/ACTIVE without image + width metafield
const hasImg = !!p.featuredImage?.url;
const hasWidth = !!(p.widthMeta && p.widthMeta.value);
if (!hasImg || !hasWidth) { skipped++; console.log(`SKIP ${r.mfr_sku}: missing ${!hasImg?'image ':''}${!hasWidth?'width-metafield':''} — held (Needs-Image/Needs-Width)`); continue; }
// idempotency: already has a non-sample sellable variant?
if (vs.some(v => !(v.sku||'').endsWith('-Sample') && parseFloat(v.price) > 4.30)) {
skipped++; console.log(`SKIP ${r.mfr_sku}: already buyable (non-sample variant present)`); continue;
}
// must be sample-only (1 variant, sample-priced, -Sample sku)
if (!(vs.length === 1 && parseFloat(vs[0].price) <= 4.30)) {
skipped++; console.log(`SKIP ${r.mfr_sku}: not sample-only (nvar=${vs.length} price0=${vs[0]?.price})`); continue;
}
const sampleSku = vs[0].sku || '';
if (!/-Sample$/i.test(sampleSku)) { skipped++; console.log(`SKIP ${r.mfr_sku}: sample sku not -Sample (${sampleSku})`); continue; }
const sellSku = sampleSku.replace(/-Sample$/i, '');
// Reuse the product's EXISTING option name (the live-proven JD path). A NEW option name
// ("Size") fails productVariantsBulkCreate with "Option does not exist".
const optName = (p.options[0] && p.options[0].name) || 'Title';
const isRoll = r.price_unit === 'roll';
const sellValue = isRoll ? 'Sold Per Roll' : 'Sold Per Yard';
const sellWeight = isRoll ? 20.0 : 2.0;
const sellPrice = r.our_price;
const variants = buildVariants({ optName, sampleSku, sellSku, sellValue, sellPrice, sellWeight });
const alreadyPubOnline = p.resourcePublicationsV2.nodes.some(n => n.publication.id === PUB_ONLINE && n.isPublished);
const alreadyPubGoogle = p.resourcePublicationsV2.nodes.some(n => n.publication.id === PUB_GOOGLE && n.isPublished);
console.log(`${EXECUTE?'DO ':'PLAN'} ${r.mfr_sku} ${p.status} "${p.title.slice(0,40)}" -> add ${sellSku} @ $${sellPrice} (${sellValue}, ${sellWeight}lb) + Sample ${sampleSku}; pub online=${alreadyPubOnline?'yes':'NEEDS'} google=${alreadyPubGoogle?'yes':'NEEDS'}`);
if (!EXECUTE) { ok++; continue; }
const cr = await gql(CREATE, { pid: gid, variants });
if (cr?.__err) { errored++; console.log(` ERR create ${r.mfr_sku}: ${JSON.stringify(cr.__err).slice(0,200)}`); continue; }
const ue = cr.productVariantsBulkCreate?.userErrors || [];
if (ue.length) { errored++; console.log(` UERR ${r.mfr_sku}: ${JSON.stringify(ue)}`); continue; }
rb.write(JSON.stringify({ mfr_sku: r.mfr_sku, pid: r.pidNum, old_sample_variant_id: vs[0].id, sell_sku: sellSku, created: (cr.productVariantsBulkCreate.productVariants||[]).map(v=>({id:v.id,sku:v.sku,price:v.price})) }) + '\n');
// publish to Online Store + Google & YouTube (idempotent; publishing already-published is a no-op)
const pubInput = [{ publicationId: PUB_ONLINE }, { publicationId: PUB_GOOGLE }];
const pub = await gql(PUBLISH, { id: gid, input: pubInput });
const pue = pub?.__err || pub?.publishablePublish?.userErrors;
if (pub?.__err || (pub?.publishablePublish?.userErrors?.length)) console.log(` PUB-WARN ${r.mfr_sku}: ${JSON.stringify(pue).slice(0,160)}`);
// self-heal verify
const re = await gql(LOOKUP, { id: gid });
const rv = (re && !re.__err && re.node?.variants?.nodes) || [];
const sampleOk = rv.some(v => (v.sku||'').endsWith('-Sample') && parseFloat(v.price) <= 4.30);
const sellOk = rv.some(v => (v.sku||'') === sellSku && Math.abs(parseFloat(v.price) - sellPrice) < 0.005 && v.inventoryPolicy === 'CONTINUE');
const rpub = (re?.node?.resourcePublicationsV2?.nodes)||[];
const pubOnlineOk = rpub.some(n => n.publication.id === PUB_ONLINE && n.isPublished);
const pubGoogleOk = rpub.some(n => n.publication.id === PUB_GOOGLE && n.isPublished);
if (!sampleOk || !sellOk) {
healFail++;
console.log(` ⛔ VERIFY FAIL ${r.mfr_sku}: sampleOk=${sampleOk} sellOk=${sellOk} — AUTO-ROLLBACK (delete sellable) + ABORT.`);
const sellIds = rv.filter(v => (v.sku||'') === sellSku).map(v => v.id);
if (sellIds.length) { const del = await gql(DELETE, { pid: gid, ids: sellIds }); console.log(` ↩︎ rolled back: ${JSON.stringify(del?.productVariantsBulkDelete?.userErrors||'ok')}`); }
break;
}
ok++;
console.log(` ✓ ${r.mfr_sku} buyable (sample✓ sell✓ pubOnline=${pubOnlineOk} pubGoogle=${pubGoogleOk})`);
await sleep(200);
}
if (rb) rb.end();
console.log(`\nDONE — ${ok} ${EXECUTE?'live':'planned'}, ${skipped} skipped, ${errored} errors, ${healFail} verify-fails`);
if (healFail) process.exit(4);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });