[object Object]

← back to Wallco Ai

etsy push: scripts/push-etsy-bundle.js — atomic just-in-time asset build → Etsy v3 API → bucket state update → local cleanup. requireCreds() fail-fast w/ secrets-manager setup hints; --dry-run + --no-cleanup flags; multipart upload for cover + 6 preview images + ZIP

5ad0135c02c9cb788a1a04fd72c49fdc00bb561c · 2026-05-28 06:59:14 -0700 · Steve Abrams

Files touched

Diff

commit 5ad0135c02c9cb788a1a04fd72c49fdc00bb561c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 06:59:14 2026 -0700

    etsy push: scripts/push-etsy-bundle.js — atomic just-in-time asset build → Etsy v3 API → bucket state update → local cleanup. requireCreds() fail-fast w/ secrets-manager setup hints; --dry-run + --no-cleanup flags; multipart upload for cover + 6 preview images + ZIP
---
 data/cactus-decisions.jsonl      |   6 +
 public/admin/cactus-curator.html |  53 +++---
 scripts/push-etsy-bundle.js      | 363 +++++++++++++++++++++++++++++++++++++++
 3 files changed, 402 insertions(+), 20 deletions(-)

diff --git a/data/cactus-decisions.jsonl b/data/cactus-decisions.jsonl
index 558265a..d2a320b 100644
--- a/data/cactus-decisions.jsonl
+++ b/data/cactus-decisions.jsonl
@@ -3751,3 +3751,9 @@
 {"ts":"2026-05-28T13:21:09.285Z","id":17439,"action":"fix"}
 {"ts":"2026-05-28T13:21:10.789Z","id":17438,"action":"fix"}
 {"ts":"2026-05-28T13:21:13.604Z","id":17437,"action":"fix"}
+{"ts":"2026-05-28T13:58:00.341Z","id":53854,"action":"live"}
+{"ts":"2026-05-28T13:58:02.642Z","id":53850,"action":"live"}
+{"ts":"2026-05-28T13:58:04.852Z","id":53844,"action":"live"}
+{"ts":"2026-05-28T13:58:13.084Z","id":53815,"action":"live"}
+{"ts":"2026-05-28T13:58:32.105Z","id":53830,"action":"live"}
+{"ts":"2026-05-28T13:59:02.315Z","id":53856,"action":"live"}
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 32ecb10..eedb8f7 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -773,7 +773,10 @@ load();
     if (stackEl) return stackEl;
     stackEl = document.createElement('div');
     stackEl.id = 'bbstack';
-    stackEl.style.cssText = 'display:none;width:100%;padding:8px 4px 80px;box-sizing:border-box;';
+    // Reuse the same .grid class as the index page — same density (--cols),
+    // same gap, same padding — so it reads as the existing tight grid.
+    stackEl.className = 'grid';
+    stackEl.style.display = 'none';
     const grid = document.getElementById('grid');
     if (grid && grid.parentNode) grid.parentNode.insertBefore(stackEl, grid.nextSibling);
     else document.body.appendChild(stackEl);
@@ -785,28 +788,38 @@ load();
       { id: fam.id, category: fam.category, dominant_hex: fam.dominant_hex, is_published: fam.is_published },
       ...(fam.children || []),
     ];
-    const baseHref = `/admin/seam-debug/${fam.id}`;
-    return `<div class="bbfam" data-base="${fam.id}" style="background:var(--panel,#17191b);border:1px solid var(--line,#2a2e31);border-radius:12px;padding:12px;margin-bottom:14px">
-      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font:12px ui-monospace,monospace;color:var(--mut,#9aa3ab)">
-        <span>base <b style="color:var(--ink,#eef1f3)">#${fam.id}</b> · ${fam.category} · ${all.length} colorways</span>
-        <span>${fam.is_published ? '<span style="color:#7bd28e">✓ published</span>' : '<span style="color:#aaa">○ unpublished</span>'}</span>
-      </div>
-      <div style="display:grid;grid-template-columns:minmax(0,1fr);gap:10px">
-        <a href="${baseHref}" target="_blank" style="display:block;background:#000;border-radius:8px;overflow:hidden">
-          <img src="/designs/img/by-id/${fam.id}" loading="lazy" style="display:block;width:100%;max-height:520px;object-fit:contain;background:#fff">
+    // Re-use the index page's own .card / .thumb / .meta classes so this reads
+    // visually identical to a regular catalog tile — base in the thumb slot,
+    // colorway pills inline in the meta row + a thumb strip where .acts goes.
+    const cwLabel = (fam.category || 'base');
+    const dot = fam.dominant_hex || '#333';
+    return `<div class="card bbfam" data-base="${fam.id}" data-id="${fam.id}">
+      <div class="thumb">
+        <div class="badges">
+          <span class="b ${fam.is_published?'pub':'unpub'}">${fam.is_published?'PUB':'unpub'}</span>
+          <span class="b reason" title="${all.length} colorways in family">${all.length}cw</span>
+        </div>
+        <a href="/admin/seam-debug/${fam.id}" target="_blank" style="display:block;width:100%;height:100%">
+          <img loading="lazy" src="/designs/img/by-id/${fam.id}" alt="base ${fam.id}">
         </a>
