@jhizzard/termdeck 0.5.1 → 0.6.0

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 CHANGED
@@ -226,6 +226,18 @@ For users who want more than `npx` — cloning from source, building a macOS `.a
226
226
 
227
227
  ---
228
228
 
229
+ ## Staying current
230
+
231
+ TermDeck, Mnestra, and Rumen all evolve fast — Flashback recall quality, Mnestra search semantics, and Rumen synth quality each shift between minor releases. Running last month's stack means missing fixed bugs and degraded recall. Three layered ways to keep current:
232
+
233
+ 1. **One command for the whole stack:** `npx @jhizzard/termdeck-stack` — re-runs the meta-installer, which detects what's already installed and updates anything that's behind. Idempotent: safe to run any time, won't touch `~/.termdeck/{config.yaml,secrets.env,termdeck.db}`.
234
+ 2. **On demand:** `termdeck doctor` — prints a 4-row table (TermDeck, Mnestra, Rumen, termdeck-stack) of installed vs. latest versions with a status column. Exit 0 = all current, 1 = at least one update available, 2 = registry/network failure.
235
+ 3. **Passive:** TermDeck prints a single yellow `[hint]` line on startup when an update is available. Rate-limited to once per 24 hours via `~/.termdeck/update-check.json`. Never blocks startup. Suppress with `TERMDECK_NO_UPDATE_CHECK=1` (the kill switch only mutes the startup hint — `termdeck doctor` still works on demand).
236
+
237
+ See **[docs/SEMVER-POLICY.md](docs/SEMVER-POLICY.md)** for what each kind of bump means across the four packages and how risky a given upgrade path is.
238
+
239
+ ---
240
+
229
241
  ## Related packages
230
242
 
231
243
  - **[@jhizzard/mnestra](https://www.npmjs.com/package/@jhizzard/mnestra)** — persistent dev memory MCP server. pgvector + hybrid search + 3-layer progressive disclosure. Works standalone with any MCP client.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -0,0 +1,255 @@
1
+ // `termdeck doctor` — Sprint 28 T2.
2
+ //
3
+ // Compares installed versions of the four TermDeck-stack packages against
4
+ // the npm registry's `dist-tags.latest` and prints a status table. Zero new
5
+ // deps — uses only node:https, node:child_process, and process.stdout.
6
+ //
7
+ // Module contract (per docs/sprint-28-update-signal/STATUS.md):
8
+ // module.exports = function doctor(argv): Promise<exitCode>
9
+ // 0 = all current
10
+ // 1 = at least one update available
11
+ // 2 = network/registry failure or unrecoverable error
12
+ //
13
+ // `_detectInstalled` and `_fetchLatest` are exposed as properties on the
14
+ // exported function so tests can monkey-patch the network/process surface
15
+ // without spinning up a real registry. The doctor body calls them via
16
+ // `module.exports.<name>` so monkey-patching takes effect at call time.
17
+
18
+ const https = require('https');
19
+ const { spawn } = require('child_process');
20
+
21
+ const STACK_PACKAGES = [
22
+ '@jhizzard/termdeck',
23
+ '@jhizzard/mnestra',
24
+ '@jhizzard/rumen',
25
+ '@jhizzard/termdeck-stack',
26
+ ];
27
+
28
+ const REGISTRY_TIMEOUT_MS = 5000;
29
+ const NPM_LS_TIMEOUT_MS = 8000;
30
+
31
+ const STATUS = {
32
+ UP_TO_DATE: 'up to date',
33
+ UPDATE: 'update available',
34
+ NOT_INSTALLED: 'not installed',
35
+ NETWORK_ERROR: 'network error',
36
+ };
37
+
38
+ function makeColors(enabled) {
39
+ if (!enabled) {
40
+ return { green: (s) => s, yellow: (s) => s, dim: (s) => s, bold: (s) => s };
41
+ }
42
+ return {
43
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
44
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
45
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
46
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
47
+ };
48
+ }
49
+
50
+ // Detect installed version via `npm ls -g <pkg> --depth=0 --json`. Returns
51
+ // the version string on success, or null on "not installed" / parse failure
52
+ // / npm-missing-from-PATH / timeout. Stderr noise (npm WARN lines) is
53
+ // silently dropped — those are not fatal.
54
+ async function _detectInstalled(pkg) {
55
+ return new Promise((resolve) => {
56
+ let child;
57
+ try {
58
+ child = spawn('npm', ['ls', '-g', pkg, '--depth=0', '--json'], {
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ });
61
+ } catch {
62
+ return resolve(null);
63
+ }
64
+
65
+ let stdout = '';
66
+ let timedOut = false;
67
+ const t = setTimeout(() => {
68
+ timedOut = true;
69
+ try { child.kill('SIGKILL'); } catch (_e) { /* already gone */ }
70
+ }, NPM_LS_TIMEOUT_MS);
71
+
72
+ child.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
73
+ child.stderr.on('data', () => { /* discard npm WARNs */ });
74
+ child.on('error', () => { clearTimeout(t); resolve(null); });
75
+ child.on('close', () => {
76
+ clearTimeout(t);
77
+ if (timedOut) return resolve(null);
78
+ try {
79
+ const parsed = JSON.parse(stdout);
80
+ const dep = parsed && parsed.dependencies && parsed.dependencies[pkg];
81
+ if (dep && typeof dep.version === 'string') return resolve(dep.version);
82
+ return resolve(null);
83
+ } catch {
84
+ return resolve(null);
85
+ }
86
+ });
87
+ });
88
+ }
89
+
90
+ // Fetch the `latest` dist-tag for a package from the public npm registry.
91
+ // Returns the version string on success, or null on any failure (offline,
92
+ // non-200, malformed JSON, timeout). The caller treats null as a network
93
+ // error and bumps the exit code to 2.
94
+ async function _fetchLatest(pkg) {
95
+ return new Promise((resolve) => {
96
+ // Encode `@scope/name` as `%40scope%2Fname` per the registry's URL spec.
97
+ const encoded = encodeURIComponent(pkg);
98
+ const url = `https://registry.npmjs.org/-/package/${encoded}/dist-tags`;
99
+ let settled = false;
100
+ const done = (v) => {
101
+ if (settled) return;
102
+ settled = true;
103
+ resolve(v);
104
+ };
105
+
106
+ let req;
107
+ try {
108
+ req = https.get(url, { timeout: REGISTRY_TIMEOUT_MS }, (res) => {
109
+ if (res.statusCode !== 200) {
110
+ res.resume();
111
+ return done(null);
112
+ }
113
+ let body = '';
114
+ res.setEncoding('utf8');
115
+ res.on('data', (chunk) => { body += chunk; });
116
+ res.on('end', () => {
117
+ try {
118
+ const parsed = JSON.parse(body);
119
+ if (parsed && typeof parsed.latest === 'string') return done(parsed.latest);
120
+ return done(null);
121
+ } catch {
122
+ return done(null);
123
+ }
124
+ });
125
+ res.on('error', () => done(null));
126
+ });
127
+ } catch {
128
+ return done(null);
129
+ }
130
+ req.on('timeout', () => {
131
+ try { req.destroy(); } catch (_e) { /* already gone */ }
132
+ done(null);
133
+ });
134
+ req.on('error', () => done(null));
135
+ });
136
+ }
137
+
138
+ // Lightweight semver compare — only looks at the first three numeric segments,
139
+ // which is all dist-tags.latest ever needs. Returns -1, 0, or 1.
140
+ function _compareSemver(a, b) {
141
+ const pa = String(a).split('.').map((s) => parseInt(s, 10) || 0);
142
+ const pb = String(b).split('.').map((s) => parseInt(s, 10) || 0);
143
+ for (let i = 0; i < 3; i++) {
144
+ const x = pa[i] || 0;
145
+ const y = pb[i] || 0;
146
+ if (x < y) return -1;
147
+ if (x > y) return 1;
148
+ }
149
+ return 0;
150
+ }
151
+
152
+ function classifyRow(installed, latest) {
153
+ if (latest === null) return STATUS.NETWORK_ERROR;
154
+ if (installed === null) return STATUS.NOT_INSTALLED;
155
+ return _compareSemver(installed, latest) < 0 ? STATUS.UPDATE : STATUS.UP_TO_DATE;
156
+ }
157
+
158
+ function pad(s, n) {
159
+ const str = String(s);
160
+ return str.length >= n ? str : str + ' '.repeat(n - str.length);
161
+ }
162
+
163
+ function renderTable(rows, c) {
164
+ const out = [];
165
+ out.push(c.bold('TermDeck stack — version check'));
166
+ out.push('');
167
+ out.push(` ${pad('Package', 32)}${pad('Installed', 12)}${pad('Latest', 12)}Status`);
168
+ out.push(' ' + '─'.repeat(63));
169
+ for (const r of rows) {
170
+ const installedDisplay = r.installed === null ? '(none)' : r.installed;
171
+ const latestDisplay = r.latest === null ? '?' : r.latest;
172
+ let statusDisplay = r.status;
173
+ if (r.status === STATUS.UP_TO_DATE) statusDisplay = c.green(r.status);
174
+ else if (r.status === STATUS.UPDATE) statusDisplay = c.yellow(r.status);
175
+ else if (r.status === STATUS.NOT_INSTALLED) statusDisplay = c.dim(r.status);
176
+ else if (r.status === STATUS.NETWORK_ERROR) statusDisplay = c.dim(r.status);
177
+ out.push(` ${pad(r.package, 32)}${pad(installedDisplay, 12)}${pad(latestDisplay, 12)}${statusDisplay}`);
178
+ }
179
+ return out.join('\n');
180
+ }
181
+
182
+ function renderFooter(rows, exitCode) {
183
+ if (exitCode === 2) {
184
+ const errors = rows.filter((r) => r.status === STATUS.NETWORK_ERROR).length;
185
+ return `\n Could not reach npm registry for ${errors} package${errors === 1 ? '' : 's'}. Try again later.`;
186
+ }
187
+ if (exitCode === 1) {
188
+ const updates = rows.filter((r) => r.status === STATUS.UPDATE).length;
189
+ return (
190
+ `\n ${updates} update${updates === 1 ? '' : 's'} available. ` +
191
+ `Run: npx @jhizzard/termdeck-stack\n` +
192
+ ` Or upgrade individually: npm install -g @jhizzard/termdeck@latest`
193
+ );
194
+ }
195
+ return `\n All packages up to date.`;
196
+ }
197
+
198
+ function parseArgv(argv) {
199
+ const args = Array.isArray(argv) ? argv : [];
200
+ return {
201
+ json: args.includes('--json'),
202
+ noColor: args.includes('--no-color'),
203
+ };
204
+ }
205
+
206
+ async function doctor(argv) {
207
+ const opts = parseArgv(argv);
208
+
209
+ // Resolve every package's installed + latest in parallel — independent
210
+ // network/process calls, no reason to serialize.
211
+ const rows = await Promise.all(
212
+ STACK_PACKAGES.map(async (pkg) => {
213
+ const [installed, latest] = await Promise.all([
214
+ module.exports._detectInstalled(pkg),
215
+ module.exports._fetchLatest(pkg),
216
+ ]);
217
+ return {
218
+ package: pkg,
219
+ installed,
220
+ latest,
221
+ status: classifyRow(installed, latest),
222
+ };
223
+ })
224
+ );
225
+
226
+ // Exit-code priority: any network failure → 2; any update available → 1;
227
+ // else 0. Computed after all rows resolve so a single transient failure
228
+ // doesn't mask real updates in stdout.
229
+ let exitCode = 0;
230
+ for (const r of rows) {
231
+ if (r.status === STATUS.NETWORK_ERROR) {
232
+ exitCode = 2;
233
+ break;
234
+ }
235
+ if (r.status === STATUS.UPDATE && exitCode < 1) exitCode = 1;
236
+ }
237
+
238
+ if (opts.json) {
239
+ process.stdout.write(JSON.stringify({ exitCode, rows }, null, 2) + '\n');
240
+ return exitCode;
241
+ }
242
+
243
+ const colorEnabled = !opts.noColor && process.stdout.isTTY === true;
244
+ const c = makeColors(colorEnabled);
245
+ process.stdout.write(renderTable(rows, c) + '\n');
246
+ process.stdout.write(renderFooter(rows, exitCode) + '\n');
247
+ return exitCode;
248
+ }
249
+
250
+ module.exports = doctor;
251
+ module.exports._detectInstalled = _detectInstalled;
252
+ module.exports._fetchLatest = _fetchLatest;
253
+ module.exports._compareSemver = _compareSemver;
254
+ module.exports.STACK_PACKAGES = STACK_PACKAGES;
255
+ module.exports.STATUS = STATUS;
@@ -76,12 +76,22 @@ if (args[0] === 'stack') {
76
76
  return;
77
77
  }
