@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,617 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Sprint 64 T1 — Unified `termdeck init` top-level wizard orchestrator.
|
|
4
|
+
//
|
|
5
|
+
// Lifts the new-user install path from 15+ manual steps to "paste 2 credentials,
|
|
6
|
+
// click 3 buttons" via the Supabase MCP auto-provision path. The keystone
|
|
7
|
+
// before MacBook Air dogfood per docs/CONVERGENCE-PLAN.md.
|
|
8
|
+
//
|
|
9
|
+
// Two paths:
|
|
10
|
+
//
|
|
11
|
+
// 1) Manual (default): runs the existing init-mnestra interactive wizard
|
|
12
|
+
// (paste Supabase URL, service_role key, DATABASE_URL, OpenAI key,
|
|
13
|
+
// Anthropic key — same as today), then init-rumen, then doctor.
|
|
14
|
+
//
|
|
15
|
+
// 2) Auto (--auto or --mcp-supabase): drives the Supabase Management API
|
|
16
|
+
// via the Supabase MCP server to provision a fresh project, apply all
|
|
17
|
+
// migrations, deploy Edge Functions, create vault secrets, and apply
|
|
18
|
+
// cron schedules — then runs the existing init-mnestra wizard in
|
|
19
|
+
// --from-env mode for the local-side wiring (config.yaml, ~/.claude/hooks,
|
|
20
|
+
// settings.json migration, pg verify), and best-effort init-rumen
|
|
21
|
+
// (function secrets + test fire). MCP unavailable → falls through to
|
|
22
|
+
// manual with a clear "MCP unavailable" log.
|
|
23
|
+
//
|
|
24
|
+
// Both paths land at the same end-state:
|
|
25
|
+
// ~/.termdeck/secrets.env populated with Mnestra credentials
|
|
26
|
+
// ~/.termdeck/config.yaml with rag.enabled: false (MCP-only default)
|
|
27
|
+
// ~/.claude/hooks/memory-session-end.js refreshed to bundled version
|
|
28
|
+
// ~/.claude/settings.json wired with SessionEnd hook
|
|
29
|
+
// memory_status_aggregation() verified via DATABASE_URL
|
|
30
|
+
// termdeck doctor green
|
|
31
|
+
//
|
|
32
|
+
// Flag reference:
|
|
33
|
+
// --help Print usage + exit
|
|
34
|
+
// --auto Use Supabase MCP auto-provision (creates new project)
|
|
35
|
+
// --mcp-supabase Alias for --auto
|
|
36
|
+
// --reset Drop any existing ~/.termdeck/secrets.env before starting
|
|
37
|
+
// --from-env Non-interactive: read every secret from env vars
|
|
38
|
+
// --dry-run Print the plan; touch nothing
|
|
39
|
+
// --skip-rumen After Mnestra, skip the Rumen init step (Tier 2 only)
|
|
40
|
+
// --skip-doctor Skip the final post-install doctor pass
|
|
41
|
+
//
|
|
42
|
+
// --auto path additional flags (or env equivalents):
|
|
43
|
+
// --pat <token> SUPABASE_ACCESS_TOKEN — Supabase Personal Access Token
|
|
44
|
+
// --org-id <id> Pick a specific org (auto-picks when only one is visible)
|
|
45
|
+
// --project-name New project's name (prompted if missing)
|
|
46
|
+
// --region Project region (default us-east-1)
|
|
47
|
+
// --db-password Auto-generated when omitted (32-char hex; stored in secrets.env)
|
|
48
|
+
//
|
|
49
|
+
// Per Sprint 64 PLANNING § Hardening rule #7 (Supabase RLS hygiene): the
|
|
50
|
+
// auto-provisioner runs `get_advisors` post-migration and blocks on any
|
|
51
|
+
// ERROR-severity advisor (RLS disabled, mutable search_path, etc.).
|
|
52
|
+
//
|
|
53
|
+
// Per Sprint 64 T1 FINDING-1.1A: `--auto` is the primary flag name with
|
|
54
|
+
// `--mcp-supabase` as a documented alias. Reasoning posted to STATUS.md.
|
|
55
|
+
|
|
56
|
+
'use strict';
|
|
57
|
+
|
|
58
|
+
const fs = require('fs');
|
|
59
|
+
const os = require('os');
|
|
60
|
+
const path = require('path');
|
|
61
|
+
const crypto = require('crypto');
|
|
62
|
+
|
|
63
|
+
const osDetect = require('./os-detect');
|
|
64
|
+
const mcpProvision = require('./mcp-supabase-provision');
|
|
65
|
+
|
|
66
|
+
const SETUP_DIR = path.join(__dirname, '..', '..', 'server', 'src', 'setup');
|
|
67
|
+
function loadSetupHelpers() {
|
|
68
|
+
// Lazy-required to keep boot cost low on --help.
|
|
69
|
+
return {
|
|
70
|
+
prompts: require(path.join(SETUP_DIR, 'prompts')),
|
|
71
|
+
dotenv: require(path.join(SETUP_DIR, 'dotenv-io')),
|
|
72
|
+
supabaseUrl: require(path.join(SETUP_DIR, 'supabase-url')),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const HELP = [
|
|
77
|
+
'',
|
|
78
|
+
'TermDeck unified setup',
|
|
79
|
+
'',
|
|
80
|
+
'Usage: termdeck init [--auto] [flags]',
|
|
81
|
+
'',
|
|
82
|
+
'Two paths:',
|
|
83
|
+
' Manual (default): interactive — paste Supabase + OpenAI/Anthropic credentials',
|
|
84
|
+
' Auto (--auto): Supabase MCP auto-provision — creates a new project for you',
|
|
85
|
+
'',
|
|
86
|
+
'Common flags:',
|
|
87
|
+
' --help Print this message and exit',
|
|
88
|
+
' --auto Auto-provision via Supabase MCP (alias: --mcp-supabase)',
|
|
89
|
+
' --reset Drop existing ~/.termdeck/secrets.env before starting',
|
|
90
|
+
' --from-env Non-interactive: read every required secret from env vars',
|
|
91
|
+
' --dry-run Print the plan; touch nothing',
|
|
92
|
+
' --skip-rumen Stop after Mnestra (Tier 2 only)',
|
|
93
|
+
' --skip-doctor Skip the final doctor pass',
|
|
94
|
+
'',
|
|
95
|
+
'--auto path additional flags (or env equivalents):',
|
|
96
|
+
' --pat <token> SUPABASE_ACCESS_TOKEN — Supabase Personal Access Token',
|
|
97
|
+
' --org-id <id> Pick a specific organization',
|
|
98
|
+
' --project-name <name> New project name (prompted if missing)',
|
|
99
|
+
' --region <region> Project region (default us-east-1)',
|
|
100
|
+
' --db-password <pwd> Auto-generated when omitted',
|
|
101
|
+
'',
|
|
102
|
+
'Sub-mode wizards (callable independently for advanced users):',
|
|
103
|
+
' termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)',
|
|
104
|
+
' termdeck init --rumen Deploy Tier 3 async learning (Rumen)',
|
|
105
|
+
' termdeck init --project Scaffold a new project with CLAUDE.md + orchestration docs',
|
|
106
|
+
'',
|
|
107
|
+
'Get a Personal Access Token at: https://supabase.com/dashboard/account/tokens',
|
|
108
|
+
'',
|
|
109
|
+
].join('\n');
|
|
110
|
+
|
|
111
|
+
// Small helpers — same shape the existing wizards use so logs look identical.
|
|
112
|
+
function step(msg) { process.stdout.write(`→ ${msg}`); }
|
|
113
|
+
function ok(suffix = '') { process.stdout.write(` ✓${suffix ? ' ' + suffix : ''}\n`); }
|
|
114
|
+
function fail(err) { process.stdout.write(` ✗\n ${err}\n`); }
|
|
115
|
+
function note(msg) { process.stdout.write(` ${msg}\n`); }
|
|
116
|
+
function dim(s) { return `\x1b[2m${s}\x1b[0m`; }
|
|
117
|
+
function bold(s) { return `\x1b[1m${s}\x1b[0m`; }
|
|
118
|
+
function yellow(s) { return `\x1b[33m${s}\x1b[0m`; }
|
|
119
|
+
function green(s) { return `\x1b[32m${s}\x1b[0m`; }
|
|
120
|
+
|
|
121
|
+
function parseFlags(argv) {
|
|
122
|
+
const out = {
|
|
123
|
+
help: false,
|
|
124
|
+
auto: false,
|
|
125
|
+
reset: false,
|
|
126
|
+
fromEnv: false,
|
|
127
|
+
dryRun: false,
|
|
128
|
+
skipRumen: false,
|
|
129
|
+
skipDoctor: false,
|
|
130
|
+
pat: null,
|
|
131
|
+
orgId: null,
|
|
132
|
+
projectName: null,
|
|
133
|
+
region: null,
|
|
134
|
+
dbPassword: null,
|
|
135
|
+
yes: false,
|
|
136
|
+
};
|
|
137
|
+
for (let i = 0; i < argv.length; i++) {
|
|
138
|
+
const a = argv[i];
|
|
139
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
140
|
+
else if (a === '--auto' || a === '--mcp-supabase') out.auto = true;
|
|
141
|
+
else if (a === '--reset') out.reset = true;
|
|
142
|
+
else if (a === '--from-env') out.fromEnv = true;
|
|
143
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
144
|
+
else if (a === '--skip-rumen') out.skipRumen = true;
|
|
145
|
+
else if (a === '--skip-doctor') out.skipDoctor = true;
|
|
146
|
+
else if (a === '--yes' || a === '-y') out.yes = true;
|
|
147
|
+
else if (a === '--pat') { out.pat = argv[++i]; }
|
|
148
|
+
else if (a === '--org-id') { out.orgId = argv[++i]; }
|
|
149
|
+
else if (a === '--project-name') { out.projectName = argv[++i]; }
|
|
150
|
+
else if (a === '--region') { out.region = argv[++i]; }
|
|
151
|
+
else if (a === '--db-password') { out.dbPassword = argv[++i]; }
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function generateDbPassword() {
|
|
157
|
+
// 32 hex chars = 128 bits of entropy; well above Supabase's 12-char minimum.
|
|
158
|
+
return crypto.randomBytes(16).toString('hex');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function defaultProjectName() {
|
|
162
|
+
// termdeck-<8hex> — deterministic shape, unique enough across users.
|
|
163
|
+
return `termdeck-${crypto.randomBytes(4).toString('hex')}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function printBanner(osInfo) {
|
|
167
|
+
const distroLine = osInfo.family === 'macos'
|
|
168
|
+
? `macOS${osInfo.isAppleSilicon ? ' (Apple Silicon)' : ' (Intel)'}`
|
|
169
|
+
: osInfo.family === 'docker'
|
|
170
|
+
? `Linux container (${osInfo.distro || 'unknown distro'})`
|
|
171
|
+
: osInfo.family === 'linux'
|
|
172
|
+
? `Linux (${osInfo.distro || 'unknown distro'}${osInfo.version ? ' ' + osInfo.version : ''})`
|
|
173
|
+
: 'Unknown platform';
|
|
174
|
+
process.stdout.write(`
|
|
175
|
+
${bold('TermDeck unified setup')}
|
|
176
|
+
─────────────────────────
|
|
177
|
+
|
|
178
|
+
${dim('Platform: ' + distroLine + ', default shell: ' + osInfo.defaultShell)}
|
|
179
|
+
|
|
180
|
+
Press Ctrl+C at any time to cancel.
|
|
181
|
+
|
|
182
|
+
`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function probeExistingInstall(homedir) {
|
|
186
|
+
const secretsPath = path.join(homedir, '.termdeck', 'secrets.env');
|
|
187
|
+
const configPath = path.join(homedir, '.termdeck', 'config.yaml');
|
|
188
|
+
return {
|
|
189
|
+
secretsPath,
|
|
190
|
+
configPath,
|
|
191
|
+
secretsExists: fs.existsSync(secretsPath),
|
|
192
|
+
configExists: fs.existsSync(configPath),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function promptExistingInstallChoice({ prompts, existing }) {
|
|
197
|
+
process.stdout.write(`Found existing install:\n`);
|
|
198
|
+
if (existing.secretsExists) process.stdout.write(` • ${existing.secretsPath}\n`);
|
|
199
|
+
if (existing.configExists) process.stdout.write(` • ${existing.configPath}\n`);
|
|
200
|
+
process.stdout.write(`\n`);
|
|
201
|
+
process.stdout.write(`Options:\n [1] Continue — re-use detected config\n [2] Reset — drop and re-provision\n [3] Cancel\n\n`);
|
|
202
|
+
const choice = await prompts.askRequired('? Choose [1/2/3]', {
|
|
203
|
+
validate: (v) => (/^[123]$/.test(v.trim()) ? null : 'enter 1, 2, or 3'),
|
|
204
|
+
});
|
|
205
|
+
const trimmed = choice.trim();
|
|
206
|
+
if (trimmed === '1') return 'continue';
|
|
207
|
+
if (trimmed === '2') return 'reset';
|
|
208
|
+
return 'cancel';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function dropExistingSecrets(homedir) {
|
|
212
|
+
const secretsPath = path.join(homedir, '.termdeck', 'secrets.env');
|
|
213
|
+
if (fs.existsSync(secretsPath)) {
|
|
214
|
+
const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
|
|
215
|
+
const backup = `${secretsPath}.bak.${stamp}`;
|
|
216
|
+
try {
|
|
217
|
+
fs.copyFileSync(secretsPath, backup);
|
|
218
|
+
fs.unlinkSync(secretsPath);
|
|
219
|
+
return backup;
|
|
220
|
+
} catch (_e) { return null; }
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Run a sub-wizard (init-mnestra.js / init-rumen.js / doctor.js) in-process.
|
|
226
|
+
// Each exposes `module.exports = main` returning a Promise<exitCode>.
|
|
227
|
+
async function runSubWizard(scriptPath, argv) {
|
|
228
|
+
const fn = require(scriptPath);
|
|
229
|
+
const code = await fn(argv);
|
|
230
|
+
return code || 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
234
|
+
// --auto path.
|
|
235
|
+
|
|
236
|
+
async function collectAutoInputs({ flags, prompts, env }) {
|
|
237
|
+
// PAT — flag → env → prompt
|
|
238
|
+
let pat = flags.pat || env.SUPABASE_ACCESS_TOKEN || null;
|
|
239
|
+
if (!pat && flags.fromEnv) {
|
|
240
|
+
throw new Error('--from-env requires SUPABASE_ACCESS_TOKEN in process.env');
|
|
241
|
+
}
|
|
242
|
+
if (!pat) {
|
|
243
|
+
process.stdout.write('? Supabase Personal Access Token (sbp_..., from https://supabase.com/dashboard/account/tokens): ');
|
|
244
|
+
pat = await prompts.askSecret('');
|
|
245
|
+
if (!pat) throw new Error('Supabase PAT is required for --auto path');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Project name — flag → prompt
|
|
249
|
+
let projectName = flags.projectName || null;
|
|
250
|
+
if (!projectName) {
|
|
251
|
+
const suggested = defaultProjectName();
|
|
252
|
+
if (flags.fromEnv || flags.yes) {
|
|
253
|
+
projectName = suggested;
|
|
254
|
+
} else {
|
|
255
|
+
const typed = await prompts.askRequired(`? New Supabase project name (default: ${suggested})`, {
|
|
256
|
+
validate: () => null,
|
|
257
|
+
});
|
|
258
|
+
projectName = typed.trim() || suggested;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// DB password — flag → auto-generate
|
|
263
|
+
const dbPassword = flags.dbPassword || generateDbPassword();
|
|
264
|
+
|
|
265
|
+
// OpenAI key — env → prompt (required)
|
|
266
|
+
let openaiKey = env.OPENAI_API_KEY || null;
|
|
267
|
+
if (!openaiKey && flags.fromEnv) {
|
|
268
|
+
throw new Error('--from-env requires OPENAI_API_KEY in process.env');
|
|
269
|
+
}
|
|
270
|
+
if (!openaiKey) {
|
|
271
|
+
process.stdout.write('? OpenAI API key (sk-... — required for embeddings): ');
|
|
272
|
+
openaiKey = await prompts.askSecret('');
|
|
273
|
+
if (!openaiKey) throw new Error('OpenAI API key is required');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Anthropic key — env → prompt (optional for Mnestra; required for Rumen if Rumen enabled)
|
|
277
|
+
let anthropicKey = env.ANTHROPIC_API_KEY || null;
|
|
278
|
+
if (!anthropicKey && !flags.fromEnv && !flags.skipRumen) {
|
|
279
|
+
process.stdout.write('? Anthropic API key (sk-ant-... — required for Rumen async learning; optional for Mnestra): ');
|
|
280
|
+
anthropicKey = (await prompts.askSecret('')) || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
pat,
|
|
285
|
+
projectName,
|
|
286
|
+
dbPassword,
|
|
287
|
+
orgId: flags.orgId || env.SUPABASE_ORG_ID || null,
|
|
288
|
+
region: flags.region || env.SUPABASE_REGION || 'us-east-1',
|
|
289
|
+
openaiKey,
|
|
290
|
+
anthropicKey,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Resolve @jhizzard/rumen version for the Edge Function deploy. Mirrors
|
|
295
|
+
// init-rumen.js#resolveRumenVersion shape (npm view → fallback). Spawn-sync
|
|
296
|
+
// based, ~1-2s on a fresh shell; cached for the duration of the call.
|
|
297
|
+
function resolveRumenVersion() {
|
|
298
|
+
const { spawnSync } = require('child_process');
|
|
299
|
+
const r = spawnSync('npm', ['view', '@jhizzard/rumen', 'version'], {
|
|
300
|
+
encoding: 'utf-8',
|
|
301
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
302
|
+
timeout: 15000,
|
|
303
|
+
});
|
|
304
|
+
const FALLBACK = '0.5.3';
|
|
305
|
+
if (r.status === 0) {
|
|
306
|
+
const v = (r.stdout || '').trim();
|
|
307
|
+
if (/^\d+\.\d+\.\d+/.test(v)) return v;
|
|
308
|
+
}
|
|
309
|
+
return FALLBACK;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function runAutoFlow({ flags, osInfo, prompts, dotenv, homedir }) {
|
|
313
|
+
process.stdout.write(`${dim('Path: --auto (Supabase MCP provisioning)')}\n\n`);
|
|
314
|
+
|
|
315
|
+
// Phase 1 — collect inputs.
|
|
316
|
+
let inputs;
|
|
317
|
+
try {
|
|
318
|
+
inputs = await collectAutoInputs({ flags, prompts, env: process.env });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
process.stderr.write(`\n[init --auto] ${err.message}\n`);
|
|
321
|
+
return 2;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (flags.dryRun) {
|
|
325
|
+
step('Auto-provision plan (dry-run)');
|
|
326
|
+
ok();
|
|
327
|
+
note(`project name: ${inputs.projectName}`);
|
|
328
|
+
note(`region: ${inputs.region}`);
|
|
329
|
+
note(`org: ${inputs.orgId || '(autopicked from PAT-visible orgs)'}`);
|
|
330
|
+
note(`db password: ${'*'.repeat(8)} (auto-generated, stored in secrets.env)`);
|
|
331
|
+
note('Supabase MCP would: list orgs → create project → wait ready → apply migrations → vault secrets → deploy functions → cron → advisors');
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Phase 2 — drive provisionViaSupabaseMcp.
|
|
336
|
+
const rumenVersion = resolveRumenVersion();
|
|
337
|
+
step('Provisioning Supabase project via MCP server (this can take 60-120s)');
|
|
338
|
+
process.stdout.write('\n');
|
|
339
|
+
|
|
340
|
+
let provision;
|
|
341
|
+
try {
|
|
342
|
+
provision = await mcpProvision.provisionViaSupabaseMcp({
|
|
343
|
+
pat: inputs.pat,
|
|
344
|
+
projectName: inputs.projectName,
|
|
345
|
+
dbPassword: inputs.dbPassword,
|
|
346
|
+
orgId: inputs.orgId,
|
|
347
|
+
region: inputs.region,
|
|
348
|
+
rumenVersion,
|
|
349
|
+
homedir,
|
|
350
|
+
onPhase: (p) => {
|
|
351
|
+
if (p.status === 'start') {
|
|
352
|
+
process.stdout.write(` ${dim('→ ' + p.phase + (p.detail ? ' (' + JSON.stringify(p.detail) + ')' : ''))}\n`);
|
|
353
|
+
} else if (p.status === 'ok') {
|
|
354
|
+
process.stdout.write(` ${green('✓')} ${p.phase}${p.detail ? ' ' + dim(JSON.stringify(p.detail)) : ''}\n`);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
} catch (err) {
|
|
359
|
+
if (err.code === 'MCP_UNAVAILABLE') {
|
|
360
|
+
process.stderr.write(`\n${yellow('[init --auto]')} Supabase MCP not installed — falling back to manual init-mnestra flow.\n`);
|
|
361
|
+
process.stderr.write(`${dim('Install: npm install -g @supabase/mcp-server-supabase')}\n\n`);
|
|
362
|
+
return runManualFlow({ flags, osInfo, prompts, dotenv, homedir });
|
|
363
|
+
}
|
|
364
|
+
if (err.code === 'ORG_LIST_REQUIRED') {
|
|
365
|
+
process.stderr.write(`\n${yellow('[init --auto]')} ${err.message}\n\n`);
|
|
366
|
+
if (Array.isArray(err.orgs)) {
|
|
367
|
+
process.stderr.write('Visible organizations:\n');
|
|
368
|
+
for (const o of err.orgs) {
|
|
369
|
+
process.stderr.write(` ${o.id} ${o.name}${o.plan ? ' (' + o.plan + ')' : ''}\n`);
|
|
370
|
+
}
|
|
371
|
+
process.stderr.write(`\nRe-run with: termdeck init --auto --org-id <id>\n`);
|
|
372
|
+
}
|
|
373
|
+
return 3;
|
|
374
|
+
}
|
|
375
|
+
if (err.code === 'ADVISOR_BLOCK') {
|
|
376
|
+
process.stderr.write(`\n${yellow('[init --auto]')} ${err.message}\n`);
|
|
377
|
+
if (Array.isArray(err.reds)) {
|
|
378
|
+
for (const r of err.reds) {
|
|
379
|
+
process.stderr.write(` ${r.type}/${r.name || 'unknown'} — ${r.message || r.detail || ''}\n`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
process.stderr.write(`\nFix the advisors above (likely a global RLS hygiene rule violation) and re-run.\n`);
|
|
383
|
+
return 4;
|
|
384
|
+
}
|
|
385
|
+
process.stderr.write(`\n[init --auto] provisioning failed (${err.code || 'unknown'}): ${err.message}\n`);
|
|
386
|
+
return 5;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Phase 3 — write secrets.env. We pad with the user-provided OPENAI/ANTHROPIC
|
|
390
|
+
// since those don't come from Supabase.
|
|
391
|
+
//
|
|
392
|
+
// Sprint 64 T4-CODEX AUDIT-RED 16:26 ET resolution: the Supabase
|
|
393
|
+
// Personal Access Token (`inputs.pat`) is INTENTIONALLY NOT persisted to
|
|
394
|
+
// ~/.termdeck/secrets.env. The PAT carries org-wide management-grade
|
|
395
|
+
// privileges (create/delete projects, set vault secrets, deploy functions
|
|
396
|
+
// across every project the user owns). `packages/server/src/index.js:1310`
|
|
397
|
+
// merges every key in secrets.env into every spawned PTY's env via
|
|
398
|
+
// `readTermdeckSecretsForPty()` — so persisting the PAT would broadcast
|
|
399
|
+
// a manage-everything credential to every Codex/Claude/Gemini/Grok/shell
|
|
400
|
+
// child. The PAT lives in process memory only for the duration of this
|
|
401
|
+
// wizard run; the per-project credentials returned BY the MCP provision
|
|
402
|
+
// call (SUPABASE_URL / SERVICE_ROLE_KEY / DATABASE_URL) are scoped to one
|
|
403
|
+
// project and ARE persisted because the running stack needs them.
|
|
404
|
+
// Re-running `termdeck init --auto --reset` re-prompts for the PAT (~90s
|
|
405
|
+
// human cost on a wizard that runs rarely; vastly preferable to broadcasting
|
|
406
|
+
// a PAT to every panel).
|
|
407
|
+
step('Writing ~/.termdeck/secrets.env with provisioned credentials');
|
|
408
|
+
try {
|
|
409
|
+
dotenv.writeSecrets({
|
|
410
|
+
SUPABASE_URL: provision.secrets.SUPABASE_URL,
|
|
411
|
+
SUPABASE_SERVICE_ROLE_KEY: provision.secrets.SUPABASE_SERVICE_ROLE_KEY,
|
|
412
|
+
...(provision.secrets.SUPABASE_ANON_KEY ? { SUPABASE_ANON_KEY: provision.secrets.SUPABASE_ANON_KEY } : {}),
|
|
413
|
+
DATABASE_URL: provision.secrets.DATABASE_URL,
|
|
414
|
+
OPENAI_API_KEY: inputs.openaiKey,
|
|
415
|
+
...(inputs.anthropicKey ? { ANTHROPIC_API_KEY: inputs.anthropicKey } : {}),
|
|
416
|
+
// NOTE: do NOT add SUPABASE_ACCESS_TOKEN here. See AUDIT-RED above.
|
|
417
|
+
});
|
|
418
|
+
} catch (err) {
|
|
419
|
+
fail(err.message);
|
|
420
|
+
return 6;
|
|
421
|
+
}
|
|
422
|
+
ok();
|
|
423
|
+
|
|
424
|
+
process.stdout.write(`\n${bold('Provisioned project:')} ${provision.projectUrl}\n`);
|
|
425
|
+
process.stdout.write(`${bold('Project ref:')} ${provision.projectRef}\n\n`);
|
|
426
|
+
|
|
427
|
+
// Phase 4 — run init-mnestra --yes for local-side wiring.
|
|
428
|
+
//
|
|
429
|
+
// Sprint 64 T4-CODEX AUDIT-CONCERN 16:27 ET resolution: previously this
|
|
430
|
+
// call passed `['--from-env', '--yes']`, but init-mnestra's `--from-env`
|
|
431
|
+
// branch reads every secret from `process.env` directly (init-mnestra.js
|
|
432
|
+
// around :119-182 `inputsFromEnv()`) and does NOT load secrets.env. The
|
|
433
|
+
// auto-flow just wrote per-project secrets to ~/.termdeck/secrets.env
|
|
434
|
+
// (Phase 3 above) but never exported them into process.env, so the chained
|
|
435
|
+
// `--from-env` call would fail with "missing required environment
|
|
436
|
+
// variable(s)". The fix: pass `['--yes']` only — init-mnestra's
|
|
437
|
+
// `collectInputs({yes:true})` path loads secrets via `loadSavedSecrets()`
|
|
438
|
+
// (init-mnestra.js around :243-268) and auto-confirms reuse when the saved
|
|
439
|
+
// bag is complete. The freshly-written secrets.env is exactly what `--yes`
|
|
440
|
+
// is designed to consume.
|
|
441
|
+
process.stdout.write(`${dim('Wiring local Mnestra config...')}\n`);
|
|
442
|
+
const mnestraCode = await runSubWizard(
|
|
443
|
+
path.join(__dirname, 'init-mnestra.js'),
|
|
444
|
+
['--yes', ...(flags.dryRun ? ['--dry-run'] : [])]
|
|
445
|
+
);
|
|
446
|
+
if (mnestraCode !== 0) {
|
|
447
|
+
process.stderr.write(`\n[init --auto] init-mnestra exited ${mnestraCode}; provisioned project credentials are saved in ~/.termdeck/secrets.env — re-run with --yes to retry locally.\n`);
|
|
448
|
+
return 7;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Phase 5 — run init-rumen --yes (best-effort).
|
|
452
|
+
if (!flags.skipRumen) {
|
|
453
|
+
if (!inputs.anthropicKey) {
|
|
454
|
+
process.stderr.write(`${yellow('[init --auto]')} ANTHROPIC_API_KEY not provided — skipping Rumen Tier 3 setup.\n`);
|
|
455
|
+
process.stderr.write(`${dim('To enable later: add ANTHROPIC_API_KEY to ~/.termdeck/secrets.env, then run: termdeck init --rumen --yes')}\n\n`);
|
|
456
|
+
} else {
|
|
457
|
+
process.stdout.write(`${dim('Wiring Rumen Tier 3 (best-effort — requires `supabase` CLI)...')}\n`);
|
|
458
|
+
try {
|
|
459
|
+
const rumenCode = await runSubWizard(
|
|
460
|
+
path.join(__dirname, 'init-rumen.js'),
|
|
461
|
+
['--yes', ...(flags.dryRun ? ['--dry-run'] : [])]
|
|
462
|
+
);
|
|
463
|
+
if (rumenCode !== 0) {
|
|
464
|
+
process.stderr.write(`${yellow('[init --auto]')} init-rumen exited ${rumenCode}; Mnestra is healthy, Rumen setup incomplete. To retry: termdeck init --rumen --yes\n`);
|
|
465
|
+
}
|
|
466
|
+
} catch (err) {
|
|
467
|
+
process.stderr.write(`${yellow('[init --auto]')} init-rumen failed: ${err.message}\n`);
|
|
468
|
+
process.stderr.write(`${dim('Mnestra is healthy. To retry Rumen: termdeck init --rumen --yes')}\n\n`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Phase 6 — doctor.
|
|
474
|
+
if (!flags.skipDoctor) {
|
|
475
|
+
process.stdout.write(`\n${dim('Running termdeck doctor...')}\n`);
|
|
476
|
+
try {
|
|
477
|
+
await runSubWizard(path.join(__dirname, 'doctor.js'), []);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
process.stderr.write(`${yellow('[init --auto]')} doctor failed: ${err.message} (non-blocking)\n`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Phase 7 — final ready message.
|
|
484
|
+
printReadyMessage({ projectUrl: provision.projectUrl, rumenSkipped: flags.skipRumen || !inputs.anthropicKey });
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
489
|
+
// Manual path.
|
|
490
|
+
|
|
491
|
+
async function runManualFlow({ flags, osInfo, prompts, dotenv, homedir }) {
|
|
492
|
+
process.stdout.write(`${dim('Path: manual (interactive credential paste)')}\n\n`);
|
|
493
|
+
|
|
494
|
+
if (flags.dryRun) {
|
|
495
|
+
step('Manual flow plan (dry-run)');
|
|
496
|
+
ok();
|
|
497
|
+
note('init-mnestra --dry-run would prompt for: Supabase URL, service_role key, DATABASE_URL, OpenAI, Anthropic');
|
|
498
|
+
note('init-rumen --dry-run would prompt for: confirm project ref + deploy functions + apply cron');
|
|
499
|
+
note('doctor --no-schema would version-check the four stack packages');
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Forward flags to init-mnestra. --reset already dropped secrets.env (above);
|
|
504
|
+
// --from-env makes init-mnestra read everything from process.env.
|
|
505
|
+
const mnestraArgs = [];
|
|
506
|
+
if (flags.fromEnv) mnestraArgs.push('--from-env');
|
|
507
|
+
if (flags.yes) mnestraArgs.push('--yes');
|
|
508
|
+
const mnestraCode = await runSubWizard(path.join(__dirname, 'init-mnestra.js'), mnestraArgs);
|
|
509
|
+
if (mnestraCode !== 0) {
|
|
510
|
+
process.stderr.write(`\n[init] init-mnestra exited ${mnestraCode}\n`);
|
|
511
|
+
return mnestraCode;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!flags.skipRumen) {
|
|
515
|
+
const rumenArgs = [];
|
|
516
|
+
if (flags.yes) rumenArgs.push('--yes');
|
|
517
|
+
try {
|
|
518
|
+
const rumenCode = await runSubWizard(path.join(__dirname, 'init-rumen.js'), rumenArgs);
|
|
519
|
+
if (rumenCode !== 0) {
|
|
520
|
+
process.stderr.write(`${yellow('[init]')} init-rumen exited ${rumenCode}; Mnestra is healthy. To retry: termdeck init --rumen --yes\n`);
|
|
521
|
+
}
|
|
522
|
+
} catch (err) {
|
|
523
|
+
process.stderr.write(`${yellow('[init]')} init-rumen failed: ${err.message}\n`);
|
|
524
|
+
process.stderr.write(`${dim('Mnestra is healthy. To retry: termdeck init --rumen --yes')}\n`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!flags.skipDoctor) {
|
|
529
|
+
process.stdout.write(`\n${dim('Running termdeck doctor...')}\n`);
|
|
530
|
+
try {
|
|
531
|
+
await runSubWizard(path.join(__dirname, 'doctor.js'), []);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
process.stderr.write(`${yellow('[init]')} doctor failed: ${err.message} (non-blocking)\n`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
printReadyMessage({ projectUrl: null, rumenSkipped: flags.skipRumen });
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function printReadyMessage({ projectUrl, rumenSkipped }) {
|
|
542
|
+
process.stdout.write(`\n${green('TermDeck is ready.')}\n\n`);
|
|
543
|
+
if (projectUrl) {
|
|
544
|
+
process.stdout.write(`Supabase dashboard: ${projectUrl}\n`);
|
|
545
|
+
}
|
|
546
|
+
process.stdout.write(`Next steps:\n`);
|
|
547
|
+
process.stdout.write(` 1. Start TermDeck: ${bold('termdeck')}\n`);
|
|
548
|
+
process.stdout.write(` 2. Dashboard: http://localhost:3000\n`);
|
|
549
|
+
if (rumenSkipped) {
|
|
550
|
+
process.stdout.write(` 3. Enable Tier 3 (Rumen async learning) later: ${bold('termdeck init --rumen --yes')}\n`);
|
|
551
|
+
}
|
|
552
|
+
process.stdout.write(`\n`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
556
|
+
// Entrypoint.
|
|
557
|
+
|
|
558
|
+
async function main(argv) {
|
|
559
|
+
const flags = parseFlags(argv || []);
|
|
560
|
+
if (flags.help) {
|
|
561
|
+
process.stdout.write(HELP);
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const homedir = os.homedir();
|
|
566
|
+
const osInfo = osDetect.detectOS();
|
|
567
|
+
printBanner(osInfo);
|
|
568
|
+
|
|
569
|
+
const setup = loadSetupHelpers();
|
|
570
|
+
const { prompts, dotenv } = setup;
|
|
571
|
+
|
|
572
|
+
// Existing install gate. --reset and --from-env bypass the prompt.
|
|
573
|
+
if (!flags.reset && !flags.fromEnv && !flags.dryRun) {
|
|
574
|
+
const existing = probeExistingInstall(homedir);
|
|
575
|
+
if (existing.secretsExists || existing.configExists) {
|
|
576
|
+
const choice = await promptExistingInstallChoice({ prompts, existing });
|
|
577
|
+
if (choice === 'cancel') {
|
|
578
|
+
process.stdout.write('Cancelled.\n');
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
if (choice === 'reset') flags.reset = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (flags.reset && !flags.dryRun) {
|
|
586
|
+
const backup = dropExistingSecrets(homedir);
|
|
587
|
+
if (backup) {
|
|
588
|
+
note(`(backed up existing secrets.env to ${path.basename(backup)})`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (flags.auto) {
|
|
593
|
+
return runAutoFlow({ flags, osInfo, prompts, dotenv, homedir });
|
|
594
|
+
}
|
|
595
|
+
return runManualFlow({ flags, osInfo, prompts, dotenv, homedir });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (require.main === module) {
|
|
599
|
+
main(process.argv.slice(2))
|
|
600
|
+
.then((code) => process.exit(code || 0))
|
|
601
|
+
.catch((err) => {
|
|
602
|
+
process.stderr.write(`\n[init] unexpected error: ${err && err.stack || err}\n`);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
module.exports = main;
|
|
608
|
+
// Test surface — exposed so tests can pin individual phases without
|
|
609
|
+
// spawning the whole wizard.
|
|
610
|
+
module.exports.parseFlags = parseFlags;
|
|
611
|
+
module.exports.generateDbPassword = generateDbPassword;
|
|
612
|
+
module.exports.defaultProjectName = defaultProjectName;
|
|
613
|
+
module.exports.probeExistingInstall = probeExistingInstall;
|
|
614
|
+
module.exports.collectAutoInputs = collectAutoInputs;
|
|
615
|
+
module.exports.runAutoFlow = runAutoFlow;
|
|
616
|
+
module.exports.runManualFlow = runManualFlow;
|
|
617
|
+
module.exports.HELP = HELP;
|