[object Object]

← 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

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 →