@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.
- package/assets/hooks/README.md +10 -0
- package/package.json +1 -1
- package/src/index.js +109 -8
- package/src/launcher.js +352 -0
package/assets/hooks/README.md
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/launcher.js
ADDED
|
@@ -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
|
+
};
|