@jhizzard/termdeck 0.4.5 → 0.4.6
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 +1 -0
- package/package.json +1 -1
- package/packages/cli/src/index.js +17 -2
- package/packages/cli/src/stack.js +467 -0
package/README.md
CHANGED
|
@@ -212,6 +212,7 @@ For users who want more than `npx` — cloning from source, building a macOS `.a
|
|
|
212
212
|
### Alternative install paths
|
|
213
213
|
|
|
214
214
|
- **Permanent global install:** `npm install -g @jhizzard/termdeck` then `termdeck` from anywhere
|
|
215
|
+
- **Full-stack one-liner:** `termdeck stack` (after global install) — boots Mnestra + checks Rumen + starts TermDeck with the same numbered-step output as `scripts/start.sh` from the repo. Available since v0.4.6; no clone required.
|
|
215
216
|
- **macOS native app:** `git clone && cd && ./install.sh` — creates `~/Applications/TermDeck.app`
|
|
216
217
|
- **From source:** `git clone && npm install && npm run dev`
|
|
217
218
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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"
|
|
@@ -62,6 +62,20 @@ 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
|
+
|
|
65
79
|
const flags = {};
|
|
66
80
|
for (let i = 0; i < args.length; i++) {
|
|
67
81
|
if (args[i] === '--port' && args[i + 1]) {
|
|
@@ -76,11 +90,12 @@ for (let i = 0; i < args.length; i++) {
|
|
|
76
90
|
TermDeck - Web-based terminal multiplexer
|
|
77
91
|
|
|
78
92
|
Usage:
|
|
79
|
-
termdeck Start
|
|
93
|
+
termdeck Start TermDeck only (port 3000)
|
|
94
|
+
termdeck stack Boot Mnestra + check Rumen + start TermDeck
|
|
80
95
|
termdeck --port 8080 Start on custom port
|
|
81
96
|
termdeck --no-open Don't auto-open browser
|
|
82
97
|
termdeck --session-logs Write per-session markdown logs to ~/.termdeck/sessions/
|
|
83
|
-
termdeck init --mnestra
|
|
98
|
+
termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
|
|
84
99
|
termdeck init --rumen Deploy Tier 3 async learning (Rumen)
|
|
85
100
|
termdeck forge Generate Claude skills from memories (experimental)
|
|
86
101
|
|
|
@@ -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
|
+
};
|