78
78
 
79
+ // `termdeck doctor` — Sprint 28: version-check the whole stack.
80
+ if (args[0] === 'doctor') {
81
+ const doctor = require(path.join(__dirname, 'doctor.js'));
82
+ doctor(args.slice(1)).then((code) => process.exit(code || 0)).catch((err) => {
83
+ console.error('[cli] doctor failed:', err && err.stack || err);
84
+ process.exit(2);
85
+ });
86
+ return;
87
+ }
88
+
79
89
  // Sprint 24: when `termdeck` is invoked with no subcommand AND a configured
80
90
  // stack is detected, route through stack.js so users don't have to remember
81
91
  // the `stack` subcommand. `--no-stack` is the explicit opt-out.
82
92
  const { shouldAutoOrchestrate } = require(path.join(__dirname, 'auto-orchestrate.js'));
83
93
 
84
- const KNOWN_SUBCOMMANDS = new Set(['init', 'forge', 'stack']);
94
+ const KNOWN_SUBCOMMANDS = new Set(['init', 'forge', 'stack', 'doctor']);
85
95
  const noStackIdx = args.indexOf('--no-stack');
86
96
  const noStackRequested = noStackIdx !== -1;
87
97
  if (noStackRequested) args.splice(noStackIdx, 1); // strip before flag parsing
