@jhizzard/termdeck 0.5.1 → 0.6.1
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 +15 -3
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +255 -0
- package/packages/cli/src/index.js +53 -1
- package/packages/cli/src/update-check.js +156 -0
- package/packages/client/public/app.js +210 -1
- package/packages/server/src/index.js +220 -0
- package/packages/server/src/session.js +1 -1
- package/packages/server/src/setup/prompts.js +87 -14
- package/packages/server/src/setup/supabase-mcp.js +195 -0
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ What's excluded at this tier: **Flashback is silent**, the "Ask about this termi
|
|
|
81
81
|
|
|
82
82
|
### Tier 2 — Add Mnestra to light up Flashback
|
|
83
83
|
|
|
84
|
-
Mnestra is a separate npm package — `@jhizzard/mnestra@0.2.
|
|
84
|
+
Mnestra is a separate npm package — `@jhizzard/mnestra@0.2.1` — that ships a Postgres-backed persistent memory store with an MCP server, a webhook server, six search tools, and six SQL migrations. It can be consumed by TermDeck (for Flashback), by Claude Code (as an MCP memory layer), or by any tool that speaks the MCP protocol.
|
|
85
85
|
|
|
86
86
|
To enable Flashback in TermDeck:
|
|
87
87
|
|
|
@@ -147,7 +147,7 @@ Restart Claude Code. Six MCP tools appear: `memory_remember`, `memory_recall`, `
|
|
|
147
147
|
|
|
148
148
|
### Tier 3 — Add Rumen for async learning
|
|
149
149
|
|
|
150
|
-
Rumen is a separate npm package — `@jhizzard/rumen@0.4.
|
|
150
|
+
Rumen is a separate npm package — `@jhizzard/rumen@0.4.3` — that ships as a Supabase Edge Function designed to run on a 15-minute `pg_cron` schedule. It's the async reflection layer over Mnestra: it reads recent session memories, cross-references them with your entire historical corpus via hybrid search, synthesizes insights via Claude Haiku, and writes the results back into `rumen_insights` (a new table alongside Mnestra's `memory_items`). TermDeck's Flashback and Claude Code's `memory_recall` both automatically benefit because insights flow back into the same database.
|
|
151
151
|
|
|
152
152
|
**Rumen is live.** First full-kickstart run against a production Mnestra store on 2026-04-15 19:47 UTC: **111 sessions processed, 111 insights generated** in one pass. Insights surfaced patterns like "the error detection regex in Flashback misses `No such file or directory` — same class of blind spot as X" and "Practice sessions exist as a separate model but frontend components were built and never wired into the schedule view." The cognitive loop is closed.
|
|
153
153
|
|
|
@@ -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.6.1, validated against 4,590 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
|
|
|
@@ -226,6 +226,18 @@ For users who want more than `npx` — cloning from source, building a macOS `.a
|
|
|
226
226
|
|
|
227
227
|
---
|
|
228
228
|
|
|
229
|
+
## Staying current
|
|
230
|
+
|
|
231
|
+
TermDeck, Mnestra, and Rumen all evolve fast — Flashback recall quality, Mnestra search semantics, and Rumen synth quality each shift between minor releases. Running last month's stack means missing fixed bugs and degraded recall. Three layered ways to keep current:
|
|
232
|
+
|
|
233
|
+
1. **One command for the whole stack:** `npx @jhizzard/termdeck-stack` — re-runs the meta-installer, which detects what's already installed and updates anything that's behind. Idempotent: safe to run any time, won't touch `~/.termdeck/{config.yaml,secrets.env,termdeck.db}`.
|
|
234
|
+
2. **On demand:** `termdeck doctor` — prints a 4-row table (TermDeck, Mnestra, Rumen, termdeck-stack) of installed vs. latest versions with a status column. Exit 0 = all current, 1 = at least one update available, 2 = registry/network failure.
|
|
235
|
+
3. **Passive:** TermDeck prints a single yellow `[hint]` line on startup when an update is available. Rate-limited to once per 24 hours via `~/.termdeck/update-check.json`. Never blocks startup. Suppress with `TERMDECK_NO_UPDATE_CHECK=1` (the kill switch only mutes the startup hint — `termdeck doctor` still works on demand).
|
|
236
|
+
|
|
237
|
+
See **[docs/SEMVER-POLICY.md](docs/SEMVER-POLICY.md)** for what each kind of bump means across the four packages and how risky a given upgrade path is.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
229
241
|
## Related packages
|
|
230
242
|
|
|
231
243
|
- **[@jhizzard/mnestra](https://www.npmjs.com/package/@jhizzard/mnestra)** — persistent dev memory MCP server. pgvector + hybrid search + 3-layer progressive disclosure. Works standalone with any MCP client.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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"
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// `termdeck doctor` — Sprint 28 T2.
|
|
2
|
+
//
|
|
3
|
+
// Compares installed versions of the four TermDeck-stack packages against
|
|
4
|
+
// the npm registry's `dist-tags.latest` and prints a status table. Zero new
|
|
5
|
+
// deps — uses only node:https, node:child_process, and process.stdout.
|
|
6
|
+
//
|
|
7
|
+
// Module contract (per docs/sprint-28-update-signal/STATUS.md):
|
|
8
|
+
// module.exports = function doctor(argv): Promise<exitCode>
|
|
9
|
+
// 0 = all current
|
|
10
|
+
// 1 = at least one update available
|
|
11
|
+
// 2 = network/registry failure or unrecoverable error
|
|
12
|
+
//
|
|
13
|
+
// `_detectInstalled` and `_fetchLatest` are exposed as properties on the
|
|
14
|
+
// exported function so tests can monkey-patch the network/process surface
|
|
15
|
+
// without spinning up a real registry. The doctor body calls them via
|
|
16
|
+
// `module.exports.<name>` so monkey-patching takes effect at call time.
|
|
17
|
+
|
|
18
|
+
const https = require('https');
|
|
19
|
+
const { spawn } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const STACK_PACKAGES = [
|
|
22
|
+
'@jhizzard/termdeck',
|
|
23
|
+
'@jhizzard/mnestra',
|
|
24
|
+
'@jhizzard/rumen',
|
|
25
|
+
'@jhizzard/termdeck-stack',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const REGISTRY_TIMEOUT_MS = 5000;
|
|
29
|
+
const NPM_LS_TIMEOUT_MS = 8000;
|
|
30
|
+
|
|
31
|
+
const STATUS = {
|
|
32
|
+
UP_TO_DATE: 'up to date',
|
|
33
|
+
UPDATE: 'update available',
|
|
34
|
+
NOT_INSTALLED: 'not installed',
|
|
35
|
+
NETWORK_ERROR: 'network error',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function makeColors(enabled) {
|
|
39
|
+
if (!enabled) {
|
|
40
|
+
return { green: (s) => s, yellow: (s) => s, dim: (s) => s, bold: (s) => s };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
44
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
45
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
46
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Detect installed version via `npm ls -g <pkg> --depth=0 --json`. Returns
|
|
51
|
+
// the version string on success, or null on "not installed" / parse failure
|
|
52
|
+
// / npm-missing-from-PATH / timeout. Stderr noise (npm WARN lines) is
|
|
53
|
+
// silently dropped — those are not fatal.
|
|
54
|
+
async function _detectInstalled(pkg) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let child;
|
|
57
|
+
try {
|
|
58
|
+
child = spawn('npm', ['ls', '-g', pkg, '--depth=0', '--json'], {
|
|
59
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
return resolve(null);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let stdout = '';
|
|
66
|
+
let timedOut = false;
|
|
67
|
+
const t = setTimeout(() => {
|
|
68
|
+
timedOut = true;
|
|
69
|
+
try { child.kill('SIGKILL'); } catch (_e) { /* already gone */ }
|
|
70
|
+
}, NPM_LS_TIMEOUT_MS);
|
|
71
|
+
|
|
72
|
+
child.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
|
|
73
|
+
child.stderr.on('data', () => { /* discard npm WARNs */ });
|
|
74
|
+
child.on('error', () => { clearTimeout(t); resolve(null); });
|
|
75
|
+
child.on('close', () => {
|
|
76
|
+
clearTimeout(t);
|
|
77
|
+
if (timedOut) return resolve(null);
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(stdout);
|
|
80
|
+
const dep = parsed && parsed.dependencies && parsed.dependencies[pkg];
|
|
81
|
+
if (dep && typeof dep.version === 'string') return resolve(dep.version);
|
|
82
|
+
return resolve(null);
|
|
83
|
+
} catch {
|
|
84
|
+
return resolve(null);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Fetch the `latest` dist-tag for a package from the public npm registry.
|
|
91
|
+
// Returns the version string on success, or null on any failure (offline,
|
|
92
|
+
// non-200, malformed JSON, timeout). The caller treats null as a network
|
|
93
|
+
// error and bumps the exit code to 2.
|
|
94
|
+
async function _fetchLatest(pkg) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
// Encode `@scope/name` as `%40scope%2Fname` per the registry's URL spec.
|
|
97
|
+
const encoded = encodeURIComponent(pkg);
|
|
98
|
+
const url = `https://registry.npmjs.org/-/package/${encoded}/dist-tags`;
|
|
99
|
+
let settled = false;
|
|
100
|
+
const done = (v) => {
|
|
101
|
+
if (settled) return;
|
|
102
|
+
settled = true;
|
|
103
|
+
resolve(v);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let req;
|
|
107
|
+
try {
|
|
108
|
+
req = https.get(url, { timeout: REGISTRY_TIMEOUT_MS }, (res) => {
|
|
109
|
+
if (res.statusCode !== 200) {
|
|
110
|
+
res.resume();
|
|
111
|
+
return done(null);
|
|
112
|
+
}
|
|
113
|
+
let body = '';
|
|
114
|
+
res.setEncoding('utf8');
|
|
115
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
116
|
+
res.on('end', () => {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(body);
|
|
119
|
+
if (parsed && typeof parsed.latest === 'string') return done(parsed.latest);
|
|
120
|
+
return done(null);
|
|
121
|
+
} catch {
|
|
122
|
+
return done(null);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
res.on('error', () => done(null));
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
return done(null);
|
|
129
|
+
}
|
|
130
|
+
req.on('timeout', () => {
|
|
131
|
+
try { req.destroy(); } catch (_e) { /* already gone */ }
|
|
132
|
+
done(null);
|
|
133
|
+
});
|
|
134
|
+
req.on('error', () => done(null));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Lightweight semver compare — only looks at the first three numeric segments,
|
|
139
|
+
// which is all dist-tags.latest ever needs. Returns -1, 0, or 1.
|
|
140
|
+
function _compareSemver(a, b) {
|
|
141
|
+
const pa = String(a).split('.').map((s) => parseInt(s, 10) || 0);
|
|
142
|
+
const pb = String(b).split('.').map((s) => parseInt(s, 10) || 0);
|
|
143
|
+
for (let i = 0; i < 3; i++) {
|
|
144
|
+
const x = pa[i] || 0;
|
|
145
|
+
const y = pb[i] || 0;
|
|
146
|
+
if (x < y) return -1;
|
|
147
|
+
if (x > y) return 1;
|
|
148
|
+
}
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function classifyRow(installed, latest) {
|
|
153
|
+
if (latest === null) return STATUS.NETWORK_ERROR;
|
|
154
|
+
if (installed === null) return STATUS.NOT_INSTALLED;
|
|
155
|
+
return _compareSemver(installed, latest) < 0 ? STATUS.UPDATE : STATUS.UP_TO_DATE;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pad(s, n) {
|
|
159
|
+
const str = String(s);
|
|
160
|
+
return str.length >= n ? str : str + ' '.repeat(n - str.length);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderTable(rows, c) {
|
|
164
|
+
const out = [];
|
|
165
|
+
out.push(c.bold('TermDeck stack — version check'));
|
|
166
|
+
out.push('');
|
|
167
|
+
out.push(` ${pad('Package', 32)}${pad('Installed', 12)}${pad('Latest', 12)}Status`);
|
|
168
|
+
out.push(' ' + '─'.repeat(63));
|
|
169
|
+
for (const r of rows) {
|
|
170
|
+
const installedDisplay = r.installed === null ? '(none)' : r.installed;
|
|
171
|
+
const latestDisplay = r.latest === null ? '?' : r.latest;
|
|
172
|
+
let statusDisplay = r.status;
|
|
173
|
+
if (r.status === STATUS.UP_TO_DATE) statusDisplay = c.green(r.status);
|
|
174
|
+
else if (r.status === STATUS.UPDATE) statusDisplay = c.yellow(r.status);
|
|
175
|
+
else if (r.status === STATUS.NOT_INSTALLED) statusDisplay = c.dim(r.status);
|
|
176
|
+
else if (r.status === STATUS.NETWORK_ERROR) statusDisplay = c.dim(r.status);
|
|
177
|
+
out.push(` ${pad(r.package, 32)}${pad(installedDisplay, 12)}${pad(latestDisplay, 12)}${statusDisplay}`);
|
|
178
|
+
}
|
|
179
|
+
return out.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderFooter(rows, exitCode) {
|
|
183
|
+
if (exitCode === 2) {
|
|
184
|
+
const errors = rows.filter((r) => r.status === STATUS.NETWORK_ERROR).length;
|
|
185
|
+
return `\n Could not reach npm registry for ${errors} package${errors === 1 ? '' : 's'}. Try again later.`;
|
|
186
|
+
}
|
|
187
|
+
if (exitCode === 1) {
|
|
188
|
+
const updates = rows.filter((r) => r.status === STATUS.UPDATE).length;
|
|
189
|
+
return (
|
|
190
|
+
`\n ${updates} update${updates === 1 ? '' : 's'} available. ` +
|
|
191
|
+
`Run: npx @jhizzard/termdeck-stack\n` +
|
|
192
|
+
` Or upgrade individually: npm install -g @jhizzard/termdeck@latest`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return `\n All packages up to date.`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseArgv(argv) {
|
|
199
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
200
|
+
return {
|
|
201
|
+
json: args.includes('--json'),
|
|
202
|
+
noColor: args.includes('--no-color'),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function doctor(argv) {
|
|
207
|
+
const opts = parseArgv(argv);
|
|
208
|
+
|
|
209
|
+
// Resolve every package's installed + latest in parallel — independent
|
|
210
|
+
// network/process calls, no reason to serialize.
|
|
211
|
+
const rows = await Promise.all(
|
|
212
|
+
STACK_PACKAGES.map(async (pkg) => {
|
|
213
|
+
const [installed, latest] = await Promise.all([
|
|
214
|
+
module.exports._detectInstalled(pkg),
|
|
215
|
+
module.exports._fetchLatest(pkg),
|
|
216
|
+
]);
|
|
217
|
+
return {
|
|
218
|
+
package: pkg,
|
|
219
|
+
installed,
|
|
220
|
+
latest,
|
|
221
|
+
status: classifyRow(installed, latest),
|
|
222
|
+
};
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Exit-code priority: any network failure → 2; any update available → 1;
|
|
227
|
+
// else 0. Computed after all rows resolve so a single transient failure
|
|
228
|
+
// doesn't mask real updates in stdout.
|
|
229
|
+
let exitCode = 0;
|
|
230
|
+
for (const r of rows) {
|
|
231
|
+
if (r.status === STATUS.NETWORK_ERROR) {
|
|
232
|
+
exitCode = 2;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (r.status === STATUS.UPDATE && exitCode < 1) exitCode = 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (opts.json) {
|
|
239
|
+
process.stdout.write(JSON.stringify({ exitCode, rows }, null, 2) + '\n');
|
|
240
|
+
return exitCode;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const colorEnabled = !opts.noColor && process.stdout.isTTY === true;
|
|
244
|
+
const c = makeColors(colorEnabled);
|
|
245
|
+
process.stdout.write(renderTable(rows, c) + '\n');
|
|
246
|
+
process.stdout.write(renderFooter(rows, exitCode) + '\n');
|
|
247
|
+
return exitCode;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = doctor;
|
|
251
|
+
module.exports._detectInstalled = _detectInstalled;
|
|
252
|
+
module.exports._fetchLatest = _fetchLatest;
|
|
253
|
+
module.exports._compareSemver = _compareSemver;
|
|
254
|
+
module.exports.STACK_PACKAGES = STACK_PACKAGES;
|
|
255
|
+
module.exports.STATUS = STATUS;
|
|
@@ -76,12 +76,22 @@ if (args[0] === 'stack') {
|
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// `termdeck doctor` — Sprint 28: version-check the whole stack.
|
|
80
|
+
if (args[0] === 'doctor') {
|
|
81
|
+
const doctor = require(path.join(__dirname, 'doctor.js'));
|
|
82
|
+
doctor(args.slice(1)).then((code) => process.exit(code || 0)).catch((err) => {
|
|
83
|
+
console.error('[cli] doctor failed:', err && err.stack || err);
|
|
84
|
+
process.exit(2);
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
// Sprint 24: when `termdeck` is invoked with no subcommand AND a configured
|
|
80
90
|
// stack is detected, route through stack.js so users don't have to remember
|
|
81
91
|
// the `stack` subcommand. `--no-stack` is the explicit opt-out.
|
|
82
92
|
const { shouldAutoOrchestrate } = require(path.join(__dirname, 'auto-orchestrate.js'));
|
|
83
93
|
|
|
84
|
-
const KNOWN_SUBCOMMANDS = new Set(['init', 'forge', 'stack']);
|
|
94
|
+
const KNOWN_SUBCOMMANDS = new Set(['init', 'forge', 'stack', 'doctor']);
|
|
85
95
|
const noStackIdx = args.indexOf('--no-stack');
|
|
86
96
|
const noStackRequested = noStackIdx !== -1;
|
|
87
97
|
if (noStackRequested) args.splice(noStackIdx, 1); // strip before flag parsing
|
|
@@ -120,6 +130,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
120
130
|
termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
|
|
121
131
|
termdeck init --rumen Deploy Tier 3 async learning (Rumen)
|
|
122
132
|
termdeck forge Generate Claude skills from memories (experimental)
|
|
133
|
+
termdeck doctor Check whether the stack packages are up to date
|
|
123
134
|
|
|
124
135
|
Keyboard shortcuts (in browser):
|
|
125
136
|
Ctrl+Shift+N Focus prompt bar
|
|
@@ -175,6 +186,30 @@ if (!LOOPBACK.has(host)) {
|
|
|
175
186
|
}
|
|
176
187
|
}
|
|
177
188
|
|
|
189
|
+
// Sprint 25 T4: non-blocking nudge when RAG is configured but the Supabase MCP
|
|
190
|
+
// (T1's `@supabase/mcp-server-supabase` detection) isn't installed. Lazy-loads
|
|
191
|
+
// T1's module so Tier 1 users with no RAG never pay the require cost. Silent
|
|
192
|
+
// when RAG is off, when the MCP is detected, when ~/.claude/mcp.json already
|
|
193
|
+
// declares a `supabase` server, or when anything below throws.
|
|
194
|
+
async function checkSupabaseMcpHint(cfg) {
|
|
195
|
+
if (!cfg || !cfg.rag || cfg.rag.enabled !== true) return null;
|
|
196
|
+
try {
|
|
197
|
+
const claudeMcpPath = path.join(os.homedir(), '.claude', 'mcp.json');
|
|
198
|
+
if (fs.existsSync(claudeMcpPath)) {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(fs.readFileSync(claudeMcpPath, 'utf8'));
|
|
201
|
+
if (parsed && parsed.mcpServers && parsed.mcpServers.supabase) return null;
|
|
202
|
+
} catch (_e) { /* malformed JSON — fall through and let detectMcp decide */ }
|
|
203
|
+
}
|
|
204
|
+
const { detectMcp } = require(path.join(__dirname, '..', '..', 'server', 'src', 'setup', 'supabase-mcp.js'));
|
|
205
|
+
const result = await detectMcp();
|
|
206
|
+
if (result && result.available) return null;
|
|
207
|
+
return 'Supabase MCP not installed — wizard auto-fill unavailable. Install with: npx @jhizzard/termdeck-stack --tier 4';
|
|
208
|
+
} catch (_e) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
178
213
|
server.listen(port, host, async () => {
|
|
179
214
|
// Box inner width is 38 (count of ═ between ╔ and ╗). Center the title
|
|
180
215
|
// dynamically so the right border stays aligned regardless of version length.
|
|
@@ -205,6 +240,23 @@ server.listen(port, host, async () => {
|
|
|
205
240
|
console.error(` \x1b[31m[health] Preflight failed: ${err.message}\x1b[0m\n`);
|
|
206
241
|
});
|
|
207
242
|
|
|
243
|
+
// Sprint 28 T3: fire-and-forget update-check banner. Lazy-required so users
|
|
244
|
+
// who never start the server don't pay the require cost. Errors are swallowed
|
|
245
|
+
// inside the module; never blocks startup. Double-protected (try/catch around
|
|
246
|
+
// the require + swallowed .catch on the promise) so a missing or broken T3
|
|
247
|
+
// module can never break startup.
|
|
248
|
+
try {
|
|
249
|
+
const { checkAndPrintHint } = require(path.join(__dirname, 'update-check.js'));
|
|
250
|
+
checkAndPrintHint(config).catch(() => { /* swallowed inside the module too */ });
|
|
251
|
+
} catch (_e) { /* never block startup on a hint module load failure */ }
|
|
252
|
+
|
|
253
|
+
// Sprint 25 T4: Supabase MCP install nudge — runs alongside (not inside)
|
|
254
|
+
// runPreflight. Silent unless RAG is on AND the MCP is missing AND the
|
|
255
|
+
// user hasn't already declared it in ~/.claude/mcp.json.
|
|
256
|
+
checkSupabaseMcpHint(config).then((msg) => {
|
|
257
|
+
if (msg) console.log(` \x1b[33m[hint]\x1b[0m ${msg}`);
|
|
258
|
+
}).catch(() => { /* silent */ });
|
|
259
|
+
|
|
208
260
|
// Skip auto-open in Codespaces/CI (port forwarding handles it)
|
|
209
261
|
const isCodespaces = !!process.env.CODESPACES || !!process.env.GITHUB_CODESPACE_TOKEN;
|
|
210
262
|
const isCI = !!process.env.CI;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Sprint 28 T3 — passive startup update-check banner.
|
|
2
|
+
//
|
|
3
|
+
// Side-effect-only module. checkAndPrintHint() rate-limits a single GET against
|
|
4
|
+
// the npm registry's dist-tags endpoint to once every 24h via a JSON cache at
|
|
5
|
+
// ~/.termdeck/update-check.json, and prints one yellow [hint] line when a
|
|
6
|
+
// newer version of @jhizzard/termdeck is published. All failures are swallowed
|
|
7
|
+
// — startup must never block on this.
|
|
8
|
+
//
|
|
9
|
+
// Suppression order (any one short-circuits before any side effect):
|
|
10
|
+
// 1. process.env.TERMDECK_NO_UPDATE_CHECK === '1'
|
|
11
|
+
// 2. process.stdout.isTTY is falsy (CI, piped output)
|
|
12
|
+
// 3. cache exists and lastCheckedAt is within 24h of now
|
|
13
|
+
//
|
|
14
|
+
// Module contract per docs/sprint-28-update-signal/STATUS.md.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
|
|
22
|
+
const CACHE_VERSION = 1;
|
|
23
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
25
|
+
const PACKAGE_NAME = '@jhizzard/termdeck';
|
|
26
|
+
const REGISTRY_URL =
|
|
27
|
+
'https://registry.npmjs.org/-/package/@jhizzard%2Ftermdeck/dist-tags';
|
|
28
|
+
|
|
29
|
+
function defaultCachePath() {
|
|
30
|
+
return path.join(os.homedir(), '.termdeck', 'update-check.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The CLI ships inside the @jhizzard/termdeck package; the workspace root
|
|
34
|
+
// package.json is three levels up from packages/cli/src/. If anything goes
|
|
35
|
+
// wrong reading it (renamed file, broken JSON), return null and let the caller
|
|
36
|
+
// suppress.
|
|
37
|
+
function defaultPackageVersion() {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json'));
|
|
40
|
+
return pkg && pkg.version ? String(pkg.version) : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isValidSemver(v) {
|
|
47
|
+
return typeof v === 'string' && /^\d+\.\d+\.\d+/.test(v);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Three-way semver compare on [major, minor, patch]. Pre-release suffixes are
|
|
51
|
+
// ignored — "0.5.1-beta" and "0.5.1" compare equal. Good enough for the hint.
|
|
52
|
+
function compareSemver(a, b) {
|
|
53
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
54
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
55
|
+
for (let i = 0; i < 3; i++) {
|
|
56
|
+
const x = pa[i] || 0;
|
|
57
|
+
const y = pb[i] || 0;
|
|
58
|
+
if (x < y) return -1;
|
|
59
|
+
if (x > y) return 1;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readCache(cachePath) {
|
|
65
|
+
try {
|
|
66
|
+
const raw = fs.readFileSync(cachePath, 'utf8');
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
69
|
+
if (parsed.version !== CACHE_VERSION) return null;
|
|
70
|
+
return parsed;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeCache(cachePath, data) {
|
|
77
|
+
try {
|
|
78
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
79
|
+
fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), 'utf8');
|
|
80
|
+
} catch {
|
|
81
|
+
// Read-only home, ENOSPC, race with another process — all benign here.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchLatest(registryUrl) {
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(registryUrl, { signal: controller.signal });
|
|
90
|
+
if (!res || !res.ok) return null;
|
|
91
|
+
const json = await res.json();
|
|
92
|
+
const latest = json && json.latest;
|
|
93
|
+
return isValidSemver(latest) ? latest : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
} finally {
|
|
97
|
+
clearTimeout(timeout);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function checkAndPrintHint(_config, opts) {
|
|
102
|
+
try {
|
|
103
|
+
if (process.env.TERMDECK_NO_UPDATE_CHECK === '1') return;
|
|
104
|
+
if (!process.stdout || !process.stdout.isTTY) return;
|
|
105
|
+
|
|
106
|
+
const o = opts || {};
|
|
107
|
+
const now = o.now instanceof Date ? o.now : new Date();
|
|
108
|
+
const registryUrl = typeof o.registryUrl === 'string' ? o.registryUrl : REGISTRY_URL;
|
|
109
|
+
const cachePath = typeof o.cachePath === 'string' ? o.cachePath : defaultCachePath();
|
|
110
|
+
const installed = typeof o.packageVersion === 'string'
|
|
111
|
+
? o.packageVersion
|
|
112
|
+
: defaultPackageVersion();
|
|
113
|
+
|
|
114
|
+
if (!isValidSemver(installed)) return;
|
|
115
|
+
|
|
116
|
+
const cache = readCache(cachePath);
|
|
117
|
+
if (cache && cache.lastCheckedAt) {
|
|
118
|
+
const lastMs = Date.parse(cache.lastCheckedAt);
|
|
119
|
+
if (Number.isFinite(lastMs) && now.getTime() - lastMs < TTL_MS) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const latest = await fetchLatest(registryUrl);
|
|
125
|
+
if (!isValidSemver(latest)) return;
|
|
126
|
+
|
|
127
|
+
writeCache(cachePath, {
|
|
128
|
+
version: CACHE_VERSION,
|
|
129
|
+
lastCheckedAt: now.toISOString(),
|
|
130
|
+
lastSeenLatest: latest,
|
|
131
|
+
installedAtCheck: installed,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (compareSemver(installed, latest) >= 0) return;
|
|
135
|
+
|
|
136
|
+
console.log(
|
|
137
|
+
'\x1b[33m[hint]\x1b[0m TermDeck v' +
|
|
138
|
+
latest +
|
|
139
|
+
' available — upgrade with: npm install -g ' +
|
|
140
|
+
PACKAGE_NAME +
|
|
141
|
+
'@latest'
|
|
142
|
+
);
|
|
143
|
+
console.log(
|
|
144
|
+
' Or run `termdeck doctor` for the whole stack. ' +
|
|
145
|
+
'Suppress with TERMDECK_NO_UPDATE_CHECK=1.'
|
|
146
|
+
);
|
|
147
|
+
} catch {
|
|
148
|
+
// Never throw from a fire-and-forget hook.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
checkAndPrintHint,
|
|
154
|
+
// Exported for unit testing only — not part of the public contract.
|
|
155
|
+
_internal: { compareSemver, isValidSemver, readCache, writeCache, CACHE_VERSION, TTL_MS },
|
|
156
|
+
};
|