@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 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.0` — 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.
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.5` — 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.
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.4.5, validated against 3,527 memories in one developer's production store. First full Rumen kickstart on 2026-04-15 processed 111 sessions into 111 insights in one pass. No multi-user data yet. Bug reports and issues welcome.
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.5.1",
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
+ };