← back to Wallco Ai
scripts: push-shopify-digital-bundle.js — Shopify counterpart to push-etsy-bundle, DTD verdict B
f55cad15dc2a3e3c40339cdca7e77d2d2443b2c8 · 2026-05-28 13:01:53 -0700 · Steve Abrams
Files touched
A scripts/push-shopify-digital-bundle.js
Diff
commit f55cad15dc2a3e3c40339cdca7e77d2d2443b2c8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 13:01:53 2026 -0700
scripts: push-shopify-digital-bundle.js — Shopify counterpart to push-etsy-bundle, DTD verdict B
---
scripts/push-shopify-digital-bundle.js | 184 +++++++++++++++++++++++++++++++++
1 file changed, 184 insertions(+)
diff --git a/scripts/push-shopify-digital-bundle.js b/scripts/push-shopify-digital-bundle.js
new file mode 100644
index 0000000..751426c
--- /dev/null
+++ b/scripts/push-shopify-digital-bundle.js
@@ -0,0 +1,184 @@
+#!/usr/bin/env node
+/**
+ * push-shopify-digital-bundle — the Shopify counterpart to push-etsy-bundle.
+ *
+ * Why this exists: Steve's Etsy seller app got banned 2026-05-28 for bulk-listing
+ * patterns. DTD verdict 2026-05-28 = B (Shopify pivot). Designer Wallcoverings
+ * already has a live Shopify store with payment infra + OAuth + admin API +
+ * existing MCP integration, and the bundle data shape (listing.json) is
+ * platform-agnostic up to the push step — so this script reuses the same
+ * data/etsy-exports/<date>/bundles/<slug>/ artifacts but pushes to Shopify
+ * instead of Etsy.
+ *
+ * Lifecycle for one bundle:
+ * 1. Read data/etsy-exports/<date>/bundles/<slug>/listing.json
+ * 2. Resolve design_ids → local_path via PG
+ * 3. Build a cover image (4×4 grid or single tile if count=1) via PIL
+ * 4. POST Shopify Admin REST:
+ * POST /admin/api/2026-01/products.json (DRAFT — never auto-publish)
+ * 5. Update wallco_etsy_bucket: state='listed', etsy_listing_id=<shopify
+ * product id> (column dual-uses for Shopify too — see comment in DDL),
+ * listed_at, notes='shopify-draft'
+ * 6. Print the Shopify admin URL so Steve can review + publish manually
+ *
+ * Default target: the SHOPIFY_STORE env value (currently the
+ * designer-laboratory-sandbox store — see line 14918-style server.js usage).
+ * Override with --store <hostname.myshopify.com> if you want production DW.
+ *
+ * Required env (from secrets-manager .env):
+ * SHOPIFY_ADMIN_TOKEN — shpat_… admin API token for the target store
+ * SHOPIFY_STORE — the target shop hostname (xxxx.myshopify.com)
+ *
+ * Usage:
+ * node scripts/push-shopify-digital-bundle.js --date 2026-05-28 --slug cactus
+ * node scripts/push-shopify-digital-bundle.js --date 2026-05-28 --slug cactus --dry
+ * node scripts/push-shopify-digital-bundle.js --date 2026-05-28 --slug cactus --store mydw.myshopify.com
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const { execSync, spawnSync } = require('child_process');
+const ROOT = path.resolve(__dirname, '..');
+// Load .env so SHOPIFY_ADMIN_TOKEN + SHOPIFY_STORE are available when run from CLI
+try { require('dotenv').config({ path: path.join(ROOT, '.env') }); } catch {}
+
+// Strip Etsy-isms from descriptions that were generated by the Etsy bundle script.
+function sanitizeForShopify(html) {
+ if (!html) return '';
+ return String(html)
+ .replace(/via Etsy's automatic download after purchase/gi, 'as a digital download immediately after purchase')
+ .replace(/via Etsy/gi, 'via Shopify')
+ .replace(/Etsy/g, 'Shopify')
+ .replace(/\bFentucci Digital Packs\b/g, 'wallco.ai by Designer Wallcoverings');
+}
+
+function argv(name, dflt) {
+ const i = process.argv.indexOf('--' + name);
+ if (i === -1) return dflt;
+ if (i === process.argv.length - 1 || process.argv[i + 1].startsWith('--')) return true; // flag
+ return process.argv[i + 1];
+}
+
+const date = argv('date', new Date().toISOString().slice(0, 10));
+const slug = argv('slug', null);
+const store = argv('store', process.env.SHOPIFY_STORE);
+const token = process.env.SHOPIFY_ADMIN_TOKEN;
+const dry = !!argv('dry', false);
+const noBumpBucket = !!argv('no-bucket', false);
+
+if (!slug) { console.error('usage: --slug <bundle-slug> [--date YYYY-MM-DD] [--store host] [--dry]'); process.exit(2); }
+if (!dry && (!token || !store)) {
+ console.error('missing SHOPIFY_ADMIN_TOKEN or SHOPIFY_STORE in env (use --dry to preview the POST body)');
+ process.exit(3);
+}
+
+const bundleDir = path.join(ROOT, 'data', 'etsy-exports', date, 'bundles', slug);
+const listingPath = path.join(bundleDir, 'listing.json');
+if (!fs.existsSync(listingPath)) {
+ console.error(`listing not found: ${listingPath}`);
+ console.error(`(generate it first via: node scripts/generate-etsy-bundles.js --by category --from-bucket --min-size 1 --commit)`);
+ process.exit(4);
+}
+const listing = JSON.parse(fs.readFileSync(listingPath, 'utf8'));
+console.log(`bundle: ${slug} · ${listing.count} designs · $${listing.price} · SKU ${listing.sku}`);
+
+// Resolve a cover image — first design's local PNG. For multi-design bundles we
+// could later generate a 4×4 grid via PIL; for now ship the first tile so the
+// product page has something to look at.
+let coverPath = null;
+try {
+ const firstId = (listing.design_ids || [])[0];
+ if (firstId) {
+ const lp = execSync(`psql dw_unified -At -c "SELECT local_path FROM all_designs WHERE id=${firstId};"`, { encoding: 'utf8' }).trim();
+ if (lp && fs.existsSync(lp)) coverPath = lp;
+ }
+} catch (e) { console.warn('cover lookup failed:', e.message.slice(0, 100)); }
+
+// Build the Shopify product body — same shape as upload-shopify but bundle-tier.
+const tagList = [
+ 'wallco-ai-bundle', 'wallco-original', 'digital-download', 'bundle', 'seamless-pattern',
+ ...((listing.tags || []).map(t => String(t).toLowerCase().replace(/\s+/g, '-'))),
+].filter(Boolean).join(', ');
+
+const productBody = {
+ product: {
+ title: listing.title || `wallco.ai ${slug} bundle`,
+ vendor: 'Designer Wallcoverings',
+ product_type: 'Digital Download',
+ status: 'draft', // always DRAFT — Steve publishes manually after review
+ tags: tagList,
+ body_html: sanitizeForShopify(String(listing.description || '').replace(/\n/g, '<br>')),
+ variants: [{
+ sku: listing.sku,
+ price: String(listing.price || '14.99'),
+ requires_shipping: false,
+ taxable: true,
+ inventory_management: null,
+ inventory_policy: 'continue',
+ }],
+ metafields: [
+ { namespace: 'wallco_ai', key: 'bundle_slug', value: slug, type: 'single_line_text_field' },
+ { namespace: 'wallco_ai', key: 'bundle_design_ids', value: (listing.design_ids || []).join(','), type: 'single_line_text_field' },
+ { namespace: 'wallco_ai', key: 'bundle_count', value: String(listing.count || 0), type: 'number_integer' },
+ { namespace: 'wallco_ai', key: 'source_date', value: date, type: 'date' },
+ ],
+ }
+};
+if (coverPath) {
+ const ext = (path.extname(coverPath) || '.png').slice(1);
+ productBody.product.images = [{
+ attachment: fs.readFileSync(coverPath).toString('base64'),
+ filename: `${slug}-cover.${ext}`,
+ alt: listing.title || slug,
+ }];
+}
+
+if (dry) {
+ console.log('--- DRY: would POST to', store, '/admin/api/2026-01/products.json ---');
+ const preview = JSON.parse(JSON.stringify(productBody));
+ if (preview.product.images) preview.product.images.forEach(im => { im.attachment = `<base64 ${(im.attachment || '').length} bytes>`; });
+ console.log(JSON.stringify(preview, null, 2));
+ process.exit(0);
+}
+
+// Real push
+(async () => {
+ const url = `https://${store}/admin/api/2026-01/products.json`;
+ console.log(`POST ${url}`);
+ let r, body;
+ try {
+ r = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token },
+ body: JSON.stringify(productBody),
+ });
+ body = await r.text();
+ } catch (e) { console.error('fetch error:', e.message); process.exit(5); }
+
+ if (!r.ok) {
+ console.error(`Shopify ${r.status}:`, body.slice(0, 800));
+ process.exit(6);
+ }
+ let parsed;
+ try { parsed = JSON.parse(body); } catch { console.error('non-JSON response:', body.slice(0, 400)); process.exit(7); }
+ const p = parsed.product || {};
+ const productId = p.id;
+ const adminUrl = `https://${store.replace('.myshopify.com', '')}.myshopify.com/admin/products/${productId}`;
+ const handleUrl = p.handle ? `https://${store}/products/${p.handle}` : null;
+ console.log(`✓ created Shopify product ${productId} (DRAFT)`);
+ console.log(` admin: ${adminUrl}`);
+ if (handleUrl) console.log(` store: ${handleUrl} (after you publish)`);
+
+ if (!noBumpBucket) {
+ // Use the existing wallco_etsy_bucket schema — etsy_listing_id is the right
+ // column name historically, here it dual-uses as shopify product id (we
+ // disambiguate via the etsy_shop field).
+ const ids = (listing.design_ids || []).filter(Number.isFinite);
+ if (ids.length) {
+ try {
+ execSync(`psql dw_unified -q -c "UPDATE wallco_etsy_bucket SET state='listed', etsy_listing_id=${productId}, etsy_shop='shopify:${store}', listed_at=now(), notes='shopify-draft' WHERE design_id IN (${ids.join(',')});"`, { stdio: 'inherit' });
+ console.log(` bucket: ${ids.length} row(s) marked state='listed' (shopify-draft)`);
+ } catch (e) { console.warn(' bucket update failed (non-fatal):', e.message.slice(0, 120)); }
+ }
+ }
+})();
← 5f263d7 cactus-curator: hover any chip to swap flat tile → room mock
·
back to Wallco Ai
·
card-room hover: drop chip inset, show entire room setting o b14db23 →