@jhizzard/termdeck 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2478,6 +2478,16 @@
2478
2478
 
2479
2479
  let setupModalOpen = false;
2480
2480
 
2481
+ // Sprint 25 T3 — Supabase MCP auto-flow state. Closure-scoped to this module
2482
+ // so a re-render of the tier list (refreshSetupStatus) doesn't lose an
2483
+ // in-flight picker step. The PAT lives here only between /connect success
2484
+ // and /select success; we null `supabaseAutoState` after /select returns ok.
2485
+ // Never log .pat. Never assign it to a property of `state` or `window`.
2486
+ let supabaseAutoState = null;
2487
+ // Cache of the last /api/setup payload so we can re-render the tier list
2488
+ // (e.g. PAT entry → project picker) without forcing another HTTP fetch.
2489
+ let lastSetupData = null;
2490
+
2481
2491
  function ensureSetupModal() {
2482
2492
  if (document.getElementById('setupModal')) return;
2483
2493
  const modal = document.createElement('div');
@@ -2546,6 +2556,7 @@
2546
2556
  if (recheckBtn) { recheckBtn.disabled = true; recheckBtn.textContent = 're-checking…'; }
2547
2557
  try {
2548
2558
  const data = await api('GET', '/api/setup');
2559
+ lastSetupData = data;
2549
2560
  renderSetupTiers(data);
2550
2561
  const cur = Number(data.tier) || 1;
2551
2562
  if (subtitle) {
@@ -2582,8 +2593,13 @@
2582
2593
  // Sprint 23 T2: tier 2 renders a credential form instead of CLI commands
2583
2594
  // when not active, so users can paste URL/keys directly in the browser.
2584
2595
  const isCredentialForm = tier.id === '2' && status !== 'active';
2596
+ // Sprint 25 T3: above the manual paste form, offer the Supabase MCP
2597
+ // auto-flow. Only render for tier 2 when status is not_configured or
2598
+ // partial — once active there is nothing to configure.
2599
+ const showSupabaseAutoFlow = tier.id === '2' && (status === 'not_configured' || status === 'partial');
2600
+ const autoFlowHtml = showSupabaseAutoFlow ? renderSupabaseAutoFlow() : '';
2585
2601
  const cmds = isCredentialForm
2586
- ? renderSetupCredentialForm()
2602
+ ? `${autoFlowHtml}${renderSetupCredentialForm()}`
2587
2603
  : (status === 'active' || tier.commands.length === 0)
2588
2604
  ? ''
2589
2605
  : `<div class="setup-cmds">${tier.commands.map((c) => {
@@ -2643,6 +2659,199 @@
2643
2659
  }
2644
2660
  });
2645
2661
  }
2662
+
2663
+ // Sprint 25 T3 — Supabase MCP auto-flow handlers. The form is only in the
2664
+ // DOM when showSupabaseAutoFlow was true above; querying for missing nodes
2665
+ // is a no-op and keeps this branch defensive against re-render order.
2666
+ const autoConnectBtn = tiersEl.querySelector('#supabaseAutoConnect');
2667
+ if (autoConnectBtn) {
2668
+ autoConnectBtn.addEventListener('click', handleSupabaseAutoConnect);
2669
+ }
2670
+ const autoPatInput = tiersEl.querySelector('#supabaseAutoPat');
2671
+ if (autoPatInput) {
2672
+ autoPatInput.addEventListener('keydown', (e) => {
2673
+ if (e.key === 'Enter') {
2674
+ e.preventDefault();
2675
+ handleSupabaseAutoConnect();
2676
+ }
2677
+ });
2678
+ }
2679
+ const autoSelectBtn = tiersEl.querySelector('#supabaseAutoSelect');
2680
+ if (autoSelectBtn) {
2681
+ autoSelectBtn.addEventListener('click', handleSupabaseAutoSelect);
2682
+ }
2683
+ const autoBackBtn = tiersEl.querySelector('#supabaseAutoBack');
2684
+ if (autoBackBtn) {
2685
+ autoBackBtn.addEventListener('click', handleSupabaseAutoBack);
2686
+ }
2687
+ }
2688
+
2689
+ // Sprint 25 T3 — Supabase MCP auto-flow renderer.
2690
+ // Inline-styled (Sprint 23 T1 owns style.css). Renders one of two states
2691
+ // driven by `supabaseAutoState`: the PAT entry form (default), or the
2692
+ // project picker (when state.picking is true). Errors render inline; the
2693
+ // manual credential form is always still visible below as a fallback.
2694
+ function renderSupabaseAutoFlow() {
2695
+ const s = supabaseAutoState || {};
2696
+ const wrapStyle = 'margin-top:10px;padding:12px;background:rgba(0,0,0,0.18);border:1px solid var(--border, #2a2c3a);border-radius:6px;';
2697
+ const titleStyle = 'font-size:11px;font-weight:600;color:var(--text, #e2e3e8);margin-bottom:4px;';
2698
+ const helpStyle = 'font-size:10px;color:var(--text-dim, #8a8d9a);margin-bottom:10px;line-height:1.4;';
2699
+ const inputStyle = 'flex:1;padding:6px 8px;background:var(--bg, #0f1017);color:var(--text, #e2e3e8);border:1px solid var(--border, #2a2c3a);border-radius:4px;font-family:monospace;font-size:12px;box-sizing:border-box;';
2700
+ const btnStyle = 'padding:6px 14px;background:var(--accent, #7aa2f7);color:#000;border:none;border-radius:4px;font-weight:600;cursor:pointer;font-size:11px;';
2701
+ const ghostBtnStyle = 'padding:6px 14px;background:transparent;color:var(--text-dim, #8a8d9a);border:1px solid var(--border, #2a2c3a);border-radius:4px;cursor:pointer;font-size:11px;';
2702
+ const dividerStyle = 'text-align:center;font-size:10px;color:var(--text-dim, #8a8d9a);margin:10px 0 0;text-transform:uppercase;letter-spacing:0.05em;';
2703
+ const errStyle = 'font-size:11px;color:var(--red, #f7768e);margin-top:8px;min-height:14px;line-height:1.4;';
2704
+ const linkStyle = 'color:var(--accent, #7aa2f7);text-decoration:underline;';
2705
+
2706
+ let body;
2707
+ if (s.picking && Array.isArray(s.projects)) {
2708
+ if (s.projects.length === 0) {
2709
+ body = `
2710
+ <div style="${errStyle}">
2711
+ Token accepted, but no projects were found on this account.
2712
+ Create one at <a href="https://supabase.com/dashboard" target="_blank" rel="noopener" style="${linkStyle}">supabase.com/dashboard</a> and try again.
2713
+ </div>
2714
+ <button type="button" id="supabaseAutoBack" style="margin-top:10px;${ghostBtnStyle}">Use a different token</button>
2715
+ `;
2716
+ } else {
2717
+ const opts = s.projects.map((p) => {
2718
+ const id = escapeHtml(String((p && p.id) || ''));
2719
+ const name = (p && p.name) || 'unnamed';
2720
+ const region = p && p.region ? ` — ${p.region}` : '';
2721
+ return `<option value="${id}">${escapeHtml(name + region)}</option>`;
2722
+ }).join('');
2723
+ body = `
2724
+ <label style="display:block;font-size:11px;color:var(--text-dim, #8a8d9a);margin-bottom:4px;">Project</label>
2725
+ <select id="supabaseAutoProject" style="${inputStyle}width:100%;font-family:inherit;">${opts}</select>
2726
+ <div style="display:flex;gap:8px;margin-top:10px;">
2727
+ <button type="button" id="supabaseAutoSelect" style="${btnStyle}">Use this project</button>
2728
+ <button type="button" id="supabaseAutoBack" style="${ghostBtnStyle}">Use a different token</button>
2729
+ </div>
2730
+ <div id="supabaseAutoError" style="${errStyle}">${s.error ? escapeHtml(s.error) : ''}</div>
2731
+ `;
2732
+ }
2733
+ } else {
2734
+ body = `
2735
+ <div style="display:flex;gap:8px;align-items:stretch;">
2736
+ <input type="password" id="supabaseAutoPat" name="supabase_pat_one_time"
2737
+ autocomplete="new-password" spellcheck="false"
2738
+ autocapitalize="off" autocorrect="off"
2739
+ placeholder="sbp_..." aria-label="Supabase Personal Access Token"
2740
+ style="${inputStyle}">
2741
+ <button type="button" id="supabaseAutoConnect" style="${btnStyle}">Connect</button>
2742
+ </div>
2743
+ <div id="supabaseAutoError" style="${errStyle}">${s.error ? escapeHtml(s.error) : ''}</div>
2744
+ `;
2745
+ }
2746
+
2747
+ return `
2748
+ <div style="${wrapStyle}">
2749
+ <div style="${titleStyle}">Faster: connect Supabase automatically</div>
2750
+ <div style="${helpStyle}">
2751
+ Paste a Supabase Personal Access Token and pick your project from a list — we'll fetch the credentials for you.
2752
+ Mint a PAT at <a href="https://supabase.com/dashboard/account/tokens" target="_blank" rel="noopener" style="${linkStyle}">supabase.com/dashboard/account/tokens</a>.
2753
+ </div>
2754
+ ${body}
2755
+ </div>
2756
+ <div style="${dividerStyle}">— or paste credentials manually below —</div>
2757
+ `;
2758
+ }
2759
+
2760
+ function rerenderSetupTiersFromCache() {
2761
+ if (lastSetupData) renderSetupTiers(lastSetupData);
2762
+ }
2763
+
2764
+ async function handleSupabaseAutoConnect() {
2765
+ const input = document.getElementById('supabaseAutoPat');
2766
+ const errEl = document.getElementById('supabaseAutoError');
2767
+ const btn = document.getElementById('supabaseAutoConnect');
2768
+ const pat = ((input && input.value) || '').trim();
2769
+ if (!pat) {
2770
+ if (errEl) errEl.textContent = 'Paste a Personal Access Token to continue.';
2771
+ return;
2772
+ }
2773
+ if (errEl) errEl.textContent = '';
2774
+ if (btn) { btn.disabled = true; btn.textContent = 'Connecting…'; }
2775
+ try {
2776
+ const res = await fetch(`${API}/api/setup/supabase/connect`, {
2777
+ method: 'POST',
2778
+ headers: { 'Content-Type': 'application/json' },
2779
+ body: JSON.stringify({ pat })
2780
+ });
2781
+ let data = {};
2782
+ try { data = await res.json(); } catch { data = {}; }
2783
+ if (res.ok && data && data.ok) {
2784
+ const projects = Array.isArray(data.projects) ? data.projects : [];
2785
+ // Hold the PAT in module-scope state only; never on `state`/`window`.
2786
+ supabaseAutoState = { pat, projects, picking: true, error: null };
2787
+ rerenderSetupTiersFromCache();
2788
+ return;
2789
+ }
2790
+ const code = data && data.code;
2791
+ let msg;
2792
+ if (code === 'mcp_not_installed') {
2793
+ msg = "The Supabase MCP isn't installed on this machine. Run `npx @jhizzard/termdeck-stack --tier 4` to install it, or paste credentials manually below.";
2794
+ } else if (code === 'pat_invalid') {
2795
+ const detail = (data && data.detail) || 'token rejected';
2796
+ msg = `Token rejected: ${detail}. Mint a fresh PAT and try again.`;
2797
+ } else if (code === 'mcp_timeout') {
2798
+ msg = "Supabase didn't respond in time. Try again or paste credentials manually below.";
2799
+ } else {
2800
+ msg = (data && (data.error || data.detail)) || `Connect failed (HTTP ${res.status}). Paste credentials manually below.`;
2801
+ }
2802
+ if (errEl) errEl.textContent = msg;
2803
+ } catch (err) {
2804
+ if (errEl) errEl.textContent = `Request failed: ${err && err.message ? err.message : String(err)}`;
2805
+ } finally {
2806
+ if (btn) { btn.disabled = false; btn.textContent = 'Connect'; }
2807
+ }
2808
+ }
2809
+
2810
+ async function handleSupabaseAutoSelect() {
2811
+ if (!supabaseAutoState || !supabaseAutoState.pat) return;
2812
+ const sel = document.getElementById('supabaseAutoProject');
2813
+ const errEl = document.getElementById('supabaseAutoError');
2814
+ const btn = document.getElementById('supabaseAutoSelect');
2815
+ const projectId = sel ? sel.value : '';
2816
+ if (!projectId) {
2817
+ if (errEl) errEl.textContent = 'Pick a project first.';
2818
+ return;
2819
+ }
2820
+ if (errEl) errEl.textContent = '';
2821
+ if (btn) { btn.disabled = true; btn.textContent = 'Configuring…'; }
2822
+ // Snapshot the PAT locally so we can null out module state before the
2823
+ // network call resolves; the snapshot only lives for the duration of
2824
+ // this async function.
2825
+ const patSnapshot = supabaseAutoState.pat;
2826
+ try {
2827
+ const res = await fetch(`${API}/api/setup/supabase/select`, {
2828
+ method: 'POST',
2829
+ headers: { 'Content-Type': 'application/json' },
2830
+ body: JSON.stringify({ pat: patSnapshot, projectId })
2831
+ });
2832
+ let data = {};
2833
+ try { data = await res.json(); } catch { data = {}; }
2834
+ if (res.ok && data && data.ok) {
2835
+ // Null out the PAT before doing anything else with the response.
2836
+ supabaseAutoState = null;
2837
+ await refreshSetupStatus();
2838
+ return;
2839
+ }
2840
+ const detail = (data && (data.error || data.detail)) || `HTTP ${res.status}`;
2841
+ if (errEl) {
2842
+ errEl.textContent = `Couldn't finish setup: ${detail}. Paste credentials manually below if this keeps failing.`;
2843
+ }
2844
+ } catch (err) {
2845
+ if (errEl) errEl.textContent = `Request failed: ${err && err.message ? err.message : String(err)}`;
2846
+ } finally {
2847
+ if (btn) { btn.disabled = false; btn.textContent = 'Use this project'; }
2848
+ }
2849
+ }
2850
+
2851
+ function handleSupabaseAutoBack() {
2852
+ // Drop the PAT and any cached project list and re-render from scratch.
2853
+ supabaseAutoState = null;
2854
+ rerenderSetupTiersFromCache();
2646
2855
  }
