[object Object]

← back to Pattern Vault

wpb-uploaders: control panel (:9813) — click-to-launch per-platform login/dry-run/verify, live status

7dbc8b463062e993c74e720e0d58b92ce9402868 · 2026-07-09 22:23:32 -0700 · Steve

Files touched

Diff

commit 7dbc8b463062e993c74e720e0d58b92ce9402868
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 9 22:23:32 2026 -0700

    wpb-uploaders: control panel (:9813) — click-to-launch per-platform login/dry-run/verify, live status
---
 wpb-uploaders/control-panel.js          | 115 ++++++++++++++++++++++++++++++++
 wpb-uploaders/public/control-panel.html |  93 ++++++++++++++++++++++++++
 2 files changed, 208 insertions(+)

diff --git a/wpb-uploaders/control-panel.js b/wpb-uploaders/control-panel.js
new file mode 100644
index 0000000..9249523
--- /dev/null
+++ b/wpb-uploaders/control-panel.js
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/*
+ * WPB Uploader Control Panel — a local dashboard YOU drive.
+ * Buttons launch each platform's login-capture / dry-run / status; live status per platform.
+ * Zero-dependency Node. Basic auth admin:DW2024!.  →  http://localhost:9813
+ *
+ *   node control-panel.js
+ */
+'use strict';
+const http = require('http'), fs = require('fs'), path = require('path'), os = require('os');
+const { spawn, execFile } = require('child_process');
+
+const PORT = process.env.PORT || 9813;
+const ROOT = __dirname;
+const PLATFORMS = ['spoonflower', 'etsy', 'creativemarket', 'redbubble', 'society6', 'patternbank'];
+const AUTH_USER = 'admin', AUTH_PASS = 'DW2024!';
+
+// track running capture children so the UI can show "login window open…"
+const running = {}; // platform -> {pid, startedAt}
+
+function loadPlatform(key) { try { return require(path.join(ROOT, 'platforms', `${key}.js`)); } catch { return null; } }
+
+// verify a captured session is REAL (load account page in a probe, check auth markers)
+function verifySession(key) {
+  return new Promise((resolve) => {
+    const cfg = loadPlatform(key);
+    if (!cfg || cfg.apiBased || !cfg.sessionFile || !fs.existsSync(cfg.sessionFile)) return resolve({ captured: false });
+    const code = `
+      const { chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright');
+      (async () => { try {
+        const b = await chromium.launch({ headless: true });
+        const ctx = await b.newContext({ storageState: ${JSON.stringify(cfg.sessionFile)} });
+        const pg = await ctx.newPage();
+        await pg.goto(${JSON.stringify(cfg.healthUrl)}, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(()=>{});
+        await pg.waitForTimeout(1500);
+        const u = pg.url();
+        const bad = /login|signin/i.test(u) || !!(await pg.$('input[type=password]').catch(()=>null));
+        const logout = !!(await pg.$('a[href*=logout i], a[href*=sign_out i], button:has-text("Sign out"), button:has-text("Log out")').catch(()=>null));
+        const txt = ((await pg.evaluate(()=>document.body.innerText).catch(()=>'')||'')).toLowerCase();
+        const words = /sign out|log out|my account|dashboard|purchases|downloads|my shop/.test(txt);
+        console.log(JSON.stringify({ captured: !bad && (logout||words) }));
+        await b.close();
+      } catch(e) { console.log(JSON.stringify({ captured:false, err:String(e).slice(0,80) })); } })();`;
+    execFile(process.execPath, ['-e', code], { timeout: 45000 }, (err, stdout) => {
+      try { resolve(JSON.parse(stdout.trim().split('\n').pop())); } catch { resolve({ captured: false }); }
+    });
+  });
+}
+
+function statusOf(key) {
+  const cfg = loadPlatform(key);
+  const armed = key === 'spoonflower'
+    ? fs.existsSync(path.join(ROOT, '..', 'whimsical-compare', 'daily', 'ARMED'))
+    : fs.existsSync(path.join(ROOT, 'platforms', `${key}.ARMED`));
+  const hasSession = key === 'spoonflower'
+    ? fs.existsSync(path.join(os.homedir(), '.config/pattern-vault/sf-state.json'))
+    : !!(cfg && cfg.sessionFile && fs.existsSync(cfg.sessionFile));
+  return {
+    key, name: cfg ? cfg.name : key, brand: cfg ? cfg.brand : '', apiBased: !!(cfg && cfg.apiBased),
+    armed, hasSession, running: !!running[key],
+    blocker: key === 'spoonflower' ? 'none — working + armed'
+      : cfg && cfg.apiBased ? (process.env.ETSY_OAUTH_TOKEN ? 'token present' : 'needs Etsy OAuth token')
+      : hasSession ? 'session captured — needs DOM calibration'
+      : ['society6', 'patternbank'].includes(key) ? 'account pending confirmation'
+      : 'needs login (click Log in)',
+  };
+}
+
+// ---- http ----
+function auth(req) { const h = req.headers.authorization || ''; if (!h.startsWith('Basic ')) return false; const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':'); return u === AUTH_USER && p === AUTH_PASS; }
+function send(res, code, body, type) { res.writeHead(code, { 'Content-Type': type || 'application/json', 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); }
+
+const server = http.createServer(async (req, res) => {
+  const url = new URL(req.url, `http://localhost:${PORT}`);
+  const p = url.pathname;
+  if (p === '/healthz') return send(res, 200, { ok: true });
+  if (!auth(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WPB"' }); return res.end('auth'); }
+
+  if (p === '/api/status') {
+    const rows = PLATFORMS.map(statusOf);
+    return send(res, 200, { rows, generatedAt: new Date().toISOString() });
+  }
+
+  // launch a login-capture window for a platform (opens a browser for Steve to log into)
+  const capM = p.match(/^\/api\/capture\/(\w+)$/);
+  if (capM && req.method === 'POST') {
+    const key = capM[1];
+    if (!['creativemarket', 'redbubble', 'society6', 'patternbank'].includes(key)) return send(res, 400, { ok: false, error: 'not a web-login platform' });
+    if (running[key]) return send(res, 200, { ok: true, already: true });
+    const engine = url.searchParams.get('engine') === 'chromium' ? 'chromium' : 'webkit';
+    const child = spawn(process.execPath, [path.join(ROOT, 'capture-session.js'), key],
+      { cwd: ROOT, detached: true, stdio: 'ignore', env: { ...process.env, WPB_ENGINE: engine } });
+    running[key] = { pid: child.pid, startedAt: Date.now() };
+    child.on('exit', () => { delete running[key]; });
+    child.unref();
+    return send(res, 200, { ok: true, launched: key, engine, note: 'a browser window is opening — log in there' });
+  }
+
+  // verify a platform's captured session is genuinely authenticated
+  const verM = p.match(/^\/api\/verify\/(\w+)$/);
+  if (verM && req.method === 'POST') { const r = await verifySession(verM[1]); return send(res, 200, r); }
+
+  // Etsy dry-run (proves the 38-payload mapping, no network)
+  if (p === '/api/etsy/dryrun') { const cfg = loadPlatform('etsy'); return send(res, 200, cfg && cfg.dryRun ? cfg.dryRun() : { error: 'no etsy' }); }
+
+  // static
+  let f = p === '/' ? '/control-panel.html' : p;
+  const abs = path.join(ROOT, 'public', path.normalize(f).replace(/^(\.\.[\/\\])+/, ''));
+  if (abs.startsWith(path.join(ROOT, 'public')) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
+    const ext = path.extname(abs);
+    return send(res, 200, fs.readFileSync(abs), { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain');
+  }
+  return send(res, 404, { error: 'not found' });
+});
+server.listen(PORT, () => console.log(`WPB Uploader Control Panel → http://localhost:${PORT} (admin:DW2024!)`));
diff --git a/wpb-uploaders/public/control-panel.html b/wpb-uploaders/public/control-panel.html
new file mode 100644
index 0000000..9729681
--- /dev/null
+++ b/wpb-uploaders/public/control-panel.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>WPB Uploader Control Panel</title>
+<style>
+  :root{--bg:#0d0e12;--card:#15171e;--line:#262a35;--ink:#f2f2f5;--dim:#9aa0ad;--accent:#f4a261;--good:#2ec4b6;--warn:#e9c46a;--bad:#e76f51;--pending:#8a8f9c}
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+  .wrap{max-width:900px;margin:0 auto;padding:26px 20px 80px}
+  h1{font:700 22px/1 Georgia,serif;margin:0 0 4px}
+  h1 b{color:var(--accent)}
+  .sub{color:var(--dim);font-size:13px;margin-bottom:22px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin-bottom:12px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+  .card .nm{font-weight:700;font-size:16px}
+  .card .meta{color:var(--dim);font-size:12.5px}
+  .pill{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 9px;border-radius:999px;white-space:nowrap}
+  .pill.good{background:rgba(46,196,182,.16);color:var(--good)}
+  .pill.warn{background:rgba(233,196,106,.16);color:var(--warn)}
+  .pill.bad{background:rgba(231,111,81,.16);color:var(--bad)}
+  .pill.pending{background:rgba(138,143,156,.16);color:var(--pending)}
+  .grow{flex:1;min-width:180px}
+  .actions{display:flex;gap:8px;flex-wrap:wrap}
+  button{background:var(--accent);color:#141414;border:0;border-radius:9px;padding:9px 15px;font-weight:700;font-size:13px;cursor:pointer}
+  button.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
+  button:disabled{opacity:.4;cursor:not-allowed}
+  button:hover:not(:disabled){filter:brightness(1.07)}
+  .msg{font-size:12.5px;color:var(--dim);margin-top:8px;width:100%}
+  input.tok{background:#1b1e27;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 10px;font-size:12.5px;min-width:220px;flex:1}
+  .foot{color:var(--dim);font-size:11.5px;margin-top:24px;text-align:center}
+  a{color:var(--accent)}
+</style></head><body>
+<div class="wrap">
+  <h1><b>WPB</b> Uploader Control Panel</h1>
+  <div class="sub">Launch each platform yourself. Spoonflower is live; the rest need one thing from you.</div>
+  <div id="cards"></div>
+  <div class="foot">Local · admin:•••• · :9813 · Spoonflower uploads daily on its own. $0 (local).</div>
+</div>
+<script>
+const $ = s => document.querySelector(s);
+const api = (u,m) => fetch(u,{method:m||'GET'}).then(r=>r.json());
+function pill(row){
+  if(row.key==='spoonflower') return '<span class="pill good">● LIVE</span>';
+  if(row.running) return '<span class="pill warn">◐ login window open…</span>';
+  if(row.apiBased) return row.blocker==='token present'?'<span class="pill warn">token — dry-run ready</span>':'<span class="pill bad">needs token</span>';
+  if(row.hasSession) return '<span class="pill warn">session ✓ — calibrating</span>';
+  if(row.blocker.includes('pending')) return '<span class="pill pending">account pending</span>';
+  return '<span class="pill bad">needs login</span>';
+}
+function actions(row){
+  if(row.key==='spoonflower') return '<span class="meta">Uploads daily via the armed cron.</span>';
+  if(row.apiBased) return `
+    <input class="tok" id="etsytok" placeholder="paste Etsy OAuth token (listings_w)…">
+    <button class="ghost" onclick="etsyDry()">Preview 38 drafts</button>
+    <button onclick="etsyPush()" title="gated — pushes drafts once token is set">Push drafts</button>`;
+  if(row.blocker.includes('pending')) return '<span class="meta">Confirm the account (check email), then it moves to “needs login”.</span>';
+  // web-login platforms
+  return `
+    <button onclick="cap('${row.key}')" ${row.running?'disabled':''}>${row.hasSession?'Re-login':'Log in'}</button>
+    <button class="ghost" onclick="verify('${row.key}')">Verify session</button>`;
+}
+async function render(){
+  const {rows} = await api('/api/status');
+  $('#cards').innerHTML = rows.map(r=>`
+    <div class="card" data-k="${r.key}">
+      <div class="grow">
+        <div class="nm">${r.name} ${pill(r)}</div>
+        <div class="meta">${r.brand} · ${r.blocker}</div>
+      </div>
+      <div class="actions">${actions(r)}</div>
+      <div class="msg" id="msg-${r.key}"></div>
+    </div>`).join('');
+}
+function msg(k,t){ const el=$('#msg-'+k); if(el) el.textContent=t; }
+async function cap(k){
+  msg(k,'launching a login window… log in there, then click “Verify session”.');
+  const r = await api('/api/capture/'+k,'POST');
+  msg(k, r.ok ? '✅ browser opening — log in, then click “Verify session”.' : ('✗ '+(r.error||'failed')));
+  setTimeout(render, 1200);
+}
+async function verify(k){
+  msg(k,'checking your session (loading the account page)…');
+  const r = await api('/api/verify/'+k,'POST');
+  msg(k, r.captured ? '✅ session is GENUINELY logged in — captured & verified.' : '✗ not logged in yet (finish the login, then Verify again).');
+  render();
+}
+async function etsyDry(){
+  const r = await api('/api/etsy/dryrun');
+  msg('etsy', r.error?('✗ '+r.error):`✅ ${r.count} drafts mapped @ $${r.price}. Blockers: ${(r.blockers||[]).join('; ')||'none'}. (Sample title: ${(r.sample&&r.sample[0]&&r.sample[0].title||'').slice(0,50)})`);
+}
+function etsyPush(){ msg('etsy','⛔ Pushing live drafts is gated — paste the token + say “push etsy” to Claude, who’ll fire the 38 drafts (Steve-approved).'); }
+render(); setInterval(render, 5000);
+</script>
+</body></html>

← a0ae52d capture-session: WebKit (Safari engine) default per Steve; c  ·  back to Pattern Vault  ·  auto-save: 2026-07-13T06:53:18 (2 files) — data/trend-board. 378dec5 →