@jhizzard/termdeck 0.6.7 → 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.
@@ -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
- if (applied.theme && this.db) {
444
- this.db.prepare('UPDATE sessions SET theme = ? WHERE id = ?')
445
- .run(applied.theme, id);
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);
@@ -11,5 +11,6 @@ module.exports = {
11
11
  supabaseUrl: require('./supabase-url'),
12
12
  migrations: require('./migrations'),
13
13
  pgRunner: require('./pg-runner'),
14
- migrationRunner: require('./migration-runner')
14
+ migrationRunner: require('./migration-runner'),
15
+ preconditions: require('./preconditions')
15
16
  };
@@ -1,16 +1,28 @@
1
1
  // Discover the SQL migration files that ship bundled inside the TermDeck
2
- // package. Both init wizards call this — init-mnestra for the six Mnestra
2
+ // package. Both init wizards call this — init-mnestra for the seven Mnestra
3
3
  // migrations, init-rumen for the two Rumen migrations.
4
4
  //
5
5
  // The wizards intentionally do NOT fall back to a sibling `../../mnestra`
6
- // working copy. Resolution order:
6
+ // working copy. Resolution order (BUNDLED FIRST as of v0.6.8):
7
7
  //
8
8
  // 1. Files bundled at `packages/server/src/setup/mnestra-migrations/*.sql`
9
9
  // (this directory is covered by the root package.json `files` glob).
10
+ // ALWAYS preferred when it has any .sql files.
10
11
  // 2. Files at `node_modules/@jhizzard/mnestra/migrations/*.sql` if that
11
- // package is installed alongside TermDeck (future-proof path shipping
12
- // `@jhizzard/mnestra` as an optional peer would let us drop the bundled
13
- // copy).
12
+ // package is installed alongside TermDeck. Used ONLY as a fallback when
13
+ // the bundled directory is missing (e.g. someone deleted it manually).
14
+ //
15
+ // Why bundled-first: the meta-installer (`@jhizzard/termdeck-stack`) installs
16
+ // `@jhizzard/mnestra` globally as a peer. When TermDeck releases a new
17
+ // migration ahead of a Mnestra release, or when a user upgrades TermDeck
18
+ // without also upgrading the global Mnestra package, the previous loader
19
+ // silently picked the older Mnestra migration set. This bit Brad on
20
+ // 2026-04-26 with v0.6.5: he upgraded TermDeck, ran `init --mnestra --yes`,
21
+ // the wizard reported "6 migrations applied cleanly" (because his global
22
+ // mnestra@0.2.1 had only 6), and the bundled 007 — the one we shipped to
23
+ // fix his Rumen schema-drift issue — was never seen. Bundled is the source
24
+ // of truth TermDeck developed and tested against. Fall-back to node_modules
25
+ // is preserved as a safety valve, not a preference.
14
26
 
15
27
  const fs = require('fs');
16
28
  const path = require('path');
@@ -45,15 +57,20 @@ function tryNodeModules(packageName, migrationSubdir = 'migrations') {
45
57
  }
46
58
 
47
59
  function listMnestraMigrations() {
48
- const fromNm = tryNodeModules('@jhizzard/mnestra');
49
- if (fromNm.length > 0) return fromNm;
50
- return listBundled('mnestra-migrations');
60
+ // Bundled FIRST (v0.6.8+). See the file header for why — this prevents
61
+ // a stale `@jhizzard/mnestra` install in global node_modules from
62
+ // silently shadowing migrations TermDeck ships with the latest version.
63
+ const bundled = listBundled('mnestra-migrations');
64
+ if (bundled.length > 0) return bundled;
65
+ return tryNodeModules('@jhizzard/mnestra');
51
66
  }
52
67
 
53
68
  function listRumenMigrations() {
54
- const fromNm = tryNodeModules('@jhizzard/rumen');
55
- if (fromNm.length > 0) return fromNm;
56
- return listBundled(path.join('rumen', 'migrations'));
69
+ // Bundled FIRST (v0.6.8+). Same rationale as listMnestraMigrations —
70
+ // a stale global `@jhizzard/rumen` cannot shadow newer bundled migrations.
71
+ const bundled = listBundled(path.join('rumen', 'migrations'));
72
+ if (bundled.length > 0) return bundled;
73
+ return tryNodeModules('@jhizzard/rumen');
57
74
  }
58
75
 
59
76
  function rumenFunctionDir() {