[object Object]

← back to Dw Contact Us Pages

scripts: push/rollback theme with preimage capture; refuses role=main writes without --allow-main

f5fc64f9163b26921c84d1e71c274f058fdb729e · 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

Diff

commit f5fc64f9163b26921c84d1e71c274f058fdb729e
Author: Claude (TK-11925) <steve@designerwallcoverings.com>
Date:   Sat Sep 19 10:11:07 2026 -0700

    scripts: push/rollback theme with preimage capture; refuses role=main writes without --allow-main
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_0163KBzeE1R39RSbxNmAjbki
---
 scripts/push-theme.mjs     | 104 +++++++++++++++++++++++++++++++++++++++++++++
 scripts/rollback-theme.mjs |  47 ++++++++++++++++++++
 2 files changed, 151 insertions(+)

diff --git a/scripts/push-theme.mjs b/scripts/push-theme.mjs
new file mode 100644
index 0000000..c96994a
--- /dev/null
+++ b/scripts/push-theme.mjs
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+// push-theme.mjs — upload theme/** to a Shopify theme. TK-11925.
+//
+//   node scripts/push-theme.mjs --theme <id>                 # dry-run (GET only)
+//   node scripts/push-theme.mjs --theme <id> --apply         # writes, refuses role=main
+//   node scripts/push-theme.mjs --theme <id> --apply --allow-main
+//
+// HARD RAILS
+//   * default is DRY-RUN — no PUT without --apply
+//   * a theme whose role is `main` is REFUSED unless --allow-main is also passed
+//   * every asset's CURRENT value is GET-saved to data/theme-preimage/<themeId>/<key>
+//     BEFORE any PUT; keys that do not exist yet are recorded in manifest.json as
+//     created:true so rollback-theme.mjs deletes them instead of restoring.
+import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'node:fs';
+import { dirname, join, relative } from 'node:path';
+import { ROOT, parseArgs, banner, rest, logReversible, LIVE_MAIN_THEME_ID } from './lib.mjs';
+
+const a = parseArgs();
+const themeId = String(a.theme || '');
+if (!/^\d+$/.test(themeId)) {
+  console.error('usage: node scripts/push-theme.mjs --theme <id> [--apply] [--allow-main]');
+  process.exit(1);
+}
+banner('push-theme', a.apply);
+
+function walk(dir) {
+  const out = [];
+  for (const e of readdirSync(dir)) {
+    const p = join(dir, e);
+    if (statSync(p).isDirectory()) out.push(...walk(p));
+    else if (!e.startsWith('.')) out.push(p);
+  }
+  return out;
+}
+const files = walk(join(ROOT, 'theme')).sort();
+const keys = files.map((f) => relative(join(ROOT, 'theme'), f));
+
+// ---- role guard --------------------------------------------------------
+const t = await rest(`themes/${themeId}.json`);
+if (!t.ok) { console.error(`FATAL: cannot read theme ${themeId}: HTTP ${t.status}`); process.exit(1); }
+const role = t.json.theme.role, name = t.json.theme.name;
+console.log(`theme ${themeId} · "${name}" · role=${role}`);
+const isLive = role === 'main' || String(themeId) === String(LIVE_MAIN_THEME_ID);
+if (isLive && !a['allow-main']) {
+  if (a.apply) {
+    // The rail: a WRITE to the live storefront needs --allow-main. Refused before any PUT.
+    console.error(`\nREFUSED: theme ${themeId} is the LIVE storefront (role=${role}).`);
+    console.error('Re-run with --apply --allow-main only after Steve has approved the live push.');
+    process.exit(2);
+  }
+  // Dry-run is GET-only, so previewing the live diff is allowed — and is exactly what
+  // an operator needs before approving. Loudly flagged, still writes nothing.
+  console.warn(`\n\x1b[33mWARNING: ${themeId} is the LIVE main theme. Dry-run only (GET); --apply here would be REFUSED without --allow-main.\x1b[0m`);
+}
+
+// ---- preimage + diff ---------------------------------------------------
+const preDir = join(ROOT, 'data', 'theme-preimage', themeId);
+mkdirSync(preDir, { recursive: true });
+const manifest = [];
+let changed = 0, created = 0, identical = 0;
+
+for (const key of keys) {
+  const local = readFileSync(join(ROOT, 'theme', key), 'utf8');
+  const r = await rest(`themes/${themeId}/assets.json?asset[key]=${encodeURIComponent(key)}`);
+  const remote = r.ok ? (r.json?.asset?.value ?? null) : null;
+  const isNew = remote === null;
+  const same = !isNew && remote === local;
+
+  if (!isNew) {
+    const dest = join(preDir, key);
+    mkdirSync(dirname(dest), { recursive: true });
+    writeFileSync(dest, remote);
+  }
+  manifest.push({ key, created: isNew, identical: same, remote_bytes: remote?.length ?? 0, local_bytes: local.length });
+
+  if (same) { identical++; console.log(`  = ${key}  (identical, ${local.length}B)`); }
+  else if (isNew) { created++; console.log(`  + ${key}  (NEW, ${local.length}B)`); }
+  else { changed++; console.log(`  ~ ${key}  (${remote.length}B -> ${local.length}B, preimage saved)`); }
+}
+writeFileSync(join(preDir, 'manifest.json'), JSON.stringify({ themeId, role, name, captured_at: new Date().toISOString(), files: manifest }, null, 2));
+
+console.log(`\nblast radius: ${keys.length} assets — ${created} new, ${changed} changed, ${identical} identical`);
+if (!a.apply) {
+  console.log('\nDRY-RUN: nothing was written. Preimages captured at data/theme-preimage/' + themeId);
+  process.exit(0);
+}
+
+// ---- apply -------------------------------------------------------------
+let ok = 0, fail = 0;
+for (const key of keys) {
+  const value = readFileSync(join(ROOT, 'theme', key), 'utf8');
+  const r = await rest(`themes/${themeId}/assets.json`, { method: 'PUT', body: { asset: { key, value } } });
+  if (r.ok) { ok++; console.log(`  PUT ok   ${key}`); }
+  else { fail++; console.error(`  PUT FAIL ${key}: HTTP ${r.status} ${r.text.slice(0, 200)}`); }
+  await new Promise((r2) => setTimeout(r2, 250));
+}
+console.log(`\napplied: ${ok} ok, ${fail} failed`);
+logReversible({
+  action: `TK-11925 push ${ok} theme assets to theme ${themeId} (${name}, role=${role})`,
+  blast: ok,
+  undo: `cd ~/Projects/dw-contact-us-pages && node scripts/rollback-theme.mjs --theme ${themeId} --apply`,
+  verify: 'cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs',
+});
+process.exit(fail ? 1 : 0);
diff --git a/scripts/rollback-theme.mjs b/scripts/rollback-theme.mjs
new file mode 100644
index 0000000..a816849
--- /dev/null
+++ b/scripts/rollback-theme.mjs
@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+// rollback-theme.mjs — restore a theme from the preimage push-theme.mjs captured.
+//   node scripts/rollback-theme.mjs --theme <id>            # dry-run
+//   node scripts/rollback-theme.mjs --theme <id> --apply
+// Assets that had NO preimage (created:true) are DELETED; the rest are restored byte-for-byte.
+import { readFileSync, existsSync } from 'node:fs';
+import { join } from 'node:path';
+import { ROOT, parseArgs, banner, rest, logReversible } from './lib.mjs';
+
+const a = parseArgs();
+const themeId = String(a.theme || '');
+if (!/^\d+$/.test(themeId)) { console.error('usage: node scripts/rollback-theme.mjs --theme <id> [--apply]'); process.exit(1); }
+banner('rollback-theme', a.apply);
+
+const preDir = join(ROOT, 'data', 'theme-preimage', themeId);
+const mf = join(preDir, 'manifest.json');
+if (!existsSync(mf)) { console.error(`FATAL: no preimage manifest at ${mf} — nothing to roll back from.`); process.exit(1); }
+const manifest = JSON.parse(readFileSync(mf, 'utf8'));
+
+const restores = manifest.files.filter((f) => !f.created);
+const deletes = manifest.files.filter((f) => f.created);
+console.log(`preimage from ${manifest.captured_at} · theme "${manifest.name}" role=${manifest.role}`);
+console.log(`plan: restore ${restores.length}, delete ${deletes.length}`);
+for (const f of restores) console.log(`  restore ${f.key} (${f.remote_bytes}B)`);
+for (const f of deletes) console.log(`  DELETE  ${f.key} (was not present before the push)`);
+if (!a.apply) { console.log('\nDRY-RUN: nothing was written.'); process.exit(0); }
+
+let ok = 0, fail = 0;
+for (const f of restores) {
+  const value = readFileSync(join(preDir, f.key), 'utf8');
+  const r = await rest(`themes/${themeId}/assets.json`, { method: 'PUT', body: { asset: { key: f.key, value } } });
+  r.ok ? (ok++, console.log(`  restored ${f.key}`)) : (fail++, console.error(`  FAIL ${f.key}: ${r.status}`));
+  await new Promise((x) => setTimeout(x, 250));
+}
+for (const f of deletes) {
+  const r = await rest(`themes/${themeId}/assets.json?asset[key]=${encodeURIComponent(f.key)}`, { method: 'DELETE' });
+  r.ok ? (ok++, console.log(`  deleted ${f.key}`)) : (fail++, console.error(`  FAIL delete ${f.key}: ${r.status}`));
+  await new Promise((x) => setTimeout(x, 250));
+}
+console.log(`\nrollback: ${ok} ok, ${fail} failed`);
+logReversible({
+  action: `TK-11925 ROLLBACK theme ${themeId} from preimage (${restores.length} restored, ${deletes.length} deleted)`,
+  blast: ok,
+  undo: `cd ~/Projects/dw-contact-us-pages && node scripts/push-theme.mjs --theme ${themeId} --apply`,
+  verify: 'cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs',
+});
+process.exit(fail ? 1 : 0);

← fa643c4 scripts: shared lib + read-only enumerate (785 targets captu  ·  back to Dw Contact Us Pages  ·  scripts: assign-template, harden-variants (tracked-first), u 63acba0 →