← back to Dw Contact Us Pages
scripts/push-theme.mjs
105 lines
#!/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);