@jhizzard/termdeck 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,14 @@ First-time user? The **config** button in the toolbar shows what's set up and wh
22
22
 
23
23
  Enabling Flashback takes **one additional 15-minute setup step** — see Tier 2 below. The rest of this README explains what you get, how it works, and how to go deeper.
24
24
 
25
+ ### Want the whole stack in one command?
26
+
27
+ ```bash
28
+ npx @jhizzard/termdeck-stack
29
+ ```
30
+
31
+ The meta-installer prints a layered overview of the four packages (TermDeck + Mnestra + Rumen + Supabase MCP), detects what's already on your machine, asks which tier you want, runs `npm install -g` for the missing pieces, and merges Mnestra + Supabase MCP entries into `~/.claude/mcp.json`. See [packages/stack-installer/README.md](packages/stack-installer/README.md) for details, or `npx @jhizzard/termdeck-stack --help`.
32
+
25
33
  ---
26
34
 
27
35
  ## Documentation hierarchy
@@ -211,7 +219,8 @@ For users who want more than `npx` — cloning from source, building a macOS `.a
211
219
 
212
220
  ### Alternative install paths
213
221
 
214
- - **Permanent global install:** `npm install -g @jhizzard/termdeck` then `termdeck` from anywhere
222
+ - **Permanent global install:** `npm install -g @jhizzard/termdeck` then `termdeck` from anywhere. From v0.5.0, `termdeck` (no subcommand) auto-detects a configured stack and boots Mnestra + checks Rumen automatically — same four-step output as `scripts/start.sh`. Use `termdeck --no-stack` to force a Tier-1-only boot.
223
+ - **Force-orchestrate alias:** `termdeck stack` always runs the orchestrator regardless of detection — kept for backward compatibility with v0.4.6 docs and muscle memory.
215
224
  - **macOS native app:** `git clone && cd && ./install.sh` — creates `~/Applications/TermDeck.app`
216
225
  - **From source:** `git clone && npm install && npm run dev`
217
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -18,7 +18,8 @@
18
18
  "workspaces": [
19
19
  "packages/server",
20
20
  "packages/client",
21
- "packages/cli"
21
+ "packages/cli",
22
+ "packages/stack-installer"
22
23
  ],
23
24
  "scripts": {
24
25
  "dev": "node packages/server/src/index.js",
@@ -0,0 +1,26 @@
1
+ // Detection helper for Sprint 24: should `termdeck` (no subcommand)
2
+ // auto-route through stack.js? Pure function, isolated for testability —
3
+ // the dispatcher in index.js still owns the actual routing decision.
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ function shouldAutoOrchestrate(homeDir) {
10
+ const home = homeDir || os.homedir();
11
+ const secretsPath = path.join(home, '.termdeck', 'secrets.env');
12
+ const configPath = path.join(home, '.termdeck', 'config.yaml');
13
+ if (!fs.existsSync(secretsPath) || !fs.existsSync(configPath)) return false;
14
+ let parsed;
15
+ try {
16
+ const yaml = require('yaml');
17
+ parsed = yaml.parse(fs.readFileSync(configPath, 'utf8')) || {};
18
+ } catch (_e) {
19
+ return false;
20
+ }
21
+ const mnestraAuto = parsed.mnestra && parsed.mnestra.autoStart === true;
22
+ const ragEnabled = parsed.rag && parsed.rag.enabled === true;
23
+ return Boolean(mnestraAuto || ragEnabled);
24
+ }
25
+
26
+ module.exports = { shouldAutoOrchestrate };
@@ -62,6 +62,41 @@ if (args[0] === 'forge') {
62
62
  return;
63
63
  }
64
64
 
65
+ // `termdeck stack` — full-stack launcher (Node port of scripts/start.sh).
66
+ // Boots Mnestra (if installed + autoStart: true), checks Rumen, then
67
+ // starts TermDeck. Lives in the npm package so users who installed via
68
+ // `npm install -g @jhizzard/termdeck` don't need to clone the repo to
69
+ // get the start.sh experience.
70
+ if (args[0] === 'stack') {
71
+ const stack = require(path.join(__dirname, 'stack.js'));
72
+ stack(args.slice(1)).then((code) => process.exit(code || 0)).catch((err) => {
73
+ console.error('[cli] stack failed:', err && err.stack || err);
74
+ process.exit(1);
75
+ });
76
+ return;
77
+ }
78
+
79
+ // Sprint 24: when `termdeck` is invoked with no subcommand AND a configured
80
+ // stack is detected, route through stack.js so users don't have to remember
81
+ // the `stack` subcommand. `--no-stack` is the explicit opt-out.
82
+ const { shouldAutoOrchestrate } = require(path.join(__dirname, 'auto-orchestrate.js'));
83
+
84
+ const KNOWN_SUBCOMMANDS = new Set(['init', 'forge', 'stack']);
85
+ const noStackIdx = args.indexOf('--no-stack');
86
+ const noStackRequested = noStackIdx !== -1;
87
+ if (noStackRequested) args.splice(noStackIdx, 1); // strip before flag parsing
88
+
89
+ const wantsHelp = args.includes('--help') || args.includes('-h');
90
+
91
+ if (!KNOWN_SUBCOMMANDS.has(args[0]) && !noStackRequested && !wantsHelp && shouldAutoOrchestrate()) {
92
+ const stack = require(path.join(__dirname, 'stack.js'));
93
+ stack(args).then((code) => process.exit(code || 0)).catch((err) => {
94
+ console.error('[cli] auto-stack failed:', err && err.stack || err);
95
+ process.exit(1);
96
+ });
97
+ return;
98
+ }
99
+
65
100
  const flags = {};
66
101
  for (let i = 0; i < args.length; i++) {
67
102
  if (args[i] === '--port' && args[i + 1]) {
@@ -76,11 +111,13 @@ for (let i = 0; i < args.length; i++) {
76
111
  TermDeck - Web-based terminal multiplexer
77
112
 
78
113
  Usage:
79
- termdeck Start with defaults (port 3000)
114
+ termdeck Auto-orchestrate stack if configured, else Tier-1-only
115
+ termdeck stack Force boot Mnestra + check Rumen + start TermDeck
116
+ termdeck --no-stack Skip orchestrator (force Tier-1-only boot)
80
117
  termdeck --port 8080 Start on custom port
81
118
  termdeck --no-open Don't auto-open browser
82
119
  termdeck --session-logs Write per-session markdown logs to ~/.termdeck/sessions/
83
- termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
120
+ termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
84
121
  termdeck init --rumen Deploy Tier 3 async learning (Rumen)
85
122
  termdeck forge Generate Claude skills from memories (experimental)
86
123
 
@@ -0,0 +1,467 @@
1
+ // `termdeck stack` — full-stack launcher.
2
+ //
3
+ // Node port of scripts/start.sh so users who installed TermDeck via npm
4
+ // (where scripts/ is excluded from the published `files` field) get the
5
+ // same "boot Mnestra → check Rumen → start TermDeck" experience without
6
+ // having to clone the repo.
7
+ //
8
+ // Numbered output (Step N/4) mirrors scripts/start.sh exactly so anyone
9
+ // who has been running the bash version sees identical behavior.
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const http = require('http');
15
+ const net = require('net');
16
+ const { spawn, spawnSync } = require('child_process');
17
+
18
+ const HOME = os.homedir();
19
+ const CONFIG_DIR = path.join(HOME, '.termdeck');
20
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.yaml');
21
+ const SECRETS_FILE = path.join(CONFIG_DIR, 'secrets.env');
22
+ const DEFAULT_MNESTRA_PORT = parseInt(process.env.MNESTRA_PORT || '37778', 10);
23
+ const MNESTRA_LOG = path.join(os.tmpdir(), 'termdeck-mnestra.log');
24
+
25
+ const ANSI = {
26
+ green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m',
27
+ dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m',
28
+ };
29
+
30
+ const LINE_WIDTH = 52;
31
+
32
+ function stepLine(step, label, status, detail) {
33
+ const prefix = `Step ${step}: ${label} `;
34
+ const padCount = Math.max(3, LINE_WIDTH - prefix.length);
35
+ const dots = '.'.repeat(padCount);
36
+ const tag = ({
37
+ OK: `${ANSI.green}OK${ANSI.reset} `,
38
+ WARN: `${ANSI.yellow}WARN${ANSI.reset}`,
39
+ SKIP: `${ANSI.dim}SKIP${ANSI.reset}`,
40
+ FAIL: `${ANSI.red}FAIL${ANSI.reset}`,
41
+ BOOT: `${ANSI.green}BOOT${ANSI.reset}`,
42
+ })[status] || status;
43
+ if (detail) {
44
+ process.stdout.write(`${prefix}${ANSI.dim}${dots}${ANSI.reset} ${tag} ${ANSI.dim}${detail}${ANSI.reset}\n`);
45
+ } else {
46
+ process.stdout.write(`${prefix}${ANSI.dim}${dots}${ANSI.reset} ${tag}\n`);
47
+ }
48
+ }
49
+
50
+ function subNote(msg) {
51
+ process.stdout.write(` ${ANSI.dim}└ ${msg}${ANSI.reset}\n`);
52
+ }
53
+
54
+ // ── Args ─────────────────────────────────────────────────────────────
55
+
56
+ function parseArgs(argv) {
57
+ const out = { extra: [], port: null, noMnestra: false };
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const a = argv[i];
60
+ if (a === '--port' && argv[i + 1]) { out.port = parseInt(argv[++i], 10); continue; }
61
+ if (a.startsWith('--port=')) { out.port = parseInt(a.split('=')[1], 10); continue; }
62
+ if (a === '--no-mnestra') { out.noMnestra = true; continue; }
63
+ if (a === '--help' || a === '-h') { out.help = true; continue; }
64
+ out.extra.push(a);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function printHelp() {
70
+ process.stdout.write(`
71
+ termdeck stack — boot Mnestra (if installed) + check Rumen + start TermDeck
72
+
73
+ Usage:
74
+ termdeck stack Start whole stack on port 3000
75
+ termdeck stack --port 8080 Custom TermDeck port
76
+ termdeck stack --no-mnestra Skip Mnestra autostart (Tier-1-only run)
77
+ termdeck stack -- --no-open Pass-through flags after -- go to termdeck
78
+
79
+ Environment:
80
+ MNESTRA_PORT=37778 Mnestra healthz port (default 37778)
81
+ TERMDECK_PORT=3000 Default TermDeck port
82
+
83
+ Config:
84
+ ~/.termdeck/config.yaml mnestra.autoStart: true|false controls Step 2
85
+ ~/.termdeck/secrets.env SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY,
86
+ DATABASE_URL, OPENAI_API_KEY
87
+ `);
88
+ }
89
+
90
+ // ── First-run bootstrap ─────────────────────────────────────────────
91
+
92
+ const FIRST_RUN_CONFIG = `# TermDeck config (auto-generated on first run by \`termdeck stack\`)
93
+ # Full reference: config/config.example.yaml in the TermDeck repo.
94
+
95
+ port: 3000
96
+ host: 127.0.0.1
97
+ shell: /bin/zsh
98
+
99
+ defaultTheme: tokyo-night
100
+
101
+ # Mnestra (pgvector memory store) — auto-start on stack launch
102
+ mnestra:
103
+ autoStart: true
104
+
105
+ # Add your projects here to enable \`cc <project>\` shorthand + auto-cd.
106
+ projects:
107
+ # my-project:
108
+ # path: ~/code/my-project
109
+ # defaultTheme: catppuccin-mocha
110
+ # defaultCommand: claude
111
+
112
+ rag:
113
+ enabled: false
114
+ syncIntervalMs: 10000
115
+
116
+ sessionLogs:
117
+ enabled: false
118
+ `;
119
+
120
+ function ensureFirstRunConfig() {
121
+ if (fs.existsSync(CONFIG_FILE)) return false;
122
+ process.stdout.write(` ${ANSI.blue}ⓘ${ANSI.reset} First run detected — creating ${CONFIG_FILE}\n`);
123
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
124
+ fs.writeFileSync(CONFIG_FILE, FIRST_RUN_CONFIG, { mode: 0o600 });
125
+ subNote(`Edit ${CONFIG_FILE} to add projects or tweak defaults.`);
126
+ subNote(`Open http://localhost:3000 and click 'config' to complete setup`);
127
+ process.stdout.write('\n');
128
+ return true;
129
+ }
130
+
131
+ // ── Step 1: Load secrets ─────────────────────────────────────────────
132
+
133
+ function loadSecrets() {
134
+ if (!fs.existsSync(SECRETS_FILE)) {
135
+ stepLine('1/4', 'Loading secrets', 'SKIP', `(no ${SECRETS_FILE} — Tier 1 only)`);
136
+ return 0;
137
+ }
138
+ const raw = fs.readFileSync(SECRETS_FILE, 'utf8');
139
+ let count = 0;
140
+ for (const line of raw.split('\n')) {
141
+ const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
142
+ if (!m) continue;
143
+ if (process.env[m[1]] === undefined) {
144
+ // Strip surrounding quotes if present
145
+ let val = m[2];
146
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
147
+ val = val.slice(1, -1);
148
+ }
149
+ process.env[m[1]] = val;
150
+ }
151
+ count++;
152
+ }
153
+ stepLine('1/4', 'Loading secrets', 'OK', `(${count} keys from ${SECRETS_FILE})`);
154
+ return count;
155
+ }
156
+
157
+ // ── Port helpers ────────────────────────────────────────────────────
158
+
159
+ function isPortFree(port, host) {
160
+ return new Promise((resolve) => {
161
+ const srv = net.createServer();
162
+ srv.once('error', () => resolve(false));
163
+ srv.once('listening', () => srv.close(() => resolve(true)));
164
+ srv.listen(port, host || '0.0.0.0');
165
+ });
166
+ }
167
+
168
+ function lsofPids(port) {
169
+ // macOS/Linux only — Windows callers fall through to the busy-port branch
170
+ // and get a manual remediation message.
171
+ if (process.platform === 'win32') return [];
172
+ const r = spawnSync('lsof', ['-ti', `TCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
173
+ if (r.status !== 0 || !r.stdout) return [];
174
+ return r.stdout.trim().split('\n').filter(Boolean).map((p) => parseInt(p, 10));
175
+ }
176
+
177
+ function isPidTermDeck(pid) {
178
+ if (process.platform === 'win32') return false;
179
+ const r = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
180
+ if (r.status !== 0) return false;
181
+ return /packages\/cli\/src\/index\.js|termdeck/.test(r.stdout || '');
182
+ }
183
+
184
+ async function reclaimPort(port) {
185
+ const pids = lsofPids(port);
186
+ if (pids.length === 0) return { reclaimed: false, blockerPids: [] };
187
+ const termdeckPids = pids.filter(isPidTermDeck);
188
+ if (termdeckPids.length === 0) return { reclaimed: false, blockerPids: pids };
189
+ for (const pid of termdeckPids) {
190
+ try { process.kill(pid, 'SIGTERM'); } catch (_e) { /* already dead */ }
191
+ }
192
+ await new Promise((r) => setTimeout(r, 1000));
193
+ for (const pid of termdeckPids) {
194
+ try { process.kill(pid, 'SIGKILL'); } catch (_e) { /* already dead */ }
195
+ }
196
+ subNote(`Killed stale TermDeck on port ${port} (PIDs: ${termdeckPids.join(' ')})`);
197
+ return { reclaimed: true, blockerPids: [] };
198
+ }
199
+
200
+ // ── Step 2: Mnestra ─────────────────────────────────────────────────
201
+
202
+ function which(cmd) {
203
+ const finder = process.platform === 'win32' ? 'where' : 'which';
204
+ const r = spawnSync(finder, [cmd], { encoding: 'utf8' });
205
+ if (r.status !== 0) return null;
206
+ const first = (r.stdout || '').split('\n')[0].trim();
207
+ return first || null;
208
+ }
209
+
210
+ function resolveMnestraCommand() {
211
+ const onPath = which('mnestra');
212
+ if (onPath) return { kind: 'bin', cmd: 'mnestra', args: ['serve'] };
213
+ const local = path.join(HOME, 'Documents', 'Graciella', 'engram', 'dist', 'mcp-server', 'index.js');
214
+ if (fs.existsSync(local)) return { kind: 'node', cmd: process.execPath, args: [local, 'serve'] };
215
+ return null;
216
+ }
217
+
218
+ function readMnestraAutoStart() {
219
+ if (!fs.existsSync(CONFIG_FILE)) return 'unset';
220
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
221
+ // Lightweight parse — full yaml.parse not needed and avoids loading the
222
+ // server's yaml dep at this stage.
223
+ const m = raw.match(/^mnestra:\s*\n((?:\s+[^\n]*\n)+)/m);
224
+ if (!m) return 'unset';
225
+ const block = m[1];
226
+ const auto = block.match(/^\s+autoStart:\s*(true|false)\s*$/m);
227
+ if (!auto) return 'unset';
228
+ return auto[1];
229
+ }
230
+
231
+ async function httpJson(url, timeoutMs = 3000) {
232
+ return new Promise((resolve, reject) => {
233
+ const req = http.get(url, { timeout: timeoutMs }, (res) => {
234
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); }
235
+ let body = ''; res.setEncoding('utf8');
236
+ res.on('data', (c) => { body += c; });
237
+ res.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(e); } });
238
+ });
239
+ req.on('error', reject);
240
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
241
+ });
242
+ }
243
+
244
+ async function mnestraHealth(port) {
245
+ try {
246
+ const j = await httpJson(`http://localhost:${port}/healthz`, 2000);
247
+ const rows = (j.store && j.store.rows) ?? j.total ?? j.memories ?? j.count ?? null;
248
+ return { up: true, rows: rows == null ? 0 : Number(rows) };
249
+ } catch (_e) {
250
+ return { up: false, rows: 0 };
251
+ }
252
+ }
253
+
254
+ function spawnMnestraDetached(resolved) {
255
+ const out = fs.openSync(MNESTRA_LOG, 'a');
256
+ const err = fs.openSync(MNESTRA_LOG, 'a');
257
+ const child = spawn(resolved.cmd, resolved.args, {
258
+ detached: true,
259
+ stdio: ['ignore', out, err],
260
+ env: process.env,
261
+ });
262
+ child.unref();
263
+ return child.pid;
264
+ }
265
+
266
+ async function waitForMnestra(port, timeoutMs = 10000) {
267
+ const start = Date.now();
268
+ while (Date.now() - start < timeoutMs) {
269
+ const h = await mnestraHealth(port);
270
+ if (h.up) return h;
271
+ await new Promise((r) => setTimeout(r, 1000));
272
+ }
273
+ return { up: false, rows: 0 };
274
+ }
275
+
276
+ async function startMnestra({ skip }) {
277
+ if (skip) {
278
+ stepLine('2/4', 'Starting Mnestra', 'SKIP', '(--no-mnestra flag)');
279
+ return { active: false };
280
+ }
281
+ const resolved = resolveMnestraCommand();
282
+ const autoStart = readMnestraAutoStart();
283
+ const port = DEFAULT_MNESTRA_PORT;
284
+
285
+ // Already running?
286
+ const existingPids = lsofPids(port);
287
+ if (existingPids.length > 0) {
288
+ const h = await mnestraHealth(port);
289
+ if (h.up && h.rows > 0) {
290
+ stepLine('2/4', 'Starting Mnestra', 'OK', `(already running, ${h.rows.toLocaleString()} memories)`);
291
+ return { active: true, rows: h.rows, port };
292
+ }
293
+ if (h.up && h.rows === 0) {
294
+ // Running but empty — kill and restart with secrets loaded
295
+ for (const pid of existingPids) { try { process.kill(pid, 'SIGTERM'); } catch (_e) { /* dead */ } }
296
+ await new Promise((r) => setTimeout(r, 1000));
297
+ for (const pid of existingPids) { try { process.kill(pid, 'SIGKILL'); } catch (_e) { /* dead */ } }
298
+ if (!resolved) {
299
+ stepLine('2/4', 'Starting Mnestra', 'FAIL', '(0 memories, killed; mnestra binary not found to restart)');
300
+ return { active: false };
301
+ }
302
+ if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) {
303
+ stepLine('2/4', 'Starting Mnestra', 'WARN', '(killed; SUPABASE_URL/SERVICE_ROLE_KEY missing in secrets.env)');
304
+ return { active: false };
305
+ }
306
+ spawnMnestraDetached(resolved);
307
+ const after = await waitForMnestra(port);
308
+ if (after.up && after.rows > 0) {
309
+ stepLine('2/4', 'Starting Mnestra', 'OK', `(restarted with secrets, ${after.rows.toLocaleString()} memories)`);
310
+ return { active: true, rows: after.rows, port };
311
+ }
312
+ stepLine('2/4', 'Starting Mnestra', 'WARN', '(restarted but store empty — check Supabase connection)');
313
+ return { active: false };
314
+ }
315
+ stepLine('2/4', 'Starting Mnestra', 'WARN', `(port ${port} held by non-Mnestra process)`);
316
+ return { active: false };
317
+ }
318
+
319
+ if (!resolved) {
320
+ stepLine('2/4', 'Starting Mnestra', 'SKIP', '(not installed — npm install -g @jhizzard/mnestra)');
321
+ return { active: false };
322
+ }
323
+ if (autoStart === 'false') {
324
+ stepLine('2/4', 'Starting Mnestra', 'SKIP', '(autoStart: false in config.yaml)');
325
+ return { active: false };
326
+ }
327
+ if (autoStart === 'unset') {
328
+ stepLine('2/4', 'Starting Mnestra', 'SKIP', `(set mnestra.autoStart: true in ${CONFIG_FILE})`);
329
+ return { active: false };
330
+ }
331
+ if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) {
332
+ stepLine('2/4', 'Starting Mnestra', 'WARN', `(SUPABASE_URL/SERVICE_ROLE_KEY missing in ${SECRETS_FILE})`);
333
+ return { active: false };
334
+ }
335
+ spawnMnestraDetached(resolved);
336
+ const after = await waitForMnestra(port);
337
+ if (after.up && after.rows > 0) {
338
+ stepLine('2/4', 'Starting Mnestra', 'OK', `(${after.rows.toLocaleString()} memories on :${port})`);
339
+ return { active: true, rows: after.rows, port };
340
+ }
341
+ if (after.up) {
342
+ stepLine('2/4', 'Starting Mnestra', 'WARN', `(started on :${port} but store is empty)`);
343
+ return { active: false };
344
+ }
345
+ stepLine('2/4', 'Starting Mnestra', 'FAIL', `(did not come up within 10s — ${MNESTRA_LOG})`);
346
+ return { active: false };
347
+ }
348
+
349
+ // ── Step 3: Rumen ───────────────────────────────────────────────────
350
+
351
+ async function checkRumen() {
352
+ const dbUrl = process.env.DATABASE_URL;
353
+ if (!dbUrl) {
354
+ stepLine('3/4', 'Checking Rumen', 'SKIP', '(DATABASE_URL not set in secrets.env)');
355
+ return { ago: null };
356
+ }
357
+ let pg;
358
+ try { pg = require('pg'); } catch (_e) { pg = null; }
359
+ if (!pg) {
360
+ stepLine('3/4', 'Checking Rumen', 'SKIP', '(pg module not available)');
361
+ return { ago: null };
362
+ }
363
+ const pool = new pg.Pool({ connectionString: dbUrl, max: 1, connectionTimeoutMillis: 5000 });
364
+ try {
365
+ const r = await pool.query("SELECT to_char(NOW() - MAX(created_at), 'HH24:MI:SS') AS ago FROM rumen_jobs");
366
+ const ago = r.rows[0] && r.rows[0].ago;
367
+ if (ago) {
368
+ stepLine('3/4', 'Checking Rumen', 'OK', `(last job ${ago} ago)`);
369
+ return { ago };
370
+ }
371
+ stepLine('3/4', 'Checking Rumen', 'WARN', '(no jobs yet — try termdeck init --rumen)');
372
+ return { ago: null };
373
+ } catch (err) {
374
+ if (/relation .*rumen_jobs.* does not exist/i.test(String(err.message))) {
375
+ stepLine('3/4', 'Checking Rumen', 'SKIP', '(rumen_jobs table not present — run termdeck init --rumen)');
376
+ } else {
377
+ stepLine('3/4', 'Checking Rumen', 'WARN', `(query failed: ${err.message})`);
378
+ }
379
+ return { ago: null };
380
+ } finally {
381
+ await pool.end().catch(() => {});
382
+ }
383
+ }
384
+
385
+ // ── Step 4: TermDeck ────────────────────────────────────────────────
386
+
387
+ function execTermDeck({ port, extra }) {
388
+ // Rather than execve, just require() the existing CLI in-process. That
389
+ // lets `termdeck stack` share signal handling with the server (Ctrl+C
390
+ // shuts everything down, including the detached Mnestra if we own it).
391
+ const cliPath = path.join(__dirname, 'index.js');
392
+ const argv = [];
393
+ if (port) argv.push('--port', String(port));
394
+ argv.push(...extra);
395
+ process.argv = [process.argv[0], cliPath, ...argv];
396
+ require(cliPath);
397
+ }
398
+
399
+ // ── Main ────────────────────────────────────────────────────────────
400
+
401
+ async function main(rawArgs) {
402
+ const args = parseArgs(rawArgs);
403
+ if (args.help) { printHelp(); return 0; }
404
+
405
+ const port = args.port || parseInt(process.env.TERMDECK_PORT || '3000', 10);
406
+
407
+ process.stdout.write(`\n${ANSI.bold}TermDeck Stack Launcher${ANSI.reset}\n`);
408
+ process.stdout.write(`${ANSI.dim}─────────────────────────────────────────────────${ANSI.reset}\n\n`);
409
+
410
+ // Node 18+ check
411
+ const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
412
+ if (nodeMajor < 18) {
413
+ process.stderr.write(` ${ANSI.red}✗ Node ${nodeMajor} detected — TermDeck requires Node 18+. Current: ${process.version}${ANSI.reset}\n`);
414
+ return 1;
415
+ }
416
+
417
+ ensureFirstRunConfig();
418
+
419
+ loadSecrets();
420
+
421
+ // Port reclaim — kill stale TermDeck on the port; refuse if held by something else
422
+ const free = await isPortFree(port, '127.0.0.1');
423
+ if (!free) {
424
+ const claim = await reclaimPort(port);
425
+ if (!claim.reclaimed) {
426
+ const blockers = claim.blockerPids.length ? ` (PIDs: ${claim.blockerPids.join(' ')})` : '';
427
+ process.stderr.write(` ${ANSI.red}✗${ANSI.reset} Port ${port} is in use by a non-TermDeck process${blockers}\n`);
428
+ subNote(`Try a different port: termdeck stack --port ${port + 1}`);
429
+ return 1;
430
+ }
431
+ }
432
+
433
+ const mnestra = await startMnestra({ skip: args.noMnestra });
434
+
435
+ // MCP config hint
436
+ if (mnestra.active) {
437
+ const mcpPath = path.join(HOME, '.claude', 'mcp.json');
438
+ let needsHint = !fs.existsSync(mcpPath);
439
+ if (!needsHint) {
440
+ try {
441
+ const j = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
442
+ if (!JSON.stringify(j).includes('mnestra')) needsHint = true;
443
+ } catch (_e) { needsHint = true; }
444
+ }
445
+ if (needsHint) subNote(`Hint: add a 'mnestra' entry to ~/.claude/mcp.json for Claude Code`);
446
+ }
447
+
448
+ const rumen = await checkRumen();
449
+
450
+ // Stack summary
451
+ const summary = [`TermDeck :${port}`];
452
+ if (mnestra.active) summary.push(`Mnestra :${mnestra.port} (${mnestra.rows.toLocaleString()})`);
453
+ if (rumen.ago) summary.push(`Rumen (${rumen.ago} ago)`);
454
+
455
+ stepLine('4/4', 'Starting TermDeck', 'BOOT', `(port ${port})`);
456
+ process.stdout.write(`\n ${ANSI.bold}Stack:${ANSI.reset} ${ANSI.green}${summary.join(' | ')}${ANSI.reset}\n\n`);
457
+
458
+ execTermDeck({ port, extra: args.extra });
459
+ return 0;
460
+ }
461
+
462
+ module.exports = function (argv) {
463
+ return main(argv).catch((err) => {
464
+ process.stderr.write(`[stack] failed: ${err && err.stack || err}\n`);
465
+ return 1;
466
+ });
467
+ };