-        <div style="display:flex;gap:6px;overflow-x:auto;padding:6px 2px;scrollbar-width:thin">
-          ${all.map(c => {
-            const cw = (c.category || '').split(' · ')[1] || 'base';
-            const isBase = c.id === fam.id;
-            const border = isBase ? '#ffd68a' : (c.is_published ? '#3a6a3a' : '#3a3a3a');
-            return `<a href="/admin/seam-debug/${c.id}" target="_blank" class="bbthumb" data-id="${c.id}" data-fam="${fam.id}" style="flex:0 0 auto;border:2px solid ${border};border-radius:6px;overflow:hidden;background:#222;text-decoration:none;color:inherit">
-              <img src="/designs/img/by-id/${c.id}" loading="lazy" style="display:block;width:84px;height:84px;object-fit:cover">
-              <div style="text-align:center;font:700 9px sans-serif;padding:3px 4px;color:${isBase ? '#ffd68a' : '#aaa'};max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${cw}</div>
-            </a>`;
-          }).join('')}
+      </div>
+      <div class="meta">
+        <div class="cw" title="${fam.category||''}">base <span class="sub">#${fam.id}</span></div>
+        <div class="scores">
+          <span class="dot" style="background:${dot}"></span>
+          <span>${all.length} cw</span>
         </div>
       </div>
