@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.
- package/README.md +15 -3
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +255 -0
- package/packages/cli/src/index.js +53 -1
- package/packages/cli/src/update-check.js +156 -0
- package/packages/client/public/app.js +210 -1
- package/packages/server/src/index.js +220 -0
- package/packages/server/src/session.js +1 -1
- package/packages/server/src/setup/prompts.js +87 -14
- package/packages/server/src/setup/supabase-mcp.js +195 -0
|
@@ -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:
|
|
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 (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
}
|