@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 +1 -1
- package/package.json +1 -1
- package/packages/cli/src/init-mnestra.js +14 -1
- package/packages/cli/src/init-rumen.js +23 -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/setup/index.js +2 -1
- package/packages/server/src/setup/migrations.js +28 -11
- package/packages/server/src/setup/preconditions.js +370 -0
- 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"
|
|
@@ -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
|
-
//
|
|
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
|
|