@jhizzard/termdeck-stack 0.4.11 → 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.
@@ -52,6 +52,16 @@ processes, so anything in your shell init or
52
52
  `~/.termdeck/secrets.env` (sourced by `scripts/start.sh` /
53
53
  `npx @jhizzard/termdeck`) is visible to the hook.
54
54
 
55
+ **From v0.17.0**, the TermDeck server also merges
56
+ `~/.termdeck/secrets.env` directly into every PTY-spawned shell — so any
57
+ Claude Code panel launched inside TermDeck inherits `SUPABASE_URL` /
58
+ `SUPABASE_SERVICE_ROLE_KEY` / `OPENAI_API_KEY` even if the user's
59
+ parent shell never sourced the file. Concrete values in
60
+ `process.env` still win (parent-shell env takes precedence over the
61
+ file fallback). Standalone Claude Code launches outside TermDeck
62
+ still rely on the parent shell having sourced the file — for those,
63
+ the wizard can offer a one-line `~/.zshrc` source addition.
64
+
55
65
  If any of the three is missing the log line will name them:
56
66
 
57
67
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck-stack",
3
- "version": "0.4.11",
3
+ "version": "0.5.0",
4
4
  "description": "One-command installer for the TermDeck developer memory stack: TermDeck + Mnestra + Rumen + Supabase MCP",
5
5
  "bin": {
6
6
  "termdeck-stack": "./src/index.js"
package/src/index.js CHANGED
@@ -53,6 +53,34 @@ const HOOK_DEST = path.join(HOOK_DEST_DIR, 'memory-session-end.js');
53
53
  const HOOK_SOURCE = path.join(__dirname, '..', 'assets', 'hooks', 'memory-session-end.js');
54
54
  const HOOK_COMMAND = 'node ~/.claude/hooks/memory-session-end.js';
55
55
  const HOOK_TIMEOUT_SECONDS = 30;
56
+ const SECRETS_PATH = path.join(HOME, '.termdeck', 'secrets.env');
57
+
58
+ // Read ~/.termdeck/secrets.env into a plain object. Returns {} if the file
59
+ // is absent or unreadable. Used to populate the mnestra MCP env block with
60
+ // concrete values — Claude Code does NOT shell-expand `${VAR}` references
61
+ // in MCP env, so writing placeholders results in mnestra receiving the
62
+ // literal string `${SUPABASE_URL}` and Supabase rejecting it as an invalid
63
+ // URL. Writing concrete values is the only thing that works.
64
+ function readTermdeckSecrets() {
65
+ try {
66
+ const text = fs.readFileSync(SECRETS_PATH, 'utf8');
67
+ const out = {};
68
+ for (const raw of text.split('\n')) {
69
+ const line = raw.trim();
70
+ if (!line || line.startsWith('#')) continue;
71
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
72
+ if (!m) continue;
73
+ let v = m[2];
74
+ if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
75
+ v = v.slice(1, -1);
76
+ }
77
+ out[m[1]] = v;
78
+ }
79
+ return out;
80
+ } catch (_err) {
81
+ return {};
82
+ }
83
+ }
56
84
 
