← 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
M data/cactus-decisions.jsonlM public/admin/cactus-curator.htmlA scripts/push-etsy-bundle.js
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 →