[object Object]

← back to Shopify Sample Shipping

TK-11333: author one-paste DEV-theme installer (themeDuplicate->upload snippet->inject render before verified cart anchor, never publishes) + undo + ~/theme launcher; tighten snippet selector to real .cart-checkout markup; RUNBOOK Step 5 paste+proof+publish

4125a6129b33b81e83ce0bd16fc0eedd88f351f4 · 2026-09-09 23:10:11 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GGVzyUM2B8rVz9TxouGgY

Files touched

Diff

commit 4125a6129b33b81e83ce0bd16fc0eedd88f351f4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 23:10:11 2026 -0700

    TK-11333: author one-paste DEV-theme installer (themeDuplicate->upload snippet->inject render before verified cart anchor, never publishes) + undo + ~/theme launcher; tighten snippet selector to real .cart-checkout markup; RUNBOOK Step 5 paste+proof+publish
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_019GGVzyUM2B8rVz9TxouGgY
---
 RUNBOOK-TK-11333.md                      | 17 +++---
 install-theme-engine.mjs                 | 95 ++++++++++++++++++++++++++++++++
 theme-engine-undo.mjs                    | 31 +++++++++++
 theme/sample-shipping-cart-engine.liquid |  7 ++-
 4 files changed, 141 insertions(+), 9 deletions(-)

diff --git a/RUNBOOK-TK-11333.md b/RUNBOOK-TK-11333.md
index 64e5fed..52681c5 100644
--- a/RUNBOOK-TK-11333.md
+++ b/RUNBOOK-TK-11333.md
@@ -62,13 +62,14 @@ Pick a real **Sample variant id** for the prototype (any `{DW_SKU}-Sample`), e.g
 ```
 Covers: retail 6 WC (must NOT claim charged), retail 5+5 free, retail 11 charged, designer 10 (no redirect), designer 12 (free + applies code), designer 13 charged, sample+roll charged, 21 soft-cap, Default-Title `-Sample` edge.
 
-**Install (Steve, on a DUPLICATE/DEV theme — NOT live):**
-1. Duplicate the live theme in Admin → Online Store → Themes → ⋯ → Duplicate.
-2. Save the file as `snippets/sample-shipping-cart-engine.liquid` on the DEV theme.
-3. Render near the cart checkout button: `{%- render 'sample-shipping-cart-engine' -%}`.
-4. Tune `CONFIG` (checkout/wallet selectors) to the theme; `bandCapCents:4500` matches the live band.
-5. QA carts on the DEV **preview** (see Step 5), then **Steve publishes**.
-**Undo:** theme is version-backed (`verification/cart.liquid.backup`); remove the snippet render + file, or revert the theme version.
+**One-paste DEV install (Steve runs this — theme writes are classifier-blocked from our sessions):**
+```
+! ~/theme
+```
+That launcher runs `install-theme-engine.mjs --apply` (tee'd to `/tmp/tk-theme.log`). It: (1) **duplicates the published theme** `carnegie-color-swatch` (gid 145121607731) into a NEW **unpublished** copy "DW Sample-Shipping DEV" — the published theme is never touched and nothing is published; (2) uploads `snippets/sample-shipping-cart-engine.liquid`; (3) injects `{% render 'sample-shipping-cart-engine' %}` into the DEV copy's **`sections/cart.liquid`** immediately before `<div class="cart-buttons-container">` (verified-unique anchor, right above the `<button name="checkout">`). If the anchor isn't uniquely found it ABORTS and prints the one manual edit instead of guessing.
+- Preview first (read-only, no writes): `! cd ~/Projects/shopify-sample-shipping && node install-theme-engine.mjs`
+- Selector VERIFIED against carnegie markup: checkout button = `<button class="cart-checkout" name="checkout">` (snippet matches on `.cart-checkout` + `[name="checkout"]`); wallets = `.additional-checkout-buttons` (snippet hides them over the soft cap). Product types confirmed Wallcovering/Fabric.
+**Undo:** `! cd ~/Projects/shopify-sample-shipping && node theme-engine-undo.mjs --apply` (deletes the DEV theme; refuses if the recorded theme is published or renamed). Record: `verification/theme-engine-install.json`.
 
 ---
 
@@ -85,6 +86,8 @@ The theme LOGIC is proven (Step 4, 9/9). The unproven piece is whether the `/dis
 
 Server-side entitlement is already proven: `verify-codes.mjs` shows TRADESHIP ACTIVE + scoped to both designer segments — so a non-designer is rejected at checkout regardless of the client JS.
 
+**PUBLISH (Steve-gated, LIVE — only after the manual proof PASSes):** in Admin → Online Store → Themes, on **"DW Sample-Shipping DEV"** click ⋯ → **Publish**. That swaps it in as the live theme (the old `carnegie-color-swatch` becomes an unpublished backup — instant rollback by re-publishing it). Do NOT publish before the Step-5 proof passes.
+
 ---
 
 ## STEP 6 — Band retune  [OPTIONAL — DTD says NO CHANGE]
