← back to Majilite Jewelry Cases
scripts/push-shopify.mjs
93 lines
#!/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';
import { execSync } from 'node:child_process';
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'));
// 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); }
// REST image attach with base64 — reliable, no staged-upload handshake.
// Returns the created image id (proof of a real attach), throws with the real body on failure.
async function alreadyHasJewel(numericId) {
const res = await fetch(`https://${SHOP}/admin/api/${API}/products/${numericId}/images.json`, {
headers: { 'X-Shopify-Access-Token': TOKEN } });
if (!res.ok) return false;
const j = await res.json();
return (j.images || []).some(im => /jewelry-case/i.test(im.src || ''));
}
async function attachViaRest(r) {
const numericId = String(r.shopify_id).split('/').pop(); // shopify_id may be a GID
if (await alreadyHasJewel(numericId)) return 'skip'; // idempotent / resume-safe
// downscale to a compact JPG to keep the base64 payload small + reliable
const tmp = `/tmp/jc-${numericId}.jpg`;
let src = r.png;
try { execSync(`magick "${r.png}" -resize 1000x1000 -quality 82 "${tmp}"`); src = tmp; } catch {}
const b64 = fs.readFileSync(src).toString('base64');
const res = await fetch(`https://${SHOP}/admin/api/${API}/products/${numericId}/images.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ image: { attachment: b64, filename: `${r.sku}-jewelry-case.png`, alt: `Jewelry display case in ${r.sku}` } }),
});
const j = await res.json();
if (!res.ok || !j.image?.id) throw new Error(`REST ${res.status}: ${JSON.stringify(j).slice(0, 220)}`);
return j.image.id;
}
let ok = 0, bad = 0, skip = 0;
for (const r of targets) {
try {
const imgId = await attachViaRest(r);
if (imgId === 'skip') { skip++; continue; }
ok++; console.log(` + ${r.handle} (image ${imgId})`);
}
catch (e) { bad++; console.log(` ! ${r.handle}: ${e.message}`); }
await new Promise(z => setTimeout(z, 600));
}
console.log(`\nLIVE PUSH done. attached=${ok} skipped_existing=${skip} failed=${bad}`);