[object Object]

← back to Designerwallcoverings

sanderson-onboard: DWXH- drafts (dual mfr_sku mf, retail Roll + $4.25 sample, PG-first) + gated/armed go-live trickle (settlement CLEAR + 5-field, no Google)

07fb88ee977145ed2148616ab77ddedd4396f825 · 2026-07-26 16:41:12 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 07fb88ee977145ed2148616ab77ddedd4396f825
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 16:41:12 2026 -0700

    sanderson-onboard: DWXH- drafts (dual mfr_sku mf, retail Roll + $4.25 sample, PG-first) + gated/armed go-live trickle (settlement CLEAR + 5-field, no Google)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/sanderson-onboard/STATUS.md                |  45 +++++
 scripts/sanderson-onboard/build-payloads.mjs       | 188 +++++++++++++++++++++
 .../com.steve.sanderson-onboard-resume.plist       |  24 +++
 scripts/sanderson-onboard/create-drafts.mjs        | 169 ++++++++++++++++++
 scripts/sanderson-onboard/go-live.mjs              | 142 ++++++++++++++++
 scripts/sanderson-onboard/resume.sh                |  61 +++++++
 scripts/sanderson-onboard/run.sh                   |  20 +++
 7 files changed, 649 insertions(+)

diff --git a/scripts/sanderson-onboard/STATUS.md b/scripts/sanderson-onboard/STATUS.md
new file mode 100644
index 0000000..5f5bb3d
--- /dev/null
+++ b/scripts/sanderson-onboard/STATUS.md
@@ -0,0 +1,45 @@
+# Sanderson onboard — STATUS (2026-07-26, ticket TK-00038)
+
+Model: osborne-onboard (priced drafts + gated go-live) + greenland (image handling). Registry 361:
+prefix `DWXH-`, range 255000, sample $4.25, collection `sanderson`, wallpaper unit = Roll, tariff 10%
+(unconfirmed, may have expired 2026-07-24).
+
+## What's built (scripts/sanderson-onboard/)
+- `build-payloads.mjs` — reads sanderson_catalog, assigns + PERSISTS DWXH- SKUs (from 255000, id order,
+  idempotent), writes out/payloads.jsonl (589). Title `Pattern Color | Sanderson` (title-case, no
+  "Wallpaper"→Wallcovering, never "Unknown"). Roll @ retail_usd + Sample @ $4.25 (no inventory).
+  Metafields: **custom.manufacturer_sku + dwc.manufacturer_sku** (dual), global.width (when present),
+  pattern/color/background, custom.color_N_*, color_details(json), specs.style/pattern, google cat.
+- `create-drafts.mjs` — dedup (live Sanderson vendor + created.jsonl + PG on_shopify), SHARED
+  `backlog` budget (never `upload`), creates DRAFT + metafields + images, **PG-first writeback**
+  (shopify_product_id + on_shopify=true + imported_at → sanderson_catalog).
+- `go-live.mjs` — GATED activation: (A) settlement CLEAR only, (B) validateBeforeActivate (5-field +
+  image), inventory on the Roll only (sample stays no-inventory), publish to 12 channels **except
+  Google**, status ACTIVE. Idempotent.
+- `resume.sh` + `com.steve.sanderson-onboard-resume.plist` — daily trickle (21:05/00:10/09:00/14:00).
+  Create step is backlog-cap metered; go-live is **DISARMED by default** + capped at 15/day when armed.
+- `run.sh` — token wiring from ~/Projects/secrets-manager/.env.
+
+## Done
+- 589 DWXH- SKUs assigned + persisted to sanderson_catalog.dw_sku.
+- `on_shopify` boolean column added to sanderson_catalog (task requirement).
+- **2 drafts created + verified live** (DWXH-255000/255001): vendor Sanderson, type Wallcovering,
+  status draft, Roll $320 + Sample $4.25, 4 images, dual manufacturer_sku metafields, PG writeback OK.
+- Plist installed (create-trickle active; go-live disarmed). 0 products ACTIVE.
+
+## Pending / next
+- **587 drafts** trickle via the plist under the SHARED `backlog` budget — starts 21:05 tonight
+  (end-of-day reclaim ≈ 284 tonight), completes over ~2–3 nights. Uploader-safe by design.
+  To ACCELERATE (create all now, uses more of Shopify's shared ~1k/day cap tonight — trades off the
+  just-revived new-arrivals uploader): `DW_BACKLOG_CAP=650 bash run.sh create --apply`.
+- **Go-live is Steve-gated** (customer-facing). To START the metered trickle (15 CLEAR/day, settlement
+  + 5-field gated, no Google): `touch scripts/sanderson-onboard/out/GOLIVE-ARMED`. `rm` it to pause.
+  Only the 224 CLEAR auto-activate; the 364 REVIEW-VISION + 1 null need the per-image vision pass first.
+
+## Known gaps
+- **global.width missing on 589/589** — no width in staging or trade catalog. global.width metafield is
+  omitted until width is backfilled. Non-blocking for activation (not a 5-field field).
+- 1 row (SAW0208-01 Fringed Tulip Toile) has no retail + null settlement → created as $0.00 draft,
+  gated out of go-live.
+- tariff 10% is `tariff_confirmed=f` and may have expired 2026-07-24; retail_usd is cost/0.65/0.85
+  WITHOUT tariff (per memo). Revisit if landed-cost basis is wanted.
diff --git a/scripts/sanderson-onboard/build-payloads.mjs b/scripts/sanderson-onboard/build-payloads.mjs
new file mode 100644
index 0000000..eca4e81
--- /dev/null
+++ b/scripts/sanderson-onboard/build-payloads.mjs
@@ -0,0 +1,188 @@
+#!/usr/bin/env node
+/**
+ * Build Sanderson wallpaper DRAFT payloads from dw_unified.sanderson_catalog → out/payloads.jsonl.
+ * Models osborne-onboard/build-payloads.mjs (priced) + greenland-onboard (image handling).
+ *
+ * Per product (Task #3 spec, 2026-07-26):
+ *   - DW SKU : DWXH-<255000+idx> (registry 361, sku_prefix DWXH-, sku_range_start 255000),
+ *              assigned by id order and PERSISTED to sanderson_catalog.dw_sku (idempotent —
+ *              a row that already has a dw_sku keeps it).
+ *   - title  : "Pattern Color | Sanderson"  Title-Case · "Wallpaper"→"Wallcovering" · NEVER "Unknown"
+ *   - variants: Roll @ retail_usd (sellable) + {DW}-Sample @ $4.25 (no inventory tracking)
+ *   - metafields: custom.manufacturer_sku + dwc.manufacturer_sku (DUAL), global.width (when present),
+ *                 custom.pattern_name/color/real_color_name/background_color, custom.color_N_*,
+ *                 custom.color_details(json), specs.style/pattern, mc-facebook.google_product_category
+ *   - tags    : Wallcovering(s), Sanderson[, Sanderson Wallcovering], collection, colors/style/pattern
+ *   - images  : primary image_url + gallery_images (deduped) — ≥1 required
+ *   - status  : draft (activation is gated in go-live.mjs: settlement + validateBeforeActivate 5-field)
+ *
+ *   node build-payloads.mjs        # assign+persist DW SKUs, write payloads.jsonl, print 3 samples
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const SAMPLE_PRICE = '4.25';               // registry 361 sample_price
+const SKU_PREFIX = 'DWXH-';                // registry 361 sku_prefix
+const SKU_START = 255000;                  // registry 361 sku_range_start
+const GOOGLE_CAT = '2334';                 // Wallpaper (Google product category); channel-excluded but metafield kept
+const VENDOR = 'Sanderson';
+const BRAND = 'Sanderson';
+
+const UNKNOWN = /^\s*(unknown|n\/?a|null|none|untitled|-)?\s*$/i;   // treat these as "no value"
+const bad = s => s == null || UNKNOWN.test(String(s));
+// "Wallpaper" is banned in customer-facing text — always Wallcovering
+const clean = s => String(s == null ? '' : s).replace(/wall\s*paper/ig, 'Wallcovering').trim();
+// cap the first letter of each word/segment WITHOUT lowercasing the rest — preserves already-correct
+// mixed case like "Rose/Cream", "101 Dalmatians", "V&A" (lowercasing-first would damage these).
+const titleCase = s => clean(s).replace(/\b\w/g, c => c.toUpperCase()).trim();
+const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
+
+function rows() {
+  const cols = `id, mfr_sku, dw_sku, pattern_name, color_name, collection, width, roll_length,
+    pattern_repeat, material, image_url, gallery_images, retail_usd, cost_usd, ai_description,
+    to_json(ai_colors) ai_colors, ai_background_color, to_json(ai_styles) ai_styles,
+    to_json(ai_patterns) ai_patterns, to_json(ai_tags) ai_tags, to_json(styles) styles,
+    settlement_verdict`;
+  const json = execSync(
+    `psql "${DB}" -tAc "select coalesce(json_agg(row_to_json(t) order by (t).id)::text,'[]') from (select ${cols} from sanderson_catalog) t"`,
+    { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
+  return JSON.parse(json.trim());
+}
+
+function titleFor(r) {
+  const pat = titleCase(r.pattern_name);
+  const patFinal = pat && !UNKNOWN.test(pat) ? pat : titleCase(r.mfr_sku);   // never blank/Unknown
+  const col = !bad(r.color_name) && r.color_name.toLowerCase() !== (r.pattern_name || '').toLowerCase()
+    ? titleCase(r.color_name) : '';
+  return `${patFinal}${col ? ' ' + col : ''} | ${BRAND}`.replace(/\s+/g, ' ').trim();
+}
+
+function arr(v) { try { return Array.isArray(v) ? v : (v ? JSON.parse(v) : []); } catch { return []; } }
+
+function tags(r) {
+  const t = new Set(['Wallcovering', 'Wallcoverings', 'Sanderson', 'Sanderson Wallcovering']);
+  if (!bad(r.collection)) t.add(titleCase(r.collection));
+  if (!bad(r.material)) t.add(titleCase(r.material));
+  if (!bad(r.ai_background_color)) t.add('color:' + titleCase(r.ai_background_color));
+  for (const c of arr(r.ai_colors)) { const n = c && (c.name || c.color); if (!bad(n)) t.add(titleCase(n)); }
+  for (const s of arr(r.ai_styles)) if (!bad(s)) t.add(titleCase(typeof s === 'string' ? s : s.name));
+  for (const s of arr(r.styles)) if (!bad(s)) t.add(titleCase(s));
+  for (const p of arr(r.ai_patterns)) if (!bad(p)) t.add(titleCase(typeof p === 'string' ? p : p.name));
+  for (const g of arr(r.ai_tags)) if (!bad(g) && typeof g === 'string') t.add(titleCase(g));
+  return [...t].filter(Boolean).join(', ');   // always ≥4 (base set) → satisfies 5-field ≥2 tags
+}
+
+function bodyHtml(r) {
+  const pat = titleCase(r.pattern_name) || titleCase(r.mfr_sku);
+  const story = !bad(r.ai_description)
+    ? `<p>${esc(clean(r.ai_description))}</p>`
+    : `<p>${esc(pat)}${!bad(r.color_name) ? ' in ' + esc(titleCase(r.color_name)) : ''} — a ${BRAND} wallcovering.</p>`;
+  const specRows = [
+    ['Pattern', titleCase(r.pattern_name)], ['Color', bad(r.color_name) ? '' : titleCase(r.color_name)],
+    ['Collection', bad(r.collection) ? '' : titleCase(r.collection)],
+    ['Material', bad(r.material) ? '' : titleCase(r.material)],
+    ['Width', bad(r.width) ? '' : r.width], ['Roll Length', bad(r.roll_length) ? '' : r.roll_length],
+    ['Pattern Repeat', bad(r.pattern_repeat) ? '' : r.pattern_repeat],
+    ['Brand', BRAND], ['Manufacturer SKU', r.mfr_sku],
+  ].filter(([, v]) => v != null && String(v).trim() !== '');
+  return `<div class="sanderson-product">\n${story}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n`
+    + specRows.map(([k, v]) => `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n')
+    + `\n</table>\n</div>`;
+}
+
+function metafields(r) {
+  const mf = [];
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (!bad(value)) mf.push({ namespace: ns, key, type, value: String(value).trim() }); };
+  const N = (ns, key, value) => { if (value != null && value !== '' && !Number.isNaN(Number(value))) mf.push({ namespace: ns, key, type: 'number_decimal', value: String(Number(value)) }); };
+  // DUAL manufacturer_sku (task spec: custom + dwc)
+  S('custom', 'manufacturer_sku', r.mfr_sku);
+  S('dwc', 'manufacturer_sku', r.mfr_sku);
+  S('global', 'Brand', BRAND);
+  S('global', 'width', r.width);                 // only when present (see width backfill note)
+  S('global', 'repeat', r.pattern_repeat);
+  S('custom', 'pattern_name', titleCase(r.pattern_name));
+  S('custom', 'color', titleCase(r.color_name));
+  S('custom', 'real_color_name', r.color_name);
+  S('custom', 'background_color', r.ai_background_color);
+  S('mc-facebook', 'google_product_category', GOOGLE_CAT);
+  const cols = arr(r.ai_colors).slice(0, 3);
+  cols.forEach((c, i) => { const n = c && (c.name || c.color); S('custom', `color_${i+1}_name`, n); S('custom', `color_${i+1}_hex`, c && c.hex); N('custom', `color_${i+1}_percentage`, c && (c.percentage ?? c.pct)); });
+  if (arr(r.ai_colors).length) mf.push({ namespace: 'custom', key: 'color_details', type: 'json', value: JSON.stringify(arr(r.ai_colors)) });
+  const style0 = arr(r.ai_styles)[0] || arr(r.styles)[0];
+  S('specs', 'style', typeof style0 === 'string' ? style0 : (style0 && style0.name));
+  const pat0 = arr(r.ai_patterns)[0];
+  S('specs', 'pattern', typeof pat0 === 'string' ? pat0 : (pat0 && pat0.name));
+  return mf;
+}
+
+function images(r) {
+  const seen = new Set(); const imgs = [];
+  const push = u => { if (u && !seen.has(u)) { seen.add(u); imgs.push({ src: u, alt: esc(titleCase(r.pattern_name) || r.mfr_sku) }); } };
+  push(r.image_url);
+  for (const g of arr(r.gallery_images)) push(g);
+  return imgs;
+}
+
+function main() {
+  const data = rows();
+  const out = [];
+  const skuAssign = [];               // [id, dw_sku] to persist for rows that had none
+  let idx = 0, noImg = 0, noPrice = 0;
+  const seenSku = new Set();
+  // stable index by id order; reuse existing dw_sku, else assign next in range
+  const usedNums = new Set(data.map(r => r.dw_sku).filter(Boolean)
+    .map(s => parseInt(String(s).replace(SKU_PREFIX, ''), 10)).filter(n => !Number.isNaN(n)));
+  let nextNum = SKU_START;
+  const nextSku = () => { while (usedNums.has(nextNum)) nextNum++; usedNums.add(nextNum); return `${SKU_PREFIX}${nextNum++}`; };
+  for (const r of data) {
+    if (!r.mfr_sku) continue;
+    let dw = r.dw_sku;
+    if (!dw) { dw = nextSku(); skuAssign.push([r.id, dw]); }
+    if (seenSku.has(dw)) continue; seenSku.add(dw);
+    const imgs = images(r);
+    if (!imgs.length) noImg++;
+    const priced = r.retail_usd != null && Number(r.retail_usd) > 0;
+    if (!priced) noPrice++;
+    out.push({
+      mfr_sku: r.mfr_sku, sku: dw, sc_id: r.id,
+      settlement_verdict: r.settlement_verdict || null,
+      retail: priced ? Number(r.retail_usd) : null,
+      product: {
+        title: titleFor(r), vendor: VENDOR, product_type: 'Wallcovering',
+        status: 'draft', tags: tags(r), body_html: bodyHtml(r),
+        images: imgs,
+        options: [{ name: 'Format' }],
+        variants: [
+          // sellable Roll @ retail (quote-fallback for the 1 unpriced row → 0.00 + Needs-Price handled at go-live)
+          { sku: dw, price: priced ? String(Number(r.retail_usd).toFixed(2)) : '0.00', option1: 'Roll', taxable: true, requires_shipping: true },
+          // Sample — no inventory tracking (task: "no inventory")
+          { sku: `${dw}-Sample`, price: SAMPLE_PRICE, option1: 'Sample', taxable: true, requires_shipping: true, inventory_management: null },
+        ],
+      },
+      metafields: metafields(r),
+    });
+  }
+  // persist newly-assigned DW SKUs (PostgreSQL-first, idempotent)
+  if (skuAssign.length) {
+    const values = skuAssign.map(([id, dw]) => `(${id}::int, '${dw}')`).join(',');
+    execSync(`psql "${DB}" -c "update sanderson_catalog s set dw_sku = v.dw_sku from (values ${values}) as v(id, dw_sku) where s.id = v.id and s.dw_sku is null"`, { stdio: 'ignore' });
+  }
+  fs.writeFileSync(path.join(OUT, 'payloads.jsonl'), out.map(o => JSON.stringify(o)).join('\n') + '\n');
+  console.log(`payloads: ${out.length}  (dw_sku newly-assigned ${skuAssign.length}, no-image ${noImg}, unpriced ${noPrice})`);
+  const clr = out.filter(o => o.settlement_verdict === 'CLEAR').length;
+  console.log(`settlement: CLEAR ${clr} · REVIEW/other ${out.length - clr}  (only CLEAR auto-goes-live)`);
+  const noWidth = out.filter(o => !o.metafields.some(m => m.namespace === 'global' && m.key === 'width')).length;
+  if (noWidth) console.log(`⚠ global.width missing on ${noWidth}/${out.length} (no width in staging — flag for backfill)`);
+  for (const o of out.slice(0, 3)) {
+    console.log(`\n[${o.sku}] ${o.product.title}`);
+    console.log(`   variants: Roll $${o.product.variants[0].price} / Sample $${o.product.variants[1].price} · settlement ${o.settlement_verdict}`);
+    console.log(`   tags(${o.product.tags.split(', ').length}): ${o.product.tags}`);
+    console.log(`   images: ${o.product.images.length} · metafields(${o.metafields.length}): ${o.metafields.map(m => m.namespace+'.'+m.key).join(', ')}`);
+  }
+}
+main();
diff --git a/scripts/sanderson-onboard/com.steve.sanderson-onboard-resume.plist b/scripts/sanderson-onboard/com.steve.sanderson-onboard-resume.plist
new file mode 100644
index 0000000..7da3dc9
--- /dev/null
+++ b/scripts/sanderson-onboard/com.steve.sanderson-onboard-resume.plist
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+  <key>Label</key><string>com.steve.sanderson-onboard-resume</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>/Users/macstudio3/Projects/designerwallcoverings/scripts/sanderson-onboard/resume.sh</string>
+  </array>
+  <!-- Trickle the Sanderson DWXH- DRAFT creates + gated go-live under the SHARED 'backlog'
+       variant-budget slice (never 'upload'). 21:05 catches the end-of-day backlog reclaim;
+       00:10 primary after the midnight cap reset; 09:00 + 14:00 retries (share the same slice,
+       so total/day can't exceed the cap). resume.sh is idempotent + cap-clean and self-unloads
+       once all payloads are created AND every CLEAR draft is live. -->
+  <key>StartCalendarInterval</key>
+  <array>
+    <dict><key>Hour</key><integer>21</integer><key>Minute</key><integer>5</integer></dict>
+    <dict><key>Hour</key><integer>0</integer><key>Minute</key><integer>10</integer></dict>
+    <dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Hour</key><integer>14</integer><key>Minute</key><integer>0</integer></dict>
+  </array>
+  <key>StandardOutPath</key><string>/Users/macstudio3/Projects/designerwallcoverings/scripts/sanderson-onboard/out/launchd.out</string>
+  <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/designerwallcoverings/scripts/sanderson-onboard/out/launchd.err</string>
+</dict></plist>
diff --git a/scripts/sanderson-onboard/create-drafts.mjs b/scripts/sanderson-onboard/create-drafts.mjs
new file mode 100644
index 0000000..51fe3da
--- /dev/null
+++ b/scripts/sanderson-onboard/create-drafts.mjs
@@ -0,0 +1,169 @@
+#!/usr/bin/env node
+/**
+ * Create Sanderson wallpaper products as DRAFT on the live store (designer-laboratory-sandbox = prod),
+ * set metafields via GraphQL, attach images, and write shopify_product_id + on_shopify back to
+ * sanderson_catalog (PostgreSQL-first). Models osborne-onboard/create-drafts.mjs.
+ *
+ *   node create-drafts.mjs                 # DRY-RUN: plan + first 3
+ *   node create-drafts.mjs --apply --limit=3   # canary
+ *   node create-drafts.mjs --apply             # all remaining (resumable, backlog-cap aware)
+ *
+ * DEDUP (task): skip any DW/mfr already live on the Shopify Sanderson vendor query,
+ *   already in out/created.jsonl, OR already flagged on_shopify=true in sanderson_catalog.
+ * Cap-aware: draws from the SHARED 'backlog' variant-budget slice (NEVER 'upload'); stops cleanly
+ *   when the slice is exhausted → resume tomorrow (idempotent).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import { execSync } from 'node:child_process';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const require = createRequire(import.meta.url);
+const budget = require('../variant-budget/budget.cjs');
+const BUDGET_CAT = 'backlog';              // SHARED background-trickle pool — NEVER 'upload'
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function rest(method, p, body) {
+  return fetch(`https://${STORE}/admin/api/${API}${p}`, {
+    method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(45000),
+  }).then(async res => ({ status: res.status, body: await res.json().catch(() => ({})),
+                          callLimit: res.headers.get('x-shopify-shop-api-call-limit') }));
+}
+async function restAll(p, key) {
+  let url = `https://${STORE}/admin/api/${API}${p}`, all = [];
+  for (let guard = 0; guard < 50 && url; guard++) {
+    const res = await fetch(url, { headers: { 'X-Shopify-Access-Token': TOKEN }, signal: AbortSignal.timeout(45000) });
+    const j = await res.json().catch(() => ({})); all = all.concat(j[key] || []);
+    const link = res.headers.get('link') || ''; const m = link.match(/<([^>]+)>;\s*rel="next"/); url = m ? m[1] : null;
+    if (url) await sleep(400);
+  }
+  return all;
+}
+async function gql(query, variables) {
+  for (let a = 0; a < 4; a++) {
+    const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+      method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(45000) });
+    const j = await res.json();
+    if (j.errors) { if (a === 3) return { errors: j.errors }; await sleep(900 * (a + 1)); continue; }
+    return j.data;
+  }
+}
+const M_MF = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{field message} } }`;
+
+const loadJsonl = f => fs.existsSync(path.join(OUT, f))
+  ? fs.readFileSync(path.join(OUT, f), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)) : [];
+
+// Sanderson serves images over its CDN; try URL-ingest first, fall back to base64 download+attach.
+async function attachImages(pid, imgs) {
+  let ok = 0;
+  for (const im of imgs) {
+    let r = await rest('POST', `/products/${pid}/images.json`, { image: { src: im.src, alt: im.alt } });
+    if (r.status !== 200 && r.status !== 201) {
+      try {
+        const resp = await fetch(im.src, { signal: AbortSignal.timeout(30000) });
+        if (resp.ok) {
+          const b64 = Buffer.from(await resp.arrayBuffer()).toString('base64');
+          r = await rest('POST', `/products/${pid}/images.json`, { image: { attachment: b64, filename: `${pid}-${ok}.jpg`, alt: im.alt } });
+        }
+      } catch { /* skip */ }
+    }
+    if (r.status === 200 || r.status === 201) ok++;
+    await sleep(300);
+  }
+  return ok;
+}
+
+async function liveSandersonSkus() {
+  try {
+    const prods = await restAll('/products.json?vendor=Sanderson&fields=id,variants&limit=250', 'products');
+    const s = new Set();
+    for (const p of prods) for (const v of (p.variants || [])) if (v.sku) s.add(v.sku.toUpperCase());
+    return s;
+  } catch { return new Set(); }
+}
+function pgOnShopifySkus() {
+  try {
+    const out = execSync(`psql "${DB}" -tAc "select coalesce(dw_sku,'') from sanderson_catalog where on_shopify is true and dw_sku is not null"`, { encoding: 'utf8' });
+    return new Set(out.trim().split('\n').filter(Boolean).map(s => s.toUpperCase()));
+  } catch { return new Set(); }
+}
+function writeBackPG(sc_id, pid) {
+  try { execSync(`psql "${DB}" -c "update sanderson_catalog set shopify_product_id='${pid}', on_shopify=true, imported_at=now() where id=${sc_id}"`, { stdio: 'ignore' }); return true; }
+  catch { return false; }
+}
+
+async function main() {
+  const payloads = loadJsonl('payloads.jsonl');
+  const liveSkus = await liveSandersonSkus();
+  const pgSkus = pgOnShopifySkus();
+  const created = loadJsonl('created.jsonl');
+  const createdSku = new Set(created.map(c => c.sku.toUpperCase()));
+  let todo = payloads.filter(p => !liveSkus.has(p.sku.toUpperCase())
+    && !createdSku.has(p.sku.toUpperCase()) && !pgSkus.has(p.sku.toUpperCase()));
+  if (LIMIT) todo = todo.slice(0, LIMIT);
+
+  const headroom = budget.remaining(BUDGET_CAT);
+  console.log(`Store ${STORE} · token …${TOKEN.slice(-4)} · payloads ${payloads.length} · live ${liveSkus.size} · pg-on ${pgSkus.size} · created ${created.length} · to create ${todo.length} · '${BUDGET_CAT}' headroom ${headroom} variants (~${Math.floor(headroom/2)} products) · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+  if (!APPLY) {
+    for (const o of todo.slice(0, 3)) console.log(`  [${o.sku}] "${o.product.title}" · Roll $${o.product.variants[0].price}/Sample $${o.product.variants[1].price} · mf:${o.metafields.length} · tags:${o.product.tags.split(', ').length} · img:${o.product.images.length}`);
+    console.log(`DRY-RUN. --apply (optionally --limit=N) to create drafts.`);
+    return;
+  }
+
+  const fd = fs.openSync(path.join(OUT, 'created.jsonl'), 'a');
+  let ok = 0, fail = 0, mfFail = 0, imgFail = 0, pgFail = 0;
+  for (const o of todo) {
+    const grant = budget.take(BUDGET_CAT, o.product.variants.length);
+    if (grant < o.product.variants.length) {
+      if (grant > 0) budget.refund(BUDGET_CAT, grant);
+      console.error(`\n⛔ '${BUDGET_CAT}' variant-budget exhausted today (granted ${grant}). Stop — resume tomorrow (idempotent).`);
+      break;
+    }
+    try {
+      const res = await rest('POST', '/products.json', { product: o.product });
+      if (res.status === 201 && res.body?.product?.id) {
+        const pid = res.body.product.id;
+        const gid = `gid://shopify/Product/${pid}`;
+        if (o.metafields.length) {
+          const d = await gql(M_MF, { mf: o.metafields.map(m => ({ ownerId: gid, namespace: m.namespace, key: m.key, type: m.type, value: m.value })) });
+          const ue = d?.metafieldsSet?.userErrors || d?.errors || [];
+          if (ue.length) { mfFail++; console.error(`  ⚠ mf ${o.sku}: ${JSON.stringify(ue).slice(0,160)}`); }
+        }
+        // images: the product create already URL-ingests o.product.images. Only base64-fallback
+        // if the create ingested NONE (avoids double-attach), and flag if we still have zero.
+        const imgWant = (o.product.images || []).length;
+        const createdImgs = (res.body.product.images || []).length;
+        if (imgWant && createdImgs === 0) { const got = await attachImages(pid, o.product.images); if (got < 1) imgFail++; }
+        else if (!imgWant) imgFail++;   // no image at all = 5-field violation; flagged
+        // PostgreSQL-first writeback
+        if (!writeBackPG(o.sc_id, pid)) pgFail++;
+        fs.writeSync(fd, JSON.stringify({ mfr_sku: o.mfr_sku, sku: o.sku, sc_id: o.sc_id, product_id: String(pid), settlement_verdict: o.settlement_verdict, retail: o.retail, status: 'draft', created_at: new Date().toISOString() }) + '\n');
+        ok++; if (ok % 20 === 0) console.log(`  ...created ${ok} (fail ${fail}, mfFail ${mfFail}, imgFail ${imgFail}, pgFail ${pgFail})`);
+      } else {
+        budget.refund(BUDGET_CAT, o.product.variants.length);   // create failed → return the reservation
+        const errStr = JSON.stringify(res.body).toLowerCase();
+        if (/exceed|limit|too many|maximum/.test(errStr) && /variant|product|day/.test(errStr)) {
+          console.error(`\n⛔ Shopify cap on ${o.sku}. Stop — resume tomorrow (idempotent).`); break;
+        }
+        fail++; console.error(`  FAIL ${o.sku}: HTTP ${res.status} ${JSON.stringify(res.body).slice(0,200)}`);
+      }
+      const used = parseInt((res.callLimit || '0/40').split('/')[0], 10);
+      await sleep(used > 30 ? 1700 : 600);
+    } catch (e) { budget.refund(BUDGET_CAT, o.product.variants.length); fail++; console.error(`  ERR ${o.sku}: ${String(e).slice(0,160)}`); await sleep(700); }
+  }
+  fs.closeSync(fd);
+  console.log(`\nDONE. created=${ok} failed=${fail} mfFail=${mfFail} imgFail=${imgFail} pgFail=${pgFail} of ${todo.length}`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/sanderson-onboard/go-live.mjs b/scripts/sanderson-onboard/go-live.mjs
new file mode 100644
index 0000000..2bc7c7a
--- /dev/null
+++ b/scripts/sanderson-onboard/go-live.mjs
@@ -0,0 +1,142 @@
+#!/usr/bin/env node
+/**
+ * Gated go-live for Sanderson drafts (reads out/created.jsonl). Models osborne go-live.mjs plus the
+ * two Task #3 gates:
+ *   (A) SETTLEMENT GATE — only products with settlement_verdict='CLEAR' activate. REVIEW-VISION / null
+ *       are skipped (need the per-image vision pass first) and logged to out/golive-skipped.jsonl.
+ *   (B) validateBeforeActivate (5-field + image) — before ACTIVE, the live product must have: a Sample
+ *       variant, a sellable Roll variant, complete pricing (Roll price > 0), a description, ≥2 tags,
+ *       and ≥1 image. Any miss → skip + log (never ship a non-compliant SKU; guards dw-five-field-canary).
+ *
+ * Per passing product: enable inventory tracking + on_hand on the SELLABLE Roll variant only (Sample
+ * stays no-inventory per spec), publish to all 12 DW sales channels EXCEPT Google, status = ACTIVE,
+ * then write status back to sanderson_catalog. Idempotent: skips products already ACTIVE.
+ *
+ *   node go-live.mjs                 # dry-run summary (counts CLEAR / gated)
+ *   node go-live.mjs --apply --limit=1   # canary
+ *   node go-live.mjs --apply             # trickle: activate every CLEAR+valid draft not yet live
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+const LOCATION_ID = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd.
+const TARGET_QTY = 2026;
+// Publish to all DW channels EXCEPT Google (29646651457 removed 2026-07-08: Shopify auto-syncs the
+// $4.25 sample as minVariantPrice → price_mismatch disapproval; Google is fed by the controlled TSV).
+const PUBLICATIONS = [
+  22208643184, 22497296496, /* 29646651457 Google-REMOVED */ 29739483201, 29776969793, 37904089153,
+  43657658419, 44234276915, 44317474867, 44317507635, 71898464307, 115856375859, 140027723827,
+].map(n => `gid://shopify/Publication/${n}`);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables = {}) {
+  for (let a = 0; a < 4; a++) {
+    try {
+      const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, { method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(45000) });
+      const j = await res.json();
+      if (j.errors) { if (a === 3) throw new Error(JSON.stringify(j.errors)); await sleep(900 * (a + 1)); continue; }
+      const rem = j.extensions?.cost?.throttleStatus?.currentlyAvailable;
+      if (rem !== undefined && rem < 250) await sleep(600);
+      return j.data;
+    } catch (e) { if (a === 3) throw e; await sleep(900 * (a + 1)); }
+  }
+}
+const Q_PROD = `query($id:ID!){ product(id:$id){ status descriptionHtml tags featuredImage{url}
+  mediaCount{count} variants(first:10){edges{node{ sku price inventoryItem{id} }}} } }`;
+const M_TRACK = `mutation($id:ID!){ inventoryItemUpdate(id:$id, input:{tracked:true}){ userErrors{message} } }`;
+const M_ACTIVATE = `mutation($iid:ID!,$loc:ID!){ inventoryActivate(inventoryItemId:$iid, locationId:$loc){ userErrors{message} } }`;
+const M_SETQTY = `mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{message code} } }`;
+const M_PUBLISH = `mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{message} } }`;
+const M_ACTIVE = `mutation($id:ID!){ productUpdate(input:{id:$id, status:ACTIVE}){ product{status} userErrors{message} } }`;
+
+// validateBeforeActivate — 5-field + image. Returns [] if OK, else list of failed fields.
+function validate(p) {
+  const fails = [];
+  const vs = (p.variants?.edges || []).map(e => e.node);
+  const sample = vs.find(v => /-sample$/i.test(v.sku || ''));
+  const sellable = vs.find(v => v && !/-sample$/i.test(v.sku || ''));
+  if (!sample) fails.push('sample-variant');
+  if (!sellable) fails.push('sellable-variant');
+  if (!sellable || !(Number(sellable.price) > 0)) fails.push('pricing');       // no $0/null on the sellable
+  if (!p.descriptionHtml || !p.descriptionHtml.replace(/<[^>]*>/g, '').trim()) fails.push('description');
+  if (!p.tags || p.tags.length < 2) fails.push('tags>=2');
+  if (!(p.featuredImage?.url) && !(p.mediaCount?.count > 0)) fails.push('image');
+  return { fails, sample, sellable };
+}
+
+async function goLive(rec) {
+  const gidP = `gid://shopify/Product/${rec.product_id}`;
+  // (A) settlement gate
+  if (rec.settlement_verdict !== 'CLEAR') return { gated: 'settlement:' + (rec.settlement_verdict || 'null') };
+  const d = await gql(Q_PROD, { id: gidP });
+  if (!d?.product) return { missing: true };
+  if (d.product.status === 'ACTIVE') return { skipped: true };
+  // (B) validateBeforeActivate
+  const { fails, sample, sellable } = validate(d.product);
+  if (fails.length) return { gated: 'validate:' + fails.join('+') };
+
+  const errs = [];
+  // inventory on the SELLABLE Roll variant only (sample stays no-inventory per spec)
+  const iid = sellable.inventoryItem?.id;
+  if (iid) {
+    let r = await gql(M_TRACK, { id: iid }); (r.inventoryItemUpdate?.userErrors || []).forEach(e => errs.push('track:' + e.message));
+    r = await gql(M_ACTIVATE, { iid, loc: LOCATION_ID });
+    (r.inventoryActivate?.userErrors || []).filter(e => !/already/i.test(e.message)).forEach(e => errs.push('activate:' + e.message));
+    const r2 = await gql(M_SETQTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true,
+      quantities: [{ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY }] } });
+    (r2.inventorySetQuantities?.userErrors || []).forEach(e => errs.push('setqty:' + e.message));
+  }
+  const r3 = await gql(M_PUBLISH, { id: gidP, pubs: PUBLICATIONS.map(p => ({ publicationId: p })) });
+  (r3.publishablePublish?.userErrors || []).forEach(e => errs.push('publish:' + e.message));
+  const r4 = await gql(M_ACTIVE, { id: gidP });
+  (r4.productUpdate?.userErrors || []).forEach(e => errs.push('active:' + e.message));
+  const status = r4.productUpdate?.product?.status;
+  // PG-first: on_shopify was set at create; nothing else to persist here (no status column on staging).
+  return { status, errs };
+}
+
+const loadJsonl = f => fs.existsSync(path.join(OUT, f))
+  ? fs.readFileSync(path.join(OUT, f), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)) : [];
+
+(async () => {
+  let todo = loadJsonl('created.jsonl');
+  const doneIds = new Set(loadJsonl('golive-done.jsonl').map(r => String(r.product_id)));
+  todo = todo.filter(r => !doneIds.has(String(r.product_id)));
+  const clear = todo.filter(r => r.settlement_verdict === 'CLEAR');
+  if (LIMIT) todo = todo.filter(r => r.settlement_verdict === 'CLEAR').slice(0, LIMIT);
+  console.log(`go-live: ${todo.length} candidates (${clear.length} CLEAR) · loc Ventura · qty ${TARGET_QTY} · ${PUBLICATIONS.length} channels (Google excluded) · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+  if (!APPLY) { console.log(`Would activate up to ${clear.length} CLEAR drafts (settlement + 5-field gated). DRY-RUN — --apply to execute.`); return; }
+
+  const fd = fs.openSync(path.join(OUT, 'golive-done.jsonl'), 'a');
+  const fg = fs.openSync(path.join(OUT, 'golive-skipped.jsonl'), 'a');
+  let ok = 0, skip = 0, gated = 0, withErr = 0, miss = 0;
+  for (const r of todo) {
+    try {
+      const res = await goLive(r);
+      if (res.missing) { miss++; console.error(`  ? ${r.sku}: product not found`); }
+      else if (res.gated) { gated++; fs.writeSync(fg, JSON.stringify({ sku: r.sku, product_id: r.product_id, reason: res.gated }) + '\n'); }
+      else if (res.skipped) { skip++; fs.writeSync(fd, JSON.stringify({ sku: r.sku, product_id: r.product_id }) + '\n'); }
+      else if (res.errs?.length) { withErr++; console.error(`  ⚠ ${r.sku} ${res.status||'?'}: ${res.errs.slice(0,3).join(' | ')}`); }
+      else { ok++; }
+      if (res.status === 'ACTIVE') fs.writeSync(fd, JSON.stringify({ sku: r.sku, product_id: r.product_id }) + '\n');
+      if ((ok+skip+gated+withErr+miss) % 20 === 0) console.log(`  ...${ok+skip+gated+withErr+miss}/${todo.length} (active=${ok} skip=${skip} gated=${gated} err=${withErr} miss=${miss})`);
+      await sleep(350);
+    } catch (e) { withErr++; console.error(`  ERR ${r.sku}: ${String(e).slice(0,160)}`); await sleep(600); }
+  }
+  fs.closeSync(fd); fs.closeSync(fg);
+  console.log(`\nDONE. active=${ok} already=${skip} gated=${gated} with-errors=${withErr} missing=${miss} of ${todo.length}`);
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/sanderson-onboard/resume.sh b/scripts/sanderson-onboard/resume.sh
new file mode 100755
index 0000000..fb879b0
--- /dev/null
+++ b/scripts/sanderson-onboard/resume.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Daily Sanderson onboard resume — models osborne resume.sh.
+#   1. CREATE remaining DRAFTs under the SHARED 'backlog' variant-budget slice (NEVER 'upload').
+#      create-drafts.mjs is idempotent (skips live + created.jsonl + pg on_shopify) + cap-clean
+#      (stops on 0 grant), so redundant runs are no-ops.
+#   2. TRICKLE GO-LIVE every run on whatever CLEAR+valid drafts exist (idempotent; skips ACTIVE).
+#      Activation is gated: settlement CLEAR only + validateBeforeActivate (5-field+image). This is
+#      the "trickle daily, do NOT same-day bulk-dump" go-live the task requires.
+# Fired by com.steve.sanderson-onboard-resume at 21:05 (end-of-day backlog reclaim) + 00:10 + 09:00
+# + 14:00 (retries share the same backlog slice, so total/day can't exceed the cap). Self-unloads
+# once every payload is created AND every CLEAR draft is live.
+set -uo pipefail
+cd "$(dirname "$0")"
+export PATH="/opt/homebrew/opt/postgresql@14/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
+LOG="out/resume.log"; mkdir -p out
+echo "=== $(date '+%F %T') sanderson resume ===" >> "$LOG"
+
+# shared kill-switch
+[ -f "$HOME/.dw-fixer-stop" ] && { echo "kill-switch present — skip" >> "$LOG"; exit 0; }
+
+TOTAL=$(wc -l < out/payloads.jsonl 2>/dev/null | tr -d ' ' || echo 0)
+CREATED=$(wc -l < out/created.jsonl 2>/dev/null | tr -d ' ' || echo 0)
+
+# 1. create remaining drafts (backlog-cap gated inside create-drafts.mjs)
+if [ "${CREATED:-0}" -lt "${TOTAL:-999999}" ]; then
+  echo "create step: created=$CREATED / total=$TOTAL" >> "$LOG"
+  bash run.sh create --apply >> "$LOG" 2>&1
+  CREATED=$(wc -l < out/created.jsonl 2>/dev/null | tr -d ' ' || echo 0)
+fi
+
+# 2. trickle go-live on CLEAR+valid drafts (idempotent, gated) — METERED at DAILY_GOLIVE_CAP/day
+#    so we never same-day bulk-dump the 224 CLEAR (task rule). A per-day counter makes the 09:00/
+#    14:00 runs true RETRIES (they top up toward the cap), not extra volume.
+#    ARM GATE: activation is customer-facing and stays Steve-gated. Go-live only runs when the arm
+#    file exists — `touch out/GOLIVE-ARMED` to start the metered trickle; `rm` it to pause. Until
+#    armed, drafts keep being created but NOTHING is published/activated.
+if [ ! -f out/GOLIVE-ARMED ]; then
+  echo "go-live DISARMED (no out/GOLIVE-ARMED) — creating drafts only, no activation. touch out/GOLIVE-ARMED to start." >> "$LOG"
+  echo "resume done (create-only): created $CREATED/$TOTAL" >> "$LOG"; exit 0
+fi
+DAILY_GOLIVE_CAP="${DAILY_GOLIVE_CAP:-15}"
+TODAY=$(date +%Y%m%d); CNT_FILE="out/golive-count-$TODAY.txt"
+done_today=$(cat "$CNT_FILE" 2>/dev/null || echo 0)
+remaining=$(( DAILY_GOLIVE_CAP - done_today ))
+if [ "${CREATED:-0}" -gt 0 ] && [ "$remaining" -gt 0 ]; then
+  echo "go-live trickle: cap $DAILY_GOLIVE_CAP/day, done_today=$done_today, this-run<=$remaining (settlement CLEAR + 5-field gated)" >> "$LOG"
+  gout=$(bash run.sh golive --apply --limit="$remaining" 2>&1); echo "$gout" >> "$LOG"
+  act=$(echo "$gout" | grep -oE 'active=[0-9]+' | tail -1 | cut -d= -f2)
+  echo $(( done_today + ${act:-0} )) > "$CNT_FILE"
+else
+  echo "go-live: skip (done_today=$done_today >= cap $DAILY_GOLIVE_CAP, or no drafts)" >> "$LOG"
+fi
+
+# self-unload when all created AND all CLEAR drafts are live
+CLEAR=$(grep -c '"settlement_verdict":"CLEAR"' out/created.jsonl 2>/dev/null || echo 0)
+GOLIVE=$(wc -l < out/golive-done.jsonl 2>/dev/null | tr -d ' ' || echo 0)
+if [ "${CREATED:-0}" -ge "${TOTAL:-999999}" ] && [ "${TOTAL:-0}" -gt 0 ] && [ "${GOLIVE:-0}" -ge "${CLEAR:-999999}" ]; then
+  echo "all $CREATED/$TOTAL created and $GOLIVE/$CLEAR CLEAR live — unloading job" >> "$LOG"
+  launchctl bootout "gui/$(id -u)/com.steve.sanderson-onboard-resume" 2>/dev/null || true
+fi
+echo "resume done: created $CREATED/$TOTAL · golive $GOLIVE/$CLEAR CLEAR" >> "$LOG"
diff --git a/scripts/sanderson-onboard/run.sh b/scripts/sanderson-onboard/run.sh
new file mode 100755
index 0000000..80b8dfb
--- /dev/null
+++ b/scripts/sanderson-onboard/run.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Wires Shopify creds (never hard-coded) and runs a Sanderson onboard step. Mirrors osborne run.sh.
+#   bash run.sh payloads
+#   bash run.sh create  [--apply] [--limit=N]
+#   bash run.sh golive  [--apply] [--limit=N]
+set -euo pipefail
+cd "$(dirname "$0")"
+export PATH="/opt/homebrew/opt/postgresql@14/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
+NODE="$(command -v node || true)"
+[ -z "$NODE" ] && { echo "FATAL: node not found in PATH ($PATH)"; exit 127; }
+SEC="$HOME/Projects/secrets-manager/.env"
+export SHOPIFY_STORE="designer-laboratory-sandbox.myshopify.com"
+export SHOPIFY_ADMIN_TOKEN="$(grep -hiE '^SHOPIFY_ADMIN_TOKEN=' "$SEC" | head -1 | cut -d= -f2- | tr -d '"'"'"' ')"
+step="${1:-}"; shift || true
+case "$step" in
+  payloads) exec "$NODE" build-payloads.mjs "$@";;
+  create)   [ -z "${SHOPIFY_ADMIN_TOKEN:-}" ] && { echo "no shopify token"; exit 1; }; exec "$NODE" create-drafts.mjs "$@";;
+  golive)   [ -z "${SHOPIFY_ADMIN_TOKEN:-}" ] && { echo "no shopify token"; exit 1; }; exec "$NODE" go-live.mjs "$@";;
+  *) echo "usage: bash run.sh {payloads|create|golive} [args]"; exit 1;;
+esac

← 7ec91f4 Stroheim onboard: ready-to-run DRAFT scripts (DWST, BUDGET_C  ·  back to Designerwallcoverings  ·  auto-save: 2026-07-26T16:44:36 (215 files) — scripts/sample- 1000bbf →