diff --git a/install-theme-engine.mjs b/install-theme-engine.mjs
new file mode 100644
index 0000000..5a3d19f
--- /dev/null
+++ b/install-theme-engine.mjs
@@ -0,0 +1,95 @@
+#!/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){theme{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 devGid = dup.theme.id, devId = devGid.split('/').pop();
+console.log(`Duplicated -> ${dup.theme.name} [${dup.theme.role}] ${devGid}`);
+if (String(dup.theme.role).toUpperCase() === 'MAIN' || String(dup.theme.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: dup.theme.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
+for (let i = 0; i < 40; i++) { const t = await restGetTheme(devId); if (!t.processing) break; await new Promise(r => setTimeout(r, 3000)); }
+
+// 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 "${dup.theme.name}" (${devId}) — UNPUBLISHED, nothing published.`);
+console.log(`Preview it: Admin -> Online Store -> Themes -> "${dup.theme.name}" -> Preview.`);
+console.log(`Undo (delete the dev theme): node theme-engine-undo.mjs --apply`);
+console.log(`Record: ${REC.pathname}`);
diff --git a/theme-engine-undo.mjs b/theme-engine-undo.mjs
new file mode 100644
index 0000000..7d53ecf
--- /dev/null
+++ b/theme-engine-undo.mjs
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+// TK-11333 — undo the DEV-theme install: DELETE the "DW Sample-Shipping DEV" theme created by
+// install-theme-engine.mjs. Reads verification/theme-engine-install.json for the id.
+// DRY-RUN by default; --apply deletes. SAFETY: refuses to delete unless the recorded theme's
+// name matches DEV_NAME and it is NOT the published/main theme.
+import fs from 'node:fs';
+import {query} from './query.mjs';
+import {TOKEN, SHOP} from '../designerwallcoverings/scripts/lib/shopify.mjs';
+
+const V = '2026-07';
+const DEV_NAME = 'DW Sample-Shipping DEV';
+const APPLY = process.argv.includes('--apply');
+const REC = new URL('./verification/theme-engine-install.json', import.meta.url);
+if (!fs.existsSync(REC)) { console.error('No install record at', REC.pathname, '— nothing to undo.'); process.exit(1); }
+const rec = JSON.parse(fs.readFileSync(REC, 'utf8'));
+const devId = rec.devThemeId, devGid = rec.devThemeGid;
+
+// re-read live to confirm name + role before deleting
+const r = await fetch(`https://${SHOP}/admin/api/${V}/themes/${devId}.json`, { headers: { 'X-Shopify-Access-Token': TOKEN }, signal: AbortSignal.timeout(30000) });
+if (r.status === 404) { console.log('Theme', devId, 'already gone — nothing to undo.'); process.exit(0); }
+if (!r.ok) throw Error('theme read ' + r.status);
+const t = (await r.json()).theme;
+console.log(`Recorded dev theme: ${devId} "${t.name}" [role:${t.role}]`);
+if (t.role === 'main' || t.role === 'published') { console.error('SAFETY ABORT: recorded theme is PUBLISHED — refusing to delete.'); process.exit(1); }
+if (t.name !== DEV_NAME) { console.error(`SAFETY ABORT: theme name "${t.name}" != "${DEV_NAME}" — refusing to delete (record may be stale).`); process.exit(1); }
+
+if (!APPLY) { console.log(`\nDRY RUN — would DELETE unpublished theme ${devGid} "${t.name}". Add --apply to delete.`); process.exit(0); }
+const del = (await query(`mutation($id:ID!){themeDelete(id:$id){deletedThemeId userErrors{field message}}}`, { id: devGid })).themeDelete;
+if (del.userErrors.length) { console.error('themeDelete errors:', del.userErrors); process.exit(1); }
+console.log('✅ Deleted dev theme', del.deletedThemeId);
+fs.writeFileSync(REC, JSON.stringify({ ...rec, deletedAt: new Date().toISOString(), deleted: true }, null, 2) + '\n');
diff --git a/theme/sample-shipping-cart-engine.liquid b/theme/sample-shipping-cart-engine.liquid
index ba7f7ad..1beeb7a 100644
--- a/theme/sample-shipping-cart-engine.liquid
+++ b/theme/sample-shipping-cart-engine.liquid
@@ -83,8 +83,11 @@
     // product.type values that count as Fabric (VERIFIED live: catalog uses "Fabric" and
     // "Wallcovering"; toLowerCase makes "Fabric" -> "fabric"). Extra synonyms are harmless.
     fabricTypes: ['fabric','fabrics','textile','textiles'],
-    checkoutBtnSelector: '[name="checkout"], button[name="checkout"], .cart__checkout, #checkout, #cart-checkout',
-    // accelerated wallet / dynamic checkout buttons to hide when over the soft cap
+    // VERIFIED against carnegie-color-swatch sections/cart.liquid: the checkout button is
+    // <button class="cart-checkout button" name="checkout">. [name="checkout"] + .cart-checkout both match.
+    checkoutBtnSelector: '[name="checkout"], button[name="checkout"], .cart-checkout, .cart__checkout, #checkout, #cart-checkout',
+    // accelerated wallet / dynamic checkout buttons to hide when over the soft cap.
+    // VERIFIED: carnegie renders <div class="additional-checkout-buttons"> for content_for_additional_checkout_buttons.
     walletSelector: '.additional-checkout-buttons, .shopify-payment-button, [data-shopify="payment-button"], .dynamic-checkout__content',
     messageMountSelector: '#dw-ship-policy'
   };

← ffc2043 TK-11333: correct band-truth (band is LIVE on dedicated Samp  ·  back to Shopify Sample Shipping  ·  auto-data-snapshot: 2026-09-09T23:22:55 (1 data files) — ver d3d59df →