← back to Wallco Ai
etsy activation wizard: /admin/etsy-setup — single-page status matrix (5 steps: app, key, oauth, shop_id, test) w/ one-click triggers. Server routes: /api/etsy-setup/{status,save-key,save-shop-id,start-oauth,test}. spawns scripts/etsy-oauth-helper.js detached; polls until tokens land; shows 'ready to push' banner linking to bucket when all 4 creds present
02f7eab68c40269ff8199005ee932f8f934b85f5 · 2026-05-28 07:26:00 -0700 · Steve Abrams
Files touched
A public/admin/etsy-setup.htmlM scripts/heal-region-inpaint.pyM server.js
Diff
commit 02f7eab68c40269ff8199005ee932f8f934b85f5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 07:26:00 2026 -0700
etsy activation wizard: /admin/etsy-setup — single-page status matrix (5 steps: app, key, oauth, shop_id, test) w/ one-click triggers. Server routes: /api/etsy-setup/{status,save-key,save-shop-id,start-oauth,test}. spawns scripts/etsy-oauth-helper.js detached; polls until tokens land; shows 'ready to push' banner linking to bucket when all 4 creds present
---
public/admin/etsy-setup.html | 236 +++++++++++++++++++++++++++++++++++++++++
scripts/heal-region-inpaint.py | 18 +++-
server.js | 170 ++++++++++++++++++++++++++++-
3 files changed, 418 insertions(+), 6 deletions(-)
diff --git a/public/admin/etsy-setup.html b/public/admin/etsy-setup.html
new file mode 100644
index 0000000..6249f0c
--- /dev/null
+++ b/public/admin/etsy-setup.html
@@ -0,0 +1,236 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Etsy activation — wallco.ai admin</title>
+<style>
+ :root{--bg:#0e0f10;--panel:#17191b;--line:#272b2e;--ink:#e9ecef;--mut:#9aa3ab;
+ --ok:#7bc96f;--bad:#ff4d4d;--etsy:#ffb86b;--blue:#7ec8ff;}
+ *{box-sizing:border-box;}
+ body{margin:0;background:var(--bg);color:var(--ink);
+ font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
+ header{position:sticky;top:0;z-index:20;background:rgba(14,15,16,.96);
+ backdrop-filter:blur(8px);border-bottom:1px solid var(--line);
+ padding:14px 20px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;}
+ h1{font-size:18px;margin:0;font-weight:650;letter-spacing:.2px;} h1 .ico{color:var(--etsy);}
+ .pill{background:var(--panel);border:1px solid var(--line);border-radius:999px;
+ padding:3px 10px;font-size:12px;color:var(--mut);} .pill b{color:var(--ink);}
+ main{max-width:920px;margin:0 auto;padding:24px 20px 80px;}
+ .matrix{display:grid;grid-template-columns:auto 1fr auto;gap:0;
+ background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;}
+ .step{display:contents;}
+ .step > div{padding:18px 20px;border-top:1px solid var(--line);}
+ .step:first-child > div{border-top:none;}
+ .step .num{width:48px;text-align:center;font:700 16px ui-monospace,monospace;
+ background:#0a0b0c;color:var(--mut);border-right:1px solid var(--line);
+ display:flex;align-items:center;justify-content:center;}
+ .step.done .num{background:#143b1d;color:var(--ok);}
+ .step .body{display:flex;flex-direction:column;gap:8px;}
+ .step .body h3{margin:0;font-size:15px;font-weight:650;display:flex;gap:8px;align-items:center;}
+ .step .body p{margin:0;color:var(--mut);font-size:13px;}
+ .step .body code{background:#0a0b0c;padding:2px 7px;border-radius:4px;font:12px ui-monospace,monospace;color:var(--blue);}
+ .step .body input[type=text]{background:#0a0b0c;color:var(--ink);
+ border:1px solid var(--line);border-radius:7px;padding:7px 11px;
+ font:13px ui-monospace,monospace;width:100%;max-width:520px;margin-top:6px;}
+ .step .ctl{display:flex;align-items:center;justify-content:flex-end;border-left:1px solid var(--line);
+ min-width:200px;background:#0c0d0e;}
+ .step .ctl > div{display:flex;flex-direction:column;gap:6px;align-items:flex-end;}
+ .status{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;
+ padding:3px 10px;border-radius:999px;background:#1c2024;color:var(--mut);}
+ .status.ok{background:#143b1d;color:var(--ok);}
+ .status.warn{background:#3b2e08;color:#f0b400;}
+ .status.bad{background:#3b0e0e;color:var(--bad);}
+ button.act{font:700 13px inherit;padding:8px 14px;border-radius:7px;cursor:pointer;
+ background:#1d3b1d;color:var(--ok);border:1px solid #2a6b35;}
+ button.act:hover:not(:disabled){background:var(--ok);color:#0a0b0c;}
+ button.act.amber{background:#3b2e08;color:#f0b400;border-color:#5a4710;}
+ button.act.amber:hover:not(:disabled){background:#f0b400;color:#0a0b0c;}
+ button.act.blue{background:#162b3b;color:var(--blue);border-color:#365778;}
+ button.act.blue:hover:not(:disabled){background:var(--blue);color:#0a0b0c;}
+ button.act:disabled{opacity:.4;cursor:not-allowed;}
+ a.act{font:700 13px inherit;padding:8px 14px;border-radius:7px;cursor:pointer;text-decoration:none;text-align:center;display:inline-block;
+ background:#3b2e08;color:#f0b400;border:1px solid #5a4710;}
+ a.act:hover{background:#f0b400;color:#0a0b0c;}
+ .ready{margin-top:24px;padding:20px;background:linear-gradient(180deg,#143b1d22,transparent);
+ border:1px solid #2a6b35;border-radius:12px;text-align:center;}
+ .ready h2{margin:0 0 8px;color:var(--ok);font-size:18px;}
+ .ready p{margin:0 0 12px;color:var(--mut);font-size:13px;}
+ .ready a.go{display:inline-block;background:var(--ok);color:#0a0b0c;
+ padding:10px 22px;border-radius:9px;font-weight:700;text-decoration:none;}
+ .toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);
+ background:#17191b;border:1px solid var(--line);color:var(--ink);
+ padding:10px 18px;border-radius:9px;z-index:30;font-size:13px;box-shadow:0 6px 24px rgba(0,0,0,.5);}
+ .toast.ok{border-color:#2a6b35;color:var(--ok);}
+ .toast.bad{border-color:#5a1515;color:var(--bad);}
+ .small{font-size:11.5px;color:var(--mut);font-family:ui-monospace,monospace;}
+ .count{background:var(--panel);border:1px solid var(--line);border-radius:8px;
+ padding:8px 14px;display:flex;gap:18px;font-size:13px;}
+ .count b{color:var(--ink);font-weight:700;font-variant-numeric:tabular-nums;}
+</style>
+</head>
+<body>
+<header>
+ <h1><span class="ico">🛒</span> Etsy activation matrix</h1>
+ <span class="pill" id="overall">checking…</span>
+ <span class="count" id="counts" style="display:none">
+ <span><b id="bktN">0</b> in bucket</span>
+ <span><b id="bdlN">0</b> bundles ready</span>
+ </span>
+</header>
+<main>
+ <div class="matrix" id="matrix">
+ <!-- Step 1 — create the Etsy app -->
+ <div class="step" id="step-app">
+ <div class="num">1</div>
+ <div class="body">
+ <h3>Create an Etsy app</h3>
+ <p>Go to the Etsy Developer portal and create a new app. Set the <b>Callback URL</b> to exactly: <code>http://localhost:3008/etsy-oauth-callback</code></p>
+ <p>Copy the <b>Keystring</b> (= your API key) — you'll paste it in Step 2.</p>
+ </div>
+ <div class="ctl"><div>
+ <a class="act" href="https://www.etsy.com/developers/your-apps" target="_blank">Open Etsy developer portal ↗</a>
+ <span class="status" id="st-app">manual</span>
+ </div></div>
+ </div>
+
+ <!-- Step 2 — paste API key -->
+ <div class="step" id="step-key">
+ <div class="num">2</div>
+ <div class="body">
+ <h3>Paste your Etsy API key</h3>
+ <p>The Keystring from Step 1. Saved to <code>~/Projects/secrets-manager/.env</code>.</p>
+ <input id="keyInput" type="text" placeholder="paste keystring (16–128 chars)" autocomplete="off" spellcheck="false">
+ <span class="small" id="keyMask"></span>
+ </div>
+ <div class="ctl"><div>
+ <button class="act" id="saveKey">Save key</button>
+ <span class="status" id="st-key">missing</span>
+ </div></div>
+ </div>
+
+ <!-- Step 3 — OAuth dance -->
+ <div class="step" id="step-oauth">
+ <div class="num">3</div>
+ <div class="body">
+ <h3>Authorize the app for your shop</h3>
+ <p>Opens Etsy in a new tab. Log in, click <b>Allow access</b>. We capture the callback automatically and write the tokens.</p>
+ <p class="small">Spawns <code>scripts/etsy-oauth-helper.js</code>; log at <code>data/etsy-oauth.log</code>.</p>
+ </div>
+ <div class="ctl"><div>
+ <button class="act amber" id="startOauth">Start OAuth →</button>
+ <span class="status" id="st-oauth">waiting</span>
+ </div></div>
+ </div>
+
+ <!-- Step 4 — confirm shop_id -->
+ <div class="step" id="step-shop">
+ <div class="num">4</div>
+ <div class="body">
+ <h3>Confirm shop ID</h3>
+ <p>OAuth normally auto-discovers your <code>ETSY_SHOP_ID</code>. If it didn't (sometimes Etsy returns a different shape), paste it here — find it at <code>etsy.com/your/shops/me/dashboard</code> → URL or the JSON dump.</p>
+ <input id="shopInput" type="text" placeholder="numeric shop id (4–12 digits)" autocomplete="off" spellcheck="false">
+ </div>
+ <div class="ctl"><div>
+ <button class="act blue" id="saveShop">Save shop id</button>
+ <span class="status" id="st-shop">missing</span>
+ </div></div>
+ </div>
+
+ <!-- Step 5 — test connectivity -->
+ <div class="step" id="step-test">
+ <div class="num">5</div>
+ <div class="body">
+ <h3>Test connectivity</h3>
+ <p>Calls <code>GET /v3/application/users/me</code> with the stored token. 200 → green.</p>
+ <pre class="small" id="testOut" style="background:#0a0b0c;padding:8px 11px;border-radius:6px;margin:0;display:none;max-height:200px;overflow:auto"></pre>
+ </div>
+ <div class="ctl"><div>
+ <button class="act" id="testBtn">Test now</button>
+ <span class="status" id="st-test">untested</span>
+ </div></div>
+ </div>
+ </div>
+
+ <div class="ready" id="ready" style="display:none">
+ <h2>✓ Account activated</h2>
+ <p>You're ready to push bundles to Etsy. Each push generates assets just-in-time and creates a draft listing.</p>
+ <a class="go" href="" id="goBucket">→ Open the Etsy bucket</a>
+ </div>
+</main>
+<script>
+const ADMIN=new URLSearchParams(location.search).get('admin');
+const q=s=>ADMIN?(s+(s.includes('?')?'&':'?')+'admin='+encodeURIComponent(ADMIN)):s;
+const $=id=>document.getElementById(id);
+
+function setStatus(id, label, kind){ const e=$('st-'+id); if(e){ e.textContent=label; e.className='status '+(kind||''); } }
+function toast(msg, kind='ok'){
+ const t=document.createElement('div'); t.className='toast '+kind; t.textContent=msg;
+ document.body.appendChild(t); setTimeout(()=>t.remove(), 4000);
+}
+
+async function refresh(){
+ const r=await fetch(q('/api/etsy-setup/status'));
+ if(!r.ok){ $('overall').innerHTML='⚠ '+r.status+(r.status===404?' (append ?admin=TOKEN)':''); return null; }
+ const j=await r.json();
+ const s=j.steps;
+ setStatus('app', 'manual', s.client_id.ok ? 'ok' : ''); // step 1 = the act of creating the app; we infer done once a key is saved
+ setStatus('key', s.client_id.ok ? 'saved' : 'missing', s.client_id.ok ? 'ok' : 'bad');
+ setStatus('oauth', s.access_token.ok ? 'authorized' : 'waiting', s.access_token.ok ? 'ok' : (s.client_id.ok ? 'warn' : 'bad'));
+ setStatus('shop', s.shop_id.ok ? `id ${s.shop_id.value}` : 'missing', s.shop_id.ok ? 'ok' : 'warn');
+ if(s.client_id.ok) $('keyMask').textContent = 'saved: '+s.client_id.value;
+ if(s.shop_id.ok) $('shopInput').placeholder = 'current: '+s.shop_id.value;
+ // overall pill
+ const doneCt = Object.values(s).filter(x=>x.ok).length;
+ $('overall').innerHTML = `<b>${doneCt}</b>/4 steps complete`;
+ $('counts').style.display='inline-flex';
+ $('bktN').textContent = j.counts.bucket_queued;
+ $('bdlN').textContent = j.counts.bundles_ready;
+ // Show ready banner when fully wired (client_id + access + shop_id at min)
+ if(s.client_id.ok && s.access_token.ok && s.shop_id.ok){
+ $('ready').style.display='block';
+ $('goBucket').href = q('/admin/etsy-bucket');
+ }
+ return j;
+}
+
+$('saveKey').onclick = async () => {
+ const v=$('keyInput').value.trim(); if(!v){ toast('paste a key first', 'bad'); return; }
+ const r=await fetch(q('/api/etsy-setup/save-key'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ETSY_CLIENT_ID:v})});
+ const j=await r.json();
+ if(!r.ok){ toast('save failed: '+(j.error||r.status),'bad'); return; }
+ $('keyInput').value=''; toast('✓ key saved'); refresh();
+};
+$('saveShop').onclick = async () => {
+ const v=$('shopInput').value.trim(); if(!v){ toast('paste your shop id','bad'); return; }
+ const r=await fetch(q('/api/etsy-setup/save-shop-id'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ETSY_SHOP_ID:v})});
+ const j=await r.json();
+ if(!r.ok){ toast('save failed: '+(j.error||r.status),'bad'); return; }
+ $('shopInput').value=''; toast('✓ shop id saved'); refresh();
+};
+$('startOauth').onclick = async () => {
+ const r=await fetch(q('/api/etsy-setup/start-oauth'),{method:'POST'});
+ const j=await r.json();
+ if(!r.ok){ toast('start failed: '+(j.error||r.status),'bad'); return; }
+ toast(j.already_running ? '↻ OAuth already running (pid '+j.pid+')' : '→ OAuth helper started — check the browser tab Etsy just opened');
+ // Poll every 3s for up to 5 min; refresh status whenever tokens land.
+ const t0=Date.now();
+ (function loop(){
+ refresh().then(j2=>{
+ if(j2 && j2.steps.access_token.ok) toast('✓ tokens captured — OAuth complete');
+ else if(Date.now()-t0 < 5*60*1000) setTimeout(loop, 3000);
+ });
+ })();
+};
+$('testBtn').onclick = async () => {
+ setStatus('test','testing…',''); $('testOut').style.display='none';
+ const r=await fetch(q('/api/etsy-setup/test'));
+ const j=await r.json();
+ $('testOut').textContent=JSON.stringify(j.body, null, 2); $('testOut').style.display='block';
+ if(r.ok && j.ok){ setStatus('test','✓ '+j.status,'ok'); toast('✓ /users/me returned '+j.status); }
+ else { setStatus('test','✗ '+(j.status||'error'),'bad'); toast('test failed: '+(j.body?.error||j.status||'?'),'bad'); }
+};
+refresh();
+</script>
+</body>
+</html>
diff --git a/scripts/heal-region-inpaint.py b/scripts/heal-region-inpaint.py
index 783003a..6d7424b 100644
--- a/scripts/heal-region-inpaint.py
+++ b/scripts/heal-region-inpaint.py
@@ -21,7 +21,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from importlib import import_module
seam = import_module('seam-defect-boxes') # reuse the scanner for before/after
-REPLICATE_MODEL = "stability-ai/sdxl-inpainting"
+REPLICATE_MODEL = "lucataco/sdxl-inpainting:a5b13068cc81a89a4fbeefeccc774869fcb34df4dbc92c1555e0f2771d49dde7"
EDGE_FRAC = 0.08 # boxes within 8% of edge get mirror-painted
FEATHER = 8 # mask edge feather (px) so inpaint blends in
ROOT = Path(__file__).resolve().parent.parent
@@ -39,7 +39,13 @@ def load_env():
def psql_scalar(sql):
r = subprocess.run(['psql', '-d', 'dw_unified', '-tAc', sql], capture_output=True, text=True, check=True)
- return r.stdout.strip()
+ # Defensive: triggers on all_designs (settlement, defect-log) sometimes emit
+ # extra status lines ("INSERT 0 1") into stdout. Take the FIRST non-empty
+ # line, which is always the RETURNING value for our scalar queries.
+ for line in r.stdout.splitlines():
+ s = line.strip()
+ if s and not s.startswith(('INSERT ', 'UPDATE ', 'NOTICE')): return s
+ return ''
def fetch_design(design_id):
row = psql_scalar(
@@ -87,15 +93,17 @@ def run_inpaint(src_png_path, mask_img, prompt):
raise RuntimeError('REPLICATE_API_TOKEN missing in env')
import replicate
src = Image.open(src_png_path).convert('RGB')
+ # lucataco/sdxl-inpainting input schema: image, mask, prompt, steps, strength,
+ # scheduler, seed, num_outputs (no negative_prompt / no guidance_scale).
out = replicate.run(
REPLICATE_MODEL,
input={
'image': to_data_url(src),
'mask': to_data_url(mask_img),
'prompt': prompt,
- 'negative_prompt': 'blur, fuzzy, gradient, soft edges, anti-aliasing, blurred line',
- 'num_inference_steps': 30,
- 'guidance_scale': 7.5,
+ 'steps': 30,
+ 'strength': 0.95, # strong inpaint — full replacement in masked area
+ 'num_outputs': 1,
}
)
# Replicate returns a URL or list of URLs
diff --git a/server.js b/server.js
index 85cc1c5..3bfb9a2 100644
--- a/server.js
+++ b/server.js
@@ -1273,6 +1273,133 @@ app.get('/admin/etsy-bucket', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin', 'etsy-bucket.html'));
});
+// ── Etsy account activation wizard ─────────────────────────────────────────
+// Single-page setup matrix at /admin/etsy-setup. Each row is one activation
+// step; status is computed server-side from what's in secrets-manager/.env +
+// a live Etsy /users/me probe. The wizard's buttons hit these endpoints:
+// /api/etsy-setup/status — JSON of every check
+// /api/etsy-setup/save-key — POST { ETSY_CLIENT_ID }
+// /api/etsy-setup/start-oauth— spawns scripts/etsy-oauth-helper.js
+// /api/etsy-setup/test — calls Etsy /users/me with current token
+app.get('/admin/etsy-setup', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+ res.sendFile(path.join(__dirname, 'public', 'admin', 'etsy-setup.html'));
+});
+function readSecretsEnv() {
+ const p = path.join(require('os').homedir(), 'Projects', 'secrets-manager', '.env');
+ const out = {};
+ if (!fs.existsSync(p)) return out;
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
+ if (m) out[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+ }
+ return out;
+}
+function writeSecretsLine(key, value) {
+ const p = path.join(require('os').homedir(), 'Projects', 'secrets-manager', '.env');
+ let text = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
+ const re = new RegExp(`^${key}\\s*=.*$`, 'm');
+ if (re.test(text)) text = text.replace(re, `${key}=${value}`);
+ else text += (text.endsWith('\n') ? '' : '\n') + `${key}=${value}\n`;
+ fs.writeFileSync(p, text);
+}
+app.get('/api/etsy-setup/status', async (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const env = readSecretsEnv();
+ const has = k => env[k] && env[k].length > 4;
+ const mask = v => (v ? v.slice(0, 6) + '…' + v.slice(-4) : null);
+ // Count bundle metadata + bucket items so the wizard can show "ready to push N"
+ let bucketCount = 0, bundleCount = 0;
+ try { bucketCount = +psqlQuery("SELECT count(*) FROM wallco_etsy_bucket WHERE state='queued';") || 0; } catch {}
+ try {
+ const today = new Date().toISOString().slice(0, 10);
+ const dir = path.join(__dirname, 'data', 'etsy-exports', today, 'bundles');
+ if (fs.existsSync(dir)) bundleCount = fs.readdirSync(dir).filter(d => {
+ try { return fs.statSync(path.join(dir, d)).isDirectory() && fs.existsSync(path.join(dir, d, 'listing.json')); } catch { return false; }
+ }).length;
+ } catch {}
+ res.json({
+ ok: true,
+ steps: {
+ client_id: { ok: has('ETSY_CLIENT_ID'), value: mask(env.ETSY_CLIENT_ID) },
+ access_token: { ok: has('ETSY_OAUTH_ACCESS_TOKEN'), value: mask(env.ETSY_OAUTH_ACCESS_TOKEN) },
+ refresh_token:{ ok: has('ETSY_OAUTH_REFRESH_TOKEN'), value: mask(env.ETSY_OAUTH_REFRESH_TOKEN) },
+ shop_id: { ok: has('ETSY_SHOP_ID'), value: env.ETSY_SHOP_ID || null },
+ },
+ counts: { bucket_queued: bucketCount, bundles_ready: bundleCount },
+ });
+});
+app.post('/api/etsy-setup/save-key', express.json({ limit: '4kb' }), (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const key = String((req.body && req.body.ETSY_CLIENT_ID) || '').trim();
+ if (!/^[A-Za-z0-9_-]{16,128}$/.test(key)) return res.status(400).json({ error: 'ETSY_CLIENT_ID looks malformed (expected 16-128 chars, alphanumeric)' });
+ try { writeSecretsLine('ETSY_CLIENT_ID', key); res.json({ ok: true }); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+});
+// Optional convenience: stamp shop_id in case OAuth didn't auto-discover it.
+app.post('/api/etsy-setup/save-shop-id', express.json({ limit: '256b' }), (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const v = String((req.body && req.body.ETSY_SHOP_ID) || '').trim();
+ if (!/^\d{4,12}$/.test(v)) return res.status(400).json({ error: 'shop_id must be a numeric id' });
+ try { writeSecretsLine('ETSY_SHOP_ID', v); res.json({ ok: true }); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+});
+// Spawn the OAuth helper as a detached child. It opens the Etsy authorize URL
+// in the user's browser, listens on :3008 for the callback, exchanges the code,
+// fetches shop_id, and writes the tokens to secrets-manager/.env. The wizard
+// page polls /status to detect completion.
+const _spawnedOAuth = { pid: null, started: 0 };
+app.post('/api/etsy-setup/start-oauth', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const env = readSecretsEnv();
+ if (!env.ETSY_CLIENT_ID) return res.status(400).json({ error: 'save ETSY_CLIENT_ID first' });
+ // Already running? Return current PID.
+ if (_spawnedOAuth.pid) {
+ try { process.kill(_spawnedOAuth.pid, 0); return res.json({ ok: true, already_running: true, pid: _spawnedOAuth.pid }); }
+ catch { _spawnedOAuth.pid = null; } // dead, restart
+ }
+ try {
+ const { spawn } = require('child_process');
+ const child = spawn('node',
+ [path.join(__dirname, 'scripts', 'etsy-oauth-helper.js')],
+ { cwd: __dirname, detached: true,
+ stdio: ['ignore',
+ fs.openSync(path.join(__dirname, 'data', 'etsy-oauth.log'), 'a'),
+ fs.openSync(path.join(__dirname, 'data', 'etsy-oauth.log'), 'a')] });
+ child.unref();
+ _spawnedOAuth.pid = child.pid; _spawnedOAuth.started = Date.now();
+ res.json({ ok: true, pid: child.pid, log: 'data/etsy-oauth.log',
+ authorize_redirect: 'http://localhost:3008/etsy-oauth-callback',
+ note: 'Etsy will open in your browser — log in + click Allow. Wizard polls /status for token completion.' });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+// Live connectivity check — hits GET /v3/application/users/me with the current
+// access token. 200 = green. 401 = token expired (run refresh).
+app.get('/api/etsy-setup/test', async (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const env = readSecretsEnv();
+ if (!env.ETSY_CLIENT_ID || !env.ETSY_OAUTH_ACCESS_TOKEN) {
+ return res.status(400).json({ error: 'missing ETSY_CLIENT_ID or ETSY_OAUTH_ACCESS_TOKEN' });
+ }
+ const r = await new Promise((resolve) => {
+ const https = require('https');
+ const req2 = https.request({
+ method: 'GET', hostname: 'openapi.etsy.com', path: '/v3/application/users/me',
+ headers: { 'x-api-key': env.ETSY_CLIENT_ID, Authorization: `Bearer ${env.ETSY_OAUTH_ACCESS_TOKEN}`, Accept: 'application/json' },
+ }, resp => {
+ let data = '';
+ resp.on('data', c => data += c);
+ resp.on('end', () => {
+ let j; try { j = data ? JSON.parse(data) : {}; } catch { j = { raw: data }; }
+ resolve({ status: resp.statusCode, body: j });
+ });
+ });
+ req2.on('error', e => resolve({ status: 0, body: { error: e.message } }));
+ req2.end();
+ });
+ res.json({ ok: r.status >= 200 && r.status < 300, status: r.status, body: r.body });
+});
+
// SMART INPAINT heal — replaces the old PIL band-blur (which produced the
// "fuzzy T/cross" defect — feedback_wallco_heal_blur_band_edges_agent_blind).
// Uses Replicate SDXL-inpainting to fill ONLY the curator-selected boxes with
@@ -1300,7 +1427,11 @@ app.post('/api/seam-debug/:id/heal', express.json({ limit: '32kb' }), (req, res)
const args = [path.join(__dirname, 'scripts', 'heal-region-inpaint.py'),
'--id', String(id), '--boxes', JSON.stringify(boxIds)];
if (manualBoxes.length) { args.push('--manual', JSON.stringify(manualBoxes)); }
- const out = _execFileSyncSeam('python3', args, { encoding: 'utf8', timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
+ // Use the vectorize venv python (has cairosvg + numpy + PIL + replicate);
+ // system python3 lacks 'replicate' which causes the heal to crash with
+ // ModuleNotFoundError on first call.
+ const venvPy = path.join(__dirname, 'scripts', 'vectorize', '.venv', 'bin', 'python3');
+ const out = _execFileSyncSeam(venvPy, args, { encoding: 'utf8', timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
res.json(JSON.parse(out));
} catch (e) {
res.status(500).json({ error: e.message, stderr: (e.stderr || '').toString().slice(0, 500) });
@@ -10254,6 +10385,43 @@ app.get('/design/:id', (req, res) => {
} catch (e) { console.warn('[design/:id] backfill failed for', id, e.message.slice(0, 120)); }
}
+ // Snapshot rows often lack the palette JSON (it's persisted in PG but not
+ // included in data/designs.json). Backfill on demand so the Full palette
+ // section renders + the hue-sort below has something to work with.
+ if (!Array.isArray(design.palette) || design.palette.length === 0) {
+ try {
+ const palRaw = psqlQuery(`SELECT COALESCE(palette::text,'') FROM all_designs WHERE id=${id};`);
+ if (palRaw && palRaw.length > 2) {
+ const parsed = JSON.parse(palRaw);
+ if (Array.isArray(parsed) && parsed.length) design.palette = parsed;
+ }
+ } catch { /* PG hiccup — leave palette absent; Full palette section will skip */ }
+ }
+
+ // Palette hue-sort (Steve 2026-05-28: "sort colors in order. they are too all
+ // over the place"). Extraction order from the DB is random; for display we
+ // want color-wheel order red→yellow→green→cyan→blue→magenta with grays at
+ // the tail. One sort here flows through Full palette, Color story, Color
+ // isolate, paint chips — every chip-render path reads design.palette.
+ if (Array.isArray(design.palette) && design.palette.length > 1) {
+ const _hueOf = (hex) => {
+ const m = String(hex || '').match(/^#?([0-9a-f]{6})$/i);
+ if (!m) return 9999;
+ const n = parseInt(m[1], 16);
+ const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
+ if (d < 0.04) return 10000 + (1 - max) * 100; // gray bucket → end, dark→light
+ let h;
+ if (max === r) h = ((g - b) / d) % 6;
+ else if (max === g) h = (b - r) / d + 2;
+ else h = (r - g) / d + 4;
+ h *= 60; if (h < 0) h += 360;
+ return h;
+ };
+ // Copy first so we don't mutate the shared DESIGNS in-memory snapshot
+ design = { ...design, palette: design.palette.slice().sort((a, b) => _hueOf(a.hex) - _hueOf(b.hex)) };
+ }
+
// Conditional GET on per-design created_at. Designs rarely mutate after
// creation — admin room-mockup generations bump CATALOG_LAST_MODIFIED but
// the per-design page itself reflects design.created_at for crawler caching.
← 09b88ed cactus-curator: click-any-image → full-screen heal modal (dr
·
back to Wallco Ai
·
design page: backfill palette from PG when snapshot lacks it 2d60ba7 →