@@ -120,6 +130,7 @@ for (let i = 0; i < args.length; i++) {
120
130
  termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
121
131
  termdeck init --rumen Deploy Tier 3 async learning (Rumen)
122
132
  termdeck forge Generate Claude skills from memories (experimental)
133
+ termdeck doctor Check whether the stack packages are up to date
123
134
 
124
135
  Keyboard shortcuts (in browser):
125
136
  Ctrl+Shift+N Focus prompt bar
@@ -175,6 +186,30 @@ if (!LOOPBACK.has(host)) {
175
186
  }
176
187
  }
177
188
 
189
+ // Sprint 25 T4: non-blocking nudge when RAG is configured but the Supabase MCP
190
+ // (T1's `@supabase/mcp-server-supabase` detection) isn't installed. Lazy-loads
191
+ // T1's module so Tier 1 users with no RAG never pay the require cost. Silent
192
+ // when RAG is off, when the MCP is detected, when ~/.claude/mcp.json already
193
+ // declares a `supabase` server, or when anything below throws.
194
+ async function checkSupabaseMcpHint(cfg) {
195
+ if (!cfg || !cfg.rag || cfg.rag.enabled !== true) return null;
196
+ try {
197
+ const claudeMcpPath = path.join(os.homedir(), '.claude', 'mcp.json');
198
+ if (fs.existsSync(claudeMcpPath)) {
199
+ try {
200
+ const parsed = JSON.parse(fs.readFileSync(claudeMcpPath, 'utf8'));
201
+ if (parsed && parsed.mcpServers && parsed.mcpServers.supabase) return null;
202
+ } catch (_e) { /* malformed JSON — fall through and let detectMcp decide */ }
203
+ }
204
+ const { detectMcp } = require(path.join(__dirname, '..', '..', 'server', 'src', 'setup', 'supabase-mcp.js'));
205
+ const result = await detectMcp();
206
+ if (result && result.available) return null;
207
+ return 'Supabase MCP not installed — wizard auto-fill unavailable. Install with: npx @jhizzard/termdeck-stack --tier 4';
208
+ } catch (_e) {
209
+ return null;
210
+ }
211
+ }
212
+
178
213
  server.listen(port, host, async () => {
179
214
  // Box inner width is 38 (count of ═ between ╔ and ╗). Center the title
180
215
  // dynamically so the right border stays aligned regardless of version length.
@@ -205,6 +240,23 @@ server.listen(port, host, async () => {
205
240
  console.error(` \x1b[31m[health] Preflight failed: ${err.message}\x1b[0m\n`);
206
241
  });
207
242
 
243
+ // Sprint 28 T3: fire-and-forget update-check banner. Lazy-required so users
244
+ // who never start the server don't pay the require cost. Errors are swallowed
245
+ // inside the module; never blocks startup. Double-protected (try/catch around
246
+ // the require + swallowed .catch on the promise) so a missing or broken T3
247
+ // module can never break startup.
248
+ try {
249
+ const { checkAndPrintHint } = require(path.join(__dirname, 'update-check.js'));
250
+ checkAndPrintHint(config).catch(() => { /* swallowed inside the module too */ });
251
+ } catch (_e) { /* never block startup on a hint module load failure */ }
252
+
253
+ // Sprint 25 T4: Supabase MCP install nudge — runs alongside (not inside)
254
+ // runPreflight. Silent unless RAG is on AND the MCP is missing AND the
255
+ // user hasn't already declared it in ~/.claude/mcp.json.
256
+ checkSupabaseMcpHint(config).then((msg) => {
257
+ if (msg) console.log(` \x1b[33m[hint]\x1b[0m ${msg}`);
258
+ }).catch(() => { /* silent */ });
259
+
208
260
  // Skip auto-open in Codespaces/CI (port forwarding handles it)
209
261
  const isCodespaces = !!process.env.CODESPACES || !!process.env.GITHUB_CODESPACE_TOKEN;
210
262
  const isCI = !!process.env.CI;
@@ -0,0 +1,156 @@
1
+ // Sprint 28 T3 — passive startup update-check banner.
2
+ //
3
+ // Side-effect-only module. checkAndPrintHint() rate-limits a single GET against
4
+ // the npm registry's dist-tags endpoint to once every 24h via a JSON cache at
5
+ // ~/.termdeck/update-check.json, and prints one yellow [hint] line when a
6
+ // newer version of @jhizzard/termdeck is published. All failures are swallowed
7
+ // — startup must never block on this.
8
+ //
9
+ // Suppression order (any one short-circuits before any side effect):
10
+ // 1. process.env.TERMDECK_NO_UPDATE_CHECK === '1'
11
+ // 2. process.stdout.isTTY is falsy (CI, piped output)
12
+ // 3. cache exists and lastCheckedAt is within 24h of now
13
+ //
14
+ // Module contract per docs/sprint-28-update-signal/STATUS.md.
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const os = require('node:os');
21
+
22
+ const CACHE_VERSION = 1;
23
+ const TTL_MS = 24 * 60 * 60 * 1000;
24
+ const FETCH_TIMEOUT_MS = 5000;
25
+ const PACKAGE_NAME = '@jhizzard/termdeck';
26
+ const REGISTRY_URL =
27
+ 'https://registry.npmjs.org/-/package/@jhizzard%2Ftermdeck/dist-tags';
28
+
29
+ function defaultCachePath() {
30
+ return path.join(os.homedir(), '.termdeck', 'update-check.json');
31
+ }
32
+
33
+ // The CLI ships inside the @jhizzard/termdeck package; the workspace root
34
+ // package.json is three levels up from packages/cli/src/. If anything goes
35
+ // wrong reading it (renamed file, broken JSON), return null and let the caller
36
+ // suppress.
37
+ function defaultPackageVersion() {
38
+ try {
39
+ const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json'));
40
+ return pkg && pkg.version ? String(pkg.version) : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function isValidSemver(v) {
47
+ return typeof v === 'string' && /^\d+\.\d+\.\d+/.test(v);
48
+ }
49
+
50
+ // Three-way semver compare on [major, minor, patch]. Pre-release suffixes are
51
+ // ignored — "0.5.1-beta" and "0.5.1" compare equal. Good enough for the hint.
52
+ function compareSemver(a, b) {
53
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
54
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
55
+ for (let i = 0; i < 3; i++) {
56
+ const x = pa[i] || 0;
57
+ const y = pb[i] || 0;
58
+ if (x < y) return -1;
59
+ if (x > y) return 1;
60
+ }
61
+ return 0;
62
+ }
63
+
64
+ function readCache(cachePath) {
65
+ try {
66
+ const raw = fs.readFileSync(cachePath, 'utf8');
67
+ const parsed = JSON.parse(raw);
68
+ if (!parsed || typeof parsed !== 'object') return null;
69
+ if (parsed.version !== CACHE_VERSION) return null;
70
+ return parsed;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function writeCache(cachePath, data) {
77
+ try {
78
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
79
+ fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), 'utf8');
80
+ } catch {
81
+ // Read-only home, ENOSPC, race with another process — all benign here.
82
+ }
83
+ }
84
+
85
+ async function fetchLatest(registryUrl) {
86
+ const controller = new AbortController();
87
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
88
+ try {
89
+ const res = await fetch(registryUrl, { signal: controller.signal });
90
+ if (!res || !res.ok) return null;
91
+ const json = await res.json();
92
+ const latest = json && json.latest;
93
+ return isValidSemver(latest) ? latest : null;
94
+ } catch {
95
+ return null;
96
+ } finally {
97
+ clearTimeout(timeout);
98
+ }
99
+ }
100
+
101
+ async function checkAndPrintHint(_config, opts) {
102
+ try {
103
+ if (process.env.TERMDECK_NO_UPDATE_CHECK === '1') return;
104
+ if (!process.stdout || !process.stdout.isTTY) return;
105
+
106
+ const o = opts || {};
107
+ const now = o.now instanceof Date ? o.now : new Date();
108
+ const registryUrl = typeof o.registryUrl === 'string' ? o.registryUrl : REGISTRY_URL;
109
+ const cachePath = typeof o.cachePath === 'string' ? o.cachePath : defaultCachePath();
110
+ const installed = typeof o.packageVersion === 'string'
111
+ ? o.packageVersion
112
+ : defaultPackageVersion();
113
+
114
+ if (!isValidSemver(installed)) return;
115
+
116
+ const cache = readCache(cachePath);
117
+ if (cache && cache.lastCheckedAt) {
118
+ const lastMs = Date.parse(cache.lastCheckedAt);
119
+ if (Number.isFinite(lastMs) && now.getTime() - lastMs < TTL_MS) {
120
+ return;
121
+ }
122
+ }
123
+
124
+ const latest = await fetchLatest(registryUrl);
125
+ if (!isValidSemver(latest)) return;
126
+
127
+ writeCache(cachePath, {
128
+ version: CACHE_VERSION,
129
+ lastCheckedAt: now.toISOString(),
130
+ lastSeenLatest: latest,
131
+ installedAtCheck: installed,
132
+ });
133
+
134
+ if (compareSemver(installed, latest) >= 0) return;
135
+
136
+ console.log(
137
+ '\x1b[33m[hint]\x1b[0m TermDeck v' +
138
+ latest +
139
+ ' available — upgrade with: npm install -g ' +
140
+ PACKAGE_NAME +
141
+ '@latest'
142
+ );
143
+ console.log(
144
+ ' Or run `termdeck doctor` for the whole stack. ' +
145
+ 'Suppress with TERMDECK_NO_UPDATE_CHECK=1.'
146
+ );
147
+ } catch {
148
+ // Never throw from a fire-and-forget hook.
149
+ }
150
+ }
151
+
152
+ module.exports = {
153
+ checkAndPrintHint,
154
+ // Exported for unit testing only — not part of the public contract.
155
+ _internal: { compareSemver, isValidSemver, readCache, writeCache, CACHE_VERSION, TTL_MS },
156
+ };
@@ -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.
@@ -0,0 +1,195 @@
1
+ // Sprint 25 T1 — Supabase MCP bridge.
2
+ //
3
+ // Thin server-side wrapper that spawns @supabase/mcp-server-supabase as a
4
+ // child process and speaks JSON-RPC 2.0 to it on stdio. One spawn per call —
5
+ // no caching, no retries, no business logic. T2's wizard endpoints stack
6
+ // listProjects / readCredentials helpers on top of this primitive.
7
+ //
8
+ // Zero new npm deps: child_process + JSON only.
9
+ //
10
+ // PAT discipline: the caller's Supabase Personal Access Token is passed via
11
+ // the SUPABASE_ACCESS_TOKEN env var on the spawned child and is never logged,
12
+ // echoed, or persisted on disk by this module.
13
+
14
+ const { spawn, spawnSync } = require('child_process');
15
+
16
+ const DEFAULT_TIMEOUT_MS = 8000;
17
+ const PACKAGE_SPEC = '@supabase/mcp-server-supabase';
18
+ const BINARY_NAME = 'mcp-server-supabase';
19
+
20
+ // Detect whether @supabase/mcp-server-supabase can be invoked on this host.
21
+ // Resolution order:
22
+ // 1. A globally installed `mcp-server-supabase` binary on PATH.
23
+ // 2. A locally cached npx package (probed without network install).
24
+ // Both probes are short-running and synchronous-shaped — wrapped in a Promise
25
+ // so the call site can stay async.
26
+ async function detectMcp() {
27
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
28
+ try {
29
+ const r = spawnSync(whichCmd, [BINARY_NAME], { encoding: 'utf-8' });
30
+ if (r.status === 0 && r.stdout && r.stdout.trim()) {
31
+ return { available: true, mode: 'binary' };
32
+ }
33
+ } catch (err) {
34
+ // `which` itself missing is unusual but not fatal — fall through to npx.
35
+ }
36
+
37
+ try {
38
+ // --no-install: only succeed if the package is already cached locally.
39
+ // Avoids surprising a user with a multi-MB install during a wizard probe.
40
+ const r = spawnSync('npx', ['--no-install', PACKAGE_SPEC, '--version'], {
41
+ encoding: 'utf-8',
42
+ timeout: 5000
43
+ });
44
+ if (r.status === 0) {
45
+ return { available: true, mode: 'npx' };
46
+ }
47
+ } catch (err) {
48
+ // npx absent (rare on Node installs) — fall through to "not installed".
49
+ }
50
+
51
+ return {
52
+ available: false,
53
+ mode: null,
54
+ error: `not installed; run: npm install -g ${PACKAGE_SPEC}`
55
+ };
56
+ }
57
+
58
+ function buildSpawnInvocation(mode) {
59
+ if (mode === 'binary') {
60
+ return { command: BINARY_NAME, args: [] };
61
+ }
62
+ // npx path — pin to @latest as the spec calls for, with -y to bypass the
63
+ // interactive "ok to proceed?" prompt that would otherwise hang stdio.
64
+ return { command: 'npx', args: ['-y', `${PACKAGE_SPEC}@latest`] };
65
+ }
66
+
67
+ // One-shot JSON-RPC tools/call. Spawns the MCP, writes a single request
68
+ // envelope, awaits the matching response by id, then kills the child.
69
+ //
70
+ // Resolves with `response.result` on success.
71
+ // Rejects with:
72
+ // - Error('mcp not installed: <hint>') if detectMcp() reports unavailable
73
+ // - Error('mcp timeout') if no response inside opts.timeoutMs
74
+ // - Error('mcp spawn failed: <msg>') on spawn-time errors (ENOENT, EACCES)
75
+ // - Error('mcp exited (code=<n>): <stderr tail>') if the child exits before
76
+ // a matching response arrives
77
+ // - Error(<rpc error message>) if the JSON-RPC response carries an `error`
78
+ async function callTool(pat, method, params, opts) {
79
+ if (typeof pat !== 'string' || !pat) {
80
+ throw new Error('callTool requires a Supabase PAT string');
81
+ }
82
+ if (typeof method !== 'string' || !method) {
83
+ throw new Error('callTool requires an MCP method name');
84
+ }
85
+ const timeoutMs = (opts && Number.isFinite(opts.timeoutMs))
86
+ ? opts.timeoutMs
87
+ : DEFAULT_TIMEOUT_MS;
88
+
89
+ const detect = await detectMcp();
90
+ if (!detect.available) {
91
+ throw new Error(`mcp not installed: ${detect.error}`);
92
+ }
93
+
94
+ const { command, args } = buildSpawnInvocation(detect.mode);
95
+
96
+ const id = Math.floor(Math.random() * 1e9) + 1;
97
+ const request = {
98
+ jsonrpc: '2.0',
99
+ id,
100
+ method: 'tools/call',
101
+ params: { name: method, arguments: params || {} }
102
+ };
103
+
104
+ return new Promise((resolve, reject) => {
105
+ let child;
106
+ try {
107
+ child = spawn(command, args, {
108
+ // Pass PAT only via env — never via argv so it can't show up in `ps`.
109
+ env: { ...process.env, SUPABASE_ACCESS_TOKEN: pat },
110
+ stdio: ['pipe', 'pipe', 'pipe']
111
+ });
112
+ } catch (err) {
113
+ reject(new Error(`mcp spawn failed: ${err.message}`));
114
+ return;
115
+ }
116
+
117
+ let stdoutBuf = '';
118
+ let stderrBuf = '';
119
+ let settled = false;
120
+ let timer = null;
121
+
122
+ const cleanup = () => {
123
+ if (timer) {
124
+ clearTimeout(timer);
125
+ timer = null;
126
+ }
127
+ try { child.stdin.end(); } catch (_e) { /* stdin already closed */ }
128
+ // SIGKILL — the MCP doesn't need a graceful shutdown for a one-shot.
129
+ try { child.kill('SIGKILL'); } catch (_e) { /* child already dead */ }
130
+ };
131
+
132
+ const settle = (fn, value) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ cleanup();
136
+ fn(value);
137
+ };
138
+
139
+ timer = setTimeout(() => {
140
+ settle(reject, new Error('mcp timeout'));
141
+ }, timeoutMs);
142
+
143
+ child.on('error', (err) => {
144
+ // 'error' fires for ENOENT / EACCES at spawn time and for write-after-end.
145
+ settle(reject, new Error(`mcp spawn failed: ${err.message}`));
146
+ });
147
+
148
+ child.stderr.on('data', (chunk) => {
149
+ stderrBuf += chunk.toString('utf-8');
150
+ // Cap so a chatty MCP can't blow memory on a stuck call.
151
+ if (stderrBuf.length > 8192) stderrBuf = stderrBuf.slice(-8192);
152
+ });
153
+
154
+ child.stdout.on('data', (chunk) => {
155
+ stdoutBuf += chunk.toString('utf-8');
156
+ let nl;
157
+ while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
158
+ const line = stdoutBuf.slice(0, nl).trim();
159
+ stdoutBuf = stdoutBuf.slice(nl + 1);
160
+ if (!line) continue;
161
+ let msg;
162
+ try {
163
+ msg = JSON.parse(line);
164
+ } catch (_e) {
165
+ // Non-JSON noise (banner, log line) — ignore and keep buffering.
166
+ continue;
167
+ }
168
+ if (msg && msg.id === id) {
169
+ if (msg.error) {
170
+ const detail = msg.error.message || JSON.stringify(msg.error);
171
+ settle(reject, new Error(detail));
172
+ } else {
173
+ settle(resolve, msg.result);
174
+ }
175
+ return;
176
+ }
177
+ }
178
+ });
179
+
180
+ child.on('exit', (code, signal) => {
181
+ if (settled) return;
182
+ const tail = stderrBuf.slice(-512).trim();
183
+ const why = signal ? `signal=${signal}` : `code=${code}`;
184
+ settle(reject, new Error(`mcp exited (${why})${tail ? ': ' + tail : ''}`));
185
+ });
186
+
187
+ try {
188
+ child.stdin.write(JSON.stringify(request) + '\n');
189
+ } catch (err) {
190
+ settle(reject, new Error(`mcp stdin write failed: ${err.message}`));
191
+ }
192
+ });
193
+ }
194
+
195
+ module.exports = { callTool, detectMcp };