@jhizzard/termdeck-stack 0.4.12 → 0.5.1
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/assets/hooks/memory-session-end.js +85 -12
- package/package.json +1 -1
- package/src/index.js +82 -15
- package/src/launcher.js +465 -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
|
```
|
|
@@ -2,25 +2,40 @@
|
|
|
2
2
|
* TermDeck session-end memory hook (Mnestra-direct, no rag-system dependency).
|
|
3
3
|
*
|
|
4
4
|
* Vendored into ~/.claude/hooks/memory-session-end.js by @jhizzard/termdeck-stack.
|
|
5
|
-
* Wired into ~/.claude/settings.json under hooks.
|
|
5
|
+
* Wired into ~/.claude/settings.json under hooks.SessionEnd — fires once per
|
|
6
|
+
* Claude Code session close (`/exit`, Ctrl+D, terminal close, or process kill).
|
|
7
|
+
*
|
|
8
|
+
* History: this hook was originally registered under hooks.Stop, which fires
|
|
9
|
+
* after every assistant turn. That meant the same transcript got embedded and
|
|
10
|
+
* INSERTed dozens of times per session (and most fired with env-var-missing
|
|
11
|
+
* because Claude Code launched outside TermDeck doesn't have SUPABASE_URL in
|
|
12
|
+
* scope). Sprint 48 close-out moved registration to SessionEnd (one row per
|
|
13
|
+
* session, fires deterministically on /exit) AND added the secrets-env
|
|
14
|
+
* fallback below so a standalone-Claude-Code launch picks up the credentials
|
|
15
|
+
* without needing them in the parent shell.
|
|
6
16
|
*
|
|
7
17
|
* Behavior:
|
|
8
18
|
* 1. Reads {transcript_path, cwd, session_id, sessionType?} from stdin (Claude
|
|
9
|
-
* Code
|
|
10
|
-
* agents).
|
|
11
|
-
* 2.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
19
|
+
* Code SessionEnd payload, or a future server-driven invocation for
|
|
20
|
+
* non-Claude agents).
|
|
21
|
+
* 2. Loads ~/.termdeck/secrets.env into process.env if any required key is
|
|
22
|
+
* absent OR is a literal `${VAR}` placeholder (Sprint 47.5 hotfix
|
|
23
|
+
* discipline — Claude Code does not expand `${VAR}` in MCP env, and we
|
|
24
|
+
* can't trust the parent shell to have sourced secrets.env).
|
|
25
|
+
* 3. Skips small transcripts (< MIN_TRANSCRIPT_BYTES, default 5KB).
|
|
26
|
+
* 4. Validates env vars; logs and exits cleanly if any required key is still
|
|
27
|
+
* missing after the secrets.env fallback.
|
|
28
|
+
* 5. Detects project from cwd against PROJECT_MAP (else "global"). Extend the
|
|
14
29
|
* map by editing the array below — see assets/hooks/README.md for guidance.
|
|
15
|
-
*
|
|
30
|
+
* 6. Dispatches to a transcript parser by sessionType (Sprint 45 T4): Claude
|
|
16
31
|
* JSONL, Codex JSONL, Gemini single-JSON, or auto-detect when sessionType
|
|
17
32
|
* is absent. Builds a coarse summary from the resulting message list
|
|
18
33
|
* (last ~30 message excerpts).
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
34
|
+
* 7. Embeds the summary via OpenAI text-embedding-3-small.
|
|
35
|
+
* 8. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
|
|
36
|
+
* 9. Logs every step to ~/.claude/hooks/memory-hook.log.
|
|
22
37
|
*
|
|
23
|
-
* Required env vars (validated at entry):
|
|
38
|
+
* Required env vars (validated at entry, after the secrets.env fallback):
|
|
24
39
|
* - SUPABASE_URL e.g. https://<project-ref>.supabase.co
|
|
25
40
|
* - SUPABASE_SERVICE_ROLE_KEY service-role key (NOT the anon key — needs INSERT on memory_items)
|
|
26
41
|
* - OPENAI_API_KEY sk-... for text-embedding-3-small
|
|
@@ -47,6 +62,14 @@ const os = require('os');
|
|
|
47
62
|
|
|
48
63
|
const LOG_FILE = join(os.homedir(), '.claude', 'hooks', 'memory-hook.log');
|
|
49
64
|
|
|
65
|
+
// Resolved per-call so tests can override via TERMDECK_HOOK_SECRETS_PATH
|
|
66
|
+
// (the const-at-load-time pattern would freeze the path before any test
|
|
67
|
+
// that mutates HOME or the override env var gets a chance to take effect).
|
|
68
|
+
function resolveSecretsPath() {
|
|
69
|
+
return process.env.TERMDECK_HOOK_SECRETS_PATH
|
|
70
|
+
|| join(os.homedir(), '.termdeck', 'secrets.env');
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
// PROJECT_MAP — minimal default. Users extend by adding entries to this array.
|
|
51
74
|
// Patterns match against the cwd reported by Claude Code at Stop time.
|
|
52
75
|
// First match wins; falls through to "global".
|
|
@@ -72,9 +95,59 @@ function detectProject(cwd) {
|
|
|
72
95
|
return 'global';
|
|
73
96
|
}
|
|
74
97
|
|
|
98
|
+
// Treat values shaped like `${VAR}` as unset. Claude Code does not expand
|
|
99
|
+
// shell placeholders in MCP env or hook env, so a literal `${SUPABASE_URL}`
|
|
100
|
+
// is non-empty-but-invalid — the same trap that caused the Sprint 47.5
|
|
101
|
+
// hotfix on the stack-installer + mnestra MCP. Mirroring that discipline
|
|
102
|
+
// here keeps the hook resilient if any future tooling regresses to the
|
|
103
|
+
// placeholder pattern.
|
|
104
|
+
function isUnexpandedPlaceholder(v) {
|
|
105
|
+
return typeof v === 'string' && v.startsWith('${') && v.endsWith('}');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Load ~/.termdeck/secrets.env into process.env when keys are absent or
|
|
109
|
+
// hold an unexpanded `${VAR}` placeholder. Concrete values already in
|
|
110
|
+
// process.env always win — the fallback only fills gaps. Silent no-op if
|
|
111
|
+
// the file is missing. Mirrors mnestra's loadTermdeckSecretsFallback so
|
|
112
|
+
// the hook works in three launch contexts:
|
|
113
|
+
// 1. Inside TermDeck PTY (Sprint 48 T4 PTY env merge supplies the vars).
|
|
114
|
+
// 2. Standalone Claude Code launched from a shell with secrets.env sourced.
|
|
115
|
+
// 3. Standalone Claude Code launched from a vanilla shell (this fallback).
|
|
116
|
+
function loadTermdeckSecretsFallback() {
|
|
117
|
+
const secretsPath = resolveSecretsPath();
|
|
118
|
+
if (!existsSync(secretsPath)) return;
|
|
119
|
+
let raw;
|
|
120
|
+
try { raw = readFileSync(secretsPath, 'utf8'); }
|
|
121
|
+
catch (err) {
|
|
122
|
+
log(`secrets-env-read-failed: ${err && err.message ? err.message : String(err)}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
let loaded = 0;
|
|
126
|
+
for (const line of raw.split('\n')) {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
129
|
+
const m = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
130
|
+
if (!m) continue;
|
|
131
|
+
const key = m[1];
|
|
132
|
+
const cur = process.env[key];
|
|
133
|
+
if (cur && !isUnexpandedPlaceholder(cur)) continue;
|
|
134
|
+
let v = m[2];
|
|
135
|
+
if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
|
|
136
|
+
v = v.slice(1, -1);
|
|
137
|
+
}
|
|
138
|
+
process.env[key] = v;
|
|
139
|
+
loaded++;
|
|
140
|
+
}
|
|
141
|
+
if (loaded > 0) debug(`secrets-env-loaded: ${loaded} keys from ${secretsPath}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
75
144
|
function readEnv() {
|
|
145
|
+
loadTermdeckSecretsFallback();
|
|
76
146
|
const required = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
|
|
77
|
-
const missing = required.filter((k) =>
|
|
147
|
+
const missing = required.filter((k) => {
|
|
148
|
+
const v = process.env[k];
|
|
149
|
+
return !v || isUnexpandedPlaceholder(v);
|
|
150
|
+
});
|
|
78
151
|
if (missing.length) {
|
|
79
152
|
log(`env-var-missing: ${missing.join(', ')} — set these in ~/.termdeck/secrets.env or your shell to enable Mnestra ingestion. Skipping.`);
|
|
80
153
|
return null;
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -131,9 +131,14 @@ function parseArgs(argv) {
|
|
|
131
131
|
|
|
132
132
|
function printHelp() {
|
|
133
133
|
process.stdout.write(`
|
|
134
|
-
termdeck-stack — install the TermDeck developer memory stack
|
|
134
|
+
termdeck-stack — install and run the TermDeck developer memory stack
|
|
135
135
|
|
|
136
|
-
|
|
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:
|
|
137
142
|
npx @jhizzard/termdeck-stack Interactive wizard
|
|
138
143
|
npx @jhizzard/termdeck-stack --tier 4 Unattended install (1|2|3|4)
|
|
139
144
|
npx @jhizzard/termdeck-stack --dry-run Print plan, don't install
|
|
@@ -431,33 +436,60 @@ function _isSessionEndHookEntry(entry) {
|
|
|
431
436
|
&& entry.command.includes('memory-session-end.js');
|
|
432
437
|
}
|
|
433
438
|
|
|
434
|
-
// Pure: merges our
|
|
435
|
-
// Returns { settings, status } where status is 'already-installed'
|
|
436
|
-
// '
|
|
439
|
+
// Pure: merges our SessionEnd entry into the given settings object. Idempotent.
|
|
440
|
+
// Returns { settings, status } where status is 'already-installed', 'installed',
|
|
441
|
+
// or 'migrated-from-stop' (when an old `Stop` entry pointing at our hook is
|
|
442
|
+
// detected and moved over to `SessionEnd`). Mutates the input.
|
|
443
|
+
//
|
|
444
|
+
// Why SessionEnd, not Stop: the `Stop` event fires after every assistant turn,
|
|
445
|
+
// so a Stop-registered session-summary hook embeds + INSERTs the same growing
|
|
446
|
+
// transcript dozens of times per session. The `SessionEnd` event fires once
|
|
447
|
+
// per Claude Code session close (`/exit`, Ctrl+D, terminal close, kill) — the
|
|
448
|
+
// correct semantics for "summarize this session." Sprint 48 close-out moved
|
|
449
|
+
// the registration; the migration branch below heals existing installs from
|
|
450
|
+
// `@jhizzard/termdeck-stack@<=0.5.0` that wired the hook under `Stop`.
|
|
437
451
|
function _mergeSessionEndHookEntry(settings, opts = {}) {
|
|
438
452
|
const command = opts.command || HOOK_COMMAND;
|
|
439
453
|
const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
|
|
454
|
+
const entry = { type: 'command', command, timeout };
|
|
440
455
|
|
|
441
456
|
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
442
|
-
if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
|
|
443
457
|
|
|
444
|
-
|
|
458
|
+
// Migrate any pre-Sprint-48 Stop registration of OUR hook to SessionEnd.
|
|
459
|
+
// We only touch entries that match `_isSessionEndHookEntry` — any unrelated
|
|
460
|
+
// Stop hooks the user has are preserved verbatim.
|
|
461
|
+
let migrated = false;
|
|
462
|
+
if (Array.isArray(settings.hooks.Stop)) {
|
|
463
|
+
for (const group of settings.hooks.Stop) {
|
|
464
|
+
if (!group || !Array.isArray(group.hooks)) continue;
|
|
465
|
+
const before = group.hooks.length;
|
|
466
|
+
group.hooks = group.hooks.filter((e) => !_isSessionEndHookEntry(e));
|
|
467
|
+
if (group.hooks.length !== before) migrated = true;
|
|
468
|
+
}
|
|
469
|
+
settings.hooks.Stop = settings.hooks.Stop.filter(
|
|
470
|
+
(g) => g && Array.isArray(g.hooks) && g.hooks.length > 0
|
|
471
|
+
);
|
|
472
|
+
if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
|
|
476
|
+
|
|
477
|
+
for (const group of settings.hooks.SessionEnd) {
|
|
445
478
|
if (!group || !Array.isArray(group.hooks)) continue;
|
|
446
479
|
if (group.hooks.some(_isSessionEndHookEntry)) {
|
|
447
|
-
return { settings, status: 'already-installed' };
|
|
480
|
+
return { settings, status: migrated ? 'migrated-from-stop' : 'already-installed' };
|
|
448
481
|
}
|
|
449
482
|
}
|
|
450
483
|
|
|
451
|
-
const
|
|
452
|
-
const emptyMatcher = settings.hooks.Stop.find(
|
|
484
|
+
const emptyMatcher = settings.hooks.SessionEnd.find(
|
|
453
485
|
(g) => g && g.matcher === '' && Array.isArray(g.hooks)
|
|
454
486
|
);
|
|
455
487
|
if (emptyMatcher) {
|
|
456
488
|
emptyMatcher.hooks.push(entry);
|
|
457
489
|
} else {
|
|
458
|
-
settings.hooks.
|
|
490
|
+
settings.hooks.SessionEnd.push({ matcher: '', hooks: [entry] });
|
|
459
491
|
}
|
|
460
|
-
return { settings, status: 'installed' };
|
|
492
|
+
return { settings, status: migrated ? 'migrated-from-stop' : 'installed' };
|
|
461
493
|
}
|
|
462
494
|
|
|
463
495
|
function _readSettingsJson(filePath) {
|
|
@@ -578,14 +610,23 @@ async function installSessionEndHook(opts = {}) {
|
|
|
578
610
|
} else {
|
|
579
611
|
const merged = _mergeSessionEndHookEntry(read.settings);
|
|
580
612
|
if (merged.status === 'already-installed') {
|
|
581
|
-
statusLine(`${ANSI.dim}=${ANSI.reset}`, 'settings.json
|
|
613
|
+
statusLine(`${ANSI.dim}=${ANSI.reset}`, 'settings.json SessionEnd hook', 'already installed');
|
|
582
614
|
settingsStatus = 'already-installed';
|
|
615
|
+
} else if (merged.status === 'migrated-from-stop') {
|
|
616
|
+
if (dryRun) {
|
|
617
|
+
statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would migrate Stop hook → SessionEnd in ${settingsPath}`);
|
|
618
|
+
settingsStatus = 'would-migrate';
|
|
619
|
+
} else {
|
|
620
|
+
_writeSettingsJson(settingsPath, merged.settings);
|
|
621
|
+
statusLine(`${ANSI.green}↻${ANSI.reset}`, 'settings.json SessionEnd hook', 'migrated from Stop (was firing on every turn)');
|
|
622
|
+
settingsStatus = 'migrated';
|
|
623
|
+
}
|
|
583
624
|
} else if (dryRun) {
|
|
584
|
-
statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would merge
|
|
625
|
+
statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would merge SessionEnd hook into ${settingsPath}`);
|
|
585
626
|
settingsStatus = 'would-install';
|
|
586
627
|
} else {
|
|
587
628
|
_writeSettingsJson(settingsPath, merged.settings);
|
|
588
|
-
statusLine(`${ANSI.green}+${ANSI.reset}`, 'settings.json
|
|
629
|
+
statusLine(`${ANSI.green}+${ANSI.reset}`, 'settings.json SessionEnd hook', 'merged');
|
|
589
630
|
settingsStatus = 'installed';
|
|
590
631
|
}
|
|
591
632
|
}
|
|
@@ -640,7 +681,32 @@ function printNextSteps(plan, opts) {
|
|
|
640
681
|
|
|
641
682
|
// ── Main ────────────────────────────────────────────────────────────
|
|
642
683
|
|
|
684
|
+
// Sprint 48 T4: persistent launcher subcommands. Short-circuits before the
|
|
685
|
+
// wizard so `npx @jhizzard/termdeck-stack start` (and stop|status) boots the
|
|
686
|
+
// stack without running the install flow. Bare invocation still falls through
|
|
687
|
+
// to the wizard for backwards compat.
|
|
688
|
+
async function _maybeRunSubcommand(argv) {
|
|
689
|
+
const sub = argv[0];
|
|
690
|
+
if (sub !== 'start' && sub !== 'stop' && sub !== 'status') return null;
|
|
691
|
+
// Lazy-require so the wizard path doesn't pay the launcher's load cost.
|
|
692
|
+
const launcher = require('./launcher');
|
|
693
|
+
if (sub === 'start') {
|
|
694
|
+
const result = await launcher.startStack({ /* opts could parse argv flags later */ });
|
|
695
|
+
return result.ok === false ? 1 : 0;
|
|
696
|
+
}
|
|
697
|
+
if (sub === 'stop') {
|
|
698
|
+
const result = await launcher.stopStack({});
|
|
699
|
+
return result.ok ? 0 : 1;
|
|
700
|
+
}
|
|
701
|
+
// status — exits non-zero if termdeck isn't healthy so scripts can branch on it.
|
|
702
|
+
const result = await launcher.statusStack({});
|
|
703
|
+
return result.ok ? 0 : 1;
|
|
704
|
+
}
|
|
705
|
+
|
|
643
706
|
async function main(argv) {
|
|
707
|
+
const subResult = await _maybeRunSubcommand(argv);
|
|
708
|
+
if (subResult !== null) return subResult;
|
|
709
|
+
|
|
644
710
|
const args = parseArgs(argv);
|
|
645
711
|
if (args.help) { printHelp(); return 0; }
|
|
646
712
|
|
|
@@ -716,6 +782,7 @@ if (require.main === module) {
|
|
|
716
782
|
}
|
|
717
783
|
|
|
718
784
|
module.exports = main;
|
|
785
|
+
module.exports._maybeRunSubcommand = _maybeRunSubcommand;
|
|
719
786
|
module.exports._mergeSessionEndHookEntry = _mergeSessionEndHookEntry;
|
|
720
787
|
module.exports._readSettingsJson = _readSettingsJson;
|
|
721
788
|
module.exports._writeSettingsJson = _writeSettingsJson;
|
package/src/launcher.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
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
|
+
// Sprint 49 T4 — walk up from the resolved `termdeck` binary to find the
|
|
158
|
+
// installed `@jhizzard/termdeck` package root and require its agent-adapter
|
|
159
|
+
// registry + mcp-autowire helper. The installer is a zero-dep package (no
|
|
160
|
+
// `dependencies` field in package.json) so we cannot require these by package
|
|
161
|
+
// name; instead we resolve by absolute path. Works for both global installs
|
|
162
|
+
// (`/usr/local/lib/node_modules/@jhizzard/termdeck/...`) and dev checkouts
|
|
163
|
+
// where the binary symlinks back into the source tree. Returns null when the
|
|
164
|
+
// adapter tree isn't reachable — caller skips auto-wire with a warning.
|
|
165
|
+
function loadTermdeckExports(termdeckBinary, _fs = fs) {
|
|
166
|
+
if (!termdeckBinary) return null;
|
|
167
|
+
let realPath;
|
|
168
|
+
try { realPath = _fs.realpathSync(termdeckBinary); }
|
|
169
|
+
catch (_) { return null; }
|
|
170
|
+
let dir = path.dirname(realPath);
|
|
171
|
+
for (let i = 0; i < 10; i++) {
|
|
172
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
173
|
+
if (_fs.existsSync(pkgPath)) {
|
|
174
|
+
let pkg;
|
|
175
|
+
try { pkg = JSON.parse(_fs.readFileSync(pkgPath, 'utf8')); }
|
|
176
|
+
catch (_) { pkg = null; }
|
|
177
|
+
if (pkg && pkg.name === '@jhizzard/termdeck') {
|
|
178
|
+
const adaptersPath = path.join(dir, 'packages/server/src/agent-adapters');
|
|
179
|
+
const autowirePath = path.join(dir, 'packages/server/src/mcp-autowire.js');
|
|
180
|
+
if (_fs.existsSync(adaptersPath) && _fs.existsSync(autowirePath)) {
|
|
181
|
+
try {
|
|
182
|
+
const adaptersMod = require(adaptersPath);
|
|
183
|
+
const autowireMod = require(autowirePath);
|
|
184
|
+
return {
|
|
185
|
+
adapters: adaptersMod.AGENT_ADAPTERS,
|
|
186
|
+
ensureMnestraBlock: autowireMod.ensureMnestraBlock,
|
|
187
|
+
packageRoot: dir,
|
|
188
|
+
};
|
|
189
|
+
} catch (_) { return null; }
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const parent = path.dirname(dir);
|
|
195
|
+
if (parent === dir) break;
|
|
196
|
+
dir = parent;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Sprint 49 T4 — iterates an adapter registry (record or array) and calls
|
|
202
|
+
// `ensureMnestraBlock(adapter, opts)` for each adapter that declares a non-null
|
|
203
|
+
// `mcpConfig`. Adapters with `mcpConfig: null` (Claude — user-managed via
|
|
204
|
+
// `claude mcp add`) are skipped without invoking the helper. Helper exceptions
|
|
205
|
+
// on a single adapter don't abort the loop — they're captured under
|
|
206
|
+
// `errored[]` so the launcher can continue with the remaining adapters and
|
|
207
|
+
// surface diagnostics in the step line.
|
|
208
|
+
//
|
|
209
|
+
// Returns { wired: string[], unchanged: string[], skipped: string[],
|
|
210
|
+
// errored: { name, error }[] } — caller renders a one-line summary.
|
|
211
|
+
// Idempotent: a second call against an already-wired environment shifts every
|
|
212
|
+
// adapter from `wired` to `unchanged` because the helper's per-shape
|
|
213
|
+
// detect-existing branches return `{ unchanged: true }` on no-op writes.
|
|
214
|
+
function autowireMcp(adapters, ensureMnestraBlockFn, opts = {}) {
|
|
215
|
+
const summary = { wired: [], unchanged: [], skipped: [], errored: [] };
|
|
216
|
+
if (!adapters || typeof ensureMnestraBlockFn !== 'function') return summary;
|
|
217
|
+
const list = Array.isArray(adapters) ? adapters : Object.values(adapters);
|
|
218
|
+
for (const adapter of list) {
|
|
219
|
+
const name = (adapter && adapter.name) || '<unknown>';
|
|
220
|
+
if (!adapter || !adapter.mcpConfig) {
|
|
221
|
+
summary.skipped.push(name);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let result;
|
|
225
|
+
try {
|
|
226
|
+
result = ensureMnestraBlockFn(adapter, opts);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
summary.errored.push({ name, error: err && err.message ? err.message : String(err) });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (result && result.wrote) summary.wired.push(name);
|
|
232
|
+
else if (result && result.unchanged) summary.unchanged.push(name);
|
|
233
|
+
else summary.skipped.push(name);
|
|
234
|
+
}
|
|
235
|
+
return summary;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function spawnDetached(command, args, logPath, env, _spawn = child_process.spawn, _fs = fs) {
|
|
239
|
+
// open() the log file then pass the fd to spawn so the child inherits a
|
|
240
|
+
// real disk-backed stdout/stderr. Close our handle after spawn so we
|
|
241
|
+
// don't keep an extra fd open.
|
|
242
|
+
const fd = _fs.openSync(logPath, 'a');
|
|
243
|
+
try {
|
|
244
|
+
const child = _spawn(command, args, {
|
|
245
|
+
detached: true,
|
|
246
|
+
stdio: ['ignore', fd, fd],
|
|
247
|
+
env,
|
|
248
|
+
});
|
|
249
|
+
child.unref();
|
|
250
|
+
return child;
|
|
251
|
+
} finally {
|
|
252
|
+
try { _fs.closeSync(fd); } catch (_e) { /* best effort */ }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function startStack(opts = {}) {
|
|
257
|
+
const _deps = opts._deps || {};
|
|
258
|
+
const _fs = _deps.fs || fs;
|
|
259
|
+
const _spawn = _deps.spawn || child_process.spawn;
|
|
260
|
+
const _fetch = _deps.fetch || globalThis.fetch;
|
|
261
|
+
const port = opts.port || parseInt(process.env.TERMDECK_PORT || '', 10) || DEFAULT_PORT;
|
|
262
|
+
const mnestraPort = opts.mnestraPort || parseInt(process.env.MNESTRA_PORT || '', 10) || DEFAULT_MNESTRA_PORT;
|
|
263
|
+
|
|
264
|
+
process.stdout.write(`\n${ANSI.bold}TermDeck Stack Launcher${ANSI.reset}\n`);
|
|
265
|
+
process.stdout.write(`${ANSI.dim}─────────────────────────────────────────────${ANSI.reset}\n\n`);
|
|
266
|
+
|
|
267
|
+
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
|
|
268
|
+
if (nodeMajor < 18) {
|
|
269
|
+
process.stdout.write(` ${ANSI.red}✗ Node ${process.version} detected — TermDeck requires Node 18+.${ANSI.reset}\n`);
|
|
270
|
+
return { ok: false, reason: 'node-too-old' };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (ensureFirstRunConfig(_fs)) {
|
|
274
|
+
process.stdout.write(` ${ANSI.blue}ⓘ${ANSI.reset} First run — created ${CONFIG_PATH}\n\n`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Step 1: secrets
|
|
278
|
+
const secrets = readSecrets(SECRETS_PATH, _fs);
|
|
279
|
+
const secretCount = Object.keys(secrets).length;
|
|
280
|
+
if (secretCount === 0) {
|
|
281
|
+
step('1/4', 'Loading secrets', 'WARN', `(no readable keys in ${SECRETS_PATH} — run \`npx @jhizzard/termdeck-stack\` to set up)`);
|
|
282
|
+
} else {
|
|
283
|
+
step('1/4', 'Loading secrets', 'OK', `(${secretCount} keys from ${SECRETS_PATH})`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Resolve binaries.
|
|
287
|
+
const termdeckBinary = (_deps.whichBinary || whichBinary)('termdeck', _deps.spawnSync || child_process.spawnSync);
|
|
288
|
+
if (!termdeckBinary) {
|
|
289
|
+
process.stdout.write(` ${ANSI.red}✗${ANSI.reset} \`termdeck\` not on PATH — install with: ${ANSI.green}npm i -g @jhizzard/termdeck${ANSI.reset}\n`);
|
|
290
|
+
return { ok: false, reason: 'termdeck-missing' };
|
|
291
|
+
}
|
|
292
|
+
const mnestraInvocation = resolveMnestraInvocation({ ..._deps, fs: _fs, whichBinary: _deps.whichBinary });
|
|
293
|
+
|
|
294
|
+
// Step 2: auto-wire MCP for non-Claude adapters (Sprint 49 T4)
|
|
295
|
+
let autowireSummary = null;
|
|
296
|
+
if (opts.noWire) {
|
|
297
|
+
step('2/4', 'Auto-wiring MCP', 'SKIP', '(--no-wire)');
|
|
298
|
+
} else {
|
|
299
|
+
const termdeckExports = (_deps.termdeckExports !== undefined)
|
|
300
|
+
? _deps.termdeckExports
|
|
301
|
+
: loadTermdeckExports(termdeckBinary, _fs);
|
|
302
|
+
if (!termdeckExports) {
|
|
303
|
+
step('2/4', 'Auto-wiring MCP', 'WARN', '(@jhizzard/termdeck adapter tree not resolvable — skipping)');
|
|
304
|
+
} else {
|
|
305
|
+
autowireSummary = autowireMcp(
|
|
306
|
+
termdeckExports.adapters,
|
|
307
|
+
termdeckExports.ensureMnestraBlock,
|
|
308
|
+
{ secrets },
|
|
309
|
+
);
|
|
310
|
+
const parts = [];
|
|
311
|
+
for (const name of autowireSummary.wired) parts.push(`${name} (wrote)`);
|
|
312
|
+
for (const name of autowireSummary.unchanged) parts.push(`${name} (unchanged)`);
|
|
313
|
+
for (const e of autowireSummary.errored) parts.push(`${e.name} (error: ${e.error})`);
|
|
314
|
+
if (autowireSummary.wired.length === 0 && autowireSummary.unchanged.length === 0 && autowireSummary.errored.length === 0) {
|
|
315
|
+
step('2/4', 'Auto-wiring MCP', 'SKIP', '(no adapters declare mcpConfig)');
|
|
316
|
+
} else if (autowireSummary.errored.length > 0 && autowireSummary.wired.length === 0 && autowireSummary.unchanged.length === 0) {
|
|
317
|
+
step('2/4', 'Auto-wiring MCP', 'FAIL', parts.join(', '));
|
|
318
|
+
} else {
|
|
319
|
+
step('2/4', 'Auto-wiring MCP', 'OK', parts.join(', '));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Step 3: mnestra
|
|
325
|
+
const childEnv = { ...process.env, ...secrets };
|
|
326
|
+
let mnestraPid = null;
|
|
327
|
+
if (!mnestraInvocation) {
|
|
328
|
+
step('3/4', 'Starting Mnestra', 'SKIP', '(not installed — npm i -g @jhizzard/mnestra)');
|
|
329
|
+
} else if (!secrets.SUPABASE_URL || !secrets.SUPABASE_SERVICE_ROLE_KEY) {
|
|
330
|
+
step('3/4', 'Starting Mnestra', 'WARN', '(SUPABASE_URL / SERVICE_ROLE_KEY missing — run wizard)');
|
|
331
|
+
} else {
|
|
332
|
+
const child = spawnDetached(mnestraInvocation.command, mnestraInvocation.args, MNESTRA_LOG_PATH, childEnv, _spawn, _fs);
|
|
333
|
+
mnestraPid = child.pid;
|
|
334
|
+
const health = await waitForHealth(`http://127.0.0.1:${mnestraPort}/healthz`, HEALTH_RETRIES, _fetch);
|
|
335
|
+
if (health.ok) {
|
|
336
|
+
const rows = (health.body && health.body.store && health.body.store.rows) || 0;
|
|
337
|
+
step('3/4', 'Starting Mnestra', 'OK', `(:${mnestraPort}, ${rows} memories)`);
|
|
338
|
+
} else {
|
|
339
|
+
step('3/4', 'Starting Mnestra', 'FAIL', `(no /healthz response — see ${MNESTRA_LOG_PATH})`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Step 4: termdeck
|
|
344
|
+
const termdeckChild = spawnDetached(termdeckBinary, ['--port', String(port), '--no-stack'], TERMDECK_LOG_PATH, childEnv, _spawn, _fs);
|
|
345
|
+
const termdeckHealth = await waitForHealth(`http://127.0.0.1:${port}/api/health`, HEALTH_RETRIES, _fetch);
|
|
346
|
+
if (termdeckHealth.ok) {
|
|
347
|
+
step('4/4', 'Starting TermDeck', 'OK', `(:${port})`);
|
|
348
|
+
} else {
|
|
349
|
+
step('4/4', 'Starting TermDeck', 'FAIL', `(no /api/health response — see ${TERMDECK_LOG_PATH})`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const pidRecord = {
|
|
353
|
+
mnestraPid,
|
|
354
|
+
termdeckPid: termdeckChild.pid,
|
|
355
|
+
port,
|
|
356
|
+
mnestraPort,
|
|
357
|
+
startedAt: new Date().toISOString(),
|
|
358
|
+
};
|
|
359
|
+
_fs.writeFileSync(PID_PATH, JSON.stringify(pidRecord, null, 2) + '\n');
|
|
360
|
+
|
|
361
|
+
process.stdout.write(`\n ${ANSI.bold}Open:${ANSI.reset} ${ANSI.green}http://127.0.0.1:${port}${ANSI.reset}\n`);
|
|
362
|
+
process.stdout.write(` ${ANSI.dim}Stop with: termdeck-stack stop${ANSI.reset}\n\n`);
|
|
363
|
+
return { ok: true, ...pidRecord };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function tryKill(pid, signal, _process = process) {
|
|
367
|
+
try { _process.kill(pid, signal); return true; }
|
|
368
|
+
catch (_err) { return false; }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function stopStack(opts = {}) {
|
|
372
|
+
const _deps = opts._deps || {};
|
|
373
|
+
const _fs = _deps.fs || fs;
|
|
374
|
+
const _process = _deps.process || process;
|
|
375
|
+
|
|
376
|
+
if (!_fs.existsSync(PID_PATH)) {
|
|
377
|
+
process.stdout.write(` ${ANSI.dim}No ${PID_PATH} — stack not started by this launcher.${ANSI.reset}\n`);
|
|
378
|
+
return { ok: false, reason: 'no-pidfile' };
|
|
379
|
+
}
|
|
380
|
+
let record;
|
|
381
|
+
try { record = JSON.parse(_fs.readFileSync(PID_PATH, 'utf8')); }
|
|
382
|
+
catch (err) {
|
|
383
|
+
process.stdout.write(` ${ANSI.red}✗${ANSI.reset} ${PID_PATH} is malformed: ${err.message}\n`);
|
|
384
|
+
return { ok: false, reason: 'malformed-pidfile' };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const stopped = [];
|
|
388
|
+
for (const [name, pid] of [['termdeck', record.termdeckPid], ['mnestra', record.mnestraPid]]) {
|
|
389
|
+
if (!pid) continue;
|
|
390
|
+
if (tryKill(pid, 'SIGTERM', _process)) {
|
|
391
|
+
stopped.push({ name, pid, signal: 'SIGTERM' });
|
|
392
|
+
process.stdout.write(` ${ANSI.green}✓${ANSI.reset} ${name} (pid ${pid}) signalled SIGTERM\n`);
|
|
393
|
+
} else {
|
|
394
|
+
process.stdout.write(` ${ANSI.dim}─${ANSI.reset} ${name} (pid ${pid}) already gone\n`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Brief grace period, then SIGKILL stragglers.
|
|
398
|
+
await new Promise((res) => setTimeout(res, 500));
|
|
399
|
+
for (const entry of stopped) {
|
|
400
|
+
if (tryKill(entry.pid, 0, _process)) {
|
|
401
|
+
tryKill(entry.pid, 'SIGKILL', _process);
|
|
402
|
+
process.stdout.write(` ${ANSI.yellow}!${ANSI.reset} ${entry.name} (pid ${entry.pid}) needed SIGKILL\n`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
try { _fs.unlinkSync(PID_PATH); } catch (_e) { /* already gone */ }
|
|
407
|
+
return { ok: true, stopped };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function statusStack(opts = {}) {
|
|
411
|
+
const _deps = opts._deps || {};
|
|
412
|
+
const _fs = _deps.fs || fs;
|
|
413
|
+
const _fetch = _deps.fetch || globalThis.fetch;
|
|
414
|
+
|
|
415
|
+
process.stdout.write(`\n${ANSI.bold}TermDeck Stack Status${ANSI.reset}\n`);
|
|
416
|
+
process.stdout.write(`${ANSI.dim}─────────────────────────────────────────────${ANSI.reset}\n\n`);
|
|
417
|
+
|
|
418
|
+
let record = null;
|
|
419
|
+
if (_fs.existsSync(PID_PATH)) {
|
|
420
|
+
try { record = JSON.parse(_fs.readFileSync(PID_PATH, 'utf8')); }
|
|
421
|
+
catch (_e) { record = null; }
|
|
422
|
+
}
|
|
423
|
+
const port = (record && record.port) || DEFAULT_PORT;
|
|
424
|
+
const mnestraPort = (record && record.mnestraPort) || DEFAULT_MNESTRA_PORT;
|
|
425
|
+
|
|
426
|
+
const td = await probeHealth(`http://127.0.0.1:${port}/api/health`, _fetch);
|
|
427
|
+
step('1/2', 'TermDeck health', td.ok ? 'OK' : 'FAIL', td.ok ? `(:${port})` : `(:${port} not responding)`);
|
|
428
|
+
|
|
429
|
+
const mn = await probeHealth(`http://127.0.0.1:${mnestraPort}/healthz`, _fetch);
|
|
430
|
+
if (mn.ok) {
|
|
431
|
+
const rows = (mn.body && mn.body.store && mn.body.store.rows) || 0;
|
|
432
|
+
step('2/2', 'Mnestra health', 'OK', `(:${mnestraPort}, ${rows} memories)`);
|
|
433
|
+
} else {
|
|
434
|
+
step('2/2', 'Mnestra health', 'WARN', `(:${mnestraPort} not responding)`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (record) {
|
|
438
|
+
process.stdout.write(`\n ${ANSI.dim}Pidfile: ${PID_PATH} (started ${record.startedAt})${ANSI.reset}\n`);
|
|
439
|
+
} else {
|
|
440
|
+
process.stdout.write(`\n ${ANSI.dim}No pidfile — stack may have been started outside the launcher.${ANSI.reset}\n`);
|
|
441
|
+
}
|
|
442
|
+
process.stdout.write('\n');
|
|
443
|
+
return { ok: td.ok, termdeck: td, mnestra: mn, record };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
module.exports = {
|
|
447
|
+
startStack,
|
|
448
|
+
stopStack,
|
|
449
|
+
statusStack,
|
|
450
|
+
// Test hooks — exposed so unit tests can drive the helpers without
|
|
451
|
+
// spawning real processes.
|
|
452
|
+
_readSecrets: readSecrets,
|
|
453
|
+
_whichBinary: whichBinary,
|
|
454
|
+
_resolveMnestraInvocation: resolveMnestraInvocation,
|
|
455
|
+
_ensureFirstRunConfig: ensureFirstRunConfig,
|
|
456
|
+
_probeHealth: probeHealth,
|
|
457
|
+
_spawnDetached: spawnDetached,
|
|
458
|
+
_autowireMcp: autowireMcp,
|
|
459
|
+
_loadTermdeckExports: loadTermdeckExports,
|
|
460
|
+
PID_PATH,
|
|
461
|
+
SECRETS_PATH,
|
|
462
|
+
CONFIG_PATH,
|
|
463
|
+
DEFAULT_PORT,
|
|
464
|
+
DEFAULT_MNESTRA_PORT,
|
|
465
|
+
};
|