← 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
M .gitignoreM scripts/gen-batch.mjsA scripts/push-shopify.mjs
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 →