[object Object]

← 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

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 →