← back to Dw Contact Us Pages
scripts: assign-template, harden-variants (tracked-first), unpublish-channels — all dry-run default with --rollback
63acba0f305efebbad12e62c42b5c01e1a4821eb · 2026-09-19 10:11:07 -0700 · Claude (TK-11925)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163KBzeE1R39RSbxNmAjbki
Files touched
A scripts/assign-template.mjsA scripts/harden-variants.mjsA scripts/unpublish-channels.mjs
Diff
commit 63acba0f305efebbad12e62c42b5c01e1a4821eb
Author: Claude (TK-11925) <steve@designerwallcoverings.com>
Date: Sat Sep 19 10:11:07 2026 -0700
scripts: assign-template, harden-variants (tracked-first), unpublish-channels — all dry-run default with --rollback
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163KBzeE1R39RSbxNmAjbki
---
scripts/assign-template.mjs | 59 +++++++++++++++
scripts/harden-variants.mjs | 160 +++++++++++++++++++++++++++++++++++++++++
scripts/unpublish-channels.mjs | 74 +++++++++++++++++++
3 files changed, 293 insertions(+)
diff --git a/scripts/assign-template.mjs b/scripts/assign-template.mjs
new file mode 100644
index 0000000..74fd17f
--- /dev/null
+++ b/scripts/assign-template.mjs
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+// assign-template.mjs — set templateSuffix="contact-us" on every target product. TK-11925.
+// node scripts/assign-template.mjs # dry-run
+// node scripts/assign-template.mjs --apply
+// node scripts/assign-template.mjs --rollback --apply
+// Preimage (old templateSuffix, exactly as read) -> data/ledger-template.jsonl
+import { join } from 'node:path';
+import { ROOT, parseArgs, banner, gql, loadTargets, appendJsonl, readJsonl, logReversible } from './lib.mjs';
+
+const a = parseArgs();
+const SUFFIX = 'contact-us';
+const LEDGER = join(ROOT, 'data', 'ledger-template.jsonl');
+banner(a.rollback ? 'assign-template --rollback' : 'assign-template', a.apply);
+
+const M = `
+mutation SetSuffix($input: ProductInput!) {
+ productUpdate(input: $input) { product { id templateSuffix } userErrors { field message } }
+}`;
+
+let work;
+if (a.rollback) {
+ const rows = readJsonl(LEDGER).filter((r) => r.applied);
+ const latest = new Map();
+ for (const r of rows) latest.set(r.productId, r);
+ // Shopify has no "null" for templateSuffix on write: '' clears it, which is how a
+ // product with templateSuffix null reads afterwards. Recorded verbatim either way.
+ work = [...latest.values()].map((r) => ({ id: r.productId, handle: r.handle, to: r.before ?? '' }));
+} else {
+ work = loadTargets()
+ .filter((p) => p.templateSuffix !== SUFFIX)
+ .map((p) => ({ id: p.id, handle: p.handle, to: SUFFIX, before: p.templateSuffix }));
+}
+
+console.log(`plan: ${work.length} productUpdate calls -> templateSuffix ${a.rollback ? '(restored)' : `"${SUFFIX}"`}`);
+if (work.length) {
+ for (const w of work.slice(0, 3)) console.log(` e.g. ${w.handle}: ${JSON.stringify(w.before ?? '')} -> ${JSON.stringify(w.to)}`);
+ if (work.length > 3) console.log(` … +${work.length - 3} more`);
+}
+if (!a.apply) { console.log('\nDRY-RUN: nothing was written.'); process.exit(0); }
+
+let ok = 0, fail = 0;
+for (const w of work) {
+ const d = await gql(M, { input: { id: w.id, templateSuffix: w.to } });
+ const errs = d.productUpdate?.userErrors || [];
+ if (errs.length) { fail++; console.error(` FAIL ${w.handle}: ${JSON.stringify(errs)}`); }
+ else {
+ ok++;
+ if (!a.rollback) appendJsonl(LEDGER, { ts: new Date().toISOString(), productId: w.id, handle: w.handle, before: w.before, after: w.to, applied: true });
+ if (ok % 50 === 0) process.stderr.write(`\r ${ok}/${work.length} `);
+ }
+}
+console.log(`\ndone: ${ok} ok, ${fail} failed`);
+if (ok && !a.rollback) logReversible({
+ action: `TK-11925 templateSuffix -> contact-us on ${ok} products (Designers Guild / Ralph Lauren / Christian Lacroix Europe)`,
+ blast: ok,
+ undo: 'cd ~/Projects/dw-contact-us-pages && node scripts/assign-template.mjs --rollback --apply',
+ verify: 'cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs',
+});
+process.exit(fail ? 1 : 0);
diff --git a/scripts/harden-variants.mjs b/scripts/harden-variants.mjs
new file mode 100644
index 0000000..f2456be
--- /dev/null
+++ b/scripts/harden-variants.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+// harden-variants.mjs — hide + HARDEN the sellable variants of the contact-us cohort. TK-11925.
+// node scripts/harden-variants.mjs # dry-run
+// node scripts/harden-variants.mjs --apply
+// node scripts/harden-variants.mjs --rollback --apply
+//
+// For every NON-SAMPLE variant (the $4.25 Sample swatch is NEVER touched):
+// 1. inventoryItemUpdate tracked:true — REQUIRED first: 598 of the 737 sellable
+// variants have tracked=false, and inventorySetQuantities fails on an untracked
+// item. An untracked variant is also ALWAYS available regardless of policy, so
+// DENY+0 only bites once tracking is on.
+// 2. inventorySetQuantities on_hand = 0 at every location the item stocks (measured:
+// exactly one location, gid://shopify/Location/5795643504).
+// 3. productVariantsBulkUpdate inventoryPolicy = DENY.
+//
+// PREIMAGE per variant -> data/ledger-variants.jsonl: {inventoryPolicy, tracked,
+// levels:[{locationId, on_hand, available}]}. --rollback replays it in reverse order
+// (policy -> quantities -> tracked:false last) so tracked=false is restored for the 598.
+import { join } from 'node:path';
+import { ROOT, parseArgs, banner, gql, loadTargets, appendJsonl, readJsonl, logReversible, chunk, isSampleVariant } from './lib.mjs';
+
+const a = parseArgs();
+const LEDGER = join(ROOT, 'data', 'ledger-variants.jsonl');
+banner(a.rollback ? 'harden-variants --rollback' : 'harden-variants', a.apply);
+
+const M_TRACK = `mutation T($id: ID!, $input: InventoryItemInput!) {
+ inventoryItemUpdate(id: $id, input: $input) { inventoryItem { id tracked } userErrors { field message } } }`;
+const M_QTY = `mutation Q($input: InventorySetQuantitiesInput!) {
+ inventorySetQuantities(input: $input) { inventoryAdjustmentGroup { createdAt } userErrors { field message } } }`;
+const M_POLICY = `mutation P($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
+ productVariantsBulkUpdate(productId: $productId, variants: $variants) {
+ productVariants { id inventoryPolicy } userErrors { field message } } }`;
+
+function qty(level, name) {
+ return (level.quantities || []).find((q) => q.name === name)?.quantity ?? 0;
+}
+
+// ---------------------------------------------------------------- build plan
+let plan;
+if (a.rollback) {
+ const rows = readJsonl(LEDGER).filter((r) => r.applied);
+ const latest = new Map();
+ for (const r of rows) if (!latest.has(r.variantId)) latest.set(r.variantId, r); // FIRST write = true preimage
+ plan = [...latest.values()].map((r) => ({
+ productId: r.productId, handle: r.handle, variantId: r.variantId, sku: r.sku, title: r.title,
+ inventoryItemId: r.inventoryItemId,
+ targetPolicy: r.before.inventoryPolicy,
+ targetTracked: r.before.tracked,
+ targetLevels: r.before.levels,
+ before: null,
+ }));
+} else {
+ plan = [];
+ for (const p of loadTargets()) {
+ for (const v of p.variants) {
+ if (v.isSample ?? isSampleVariant(v)) continue; // never touch the swatch
+ plan.push({
+ productId: p.id, handle: p.handle, variantId: v.id, sku: v.sku, title: v.title, price: v.price,
+ inventoryItemId: v.inventoryItem.id,
+ targetPolicy: 'DENY', targetTracked: true,
+ targetLevels: (v.inventoryItem.inventoryLevels || []).map((l) => ({ locationId: l.location.id, on_hand: 0 })),
+ before: {
+ inventoryPolicy: v.inventoryPolicy,
+ tracked: v.inventoryItem.tracked,
+ levels: (v.inventoryItem.inventoryLevels || []).map((l) => ({
+ locationId: l.location.id, on_hand: qty(l, 'on_hand'), available: qty(l, 'available'),
+ })),
+ },
+ });
+ }
+ }
+}
+
+const needTrack = plan.filter((x) => a.rollback ? x.targetTracked === false : true).length;
+const trackFlips = a.rollback
+ ? plan.filter((x) => x.targetTracked === false).length
+ : plan.filter((x) => x.before.tracked === false).length;
+const policyFlips = a.rollback
+ ? plan.filter((x) => x.targetPolicy !== 'DENY').length
+ : plan.filter((x) => x.before.inventoryPolicy !== 'DENY').length;
+const qtyOps = plan.reduce((n, x) => n + x.targetLevels.length, 0);
+const sampleSkipped = a.rollback ? 'n/a' : loadTargets().reduce((n, p) => n + p.variants.filter((v) => v.isSample ?? isSampleVariant(v)).length, 0);
+const productsTouched = new Set(plan.map((x) => x.productId)).size;
+
+console.log(`plan (${a.rollback ? 'ROLLBACK' : 'HARDEN'}):`);
+console.log(` non-sample variants : ${plan.length} across ${productsTouched} products`);
+console.log(` inventoryItemUpdate tracked: ${trackFlips} flips -> ${a.rollback ? 'false (restore)' : 'true'}`);
+console.log(` inventorySetQuantities : ${qtyOps} location rows -> ${a.rollback ? 'restored on_hand' : 'on_hand 0'}`);
+console.log(` inventoryPolicy : ${policyFlips} flips -> ${a.rollback ? 'restored' : 'DENY'}`);
+console.log(` SAMPLE variants untouched : ${sampleSkipped}`);
+for (const x of plan.slice(0, 3)) {
+ console.log(` e.g. ${x.handle} / "${x.title}" ${x.sku || '(no sku)'} — tracked ${a.rollback ? '->' + x.targetTracked : (x.before.tracked + ' -> true')}, on_hand ${a.rollback ? JSON.stringify(x.targetLevels) : JSON.stringify(x.before.levels.map((l) => l.on_hand)) + ' -> 0'}`);
+}
+if (!a.apply) { console.log('\nDRY-RUN: nothing was written.'); process.exit(0); }
+
+// ---------------------------------------------------------------- apply
+let ok = 0, fail = 0;
+const failed = [];
+function note(msg) { fail++; failed.push(msg); console.error(' FAIL ' + msg); }
+
+// Order matters. Forward: tracked -> qty -> policy. Rollback: policy -> qty -> tracked(false last).
+const steps = a.rollback ? ['policy', 'qty', 'tracked'] : ['tracked', 'qty', 'policy'];
+
+for (const step of steps) {
+ if (step === 'tracked') {
+ const items = plan.filter((x) => a.rollback ? x.targetTracked === false : x.before.tracked !== true);
+ for (const x of items) {
+ const d = await gql(M_TRACK, { id: x.inventoryItemId, input: { tracked: a.rollback ? x.targetTracked : true } });
+ const e = d.inventoryItemUpdate?.userErrors || [];
+ e.length ? note(`tracked ${x.sku}: ${JSON.stringify(e)}`) : ok++;
+ }
+ console.log(` tracked step: ${items.length} calls`);
+ }
+ if (step === 'qty') {
+ const rows = [];
+ for (const x of plan) {
+ for (const l of x.targetLevels) {
+ rows.push({ inventoryItemId: x.inventoryItemId, locationId: l.locationId, quantity: a.rollback ? l.on_hand : 0 });
+ }
+ }
+ for (const b of chunk(rows, 200)) {
+ const d = await gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: b } });
+ const e = d.inventorySetQuantities?.userErrors || [];
+ e.length ? note(`qty batch(${b.length}): ${JSON.stringify(e).slice(0, 300)}`) : (ok += b.length);
+ }
+ console.log(` qty step: ${rows.length} rows in ${Math.ceil(rows.length / 200)} batches`);
+ }
+ if (step === 'policy') {
+ const byProduct = new Map();
+ for (const x of plan) {
+ if (!byProduct.has(x.productId)) byProduct.set(x.productId, []);
+ byProduct.get(x.productId).push({ id: x.variantId, inventoryPolicy: a.rollback ? x.targetPolicy : 'DENY' });
+ }
+ for (const [pid, variants] of byProduct) {
+ const d = await gql(M_POLICY, { productId: pid, variants });
+ const e = d.productVariantsBulkUpdate?.userErrors || [];
+ e.length ? note(`policy ${pid}: ${JSON.stringify(e).slice(0, 200)}`) : (ok += variants.length);
+ }
+ console.log(` policy step: ${byProduct.size} productVariantsBulkUpdate calls`);
+ }
+}
+
+if (!a.rollback) {
+ for (const x of plan) {
+ appendJsonl(LEDGER, {
+ ts: new Date().toISOString(), productId: x.productId, handle: x.handle, variantId: x.variantId,
+ sku: x.sku, title: x.title, inventoryItemId: x.inventoryItemId, before: x.before,
+ after: { inventoryPolicy: 'DENY', tracked: true, levels: x.targetLevels }, applied: true,
+ });
+ }
+}
+
+console.log(`\ndone: ${ok} successful ops, ${fail} failures`);
+if (ok && !a.rollback) logReversible({
+ action: `TK-11925 harden ${plan.length} non-sample variants (tracked:true + on_hand 0 + policy DENY) across ${productsTouched} products`,
+ blast: plan.length,
+ undo: 'cd ~/Projects/dw-contact-us-pages && node scripts/harden-variants.mjs --rollback --apply',
+ verify: 'cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs',
+});
+process.exit(fail ? 1 : 0);
diff --git a/scripts/unpublish-channels.mjs b/scripts/unpublish-channels.mjs
new file mode 100644
index 0000000..24c636f
--- /dev/null
+++ b/scripts/unpublish-channels.mjs
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+// unpublish-channels.mjs — remove the cohort from 3 sales channels ONLY. TK-11925.
+// node scripts/unpublish-channels.mjs # dry-run
+// node scripts/unpublish-channels.mjs --apply
+// node scripts/unpublish-channels.mjs --rollback --apply
+//
+// Channels (Steve-specified, nothing else):
+// Google & YouTube gid://shopify/Publication/29646651457
+// Shop gid://shopify/Publication/44317507635
+// Buy Button gid://shopify/Publication/22497296496
+// Online Store stays PUBLISHED — the PDP must remain reachable; it is the contact-us page.
+import { join } from 'node:path';
+import { ROOT, parseArgs, banner, gql, loadTargets, appendJsonl, readJsonl, logReversible, TARGET_PUBLICATIONS } from './lib.mjs';
+
+const a = parseArgs();
+const LEDGER = join(ROOT, 'data', 'ledger-channels.jsonl');
+banner(a.rollback ? 'unpublish-channels --rollback' : 'unpublish-channels', a.apply);
+
+const M_UNPUB = `mutation U($id: ID!, $input: [PublicationInput!]!) {
+ publishableUnpublish(id: $id, input: $input) { publishable { availablePublicationsCount { count } } userErrors { field message } } }`;
+const M_PUB = `mutation P($id: ID!, $input: [PublicationInput!]!) {
+ publishablePublish(id: $id, input: $input) { publishable { availablePublicationsCount { count } } userErrors { field message } } }`;
+
+const byId = new Map(TARGET_PUBLICATIONS.map((p) => [p.id, p.name]));
+let work;
+if (a.rollback) {
+ const rows = readJsonl(LEDGER).filter((r) => r.applied);
+ const m = new Map();
+ for (const r of rows) if (!m.has(r.productId)) m.set(r.productId, r); // first record = true preimage
+ work = [...m.values()]
+ .map((r) => ({ id: r.productId, handle: r.handle, pubs: r.before.filter((b) => b.isPublished).map((b) => b.publicationId) }))
+ .filter((w) => w.pubs.length);
+} else {
+ work = [];
+ for (const p of loadTargets()) {
+ const pubs = p.resourcePublicationsV2.filter((rp) => byId.has(rp.publication.id) && rp.isPublished);
+ if (!pubs.length) continue;
+ work.push({
+ id: p.id, handle: p.handle, pubs: pubs.map((rp) => rp.publication.id),
+ before: p.resourcePublicationsV2.filter((rp) => byId.has(rp.publication.id))
+ .map((rp) => ({ publicationId: rp.publication.id, name: rp.publication.name, isPublished: rp.isPublished })),
+ });
+ }
+}
+
+const ops = work.reduce((n, w) => n + w.pubs.length, 0);
+const perChannel = {};
+for (const w of work) for (const id of w.pubs) perChannel[byId.get(id) || id] = (perChannel[byId.get(id) || id] || 0) + 1;
+console.log(`plan (${a.rollback ? 'RE-PUBLISH' : 'UNPUBLISH'}): ${work.length} products, ${ops} publication operations`);
+console.log(' per channel: ' + JSON.stringify(perChannel));
+console.log(' Online Store is NOT in scope and stays published.');
+if (!a.apply) { console.log('\nDRY-RUN: nothing was written.'); process.exit(0); }
+
+let ok = 0, fail = 0;
+for (const w of work) {
+ const input = w.pubs.map((publicationId) => ({ publicationId }));
+ const d = await gql(a.rollback ? M_PUB : M_UNPUB, { id: w.id, input });
+ const node = a.rollback ? d.publishablePublish : d.publishableUnpublish;
+ const e = node?.userErrors || [];
+ if (e.length) { fail++; console.error(` FAIL ${w.handle}: ${JSON.stringify(e).slice(0, 200)}`); }
+ else {
+ ok++;
+ if (!a.rollback) appendJsonl(LEDGER, { ts: new Date().toISOString(), productId: w.id, handle: w.handle, before: w.before, unpublished: w.pubs, applied: true });
+ if (ok % 50 === 0) process.stderr.write(`\r ${ok}/${work.length} `);
+ }
+}
+console.log(`\ndone: ${ok} products ok, ${fail} failed`);
+if (ok && !a.rollback) logReversible({
+ action: `TK-11925 unpublish ${ok} products from Google & YouTube / Shop / Buy Button (${ops} publication ops)`,
+ blast: ops,
+ undo: 'cd ~/Projects/dw-contact-us-pages && node scripts/unpublish-channels.mjs --rollback --apply',
+ verify: 'cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs',
+});
+process.exit(fail ? 1 : 0);
← f5fc64f scripts: push/rollback theme with preimage capture; refuses
·
back to Dw Contact Us Pages
·
verify.mjs (PASS/FAIL/NOT-MEASURED) + README with order of o b61f5af →