← 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);