@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.
@@ -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
+ };