← 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
A scripts/push-theme.mjsA scripts/rollback-theme.mjs
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 →