2647
2856
 
2648
2857
  // Sprint 23 T2 — credential form for Tier 2.
@@ -418,6 +418,226 @@ function createServer(config) {
418
418
  }
419
419
  });
420
420
 
421
+ // ── Sprint 25 T2 — Supabase MCP wizard endpoints ──────────────────────────
422
+ //
423
+ // Three thin orchestrators that let the Tier-2 setup wizard skip the manual
424
+ // 4-credential paste step. They sit on top of T1's `supabase-mcp.callTool`
425
+ // bridge plus the existing Sprint 23 `configure` + `migrate` flow. The PAT
426
+ // travels in the request body for the lifetime of the call only — it is
427
+ // never persisted, never echoed, and never logged.
428
+ let _supabaseMcp = null;
429
+ try {
430
+ _supabaseMcp = require('./setup/supabase-mcp');
431
+ } catch (_err) {
432
+ // T1's bridge module may not exist yet on a fresh checkout, or the user
433
+ // may not have `@supabase/mcp-server-supabase` on PATH. Either case
434
+ // surfaces as `code: 'mcp_not_installed'` at request time.
435
+ }
436
+ let _supabaseSelectInFlight = false;
437
+
438
+ function _mapMcpError(err) {
439
+ const code = err && (err.code || (err.cause && err.cause.code));
440
+ const msg = (err && err.message) || '';
441
+ if (code === 'mcp_not_installed' || code === 'ENOENT' || /not.*installed|cannot.*spawn|module not found/i.test(msg)) {
442
+ return {
443
+ status: 400,
444
+ body: { ok: false, code: 'mcp_not_installed', detail: 'run: npm install -g @supabase/mcp-server-supabase' }
445
+ };
446
+ }
447
+ if (code === 'mcp_timeout' || code === 'ETIMEDOUT' || /timeout|timed out/i.test(msg)) {
448
+ return { status: 504, body: { ok: false, code: 'mcp_timeout' } };
449
+ }
450
+ return { status: 401, body: { ok: false, code: 'pat_invalid', detail: msg || 'PAT verification failed' } };
451
+ }
452
+
453
+ function _ensureMcpAvailable(res) {
454
+ if (_supabaseMcp && typeof _supabaseMcp.callTool === 'function') return true;
455
+ res.status(400).json({
456
+ ok: false,
457
+ code: 'mcp_not_installed',
458
+ detail: 'run: npm install -g @supabase/mcp-server-supabase'
459
+ });
460
+ return false;
461
+ }
462
+
463
+ // POST /api/setup/supabase/connect — verify a PAT works by listing projects.
464
+ // We only return the count; the project list itself is fetched by /projects.
465
+ app.post('/api/setup/supabase/connect', async (req, res) => {
466
+ const pat = (req.body && typeof req.body.pat === 'string') ? req.body.pat : '';
467
+ if (!pat) {
468
+ return res.status(400).json({ ok: false, code: 'pat_invalid', detail: 'pat field is required' });
469
+ }
470
+ if (!_ensureMcpAvailable(res)) return;
471
+ try {
472
+ const result = await _supabaseMcp.callTool(pat, 'list_projects', {}, { timeoutMs: 6000 });
473
+ const list = Array.isArray(result)
474
+ ? result
475
+ : (Array.isArray(result && result.projects) ? result.projects : []);
476
+ console.log(`[setup] supabase/connect ok (${list.length} projects)`);
477
+ return res.json({ ok: true, projectCount: list.length });
478
+ } catch (err) {
479
+ const m = _mapMcpError(err);
480
+ console.warn(`[setup] supabase/connect failed: ${m.body.code}`);
481
+ return res.status(m.status).json(m.body);
482
+ }
483
+ });
484
+
485
+ // POST /api/setup/supabase/projects — return a stable-shape project list.
486
+ // Mapping isolates the wizard from MCP field-name churn.
487
+ app.post('/api/setup/supabase/projects', async (req, res) => {
488
+ const pat = (req.body && typeof req.body.pat === 'string') ? req.body.pat : '';
489
+ if (!pat) {
490
+ return res.status(400).json({ ok: false, code: 'pat_invalid', detail: 'pat field is required' });
491
+ }
492
+ if (!_ensureMcpAvailable(res)) return;
493
+ try {
494
+ const result = await _supabaseMcp.callTool(pat, 'list_projects', {}, { timeoutMs: 6000 });
495
+ const raw = Array.isArray(result)
496
+ ? result
497
+ : (Array.isArray(result && result.projects) ? result.projects : []);
498
+ const projects = raw.map((p) => ({
499
+ id: (p && (p.id || p.ref || p.project_id)) || '',
500
+ name: (p && p.name) || '',
501
+ region: (p && (p.region || p.region_name)) || null,
502
+ createdAt: (p && (p.createdAt || p.created_at)) || null,
503
+ }));
504
+ console.log(`[setup] supabase/projects ok (${projects.length} returned)`);
505
+ return res.json({ ok: true, projects });
506
+ } catch (err) {
507
+ const m = _mapMcpError(err);
508
+ console.warn(`[setup] supabase/projects failed: ${m.body.code}`);
509
+ return res.status(m.status).json(m.body);
510
+ }
511
+ });
512
+
513
+ // POST /api/setup/supabase/select — full chain: MCP → configure → migrate.
514
+ // Concurrency guarded by a module-scoped boolean — second call gets 409.
515
+ app.post('/api/setup/supabase/select', async (req, res) => {
516
+ if (_supabaseSelectInFlight) {
517
+ return res.status(409).json({ ok: false, code: 'select_in_flight', error: 'Supabase select already in progress' });
518
+ }
519
+ const pat = (req.body && typeof req.body.pat === 'string') ? req.body.pat : '';
520
+ const projectId = (req.body && typeof req.body.projectId === 'string') ? req.body.projectId.trim() : '';
521
+ if (!pat || !projectId) {
522
+ return res.status(400).json({ ok: false, code: 'bad_request', detail: 'pat and projectId are required' });
523
+ }
524
+ if (!_ensureMcpAvailable(res)) return;
525
+
526
+ _supabaseSelectInFlight = true;
527
+ try {
528
+ // 1. Pull credentials via MCP. Prefer the bundled tool if T1 ships one;
529
+ // fall back to the four single-field tools so we are robust to either
530
+ // bridge shape.
531
+ let creds;
532
+ try {
533
+ creds = await _supabaseMcp.callTool(pat, 'fetch_project_credentials', { projectId }, { timeoutMs: 8000 });
534
+ } catch (errBundle) {
535
+ const code = errBundle && errBundle.code;
536
+ const msg = (errBundle && errBundle.message) || '';
537
+ const isUnknownTool = code === 'unknown_tool' || /unknown.?tool|method not found|no such tool/i.test(msg);
538
+ if (!isUnknownTool) throw errBundle;
539
+ const [proj, anon, service, db] = await Promise.all([
540
+ _supabaseMcp.callTool(pat, 'get_project', { projectId }, { timeoutMs: 6000 }),
541
+ _supabaseMcp.callTool(pat, 'get_anon_key', { projectId }, { timeoutMs: 6000 }),
542
+ _supabaseMcp.callTool(pat, 'get_service_role_key', { projectId }, { timeoutMs: 6000 }),
543
+ _supabaseMcp.callTool(pat, 'get_database_url', { projectId }, { timeoutMs: 6000 }),
544
+ ]);
545
+ creds = {
546
+ url: (proj && (proj.url || proj.api_url)) || '',
547
+ anonKey: (anon && (anon.key || anon.anon_key)) || (typeof anon === 'string' ? anon : ''),
548
+ serviceRoleKey: (service && (service.key || service.service_role_key)) || (typeof service === 'string' ? service : ''),
549
+ databaseUrl: (db && (db.connectionString || db.url || db.database_url)) || (typeof db === 'string' ? db : ''),
550
+ };
551
+ }
552
+
553
+ const supabaseUrl = (creds && (creds.url || creds.supabaseUrl || creds.api_url)) || '';
554
+ const serviceRoleKey = (creds && (creds.serviceRoleKey || creds.service_role_key)) || '';
555
+ const databaseUrl = (creds && (creds.databaseUrl || creds.database_url)) || '';
556
+ const anonKey = (creds && (creds.anonKey || creds.anon_key)) || '';
557
+
558
+ if (!supabaseUrl || !serviceRoleKey || !databaseUrl) {
559
+ return res.status(502).json({
560
+ ok: false,
561
+ code: 'mcp_incomplete',
562
+ detail: 'MCP did not return all required credentials (url, service role key, database url)'
563
+ });
564
+ }
565
+
566
+ // 2. Hand off to existing /api/setup/configure via in-process loopback
567
+ // fetch. This keeps Sprint 23's validators and writers as the single
568
+ // source of truth — no validation logic is duplicated here.
569
+ const port = (config && config.port) || 3000;
570
+ const headers = { 'content-type': 'application/json' };
571
+ if (req.headers.authorization) headers.authorization = req.headers.authorization;
572
+
573
+ const openaiApiKey = (req.body && typeof req.body.openaiApiKey === 'string')
574
+ ? req.body.openaiApiKey
575
+ : (process.env.OPENAI_API_KEY || '');
576
+ const anthropicApiKey = (req.body && typeof req.body.anthropicApiKey === 'string')
577
+ ? req.body.anthropicApiKey
578
+ : (process.env.ANTHROPIC_API_KEY || '');
579
+
580
+ const configureRes = await fetch(`http://127.0.0.1:${port}/api/setup/configure`, {
581
+ method: 'POST',
582
+ headers,
583
+ body: JSON.stringify({
584
+ supabaseUrl,
585
+ supabaseServiceRoleKey: serviceRoleKey,
586
+ databaseUrl,
587
+ openaiApiKey,
588
+ anthropicApiKey,
589
+ // anonKey is not part of the Sprint 23 contract — we hold it here
590
+ // for parity with future runtime needs but do not pass it on.
591
+ })
592
+ });
593
+ const configureBody = await configureRes.json().catch(() => ({}));
594
+ if (!configureRes.ok || configureBody.success === false) {
595
+ const status = configureRes.status >= 400 ? configureRes.status : 500;
596
+ return res.status(status).json({
597
+ ok: false,
598
+ code: 'configure_failed',
599
+ detail: configureBody.error || 'configure step failed',
600
+ validation: configureBody.validation || null,
601
+ });
602
+ }
603
+
604
+ // 3. Trigger /api/setup/migrate. Pass databaseUrl explicitly so we don't
605
+ // depend on the migrate endpoint's dotenv refresh ordering.
606
+ const migrateRes = await fetch(`http://127.0.0.1:${port}/api/setup/migrate`, {
607
+ method: 'POST',
608
+ headers,
609
+ body: JSON.stringify({ databaseUrl })
610
+ });
611
+ const migrateBody = await migrateRes.json().catch(() => ({}));
612
+ if (!migrateRes.ok || migrateBody.ok === false) {
613
+ const status = migrateRes.status >= 400 ? migrateRes.status : 500;
614
+ return res.status(status).json({
615
+ ok: false,
616
+ code: 'migrate_failed',
617
+ detail: migrateBody.error || 'migrate step failed',
618
+ applied: migrateBody.applied || 0,
619
+ });
620
+ }
621
+
622
+ console.log(`[setup] supabase/select complete (${migrateBody.applied || 0} migrations applied)`);
623
+ // Mark the anonKey unused so lint stays clean — see comment above.
624
+ void anonKey;
625
+ return res.json({
626
+ ok: true,
627
+ configured: true,
628
+ migrated: true,
629
+ validation: configureBody.validation || null,
630
+ applied: migrateBody.applied || 0,
631
+ });
632
+ } catch (err) {
633
+ const m = _mapMcpError(err);
634
+ console.warn(`[setup] supabase/select failed: ${m.body.code}`);
635
+ return res.status(m.status).json(m.body);
636
+ } finally {
637
+ _supabaseSelectInFlight = false;
638
+ }
639
+ });
640
+
421
641
  // GET /api/sessions - list all active sessions
