[object Object]

← back to Majilite Jewelry Cases

Add --all render mode + gated dry-run Shopify gallery push script

8205b49cbb02a2b16ceeefede949d99a3065ef4d · 2026-08-10 14:38:03 -0700 · Steve Abrams

Files touched

Diff

commit 8205b49cbb02a2b16ceeefede949d99a3065ef4d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 14:38:03 2026 -0700

    Add --all render mode + gated dry-run Shopify gallery push script
---
 .gitignore               |  1 +
 scripts/gen-batch.mjs    |  6 ++--
 scripts/push-shopify.mjs | 86 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 91 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index 9d89c04..ff5e4b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ tmp/
 data/dump.err
 data/contact-*.jpg
 data/batch*.log
+data/shopify-map.json
diff --git a/scripts/gen-batch.mjs b/scripts/gen-batch.mjs
index 3a4f834..f63dd4d 100644
--- a/scripts/gen-batch.mjs
+++ b/scripts/gen-batch.mjs
@@ -57,10 +57,12 @@ async function genOne(prompt, swatchUrl) {
 async function main() {
   const all = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/items.json'), 'utf8'));
   // union: metallic (any vendor) + all Novasuede; dedupe by sku
+  const ALL = process.argv.includes('--all');
   const seen = new Set(), items = [];
   for (const x of all) {
-    if (seen.has(x.sku)) continue;
-    if (METAL_RX.test(x.title) || x.vendor === 'Novasuede') { seen.add(x.sku); items.push(x); }
+    const key = x.sku || x.title;
+    if (seen.has(key)) continue;
+    if (ALL || METAL_RX.test(x.title) || x.vendor === 'Novasuede') { seen.add(key); items.push(x); }
   }
   console.log(`Target items: ${items.length}  (metallics + all Novasuede, 1 render each)`);
 
diff --git a/scripts/push-shopify.mjs b/scripts/push-shopify.mjs
new file mode 100644
index 0000000..916598a
--- /dev/null
+++ b/scripts/push-shopify.mjs
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+// GATED: attach generated jewelry-case renders as product-gallery media on the LIVE DW Shopify store.
+// DRY-RUN by default. Real writes require --apply (a customer-facing production action -> Steve-gated).
+// Flags: --apply  --limit N (canary)  --published-only  --only <sku-substr>
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..');
+const SHOP = 'designer-laboratory-sandbox.myshopify.com'; // LIVE DW store (legacy misnomer)
+const API = '2024-10';
+const TOKEN = (fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8')
+  .match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1]?.trim().replace(/"/g, '');
+
+const args = process.argv.slice(2);
+const has = f => args.includes(f);
+const val = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
+const APPLY = has('--apply');
+const LIMIT = parseInt(val('--limit', '0'), 10);
+const PUBONLY = has('--published-only');
+const ONLY = val('--only', '');
+
+const map = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/shopify-map.json'), 'utf8'));
+const bySku = new Map(map.map(r => [r.sku, r]));
+
+// discover generated glass-counter renders -> match folder back to sku
+const OUT = path.join(ROOT, 'output');
+const rows = [];
+for (const d of fs.readdirSync(OUT)) {
+  const png = path.join(OUT, d, 'glass-counter.png');
+  if (!fs.existsSync(png)) continue;
+  // folder = <vendor>__<safe(sku||title)>; recover sku by matching against map
+  const m = map.find(r => path.join(OUT, `${r.vendor.replace(/[^a-zA-Z0-9]+/g,'-')}__${String(r.sku||r.title).replace(/[^a-zA-Z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,60)}`) === path.join(OUT, d));
+  if (!m) continue;
+  if (PUBONLY && !m.online_store_published) continue;
+  if (ONLY && !(m.sku + m.title).toLowerCase().includes(ONLY.toLowerCase())) continue;
+  rows.push({ sku: m.sku, shopify_id: m.shopify_id, handle: m.handle, published: m.online_store_published, png });
+}
+let targets = rows;
+if (LIMIT > 0) targets = targets.slice(0, LIMIT);
+
+console.log(`Store: ${SHOP} (LIVE)  API ${API}`);
+console.log(`Renders on disk matched to products: ${rows.length}  | published: ${rows.filter(r=>r.published).length}`);
+console.log(`This run would touch: ${targets.length} products  | mode: ${APPLY ? 'APPLY (LIVE WRITE)' : 'DRY-RUN'}${PUBONLY?' | published-only':''}`);
+
+if (!APPLY) {
+  targets.slice(0, 12).forEach(r => console.log(`  DRY: product ${r.shopify_id} /${r.handle}  <- ${path.basename(path.dirname(r.png))}/glass-counter.png ${r.published?'(LIVE)':'(unpublished)'}`));
+  if (targets.length > 12) console.log(`  ... +${targets.length - 12} more`);
+  console.log('\nDRY-RUN only. No writes performed. Re-run with --apply (Steve-gated) to push.');
+  process.exit(0);
+}
+
+// ---- LIVE WRITE PATH (only reached with --apply) ----
+if (!TOKEN) { console.error('No SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const gql = async (query, variables) => {
+  const r = await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`, {
+    method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  return r.json();
+};
+async function stageAndAttach(r) {
+  const buf = fs.readFileSync(r.png);
+  const staged = await gql(`mutation($input:[StagedUploadInput!]!){stagedUploadsCreate(input:$input){stagedTargets{url resourceUrl parameters{name value}} userErrors{message}}}`,
+    { input: [{ filename: `${r.sku}-jewelry-case.png`, mimeType: 'image/png', resource: 'IMAGE', httpMethod: 'POST' }] });
+  const t = staged?.data?.stagedUploadsCreate?.stagedTargets?.[0];
+  if (!t) throw new Error('stage failed: ' + JSON.stringify(staged).slice(0, 200));
+  const form = new FormData();
+  for (const p of t.parameters) form.append(p.name, p.value);
+  form.append('file', new Blob([buf], { type: 'image/png' }), `${r.sku}.png`);
+  const up = await fetch(t.url, { method: 'POST', body: form });
+  if (!up.ok && up.status !== 201) throw new Error('upload ' + up.status);
+  const attach = await gql(`mutation($id:ID!,$media:[CreateMediaInput!]!){productCreateMedia(productId:$id,media:$media){mediaUserErrors{message} media{status}}}`,
+    { id: `gid://shopify/Product/${r.shopify_id}`, media: [{ originalSource: t.resourceUrl, mediaContentType: 'IMAGE', alt: `Jewelry display case in ${r.sku}` }] });
+  const errs = attach?.data?.productCreateMedia?.mediaUserErrors;
+  if (errs && errs.length) throw new Error(JSON.stringify(errs));
+  return true;
+}
+let ok = 0, bad = 0;
+for (const r of targets) {
+  try { await stageAndAttach(r); ok++; console.log(`  + ${r.handle}`); }
+  catch (e) { bad++; console.log(`  ! ${r.handle}: ${e.message}`); }
+  await new Promise(z => setTimeout(z, 500));
+}
+console.log(`\nLIVE PUSH done. attached=${ok} failed=${bad}`);

← 37c9698 Batch generator: all metallics + Novasuede jewelry cases (27  ·  back to Majilite Jewelry Cases  ·  auto-data-snapshot: 2026-08-10T15:38:06 (2 data files) — pac d3b0a1c →