@jhizzard/termdeck 0.7.2 → 0.8.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/config/transcript-migration.sql +34 -0
- package/package.json +2 -1
- package/packages/cli/src/auto-orchestrate.js +28 -22
- package/packages/cli/src/doctor.js +296 -16
- package/packages/cli/src/index.js +117 -10
- package/packages/cli/src/init-mnestra.js +31 -7
- package/packages/cli/src/init-rumen.js +30 -33
- package/packages/cli/src/mcp-config.js +174 -0
- package/packages/cli/src/stack.js +61 -11
- package/packages/client/public/app.js +114 -2
- package/packages/client/public/style.css +121 -0
- package/packages/server/src/config.js +96 -0
- package/packages/server/src/index.js +176 -6
- package/packages/server/src/rag.js +43 -0
- package/packages/server/src/setup/migration-runner.js +12 -5
- package/packages/server/src/setup/mnestra-migrations/008_legacy_rag_tables.sql +122 -0
- package/packages/server/src/setup/preconditions.js +24 -4
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- termdeck_transcripts: append-only log of all PTY output
|
|
2
|
+
-- Run with: psql -f config/transcript-migration.sql "$DATABASE_URL"
|
|
3
|
+
-- Idempotent — safe to re-run.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS termdeck_transcripts (
|
|
6
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
7
|
+
session_id TEXT NOT NULL, -- TermDeck session UUID
|
|
8
|
+
chunk_index BIGINT NOT NULL, -- monotonic per session, for ordering
|
|
9
|
+
content TEXT NOT NULL, -- raw PTY output (ANSI stripped)
|
|
10
|
+
raw_bytes BIGINT NOT NULL DEFAULT 0, -- byte count before stripping
|
|
11
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Index for session replay (ordered chunks)
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_transcripts_session_order
|
|
16
|
+
ON termdeck_transcripts (session_id, chunk_index);
|
|
17
|
+
|
|
18
|
+
-- Index for time-range queries (crash recovery: "what happened in the last hour?")
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_transcripts_created
|
|
20
|
+
ON termdeck_transcripts (created_at DESC);
|
|
21
|
+
|
|
22
|
+
-- Full-text search for finding specific output across all sessions
|
|
23
|
+
ALTER TABLE termdeck_transcripts
|
|
24
|
+
ADD COLUMN IF NOT EXISTS fts tsvector
|
|
25
|
+
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
|
|
26
|
+
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_transcripts_fts
|
|
28
|
+
ON termdeck_transcripts USING GIN (fts);
|
|
29
|
+
|
|
30
|
+
-- RLS: service-role only (no anon access to raw terminal output)
|
|
31
|
+
ALTER TABLE termdeck_transcripts ENABLE ROW LEVEL SECURITY;
|
|
32
|
+
|
|
33
|
+
-- Cleanup policy: transcripts older than 30 days get purged
|
|
34
|
+
-- (implement as a pg_cron job or scheduled function, not in this migration)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"packages/client/public/**",
|
|
13
13
|
"config/config.example.yaml",
|
|
14
14
|
"config/secrets.env.example",
|
|
15
|
+
"config/transcript-migration.sql",
|
|
15
16
|
"LICENSE",
|
|
16
17
|
"README.md"
|
|
17
18
|
],
|
|
@@ -1,26 +1,32 @@
|
|
|
1
|
-
// Detection helper for
|
|
2
|
-
//
|
|
3
|
-
// the dispatcher in index.js still owns the actual
|
|
1
|
+
// Detection helper for default-entry routing — does plain `termdeck`
|
|
2
|
+
// (no subcommand) route through stack.js? Pure function, isolated for
|
|
3
|
+
// testability — the dispatcher in index.js still owns the actual
|
|
4
|
+
// routing decision.
|
|
5
|
+
//
|
|
6
|
+
// Sprint 24 policy (original): only auto-orchestrate when both
|
|
7
|
+
// ~/.termdeck/secrets.env and ~/.termdeck/config.yaml exist AND either
|
|
8
|
+
// mnestra.autoStart or rag.enabled is true. Fresh boxes fell through to
|
|
9
|
+
// Tier-1-only.
|
|
10
|
+
//
|
|
11
|
+
// Sprint 36 policy (current): always orchestrate by default. Acceptance
|
|
12
|
+
// criterion #2 of Sprint 36 (`docs/sprint-36-launcher-ui-parity/PLANNING.md`)
|
|
13
|
+
// requires `npx @jhizzard/termdeck` to match `scripts/start.sh` step-by-step
|
|
14
|
+
// on every machine, fresh or configured. stack.js handles the fresh-machine
|
|
15
|
+
// case via `ensureFirstRunConfig()` — it auto-writes a minimal
|
|
16
|
+
// ~/.termdeck/config.yaml on first run, then proceeds through Step 1/4–4/4
|
|
17
|
+
// with mostly-SKIP statuses (no secrets → SKIP, no mnestra binary → SKIP,
|
|
18
|
+
// no DATABASE_URL → SKIP, BOOT). That output mirrors what start.sh produces
|
|
19
|
+
// on a fresh box.
|
|
20
|
+
//
|
|
21
|
+
// The escape hatch is the explicit `--no-stack` flag handled in index.js.
|
|
22
|
+
//
|
|
23
|
+
// The function signature stays the same so callers and tests don't break;
|
|
24
|
+
// the body is just a constant now. Keeping the function (rather than
|
|
25
|
+
// inlining the boolean) leaves a hook for future telemetry — e.g., emitting
|
|
26
|
+
// "why we orchestrated" reasons — without another dispatcher rewrite.
|
|
4
27
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const path = require('path');
|
|
8
|
-
|
|
9
|
-
function shouldAutoOrchestrate(homeDir) {
|
|
10
|
-
const home = homeDir || os.homedir();
|
|
11
|
-
const secretsPath = path.join(home, '.termdeck', 'secrets.env');
|
|
12
|
-
const configPath = path.join(home, '.termdeck', 'config.yaml');
|
|
13
|
-
if (!fs.existsSync(secretsPath) || !fs.existsSync(configPath)) return false;
|
|
14
|
-
let parsed;
|
|
15
|
-
try {
|
|
16
|
-
const yaml = require('yaml');
|
|
17
|
-
parsed = yaml.parse(fs.readFileSync(configPath, 'utf8')) || {};
|
|
18
|
-
} catch (_e) {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
const mnestraAuto = parsed.mnestra && parsed.mnestra.autoStart === true;
|
|
22
|
-
const ragEnabled = parsed.rag && parsed.rag.enabled === true;
|
|
23
|
-
return Boolean(mnestraAuto || ragEnabled);
|
|
28
|
+
function shouldAutoOrchestrate(_homeDir) {
|
|
29
|
+
return true;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
module.exports = { shouldAutoOrchestrate };
|
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
// `termdeck doctor` — Sprint 28 T2.
|
|
1
|
+
// `termdeck doctor` — Sprint 28 T2 + Sprint 35 T3.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Two-section diagnostic:
|
|
4
|
+
// Section 1 (Sprint 28) — npm version-check across the four stack packages,
|
|
5
|
+
// comparing installed (`npm ls -g`) to the registry's `dist-tags.latest`.
|
|
6
|
+
// Section 2 (Sprint 35) — Supabase schema state. Connects via DATABASE_URL
|
|
7
|
+
// from ~/.termdeck/secrets.env and verifies the tables / columns / RPCs /
|
|
8
|
+
// extensions that TermDeck + Mnestra + Rumen depend on.
|
|
6
9
|
//
|
|
7
|
-
//
|
|
10
|
+
// Read-only — no auto-fix. Each fail prints a remediation hint.
|
|
11
|
+
//
|
|
12
|
+
// Module contract:
|
|
8
13
|
// module.exports = function doctor(argv): Promise<exitCode>
|
|
9
|
-
// 0 = all current
|
|
10
|
-
// 1 = at least one update available
|
|
11
|
-
// 2 = network/registry failure or
|
|
14
|
+
// 0 = all current and schema clean
|
|
15
|
+
// 1 = at least one update available OR at least one schema gap
|
|
16
|
+
// 2 = network/registry failure or DB-unreachable when --schema requested
|
|
17
|
+
//
|
|
18
|
+
// Flags:
|
|
19
|
+
// --json Emit a parseable JSON document (shape extended for Sprint 35:
|
|
20
|
+
// `{ exitCode, rows, schema? }` — `rows` retained for back-compat)
|
|
21
|
+
// --no-color Strip ANSI codes
|
|
22
|
+
// --no-schema Skip the Supabase schema section (used by tests + offline runs)
|
|
12
23
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// `module.exports.<name>` so monkey-patching takes effect at call time.
|
|
24
|
+
// Test seams (monkey-patchable):
|
|
25
|
+
// _detectInstalled / _fetchLatest — npm probes (Sprint 28)
|
|
26
|
+
// _runSchemaCheck — Supabase probe (Sprint 35) — tests stub to `{ skipped: true }`
|
|
17
27
|
|
|
18
28
|
const https = require('https');
|
|
19
29
|
const { spawn } = require('child_process');
|
|
30
|
+
const path = require('path');
|
|
20
31
|
|
|
21
32
|
const STACK_PACKAGES = [
|
|
22
33
|
'@jhizzard/termdeck',
|
|
@@ -200,9 +211,255 @@ function parseArgv(argv) {
|
|
|
200
211
|
return {
|
|
201
212
|
json: args.includes('--json'),
|
|
202
213
|
noColor: args.includes('--no-color'),
|
|
214
|
+
noSchema: args.includes('--no-schema'),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Sprint 35 T3: Supabase schema-check ────────────────────────────────────
|
|
219
|
+
//
|
|
220
|
+
// Connects via DATABASE_URL from ~/.termdeck/secrets.env and runs the schema
|
|
221
|
+
// invariants TermDeck + Mnestra + Rumen depend on. Read-only — no DDL.
|
|
222
|
+
//
|
|
223
|
+
// Returns `{ skipped, sections, passed, total, hasGaps, error? }` where
|
|
224
|
+
// `sections` is an ordered list of `{ name, checks: [{ label, status, hint? }] }`.
|
|
225
|
+
// `status` is one of 'pass' | 'fail'. A `skipped: true` result short-circuits
|
|
226
|
+
// rendering with an informational note.
|
|
227
|
+
|
|
228
|
+
const SCHEMA_QUERIES = {
|
|
229
|
+
table: (name) =>
|
|
230
|
+
`SELECT EXISTS(SELECT 1 FROM information_schema.tables ` +
|
|
231
|
+
`WHERE table_schema = 'public' AND table_name = '${name}') AS ok`,
|
|
232
|
+
column: (table, column) =>
|
|
233
|
+
`SELECT EXISTS(SELECT 1 FROM information_schema.columns ` +
|
|
234
|
+
`WHERE table_schema = 'public' AND table_name = '${table}' ` +
|
|
235
|
+
`AND column_name = '${column}') AS ok`,
|
|
236
|
+
rpc: (name) =>
|
|
237
|
+
`SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = '${name}') AS ok`,
|
|
238
|
+
extension: (name) =>
|
|
239
|
+
`SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = '${name}') AS ok`,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// pgvector ships under extname 'vector' on Supabase; some older installs
|
|
243
|
+
// or self-hosted boxes use 'pgvector' directly. Accept either.
|
|
244
|
+
async function checkPgVector(client) {
|
|
245
|
+
try {
|
|
246
|
+
const r = await client.query(
|
|
247
|
+
"SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname IN ('vector', 'pgvector')) AS ok"
|
|
248
|
+
);
|
|
249
|
+
return r.rows && r.rows[0] && r.rows[0].ok === true;
|
|
250
|
+
} catch (_e) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function probeSchema(client, sql) {
|
|
256
|
+
try {
|
|
257
|
+
const r = await client.query(sql);
|
|
258
|
+
return r.rows && r.rows[0] && r.rows[0].ok === true;
|
|
259
|
+
} catch (_e) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function _runSchemaCheck(opts = {}) {
|
|
265
|
+
const optsObj = opts || {};
|
|
266
|
+
// Lazy-require so users running version-check-only never load pg / fs.
|
|
267
|
+
const fs = require('fs');
|
|
268
|
+
const os = require('os');
|
|
269
|
+
const SETUP_DIR = path.join(__dirname, '..', '..', 'server', 'src', 'setup');
|
|
270
|
+
let pgRunner;
|
|
271
|
+
let dotenv;
|
|
272
|
+
try {
|
|
273
|
+
pgRunner = require(path.join(SETUP_DIR, 'pg-runner'));
|
|
274
|
+
dotenv = require(path.join(SETUP_DIR, 'dotenv-io'));
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return {
|
|
277
|
+
skipped: true,
|
|
278
|
+
reason: `setup helpers unavailable: ${err.message}`,
|
|
279
|
+
sections: [], passed: 0, total: 0, hasGaps: false,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const secretsPath = optsObj.secretsPath ||
|
|
284
|
+
path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
285
|
+
if (!fs.existsSync(secretsPath)) {
|
|
286
|
+
return {
|
|
287
|
+
skipped: true,
|
|
288
|
+
reason: `~/.termdeck/secrets.env not found — run \`termdeck init --mnestra\` first`,
|
|
289
|
+
sections: [], passed: 0, total: 0, hasGaps: false,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const secrets = optsObj.secrets || dotenv.readSecrets(secretsPath);
|
|
293
|
+
if (!secrets.DATABASE_URL) {
|
|
294
|
+
return {
|
|
295
|
+
skipped: true,
|
|
296
|
+
reason: `DATABASE_URL not set in ${secretsPath}`,
|
|
297
|
+
sections: [], passed: 0, total: 0, hasGaps: false,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let client = optsObj._pgClient || null;
|
|
302
|
+
let ownsClient = false;
|
|
303
|
+
if (!client) {
|
|
304
|
+
try {
|
|
305
|
+
client = await pgRunner.connect(secrets.DATABASE_URL);
|
|
306
|
+
ownsClient = true;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return {
|
|
309
|
+
skipped: false,
|
|
310
|
+
connectError: err.message,
|
|
311
|
+
sections: [], passed: 0, total: 0, hasGaps: true,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const sections = [
|
|
317
|
+
{ name: 'Mnestra modern schema', checks: [] },
|
|
318
|
+
{ name: 'Mnestra legacy schema', checks: [] },
|
|
319
|
+
{ name: 'Transcript backup', checks: [] },
|
|
320
|
+
{ name: 'Rumen schema', checks: [] },
|
|
321
|
+
{ name: 'Postgres extensions', checks: [] },
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
// Mnestra modern
|
|
326
|
+
const modern = sections[0].checks;
|
|
327
|
+
for (const t of ['memory_items', 'memory_sessions', 'memory_relationships']) {
|
|
328
|
+
modern.push({
|
|
329
|
+
label: `${t} table`,
|
|
330
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.table(t))) ? 'pass' : 'fail',
|
|
331
|
+
hint: `run: termdeck init --mnestra (applies migrations 001–007)`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
modern.push({
|
|
335
|
+
label: `memory_items.source_session_id column (v0.6.5+)`,
|
|
336
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.column('memory_items', 'source_session_id'))) ? 'pass' : 'fail',
|
|
337
|
+
hint: `migration 007 adds it — run: npm cache clean --force && npm i -g @jhizzard/termdeck@latest && termdeck init --mnestra --yes`,
|
|
338
|
+
});
|
|
339
|
+
for (const fn of ['match_memories', 'search_memories', 'memory_status_aggregation']) {
|
|
340
|
+
modern.push({
|
|
341
|
+
label: `${fn}() RPC`,
|
|
342
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.rpc(fn))) ? 'pass' : 'fail',
|
|
343
|
+
hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Mnestra legacy (Sprint 35 T2 ships these via 008_legacy_rag_tables.sql)
|
|
348
|
+
const legacy = sections[1].checks;
|
|
349
|
+
for (const t of ['mnestra_session_memory', 'mnestra_project_memory', 'mnestra_developer_memory', 'mnestra_commands']) {
|
|
350
|
+
legacy.push({
|
|
351
|
+
label: `${t} table`,
|
|
352
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.table(t))) ? 'pass' : 'fail',
|
|
353
|
+
hint: `run: termdeck init --mnestra --yes (applies migration 008 — Sprint 35)`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Transcript
|
|
358
|
+
sections[2].checks.push({
|
|
359
|
+
label: `termdeck_transcripts table`,
|
|
360
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.table('termdeck_transcripts'))) ? 'pass' : 'fail',
|
|
361
|
+
hint: `run: psql "$DATABASE_URL" -f config/transcript-migration.sql`,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Rumen — table existence and the created_at column drift Brad hit
|
|
365
|
+
const rumen = sections[3].checks;
|
|
366
|
+
for (const t of ['rumen_jobs', 'rumen_insights', 'rumen_questions']) {
|
|
367
|
+
const tableOk = await probeSchema(client, SCHEMA_QUERIES.table(t));
|
|
368
|
+
rumen.push({
|
|
369
|
+
label: `${t} table`,
|
|
370
|
+
status: tableOk ? 'pass' : 'fail',
|
|
371
|
+
hint: `run: termdeck init --rumen (applies rumen migration 001)`,
|
|
372
|
+
});
|
|
373
|
+
// Only check the column when the table exists — otherwise the column
|
|
374
|
+
// line is redundant noise.
|
|
375
|
+
if (tableOk) {
|
|
376
|
+
rumen.push({
|
|
377
|
+
label: `${t}.created_at column`,
|
|
378
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.column(t, 'created_at'))) ? 'pass' : 'fail',
|
|
379
|
+
hint: `column drift detected — re-run: termdeck init --rumen`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Extensions — pg_cron / pg_net / pgvector / pg_trgm / pgcrypto
|
|
385
|
+
const exts = sections[4].checks;
|
|
386
|
+
const dashboardHint = (() => {
|
|
387
|
+
if (!secrets.SUPABASE_URL) return `enable in dashboard: Database → Extensions`;
|
|
388
|
+
const m = String(secrets.SUPABASE_URL).match(/https:\/\/([a-z0-9-]+)\.supabase\.(co|in)/i);
|
|
389
|
+
if (!m) return `enable in dashboard: Database → Extensions`;
|
|
390
|
+
return `enable: https://supabase.com/dashboard/project/${m[1]}/database/extensions`;
|
|
391
|
+
})();
|
|
392
|
+
for (const ext of ['pg_cron', 'pg_net', 'pg_trgm', 'pgcrypto']) {
|
|
393
|
+
exts.push({
|
|
394
|
+
label: `${ext}`,
|
|
395
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.extension(ext))) ? 'pass' : 'fail',
|
|
396
|
+
hint: dashboardHint,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
exts.push({
|
|
400
|
+
label: `pgvector (extname: vector)`,
|
|
401
|
+
status: (await checkPgVector(client)) ? 'pass' : 'fail',
|
|
402
|
+
hint: dashboardHint,
|
|
403
|
+
});
|
|
404
|
+
} finally {
|
|
405
|
+
if (ownsClient) {
|
|
406
|
+
try { await client.end(); } catch (_e) { /* ignore */ }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let passed = 0;
|
|
411
|
+
let total = 0;
|
|
412
|
+
for (const s of sections) {
|
|
413
|
+
for (const c of s.checks) {
|
|
414
|
+
total += 1;
|
|
415
|
+
if (c.status === 'pass') passed += 1;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
skipped: false,
|
|
420
|
+
sections,
|
|
421
|
+
passed,
|
|
422
|
+
total,
|
|
423
|
+
hasGaps: passed < total,
|
|
203
424
|
};
|
|
204
425
|
}
|
|
205
426
|
|
|
427
|
+
function renderSchemaResult(result, c) {
|
|
428
|
+
const out = [];
|
|
429
|
+
out.push('');
|
|
430
|
+
out.push(c.bold('TermDeck stack — Supabase schema check'));
|
|
431
|
+
out.push('');
|
|
432
|
+
if (result.skipped) {
|
|
433
|
+
out.push(` ${c.dim(`(skipped) ${result.reason}`)}`);
|
|
434
|
+
return out.join('\n');
|
|
435
|
+
}
|
|
436
|
+
if (result.connectError) {
|
|
437
|
+
out.push(` ${c.yellow('✗')} could not connect: ${result.connectError}`);
|
|
438
|
+
out.push(` ${c.dim('Check DATABASE_URL in ~/.termdeck/secrets.env, then re-run.')}`);
|
|
439
|
+
return out.join('\n');
|
|
440
|
+
}
|
|
441
|
+
for (const section of result.sections) {
|
|
442
|
+
out.push(` ${c.bold(section.name)}`);
|
|
443
|
+
if (section.checks.length === 0) {
|
|
444
|
+
out.push(` ${c.dim('(no checks ran)')}`);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
for (const check of section.checks) {
|
|
448
|
+
if (check.status === 'pass') {
|
|
449
|
+
out.push(` ${c.green('✓')} ${check.label}`);
|
|
450
|
+
} else {
|
|
451
|
+
out.push(` ${c.yellow('✗')} ${check.label}`);
|
|
452
|
+
if (check.hint) {
|
|
453
|
+
out.push(` ${c.dim(check.hint)}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
out.push('');
|
|
458
|
+
}
|
|
459
|
+
out.push(` ${result.passed}/${result.total} schema checks passed`);
|
|
460
|
+
return out.join('\n');
|
|
461
|
+
}
|
|
462
|
+
|
|
206
463
|
async function doctor(argv) {
|
|
207
464
|
const opts = parseArgv(argv);
|
|
208
465
|
|
|
@@ -223,9 +480,24 @@ async function doctor(argv) {
|
|
|
223
480
|
})
|
|
224
481
|
);
|
|
225
482
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
483
|
+
// Sprint 35 T3: schema check (skippable for tests / offline runs).
|
|
484
|
+
let schema = null;
|
|
485
|
+
if (!opts.noSchema) {
|
|
486
|
+
try {
|
|
487
|
+
schema = await module.exports._runSchemaCheck();
|
|
488
|
+
} catch (err) {
|
|
489
|
+
schema = {
|
|
490
|
+
skipped: false,
|
|
491
|
+
connectError: `unexpected error: ${err && err.message || err}`,
|
|
492
|
+
sections: [], passed: 0, total: 0, hasGaps: true,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Exit-code priority: any network failure → 2; any update available OR
|
|
498
|
+
// schema gap → 1; else 0. Computed after all rows resolve so a single
|
|
499
|
+
// transient failure doesn't mask real updates in stdout. A schema connect
|
|
500
|
+
// error counts as 2 (same class as a registry fetch failure).
|
|
229
501
|
let exitCode = 0;
|
|
230
502
|
for (const r of rows) {
|
|
231
503
|
if (r.status === STATUS.NETWORK_ERROR) {
|
|
@@ -234,9 +506,13 @@ async function doctor(argv) {
|
|
|
234
506
|
}
|
|
235
507
|
if (r.status === STATUS.UPDATE && exitCode < 1) exitCode = 1;
|
|
236
508
|
}
|
|
509
|
+
if (schema && schema.connectError && exitCode < 2) exitCode = 2;
|
|
510
|
+
if (schema && !schema.skipped && schema.hasGaps && exitCode < 1) exitCode = 1;
|
|
237
511
|
|
|
238
512
|
if (opts.json) {
|
|
239
|
-
|
|
513
|
+
const payload = { exitCode, rows };
|
|
514
|
+
if (schema) payload.schema = schema;
|
|
515
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
240
516
|
return exitCode;
|
|
241
517
|
}
|
|
242
518
|
|
|
@@ -244,6 +520,9 @@ async function doctor(argv) {
|
|
|
244
520
|
const c = makeColors(colorEnabled);
|
|
245
521
|
process.stdout.write(renderTable(rows, c) + '\n');
|
|
246
522
|
process.stdout.write(renderFooter(rows, exitCode) + '\n');
|
|
523
|
+
if (schema) {
|
|
524
|
+
process.stdout.write(renderSchemaResult(schema, c) + '\n');
|
|
525
|
+
}
|
|
247
526
|
return exitCode;
|
|
248
527
|
}
|
|
249
528
|
|
|
@@ -251,5 +530,6 @@ module.exports = doctor;
|
|
|
251
530
|
module.exports._detectInstalled = _detectInstalled;
|
|
252
531
|
module.exports._fetchLatest = _fetchLatest;
|
|
253
532
|
module.exports._compareSemver = _compareSemver;
|
|
533
|
+
module.exports._runSchemaCheck = _runSchemaCheck;
|
|
254
534
|
module.exports.STACK_PACKAGES = STACK_PACKAGES;
|
|
255
535
|
module.exports.STATUS = STATUS;
|
|
@@ -14,7 +14,92 @@
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const os = require('os');
|
|
17
|
-
const { execSync } = require('child_process');
|
|
17
|
+
const { exec, execSync } = require('child_process');
|
|
18
|
+
|
|
19
|
+
// Sprint 35 T4: stale-port reclaim. If the target port is held by a previous
|
|
20
|
+
// TermDeck instance (crash, runaway, prior `termdeck` left orphaned), kill it
|
|
21
|
+
// and continue. If it's held by something else, print a clear error and exit
|
|
22
|
+
// instead of letting `server.listen()` throw a generic EADDRINUSE.
|
|
23
|
+
// Lifted from scripts/start.sh:127–154 so npm-installed users (who never see
|
|
24
|
+
// start.sh) get the same recovery behavior.
|
|
25
|
+
function reclaimStalePort(port) {
|
|
26
|
+
let pids = [];
|
|
27
|
+
try {
|
|
28
|
+
const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: 'utf8' });
|
|
29
|
+
pids = out.split(/\s+/).filter(Boolean);
|
|
30
|
+
} catch (_e) {
|
|
31
|
+
// lsof exits 1 when no PIDs match — empty case, not an error.
|
|
32
|
+
pids = [];
|
|
33
|
+
}
|
|
34
|
+
if (pids.length === 0) {
|
|
35
|
+
// Linux fallback for systems without lsof
|
|
36
|
+
try {
|
|
37
|
+
const out = execSync(`fuser -n tcp ${port} 2>/dev/null`, { encoding: 'utf8' });
|
|
38
|
+
pids = out.split(/\s+/).filter((s) => /^\d+$/.test(s));
|
|
39
|
+
} catch (_e) { pids = []; }
|
|
40
|
+
}
|
|
41
|
+
if (pids.length === 0) return;
|
|
42
|
+
|
|
43
|
+
let isTermDeck = false;
|
|
44
|
+
for (const pid of pids) {
|
|
45
|
+
try {
|
|
46
|
+
const cmd = execSync(`ps -o command= -p ${pid}`, { encoding: 'utf8' });
|
|
47
|
+
if (/packages\/cli\/src\/index\.js/.test(cmd) || /termdeck/i.test(cmd)) {
|
|
48
|
+
isTermDeck = true;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
} catch (_e) { /* PID gone between lsof and ps — ignore */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (isTermDeck) {
|
|
55
|
+
// Liveness probe — never kill a TermDeck that's actively serving requests.
|
|
56
|
+
// A responsive /api/sessions means it's the orchestrator's live server, and
|
|
57
|
+
// killing it cascades to every child PTY. This was the actual root cause of
|
|
58
|
+
// four Sprint 36 server-kill incidents on 2026-04-27 (a sibling reclaimPort
|
|
59
|
+
// in stack.js had the same flaw and was already patched; this twin in the
|
|
60
|
+
// CLI entry was missed). Mirror of stack.js:isTermDeckLive.
|
|
61
|
+
let alreadyLive = false;
|
|
62
|
+
try {
|
|
63
|
+
const probe = execSync(`curl -sf -m 1.5 -o /dev/null -w "%{http_code}" http://127.0.0.1:${port}/api/sessions 2>/dev/null`, { encoding: 'utf8' });
|
|
64
|
+
if (probe.trim() === '200') alreadyLive = true;
|
|
65
|
+
} catch (_e) { /* curl missing or non-200 → treat as stale */ }
|
|
66
|
+
|
|
67
|
+
if (alreadyLive) {
|
|
68
|
+
console.log(` \x1b[2m[port] :${port} held by live TermDeck (PIDs: ${pids.join(' ')}) — not killing. Use --port <other> for a second instance.\x1b[0m`);
|
|
69
|
+
process.exit(0); // graceful exit; don't try to bind a port that's already serving
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(` \x1b[2m[port] Reclaiming :${port} from stale TermDeck (PIDs: ${pids.join(' ')})\x1b[0m`);
|
|
73
|
+
for (const pid of pids) {
|
|
74
|
+
try { process.kill(parseInt(pid, 10), 'SIGTERM'); } catch (_e) {}
|
|
75
|
+
}
|
|
76
|
+
try { execSync('sleep 1'); } catch (_e) {}
|
|
77
|
+
for (const pid of pids) {
|
|
78
|
+
try { process.kill(parseInt(pid, 10), 'SIGKILL'); } catch (_e) {}
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
console.error(`\n \x1b[31m✗ Port ${port} is in use by a non-TermDeck process (PIDs: ${pids.join(' ')})\x1b[0m`);
|
|
82
|
+
console.error(` \x1b[2mTry a different port: termdeck --port ${port + 1}\x1b[0m\n`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Sprint 35 T4: transcript-table-missing hint. If DATABASE_URL is set and
|
|
88
|
+
// psql is on PATH, probe for termdeck_transcripts. Fire-and-forget so a slow
|
|
89
|
+
// network round-trip to Supabase never blocks boot. Lifted from
|
|
90
|
+
// scripts/start.sh:309–313.
|
|
91
|
+
function checkTranscriptTableHint(databaseUrl) {
|
|
92
|
+
if (!databaseUrl) return;
|
|
93
|
+
try { execSync('command -v psql', { stdio: 'ignore' }); } catch (_e) { return; }
|
|
94
|
+
exec('psql "$DATABASE_URL" -c "SELECT 1 FROM termdeck_transcripts LIMIT 0"', {
|
|
95
|
+
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
96
|
+
timeout: 5000,
|
|
97
|
+
}, (err) => {
|
|
98
|
+
if (err) {
|
|
99
|
+
console.log(` \x1b[33m[hint]\x1b[0m Transcript backup table missing. Run: \x1b[1mtermdeck doctor\x1b[0m (or psql $DATABASE_URL -f config/transcript-migration.sql)`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
18
103
|
|
|
19
104
|
// Parse CLI args
|
|
20
105
|
const args = process.argv.slice(2);
|
|
@@ -130,7 +215,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
130
215
|
termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
|
|
131
216
|
termdeck init --rumen Deploy Tier 3 async learning (Rumen)
|
|
132
217
|
termdeck forge Generate Claude skills from memories (experimental)
|
|
133
|
-
termdeck doctor
|
|
218
|
+
termdeck doctor Diagnose stack — npm versions + Supabase schema (use --no-schema to skip the DB probe)
|
|
134
219
|
|
|
135
220
|
Keyboard shortcuts (in browser):
|
|
136
221
|
Ctrl+Shift+N Focus prompt bar
|
|
@@ -164,6 +249,7 @@ const firstRun = !fs.existsSync(path.join(os.homedir(), '.termdeck', 'config.yam
|
|
|
164
249
|
|
|
165
250
|
const config = loadConfig();
|
|
166
251
|
if (flags.port) config.port = flags.port;
|
|
252
|
+
else if (process.env.TERMDECK_PORT) config.port = parseInt(process.env.TERMDECK_PORT, 10);
|
|
167
253
|
if (flags.sessionLogs) {
|
|
168
254
|
config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
|
|
169
255
|
console.log('[cli] session logs enabled — writing to ~/.termdeck/sessions/ on panel exit');
|
|
@@ -174,6 +260,11 @@ const port = config.port || 3000;
|
|
|
174
260
|
const host = config.host || '127.0.0.1';
|
|
175
261
|
const url = `http://${host}:${port}`;
|
|
176
262
|
|
|
263
|
+
// Sprint 35 T4: reclaim the port if a previous TermDeck is squatting on it,
|
|
264
|
+
// or hard-stop with a useful hint if a non-TermDeck process holds it. Runs
|
|
265
|
+
// before server.listen() so EADDRINUSE never bubbles up.
|
|
266
|
+
reclaimStalePort(port);
|
|
267
|
+
|
|
177
268
|
// Bind guardrail: refuse non-loopback without auth token
|
|
178
269
|
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
179
270
|
if (!LOOPBACK.has(host)) {
|
|
@@ -189,17 +280,20 @@ if (!LOOPBACK.has(host)) {
|
|
|
189
280
|
// Sprint 25 T4: non-blocking nudge when RAG is configured but the Supabase MCP
|
|
190
281
|
// (T1's `@supabase/mcp-server-supabase` detection) isn't installed. Lazy-loads
|
|
191
282
|
// T1's module so Tier 1 users with no RAG never pay the require cost. Silent
|
|
192
|
-
// when RAG is off, when the MCP is detected, when
|
|
193
|
-
// declares a `supabase`
|
|
283
|
+
// when RAG is off, when the MCP is detected, when the MCP config (canonical
|
|
284
|
+
// ~/.claude.json or legacy ~/.claude/mcp.json) already declares a `supabase`
|
|
285
|
+
// server, or when anything below throws.
|
|
286
|
+
//
|
|
287
|
+
// Sprint 36 T2: read order is canonical → legacy. Claude Code v2.1.119+ reads
|
|
288
|
+
// only the canonical file; the legacy fallback covers users who haven't yet
|
|
289
|
+
// migrated and pinned other tooling to the old path.
|
|
194
290
|
async function checkSupabaseMcpHint(cfg) {
|
|
195
291
|
if (!cfg || !cfg.rag || cfg.rag.enabled !== true) return null;
|
|
196
292
|
try {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (parsed && parsed.mcpServers && parsed.mcpServers.supabase) return null;
|
|
202
|
-
} catch (_e) { /* malformed JSON — fall through and let detectMcp decide */ }
|
|
293
|
+
const { CLAUDE_MCP_PATH_CANONICAL, CLAUDE_MCP_PATH_LEGACY, readMcpServers } = require('./mcp-config');
|
|
294
|
+
for (const candidate of [CLAUDE_MCP_PATH_CANONICAL, CLAUDE_MCP_PATH_LEGACY]) {
|
|
295
|
+
const read = readMcpServers(candidate);
|
|
296
|
+
if (read.servers && read.servers.supabase) return null;
|
|
203
297
|
}
|
|
204
298
|
const { detectMcp } = require(path.join(__dirname, '..', '..', 'server', 'src', 'setup', 'supabase-mcp.js'));
|
|
205
299
|
const result = await detectMcp();
|
|
@@ -229,10 +323,23 @@ server.listen(port, host, async () => {
|
|
|
229
323
|
╚══════════════════════════════════════╝
|
|
230
324
|
`);
|
|
231
325
|
|
|
326
|
+
// Sprint 35 T4: RAG state line. Always-visible indicator of what mode the
|
|
327
|
+
// user is in — MCP-only (the new default after Sprint 35 T1) or full RAG
|
|
328
|
+
// writing to mnestra_*_memory tables. Dim line, single sentence.
|
|
329
|
+
if (config.rag && config.rag.enabled === true) {
|
|
330
|
+
console.log(` \x1b[2mRAG: on — events syncing to mnestra_session_memory / mnestra_project_memory / mnestra_developer_memory\x1b[0m\n`);
|
|
331
|
+
} else {
|
|
332
|
+
console.log(` \x1b[2mRAG: off (MCP-only mode) — toggle in dashboard at ${url}/#config to enable session/project/developer memory tables\x1b[0m\n`);
|
|
333
|
+
}
|
|
334
|
+
|
|
232
335
|
if (firstRun) {
|
|
233
336
|
console.log(" First run detected. Open http://localhost:3000 and click 'config' to set up.\n");
|
|
234
337
|
}
|
|
235
338
|
|
|
339
|
+
// Sprint 35 T4: probe Supabase for the transcript backup table; print a
|
|
340
|
+
// hint if it's missing. Non-blocking — the result lands after the banner.
|
|
341
|
+
checkTranscriptTableHint(process.env.DATABASE_URL || (config.rag && config.rag.databaseUrl));
|
|
342
|
+
|
|
236
343
|
// Run preflight health checks (non-blocking — warn but don't prevent startup)
|
|
237
344
|
runPreflight(config).then((result) => {
|
|
238
345
|
printHealthBanner(result);
|