[object Object]

← 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

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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[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 →