@jhizzard/termdeck 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +255 -0
- package/packages/cli/src/index.js +53 -1
- package/packages/cli/src/update-check.js +156 -0
- package/packages/client/public/app.js +210 -1
- package/packages/server/src/index.js +220 -0
- package/packages/server/src/session.js +1 -1
- package/packages/server/src/setup/prompts.js +87 -14
- package/packages/server/src/setup/supabase-mcp.js +195 -0
|
@@ -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 };
|