@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.
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.6.4, 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.
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.6.7",
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"
@@ -43,7 +43,8 @@ const {
43
43
  yaml,
44
44
  supabaseUrl: urlHelper,
45
45
  migrations,
46
- pgRunner
46
+ pgRunner,
47
+ preconditions
47
48
  } = require(SETUP_DIR);
48
49
 
49
50
  const HELP = [
@@ -514,7 +515,19 @@ async function main(argv) {
514
515
  await checkExistingStore(client);
515
516
  await applyMigrations(client, false);
516
517
  writeYamlConfig(false);
518
+ // v0.6.9: post-write outcome verification. Confirms each migration's
519
+ // expected schema bits actually landed — including memory_items.
520
+ // source_session_id (the v0.6.5 column whose absence cascaded into
521
+ // Brad's Rumen failures). This is the test that, if it had existed
522
+ // before v0.6.5, would have caught the silent-shadow saga at install
523
+ // time instead of cron-tick time.
517
524
  if (!flags.skipVerify) {
525
+ const verify = await preconditions.verifyMnestraOutcomes({ secrets: { DATABASE_URL: inputs.databaseUrl }, _pgClient: client });
526
+ preconditions.printVerifyReport(verify, 'mnestra');
527
+ if (!verify.ok) {
528
+ printResumeHint();
529
+ return 8;
530
+ }
518
531
  const verified = await verifyStatus(client);
519
532
  if (!verified) {
520
533
  process.stdout.write(
@@ -34,7 +34,8 @@ const {
34
34
  dotenv,
35
35
  supabaseUrl: urlHelper,
36
36
  migrations,
37
- pgRunner
37
+ pgRunner,
38
+ preconditions
38
39
  } = require(SETUP_DIR);
39
40
 
40
41
  // Pinned fallback used only when the npm registry is unreachable. Bump this
@@ -593,6 +594,18 @@ async function main(argv) {
593
594
  }
594
595
  }
595
596
 
597
+ // v0.6.9: front-loaded precondition audit. Runs BEFORE link so we don't
598
+ // create state (function deploy, function secrets, schedule SQL) that the
599
+ // user would have to manually clean up if a precondition is missing. Every
600
+ // gap is reported in one pass with actionable hints. The audit class — env
601
+ // tokens, pg extensions, Vault secret — covers the v0.6.4 / v0.6.6 / v0.6.7
602
+ // / v0.6.9-equivalent failure modes that previously surfaced one-per-patch.
603
+ if (!flags.dryRun) {
604
+ const audit = await preconditions.auditRumenPreconditions({ secrets, env: process.env });
605
+ preconditions.printAuditReport(audit, 'rumen');
606
+ if (!audit.ok) return 10;
607
+ }
608
+
596
609
  if (!(await link(projectRef, flags.dryRun))) return 4;
597
610
 
598
611
  // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json now that
@@ -630,6 +643,15 @@ async function main(argv) {
630
643
  if (!(await testFunction(projectRef, secrets, flags.dryRun))) return 8;
631
644
  if (!flags.skipSchedule) {
632
645
  if (!(await applySchedule(projectRef, secrets, flags.dryRun))) return 9;
646
+ // v0.6.9: post-write outcome verification. Confirms cron.job has the
647
+ // active rumen-tick row. Doesn't poll for the first 15-min tick — that's
648
+ // too long for an interactive wizard — but tells the user the exact
649
+ // query to run after waiting if they want firing-confirmation.
650
+ if (!flags.dryRun) {
651
+ const verify = await preconditions.verifyRumenOutcomes({ secrets });
652
+ preconditions.printVerifyReport(verify, 'rumen');
653
+ if (!verify.ok) return 11;
654
+ }
633
655
  } else {
634
656
  process.stdout.write('→ Skipping pg_cron schedule (per --skip-schedule) ✓\n');
635
657
  }
@@ -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
- // stores the token in a cookie client-side and retries. API requests get a
13
- // JSON 401.
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/config', { credentials: 'same-origin' }).then(function(r) {
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