← back to Shopify Sample Shipping

install-theme-engine.mjs

99 lines

#!/usr/bin/env node
// TK-11333 — one-paste DEV-theme installer for the sample-shipping cart engine.
// DRY-RUN by default (prints the plan, touches nothing). --apply performs the writes.
//
// SAFETY: it ONLY ever (1) DUPLICATES the published theme into a NEW unpublished dev copy,
// (2) uploads the snippet + injects the render onto that DEV copy. It NEVER edits the
// published theme and NEVER publishes anything. Undo = delete the dev theme (theme-engine-undo.mjs).
//
//   node install-theme-engine.mjs            # DRY: prints exactly what --apply would do
//   node install-theme-engine.mjs --apply    # WRITE: duplicate + upload + inject (Steve runs this)
//
// Records the created theme id + modified files to verification/theme-engine-install.json for undo.
import fs from 'node:fs';
import {query} from './query.mjs';
import {TOKEN, SHOP} from '../designerwallcoverings/scripts/lib/shopify.mjs';

const V = '2026-07';
const MAIN_THEME_GID = 'gid://shopify/OnlineStoreTheme/145121607731'; // carnegie-color-swatch (PUBLISHED — never edited)
const MAIN_THEME_ID = '145121607731';
const DEV_NAME = 'DW Sample-Shipping DEV';
const SNIPPET_KEY = 'snippets/sample-shipping-cart-engine.liquid';
const CART_FILE = 'sections/cart.liquid';
const ANCHOR = '<div class="cart-buttons-container">';           // unique in carnegie cart.liquid (verified)
const RENDER = "          {% render 'sample-shipping-cart-engine' %}\n"; // injected immediately BEFORE the anchor
const APPLY = process.argv.includes('--apply');
const REC = new URL('./verification/theme-engine-install.json', import.meta.url);

const snippetSrc = fs.readFileSync(new URL('./theme/sample-shipping-cart-engine.liquid', import.meta.url), 'utf8');
const restGet = async k => { const r = await fetch(`https://${SHOP}/admin/api/${V}/themes/${MAIN_THEME_ID}/assets.json?asset[key]=${encodeURIComponent(k)}`, { headers: { 'X-Shopify-Access-Token': TOKEN }, signal: AbortSignal.timeout(30000) }); if (!r.ok) throw Error(k + ' read ' + r.status); return (await r.json()).asset.value; };
const restGetTheme = async id => { const r = await fetch(`https://${SHOP}/admin/api/${V}/themes/${id}.json`, { headers: { 'X-Shopify-Access-Token': TOKEN }, signal: AbortSignal.timeout(30000) }); if (!r.ok) throw Error('theme ' + id + ' ' + r.status); return (await r.json()).theme; };
const restGetDevAsset = async (id, k) => { const r = await fetch(`https://${SHOP}/admin/api/${V}/themes/${id}/assets.json?asset[key]=${encodeURIComponent(k)}`, { headers: { 'X-Shopify-Access-Token': TOKEN }, signal: AbortSignal.timeout(30000) }); if (!r.ok) throw Error(k + ' dev read ' + r.status); return (await r.json()).asset.value; };

// ---- read the LIVE cart file (read-only) so we can validate the anchor now, before any write ----
const liveCart = await restGet(CART_FILE);
const anchorCount = liveCart.split(ANCHOR).length - 1;
console.log(`Main theme cart file: ${CART_FILE} (${liveCart.length} bytes)`);
console.log(`Anchor "${ANCHOR}" occurrences: ${anchorCount}`);
if (anchorCount !== 1) {
  console.error('\n⛔ ABORT: anchor is not uniquely present — will NOT auto-edit. Do this ONE manual edit on the DEV theme instead:');
  console.error(`   In ${CART_FILE}, add this line immediately BEFORE the checkout button block (the <button name="checkout"> inside .cart-totals):`);
  console.error(`   {% render 'sample-shipping-cart-engine' %}`);
  process.exit(1);
}
const injectedPreview = liveCart.replace(ANCHOR, RENDER + '          ' + ANCHOR);