+      <div class="bbstrip" style="display:flex;gap:3px;overflow-x:auto;padding:0 8px 9px;scrollbar-width:thin">
+        ${all.map(c => {
+          const isBase = c.id === fam.id;
+          const border = isBase ? '#ffd68a' : (c.is_published ? '#3a6a3a' : '#2a2a2a');
+          const cw = (c.category || '').split(' · ')[1] || 'base';
+          return `<a href="/admin/seam-debug/${c.id}" target="_blank" title="#${c.id} · ${cw}${c.is_published?' · ✓ published':''}" style="flex:0 0 auto;border:1.5px solid ${border};border-radius:4px;overflow:hidden;background:#000;text-decoration:none;line-height:0">
+            <img loading="lazy" src="/designs/img/by-id/${c.id}" style="display:block;width:38px;height:38px;object-fit:cover">
+          </a>`;
+        }).join('')}
+      </div>
     </div>`;
   }
 
diff --git a/scripts/push-etsy-bundle.js b/scripts/push-etsy-bundle.js
new file mode 100644
index 0000000..da6d7e4
--- /dev/null
+++ b/scripts/push-etsy-bundle.js
@@ -0,0 +1,363 @@
+#!/usr/bin/env node
+/**
+ * push-etsy-bundle — atomic "generate assets → upload to Etsy → cleanup"
+ * for one bundle. This is the ONLY place where bundle files get written to
+ * disk (Steve "only create file when we upload it to etsy" 2026-05-28).
+ *
+ * Lifecycle for one bundle:
+ *   1.  Read data/etsy-exports/<date>/bundles/<slug>/listing.json
+ *   2.  Resolve design_ids → local_path via PG
+ *   3.  Generate assets just-in-time (PIL):
+ *         cover_grid.png     — 4×4 (or smaller) hero
+ *         files/<id>_source_1024.png   — original tile
+ *         files/<id>_print_3600.png    — LANCZOS-upscaled (24" @ 150 DPI)
+ *         <slug>.zip          — packaged digital download
+ *   4.  Etsy API v3:
+ *         POST  /v3/application/shops/{shop_id}/listings   (draft)
+ *         POST  /v3/application/listings/{listing_id}/images       × N
+ *         POST  /v3/application/shops/{shop_id}/listings/{listing_id}/files (the ZIP)
+ *   5.  Update wallco_etsy_bucket: state='listed', etsy_listing_id, listed_at
+ *   6.  Delete local assets (unless --no-cleanup)
+ *
+ * Required env (in ~/Projects/secrets-manager/.env or this process's env):
+ *   ETSY_CLIENT_ID            — Etsy app's API key
+ *   ETSY_OAUTH_ACCESS_TOKEN   — seller OAuth2 access token (you, not the buyer)
+ *   ETSY_OAUTH_REFRESH_TOKEN  — for auto-refresh when access expires
+ *   ETSY_SHOP_ID              — numeric shop id
+ *
+ * Usage:
+ *   node scripts/push-etsy-bundle.js --slug warm-red-01
+ *   node scripts/push-etsy-bundle.js --slug warm-red-01 --dry-run   # build assets + show payload, don't upload
+ *   node scripts/push-etsy-bundle.js --slug warm-red-01 --no-cleanup
+ *
+ * Exit codes:
+ *   0   success — listing live in Etsy as DRAFT
+ *   1   bad input (bundle not found, bad args)
+ *   2   missing Etsy credentials (run secrets-manager add etsy)
+ *   3   asset-generation failure
+ *   4   Etsy API error
+ */
+const { execSync, spawnSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const argv = process.argv.slice(2);
+const ARG = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d; };
+const HAS = k => argv.includes(k);
+const opt = {
+  slug:      ARG('--slug'),
+  date:      ARG('--date', new Date().toISOString().slice(0, 10)),
+  dryRun:    HAS('--dry-run'),
+  noCleanup: HAS('--no-cleanup'),
+  state:     ARG('--state', 'draft'),    // Etsy listing state at creation
+};
+if (!opt.slug) {
+  console.error('usage: node scripts/push-etsy-bundle.js --slug <bundle-slug> [--date YYYY-MM-DD] [--dry-run] [--no-cleanup]');
+  process.exit(1);
+}
+
+const BUNDLE_DIR = path.join(__dirname, '..', 'data', 'etsy-exports', opt.date, 'bundles', opt.slug);
+const LISTING_JSON = path.join(BUNDLE_DIR, 'listing.json');
+if (!fs.existsSync(LISTING_JSON)) {
+  console.error(`bundle metadata not found: ${LISTING_JSON}`);
+  console.error('(run scripts/generate-etsy-bundles.js first to create the bundle metadata)');
+  process.exit(1);
+}
+const meta = JSON.parse(fs.readFileSync(LISTING_JSON, 'utf8'));
+
+// ── creds ──────────────────────────────────────────────────────────────────
+// Pull from ~/Projects/secrets-manager/.env via inheritance, or this process
+// env. Fail-fast if missing.
+function loadSecretsEnv() {
+  const p = path.join(require('os').homedir(), 'Projects', 'secrets-manager', '.env');
+  if (!fs.existsSync(p)) return;
+  const text = fs.readFileSync(p, 'utf8');
+  for (const line of text.split('\n')) {
+    const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
+    if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+  }
+}
+loadSecretsEnv();
+
+const ETSY = {
+  clientId:     process.env.ETSY_CLIENT_ID,
+  accessToken:  process.env.ETSY_OAUTH_ACCESS_TOKEN,
+  refreshToken: process.env.ETSY_OAUTH_REFRESH_TOKEN,
+  shopId:       process.env.ETSY_SHOP_ID,
+};
+function requireCreds() {
+  const missing = [];
+  if (!ETSY.clientId)    missing.push('ETSY_CLIENT_ID');
+  if (!ETSY.accessToken) missing.push('ETSY_OAUTH_ACCESS_TOKEN');
+  if (!ETSY.shopId)      missing.push('ETSY_SHOP_ID');
+  if (missing.length) {
+    console.error('\n⚠  Etsy credentials missing — set these via secrets-manager:');
+    for (const k of missing) console.error(`     ${k}`);
+    console.error('\nQuick setup:');
+    console.error('  1. Create an Etsy app at https://www.etsy.com/developers/your-apps');
+    console.error('  2. Run OAuth2 flow to get an access + refresh token for your seller account');
+    console.error('  3. node ~/Projects/secrets-manager/cli.js add ETSY_CLIENT_ID=xxx ETSY_OAUTH_ACCESS_TOKEN=xxx ETSY_OAUTH_REFRESH_TOKEN=xxx ETSY_SHOP_ID=12345');
+    console.error('  4. Re-run this script.');
+    process.exit(2);
+  }
+}
+
+// ── PG helpers ─────────────────────────────────────────────────────────────
+const oneLine = s => s.replace(/\s+/g, ' ').trim();
+function psqlJson(sql) {
+  const r = execSync(`psql dw_unified -At -q -c ${JSON.stringify(oneLine(sql))}`, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
+  return r.split('\n').filter(Boolean).map(l => JSON.parse(l));
+}
+function psqlExec(sql) {
+  execSync(`psql dw_unified -q -c ${JSON.stringify(oneLine(sql))}`, { encoding: 'utf8' });
+}
+function resolveDesigns(ids) {
+  return psqlJson(`SELECT row_to_json(t) FROM (
+    SELECT id, local_path, dominant_hex, category, prompt
+    FROM all_designs WHERE id IN (${ids.join(',')})
+    ORDER BY id
+  ) t;`);
+}
+
+// ── asset generation (just-in-time) ────────────────────────────────────────
+function buildAssets(designs, slug) {
+  const files = path.join(BUNDLE_DIR, 'files');
+  fs.mkdirSync(files, { recursive: true });
+  const present = designs.filter(d => d.local_path && fs.existsSync(d.local_path));
+  if (!present.length) throw new Error('no source files exist for any design in the bundle');
+
+  const cols = Math.min(4, Math.ceil(Math.sqrt(present.length)));
+  const upscalePx = 3600;     // 24" @ 150 DPI — matches export-etsy-digital default
+  const py = `
+import os, json
+from PIL import Image
+
+items = ${JSON.stringify(present.map(d => ({ id: d.id, src: d.local_path })))}
+cols = ${cols}
+upscale_px = ${upscalePx}
+cw = 600
+
+# 4×4 (or NxN) cover hero
+rows = (len(items) + cols - 1) // cols
+canvas = Image.new('RGB', (cw * cols, cw * rows), '#0e0f10')
+for i, it in enumerate(items):
+    src = Image.open(it['src']).convert('RGB').resize((cw, cw), Image.LANCZOS)
+    canvas.paste(src, ((i % cols) * cw, (i // cols) * cw))
+canvas.save(${JSON.stringify(path.join(BUNDLE_DIR, 'cover_grid.png'))}, optimize=True)
+
+# Per-design source + LANCZOS upscale
+files_dir = ${JSON.stringify(files)}
+for it in items:
+    src = Image.open(it['src']).convert('RGB')
+    src.save(os.path.join(files_dir, f"{it['id']}_source_1024.png"), optimize=True)
+    src.resize((upscale_px, upscale_px), Image.LANCZOS).save(
+        os.path.join(files_dir, f"{it['id']}_print_3600.png"), optimize=True
+    )
+print(json.dumps({"cover": ${JSON.stringify(path.join(BUNDLE_DIR, 'cover_grid.png'))},
+                  "files_dir": files_dir, "count": len(items),
+                  "upscale_px": upscale_px}))
+`;
+  const r = spawnSync('python3', ['-c', py], { encoding: 'utf8', timeout: 600000, maxBuffer: 8 * 1024 * 1024 });
+  if (r.status !== 0) {
+    console.error('PIL stderr:', r.stderr);
+    throw new Error('asset generation failed');
+  }
+  // Build the ZIP that buyers download
+  const zipPath = path.join(BUNDLE_DIR, `${slug}.zip`);
+  execSync(`cd ${JSON.stringify(BUNDLE_DIR)} && zip -q -r ${JSON.stringify(slug + '.zip')} files cover_grid.png`,
+    { stdio: 'inherit' });
+  const zipStats = fs.statSync(zipPath);
+  return { ...JSON.parse(r.stdout.trim()), zip: zipPath, zipBytes: zipStats.size };
+}
+
+// ── Etsy API client ────────────────────────────────────────────────────────
+// Thin v3 client; if MCP `mcp__etsy__*` schema retrieval is wired into your
+// agent runtime, mcp__etsy__search_etsy_api / get_endpoint can verify the
+// payload shape before deploy. The actual write is plain HTTPS.
+function etsyRequest(method, p, body, isMultipart = false) {
+  return new Promise((resolve, reject) => {
+    const url = new URL('https://openapi.etsy.com/v3' + p);
+    const headers = {
+      'x-api-key': ETSY.clientId,
+      'Authorization': `Bearer ${ETSY.accessToken}`,
+      'Accept': 'application/json',
+    };
+    let payload;
+    if (body && !isMultipart) {
+      headers['Content-Type'] = 'application/json';
+      payload = Buffer.from(JSON.stringify(body));
+      headers['Content-Length'] = payload.length;
+    } else if (body && isMultipart) {
+      // body is already a Buffer + boundary already set on Content-Type
+      payload = body.buffer;
+      headers['Content-Type'] = body.contentType;
+      headers['Content-Length'] = payload.length;
+    }
+    const req = https.request({
+      method, hostname: url.hostname, path: url.pathname + url.search, headers,
+    }, res => {
+      let data = '';
+      res.on('data', c => data += c);
+      res.on('end', () => {
+        const ok = res.statusCode >= 200 && res.statusCode < 300;
+        try {
+          const j = data ? JSON.parse(data) : {};
+          if (!ok) return reject(new Error(`Etsy ${method} ${p} → ${res.statusCode}: ${j.error || data.slice(0, 400)}`));
+          resolve(j);
+        } catch { ok ? resolve({ raw: data }) : reject(new Error(`Etsy ${method} ${p} → ${res.statusCode}: ${data.slice(0, 400)}`)); }
+      });
+    });
+    req.on('error', reject);
+    if (payload) req.write(payload);
+    req.end();
+  });
+}
+
+// Minimal multipart/form-data builder for file uploads.
+function buildMultipart(fields) {
+  const boundary = '----WallcoEtsy' + Math.random().toString(36).slice(2);
+  const chunks = [];
+  for (const [k, v] of Object.entries(fields)) {
+    chunks.push(Buffer.from(`--${boundary}\r\n`));
+    if (v && typeof v === 'object' && v.file) {
+      const filename = path.basename(v.file);
+      chunks.push(Buffer.from(`Content-Disposition: form-data; name="${k}"; filename="${filename}"\r\nContent-Type: ${v.type || 'application/octet-stream'}\r\n\r\n`));
+      chunks.push(fs.readFileSync(v.file));
+      chunks.push(Buffer.from('\r\n'));
+    } else {
+      chunks.push(Buffer.from(`Content-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
+    }
+  }
+  chunks.push(Buffer.from(`--${boundary}--\r\n`));
+  return { buffer: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` };
+}
+
+async function pushToEtsy(meta, assets) {
+  // 1) Create the draft listing (digital download type)
+  const listingPayload = {
+    quantity: 999,                          // digital — effectively unlimited
+    title: meta.title,
+    description: meta.description,
+    price: meta.price,
+    who_made: 'i_did',
+    when_made: '2020_2025',
+    taxonomy_id: 1078,                      // "Wallpaper & Wall Décor" — verify w/ mcp__etsy__get_endpoint
+    type: 'download',
+    state: opt.state,                       // 'draft' or 'active'
+    is_digital: true,
+    is_personalizable: false,
+    listing_type: 'download',
+    tags: meta.tags.slice(0, 13).join(','),
+    materials: 'Digital file,PNG,Seamless tile',
+    sku: meta.sku,
+  };
+  console.log('  → POST /shops/{shop_id}/listings  (draft)');
+  const created = await etsyRequest('POST', `/application/shops/${ETSY.shopId}/listings`, listingPayload);
+  const listingId = created.listing_id;
+  if (!listingId) throw new Error('Etsy did not return a listing_id');
+
+  // 2) Attach the cover image as the FIRST image
+  console.log(`  → POST /listings/${listingId}/images  cover_grid.png`);
+  const coverForm = buildMultipart({ image: { file: assets.cover, type: 'image/png' }, rank: 1 });
+  await etsyRequest('POST', `/application/shops/${ETSY.shopId}/listings/${listingId}/images`, coverForm, true);
+
+  // 3) Attach a handful of preview images (first 6 designs as standalone previews)
+  const previews = fs.readdirSync(assets.files_dir)
+    .filter(f => f.endsWith('_source_1024.png')).slice(0, 6);
+  for (let i = 0; i < previews.length; i++) {
+    const f = path.join(assets.files_dir, previews[i]);
+    console.log(`  → POST /listings/${listingId}/images  ${previews[i]}`);
+    const form = buildMultipart({ image: { file: f, type: 'image/png' }, rank: i + 2 });
+    await etsyRequest('POST', `/application/shops/${ETSY.shopId}/listings/${listingId}/images`, form, true);
+  }
+
+  // 4) Attach the ZIP as the digital download file
+  console.log(`  → POST /shops/{shop_id}/listings/${listingId}/files  ${path.basename(assets.zip)}`);
+  const fileForm = buildMultipart({
+    file: { file: assets.zip, type: 'application/zip' },
+    name: path.basename(assets.zip),
+    rank: 1,
+  });
+  await etsyRequest('POST', `/application/shops/${ETSY.shopId}/listings/${listingId}/files`, fileForm, true);
+
+  return { listingId, url: `https://www.etsy.com/listing/${listingId}` };
+}
+
+// ── main ───────────────────────────────────────────────────────────────────
+async function main() {
+  console.log(`\n══ pushing bundle '${opt.slug}' (${meta.count} designs) ══`);
+  console.log(`  title: ${meta.title.slice(0, 96)}…`);
+  console.log(`  price: $${meta.price}  ·  tags: ${meta.tags.length}  ·  designs: ${meta.design_ids.length}`);
+
+  if (!opt.dryRun) requireCreds();
+
+  // 1. Resolve design_ids → local_path
+  const designs = resolveDesigns(meta.design_ids);
+  console.log(`  resolved ${designs.length} designs from PG`);
+
+  // 2. Generate assets just-in-time
+  console.log('  building assets…');
+  let assets;
+  try {
+    assets = buildAssets(designs, opt.slug);
+    console.log(`  ✓ cover ${path.basename(assets.cover)}, ${assets.count} per-design pairs, zip ${(assets.zipBytes / 1024 / 1024).toFixed(1)} MB`);
+  } catch (e) {
+    console.error(`  ✗ asset build failed: ${e.message}`);
+    process.exit(3);
+  }
+
+  if (opt.dryRun) {
+    console.log('\n--- DRY RUN — would POST to Etsy ---');
+    console.log(`POST /v3/application/shops/${ETSY.shopId || '<SHOP_ID>'}/listings`);
+    console.log(JSON.stringify({
+      title: meta.title.slice(0, 60) + '…',
+      price: meta.price,
+      tags: meta.tags,
+      type: 'download',
+      state: opt.state,
+    }, null, 2));
+    console.log(`then attach: cover + ${Math.min(6, assets.count)} preview images + 1 ZIP (${(assets.zipBytes / 1024 / 1024).toFixed(1)} MB)`);
+    if (!opt.noCleanup) console.log('(dry-run: leaving assets in place; use --no-cleanup on a real run to keep them)');
+    return;
+  }
+
+  // 3. Push to Etsy
+  let result;
+  try {
+    result = await pushToEtsy(meta, assets);
+    console.log(`\n  ✓ Etsy listing #${result.listingId} created as ${opt.state}`);
+    console.log(`  ${result.url}`);
+  } catch (e) {
+    console.error(`  ✗ Etsy upload failed: ${e.message}`);
+    console.error('  (assets kept in place for retry)');
+    process.exit(4);
+  }
+
+  // 4. Update wallco_etsy_bucket
+  try {
+    psqlExec(
+      `UPDATE wallco_etsy_bucket SET state='listed', etsy_listing_id=${result.listingId}, ` +
+      `listed_at=now(), etsy_shop='${(ETSY.shopId || '').replace(/[^0-9]/g, '')}', ` +
+      `notes=CONCAT(COALESCE(notes,''), E'\\nlisted ${new Date().toISOString()}: ${result.url}') ` +
+      `WHERE design_id IN (${meta.design_ids.join(',')});`
+    );
+    console.log(`  ✓ bucket rows stamped state='listed' + etsy_listing_id=${result.listingId}`);
+  } catch (e) {
+    console.error(`  ⚠ DB update failed (Etsy listing IS live): ${e.message}`);
+  }
+
+  // 5. Cleanup local assets (unless --no-cleanup)
+  if (!opt.noCleanup) {
+    try {
+      const filesDir = path.join(BUNDLE_DIR, 'files');
+      if (fs.existsSync(filesDir)) fs.rmSync(filesDir, { recursive: true });
+      if (fs.existsSync(assets.cover)) fs.rmSync(assets.cover);
+      if (fs.existsSync(assets.zip)) fs.rmSync(assets.zip);
+      console.log(`  ✓ local assets cleaned (use --no-cleanup to keep next time)`);
+    } catch (e) {
+      console.error(`  ⚠ cleanup failed: ${e.message}`);
+    }
+  }
+}
+main().catch(e => { console.error('fatal:', e.message); process.exit(4); });

← 54a491e cactus-curator: 'By-base layout' is now an INDEX-PAGE mode (  ·  back to Wallco Ai  ·  cactus-curator by-base layout: tight grid like the index pag 3ce6882 →