@jhizzard/termdeck 0.5.0 → 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 +12 -0
- 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/stack.js +23 -6
- 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/supabase-mcp.js +195 -0
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.
|
|
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;
|
|
@@ -385,15 +385,32 @@ async function checkRumen() {
|
|
|
385
385
|
// ── Step 4: TermDeck ────────────────────────────────────────────────
|
|
386
386
|
|
|
387
387
|
function execTermDeck({ port, extra }) {
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
388
|
+
// Spawn a fresh node process for the CLI rather than require()-ing it
|
|
389
|
+
// in-process. Two reasons:
|
|
390
|
+
// 1. require() hits Node's module cache after stack.js → index.js →
|
|
391
|
+
// stack.js bounces (the v0.5.0 auto-orchestrate path), so the
|
|
392
|
+
// cached index.js is a no-op and the server never starts. This
|
|
393
|
+
// manifested in `scripts/start.sh` which already exec'd node, then
|
|
394
|
+
// v0.5.0's auto-orchestrate routed it back through stack.js, then
|
|
395
|
+
// stack.js tried to re-require the (cached) CLI — silent exit.
|
|
396
|
+
// 2. Pass --no-stack on the way back so index.js definitively skips
|
|
397
|
+
// the auto-orchestrate detection. Defensive even with the spawn.
|
|
391
398
|
const cliPath = path.join(__dirname, 'index.js');
|
|
392
|
-
const argv = [];
|
|
399
|
+
const argv = [cliPath, '--no-stack'];
|
|
393
400
|
if (port) argv.push('--port', String(port));
|
|
394
401
|
argv.push(...extra);
|
|
395
|
-
|
|
396
|
-
|
|
402
|
+
const child = spawn(process.execPath, argv, {
|
|
403
|
+
stdio: 'inherit',
|
|
404
|
+
env: process.env,
|
|
405
|
+
});
|
|
406
|
+
child.on('exit', (code, signal) => {
|
|
407
|
+
if (signal) process.kill(process.pid, signal);
|
|
408
|
+
else process.exit(code == null ? 0 : code);
|
|
409
|
+
});
|
|
410
|
+
// Forward Ctrl+C cleanly so the spawned server can shut down.
|
|
411
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
412
|
+
process.on(sig, () => { try { child.kill(sig); } catch (_e) { /* gone */ } });
|
|
413
|
+
}
|
|
397
414
|
}
|
|
398
415
|
|
|
399
416
|
// ── Main ────────────────────────────────────────────────────────────
|
|
@@ -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:
|
|
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 };
|