if (!APPLY) {
  console.log('\n=== DRY RUN (no writes). With --apply this will: ===');
  console.log(`1. themeDuplicate ${MAIN_THEME_GID} -> new UNPUBLISHED theme "${DEV_NAME}" (published theme untouched, nothing published).`);
  console.log(`2. themeFilesUpsert ${SNIPPET_KEY} (${snippetSrc.length} bytes) onto the DEV copy.`);
  console.log(`3. Inject ${RENDER.trim()} into the DEV copy's ${CART_FILE} immediately before ${ANCHOR}.`);
  console.log(`4. Write created theme id + modified files to ${REC.pathname} for undo.`);
  console.log('\nInjection preview (context):');
  const idx = injectedPreview.indexOf(RENDER.trim());
  console.log(injectedPreview.slice(Math.max(0, idx - 160), idx + 200));
  console.log('\nRun again with --apply to perform it. Undo afterwards: node theme-engine-undo.mjs --apply');
  process.exit(0);
}

// ---------------- APPLY ----------------
console.log('\n=== APPLY ===');
// 1) duplicate
const dup = (await query(`mutation($id:ID!,$name:String){themeDuplicate(id:$id,name:$name){newTheme{id name role} userErrors{field message}}}`, { id: MAIN_THEME_GID, name: DEV_NAME })).themeDuplicate;
if (dup.userErrors.length) { console.error('themeDuplicate errors:', dup.userErrors); process.exit(1); }
const dt = dup.newTheme;
const devGid = dt.id, devId = devGid.split('/').pop();
console.log(`Duplicated -> ${dt.name} [${dt.role}] ${devGid}`);
if (String(dt.role).toUpperCase() === 'MAIN' || String(dt.role).toUpperCase() === 'PUBLISHED') { console.error('SAFETY ABORT: duplicate came back published?! Not proceeding.'); process.exit(1); }

// record immediately so undo works even if a later step fails
fs.writeFileSync(REC, JSON.stringify({ at: new Date().toISOString(), devThemeGid: devGid, devThemeId: devId, devThemeName: dt.name, sourceThemeId: MAIN_THEME_ID, modifiedFiles: [SNIPPET_KEY, CART_FILE], published: false }, null, 2) + '\n');

// 2) wait for duplication to finish processing before uploading assets
let ready = false;
for (let i = 0; i < 40; i++) { const t = await restGetTheme(devId); if (!t.processing) { ready = true; break; } await new Promise(r => setTimeout(r, 3000)); }
if (!ready) { console.error('⛔ dev theme still processing after 120s — aborting before any upload. Undo: node theme-engine-undo.mjs --apply'); process.exit(1); }

// 3) upsert the snippet
const up = (await query(`mutation($t:ID!,$f:[OnlineStoreThemeFilesUpsertFileInput!]!){themeFilesUpsert(themeId:$t,files:$f){upsertedThemeFiles{filename} userErrors{filename code message}}}`,
  { t: devGid, f: [{ filename: SNIPPET_KEY, body: { type: 'TEXT', value: snippetSrc } }] })).themeFilesUpsert;
if (up.userErrors.length) { console.error('snippet upsert errors:', up.userErrors); process.exit(1); }
console.log('Uploaded snippet:', up.upsertedThemeFiles.map(f => f.filename).join(', '));

// 4) inject the render into the DEV copy's cart file (re-read the dev copy, guard the anchor)
const devCart = await restGetDevAsset(devId, CART_FILE);
if ((devCart.split(ANCHOR).length - 1) !== 1) { console.error('⛔ dev cart anchor not unique — snippet uploaded but NOT injected. Add {% render \'sample-shipping-cart-engine\' %} before the checkout button manually.'); process.exit(1); }
if (devCart.includes("render 'sample-shipping-cart-engine'")) { console.log('Render already present on dev cart — skipping inject.'); }
else {
  const injected = devCart.replace(ANCHOR, RENDER + '          ' + ANCHOR);
  const inj = (await query(`mutation($t:ID!,$f:[OnlineStoreThemeFilesUpsertFileInput!]!){themeFilesUpsert(themeId:$t,files:$f){upsertedThemeFiles{filename} userErrors{filename code message}}}`,
    { t: devGid, f: [{ filename: CART_FILE, body: { type: 'TEXT', value: injected } }] })).themeFilesUpsert;
  if (inj.userErrors.length) { console.error('cart inject errors:', inj.userErrors); process.exit(1); }
  console.log('Injected render into', CART_FILE);
}

console.log(`\n✅ DONE. DEV theme "${dt.name}" (${devId}) — UNPUBLISHED, nothing published.`);
console.log(`Preview it: Admin -> Online Store -> Themes -> "${dt.name}" -> Preview.`);
console.log(`Undo (delete the dev theme): node theme-engine-undo.mjs --apply`);
console.log(`Record: ${REC.pathname}`);