@jhizzard/termdeck 0.6.9 → 0.7.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/README.md +1 -1
- package/package.json +1 -1
- package/packages/client/public/app.js +17 -1
- package/packages/server/src/auth.js +79 -8
- package/packages/server/src/database.js +38 -2
- package/packages/server/src/health.js +377 -0
- package/packages/server/src/index.js +19 -0
- package/packages/server/src/session.js +58 -13
- package/packages/server/src/theme-resolver.js +77 -0
package/README.md
CHANGED
|
@@ -171,7 +171,7 @@ Honest limits, stated upfront so the skeptic has nothing to chase:
|
|
|
171
171
|
- **Not a replacement for reading docs.** It's the shortest path to a memory you already wrote. If the memory isn't there, the feature does nothing.
|
|
172
172
|
- **Not fully local by default.** Tier 2+ reaches out to Supabase for storage and OpenAI for embeddings. Tier 1 is fully local. A fully-local Tier 2 (local Postgres + local embeddings) is on the roadmap.
|
|
173
173
|
- **Not free forever.** Tier 2+ pays OpenAI fractions of a cent per memory for embeddings. Self-hosted embeddings via Ollama are on the roadmap.
|
|
174
|
-
- **Not proven at scale.** v0.
|
|
174
|
+
- **Not proven at scale.** v0.7.0, validated against 4,669 memories in one developer's production store. The Rumen 2026-04-19 re-kickstart processed 166 sessions into 166 insights in ~5.5 minutes. No multi-user data yet. Bug reports and issues welcome.
|
|
175
175
|
|
|
176
176
|
---
|
|
177
177
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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"
|
|
@@ -159,6 +159,7 @@
|
|
|
159
159
|
`<option value="${tid}" ${tid === meta.theme ? 'selected' : ''}>${t.label}</option>`
|
|
160
160
|
).join('')}
|
|
161
161
|
</select>
|
|
162
|
+
<a class="theme-reset" id="theme-reset-${id}" href="javascript:void(0)" onclick="resetTheme('${id}')" title="Revert to project / global default from config.yaml" style="font-size:11px;color:#7aa2f7;text-decoration:none;margin-left:4px;opacity:0.7;cursor:pointer">↺ default</a>
|
|
162
163
|
<button class="ctrl-btn" onclick="focusPanel('${id}')">focus</button>
|
|
163
164
|
<button class="ctrl-btn" onclick="halfPanel('${id}')">half</button>
|
|
164
165
|
<button class="ctrl-btn reply-toggle" id="reply-btn-${id}" onclick="toggleReplyForm('${id}')" title="Send text to another terminal">reply ▸</button>
|
|
@@ -1309,10 +1310,25 @@
|
|
|
1309
1310
|
const themeObj = getThemeObject(themeId);
|
|
1310
1311
|
entry.terminal.options.theme = themeObj;
|
|
1311
1312
|
|
|
1312
|
-
// Persist to server
|
|
1313
|
+
// Persist to server (writes to sessions.theme_override server-side)
|
|
1313
1314
|
api('PATCH', `/api/sessions/${id}`, { theme: themeId });
|
|
1314
1315
|
}
|
|
1315
1316
|
|
|
1317
|
+
// v0.7.0: clear sessions.theme_override server-side and snap the panel back
|
|
1318
|
+
// to whatever the resolver currently picks (project default → global default
|
|
1319
|
+
// → tokyo-night). Server returns the resolved value in the PATCH response so
|
|
1320
|
+
// the dropdown + xterm theme update without waiting for the 2s broadcast.
|
|
1321
|
+
async function resetTheme(id) {
|
|
1322
|
+
const entry = state.sessions.get(id);
|
|
1323
|
+
if (!entry) return;
|
|
1324
|
+
const updated = await api('PATCH', '/api/sessions/' + id, { theme: null });
|
|
1325
|
+
const resolved = updated && updated.meta && updated.meta.theme;
|
|
1326
|
+
if (!resolved) return;
|
|
1327
|
+
entry.terminal.options.theme = getThemeObject(resolved);
|
|
1328
|
+
const sel = document.getElementById('theme-' + id);
|
|
1329
|
+
if (sel && sel.value !== resolved) sel.value = resolved;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1316
1332
|
async function askAI(id, question) {
|
|
1317
1333
|
if (!question.trim()) return;
|
|
1318
1334
|
const entry = state.sessions.get(id);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Optional token authentication for TermDeck (Sprint 9 T3).
|
|
1
|
+
// Optional token authentication for TermDeck (Sprint 9 T3, extended Sprint 32).
|
|
2
2
|
//
|
|
3
3
|
// When no token is configured, auth is a no-op — `createAuthMiddleware()` returns
|
|
4
4
|
// null and callers skip wiring. When a token is configured (config.auth.token
|
|
@@ -9,8 +9,19 @@
|
|
|
9
9
|
// - termdeck_token=<token> cookie
|
|
10
10
|
//
|
|
11
11
|
// Browser requests without a valid token receive a minimal HTML login page that
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// POSTs to /api/auth/login. The login handler validates the token and issues a
|
|
13
|
+
// Set-Cookie header with HttpOnly + SameSite=Lax + Max-Age=2592000 (30 days),
|
|
14
|
+
// and Secure when the request was over HTTPS (direct or via X-Forwarded-Proto).
|
|
15
|
+
// API requests get a JSON 401.
|
|
16
|
+
//
|
|
17
|
+
// 30-day cookie trade-off (v0.7.0):
|
|
18
|
+
// TermDeck is intended as a local dev tool. Brad's 2026-04-26 feedback ("is
|
|
19
|
+
// there a way not to have to enter the token at each termdeck session?")
|
|
20
|
+
// showed the per-browser-session re-prompt was a real adoption tax. The
|
|
21
|
+
// compromise risk of a longer cookie is bounded by the local-only attack
|
|
22
|
+
// surface (HttpOnly blocks JS exfiltration, SameSite=Lax blocks CSRF on
|
|
23
|
+
// cross-site POSTs, Secure-when-HTTPS prevents plaintext sniffing on the
|
|
24
|
+
// reverse-proxy path documented in docs/DEPLOYMENT.md). UX wins.
|
|
14
25
|
|
|
15
26
|
function getConfiguredToken(config) {
|
|
16
27
|
const fromConfig = config && config.auth && config.auth.token;
|
|
@@ -63,6 +74,51 @@ function extractToken(req) {
|
|
|
63
74
|
return null;
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
// 30 days in seconds — see head-of-file trade-off note.
|
|
78
|
+
const COOKIE_MAX_AGE_SECONDS = 2592000;
|
|
79
|
+
|
|
80
|
+
// HTTPS detection for the Secure cookie flag. Express sets req.protocol from
|
|
81
|
+
// the socket; behind a reverse proxy with `app.set('trust proxy', ...)` it
|
|
82
|
+
// reads X-Forwarded-Proto. We also fall back to reading the header directly
|
|
83
|
+
// so the helper is correct even when trust-proxy isn't enabled.
|
|
84
|
+
function isSecureRequest(req) {
|
|
85
|
+
if (!req) return false;
|
|
86
|
+
if (req.protocol === 'https') return true;
|
|
87
|
+
const xfp = req.headers && req.headers['x-forwarded-proto'];
|
|
88
|
+
if (typeof xfp === 'string') {
|
|
89
|
+
const first = xfp.split(',')[0].trim().toLowerCase();
|
|
90
|
+
if (first === 'https') return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildAuthCookie(token, secure) {
|
|
96
|
+
const parts = [
|
|
97
|
+
`termdeck_token=${encodeURIComponent(token)}`,
|
|
98
|
+
'Path=/',
|
|
99
|
+
`Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
|
|
100
|
+
'HttpOnly',
|
|
101
|
+
'SameSite=Lax',
|
|
102
|
+
];
|
|
103
|
+
if (secure) parts.push('Secure');
|
|
104
|
+
return parts.join('; ');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function writeAuthCookie(req, res, token) {
|
|
108
|
+
res.setHeader('Set-Cookie', buildAuthCookie(token, isSecureRequest(req)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function handleLogin(req, res, configuredToken) {
|
|
112
|
+
const provided =
|
|
113
|
+
(req.body && typeof req.body.token === 'string' && req.body.token.trim()) ||
|
|
114
|
+
extractToken(req);
|
|
115
|
+
if (!provided || provided !== configuredToken) {
|
|
116
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
117
|
+
}
|
|
118
|
+
writeAuthCookie(req, res, configuredToken);
|
|
119
|
+
return res.status(200).json({ ok: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
66
122
|
function loginPage() {
|
|
67
123
|
return `<!doctype html>
|
|
68
124
|
<html lang="en">
|
|
@@ -104,12 +160,14 @@ function submitToken(e) {
|
|
|
104
160
|
err.textContent = '';
|
|
105
161
|
var t = document.getElementById('t').value.trim();
|
|
106
162
|
if (!t) return false;
|
|
107
|
-
document.cookie = 'termdeck_token=' + encodeURIComponent(t) +
|
|
108
|
-
'; path=/; SameSite=Strict; Max-Age=2592000';
|
|
109
163
|
var next = new URLSearchParams(location.search).get('next') || '/';
|
|
110
|
-
fetch('/api/
|
|
164
|
+
fetch('/api/auth/login', {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
credentials: 'same-origin',
|
|
168
|
+
body: JSON.stringify({ token: t })
|
|
169
|
+
}).then(function(r) {
|
|
111
170
|
if (r.ok) { location.href = next; return; }
|
|
112
|
-
document.cookie = 'termdeck_token=; path=/; Max-Age=0';
|
|
113
171
|
err.textContent = 'Invalid token.';
|
|
114
172
|
}).catch(function() {
|
|
115
173
|
err.textContent = 'Network error.';
|
|
@@ -130,6 +188,13 @@ function createAuthMiddleware(config) {
|
|
|
130
188
|
// without being handed a secret.
|
|
131
189
|
if (req.path === '/api/health') return next();
|
|
132
190
|
|
|
191
|
+
// The login endpoint validates credentials and issues the cookie itself —
|
|
192
|
+
// it must run before the token-required check below or browsers could
|
|
193
|
+
// never reach it.
|
|
194
|
+
if (req.method === 'POST' && req.path === '/api/auth/login') {
|
|
195
|
+
return handleLogin(req, res, token);
|
|
196
|
+
}
|
|
197
|
+
|
|
133
198
|
const provided = extractToken(req);
|
|
134
199
|
if (provided && provided === token) return next();
|
|
135
200
|
|
|
@@ -168,5 +233,11 @@ module.exports = {
|
|
|
168
233
|
verifyWebSocketUpgrade,
|
|
169
234
|
getConfiguredToken,
|
|
170
235
|
hasAuth,
|
|
171
|
-
loginPage
|
|
236
|
+
loginPage,
|
|
237
|
+
// Exposed for tests + future reuse by other server modules.
|
|
238
|
+
buildAuthCookie,
|
|
239
|
+
isSecureRequest,
|
|
240
|
+
writeAuthCookie,
|
|
241
|
+
handleLogin,
|
|
242
|
+
COOKIE_MAX_AGE_SECONDS,
|
|
172
243
|
};
|
|
@@ -27,7 +27,8 @@ function initDatabase(Database) {
|
|
|
27
27
|
exited_at TEXT,
|
|
28
28
|
exit_code INTEGER,
|
|
29
29
|
reason TEXT,
|
|
30
|
-
theme TEXT DEFAULT 'tokyo-night'
|
|
30
|
+
theme TEXT DEFAULT 'tokyo-night',
|
|
31
|
+
theme_override TEXT
|
|
31
32
|
);
|
|
32
33
|
|
|
33
34
|
CREATE TABLE IF NOT EXISTS command_history (
|
|
@@ -54,7 +55,6 @@ function initDatabase(Database) {
|
|
|
54
55
|
CREATE TABLE IF NOT EXISTS projects (
|
|
55
56
|
name TEXT PRIMARY KEY,
|
|
56
57
|
path TEXT NOT NULL,
|
|
57
|
-
default_theme TEXT DEFAULT 'tokyo-night',
|
|
58
58
|
default_command TEXT DEFAULT 'bash',
|
|
59
59
|
rag_namespace TEXT,
|
|
60
60
|
created_at TEXT NOT NULL
|
|
@@ -79,6 +79,42 @@ function initDatabase(Database) {
|
|
|
79
79
|
console.warn('[db] command_history.source migration failed:', err.message);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Migration (v0.7.0): add sessions.theme_override and backfill from theme.
|
|
83
|
+
// The backfill runs exactly once — only when the column is being added — so
|
|
84
|
+
// post-migration sessions created with theme_override=NULL stay un-overridden
|
|
85
|
+
// and pick up config.yaml changes via the theme-resolver. Pre-v0.7.0 rows
|
|
86
|
+
// had `theme` written by the dropdown PATCH, so treating them as user-set is
|
|
87
|
+
// the correct preservation of customizations.
|
|
88
|
+
// (SQLite has no `ADD COLUMN IF NOT EXISTS`, so we PRAGMA-check first — same
|
|
89
|
+
// pattern as the command_history.source migration above.)
|
|
90
|
+
try {
|
|
91
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
92
|
+
const hasOverride = cols.some((c) => c.name === 'theme_override');
|
|
93
|
+
if (!hasOverride) {
|
|
94
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN theme_override TEXT`);
|
|
95
|
+
const updated = db.prepare(`UPDATE sessions SET theme_override = theme WHERE theme IS NOT NULL`).run();
|
|
96
|
+
console.log(`[db] Migrated sessions: added 'theme_override' column, backfilled ${updated.changes} row(s) from theme`);
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn('[db] sessions.theme_override migration failed:', err.message);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Migration (v0.7.0): drop the dead projects.default_theme column. It was
|
|
103
|
+
// CREATE'd in early v0.1 but was never read or written by any code path
|
|
104
|
+
// (see Sprint 32 T1 grep). Removing it eliminates a latent contract-drift
|
|
105
|
+
// trap. SQLite supports DROP COLUMN since 3.35 (well below better-sqlite3's
|
|
106
|
+
// bundled version). No `IF EXISTS` in SQLite, so PRAGMA-check first.
|
|
107
|
+
try {
|
|
108
|
+
const cols = db.prepare(`PRAGMA table_info(projects)`).all();
|
|
109
|
+
const hasDefaultTheme = cols.some((c) => c.name === 'default_theme');
|
|
110
|
+
if (hasDefaultTheme) {
|
|
111
|
+
db.exec(`ALTER TABLE projects DROP COLUMN default_theme`);
|
|
112
|
+
console.log("[db] Migrated projects: dropped dead 'default_theme' column");
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn('[db] projects.default_theme drop migration failed:', err.message);
|
|
116
|
+
}
|
|
117
|
+
|
|
82
118
|
return db;
|
|
83
119
|
}
|
|
84
120
|
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Runtime health snapshot — the v0.7.0 sibling of v0.6.9's
|
|
2
|
+
// auditPreconditions/verifyOutcomes (packages/server/src/setup/preconditions.js).
|
|
3
|
+
//
|
|
4
|
+
// Why this module exists
|
|
5
|
+
// ──────────────────────
|
|
6
|
+
// v0.6.9 closed the install-time precondition-drift class with a front-loaded
|
|
7
|
+
// audit and a post-write verify inside the wizards. That defends the moment
|
|
8
|
+
// the user runs `termdeck init --mnestra` / `--rumen`. It does not defend
|
|
9
|
+
// later — when an extension gets disabled in the Supabase dashboard, when a
|
|
10
|
+
// migration loader picks up a stale set on a subsequent reinstall, or when
|
|
11
|
+
// the cron job is paused without anyone noticing. `/api/health/full` answers
|
|
12
|
+
// "is this install actually healthy *right now*?" by re-running the same
|
|
13
|
+
// SELECTs at runtime instead of install-time.
|
|
14
|
+
//
|
|
15
|
+
// Required vs warn checks
|
|
16
|
+
// ───────────────────────
|
|
17
|
+
// Required checks (sqlite, mnestra-pg, memory-items-col, pg-cron-ext,
|
|
18
|
+
// pg-net-ext, vault-secret, cron-job-active) drive the overall `ok` flag —
|
|
19
|
+
// any non-pass marks the report unhealthy and the route returns 503. Warn
|
|
20
|
+
// checks (mnestra-webhook, rumen-pool) are best-effort: a failure surfaces
|
|
21
|
+
// as `warn` with detail, but does not flip `ok`.
|
|
22
|
+
//
|
|
23
|
+
// Caching
|
|
24
|
+
// ───────
|
|
25
|
+
// Reports cached in module scope for 30s. `getFullHealth(config, { refresh: true })`
|
|
26
|
+
// or the `?refresh=1` query param bypasses the cache. The TTL is reflected
|
|
27
|
+
// in the response's `ttlSeconds` field so polling clients can self-pace.
|
|
28
|
+
//
|
|
29
|
+
// Error handling
|
|
30
|
+
// ──────────────
|
|
31
|
+
// Every check is wrapped: any unexpected error downgrades that single check
|
|
32
|
+
// to `fail` (or `warn` for warn-checks) with the error message in `detail`.
|
|
33
|
+
// `getFullHealth()` always resolves with a structured report — never throws.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const http = require('http');
|
|
38
|
+
const https = require('https');
|
|
39
|
+
|
|
40
|
+
const TTL_SECONDS = 30;
|
|
41
|
+
const TTL_MS = TTL_SECONDS * 1000;
|
|
42
|
+
|
|
43
|
+
const REQUIRED_CHECKS = new Set([
|
|
44
|
+
'sqlite',
|
|
45
|
+
'mnestra-pg',
|
|
46
|
+
'memory-items-col',
|
|
47
|
+
'pg-cron-ext',
|
|
48
|
+
'pg-net-ext',
|
|
49
|
+
'vault-secret',
|
|
50
|
+
'cron-job-active'
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
let _cache = null;
|
|
54
|
+
let _cachedAt = 0;
|
|
55
|
+
|
|
56
|
+
// ── SQLite check ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function checkSqlite(db) {
|
|
59
|
+
if (!db) {
|
|
60
|
+
return { name: 'sqlite', status: 'fail', detail: 'better-sqlite3 not initialized' };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const row = db.prepare('SELECT 1 AS ok').get();
|
|
64
|
+
if (row && row.ok === 1) return { name: 'sqlite', status: 'pass' };
|
|
65
|
+
return { name: 'sqlite', status: 'fail', detail: 'SELECT 1 returned unexpected result' };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { name: 'sqlite', status: 'fail', detail: err && err.message ? err.message : String(err) };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Postgres-side checks ────────────────────────────────────────────────────
|
|
72
|
+
//
|
|
73
|
+
// These mirror auditRumenPreconditions + verifyMnestraOutcomes + verifyRumenOutcomes
|
|
74
|
+
// from setup/preconditions.js. We deliberately copy the SQL rather than
|
|
75
|
+
// import the helper — preconditions.js is owned by other lanes and concurrent
|
|
76
|
+
// edits to share code would step on T1/T4. The queries are small and stable.
|
|
77
|
+
|
|
78
|
+
async function safeQueryRow(client, sql) {
|
|
79
|
+
try {
|
|
80
|
+
const r = await client.query(sql);
|
|
81
|
+
if (r.rows && r.rows.length > 0 && r.rows[0].ok) return { ok: true };
|
|
82
|
+
return { ok: false };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { error: err && err.message ? err.message : String(err) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function safeQueryRows(client, sql) {
|
|
89
|
+
try {
|
|
90
|
+
const r = await client.query(sql);
|
|
91
|
+
return { rows: r.rows || [] };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { error: err && err.message ? err.message : String(err) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function openPgClient(databaseUrl) {
|
|
98
|
+
if (!databaseUrl) return null;
|
|
99
|
+
let pgRunner;
|
|
100
|
+
try { pgRunner = require('./setup/pg-runner'); } catch (_e) { return null; }
|
|
101
|
+
try { return await pgRunner.connect(databaseUrl); } catch (_e) { return null; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runPgChecks({ databaseUrl, _pgClient }) {
|
|
105
|
+
const checks = [];
|
|
106
|
+
const client = _pgClient || (await openPgClient(databaseUrl));
|
|
107
|
+
const owned = !_pgClient;
|
|
108
|
+
|
|
109
|
+
if (!client) {
|
|
110
|
+
checks.push({
|
|
111
|
+
name: 'mnestra-pg',
|
|
112
|
+
status: 'fail',
|
|
113
|
+
detail: databaseUrl
|
|
114
|
+
? 'could not connect to Postgres using DATABASE_URL'
|
|
115
|
+
: 'DATABASE_URL not configured (set in ~/.termdeck/secrets.env)'
|
|
116
|
+
});
|
|
117
|
+
// Dependent checks can't run without a connection — surface them as
|
|
118
|
+
// fail rather than silently skipping so the report is complete.
|
|
119
|
+
for (const name of ['memory-items-col', 'pg-cron-ext', 'pg-net-ext', 'vault-secret', 'cron-job-active']) {
|
|
120
|
+
checks.push({ name, status: 'fail', detail: 'pg unavailable' });
|
|
121
|
+
}
|
|
122
|
+
return checks;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const ping = await safeQueryRow(client, 'SELECT 1 AS ok');
|
|
127
|
+
if (ping.error) {
|
|
128
|
+
checks.push({ name: 'mnestra-pg', status: 'fail', detail: ping.error });
|
|
129
|
+
} else if (!ping.ok) {
|
|
130
|
+
checks.push({ name: 'mnestra-pg', status: 'fail', detail: 'SELECT 1 returned no row' });
|
|
131
|
+
} else {
|
|
132
|
+
checks.push({ name: 'mnestra-pg', status: 'pass' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// memory_items.source_session_id — the v0.6.5 column from Brad's saga.
|
|
136
|
+
const col = await safeQueryRow(client,
|
|
137
|
+
"SELECT 1 AS ok FROM information_schema.columns " +
|
|
138
|
+
"WHERE table_schema = 'public' AND table_name = 'memory_items' AND column_name = 'source_session_id'");
|
|
139
|
+
if (col.error) {
|
|
140
|
+
checks.push({ name: 'memory-items-col', status: 'fail', detail: col.error });
|
|
141
|
+
} else if (!col.ok) {
|
|
142
|
+
checks.push({
|
|
143
|
+
name: 'memory-items-col',
|
|
144
|
+
status: 'fail',
|
|
145
|
+
detail:
|
|
146
|
+
'memory_items.source_session_id missing — re-run termdeck init --mnestra --yes ' +
|
|
147
|
+
'(if loader picked up a stale set, first: npm cache clean --force && npm i -g @jhizzard/termdeck@latest)'
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
checks.push({ name: 'memory-items-col', status: 'pass' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cron = await safeQueryRow(client,
|
|
154
|
+
"SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_cron'");
|
|
155
|
+
if (cron.error) {
|
|
156
|
+
checks.push({ name: 'pg-cron-ext', status: 'fail', detail: cron.error });
|
|
157
|
+
} else if (!cron.ok) {
|
|
158
|
+
checks.push({
|
|
159
|
+
name: 'pg-cron-ext',
|
|
160
|
+
status: 'fail',
|
|
161
|
+
detail: 'extension not enabled — Supabase dashboard → Database → Extensions → pg_cron'
|
|
162
|
+
});
|
|
163
|
+
} else {
|
|
164
|
+
checks.push({ name: 'pg-cron-ext', status: 'pass' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const net = await safeQueryRow(client,
|
|
168
|
+
"SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_net'");
|
|
169
|
+
if (net.error) {
|
|
170
|
+
checks.push({ name: 'pg-net-ext', status: 'fail', detail: net.error });
|
|
171
|
+
} else if (!net.ok) {
|
|
172
|
+
checks.push({
|
|
173
|
+
name: 'pg-net-ext',
|
|
174
|
+
status: 'fail',
|
|
175
|
+
detail: 'extension not enabled — Supabase dashboard → Database → Extensions → pg_net'
|
|
176
|
+
});
|
|
177
|
+
} else {
|
|
178
|
+
checks.push({ name: 'pg-net-ext', status: 'pass' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const vault = await safeQueryRow(client,
|
|
182
|
+
"SELECT 1 AS ok FROM vault.decrypted_secrets WHERE name = 'rumen_service_role_key'");
|
|
183
|
+
if (vault.error) {
|
|
184
|
+
checks.push({
|
|
185
|
+
name: 'vault-secret',
|
|
186
|
+
status: 'fail',
|
|
187
|
+
detail: `vault.decrypted_secrets unreadable — ${vault.error}`
|
|
188
|
+
});
|
|
189
|
+
} else if (!vault.ok) {
|
|
190
|
+
checks.push({
|
|
191
|
+
name: 'vault-secret',
|
|
192
|
+
status: 'fail',
|
|
193
|
+
detail: 'rumen_service_role_key missing — Supabase dashboard → Project Settings → Vault → New secret'
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
checks.push({ name: 'vault-secret', status: 'pass' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const job = await safeQueryRows(client,
|
|
200
|
+
"SELECT active FROM cron.job WHERE jobname = 'rumen-tick'");
|
|
201
|
+
if (job.error) {
|
|
202
|
+
checks.push({ name: 'cron-job-active', status: 'fail', detail: `cron.job unreadable — ${job.error}` });
|
|
203
|
+
} else if (!job.rows || job.rows.length === 0) {
|
|
204
|
+
checks.push({
|
|
205
|
+
name: 'cron-job-active',
|
|
206
|
+
status: 'fail',
|
|
207
|
+
detail: 'rumen-tick row not found — re-run `termdeck init --rumen`'
|
|
208
|
+
});
|
|
209
|
+
} else if (!job.rows[0].active) {
|
|
210
|
+
checks.push({
|
|
211
|
+
name: 'cron-job-active',
|
|
212
|
+
status: 'fail',
|
|
213
|
+
detail:
|
|
214
|
+
"rumen-tick paused — SELECT cron.alter_job((SELECT jobid FROM cron.job WHERE jobname = 'rumen-tick'), active := true);"
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
checks.push({ name: 'cron-job-active', status: 'pass' });
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (owned) {
|
|
221
|
+
try { await client.end(); } catch (_e) { /* ignore */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return checks;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Warn checks ─────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
function httpReachable(url, timeoutMs = 2000) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
const mod = url.startsWith('https:') ? https : http;
|
|
233
|
+
let req;
|
|
234
|
+
try {
|
|
235
|
+
req = mod.get(url, { timeout: timeoutMs }, (res) => {
|
|
236
|
+
const ok = res.statusCode != null && res.statusCode < 500;
|
|
237
|
+
res.resume();
|
|
238
|
+
resolve({ ok, status: res.statusCode });
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
resolve({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
req.on('error', (err) => resolve({ ok: false, error: err && err.message ? err.message : String(err) }));
|
|
245
|
+
req.on('timeout', () => { try { req.destroy(); } catch (_e) { /* gone */ } resolve({ ok: false, error: 'timeout' }); });
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function checkMnestraWebhook(config, options) {
|
|
250
|
+
if (options && typeof options._mnestraWebhookProbe === 'function') {
|
|
251
|
+
try {
|
|
252
|
+
const r = await options._mnestraWebhookProbe();
|
|
253
|
+
if (r && r.ok) return { name: 'mnestra-webhook', status: 'pass' };
|
|
254
|
+
return { name: 'mnestra-webhook', status: 'warn', detail: (r && r.detail) || 'unreachable' };
|
|
255
|
+
} catch (err) {
|
|
256
|
+
return { name: 'mnestra-webhook', status: 'warn', detail: err && err.message ? err.message : String(err) };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const rag = (config && config.rag) || {};
|
|
260
|
+
if (!rag.mnestraWebhookUrl) {
|
|
261
|
+
return { name: 'mnestra-webhook', status: 'warn', detail: 'webhook URL not configured' };
|
|
262
|
+
}
|
|
263
|
+
const healthUrl = String(rag.mnestraWebhookUrl).replace(/\/mnestra\/?$/, '/healthz');
|
|
264
|
+
const r = await httpReachable(healthUrl, 2000);
|
|
265
|
+
if (r.ok) return { name: 'mnestra-webhook', status: 'pass' };
|
|
266
|
+
return {
|
|
267
|
+
name: 'mnestra-webhook',
|
|
268
|
+
status: 'warn',
|
|
269
|
+
detail: r.error ? `unreachable — ${r.error}` : `HTTP ${r.status || '???'}`
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function checkRumenPool(config, options) {
|
|
274
|
+
if (options && typeof options._rumenPoolProbe === 'function') {
|
|
275
|
+
try {
|
|
276
|
+
const r = await options._rumenPoolProbe();
|
|
277
|
+
if (r && r.ok) return { name: 'rumen-pool', status: 'pass' };
|
|
278
|
+
return { name: 'rumen-pool', status: 'warn', detail: (r && r.detail) || 'unreachable (best-effort)' };
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
let pg;
|
|
284
|
+
try { pg = require('pg'); } catch (_e) { pg = null; }
|
|
285
|
+
if (!pg) return { name: 'rumen-pool', status: 'warn', detail: 'pg module not installed' };
|
|
286
|
+
|
|
287
|
+
const dbUrl = (config && config.rag && config.rag.databaseUrl) || process.env.DATABASE_URL;
|
|
288
|
+
if (!dbUrl) return { name: 'rumen-pool', status: 'warn', detail: 'DATABASE_URL not set' };
|
|
289
|
+
|
|
290
|
+
const pool = new pg.Pool({ connectionString: dbUrl, max: 1, connectionTimeoutMillis: 3000 });
|
|
291
|
+
try {
|
|
292
|
+
const res = await pool.query('SELECT 1 AS ok');
|
|
293
|
+
if (res.rows[0] && res.rows[0].ok === 1) return { name: 'rumen-pool', status: 'pass' };
|
|
294
|
+
return { name: 'rumen-pool', status: 'warn', detail: 'SELECT 1 returned unexpected result' };
|
|
295
|
+
} catch (err) {
|
|
296
|
+
return { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) };
|
|
297
|
+
} finally {
|
|
298
|
+
try { await pool.end(); } catch (_e) { /* ignore */ }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Aggregator ──────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
async function getFullHealth(config = {}, options = {}) {
|
|
305
|
+
const refresh = !!options.refresh;
|
|
306
|
+
if (!refresh && _cache && (Date.now() - _cachedAt) < TTL_MS) {
|
|
307
|
+
return _cache;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const db = options.db || (config && config._db) || null;
|
|
311
|
+
const databaseUrl =
|
|
312
|
+
options.databaseUrl ||
|
|
313
|
+
(config && config.rag && config.rag.databaseUrl) ||
|
|
314
|
+
process.env.DATABASE_URL ||
|
|
315
|
+
null;
|
|
316
|
+
|
|
317
|
+
const checks = [];
|
|
318
|
+
|
|
319
|
+
// 1. SQLite (sync — small DB, no risk of blocking)
|
|
320
|
+
try { checks.push(checkSqlite(db)); }
|
|
321
|
+
catch (err) { checks.push({ name: 'sqlite', status: 'fail', detail: err && err.message ? err.message : String(err) }); }
|
|
322
|
+
|
|
323
|
+
// 2-7. Postgres-side suite
|
|
324
|
+
let pgChecks;
|
|
325
|
+
try { pgChecks = await runPgChecks({ databaseUrl, _pgClient: options._pgClient }); }
|
|
326
|
+
catch (err) {
|
|
327
|
+
pgChecks = [{
|
|
328
|
+
name: 'mnestra-pg',
|
|
329
|
+
status: 'fail',
|
|
330
|
+
detail: err && err.message ? err.message : String(err)
|
|
331
|
+
}];
|
|
332
|
+
for (const name of ['memory-items-col', 'pg-cron-ext', 'pg-net-ext', 'vault-secret', 'cron-job-active']) {
|
|
333
|
+
pgChecks.push({ name, status: 'fail', detail: 'pg suite aborted' });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const c of pgChecks) checks.push(c);
|
|
337
|
+
|
|
338
|
+
// 8. Mnestra webhook (warn)
|
|
339
|
+
let webhook;
|
|
340
|
+
try { webhook = await checkMnestraWebhook(config, options); }
|
|
341
|
+
catch (err) { webhook = { name: 'mnestra-webhook', status: 'warn', detail: err && err.message ? err.message : String(err) }; }
|
|
342
|
+
checks.push(webhook);
|
|
343
|
+
|
|
344
|
+
// 9. Rumen pool (warn)
|
|
345
|
+
let pool;
|
|
346
|
+
try { pool = await checkRumenPool(config, options); }
|
|
347
|
+
catch (err) { pool = { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) }; }
|
|
348
|
+
checks.push(pool);
|
|
349
|
+
|
|
350
|
+
const ok = checks
|
|
351
|
+
.filter((c) => REQUIRED_CHECKS.has(c.name))
|
|
352
|
+
.every((c) => c.status === 'pass');
|
|
353
|
+
|
|
354
|
+
const report = {
|
|
355
|
+
ok,
|
|
356
|
+
timestamp: new Date().toISOString(),
|
|
357
|
+
ttlSeconds: TTL_SECONDS,
|
|
358
|
+
checks
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
_cache = report;
|
|
362
|
+
_cachedAt = Date.now();
|
|
363
|
+
|
|
364
|
+
return report;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Test seam — drop the cache between cases so call-count assertions hold.
|
|
368
|
+
function _resetCache() {
|
|
369
|
+
_cache = null;
|
|
370
|
+
_cachedAt = 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
module.exports = {
|
|
374
|
+
getFullHealth,
|
|
375
|
+
REQUIRED_CHECKS,
|
|
376
|
+
_resetCache
|
|
377
|
+
};
|
|
@@ -59,6 +59,7 @@ const { createBridge } = require('./mnestra-bridge');
|
|
|
59
59
|
const { writeSessionLog } = require('./session-logger');
|
|
60
60
|
const { TranscriptWriter } = require('./transcripts');
|
|
61
61
|
const { createHealthHandler, runPreflight } = require('./preflight');
|
|
62
|
+
const { getFullHealth } = require('./health');
|
|
62
63
|
const { themes, statusColors } = require('./themes');
|
|
63
64
|
const { loadConfig, addProject } = require('./config');
|
|
64
65
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
@@ -146,6 +147,24 @@ function createServer(config) {
|
|
|
146
147
|
// or scope the response to a minimal {status, version} payload.
|
|
147
148
|
app.get('/api/health', createHealthHandler(config));
|
|
148
149
|
|
|
150
|
+
// GET /api/health/full - v0.7.0 runtime health snapshot (Sprint 32 T3)
|
|
151
|
+
// Mirrors the install-time auditPreconditions/verifyOutcomes pattern from
|
|
152
|
+
// v0.6.9 at runtime: re-runs the same SELECTs against pg_extension,
|
|
153
|
+
// vault.decrypted_secrets, cron.job, and information_schema.columns so a
|
|
154
|
+
// post-install drift (extension toggled off, schedule paused, stale loader
|
|
155
|
+
// shadow) is observable without a re-install. Cached 30s; pass ?refresh=1
|
|
156
|
+
// to bypass. Required checks drive the response status (200 ok / 503 fail);
|
|
157
|
+
// warn checks (mnestra-webhook, rumen-pool) never flip ok.
|
|
158
|
+
app.get('/api/health/full', async (req, res) => {
|
|
159
|
+
const refresh = req.query.refresh === '1' || req.query.refresh === 'true';
|
|
160
|
+
try {
|
|
161
|
+
const report = await getFullHealth(config, { refresh, db });
|
|
162
|
+
res.status(report.ok ? 200 : 503).json(report);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
res.status(500).json({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
149
168
|
// GET /api/setup - setup wizard tier status (Sprint 19 T1)
|
|
150
169
|
// Reuses preflight checks (mnestra_reachable, rumen_recent) and pairs them
|
|
151
170
|
// with filesystem + config signals to classify which of the 4 TermDeck tiers
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
// Session manager - PTY lifecycle, metadata tracking, output analysis
|
|
2
|
-
// Each session wraps a node-pty instance with rich metadata
|
|
2
|
+
// Each session wraps a node-pty instance with rich metadata.
|
|
3
|
+
//
|
|
4
|
+
// v0.7.0 theme model (see theme-resolver.js): meta.theme is a *getter* that
|
|
5
|
+
// resolves at read time from { session.theme_override → project default →
|
|
6
|
+
// global default → 'tokyo-night' }. The session no longer snapshots a theme
|
|
7
|
+
// string into meta at construction. This is the getter form (vs. the
|
|
8
|
+
// alternative of recomputing-and-writing on every metadata broadcast) because
|
|
9
|
+
// it makes the resolution path explicit at every read site and keeps the
|
|
10
|
+
// metadata broadcast in index.js untouched — `s.meta.theme` already returns
|
|
11
|
+
// the right thing whenever index.js dereferences it.
|
|
3
12
|
|
|
4
13
|
const { v4: uuidv4 } = require('uuid');
|
|
5
14
|
const os = require('os');
|
|
6
15
|
const path = require('path');
|
|
16
|
+
const { resolveTheme } = require('./theme-resolver');
|
|
7
17
|
|
|
8
18
|
// Strip ANSI escape codes for pattern matching
|
|
9
19
|
function stripAnsi(str) {
|
|
@@ -69,6 +79,24 @@ class Session {
|
|
|
69
79
|
this.pty = null;
|
|
70
80
|
this.ws = null;
|
|
71
81
|
|
|
82
|
+
// v0.7.0: theme_override is the user's explicit dropdown choice (NULL = no
|
|
83
|
+
// override → resolveTheme falls through to project / global default at
|
|
84
|
+
// read time). The legacy `options.theme` argument is intentionally NOT
|
|
85
|
+
// stored as an override here — it arrives from index.js already
|
|
86
|
+
// pre-defaulted (`theme || project.defaultTheme || config.defaultTheme`),
|
|
87
|
+
// which means we can no longer distinguish a real user choice from the
|
|
88
|
+
// server-filled default at the create-call boundary. Real overrides come
|
|
89
|
+
// through PATCH /api/sessions/:id (see updateMeta below).
|
|
90
|
+
this.theme_override = options.themeOverride != null ? options.themeOverride : null;
|
|
91
|
+
|
|
92
|
+
// Project (mirrored from meta for theme-resolver convenience — resolveTheme
|
|
93
|
+
// reads `session.project`, not `session.meta.project`).
|
|
94
|
+
Object.defineProperty(this, 'project', {
|
|
95
|
+
get: () => this.meta.project,
|
|
96
|
+
enumerable: false,
|
|
97
|
+
configurable: true
|
|
98
|
+
});
|
|
99
|
+
|
|
72
100
|
// Metadata
|
|
73
101
|
this.meta = {
|
|
74
102
|
type: options.type || 'shell', // shell, claude-code, gemini, python-server, one-shot
|
|
@@ -89,14 +117,23 @@ class Session {
|
|
|
89
117
|
exitCode: null,
|
|
90
118
|
childProcesses: [],
|
|
91
119
|
|
|
92
|
-
// Theme
|
|
93
|
-
theme: options.theme || 'tokyo-night',
|
|
94
|
-
|
|
95
120
|
// RAG
|
|
96
121
|
ragEnabled: options.ragEnabled !== false,
|
|
97
122
|
ragEvents: [] // buffer before flush
|
|
98
123
|
};
|
|
99
124
|
|
|
125
|
+
// theme is render-time resolved (see header comment + theme-resolver.js).
|
|
126
|
+
// Reads call resolveTheme(this, undefined) which falls back to the cached
|
|
127
|
+
// ~/.termdeck/config.yaml. Writes route to theme_override so PATCH/UPDATE
|
|
128
|
+
// through `session.meta.theme = 'dracula'` persists correctly. Setting
|
|
129
|
+
// null clears the override and reverts to the config-derived default.
|
|
130
|
+
Object.defineProperty(this.meta, 'theme', {
|
|
131
|
+
get: () => resolveTheme(this),
|
|
132
|
+
set: (val) => { this.theme_override = val == null ? null : val; },
|
|
133
|
+
enumerable: true,
|
|
134
|
+
configurable: true
|
|
135
|
+
});
|
|
136
|
+
|
|
100
137
|
// Transcript chunk counter — monotonic per session for deterministic replay
|
|
101
138
|
this.transcriptChunkIndex = 0;
|
|
102
139
|
|
|
@@ -383,11 +420,16 @@ class SessionManager {
|
|
|
383
420
|
const session = new Session(options);
|
|
384
421
|
this.sessions.set(session.id, session);
|
|
385
422
|
|
|
386
|
-
// Persist to SQLite
|
|
423
|
+
// Persist to SQLite. Both columns get written:
|
|
424
|
+
// theme — legacy; the resolved value at create time, kept for
|
|
425
|
+
// backward-compat with any consumer that still reads it.
|
|
426
|
+
// Not authoritative post-v0.7.0.
|
|
427
|
+
// theme_override — v0.7.0 authoritative column. NULL on create — only
|
|
428
|
+
// a PATCH from the dropdown sets it (see updateMeta).
|
|
387
429
|
if (this.db) {
|
|
388
430
|
this.db.prepare(`
|
|
389
|
-
INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme)
|
|
390
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
431
|
+
INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme, theme_override)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
391
433
|
`).run(
|
|
392
434
|
session.id,
|
|
393
435
|
session.meta.type,
|
|
@@ -397,7 +439,8 @@ class SessionManager {
|
|
|
397
439
|
session.meta.cwd,
|
|
398
440
|
session.meta.createdAt,
|
|
399
441
|
session.meta.reason,
|
|
400
|
-
session.meta.theme
|
|
442
|
+
session.meta.theme, // resolved snapshot, legacy column
|
|
443
|
+
session.theme_override // NULL by default
|
|
401
444
|
);
|
|
402
445
|
}
|
|
403
446
|
|
|
@@ -435,14 +478,16 @@ class SessionManager {
|
|
|
435
478
|
const applied = {};
|
|
436
479
|
for (const [key, val] of Object.entries(updates)) {
|
|
437
480
|
if (!SessionManager.PATCHABLE_META_FIELDS.has(key)) continue;
|
|
438
|
-
session.meta[key] = val;
|
|
481
|
+
session.meta[key] = val; // theme assignment routes through the setter → theme_override
|
|
439
482
|
applied[key] = val;
|
|
440
483
|
}
|
|
441
484
|
|
|
442
|
-
// Persist theme changes to SQLite
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
485
|
+
// Persist theme changes to SQLite. v0.7.0: writes go to theme_override
|
|
486
|
+
// (the authoritative column); a `theme: null` PATCH clears the override
|
|
487
|
+
// and reverts the session to the config-derived default at next read.
|
|
488
|
+
if ('theme' in applied && this.db) {
|
|
489
|
+
this.db.prepare('UPDATE sessions SET theme_override = ? WHERE id = ?')
|
|
490
|
+
.run(applied.theme == null ? null : applied.theme, id);
|
|
446
491
|
}
|
|
447
492
|
|
|
448
493
|
this._emit('session:updated', session);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Theme resolver — render-time theme resolution (v0.7.0).
|
|
2
|
+
//
|
|
3
|
+
// Pre-v0.7.0, the theme each terminal rendered with was snapshotted at session
|
|
4
|
+
// creation time and written into sessions.theme. Editing ~/.termdeck/config.yaml
|
|
5
|
+
// and restarting the server did not change existing terminals' themes — they
|
|
6
|
+
// kept whatever the wizard had written into SQLite at create time.
|
|
7
|
+
// (Brad, 2026-04-26: "ignores changes to config.yaml and is stuck in tokyo night.")
|
|
8
|
+
//
|
|
9
|
+
// v0.7.0 separates "the user explicitly chose a theme for this terminal" from
|
|
10
|
+
// "fall back to whatever the config currently says is the default":
|
|
11
|
+
//
|
|
12
|
+
// sessions.theme_override → user's explicit choice via the dropdown (NULL = no override)
|
|
13
|
+
// config.projects[p].defaultTheme → per-project default in YAML
|
|
14
|
+
// config.defaultTheme → global default in YAML
|
|
15
|
+
// 'tokyo-night' → hard-coded floor
|
|
16
|
+
//
|
|
17
|
+
// resolveTheme(session, config) walks that ladder. Called at *read time*
|
|
18
|
+
// (every metadata broadcast), so editing config.yaml + restarting the server
|
|
19
|
+
// — or just editing it, since getCurrentConfig() invalidates on file mtime —
|
|
20
|
+
// propagates to all un-overridden sessions immediately.
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
|
|
26
|
+
let _configCache = { mtimeMs: 0, value: null, frozen: false };
|
|
27
|
+
|
|
28
|
+
function getCurrentConfig() {
|
|
29
|
+
// Used as the second-arg fallback when the caller doesn't pass an explicit
|
|
30
|
+
// config (production path: meta.theme getter inside Session). Keyed off the
|
|
31
|
+
// YAML file's mtime so a Brad-style "edit config.yaml + restart" — and even
|
|
32
|
+
// an edit *without* restart — picks up the new defaults on the next read.
|
|
33
|
+
if (_configCache.frozen) return _configCache.value;
|
|
34
|
+
try {
|
|
35
|
+
const cfgPath = path.join(os.homedir(), '.termdeck', 'config.yaml');
|
|
36
|
+
const stat = fs.statSync(cfgPath);
|
|
37
|
+
if (_configCache.value && stat.mtimeMs === _configCache.mtimeMs) {
|
|
38
|
+
return _configCache.value;
|
|
39
|
+
}
|
|
40
|
+
const { loadConfig } = require('./config');
|
|
41
|
+
_configCache = { mtimeMs: stat.mtimeMs, value: loadConfig(), frozen: false };
|
|
42
|
+
return _configCache.value;
|
|
43
|
+
} catch {
|
|
44
|
+
return _configCache.value || {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveTheme(session, config) {
|
|
49
|
+
const cfg = config || getCurrentConfig();
|
|
50
|
+
const project = session && session.project;
|
|
51
|
+
const projectDefault = cfg && cfg.projects && project && cfg.projects[project] && cfg.projects[project].defaultTheme;
|
|
52
|
+
return (
|
|
53
|
+
(session && session.theme_override) ||
|
|
54
|
+
projectDefault ||
|
|
55
|
+
(cfg && cfg.defaultTheme) ||
|
|
56
|
+
'tokyo-night'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Test seam: freezes the cache so the disk-mtime check is bypassed. Lets unit
|
|
61
|
+
// tests inject a config without an actual ~/.termdeck/config.yaml on disk (or
|
|
62
|
+
// while the user *does* have one — important: the dev box typically has a real
|
|
63
|
+
// config.yaml that would otherwise leak into tests). Production never calls this.
|
|
64
|
+
function _setCachedConfigForTests(value) {
|
|
65
|
+
_configCache = { mtimeMs: Number.MAX_SAFE_INTEGER, value, frozen: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _resetCacheForTests() {
|
|
69
|
+
_configCache = { mtimeMs: 0, value: null, frozen: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
resolveTheme,
|
|
74
|
+
getCurrentConfig,
|
|
75
|
+
_setCachedConfigForTests,
|
|
76
|
+
_resetCacheForTests
|
|
77
|
+
};
|