422
642
  app.get('/api/sessions', (req, res) => {
423
643
  res.json(sessions.getAll());
@@ -55,7 +55,7 @@ const PATTERNS = {
55
55
  // tools (cat, ls, cd, rm, etc.) report filesystem misses in plain English
56
56
  // without ever emitting the ENOENT errno code. Flagged as a gap by Rumen's
57
57
  // first production kickstart insight on 2026-04-15.
58
- error: /\b(error|Error|ERROR|exception|Exception|Traceback|fatal|FATAL|segmentation fault|panic|EACCES|ECONNREFUSED|ENOENT|command not found|undefined reference|cannot find module|failed with exit code|No such file or directory|Permission denied|\b5\d\d\b)\b/,
58
+ error: /(?:^|\n)\s*(?:Error:\s+\S|error:\s+\S|Traceback \(most recent call last\):|npm ERR!|error\[E\d+\]:|Uncaught Exception|Fatal:)/m,
59
59
  // Stricter line-anchored variant for Claude Code, whose tool output (grep
60
60
  // results, test logs, file contents) routinely mentions "Error" mid-line
61
61
  // without representing an actual failure of the agent itself.
@@ -105,13 +105,38 @@ async function askOptional(question, { validate } = {}) {
105
105
  // Secret prompt. On TTY we mute echo; on non-TTY we fall back to a visible
106
106
  // line read. Callers typically pass an empty string for `question` and write
107
107
  // their own label first.
108
+ //
109
+ // Reported as broken on MobaXterm SSH (Brad, 2026-04-25): the wizard would
110
+ // abort after the Anthropic key prompt as if Ctrl-C were pressed. Three real
111
+ // bugs hardened against here, all from the original raw-mode loop:
112
+ //
113
+ // 1. CRLF leak. When the terminal sends "\r\n" as a single chunk (Windows /
114
+ // MobaXterm Enter key), the original loop matched the "\r", resolved,
115
+ // and dropped the rest of the chunk on the floor. The trailing "\n"
116
+ // then surfaced through the next prompt's data path. Worst case the
117
+ // dropped chunk also contained  from a stray keystroke and the
118
+ // original SIGINT branch fired, killing the wizard mid-flow.
119
+ //
120
+ // 2. ANSI / escape-sequence pollution. Some terminals emit "[..."
121
+ // sequences for non-character events (focus changes, cursor reports,
122
+ // paste-bracketing). The old loop fed those bytes into the password
123
+ // buffer and echoed "*" for each one. We now consume escape sequences
124
+ // silently.
125
+ //
126
+ // 3. SIGINT during a secret prompt is now a soft cancel — return empty
127
+ // string, let the caller's shape validator re-prompt — instead of
128
+ // `process.kill`-ing the process. The hard-kill was masking the CRLF
129
+ // bug above and aborting the wizard from stray bytes.
130
+ //
131
+ // Carry-over bytes (anything in the chunk after the resolving "\r"/"\n")
132
+ // are pushed back onto stdin via `stdin.unshift` so the next consumer
133
+ // (readline, the next askSecret) reads from a clean stream. Regression
134
+ // fixtures in tests/setup-prompts.test.js.
108
135
  async function askSecret(question) {
109
136
  if (!process.stdin.isTTY) {
110
137
  return ask(question);
111
138
  }
112
139
  if (question) process.stdout.write(`${question}: `);
113
- // Raw-mode reader. Detach the shared readline for the duration so both
114
- // consumers aren't racing on stdin 'data' events.
115
140
  return new Promise((resolve) => {
116
141
  if (rl) rl.pause();
117
142
  const stdin = process.stdin;
@@ -120,30 +145,78 @@ async function askSecret(question) {
120
145
  stdin.setEncoding('utf-8');
121
146
 
122
147
  let buffer = '';
148
+ let inEscape = false;
149
+ let escapeDepth = 0;
150
+
151
+ const cleanup = () => {
152
+ stdin.setRawMode(false);
153
+ stdin.removeListener('data', onData);
154
+ process.stdout.write('\n');
155
+ if (rl) rl.resume();
156
+ };
157
+
158
+ const carryOver = (chunk, fromIndex) => {
159
+ if (fromIndex >= chunk.length) return;
160
+ const tail = chunk.slice(fromIndex);
161
+ // Drop a single leading \n if we just consumed \r (CRLF pair already
162
+ // accounted for at the call site).
163
+ const trimmed = tail[0] === '\n' ? tail.slice(1) : tail;
164
+ if (trimmed.length > 0) {
165
+ try { stdin.unshift(Buffer.from(trimmed, 'utf-8')); } catch (_e) { /* unsupported on some Node versions */ }
166
+ }
167
+ };
168
+
123
169
  const onData = (chunk) => {
124
- for (const ch of chunk) {
125
- if (ch === '\n' || ch === '\r' || ch === '\u0004') {
126
- stdin.setRawMode(false);
127
- stdin.removeListener('data', onData);
128
- process.stdout.write('\n');
129
- if (rl) rl.resume();
170
+ for (let i = 0; i < chunk.length; i++) {
171
+ const ch = chunk[i];
172
+
173
+ // Bug #2: silently consume ANSI escape sequences. ESC (\u001b) starts one;
174
+ // we consume until a final byte (CSI: 0x40..0x7E) or run out of
175
+ // patience (16 bytes).
176
+ if (inEscape) {
177
+ escapeDepth++;
178
+ if ((escapeDepth === 1 && ch !== '[') ||
179
+ (escapeDepth > 1 && ch >= '@' && ch <= '~') ||
180
+ escapeDepth > 16) {
181
+ inEscape = false;
182
+ escapeDepth = 0;
183
+ }
184
+ continue;
185
+ }
186
+ if (ch === '') { inEscape = true; escapeDepth = 0; continue; }
187
+
188
+ // Line terminators — the resolve path. Bug #1 handled here via the
189
+ // explicit CRLF drain inside the same chunk.
190
+ if (ch === '\n' || ch === '\r' || ch === '') {
191
+ let consumeUpTo = i + 1;
192
+ if (ch === '\r' && chunk[i + 1] === '\n') consumeUpTo++;
193
+ cleanup();
194
+ carryOver(chunk, consumeUpTo);
130
195
  resolve(buffer);
131
196
  return;
132
197
  }
133
- if (ch === '\u0003') {
134
- stdin.setRawMode(false);
135
- stdin.removeListener('data', onData);
136
- process.stdout.write('\n');
137
- process.kill(process.pid, 'SIGINT');
198
+
199
+ // Bug #3: Ctrl-C during a secret prompt → soft cancel, not SIGINT.
200
+ if (ch === '') {
201
+ cleanup();
202
+ carryOver(chunk, i + 1);
203
+ resolve('');
138
204
  return;
139
205
  }
140
- if (ch === '\u007f' || ch === '\b') {
206
+
207
+ // Backspace / DEL.
208
+ if (ch === '' || ch === '\b') {
141
209
  if (buffer.length > 0) {
142
210
  buffer = buffer.slice(0, -1);
143
211
  process.stdout.write('\b \b');
144
212
  }
145
213
  continue;
146
214
  }
215
+
216
+ // Drop any other control character silently; never let them into
217
+ // the password buffer.
218
+ if (ch < ' ' && ch !== '\t') continue;
219
+
147
220
  buffer += ch;
148
221
  process.stdout.write('*');
149
222
  }