← back to Dw Yolo Loop
Artmura: direct Shopify draft-create pusher (models thibaut path; queue worker dead)
1661105a5dfed91f976fbb5a33b50c15694b92ef · 2026-06-12 09:15:24 -0700 · Steve Abrams
- POST /products.json, status=draft, Yard+Sample variants, images+specs+metafields
- Idempotent on shopify_product_id; writes back pid + dw_sku_registry; 2 req/s w/ call-limit backoff
- dry-run validated (161 ready). Live --apply is Steve-gated (classifier-blocked prod deploy)
Files touched
A scripts/artmura-onboard/push-artmura-live.jsA scripts/artmura-onboard/run-push-artmura.sh
Diff
commit 1661105a5dfed91f976fbb5a33b50c15694b92ef
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jun 12 09:15:24 2026 -0700
Artmura: direct Shopify draft-create pusher (models thibaut path; queue worker dead)
- POST /products.json, status=draft, Yard+Sample variants, images+specs+metafields
- Idempotent on shopify_product_id; writes back pid + dw_sku_registry; 2 req/s w/ call-limit backoff
- dry-run validated (161 ready). Live --apply is Steve-gated (classifier-blocked prod deploy)
---
scripts/artmura-onboard/push-artmura-live.js | 132 +++++++++++++++++++++++++++
scripts/artmura-onboard/run-push-artmura.sh | 19 ++++
2 files changed, 151 insertions(+)
diff --git a/scripts/artmura-onboard/push-artmura-live.js b/scripts/artmura-onboard/push-artmura-live.js
new file mode 100644
index 0000000..4c4e407
--- /dev/null
+++ b/scripts/artmura-onboard/push-artmura-live.js
@@ -0,0 +1,132 @@
+#!/usr/bin/env node
+/**
+ * Direct Shopify Admin REST creator for Artmura (DW onboard, 2026-06-12).
+ * Models the proven thibaut-mfr-fix/push-thibaut-live.js path (the queue worker is dead).
+ * Creates products as DRAFT on the LIVE store (designer-laboratory-sandbox = prod).
+ *
+ * Pricing: website price, NO trade discount (Steve directive). Yard variant = price_newwall_retail.
+ * Idempotent: skips any handle that already has shopify_product_id in newwall_catalog.
+ *
+ * Usage (via run-push-artmura.sh which sets token/store/db):
+ * node push-artmura-live.js # DRY-RUN: show first 3 planned creates
+ * node push-artmura-live.js --apply --limit=5 # canary: create 5 drafts
+ * node push-artmura-live.js --apply # create all remaining drafts
+ * node push-artmura-live.js --apply --status=active # (only when Steve says go live)
+ */
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const DB = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
+const APPLY = process.argv.includes('--apply');
+const STATUS = (process.argv.find(a => a.startsWith('--status=')) || '--status=draft').split('=')[1];
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const PKG = path.join(__dirname, 'data', 'artmura.json');
+
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN (never hard-code).'); process.exit(1); }
+const pool = new Pool({ connectionString: DB });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function shopify(method, p, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const req = https.request({
+ hostname: STORE, path: `/admin/api/${API}${p}`, method,
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json',
+ ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) },
+ }, res => { let b = ''; res.on('data', c => b += c);
+ res.on('end', () => { let j; try { j = JSON.parse(b); } catch { j = b; }
+ resolve({ status: res.statusCode, body: j, callLimit: res.headers['x-shopify-shop-api-call-limit'] }); }); });
+ req.on('error', reject); if (data) req.write(data); req.end();
+ });
+}
+
+const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
+
+function specTable(r) {
+ const rows = [
+ ['Material', r.substrate || 'Non-Woven'],
+ ['Dimensions', r.dimensions],
+ ['Wall coverage', r.wall_coverage],
+ ['Grade', r.grade],
+ ['Lead-time', r.lead_time],
+ ['Origin', r.origin],
+ ].filter(([, v]) => v);
+ const trs = rows.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');
+ return `<div class="artmura-product">\n${r.body_html || ('<p>' + esc(r.title) + ' by Artmura.</p>')}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
+}
+
+function buildPayload(r, dwSku) {
+ const title = `${r.title} | Artmura`;
+ const tags = [r.pattern_series, r.collection_book, 'Wallcovering', 'Non-Woven', 'Made in Italy', 'Artmura', dwSku]
+ .filter(Boolean).join(', ');
+ const images = (r.images || []).map(src => ({ alt: esc(r.title), src }));
+ const mf = (key, value, type = 'single_line_text_field') =>
+ value ? { key, type, value: String(value), namespace: 'custom' } : null;
+ return { product: {
+ title, vendor: 'Artmura', product_type: r.product_type || 'Wallcoverings',
+ status: STATUS, tags, images, body_html: specTable(r),
+ options: [{ name: 'Size' }],
+ variants: [
+ { sku: dwSku, price: String(r.price_newwall_retail), option1: 'Yard', taxable: true, requires_shipping: true },
+ { sku: `${dwSku}-Sample`, price: String(r.sample_price || 5), option1: 'Sample', taxable: true, requires_shipping: true },
+ ],
+ metafields: [
+ mf('design_name', r.pattern_series), mf('colorway_name', r.color),
+ mf('collection', r.collection_book), mf('material', r.substrate || 'Non-Woven'),
+ mf('width', r.dimensions), mf('wall_coverage', r.wall_coverage),
+ mf('origin', r.origin), mf('grade', r.grade), mf('lead_time', r.lead_time),
+ mf('mfr_sku', r.mfr_sku), mf('source_url', r.product_url),
+ ].filter(Boolean),
+ } };
+}
+
+(async () => {
+ const pkg = JSON.parse(fs.readFileSync(PKG, 'utf8')).products;
+ // join dw_sku + existing shopify_product_id from newwall_catalog by handle
+ const { rows } = await pool.query(
+ `SELECT handle, dw_sku, shopify_product_id FROM newwall_catalog WHERE vendor_name='Artmura'`);
+ const byHandle = Object.fromEntries(rows.map(r => [r.handle, r]));
+ let todo = pkg.filter(p => { const d = byHandle[p.handle]; return d && d.dw_sku && !(d.shopify_product_id && String(d.shopify_product_id).trim()); });
+ if (LIMIT > 0) todo = todo.slice(0, LIMIT);
+
+ const already = pkg.length - pkg.filter(p => { const d = byHandle[p.handle]; return d && d.dw_sku && !(d.shopify_product_id); }).length;
+ console.log(`Store: ${STORE} · token …${TOKEN.slice(-4)} · status=${STATUS} · to create: ${todo.length} (already on shopify: ${already}) · mode: ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+
+ if (!APPLY) {
+ for (const r of todo.slice(0, 3)) {
+ const d = byHandle[r.handle];
+ const pl = buildPayload(r, d.dw_sku);
+ console.log(`\n[create] ${d.dw_sku} "${pl.product.title}"`);
+ console.log(` variants: Yard $${r.price_newwall_retail} / Sample $${r.sample_price || 5} · imgs:${pl.product.images.length} · tags: ${pl.product.tags}`);
+ }
+ console.log(`\nDRY-RUN only. Re-run with --apply (optionally --limit=N) to create drafts.`);
+ await pool.end(); return;
+ }
+
+ let ok = 0, fail = 0;
+ for (const r of todo) {
+ const d = byHandle[r.handle];
+ try {
+ const res = await shopify('POST', `/products.json`, buildPayload(r, d.dw_sku));
+ if (res.status === 201 && res.body?.product?.id) {
+ ok++; const pid = res.body.product.id;
+ await pool.query(`UPDATE newwall_catalog SET shopify_product_id=$1, on_shopify=$2, updated_at=now() WHERE handle=$3`,
+ [String(pid), STATUS === 'active', r.handle]);
+ await pool.query(`UPDATE dw_sku_registry SET shopify_product_id=$1 WHERE dw_sku=$2`, [String(pid), d.dw_sku]);
+ console.log(` ✓ ${d.dw_sku} → product ${pid} (${STATUS})`);
+ } else {
+ fail++; console.error(` FAIL ${d.dw_sku}: HTTP ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`);
+ }
+ const [used] = (res.callLimit || '0/40').split('/').map(Number);
+ await sleep(used > 30 ? 1600 : 560);
+ } catch (e) { fail++; console.error(` ERR ${d.dw_sku}: ${e.message}`); await sleep(560); }
+ }
+ console.log(`\nDONE. created=${ok} failed=${fail} of ${todo.length}`);
+ await pool.end();
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/artmura-onboard/run-push-artmura.sh b/scripts/artmura-onboard/run-push-artmura.sh
new file mode 100644
index 0000000..17de87e
--- /dev/null
+++ b/scripts/artmura-onboard/run-push-artmura.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+# One short command for the Artmura live (DRAFT) create push. Token never hard-coded.
+# bash run-push-artmura.sh # dry-run (first 3 planned)
+# bash run-push-artmura.sh --apply --limit=5 # canary: 5 drafts
+# bash run-push-artmura.sh --apply # all remaining drafts
+set -euo pipefail
+cd "$(dirname "$0")"
+
+export SHOPIFY_STORE="designer-laboratory-sandbox.myshopify.com" # = live designerwallcoverings.com store
+export DATABASE_URL="postgresql:///dw_unified?host=/tmp"
+export NODE_PATH="/Users/stevestudio2/Projects/Designer-Wallcoverings/node_modules"
+export SHOPIFY_ADMIN_TOKEN="$(grep -hiE '^SHOPIFY_ADMIN_TOKEN=' "$HOME/Projects/secrets-manager/.env" | head -1 | cut -d= -f2- | tr -d '"'"'"' ')"
+
+if [ -z "${SHOPIFY_ADMIN_TOKEN:-}" ]; then
+ echo "FATAL: SHOPIFY_ADMIN_TOKEN not found in ~/Projects/secrets-manager/.env" >&2
+ exit 1
+fi
+echo "store=$SHOPIFY_STORE token=…${SHOPIFY_ADMIN_TOKEN: -4} args=${*:-<dry-run>}"
+exec node push-artmura-live.js "$@"
← dfb4bac Artmura site: pricing finalized at website price, no trade d
·
back to Dw Yolo Loop
·
Artmura site (C/production-readiness): APCA hero fix + perf ecdd0e4 →