57
85
  const LAYERS = [
58
86
  {
@@ -103,9 +131,14 @@ function parseArgs(argv) {
103
131
 
104
132
  function printHelp() {
105
133
  process.stdout.write(`
106
- termdeck-stack — install the TermDeck developer memory stack
134
+ termdeck-stack — install and run the TermDeck developer memory stack
107
135
 
108
- Usage:
136
+ Subcommands:
137
+ termdeck-stack start Boot the full stack (TermDeck + Mnestra)
138
+ termdeck-stack stop Stop the running stack
139
+ termdeck-stack status Print stack health
140
+
141
+ Install:
109
142
  npx @jhizzard/termdeck-stack Interactive wizard
110
143
  npx @jhizzard/termdeck-stack --tier 4 Unattended install (1|2|3|4)
111
144
  npx @jhizzard/termdeck-stack --dry-run Print plan, don't install
@@ -297,18 +330,60 @@ function wireMcpEntries(plan, opts) {
297
330
  const keptExisting = [];
298
331
 
299
332
  if (installedTiers.has(2) && !servers.mnestra) {
333
+ // Claude Code does NOT expand `${VAR}` in MCP env — placeholders pass
334
+ // through literally and mnestra rejects them as an invalid SUPABASE_URL.
335
+ // Read concrete values from ~/.termdeck/secrets.env. Missing keys fall
336
+ // back to process.env (the installer was launched from the user's shell,
337
+ // which may export them); if still empty, leave the key out so mnestra's
338
+ // own secrets.env fallback gets a chance to load it.
339
+ const secrets = readTermdeckSecrets();
340
+ const env = {};
341
+ for (const key of ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY']) {
342
+ const v = secrets[key] || process.env[key] || '';
343
+ if (v) env[key] = v;
344
+ }
300
345
  servers.mnestra = {
301
346
  type: 'stdio',
302
347
  command: 'mnestra',
303
- env: {
304
- SUPABASE_URL: '${SUPABASE_URL}',
305
- SUPABASE_SERVICE_ROLE_KEY: '${SUPABASE_SERVICE_ROLE_KEY}',
306
- OPENAI_API_KEY: '${OPENAI_API_KEY}',
307
- },
348
+ env,
308
349
  };
309
350
  additions.push('mnestra');
351
+ if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
352
+ process.stdout.write(
353
+ `${ANSI.yellow}!${ANSI.reset} mnestra MCP added with incomplete env — ` +
354
+ `set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in ${SECRETS_PATH} ` +
355
+ `or via \`claude mcp remove mnestra -s user\` followed by ` +
356
+ `\`claude mcp add mnestra -s user -e SUPABASE_URL=... -e SUPABASE_SERVICE_ROLE_KEY=... -e OPENAI_API_KEY=... -- mnestra\`.\n`
357
+ );
358
+ }
310
359
  } else if (servers.mnestra) {
311
- keptExisting.push('mnestra');
360
+ // Repair pass: existing entry from a buggy installer (≤ 0.4.11) used
361
+ // `${VAR}` placeholders that Claude Code never expands. If we detect
362
+ // those, swap in concrete values from secrets.env / process.env.
363
+ const env = { ...(servers.mnestra.env || {}) };
364
+ let repaired = false;
365
+ const secrets = readTermdeckSecrets();
366
+ for (const key of ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY']) {
367
+ const cur = env[key];
368
+ const looksLikePlaceholder = typeof cur === 'string'
369
+ && cur.startsWith('${') && cur.endsWith('}');
370
+ if (looksLikePlaceholder || cur === '') {
371
+ const v = secrets[key] || process.env[key] || '';
372
+ if (v) {
373
+ env[key] = v;
374
+ repaired = true;
375
+ } else if (looksLikePlaceholder) {
376
+ delete env[key];
377
+ repaired = true;
378
+ }
379
+ }
380
+ }
381
+ if (repaired) {
382
+ servers.mnestra = { ...servers.mnestra, env };
383
+ additions.push('mnestra (env repaired)');
384
+ } else {
385
+ keptExisting.push('mnestra');
386
+ }
312
387
  }
313
388
 
314
389
  if (installedTiers.has(4) && !servers.supabase) {
@@ -570,7 +645,32 @@ function printNextSteps(plan, opts) {
570
645
 
571
646
  // ── Main ────────────────────────────────────────────────────────────
572
647
 
648
+ // Sprint 48 T4: persistent launcher subcommands. Short-circuits before the
649
+ // wizard so `npx @jhizzard/termdeck-stack start` (and stop|status) boots the
650
+ // stack without running the install flow. Bare invocation still falls through
651
+ // to the wizard for backwards compat.
652
+ async function _maybeRunSubcommand(argv) {
653
+ const sub = argv[0];
654
+ if (sub !== 'start' && sub !== 'stop' && sub !== 'status') return null;
655
+ // Lazy-require so the wizard path doesn't pay the launcher's load cost.
656
+ const launcher = require('./launcher');
657
+ if (sub === 'start') {
658
+ const result = await launcher.startStack({ /* opts could parse argv flags later */ });
659
+ return result.ok === false ? 1 : 0;
660
+ }
661
+ if (sub === 'stop') {
662
+ const result = await launcher.stopStack({});
663
+ return result.ok ? 0 : 1;
664
+ }
665
+ // status — exits non-zero if termdeck isn't healthy so scripts can branch on it.
666
+ const result = await launcher.statusStack({});
667
+ return result.ok ? 0 : 1;
668
+ }
669
+
573
670
  async function main(argv) {
671
+ const subResult = await _maybeRunSubcommand(argv);
672
+ if (subResult !== null) return subResult;
673
+
574
674
  const args = parseArgs(argv);
575
675
  if (args.help) { printHelp(); return 0; }
576
676
 
@@ -646,6 +746,7 @@ if (require.main === module) {
646
746
  }
647
747
 
648
748
  module.exports = main;
749
+ module.exports._maybeRunSubcommand = _maybeRunSubcommand;
649
750
  module.exports._mergeSessionEndHookEntry = _mergeSessionEndHookEntry;
650
751
  module.exports._readSettingsJson = _readSettingsJson;
651
752
  module.exports._writeSettingsJson = _writeSettingsJson;
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+
3
+ // @jhizzard/termdeck-stack launcher subcommands — Sprint 48 T4.
4
+ //
5
+ // Ports the canonical `scripts/start.sh` flow into a globally-installable
6
+ // JS entry point so users who installed via `npm i -g @jhizzard/termdeck-stack`
7
+ // can boot the full stack without cloning the repo.
8
+ //
9
+ // Public API:
10
+ // startStack(opts) → boots mnestra (if installed) + termdeck, writes pidfile.
11
+ // stopStack(opts) → reads pidfile, SIGTERMs each pid, removes pidfile.
12
+ // statusStack(opts) → probes health endpoints + reports component state.
13
+ //
14
+ // All three are async and accept an `opts` object whose shape is documented
15
+ // inline. Each one writes step-line output to stdout in the same aesthetic
16
+ // as scripts/start.sh — the user-facing UX is intentionally familiar.
17
+ //
18
+ // Dependency injection: each function takes an optional `_deps` field on
19
+ // opts so unit tests can stub spawn/fetch/fs without monkey-patching.
20
+ // Default deps wire to the real Node built-ins.
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+ const child_process = require('node:child_process');
28
+
29
+ const HOME = os.homedir();
30
+ const TERMDECK_DIR = path.join(HOME, '.termdeck');
31
+ const SECRETS_PATH = path.join(TERMDECK_DIR, 'secrets.env');
32
+ const CONFIG_PATH = path.join(TERMDECK_DIR, 'config.yaml');
33
+ const PID_PATH = path.join(TERMDECK_DIR, 'stack.pid');
34
+ const MNESTRA_LOG_PATH = '/tmp/termdeck-mnestra.log';
35
+ const TERMDECK_LOG_PATH = '/tmp/termdeck-server.log';
36
+
37
+ const DEFAULT_PORT = 3000;
38
+ const DEFAULT_MNESTRA_PORT = 37778;
39
+ const HEALTH_TIMEOUT_MS = 1000;
40
+ const HEALTH_RETRIES = 10;
41
+
42
+ const ANSI = {
43
+ green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m',
44
+ dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m',
45
+ };
46
+
47
+ // Parses ~/.termdeck/secrets.env into a plain object. Same contract as
48
+ // stack-installer/src/index.js#readTermdeckSecrets — duplicated here rather
49
+ // than imported because launcher.js is a distinct entry point and circular
50
+ // requires complicate the subcommand-dispatch flow in index.js.
51
+ function readSecrets(filePath = SECRETS_PATH, _fs = fs) {
52
+ try {
53
+ const text = _fs.readFileSync(filePath, 'utf8');
54
+ const out = {};
55
+ for (const raw of text.split('\n')) {
56
+ const line = raw.trim();
57
+ if (!line || line.startsWith('#')) continue;
58
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
59
+ if (!m) continue;
60
+ let v = m[2].trim();
61
+ if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
62
+ v = v.slice(1, -1);
63
+ }
64
+ if (v.startsWith('${') && v.endsWith('}')) continue;
65
+ out[m[1]] = v;
66
+ }
67
+ return out;
68
+ } catch (_err) {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ // Resolves a binary on PATH. Returns the absolute path or null. Uses
74
+ // `which` so the launcher behaves identically across darwin/linux without
75
+ // shelling out to bash. Falls back to checking spawn output exit code.
76
+ function whichBinary(name, _spawnSync = child_process.spawnSync) {
77
+ const r = _spawnSync('which', [name], { encoding: 'utf8' });
78
+ if (r.status === 0 && r.stdout) {
79
+ const trimmed = r.stdout.trim();
80
+ if (trimmed) return trimmed;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // Resolves mnestra invocation: prefers global binary, falls back to the
86
+ // ~/Documents/Graciella/engram dev checkout (matches scripts/start.sh).
87
+ function resolveMnestraInvocation(_deps = {}) {
88
+ const which = _deps.whichBinary || whichBinary;
89
+ const _fs = _deps.fs || fs;
90
+ const onPath = which('mnestra');
91
+ if (onPath) return { command: 'mnestra', args: ['serve'], origin: 'path' };
92
+ const devCheckout = path.join(HOME, 'Documents', 'Graciella', 'engram', 'dist', 'mcp-server', 'index.js');
93
+ if (_fs.existsSync(devCheckout)) {
94
+ return { command: 'node', args: [devCheckout, 'serve'], origin: 'dev-checkout' };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function step(stepLabel, label, status, detail) {
100
+ const colors = { OK: ANSI.green, WARN: ANSI.yellow, SKIP: ANSI.dim, FAIL: ANSI.red, BOOT: ANSI.green };
101
+ const tag = `${colors[status] || ''}${status}${ANSI.reset}`;
102
+ const dots = '.'.repeat(Math.max(3, 52 - `Step ${stepLabel}: ${label} `.length));
103
+ const out = `Step ${stepLabel}: ${label} ${ANSI.dim}${dots}${ANSI.reset} ${tag}${detail ? ` ${ANSI.dim}${detail}${ANSI.reset}` : ''}\n`;
104
+ process.stdout.write(out);
105
+ }
106
+
107
+ async function probeHealth(url, _fetch = globalThis.fetch) {
108
+ try {
109
+ const res = await _fetch(url, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) });
110
+ if (!res.ok) return { ok: false, status: res.status };
111
+ const json = await res.json().catch(() => ({}));
112
+ return { ok: true, body: json };
113
+ } catch (err) {
114
+ return { ok: false, error: err && err.message ? err.message : String(err) };
115
+ }
116
+ }
117
+
118
+ async function waitForHealth(url, retries = HEALTH_RETRIES, _fetch = globalThis.fetch) {
119
+ for (let i = 0; i < retries; i++) {
120
+ const r = await probeHealth(url, _fetch);
121
+ if (r.ok) return r;
122
+ await new Promise((res) => setTimeout(res, 1000));
123
+ }
124
+ return { ok: false, error: 'timeout' };
125
+ }
126
+
127
+ function ensureFirstRunConfig(_fs = fs) {
128
+ if (_fs.existsSync(CONFIG_PATH)) return false;
129
+ _fs.mkdirSync(TERMDECK_DIR, { recursive: true });
130
+ const minimal = [
131
+ '# TermDeck config (auto-generated on first run by termdeck-stack start)',
132
+ '# Full reference: config/config.example.yaml in the TermDeck repo.',
133
+ '',
134
+ `port: ${DEFAULT_PORT}`,
135
+ 'host: 127.0.0.1',
136
+ 'shell: /bin/zsh',
137
+ '',
138
+ 'defaultTheme: tokyo-night',
139
+ '',
140
+ 'mnestra:',
141
+ ' autoStart: true',
142
+ '',
143
+ 'projects:',
144
+ '',
145
+ 'rag:',
146
+ ' enabled: false',
147
+ ' syncIntervalMs: 10000',
148
+ '',
149
+ 'sessionLogs:',
150
+ ' enabled: false',
151
+ ''
152
+ ].join('\n');
153
+ _fs.writeFileSync(CONFIG_PATH, minimal);
154
+ return true;
155
+ }
156
+
157
+ function spawnDetached(command, args, logPath, env, _spawn = child_process.spawn, _fs = fs) {
158
+ // open() the log file then pass the fd to spawn so the child inherits a
159
+ // real disk-backed stdout/stderr. Close our handle after spawn so we
160
+ // don't keep an extra fd open.
161
+ const fd = _fs.openSync(logPath, 'a');
162
+ try {
163
+ const child = _spawn(command, args, {
164
+ detached: true,
165
+ stdio: ['ignore', fd, fd],
166
+ env,
167
+ });
168
+ child.unref();
169
+ return child;
170
+ } finally {
171
+ try { _fs.closeSync(fd); } catch (_e) { /* best effort */ }
172
+ }
173
+ }
174
+
175
+ async function startStack(opts = {}) {
176
+ const _deps = opts._deps || {};
177
+ const _fs = _deps.fs || fs;
178
+ const _spawn = _deps.spawn || child_process.spawn;
179
+ const _fetch = _deps.fetch || globalThis.fetch;
180
+ const port = opts.port || parseInt(process.env.TERMDECK_PORT || '', 10) || DEFAULT_PORT;
181
+ const mnestraPort = opts.mnestraPort || parseInt(process.env.MNESTRA_PORT || '', 10) || DEFAULT_MNESTRA_PORT;
182
+
183
+ process.stdout.write(`\n${ANSI.bold}TermDeck Stack Launcher${ANSI.reset}\n`);
184
+ process.stdout.write(`${ANSI.dim}─────────────────────────────────────────────${ANSI.reset}\n\n`);
185
+
186
+ const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
187
+ if (nodeMajor < 18) {
188
+ process.stdout.write(` ${ANSI.red}✗ Node ${process.version} detected — TermDeck requires Node 18+.${ANSI.reset}\n`);
189
+ return { ok: false, reason: 'node-too-old' };
190
+ }
191
+
192
+ if (ensureFirstRunConfig(_fs)) {
193
+ process.stdout.write(` ${ANSI.blue}ⓘ${ANSI.reset} First run — created ${CONFIG_PATH}\n\n`);
194
+ }
195
+
196
+ // Step 1: secrets
197
+ const secrets = readSecrets(SECRETS_PATH, _fs);
198
+ const secretCount = Object.keys(secrets).length;
199
+ if (secretCount === 0) {
200
+ step('1/3', 'Loading secrets', 'WARN', `(no readable keys in ${SECRETS_PATH} — run \`npx @jhizzard/termdeck-stack\` to set up)`);
201
+ } else {
202
+ step('1/3', 'Loading secrets', 'OK', `(${secretCount} keys from ${SECRETS_PATH})`);
203
+ }
204
+
205
+ // Resolve binaries.
206
+ const termdeckBinary = (_deps.whichBinary || whichBinary)('termdeck', _deps.spawnSync || child_process.spawnSync);
207
+ if (!termdeckBinary) {
208
+ process.stdout.write(` ${ANSI.red}✗${ANSI.reset} \`termdeck\` not on PATH — install with: ${ANSI.green}npm i -g @jhizzard/termdeck${ANSI.reset}\n`);
209
+ return { ok: false, reason: 'termdeck-missing' };
210
+ }
211
+ const mnestraInvocation = resolveMnestraInvocation({ ..._deps, fs: _fs, whichBinary: _deps.whichBinary });
212
+
213
+ // Step 2: mnestra
214
+ const childEnv = { ...process.env, ...secrets };
215
+ let mnestraPid = null;
216
+ if (!mnestraInvocation) {
217
+ step('2/3', 'Starting Mnestra', 'SKIP', '(not installed — npm i -g @jhizzard/mnestra)');
218
+ } else if (!secrets.SUPABASE_URL || !secrets.SUPABASE_SERVICE_ROLE_KEY) {
219
+ step('2/3', 'Starting Mnestra', 'WARN', '(SUPABASE_URL / SERVICE_ROLE_KEY missing — run wizard)');
220
+ } else {
221
+ const child = spawnDetached(mnestraInvocation.command, mnestraInvocation.args, MNESTRA_LOG_PATH, childEnv, _spawn, _fs);
222
+ mnestraPid = child.pid;
223
+ const health = await waitForHealth(`http://127.0.0.1:${mnestraPort}/healthz`, HEALTH_RETRIES, _fetch);
224
+ if (health.ok) {
225
+ const rows = (health.body && health.body.store && health.body.store.rows) || 0;
226
+ step('2/3', 'Starting Mnestra', 'OK', `(:${mnestraPort}, ${rows} memories)`);
227
+ } else {
228
+ step('2/3', 'Starting Mnestra', 'FAIL', `(no /healthz response — see ${MNESTRA_LOG_PATH})`);
229
+ }
230
+ }
231
+
232
+ // Step 3: termdeck
233
+ const termdeckChild = spawnDetached(termdeckBinary, ['--port', String(port), '--no-stack'], TERMDECK_LOG_PATH, childEnv, _spawn, _fs);
234
+ const termdeckHealth = await waitForHealth(`http://127.0.0.1:${port}/api/health`, HEALTH_RETRIES, _fetch);
235
+ if (termdeckHealth.ok) {
236
+ step('3/3', 'Starting TermDeck', 'OK', `(:${port})`);
237
+ } else {
238
+ step('3/3', 'Starting TermDeck', 'FAIL', `(no /api/health response — see ${TERMDECK_LOG_PATH})`);
239
+ }
240
+
241
+ const pidRecord = {
242
+ mnestraPid,
243
+ termdeckPid: termdeckChild.pid,
244
+ port,
245
+ mnestraPort,
246
+ startedAt: new Date().toISOString(),
247
+ };
248
+ _fs.writeFileSync(PID_PATH, JSON.stringify(pidRecord, null, 2) + '\n');
249
+
250
+ process.stdout.write(`\n ${ANSI.bold}Open:${ANSI.reset} ${ANSI.green}http://127.0.0.1:${port}${ANSI.reset}\n`);
251
+ process.stdout.write(` ${ANSI.dim}Stop with: termdeck-stack stop${ANSI.reset}\n\n`);
252
+ return { ok: true, ...pidRecord };
253
+ }
254
+
255
+ function tryKill(pid, signal, _process = process) {
256
+ try { _process.kill(pid, signal); return true; }
257
+ catch (_err) { return false; }
258
+ }
259
+
260
+ async function stopStack(opts = {}) {
261
+ const _deps = opts._deps || {};
262
+ const _fs = _deps.fs || fs;
263
+ const _process = _deps.process || process;
264
+
265
+ if (!_fs.existsSync(PID_PATH)) {
266
+ process.stdout.write(` ${ANSI.dim}No ${PID_PATH} — stack not started by this launcher.${ANSI.reset}\n`);
267
+ return { ok: false, reason: 'no-pidfile' };
268
+ }
269
+ let record;
270
+ try { record = JSON.parse(_fs.readFileSync(PID_PATH, 'utf8')); }
271
+ catch (err) {
272
+ process.stdout.write(` ${ANSI.red}✗${ANSI.reset} ${PID_PATH} is malformed: ${err.message}\n`);
273
+ return { ok: false, reason: 'malformed-pidfile' };
274
+ }
275
+
276
+ const stopped = [];
277
+ for (const [name, pid] of [['termdeck', record.termdeckPid], ['mnestra', record.mnestraPid]]) {
278
+ if (!pid) continue;
279
+ if (tryKill(pid, 'SIGTERM', _process)) {
280
+ stopped.push({ name, pid, signal: 'SIGTERM' });
281
+ process.stdout.write(` ${ANSI.green}✓${ANSI.reset} ${name} (pid ${pid}) signalled SIGTERM\n`);
282
+ } else {
283
+ process.stdout.write(` ${ANSI.dim}─${ANSI.reset} ${name} (pid ${pid}) already gone\n`);
284
+ }
285
+ }
286
+ // Brief grace period, then SIGKILL stragglers.
287
+ await new Promise((res) => setTimeout(res, 500));
288
+ for (const entry of stopped) {
289
+ if (tryKill(entry.pid, 0, _process)) {
290
+ tryKill(entry.pid, 'SIGKILL', _process);
291
+ process.stdout.write(` ${ANSI.yellow}!${ANSI.reset} ${entry.name} (pid ${entry.pid}) needed SIGKILL\n`);
292
+ }
293
+ }
294
+
295
+ try { _fs.unlinkSync(PID_PATH); } catch (_e) { /* already gone */ }
296
+ return { ok: true, stopped };
297
+ }
298
+
299
+ async function statusStack(opts = {}) {
300
+ const _deps = opts._deps || {};
301
+ const _fs = _deps.fs || fs;
302
+ const _fetch = _deps.fetch || globalThis.fetch;
303
+
304
+ process.stdout.write(`\n${ANSI.bold}TermDeck Stack Status${ANSI.reset}\n`);
305
+ process.stdout.write(`${ANSI.dim}─────────────────────────────────────────────${ANSI.reset}\n\n`);
306
+
307
+ let record = null;
308
+ if (_fs.existsSync(PID_PATH)) {
309
+ try { record = JSON.parse(_fs.readFileSync(PID_PATH, 'utf8')); }
310
+ catch (_e) { record = null; }
311
+ }
312
+ const port = (record && record.port) || DEFAULT_PORT;
313
+ const mnestraPort = (record && record.mnestraPort) || DEFAULT_MNESTRA_PORT;
314
+
315
+ const td = await probeHealth(`http://127.0.0.1:${port}/api/health`, _fetch);
316
+ step('1/2', 'TermDeck health', td.ok ? 'OK' : 'FAIL', td.ok ? `(:${port})` : `(:${port} not responding)`);
317
+
318
+ const mn = await probeHealth(`http://127.0.0.1:${mnestraPort}/healthz`, _fetch);
319
+ if (mn.ok) {
320
+ const rows = (mn.body && mn.body.store && mn.body.store.rows) || 0;
321
+ step('2/2', 'Mnestra health', 'OK', `(:${mnestraPort}, ${rows} memories)`);
322
+ } else {
323
+ step('2/2', 'Mnestra health', 'WARN', `(:${mnestraPort} not responding)`);
324
+ }
325
+
326
+ if (record) {
327
+ process.stdout.write(`\n ${ANSI.dim}Pidfile: ${PID_PATH} (started ${record.startedAt})${ANSI.reset}\n`);
328
+ } else {
329
+ process.stdout.write(`\n ${ANSI.dim}No pidfile — stack may have been started outside the launcher.${ANSI.reset}\n`);
330
+ }
331
+ process.stdout.write('\n');
332
+ return { ok: td.ok, termdeck: td, mnestra: mn, record };
333
+ }
334
+
335
+ module.exports = {
336
+ startStack,
337
+ stopStack,
338
+ statusStack,
339
+ // Test hooks — exposed so unit tests can drive the helpers without
340
+ // spawning real processes.
341
+ _readSecrets: readSecrets,
342
+ _whichBinary: whichBinary,
343
+ _resolveMnestraInvocation: resolveMnestraInvocation,
344
+ _ensureFirstRunConfig: ensureFirstRunConfig,
345
+ _probeHealth: probeHealth,
346
+ _spawnDetached: spawnDetached,
347
+ PID_PATH,
348
+ SECRETS_PATH,
349
+ CONFIG_PATH,
350
+ DEFAULT_PORT,
351
+ DEFAULT_MNESTRA_PORT,
352
+ };