← 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
A scripts/sanderson-onboard/STATUS.mdA scripts/sanderson-onboard/build-payloads.mjsA scripts/sanderson-onboard/com.steve.sanderson-onboard-resume.plistA scripts/sanderson-onboard/create-drafts.mjsA scripts/sanderson-onboard/go-live.mjsA scripts/sanderson-onboard/resume.shA scripts/sanderson-onboard/run.sh
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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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 →