@jhizzard/termdeck 1.2.0 → 1.4.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/package.json +2 -2
- package/packages/cli/src/index.js +53 -16
- package/packages/cli/src/init-mnestra.js +131 -0
- package/packages/cli/src/init.js +617 -0
- package/packages/cli/src/mcp-supabase-provision.js +685 -0
- package/packages/cli/src/os-detect.js +297 -0
- package/packages/client/public/app.js +555 -8
- package/packages/client/public/index.html +28 -6
- package/packages/client/public/style.css +127 -0
- package/packages/server/src/agent-adapters/claude.js +11 -0
- package/packages/server/src/agent-adapters/codex.js +203 -1
- package/packages/server/src/agent-adapters/gemini.js +4 -0
- package/packages/server/src/agent-adapters/grok.js +4 -0
- package/packages/server/src/database.js +20 -1
- package/packages/server/src/index.js +364 -12
- package/packages/server/src/session.js +25 -5
- package/packages/server/src/setup/supabase-mcp.js +42 -3
- package/packages/stack-installer/assets/hooks/memory-pre-compact.js +277 -0
- package/packages/stack-installer/assets/hooks/memory-session-end.js +14 -2
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
// Sprint 64 T1 — Supabase MCP auto-provision path.
|
|
2
|
+
//
|
|
3
|
+
// This module wraps the Supabase Management API surface (exposed via the
|
|
4
|
+
// official `@supabase/mcp-server-supabase` MCP server) into the wizard-shaped
|
|
5
|
+
// flow that `termdeck init --auto` (or `--mcp-supabase`) drives:
|
|
6
|
+
//
|
|
7
|
+
// 1. detect MCP → list orgs → create project
|
|
8
|
+
// 2. poll ready → fetch URL + anon + service_role keys
|
|
9
|
+
// 3. apply mnestra migrations (001..022) + rumen migration 001 (tables)
|
|
10
|
+
// 4. create vault secrets (rumen_service_role_key, graph_inference_service_role_key)
|
|
11
|
+
// 5. deploy edge functions (rumen-tick + graph-inference)
|
|
12
|
+
// 6. apply rumen migrations 002 + 003 (cron schedules with <project-ref> substitution)
|
|
13
|
+
// 7. run security + performance advisors; RED entries BLOCK
|
|
14
|
+
// 8. return the secrets bag for the wizard to write to ~/.termdeck/secrets.env
|
|
15
|
+
//
|
|
16
|
+
// PAT discipline: the caller's Supabase Personal Access Token is passed
|
|
17
|
+
// straight through to `setup/supabase-mcp.js#callTool` which puts it in the
|
|
18
|
+
// child process's `SUPABASE_ACCESS_TOKEN` env var (never argv, never logged).
|
|
19
|
+
// Every callTool exception path in THIS module routes through
|
|
20
|
+
// `sanitizeErrorForLogs()` which redacts the PAT + every returned key + every
|
|
21
|
+
// project-ref / vault secret before the error reaches stderr/logs. This
|
|
22
|
+
// closes T4-CODEX's Sprint 64 16:09 ET AUDIT-CONCERN #2.
|
|
23
|
+
//
|
|
24
|
+
// Failure modes (every error has a stable `.code` for the caller):
|
|
25
|
+
// MCP_UNAVAILABLE — Supabase MCP not installed; caller falls back to manual
|
|
26
|
+
// ORG_LIST_REQUIRED — caller didn't pass orgId; we return the org list
|
|
27
|
+
// PROJECT_CREATE_FAILED — create_project RPC errored
|
|
28
|
+
// READY_TIMEOUT — project never became ACTIVE_HEALTHY within window
|
|
29
|
+
// FETCH_KEYS_FAILED — get_project_url / get_publishable_keys errored
|
|
30
|
+
// MIGRATION_FAILED — apply_migration errored mid-flight (partial-install marker written)
|
|
31
|
+
// VAULT_FAILED — execute_sql vault.create_secret errored
|
|
32
|
+
// DEPLOY_FAILED — deploy_edge_function errored
|
|
33
|
+
// ADVISOR_BLOCK — RLS hygiene / lint advisor flagged a RED row
|
|
34
|
+
//
|
|
35
|
+
// Out of scope (per T1 brief §1.1 "Out of scope"):
|
|
36
|
+
// - Auto-detecting an existing project to attach to (handled by --from-env).
|
|
37
|
+
// - Vault-dashboard UI (deliberately retired per Sprint 51.5 T3 Class B).
|
|
38
|
+
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
const fs = require('fs');
|
|
42
|
+
const os = require('os');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
|
|
45
|
+
const SETUP_DIR = path.join(__dirname, '..', '..', 'server', 'src', 'setup');
|
|
46
|
+
|
|
47
|
+
// Lazy-required so a wizard that never enters the --auto path doesn't pay
|
|
48
|
+
// the require cost of pg / supabase-mcp.
|
|
49
|
+
function loadSetup() {
|
|
50
|
+
return {
|
|
51
|
+
supabaseMcp: require(path.join(SETUP_DIR, 'supabase-mcp')),
|
|
52
|
+
migrations: require(path.join(SETUP_DIR, 'migrations')),
|
|
53
|
+
migrationTemplating: require(path.join(SETUP_DIR, 'migration-templating')),
|
|
54
|
+
supabaseUrl: require(path.join(SETUP_DIR, 'supabase-url')),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_REGION = 'us-east-1';
|
|
59
|
+
const READY_POLL_INTERVAL_MS = 4000;
|
|
60
|
+
const READY_TIMEOUT_MS = 5 * 60 * 1000; // 5 min — Supabase free-tier provisioning
|
|
61
|
+
const ADVISOR_TYPES = ['security', 'performance'];
|
|
62
|
+
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
64
|
+
// Sensitive-string redaction. Sprint 64 16:09 ET T4-CODEX AUDIT-CONCERN #2:
|
|
65
|
+
// supabase-mcp.js:169-184 returns raw RPC error text + stderr tail on
|
|
66
|
+
// callTool failure. If the PAT or returned service-role key surfaces in that
|
|
67
|
+
// error text, it lands in stderr/logs. This helper redacts every known
|
|
68
|
+
// sensitive literal from any Error or string before it gets logged.
|
|
69
|
+
//
|
|
70
|
+
// Strategy: caller passes a `redactList` of (label, value) pairs; the helper
|
|
71
|
+
// walks the error message + stack + any nested .body field and replaces every
|
|
72
|
+
// occurrence of each value with `[REDACTED:label]`. Empty / short values
|
|
73
|
+
// (<8 chars) are skipped to avoid false-positive redactions on common
|
|
74
|
+
// substrings. Returns a NEW Error preserving .code; original is untouched.
|
|
75
|
+
|
|
76
|
+
function sanitizeErrorForLogs(err, redactList) {
|
|
77
|
+
if (!err) return err;
|
|
78
|
+
const original = err instanceof Error ? err : new Error(String(err));
|
|
79
|
+
const safeRedacts = (redactList || []).filter(
|
|
80
|
+
(r) => r && typeof r.value === 'string' && r.value.length >= 8
|
|
81
|
+
);
|
|
82
|
+
if (safeRedacts.length === 0) return original;
|
|
83
|
+
|
|
84
|
+
function scrub(s) {
|
|
85
|
+
if (typeof s !== 'string') return s;
|
|
86
|
+
let out = s;
|
|
87
|
+
for (const { label, value } of safeRedacts) {
|
|
88
|
+
// Escape regex meta-chars so a value like 'sb_secret_+/=' substring-matches.
|
|
89
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
90
|
+
out = out.replace(new RegExp(escaped, 'g'), `[REDACTED:${label}]`);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const cleaned = new Error(scrub(original.message || ''));
|
|
96
|
+
cleaned.code = original.code;
|
|
97
|
+
if (original.stack) cleaned.stack = scrub(original.stack);
|
|
98
|
+
if (original.body) cleaned.body = scrub(typeof original.body === 'string' ? original.body : JSON.stringify(original.body));
|
|
99
|
+
if (original.detail) cleaned.detail = scrub(String(original.detail));
|
|
100
|
+
// Preserve any structured fields the caller attached, scrubbing strings.
|
|
101
|
+
for (const k of Object.keys(original)) {
|
|
102
|
+
if (k === 'code' || k === 'body' || k === 'detail' || k === 'message' || k === 'stack') continue;
|
|
103
|
+
const v = original[k];
|
|
104
|
+
cleaned[k] = typeof v === 'string' ? scrub(v) : v;
|
|
105
|
+
}
|
|
106
|
+
return cleaned;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function structuredError(code, message, extras) {
|
|
110
|
+
const e = new Error(message);
|
|
111
|
+
e.code = code;
|
|
112
|
+
if (extras && typeof extras === 'object') {
|
|
113
|
+
for (const k of Object.keys(extras)) e[k] = extras[k];
|
|
114
|
+
}
|
|
115
|
+
return e;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// MCP tool-call shape varies by tool: most return `{content: [{type:'text',
|
|
120
|
+
// text: JSONSTRING}]}`. Some return native objects. This helper normalizes.
|
|
121
|
+
function unwrapMcpResult(result) {
|
|
122
|
+
if (!result) return null;
|
|
123
|
+
if (Array.isArray(result.content)) {
|
|
124
|
+
// Text-array shape — first text payload is the JSON body.
|
|
125
|
+
const text = result.content.find((c) => c && c.type === 'text' && typeof c.text === 'string');
|
|
126
|
+
if (text) {
|
|
127
|
+
try { return JSON.parse(text.text); }
|
|
128
|
+
catch (_e) { return text.text; }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(result.toolResult)) return result.toolResult;
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Convenience wrapper around supabaseMcp.callTool that:
|
|
136
|
+
// • applies a per-call timeout (Management API can be slow under load)
|
|
137
|
+
// • normalizes the response via unwrapMcpResult
|
|
138
|
+
// • routes every thrown error through sanitizeErrorForLogs (PAT redaction)
|
|
139
|
+
async function mcpCall({ supabaseMcp, pat, method, params, timeoutMs, redactList }) {
|
|
140
|
+
try {
|
|
141
|
+
const raw = await supabaseMcp.callTool(pat, method, params || {}, {
|
|
142
|
+
timeoutMs: timeoutMs || 30000,
|
|
143
|
+
});
|
|
144
|
+
return unwrapMcpResult(raw);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
throw sanitizeErrorForLogs(err, redactList);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function nowMs() { return Date.now(); }
|
|
151
|
+
|
|
152
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
153
|
+
|
|
154
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
155
|
+
// Phase implementations. Each is a standalone async function with explicit
|
|
156
|
+
// deps so the test surface can mock individual phases without rewiring the
|
|
157
|
+
// full pipeline.
|
|
158
|
+
|
|
159
|
+
async function listOrganizations({ supabaseMcp, pat, redactList }) {
|
|
160
|
+
const result = await mcpCall({
|
|
161
|
+
supabaseMcp, pat, method: 'list_organizations', params: {}, redactList,
|
|
162
|
+
});
|
|
163
|
+
if (!Array.isArray(result)) return [];
|
|
164
|
+
// Normalize the org shape — some MCP versions return {id,name,plan},
|
|
165
|
+
// others {organization_id,organization_name,...}. Coerce.
|
|
166
|
+
return result.map((o) => ({
|
|
167
|
+
id: o.id || o.organization_id || o.slug,
|
|
168
|
+
name: o.name || o.organization_name || o.id,
|
|
169
|
+
plan: o.plan || o.subscription_tier || null,
|
|
170
|
+
raw: o,
|
|
171
|
+
})).filter((o) => o.id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function createProject({ supabaseMcp, pat, orgId, projectName, dbPassword, region, redactList }) {
|
|
175
|
+
const params = {
|
|
176
|
+
name: projectName,
|
|
177
|
+
organization_id: orgId,
|
|
178
|
+
db_pass: dbPassword,
|
|
179
|
+
region: region || DEFAULT_REGION,
|
|
180
|
+
};
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
result = await mcpCall({
|
|
184
|
+
supabaseMcp, pat, method: 'create_project', params, redactList,
|
|
185
|
+
timeoutMs: 60000,
|
|
186
|
+
});
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw structuredError('PROJECT_CREATE_FAILED', `create_project failed: ${err.message}`, { cause: err });
|
|
189
|
+
}
|
|
190
|
+
const projectRef = result && (result.id || result.project_ref || result.ref);
|
|
191
|
+
if (!projectRef) {
|
|
192
|
+
throw structuredError('PROJECT_CREATE_FAILED',
|
|
193
|
+
'create_project returned no project ref; cannot continue', { detail: result });
|
|
194
|
+
}
|
|
195
|
+
return { projectRef, raw: result };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function waitForProjectReady({ supabaseMcp, pat, projectRef, redactList, deps }) {
|
|
199
|
+
const start = nowMs();
|
|
200
|
+
const pollInterval = (deps && deps.pollInterval) || READY_POLL_INTERVAL_MS;
|
|
201
|
+
const timeout = (deps && deps.readyTimeout) || READY_TIMEOUT_MS;
|
|
202
|
+
let lastStatus = null;
|
|
203
|
+
while (nowMs() - start < timeout) {
|
|
204
|
+
let info;
|
|
205
|
+
try {
|
|
206
|
+
info = await mcpCall({
|
|
207
|
+
supabaseMcp, pat, method: 'get_project', params: { id: projectRef }, redactList,
|
|
208
|
+
timeoutMs: 15000,
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// Transient errors during boot-up — log via onPhase but don't bail until timeout.
|
|
212
|
+
lastStatus = `get_project error: ${err.message}`;
|
|
213
|
+
}
|
|
214
|
+
if (info) {
|
|
215
|
+
const status = info.status || info.health || (info.database && info.database.status);
|
|
216
|
+
lastStatus = status;
|
|
217
|
+
if (status === 'ACTIVE_HEALTHY' || status === 'ACTIVE' || status === 'HEALTHY') {
|
|
218
|
+
return { ready: true, info };
|
|
219
|
+
}
|
|
220
|
+
if (status === 'FAILED' || status === 'UNHEALTHY') {
|
|
221
|
+
throw structuredError('READY_TIMEOUT', `project entered ${status} state`, { lastStatus });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
await sleep(pollInterval);
|
|
225
|
+
}
|
|
226
|
+
throw structuredError('READY_TIMEOUT',
|
|
227
|
+
`project did not reach ACTIVE_HEALTHY within ${Math.round(timeout / 1000)}s`,
|
|
228
|
+
{ lastStatus });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function fetchProjectAccess({ supabaseMcp, pat, projectRef, redactList }) {
|
|
232
|
+
let url;
|
|
233
|
+
let keys;
|
|
234
|
+
try {
|
|
235
|
+
url = await mcpCall({
|
|
236
|
+
supabaseMcp, pat, method: 'get_project_url',
|
|
237
|
+
params: { id: projectRef }, redactList,
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
throw structuredError('FETCH_KEYS_FAILED', `get_project_url failed: ${err.message}`, { cause: err });
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
keys = await mcpCall({
|
|
244
|
+
supabaseMcp, pat, method: 'get_publishable_keys',
|
|
245
|
+
params: { id: projectRef }, redactList,
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
throw structuredError('FETCH_KEYS_FAILED', `get_publishable_keys failed: ${err.message}`, { cause: err });
|
|
249
|
+
}
|
|
250
|
+
// url may be returned as a string OR an object {url: ...}.
|
|
251
|
+
const projectUrl = typeof url === 'string' ? url : (url && (url.url || url.project_url));
|
|
252
|
+
if (!projectUrl) {
|
|
253
|
+
throw structuredError('FETCH_KEYS_FAILED', 'get_project_url returned no URL', { detail: url });
|
|
254
|
+
}
|
|
255
|
+
// keys may be an array of {name, api_key} OR an object map.
|
|
256
|
+
let anonKey = null;
|
|
257
|
+
let serviceRoleKey = null;
|
|
258
|
+
if (Array.isArray(keys)) {
|
|
259
|
+
for (const k of keys) {
|
|
260
|
+
const name = (k.name || k.role || '').toLowerCase();
|
|
261
|
+
const val = k.api_key || k.key || k.value;
|
|
262
|
+
if (name === 'anon' || name.includes('anon')) anonKey = anonKey || val;
|
|
263
|
+
if (name === 'service_role' || name.includes('service_role')) serviceRoleKey = serviceRoleKey || val;
|
|
264
|
+
}
|
|
265
|
+
} else if (keys && typeof keys === 'object') {
|
|
266
|
+
anonKey = keys.anon || keys.anon_key || null;
|
|
267
|
+
serviceRoleKey = keys.service_role || keys.service_role_key || null;
|
|
268
|
+
}
|
|
269
|
+
if (!serviceRoleKey) {
|
|
270
|
+
throw structuredError('FETCH_KEYS_FAILED', 'get_publishable_keys did not return a service_role key', { detail: keys });
|
|
271
|
+
}
|
|
272
|
+
return { projectUrl, anonKey, serviceRoleKey };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Apply the full bundled migration set in declared order. On failure mid-run,
|
|
276
|
+
// writes ~/.termdeck/.partial-install with the applied list so subsequent
|
|
277
|
+
// runs can resume rather than re-provision. Sprint 51.5 / Sprint 61 already
|
|
278
|
+
// solved the apply-set ordering via setup/migrations.js — we use the same
|
|
279
|
+
// listMnestraMigrations() ordering here for parity with the manual path.
|
|
280
|
+
async function applyAllMigrations({ supabaseMcp, pat, projectRef, redactList, partialInstallPath, deps }) {
|
|
281
|
+
const { migrations, migrationTemplating } = deps;
|
|
282
|
+
const mnestraFiles = migrations.listMnestraMigrations();
|
|
283
|
+
if (mnestraFiles.length === 0) {
|
|
284
|
+
throw structuredError('MIGRATION_FAILED', 'no Mnestra migrations bundled; TermDeck install corrupted');
|
|
285
|
+
}
|
|
286
|
+
const rumenFiles = migrations.listRumenMigrations();
|
|
287
|
+
const rumenTables = rumenFiles.find((f) => /001.*rumen_tables/.test(path.basename(f)));
|
|
288
|
+
if (!rumenTables) {
|
|
289
|
+
throw structuredError('MIGRATION_FAILED', 'bundled 001_rumen_tables.sql is missing');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const applied = [];
|
|
293
|
+
|
|
294
|
+
async function applyOne(filepath, label) {
|
|
295
|
+
const basename = path.basename(filepath);
|
|
296
|
+
const raw = migrations.readFile(filepath);
|
|
297
|
+
let sql = raw;
|
|
298
|
+
// Sprint 42 T3 templating only applies to cron-schedule migrations;
|
|
299
|
+
// call applyTemplating with the project-ref so the helper can substitute
|
|
300
|
+
// placeholders in the rumen-side cron migrations. Mnestra migrations
|
|
301
|
+
// don't carry placeholders — applyTemplating is a no-op on them.
|
|
302
|
+
sql = migrationTemplating.applyTemplating(raw, { projectRef });
|
|
303
|
+
try {
|
|
304
|
+
await mcpCall({
|
|
305
|
+
supabaseMcp, pat, method: 'apply_migration',
|
|
306
|
+
params: { id: projectRef, name: label || basename.replace(/\.sql$/, ''), query: sql },
|
|
307
|
+
redactList,
|
|
308
|
+
timeoutMs: 90000,
|
|
309
|
+
});
|
|
310
|
+
applied.push(basename);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
// Write partial-install marker so the user can resume on next run.
|
|
313
|
+
try {
|
|
314
|
+
const marker = {
|
|
315
|
+
timestamp: new Date().toISOString(),
|
|
316
|
+
projectRef,
|
|
317
|
+
applied,
|
|
318
|
+
failedAt: basename,
|
|
319
|
+
reason: err.message,
|
|
320
|
+
};
|
|
321
|
+
fs.mkdirSync(path.dirname(partialInstallPath), { recursive: true });
|
|
322
|
+
fs.writeFileSync(partialInstallPath, JSON.stringify(marker, null, 2));
|
|
323
|
+
} catch (_e) { /* best-effort */ }
|
|
324
|
+
throw structuredError('MIGRATION_FAILED',
|
|
325
|
+
`${basename} failed: ${err.message}`,
|
|
326
|
+
{ applied, failedAt: basename, cause: err });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const f of mnestraFiles) await applyOne(f);
|
|
331
|
+
await applyOne(rumenTables);
|
|
332
|
+
return { applied };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Vault secrets created via execute_sql calling vault.create_secret($value, $name).
|
|
336
|
+
// Both keys hold the same value (the project's service_role key). Mirrors
|
|
337
|
+
// init-rumen.js#ensureVaultSecrets behavior at lines 596-673.
|
|
338
|
+
async function createVaultSecrets({ supabaseMcp, pat, projectRef, serviceRoleKey, redactList }) {
|
|
339
|
+
const required = [
|
|
340
|
+
{ name: 'rumen_service_role_key', value: serviceRoleKey },
|
|
341
|
+
{ name: 'graph_inference_service_role_key', value: serviceRoleKey },
|
|
342
|
+
];
|
|
343
|
+
const created = [];
|
|
344
|
+
for (const { name, value } of required) {
|
|
345
|
+
// Escape single quotes via SQL doubling — matches init-rumen#vaultSqlEditorUrl logic.
|
|
346
|
+
const escapedValue = String(value).replace(/'/g, "''");
|
|
347
|
+
const escapedName = String(name).replace(/'/g, "''");
|
|
348
|
+
const query = `select vault.create_secret('${escapedValue}', '${escapedName}');`;
|
|
349
|
+
try {
|
|
350
|
+
await mcpCall({
|
|
351
|
+
supabaseMcp, pat, method: 'execute_sql',
|
|
352
|
+
params: { id: projectRef, query }, redactList,
|
|
353
|
+
});
|
|
354
|
+
created.push(name);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
// If it already exists, vault.create_secret raises a unique-violation;
|
|
357
|
+
// treat that as success (idempotent semantics).
|
|
358
|
+
if (/duplicate key|unique constraint|already exists/i.test(err.message)) {
|
|
359
|
+
created.push(name);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
throw structuredError('VAULT_FAILED', `vault.create_secret(${name}) failed: ${err.message}`, { cause: err });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return { created };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Deploy Rumen Edge Functions (rumen-tick + graph-inference) via the MCP
|
|
369
|
+
// deploy_edge_function tool. Functions are bundled at
|
|
370
|
+
// packages/server/src/setup/rumen/functions/<name>/index.ts (rumen-tick has
|
|
371
|
+
// a __RUMEN_VERSION__ placeholder substituted at deploy time).
|
|
372
|
+
async function deployEdgeFunctions({ supabaseMcp, pat, projectRef, rumenVersion, redactList, deps }) {
|
|
373
|
+
const { migrations } = deps;
|
|
374
|
+
const functionNames = migrations.listRumenFunctions();
|
|
375
|
+
if (functionNames.length === 0) {
|
|
376
|
+
throw structuredError('DEPLOY_FAILED', 'no Rumen Edge Functions bundled');
|
|
377
|
+
}
|
|
378
|
+
const root = migrations.rumenFunctionsRoot();
|
|
379
|
+
const deployed = [];
|
|
380
|
+
for (const name of functionNames) {
|
|
381
|
+
const indexPath = path.join(root, name, 'index.ts');
|
|
382
|
+
let body;
|
|
383
|
+
try {
|
|
384
|
+
body = fs.readFileSync(indexPath, 'utf8');
|
|
385
|
+
} catch (err) {
|
|
386
|
+
throw structuredError('DEPLOY_FAILED', `cannot read ${indexPath}: ${err.message}`);
|
|
387
|
+
}
|
|
388
|
+
// rumen-tick carries a version placeholder; substitute now.
|
|
389
|
+
if (body.includes('__RUMEN_VERSION__')) {
|
|
390
|
+
if (!rumenVersion) {
|
|
391
|
+
throw structuredError('DEPLOY_FAILED',
|
|
392
|
+
`${name}/index.ts has __RUMEN_VERSION__ placeholder but no rumenVersion provided`);
|
|
393
|
+
}
|
|
394
|
+
body = body.replace(/__RUMEN_VERSION__/g, rumenVersion);
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
await mcpCall({
|
|
398
|
+
supabaseMcp, pat, method: 'deploy_edge_function',
|
|
399
|
+
params: {
|
|
400
|
+
id: projectRef,
|
|
401
|
+
name,
|
|
402
|
+
files: [{ name: 'index.ts', content: body }],
|
|
403
|
+
verify_jwt: false,
|
|
404
|
+
},
|
|
405
|
+
redactList,
|
|
406
|
+
timeoutMs: 120000,
|
|
407
|
+
});
|
|
408
|
+
deployed.push(name);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
throw structuredError('DEPLOY_FAILED', `deploy ${name} failed: ${err.message}`, { cause: err, deployed });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { deployed };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Apply the cron-schedule migrations (rumen 002 + 003) with project-ref
|
|
417
|
+
// substitution. These reference `vault.decrypted_secrets` — must run AFTER
|
|
418
|
+
// createVaultSecrets so the secret names resolve at function-call time.
|
|
419
|
+
async function applyCronSchedules({ supabaseMcp, pat, projectRef, redactList, deps }) {
|
|
420
|
+
const { migrations, migrationTemplating } = deps;
|
|
421
|
+
const rumenFiles = migrations.listRumenMigrations();
|
|
422
|
+
const applied = [];
|
|
423
|
+
|
|
424
|
+
const cronFiles = [
|
|
425
|
+
rumenFiles.find((f) => /002.*pg_cron/.test(path.basename(f))),
|
|
426
|
+
rumenFiles.find((f) => /003.*graph_inference/.test(path.basename(f))),
|
|
427
|
+
].filter(Boolean);
|
|
428
|
+
|
|
429
|
+
for (const file of cronFiles) {
|
|
430
|
+
const basename = path.basename(file);
|
|
431
|
+
const raw = migrations.readFile(file);
|
|
432
|
+
const sql = migrationTemplating.applyTemplating(raw, { projectRef });
|
|
433
|
+
try {
|
|
434
|
+
await mcpCall({
|
|
435
|
+
supabaseMcp, pat, method: 'apply_migration',
|
|
436
|
+
params: { id: projectRef, name: basename.replace(/\.sql$/, ''), query: sql },
|
|
437
|
+
redactList,
|
|
438
|
+
timeoutMs: 30000,
|
|
439
|
+
});
|
|
440
|
+
applied.push(basename);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
throw structuredError('MIGRATION_FAILED',
|
|
443
|
+
`cron schedule ${basename} failed: ${err.message}`, { applied, failedAt: basename, cause: err });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return { applied };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Run security + performance advisors; any RED-severity row blocks. This is
|
|
450
|
+
// the post-provision RLS hygiene gate per Sprint 64 PLANNING § Hardening
|
|
451
|
+
// rule #7 + global CLAUDE.md § Supabase RLS hygiene.
|
|
452
|
+
async function runAdvisors({ supabaseMcp, pat, projectRef, redactList }) {
|
|
453
|
+
const findings = { security: [], performance: [] };
|
|
454
|
+
const reds = [];
|
|
455
|
+
for (const type of ADVISOR_TYPES) {
|
|
456
|
+
try {
|
|
457
|
+
const result = await mcpCall({
|
|
458
|
+
supabaseMcp, pat, method: 'get_advisors',
|
|
459
|
+
params: { id: projectRef, type }, redactList,
|
|
460
|
+
timeoutMs: 30000,
|
|
461
|
+
});
|
|
462
|
+
const rows = Array.isArray(result) ? result : (result && Array.isArray(result.lints) ? result.lints : []);
|
|
463
|
+
findings[type] = rows;
|
|
464
|
+
for (const row of rows) {
|
|
465
|
+
const severity = (row.level || row.severity || '').toUpperCase();
|
|
466
|
+
// RED severities: ERROR (top), WARN (medium). Block on ERROR only — WARN is
|
|
467
|
+
// surfaced but doesn't block, matching the way Brad's 2026-05-06 sweep was
|
|
468
|
+
// run (the migration_019 changes addressed errors, warns were docs-only).
|
|
469
|
+
if (severity === 'ERROR') {
|
|
470
|
+
reds.push({ type, ...row });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
} catch (err) {
|
|
474
|
+
// Advisor failure shouldn't block install — surface as a warning row.
|
|
475
|
+
findings[type] = [{ severity: 'ADVISOR_UNAVAILABLE', message: err.message }];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (reds.length > 0) {
|
|
479
|
+
throw structuredError('ADVISOR_BLOCK',
|
|
480
|
+
`Supabase advisors flagged ${reds.length} ERROR-level finding(s); install blocked`,
|
|
481
|
+
{ advisors: findings, reds });
|
|
482
|
+
}
|
|
483
|
+
return findings;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
487
|
+
// Top-level entrypoint. Orchestrates phases 1-8 with progress callbacks.
|
|
488
|
+
|
|
489
|
+
const PHASES = [
|
|
490
|
+
'preflight',
|
|
491
|
+
'list-orgs',
|
|
492
|
+
'create-project',
|
|
493
|
+
'wait-ready',
|
|
494
|
+
'fetch-access',
|
|
495
|
+
'apply-migrations',
|
|
496
|
+
'create-vault-secrets',
|
|
497
|
+
'deploy-functions',
|
|
498
|
+
'apply-cron',
|
|
499
|
+
'run-advisors',
|
|
500
|
+
'done',
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
async function provisionViaSupabaseMcp(opts) {
|
|
504
|
+
opts = opts || {};
|
|
505
|
+
const pat = opts.pat;
|
|
506
|
+
const projectName = opts.projectName;
|
|
507
|
+
const dbPassword = opts.dbPassword;
|
|
508
|
+
const orgId = opts.orgId;
|
|
509
|
+
const region = opts.region || DEFAULT_REGION;
|
|
510
|
+
const dryRun = !!opts.dryRun;
|
|
511
|
+
const onPhase = typeof opts.onPhase === 'function' ? opts.onPhase : (() => {});
|
|
512
|
+
const homedir = opts.homedir || os.homedir();
|
|
513
|
+
const rumenVersion = opts.rumenVersion;
|
|
514
|
+
const deps = opts.deps || loadSetup();
|
|
515
|
+
const supabaseMcp = deps.supabaseMcp;
|
|
516
|
+
|
|
517
|
+
if (!pat || typeof pat !== 'string') {
|
|
518
|
+
throw structuredError('MCP_UNAVAILABLE', 'provisionViaSupabaseMcp requires `pat` (Supabase Personal Access Token)');
|
|
519
|
+
}
|
|
520
|
+
if (!projectName || typeof projectName !== 'string') {
|
|
521
|
+
throw structuredError('MCP_UNAVAILABLE', 'provisionViaSupabaseMcp requires `projectName`');
|
|
522
|
+
}
|
|
523
|
+
if (!dbPassword || typeof dbPassword !== 'string' || dbPassword.length < 12) {
|
|
524
|
+
throw structuredError('MCP_UNAVAILABLE', 'provisionViaSupabaseMcp requires `dbPassword` (12+ chars)');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Redact list — every sensitive literal sanitizeErrorForLogs scrubs from
|
|
528
|
+
// any thrown error before it reaches stderr/logs.
|
|
529
|
+
const redactList = [
|
|
530
|
+
{ label: 'PAT', value: pat },
|
|
531
|
+
{ label: 'DB_PASSWORD', value: dbPassword },
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
// Phase 1 — preflight: confirm MCP available.
|
|
535
|
+
onPhase({ phase: 'preflight', status: 'start' });
|
|
536
|
+
const detection = await supabaseMcp.detectMcp();
|
|
537
|
+
if (!detection || !detection.available) {
|
|
538
|
+
throw structuredError('MCP_UNAVAILABLE',
|
|
539
|
+
`Supabase MCP not installed (${(detection && detection.error) || 'unknown'}). Run: npm install -g @supabase/mcp-server-supabase`);
|
|
540
|
+
}
|
|
541
|
+
onPhase({ phase: 'preflight', status: 'ok', detail: { mode: detection.mode } });
|
|
542
|
+
|
|
543
|
+
if (dryRun) {
|
|
544
|
+
onPhase({ phase: 'done', status: 'ok', detail: 'dry-run; no Supabase calls fired' });
|
|
545
|
+
return { ok: true, dryRun: true, projectRef: null, secrets: null };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Phase 2 — list orgs (and pick the one to use).
|
|
549
|
+
onPhase({ phase: 'list-orgs', status: 'start' });
|
|
550
|
+
const orgs = await listOrganizations({ supabaseMcp, pat, redactList });
|
|
551
|
+
if (orgs.length === 0) {
|
|
552
|
+
throw structuredError('ORG_LIST_REQUIRED', 'no Supabase organizations visible to this PAT — verify token scope at https://supabase.com/dashboard/account/tokens', { orgs });
|
|
553
|
+
}
|
|
554
|
+
let resolvedOrgId = orgId;
|
|
555
|
+
if (!resolvedOrgId) {
|
|
556
|
+
if (orgs.length === 1) {
|
|
557
|
+
resolvedOrgId = orgs[0].id;
|
|
558
|
+
onPhase({ phase: 'list-orgs', status: 'ok', detail: { autopicked: orgs[0].name } });
|
|
559
|
+
} else {
|
|
560
|
+
throw structuredError('ORG_LIST_REQUIRED',
|
|
561
|
+
`${orgs.length} organizations visible; caller must pick one and re-call with orgId set`,
|
|
562
|
+
{ orgs });
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
if (!orgs.some((o) => o.id === resolvedOrgId)) {
|
|
566
|
+
throw structuredError('ORG_LIST_REQUIRED',
|
|
567
|
+
`orgId ${resolvedOrgId} not in visible org list`, { orgs });
|
|
568
|
+
}
|
|
569
|
+
onPhase({ phase: 'list-orgs', status: 'ok', detail: { orgId: resolvedOrgId } });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Phase 3 — create project.
|
|
573
|
+
onPhase({ phase: 'create-project', status: 'start', detail: { name: projectName, region } });
|
|
574
|
+
const { projectRef, raw: projectInfo } = await createProject({
|
|
575
|
+
supabaseMcp, pat, orgId: resolvedOrgId, projectName, dbPassword, region, redactList,
|
|
576
|
+
});
|
|
577
|
+
// Add the new project ref to the redact list so it doesn't leak in
|
|
578
|
+
// subsequent error messages (per T4-CODEX AUDIT-CONCERN #2 hygiene).
|
|
579
|
+
redactList.push({ label: 'PROJECT_REF', value: projectRef });
|
|
580
|
+
onPhase({ phase: 'create-project', status: 'ok', detail: { projectRef } });
|
|
581
|
+
|
|
582
|
+
// Phase 4 — wait for ready.
|
|
583
|
+
onPhase({ phase: 'wait-ready', status: 'start', detail: { projectRef } });
|
|
584
|
+
await waitForProjectReady({ supabaseMcp, pat, projectRef, redactList, deps: opts.deps });
|
|
585
|
+
onPhase({ phase: 'wait-ready', status: 'ok' });
|
|
586
|
+
|
|
587
|
+
// Phase 5 — fetch URL + keys.
|
|
588
|
+
onPhase({ phase: 'fetch-access', status: 'start' });
|
|
589
|
+
const { projectUrl, anonKey, serviceRoleKey } = await fetchProjectAccess({
|
|
590
|
+
supabaseMcp, pat, projectRef, redactList,
|
|
591
|
+
});
|
|
592
|
+
redactList.push({ label: 'SERVICE_ROLE_KEY', value: serviceRoleKey });
|
|
593
|
+
if (anonKey) redactList.push({ label: 'ANON_KEY', value: anonKey });
|
|
594
|
+
onPhase({ phase: 'fetch-access', status: 'ok', detail: { projectUrl } });
|
|
595
|
+
|
|
596
|
+
// Compose the DATABASE_URL from project ref + db password. Supabase's
|
|
597
|
+
// canonical Transaction Pooler URL pattern (Sprint 51.5 T3 reference):
|
|
598
|
+
// postgres://postgres.<ref>:<encoded-pw>@aws-0-<region>.pooler.supabase.com:6543/postgres
|
|
599
|
+
//
|
|
600
|
+
// Region prefix mapping (Supabase exposes the host string from the
|
|
601
|
+
// dashboard; the MCP doesn't return it directly, so we synthesize from
|
|
602
|
+
// the region the caller specified). Match exactly Sprint 51.5 T3 prep.
|
|
603
|
+
const dbUrlEncoded = encodeURIComponent(dbPassword);
|
|
604
|
+
const databaseUrl =
|
|
605
|
+
`postgres://postgres.${projectRef}:${dbUrlEncoded}@aws-0-${region}.pooler.supabase.com:6543/postgres` +
|
|
606
|
+
`?pgbouncer=true&connection_limit=1`;
|
|
607
|
+
|
|
608
|
+
// Phase 6 — apply migrations (mnestra 1-22 + rumen 001).
|
|
609
|
+
const partialInstallPath = path.join(homedir, '.termdeck', '.partial-install');
|
|
610
|
+
onPhase({ phase: 'apply-migrations', status: 'start' });
|
|
611
|
+
const migResult = await applyAllMigrations({
|
|
612
|
+
supabaseMcp, pat, projectRef, redactList, partialInstallPath, deps,
|
|
613
|
+
});
|
|
614
|
+
onPhase({ phase: 'apply-migrations', status: 'ok', detail: { count: migResult.applied.length } });
|
|
615
|
+
|
|
616
|
+
// Phase 7 — vault secrets.
|
|
617
|
+
onPhase({ phase: 'create-vault-secrets', status: 'start' });
|
|
618
|
+
const vaultResult = await createVaultSecrets({
|
|
619
|
+
supabaseMcp, pat, projectRef, serviceRoleKey, redactList,
|
|
620
|
+
});
|
|
621
|
+
onPhase({ phase: 'create-vault-secrets', status: 'ok', detail: { created: vaultResult.created } });
|
|
622
|
+
|
|
623
|
+
// Phase 8 — deploy edge functions.
|
|
624
|
+
onPhase({ phase: 'deploy-functions', status: 'start' });
|
|
625
|
+
const deployResult = await deployEdgeFunctions({
|
|
626
|
+
supabaseMcp, pat, projectRef, rumenVersion, redactList, deps,
|
|
627
|
+
});
|
|
628
|
+
onPhase({ phase: 'deploy-functions', status: 'ok', detail: { deployed: deployResult.deployed } });
|
|
629
|
+
|
|
630
|
+
// Phase 9 — apply cron schedules (rumen 002 + 003 with project-ref templating).
|
|
631
|
+
onPhase({ phase: 'apply-cron', status: 'start' });
|
|
632
|
+
const cronResult = await applyCronSchedules({
|
|
633
|
+
supabaseMcp, pat, projectRef, redactList, deps,
|
|
634
|
+
});
|
|
635
|
+
onPhase({ phase: 'apply-cron', status: 'ok', detail: { applied: cronResult.applied } });
|
|
636
|
+
|
|
637
|
+
// Phase 10 — run advisors. RED blocks.
|
|
638
|
+
onPhase({ phase: 'run-advisors', status: 'start' });
|
|
639
|
+
const advisorFindings = await runAdvisors({
|
|
640
|
+
supabaseMcp, pat, projectRef, redactList,
|
|
641
|
+
});
|
|
642
|
+
onPhase({ phase: 'run-advisors', status: 'ok' });
|
|
643
|
+
|
|
644
|
+
// Clean up any stale partial-install marker from a prior failed run.
|
|
645
|
+
try { fs.unlinkSync(partialInstallPath); } catch (_e) { /* not present */ }
|
|
646
|
+
|
|
647
|
+
const result = {
|
|
648
|
+
ok: true,
|
|
649
|
+
projectRef,
|
|
650
|
+
projectUrl,
|
|
651
|
+
projectInfo,
|
|
652
|
+
appliedMigrations: migResult.applied,
|
|
653
|
+
deployedFunctions: deployResult.deployed,
|
|
654
|
+
vaultSecrets: vaultResult.created,
|
|
655
|
+
cronApplied: cronResult.applied,
|
|
656
|
+
advisors: advisorFindings,
|
|
657
|
+
secrets: {
|
|
658
|
+
SUPABASE_URL: projectUrl,
|
|
659
|
+
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
660
|
+
SUPABASE_ANON_KEY: anonKey || null,
|
|
661
|
+
DATABASE_URL: databaseUrl,
|
|
662
|
+
// OPENAI_API_KEY + ANTHROPIC_API_KEY come from the user — caller fills.
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
onPhase({ phase: 'done', status: 'ok' });
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
module.exports = {
|
|
670
|
+
provisionViaSupabaseMcp,
|
|
671
|
+
PHASES,
|
|
672
|
+
// Exported for tests:
|
|
673
|
+
sanitizeErrorForLogs,
|
|
674
|
+
unwrapMcpResult,
|
|
675
|
+
_listOrganizations: listOrganizations,
|
|
676
|
+
_createProject: createProject,
|
|
677
|
+
_waitForProjectReady: waitForProjectReady,
|
|
678
|
+
_fetchProjectAccess: fetchProjectAccess,
|
|
679
|
+
_applyAllMigrations: applyAllMigrations,
|
|
680
|
+
_createVaultSecrets: createVaultSecrets,
|
|
681
|
+
_deployEdgeFunctions: deployEdgeFunctions,
|
|
682
|
+
_applyCronSchedules: applyCronSchedules,
|
|
683
|
+
_runAdvisors: runAdvisors,
|
|
684
|
+
_structuredError: structuredError,
|
|
685
|
+
};
|