← back to Designerwallcoverings
auto-save: 2026-07-26T09:12:24 (2 files) — scripts/sample-split-pilot scripts/muralsource-onboard/
ebe40ffa3fd269bbbce0f5ac9b71791296fde2ab · 2026-07-26 09:12:26 -0700 · Steve Abrams
Files touched
A scripts/muralsource-onboard/.gitignoreA scripts/muralsource-onboard/build-payloads.mjsA scripts/muralsource-onboard/create-drafts.mjsA scripts/muralsource-onboard/go-live.mjsA scripts/muralsource-onboard/resume.shA scripts/muralsource-onboard/settlement-gate.mjs
Diff
commit ebe40ffa3fd269bbbce0f5ac9b71791296fde2ab
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 26 09:12:26 2026 -0700
auto-save: 2026-07-26T09:12:24 (2 files) — scripts/sample-split-pilot scripts/muralsource-onboard/
---
scripts/muralsource-onboard/.gitignore | 2 +
scripts/muralsource-onboard/build-payloads.mjs | 164 ++++++++++++++++++++++++
scripts/muralsource-onboard/create-drafts.mjs | 135 +++++++++++++++++++
scripts/muralsource-onboard/go-live.mjs | 152 ++++++++++++++++++++++
scripts/muralsource-onboard/resume.sh | 40 ++++++
scripts/muralsource-onboard/settlement-gate.mjs | 112 ++++++++++++++++
6 files changed, 605 insertions(+)
diff --git a/scripts/muralsource-onboard/.gitignore b/scripts/muralsource-onboard/.gitignore
new file mode 100644
index 0000000..4467388
--- /dev/null
+++ b/scripts/muralsource-onboard/.gitignore
@@ -0,0 +1,2 @@
+out/*.jsonl
+out/*.log
diff --git a/scripts/muralsource-onboard/build-payloads.mjs b/scripts/muralsource-onboard/build-payloads.mjs
new file mode 100644
index 0000000..752ac43
--- /dev/null
+++ b/scripts/muralsource-onboard/build-payloads.mjs
@@ -0,0 +1,164 @@
+#!/usr/bin/env node
+/**
+ * Build Mural Source DRAFT payloads from dw_unified.muralsource_catalog → out/payloads.jsonl.
+ * Mirrors maharam-onboard/build-payloads.mjs, but Mural Source is a PRICED line (real
+ * price_retail per single roll / mural set), so each product carries TWO variants — a
+ * sellable "Priced Per Single Roll" variant at price_retail AND a {DW_SKU}-Sample $4.25
+ * memo — exactly matching the 6 Wave-1 products already live on the store.
+ *
+ * vendor='Mural Source' drives the SMART collection `mural-source` (rule vendor EQUALS
+ * Mural Source), so activation alone auto-populates the (currently empty) collection.
+ *
+ * Eligible pool = NOT discontinued AND price_retail IS NOT NULL AND ≥1 image AND not
+ * already pushed (shopify_product_id IS NULL). Dedup is by mfr_sku. Every emitted title
+ * is title-cased, never contains "Wallpaper" (→ "Wallcovering") and never "Unknown".
+ *
+ * The settlement image-vision gate (Wave 2) is applied downstream: create-drafts.mjs
+ * refuses any SKU whose out/settlement-verdicts.jsonl verdict is BLOCK, and go-live.mjs
+ * activates only OK. This builder emits the full priced pool; the gate sorts it.
+ *
+ * node build-payloads.mjs # build payloads.jsonl + print first 3 + held counts
+ */
+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 BRAND = 'Mural Source';
+const SAMPLE_PRICE = '4.25';
+
+const titleCase = s => (s || '').replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase()).trim();
+const clean = s => (s || '').replace(/wall\s*paper/ig, 'Wallcovering').replace(/\s+/g, ' ').trim();
+const isBadWord = s => !s || /unknown/i.test(s);
+// ai_colors/ai_styles/ai_tags come back from json_agg(row_to_json()) as ALREADY-PARSED
+// arrays (JSONB); all_images may be a TEXT json string. Handle both.
+const jarr = v => { if (Array.isArray(v)) return v; if (v == null) return []; try { const p = JSON.parse(v); return Array.isArray(p) ? p : []; } catch { return []; } };
+
+function rows() {
+ const json = execSync(`psql "${DB}" -tAc "select coalesce(json_agg(row_to_json(t))::text,'[]') from (
+ select mfr_sku, dw_sku, pattern_name, color_name, collection, product_type,
+ width, length, repeat_v, repeat_h, material, fire_rating, finish, features, application,
+ price_retail, in_stock, discontinued, ai_description,
+ ai_colors, ai_styles, ai_tags,
+ image_url, all_images, product_url, shopify_product_id
+ from muralsource_catalog
+ where dw_sku is not null and mfr_sku is not null
+ order by dw_sku) t"`, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
+ return JSON.parse(json.trim());
+}
+
+function titleFor(r) {
+ let pat = titleCase(clean(r.pattern_name));
+ if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku)); // never "Unknown"/empty
+ const rawCol = clean(r.color_name);
+ const col = rawCol && !isBadWord(rawCol) && rawCol.toLowerCase() !== (r.pattern_name || '').toLowerCase()
+ ? titleCase(rawCol) : '';
+ return `${pat}${col ? ' ' + col : ''} | ${BRAND}`.replace(/\s+/g, ' ').trim();
+}
+
+// Faithful to the live 6: Brand + fixed + pattern + color + ai_colors[].name + ai_styles + ai_tags,
+// deduped case-insensitively, banned/noise words stripped. Always ≥2 tags.
+function tags(r) {
+ const out = new Map(); // lowercase → display
+ const add = v => { const s = titleCase(clean(String(v || ''))); if (s && !isBadWord(s) && !/^paper$/i.test(s) && !/wall\s*paper/i.test(s)) out.set(s.toLowerCase(), s); };
+ ['Mural Source', 'Wallcovering', 'Mural', 'display_variant'].forEach(t => out.set(t.toLowerCase(), t));
+ add(r.pattern_name); add(r.color_name);
+ for (const c of jarr(r.ai_colors)) add(c && c.name);
+ for (const s of jarr(r.ai_styles)) add(s);
+ for (const t of jarr(r.ai_tags)) { const s = String(t || ''); if (s.length <= 24) add(s); } // drop long material strings
+ return [...out.values()].join(', ');
+}
+function repeatStr(r) {
+ const v = (r.repeat_v && r.repeat_v !== '0') ? r.repeat_v : '';
+ const h = (r.repeat_h && r.repeat_h !== '0') ? r.repeat_h : '';
+ if (!v && !h) return '';
+ return [h && `H ${h}`, v && `V ${v}`].filter(Boolean).join(' · ');
+}
+function bodyHtml(r) {
+ let pat = titleCase(clean(r.pattern_name)); if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));
+ const intro = (r.ai_description && String(r.ai_description).trim().length >= 24)
+ ? clean(r.ai_description)
+ : `${pat} from ${BRAND} — a made-to-order scenic wallcovering mural. Request a $4.25 memo sample to see the material in hand before ordering the full mural set.`;
+ const rowsHtml = [
+ ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
+ ['Width', r.width], ['Length', r.length], ['Repeat', repeatStr(r)],
+ ['Finish', r.finish], ['Fire Rating', r.fire_rating], ['Application', r.application],
+ ['Manufacturer SKU', r.mfr_sku], ['Collection', new RegExp(BRAND, 'i').test(r.collection || '') ? '' : r.collection],
+ ].filter(([, v]) => v != null && String(v).trim() !== '' && !isBadWord(String(v)));
+ return `<p>${intro}</p><table>${rowsHtml.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${clean(String(v))}</td></tr>`).join('')}</table>`;
+}
+function metafields(r) {
+ const mf = [];
+ const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value))) mf.push({ namespace: ns, key, type, value: String(value) }); };
+ S('global', 'Brand', BRAND);
+ S('global', 'width', r.width);
+ S('global', 'length', r.length);
+ S('global', 'repeat', repeatStr(r));
+ S('global', 'unit_of_measure', 'Priced Per Single Roll');
+ S('global', 'lead_time', 'Usually in Stock');
+ S('custom', 'pattern_name', titleCase(clean(r.pattern_name)));
+ S('custom', 'supplier_name', BRAND);
+ S('custom', 'manufacturer_sku', r.mfr_sku);
+ S('custom', 'color', r.color_name);
+ S('custom', 'real_color_name', r.color_name);
+ S('custom', 'material', r.material, 'multi_line_text_field');
+ S('custom', 'product_class', 'Wallcovering');
+ S('dwc', 'manufacturer_sku', r.mfr_sku);
+ return mf;
+}
+function galleryUrls(r) {
+ const urls = new Set();
+ if (r.image_url) urls.add(r.image_url);
+ for (const u of jarr(r.all_images)) if (u) urls.add(u);
+ return [...urls];
+}
+
+function main() {
+ const data = rows();
+ const seen = new Set();
+ const fd = fs.openSync(path.join(OUT, 'payloads.jsonl'), 'w');
+ let n = 0, heldDisc = 0, heldNoPrice = 0, heldNoImg = 0, heldPushed = 0, dupe = 0;
+ for (const r of data) {
+ if (!r.mfr_sku || seen.has(r.mfr_sku)) { dupe++; continue; } // dedup by mfr_sku
+ seen.add(r.mfr_sku);
+ if (r.shopify_product_id) heldPushed++; // already live — still emit (stable plan; create-drafts dedups)
+ if (r.discontinued === true) { heldDisc++; continue; }
+ if (r.price_retail == null || Number(r.price_retail) <= 0) { heldNoPrice++; continue; }
+ const gallery = galleryUrls(r);
+ if (gallery.length === 0) { heldNoImg++; continue; } // ≥1 image required
+ const sku = r.dw_sku;
+ const price = Number(r.price_retail).toFixed(2);
+ const o = {
+ mfr_sku: r.mfr_sku, sku, dw_sku: r.dw_sku,
+ image_url: gallery[0], gallery,
+ product: {
+ title: titleFor(r), vendor: BRAND, product_type: 'Wallcovering',
+ status: 'draft', tags: tags(r), body_html: bodyHtml(r),
+ options: [{ name: 'Size' }],
+ variants: [
+ // Sellable roll variant at price_retail + $4.25 memo Sample. inventory_management:null
+ // at draft time; go-live enables tracking + on_hand qty on BOTH variants.
+ { sku, price, option1: 'Priced Per Single Roll', taxable: true, requires_shipping: true, inventory_policy: 'deny', inventory_management: null },
+ { sku: `${sku}-Sample`, price: SAMPLE_PRICE, option1: 'Sample', taxable: true, requires_shipping: true, inventory_policy: 'deny', inventory_management: null },
+ ],
+ },
+ metafields: metafields(r),
+ };
+ fs.writeSync(fd, JSON.stringify(o) + '\n'); n++;
+ }
+ fs.closeSync(fd);
+ console.log(`✓ ${n} payloads → out/payloads.jsonl`);
+ console.log(` emitted incl. ${heldPushed} already-live (create-drafts dedups) · held: discontinued ${heldDisc} · no-price ${heldNoPrice} · no-image ${heldNoImg} · dupe-mfr ${dupe}`);
+ const all = fs.readFileSync(path.join(OUT, 'payloads.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
+ for (const o of all.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} · imgs:${o.gallery.length}`);
+ const bad = all.filter(o => /wall\s*paper|unknown/i.test(o.product.title));
+ console.log(bad.length ? ` ⛔ ${bad.length} titles contain banned word(s): ${bad.slice(0,3).map(o=>o.product.title).join(' | ')}` : ` ✓ 0 titles contain "Wallpaper"/"Unknown"`);
+ const noImg = all.filter(o => !o.gallery || o.gallery.length === 0);
+ console.log(noImg.length ? ` ⛔ ${noImg.length} payloads have no image` : ` ✓ every payload has ≥1 image`);
+ const noWidth = all.filter(o => !o.metafields.some(m => m.namespace === 'global' && m.key === 'width'));
+ console.log(` ℹ ${noWidth.length} payloads lack global.width → will be HELD at go-live (Needs-Width) until backfilled`);
+}
+main();
diff --git a/scripts/muralsource-onboard/create-drafts.mjs b/scripts/muralsource-onboard/create-drafts.mjs
new file mode 100644
index 0000000..73345d3
--- /dev/null
+++ b/scripts/muralsource-onboard/create-drafts.mjs
@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+/**
+ * Create Mural Source products as DRAFT on the live store (designer-laboratory-sandbox),
+ * set metafields, attach + localize the image to the Shopify CDN, and — PostgreSQL-FIRST —
+ * write shopify_product_id + on_shopify back to muralsource_catalog the moment it's minted.
+ * Mirrors maharam-onboard/create-drafts.mjs; Mural Source is PRICED (2 variants: roll + sample).
+ *
+ * SETTLEMENT (Wave 2): only creates a draft for a SKU whose out/settlement-verdicts.jsonl
+ * verdict is OK. A BLOCK — or a SKU with no verdict yet — is SKIPPED (never gets a Shopify
+ * row). Run settlement-gate.mjs first (resume.sh does this ordering automatically).
+ *
+ * node create-drafts.mjs # DRY-RUN: show plan
+ * node create-drafts.mjs --apply --limit=2 # canary
+ * node create-drafts.mjs --apply # all remaining (resumable, backlog-cap aware)
+ *
+ * Cap-aware: draws from the shared daily variant budget under 'backlog' (NOT 'upload' — the
+ * new-arrivals uploader keeps its slice). Override: DW_MURALSOURCE_BUDGET.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import { execFileSync } from 'node:child_process';
+import { rest, gql, restAll, SHOP, TOKEN } from '../lib/shopify.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const require = createRequire(import.meta.url);
+const budget = require('../variant-budget/budget.cjs');
+const BUDGET_CAT = process.env.DW_MURALSOURCE_BUDGET || 'backlog';
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const PGENV = { ...process.env, PGHOST: '/tmp', PGDATABASE: 'dw_unified' };
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+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)) : [];
+
+// PG-FIRST write-back: stamp the real product_id + on_shopify onto the staging row AND
+// REGISTER the DWMS SKU in dw_sku_registry (status=draft) — the SKUs are pre-assigned in
+// staging but were never registered. go-live flips the registry row to status=active.
+function dbWriteBack(sku, mfrSku, pid) {
+ const q = sku.replace(/'/g, "''"), mq = String(mfrSku || '').replace(/'/g, "''");
+ try {
+ execFileSync(PSQL, ['-d', 'dw_unified', '-c',
+ `UPDATE muralsource_catalog SET shopify_product_id='${pid}', on_shopify=true, updated_at=NOW() WHERE dw_sku='${q}';
+ INSERT INTO dw_sku_registry (dw_sku, vendor_prefix, vendor_name, mfr_sku, shopify_product_id, status, min_order_qty, order_increment, created_at, updated_at)
+ VALUES ('${q}','DWMS','Mural Source','${mq}','${pid}','draft',1,1,NOW(),NOW())
+ ON CONFLICT (dw_sku) DO UPDATE SET shopify_product_id=EXCLUDED.shopify_product_id, updated_at=NOW();`],
+ { env: PGENV, stdio: 'ignore' });
+ } catch (e) { /* non-fatal — created.jsonl is durable; go-live re-registers too */ }
+}
+
+// localize: download the vendor hotlink, POST the bytes (base64) → Shopify re-hosts on its CDN.
+async function attachImage(pid, url, filename) {
+ if (!url) return 'no-url';
+ try {
+ const resp = await fetch(url, { signal: AbortSignal.timeout(30000) });
+ if (!resp.ok) return `dl-${resp.status}`;
+ const b64 = Buffer.from(await resp.arrayBuffer()).toString('base64');
+ const r = await rest(`/products/${pid}/images.json`, { method: 'POST', body: { image: { attachment: b64, filename } } });
+ return (r.status === 200 || r.status === 201) ? 'ok' : `up-${r.status}`;
+ } catch (e) { return 'err'; }
+}
+
+async function liveMuralSkus() {
+ try {
+ const prods = await restAll(`/products.json?vendor=${encodeURIComponent('Mural Source')}&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(); }
+}
+
+async function main() {
+ const payloads = loadJsonl('payloads.jsonl');
+ const verdict = new Map(loadJsonl('settlement-verdicts.jsonl').map(r => [r.sku.toUpperCase(), r.verdict]));
+ const liveSkus = await liveMuralSkus();
+ const created = loadJsonl('created.jsonl');
+ const createdSku = new Set(created.map(c => c.sku.toUpperCase()));
+
+ const gatedOk = payloads.filter(p => verdict.get(p.sku.toUpperCase()) === 'OK');
+ const blocked = payloads.filter(p => verdict.get(p.sku.toUpperCase()) === 'BLOCK').length;
+ const ungated = payloads.filter(p => !verdict.has(p.sku.toUpperCase())).length;
+ let todo = gatedOk.filter(p => !liveSkus.has(p.sku.toUpperCase()) && !createdSku.has(p.sku.toUpperCase()));
+ if (LIMIT) todo = todo.slice(0, LIMIT);
+
+ const headroom = budget.remaining(BUDGET_CAT);
+ console.log(`Store ${SHOP} · token …${TOKEN.slice(-4)} · payloads ${payloads.length} · settlement OK ${gatedOk.length} / BLOCK ${blocked} / ungated ${ungated} · live ${liveSkus.size} · to create ${todo.length} · '${BUDGET_CAT}' headroom ${headroom} variants (~${Math.floor(headroom/2)} products) · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+ if (ungated) console.log(` ℹ ${ungated} payloads not yet settlement-gated — run settlement-gate.mjs --apply first (they are skipped here).`);
+ 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} · imgs:${o.gallery.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;
+ for (const o of todo) {
+ const grant = budget.take(BUDGET_CAT, o.product.variants.length);
+ if (grant < o.product.variants.length) { if (grant) budget.refund(BUDGET_CAT, grant); console.error(`\n⛔ '${BUDGET_CAT}' budget exhausted today (granted ${grant}/${o.product.variants.length}). Stop — resume tomorrow (idempotent).`); break; }
+ try {
+ const res = await rest('/products.json', { method: 'POST', body: { product: o.product } });
+ const body = await res.json().catch(() => ({}));
+ if (res.status === 201 && body?.product?.id) {
+ const pid = body.product.id;
+ dbWriteBack(o.sku, o.mfr_sku, pid); // PG-FIRST + register
+ if (o.metafields.length) {
+ const gid = `gid://shopify/Product/${pid}`;
+ 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?.__err || [];
+ if (ue.length) { mfFail++; console.error(` ⚠ mf ${o.sku}: ${JSON.stringify(ue).slice(0,160)}`); }
+ }
+ let imgOk = 0;
+ for (let i = 0; i < o.gallery.length; i++) {
+ const res2 = await attachImage(pid, o.gallery[i], `${o.mfr_sku}_${i}.jpg`);
+ if (res2 === 'ok') imgOk++; else if (i === 0 && res2 !== 'no-url') console.error(` ⚠ img ${o.sku}: ${res2}`);
+ }
+ if (imgOk === 0) imgFail++;
+ fs.writeSync(fd, JSON.stringify({ mfr_sku: o.mfr_sku, sku: o.sku, product_id: String(pid), imgs: imgOk, status: 'draft', created_at: new Date().toISOString() }) + '\n');
+ ok++; if (ok % 20 === 0) console.log(` ...created ${ok} (fail ${fail}, mfFail ${mfFail}, imgFail ${imgFail})`);
+ } else {
+ budget.refund(BUDGET_CAT, o.product.variants.length); // creation failed → return the grant
+ const errStr = JSON.stringify(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.`); break; }
+ fail++; console.error(` FAIL ${o.sku}: HTTP ${res.status} ${JSON.stringify(body).slice(0,200)}`);
+ }
+ } 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} of ${todo.length}`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/muralsource-onboard/go-live.mjs b/scripts/muralsource-onboard/go-live.mjs
new file mode 100644
index 0000000..d7bf61c
--- /dev/null
+++ b/scripts/muralsource-onboard/go-live.mjs
@@ -0,0 +1,152 @@
+#!/usr/bin/env node
+/**
+ * Go-live (GATED trickle) for the Mural Source drafts created by create-drafts.mjs.
+ * For each draft (out/created.jsonl) this:
+ * 1. SETTLEMENT re-gate — activate only if out/settlement-verdicts.jsonl verdict is OK.
+ * 2. validateBeforeActivate (5/6-field) — width, description, ≥1 image, title guards,
+ * sample variant. On FAIL: keep DRAFT, add Needs-* tags, record in out/held.jsonl, skip.
+ * 3. Enable inventory tracking + activate at Ventura Blvd + on_hand=2026 on BOTH variants
+ * (roll + $4.25 sample) so the memo AND the mural are orderable.
+ * 4. Publish to the 12 DW sales channels — Google & YouTube EXCLUDED (per Steve's rule +
+ * GMC $4.25-leak protection; the sample variant would otherwise disapprove).
+ * 5. status = ACTIVE. vendor='Mural Source' → SMART collection `mural-source` auto-fills.
+ * 6. PG-first write-back: shopify_product_id + on_shopify on muralsource_catalog, and flip
+ * dw_sku_registry → active.
+ * Idempotent: skips products already ACTIVE or already in golive-done.jsonl.
+ *
+ * node go-live.mjs # dry-run summary
+ * node go-live.mjs --apply --limit=2 # canary
+ * node go-live.mjs --apply --limit=40 # trickle N/tick (metered cadence)
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import { execFileSync } from 'node:child_process';
+import { gql } from '../lib/shopify.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const require = createRequire(import.meta.url);
+const { validateBeforeActivate } = require('/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js');
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const PGENV = { ...process.env, PGHOST: '/tmp', PGDATABASE: 'dw_unified' };
+
+const LOCATION_ID = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd.
+const TARGET_QTY = 2026;
+// Google & YouTube (29646651457) EXCLUDED — the $4.25 sample variant → GMC price disapproval.
+const PUBLICATIONS = [
+ 22208643184, 22497296496, 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));
+
+const Q_VARIANTS = `query($id:ID!){ product(id:$id){ status handle variants(first:10){edges{node{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} } }`;
+const M_TAGS = `mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{message} } }`;
+
+const loadJsonl = file => {
+ const p = path.join(OUT, file);
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)) : [];
+};
+
+function dbWriteBack(sku, mfrSku, pid, handle) {
+ const q = sku.replace(/'/g, "''"), mq = String(mfrSku || '').replace(/'/g, "''");
+ const h = handle ? `'${handle.replace(/'/g, "''")}'` : 'NULL';
+ try {
+ execFileSync(PSQL, ['-d', 'dw_unified', '-c',
+ `UPDATE muralsource_catalog SET shopify_product_id='${pid}', on_shopify=true, updated_at=NOW() WHERE dw_sku='${q}';
+ INSERT INTO dw_sku_registry (dw_sku, vendor_prefix, vendor_name, mfr_sku, shopify_product_id, shopify_handle, status, min_order_qty, order_increment, created_at, updated_at)
+ VALUES ('${q}','DWMS','Mural Source','${mq}','${pid}',${h},'active',1,1,NOW(),NOW())
+ ON CONFLICT (dw_sku) DO UPDATE SET shopify_product_id=EXCLUDED.shopify_product_id, shopify_handle=COALESCE(EXCLUDED.shopify_handle, dw_sku_registry.shopify_handle), status='active', updated_at=NOW();`],
+ { env: PGENV, stdio: 'ignore' });
+ } catch (e) { /* non-fatal — Shopify write is authoritative; mirror re-syncs */ }
+}
+
+// Build the validateBeforeActivate shape straight from the payload the draft was minted from.
+function validateShape(o) {
+ const mf = (ns, k) => { const m = (o.metafields || []).find(x => x.namespace === ns && x.key === k); return m ? m.value : ''; };
+ const specs = { width: mf('global', 'width'), length: mf('global', 'length'), repeat: mf('global', 'repeat'),
+ material: mf('custom', 'material'), unitOfMeasure: mf('global', 'unit_of_measure') };
+ return {
+ title: o.product.title, vendor: o.product.vendor, tags: o.product.tags, dwSku: o.sku,
+ descriptionHtml: o.product.body_html,
+ specs, vendorSpecs: specs, // payload only carries specs the vendor actually had
+ images: o.gallery, vendorImages: o.gallery,
+ variants: o.product.variants,
+ };
+}
+
+async function goLive(pid, sku, mfrSku) {
+ const gidP = `gid://shopify/Product/${pid}`;
+ const errs = [];
+ const d = await gql(Q_VARIANTS, { id: gidP });
+ if (!d?.product) return { missing: true };
+ if (d.product.status === 'ACTIVE') { dbWriteBack(sku, mfrSku, pid, d.product.handle); return { skipped: true }; }
+ const items = d.product.variants.edges.map(e => e.node.inventoryItem.id);
+ for (const iid of items) {
+ 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: items.map(iid => ({ 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;
+ if (status === 'ACTIVE') dbWriteBack(sku, mfrSku, pid, d.product.handle);
+ return { status, errs };
+}
+
+(async () => {
+ const done = new Set(loadJsonl('golive-done.jsonl').map(r => r.sku));
+ const heldAlready = new Set(loadJsonl('held.jsonl').map(r => r.sku));
+ const verdict = new Map(loadJsonl('settlement-verdicts.jsonl').map(r => [r.sku.toUpperCase(), r.verdict]));
+ const payload = new Map(loadJsonl('payloads.jsonl').map(o => [o.sku.toUpperCase(), o]));
+ let todo = loadJsonl('created.jsonl').filter(r => !done.has(r.sku));
+ if (LIMIT) todo = todo.slice(0, LIMIT);
+ console.log(`go-live: ${todo.length} drafts · loc ${LOCATION_ID} · qty ${TARGET_QTY} · ${PUBLICATIONS.length} channels (Google excluded) · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+ if (!APPLY) { console.log('first 3:', todo.slice(0, 3).map(r => r.sku).join(', ')); console.log('DRY-RUN. --apply to execute.'); return; }
+
+ const fdDone = fs.openSync(path.join(OUT, 'golive-done.jsonl'), 'a');
+ const fdHeld = fs.openSync(path.join(OUT, 'held.jsonl'), 'a');
+ let ok = 0, skip = 0, withErr = 0, miss = 0, held = 0;
+ for (const r of todo) {
+ try {
+ const gidP = `gid://shopify/Product/${r.product_id}`;
+ // 1) settlement re-gate
+ if (verdict.get(r.sku.toUpperCase()) !== 'OK') {
+ if (!heldAlready.has(r.sku)) { fs.writeSync(fdHeld, JSON.stringify({ sku: r.sku, product_id: r.product_id, reason: `settlement:${verdict.get(r.sku.toUpperCase()) || 'ungated'}` }) + '\n'); heldAlready.add(r.sku); }
+ held++; console.error(` ⏸ HOLD ${r.sku}: settlement ${verdict.get(r.sku.toUpperCase()) || 'ungated'}`); continue;
+ }
+ // 2) validateBeforeActivate
+ const o = payload.get(r.sku.toUpperCase());
+ const gate = o ? validateBeforeActivate(validateShape(o)) : { ok: false, reasons: ['no payload'], tags: [] };
+ if (!gate.ok) {
+ if (gate.tags.length) await gql(M_TAGS, { id: gidP, tags: gate.tags }); // flag Needs-* on the DRAFT
+ if (!heldAlready.has(r.sku)) { fs.writeSync(fdHeld, JSON.stringify({ sku: r.sku, product_id: r.product_id, reason: gate.reasons.join('; '), tags: gate.tags }) + '\n'); heldAlready.add(r.sku); }
+ held++; console.error(` ⏸ HOLD ${r.sku}: ${gate.reasons.join('; ')}`); continue;
+ }
+ // 3-6) activate
+ const res = await goLive(r.product_id, r.sku, r.mfr_sku);
+ if (res.missing) { miss++; console.error(` ? ${r.sku}: product not found`); }
+ else if (res.skipped) skip++;
+ else if (res.errs?.length) { withErr++; console.error(` ⚠ ${r.sku} ${res.status || '?'}: ${res.errs.slice(0, 3).join(' | ')}`); }
+ else { ok++; }
+ if (res.status === 'ACTIVE' || res.skipped) fs.writeSync(fdDone, JSON.stringify({ sku: r.sku, product_id: r.product_id }) + '\n');
+ if ((ok + skip + withErr + miss + held) % 20 === 0) console.log(` ...${ok + skip + withErr + miss + held}/${todo.length} (active=${ok} skip=${skip} held=${held} 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(fdDone); fs.closeSync(fdHeld);
+ console.log(`\nDONE. active=${ok} skipped=${skip} held=${held} with-errors=${withErr} missing=${miss} of ${todo.length}`);
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/muralsource-onboard/resume.sh b/scripts/muralsource-onboard/resume.sh
new file mode 100755
index 0000000..3041cd3
--- /dev/null
+++ b/scripts/muralsource-onboard/resume.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+# Mural Source onboard — daily TRICKLE driver (idempotent, cap-clean, self-unloading).
+# Modeled on the greenland-onboard-resume pattern. One tick =
+# 1. build-payloads — refresh the plan from muralsource_catalog (picks up backfilled width etc.)
+# 2. settlement-gate — Gemini-vision gate any not-yet-gated image (Wave-2 legal gate; metered)
+# 3. create-drafts — mint settlement-OK drafts as DRAFT (draws the shared 'backlog' variant slice)
+# 4. go-live — activate up to GOLIVE_LIMIT/tick of the validated, settlement-OK drafts
+# (validateBeforeActivate 5-field + publish all channels EXCEPT Google)
+# When every priced/OK SKU is live (nothing left to create AND nothing left to go-live),
+# it unloads its own launchd job so it stops firing.
+set -uo pipefail
+cd "$(dirname "$0")" || exit 1
+export PATH="/opt/homebrew/bin:/opt/homebrew/opt/postgresql@14/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
+NODE="$(command -v node)"
+GOLIVE_LIMIT="${DW_MURALSOURCE_GOLIVE_LIMIT:-40}" # metered cadence — activations per tick
+LABEL="com.steve.muralsource-onboard-resume"
+ts() { date '+%Y-%m-%dT%H:%M:%S'; }
+
+echo "===== $(ts) muralsource resume tick (go-live limit ${GOLIVE_LIMIT}) ====="
+"$NODE" build-payloads.mjs
+# gate any ungated payloads (idempotent — skips already-gated). Metered Gemini; safe/local.
+"$NODE" settlement-gate.mjs --apply
+# create settlement-OK drafts up to the daily 'backlog' variant budget (self-stops when exhausted)
+"$NODE" create-drafts.mjs --apply
+# trickle activation of validated, settlement-OK drafts
+"$NODE" go-live.mjs --apply --limit="${GOLIVE_LIMIT}"
+
+# ---- self-unload when done: no OK draft left uncreated AND no created draft left un-live ----
+CREATED=$(wc -l < out/created.jsonl 2>/dev/null | tr -d ' ' || echo 0)
+GOLIVE=$(wc -l < out/golive-done.jsonl 2>/dev/null | tr -d ' ' || echo 0)
+HELD=$(wc -l < out/held.jsonl 2>/dev/null | tr -d ' ' || echo 0)
+OK_TOTAL=$("$NODE" -e 'const fs=require("fs");const v=fs.existsSync("out/settlement-verdicts.jsonl")?fs.readFileSync("out/settlement-verdicts.jsonl","utf8").trim().split("\n").filter(Boolean).map(l=>JSON.parse(l)):[];console.log(v.filter(r=>r.verdict==="OK").length)' 2>/dev/null || echo 0)
+UNGATED=$("$NODE" -e 'const fs=require("fs");const p=fs.readFileSync("out/payloads.jsonl","utf8").trim().split("\n").map(l=>JSON.parse(l));const g=fs.existsSync("out/settlement-verdicts.jsonl")?new Set(fs.readFileSync("out/settlement-verdicts.jsonl","utf8").trim().split("\n").filter(Boolean).map(l=>JSON.parse(l).sku.toUpperCase())):new Set();console.log(p.filter(o=>!g.has(o.sku.toUpperCase())).length)' 2>/dev/null || echo 999)
+echo "$(ts) state: created=${CREATED} golive=${GOLIVE} held=${HELD} settlementOK=${OK_TOTAL} ungated=${UNGATED}"
+# Done iff everything is gated, every OK is created, and every created is live-or-held.
+if [ "${UNGATED}" = "0" ] && [ "${CREATED}" -ge "${OK_TOTAL}" ] && [ "$((GOLIVE + HELD))" -ge "${CREATED}" ]; then
+ echo "$(ts) ✅ all Mural Source OK SKUs live (created=${CREATED} golive=${GOLIVE} held=${HELD}) — unloading ${LABEL}"
+ launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || launchctl unload "$HOME/Library/LaunchAgents/${LABEL}.plist" 2>/dev/null
+fi
+echo "===== $(ts) tick done ====="
diff --git a/scripts/muralsource-onboard/settlement-gate.mjs b/scripts/muralsource-onboard/settlement-gate.mjs
new file mode 100644
index 0000000..e825d97
--- /dev/null
+++ b/scripts/muralsource-onboard/settlement-gate.mjs
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+/**
+ * Wave-2 settlement image-vision gate for Mural Source.
+ * Runs the settlement post-gen-vision check (Gemini 2.0 Flash) over each payload's
+ * primary image and writes out/settlement-verdicts.jsonl: { sku, verdict, a1..b, reason }.
+ *
+ * Binding combinator (settlement-verdict, defendant-favorable, fail-closed):
+ * BLOCK iff (A1 ∧ A2 ∧ A3 ∧ B) OR any of {a1,a2,a3,b} is unknown/unparseable.
+ * OK otherwise (Part A only, partial A, or non-tropical).
+ * A BLOCK never gets a Shopify row (create-drafts skips it); OK is eligible for go-live.
+ *
+ * Idempotent: skips any sku already in settlement-verdicts.jsonl.
+ * Metered: ~$0.0006 / image (Gemini 2.0 Flash vision). Cost printed per-batch.
+ *
+ * node settlement-gate.mjs # DRY: show how many need a verdict
+ * node settlement-gate.mjs --apply # gate all ungated payloads
+ * node settlement-gate.mjs --apply --limit=2
+ * node settlement-gate.mjs --apply --sku=DWMS-320001,DWMS-320002
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const ONLY = (process.argv.find(a => a.startsWith('--sku=')) || '').split('=')[1];
+const ONLY_SET = ONLY ? new Set(ONLY.split(',').map(s => s.trim().toUpperCase())) : null;
+const COST_PER_IMG = 0.0011;
+const MODEL = 'gemini-2.5-flash';
+
+const KEY = (fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8')
+ .match(/^GEMINI_API_KEY=(.+)$/m) || [])[1]?.trim();
+if (!KEY) { console.error('no GEMINI_API_KEY in ~/Projects/secrets-manager/.env'); process.exit(1); }
+const URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+
+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)) : [];
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+const PROMPT = `You are a legal image auditor applying a narrow trademark SETTLEMENT to a wallcovering image.
+Answer ONLY these five yes/no questions about the LEAF / FOLIAGE layer of the pattern, then return strict JSON.
+A1 (directional_leaves): Do leaves, palm fronds, or similar foliage point in MORE THAN ONE orientation across the repeat (directional variation), rather than all one way?
+A2 (open_space): Is there visible OPEN NEGATIVE SPACE between the foliage motifs (not edge-to-edge coverage)?
+A3 (multiple_colors): Does the leaf/foliage layer use MORE THAN ONE color (excluding the background)?
+B (prohibited): Does the image contain any of these SPECIFIC elements: bananas, banana pods/flowers, grapes, birds, or butterflies?
+Acceptable (carveout): Does it clearly contain tree trunks, clearly-drawn branches, OR a fruit/animal OTHER THAN bananas/grapes/birds/butterflies?
+Respond with ONLY this JSON, booleans true/false (never null), plus a 3-6 word evidence string each:
+{"a1":bool,"a2":bool,"a3":bool,"b":bool,"acceptable":bool,"evidence":{"a1":"","a2":"","a3":"","b":"","acceptable":""}}`;
+
+async function fetchB64(url) {
+ const r = await fetch(url, { signal: AbortSignal.timeout(30000) });
+ if (!r.ok) throw new Error(`img ${r.status}`);
+ const ct = r.headers.get('content-type') || 'image/jpeg';
+ const buf = Buffer.from(await r.arrayBuffer());
+ return { mime: ct.split(';')[0], data: buf.toString('base64') };
+}
+
+async function gemini(b64) {
+ const body = { contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: b64.mime, data: b64.data } }] }],
+ generationConfig: { temperature: 0, responseMimeType: 'application/json' } };
+ for (let t = 0; t < 4; t++) {
+ const r = await fetch(URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(45000) });
+ if (r.status === 429 || r.status >= 500) { await sleep(1500 * (t + 1)); continue; }
+ const j = await r.json();
+ const txt = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+ try { return JSON.parse(txt); } catch { return null; }
+ }
+ return null;
+}
+
+// defendant-favorable, fail-closed
+function verdict(v) {
+ if (!v) return { verdict: 'BLOCK', reason: 'vision unparseable → fail closed (manual review)' };
+ const b = k => v[k] === true;
+ const unknown = ['a1', 'a2', 'a3', 'b'].some(k => typeof v[k] !== 'boolean');
+ if (unknown) return { verdict: 'BLOCK', reason: 'unknown vision input → fail closed', ...v };
+ if (b('a1') && b('a2') && b('a3') && b('b'))
+ return { verdict: 'BLOCK', reason: 'full settlement violation — Part A + Part B', ...v };
+ return { verdict: 'OK', reason: 'no violation (Part B absent or Part A partial)', ...v };
+}
+
+async function main() {
+ const payloads = loadJsonl('payloads.jsonl');
+ const done = new Map(loadJsonl('settlement-verdicts.jsonl').map(r => [r.sku.toUpperCase(), r]));
+ let todo = payloads.filter(p => !done.has(p.sku.toUpperCase()));
+ if (ONLY_SET) todo = todo.filter(p => ONLY_SET.has(p.sku.toUpperCase()));
+ if (LIMIT) todo = todo.slice(0, LIMIT);
+
+ console.log(`settlement-gate · model ${MODEL} · payloads ${payloads.length} · already-gated ${done.size} · to gate ${todo.length} · est $${(todo.length * COST_PER_IMG).toFixed(4)} · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+ if (!APPLY) { console.log(` first 3: ${todo.slice(0, 3).map(p => p.sku).join(', ')}`); console.log('DRY-RUN. --apply to gate.'); return; }
+
+ const fd = fs.openSync(path.join(OUT, 'settlement-verdicts.jsonl'), 'a');
+ let ok = 0, block = 0, err = 0, spent = 0;
+ for (const p of todo) {
+ let rec;
+ try {
+ const b64 = await fetchB64(p.image_url);
+ const v = await gemini(b64); spent += COST_PER_IMG;
+ rec = { sku: p.sku, ...verdict(v), gated_at: new Date().toISOString() };
+ } catch (e) { rec = { sku: p.sku, verdict: 'BLOCK', reason: `error: ${String(e).slice(0, 60)} → fail closed`, gated_at: new Date().toISOString() }; err++; }
+ fs.writeSync(fd, JSON.stringify(rec) + '\n');
+ if (rec.verdict === 'OK') ok++; else block++;
+ if ((ok + block) % 20 === 0) console.log(` ...${ok + block}/${todo.length} (OK ${ok}, BLOCK ${block}, err ${err}) · $${spent.toFixed(4)}`);
+ await sleep(120);
+ }
+ fs.closeSync(fd);
+ console.log(`\nDONE. OK=${ok} BLOCK=${block} err=${err} of ${todo.length} · actual ~$${spent.toFixed(4)} (Gemini ${MODEL} vision)`);
+ if (block) console.log(` ⚠ ${block} BLOCK/held — will NOT be created/activated; review out/settlement-verdicts.jsonl (grep BLOCK).`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
← ba9fbe3 maharam-onboard: knoll-model sample-only pipeline (1429 wall
·
back to Designerwallcoverings
·
auto-save: 2026-07-26T13:13:36 (2 files) — scripts/greenland c0f5a0a →