Skip to content

Commit eb3ccdf

Browse files
feat(cli): add update notification after commands
Check the npm registry for newer versions (cached 24h) and print a non-intrusive notification box to stderr. Zero new dependencies — uses Node built-in https module and a minimal inline semver compare. Suppressed in CI, non-TTY, --json output, and when NO_UPDATE_CHECK=1 is set. Skipped for long-running commands (mcp, watch). Impact: 6 functions changed, 2 affected
1 parent 1aeea34 commit eb3ccdf

File tree

3 files changed

+453
-0
lines changed

3 files changed

+453
-0
lines changed

src/cli.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
registerRepo,
4040
unregisterRepo,
4141
} from './registry.js';
42+
import { checkForUpdates, printUpdateNotification } from './update-check.js';
4243
import { watchProject } from './watcher.js';
4344

4445
const __cliDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1'));
@@ -56,6 +57,17 @@ program
5657
.hook('preAction', (thisCommand) => {
5758
const opts = thisCommand.opts();
5859
if (opts.verbose) setVerbose(true);
60+
})
61+
.hook('postAction', async (_thisCommand, actionCommand) => {
62+
const name = actionCommand.name();
63+
if (name === 'mcp' || name === 'watch') return;
64+
if (actionCommand.opts().json) return;
65+
try {
66+
const result = await checkForUpdates(pkg.version);
67+
if (result) printUpdateNotification(result.current, result.latest);
68+
} catch {
69+
/* never break CLI */
70+
}
5971
});
6072

6173
/**

src/update-check.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import fs from 'node:fs';
2+
import https from 'node:https';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
6+
const CACHE_PATH =
7+
process.env.CODEGRAPH_UPDATE_CACHE_PATH ||
8+
path.join(os.homedir(), '.codegraph', 'update-check.json');
9+
10+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
11+
const FETCH_TIMEOUT_MS = 3000;
12+
const REGISTRY_URL = 'https://registry.npmjs.org/@optave/codegraph/latest';
13+
14+
/**
15+
* Minimal semver comparison. Returns -1, 0, or 1.
16+
* Only handles numeric x.y.z (no pre-release tags).
17+
*/
18+
export function semverCompare(a, b) {
19+
const pa = a.split('.').map(Number);
20+
const pb = b.split('.').map(Number);
21+
for (let i = 0; i < 3; i++) {
22+
const na = pa[i] || 0;
23+
const nb = pb[i] || 0;
24+
if (na < nb) return -1;
25+
if (na > nb) return 1;
26+
}
27+
return 0;
28+
}
29+
30+
/**
31+
* Load the cached update-check result from disk.
32+
* Returns null on missing or corrupt file.
33+
*/
34+
function loadCache(cachePath = CACHE_PATH) {
35+
try {
36+
const raw = fs.readFileSync(cachePath, 'utf-8');
37+
const data = JSON.parse(raw);
38+
if (!data || typeof data.lastCheckedAt !== 'number' || typeof data.latestVersion !== 'string') {
39+
return null;
40+
}
41+
return data;
42+
} catch {
43+
return null;
44+
}
45+
}
46+
47+
/**
48+
* Persist the cache to disk (atomic write via temp + rename).
49+
*/
50+
function saveCache(cache, cachePath = CACHE_PATH) {
51+
const dir = path.dirname(cachePath);
52+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
53+
54+
const tmp = `${cachePath}.tmp.${process.pid}`;
55+
fs.writeFileSync(tmp, JSON.stringify(cache), 'utf-8');
56+
fs.renameSync(tmp, cachePath);
57+
}
58+
59+
/**
60+
* Fetch the latest version string from the npm registry.
61+
* Returns the version string or null on failure.
62+
*/
63+
function fetchLatestVersion() {
64+
return new Promise((resolve) => {
65+
const req = https.get(
66+
REGISTRY_URL,
67+
{ timeout: FETCH_TIMEOUT_MS, headers: { Accept: 'application/json' } },
68+
(res) => {
69+
if (res.statusCode !== 200) {
70+
res.resume();
71+
resolve(null);
72+
return;
73+
}
74+
let body = '';
75+
res.setEncoding('utf-8');
76+
res.on('data', (chunk) => {
77+
body += chunk;
78+
});
79+
res.on('end', () => {
80+
try {
81+
const data = JSON.parse(body);
82+
resolve(typeof data.version === 'string' ? data.version : null);
83+
} catch {
84+
resolve(null);
85+
}
86+
});
87+
},
88+
);
89+
req.on('error', () => resolve(null));
90+
req.on('timeout', () => {
91+
req.destroy();
92+
resolve(null);
93+
});
94+
});
95+
}
96+
97+
/**
98+
* Check whether a newer version of codegraph is available.
99+
*
100+
* Returns `{ current, latest }` if an update is available, `null` otherwise.
101+
* Silently returns null on any error — never affects CLI operation.
102+
*
103+
* Options:
104+
* cachePath — override cache file location (for testing)
105+
* _fetchLatest — override the fetch function (for testing)
106+
*/
107+
export async function checkForUpdates(currentVersion, options = {}) {
108+
// Suppress in non-interactive / CI contexts
109+
if (process.env.CI) return null;
110+
if (process.env.NO_UPDATE_CHECK) return null;
111+
if (!process.stderr.isTTY) return null;
112+
113+
const cachePath = options.cachePath || CACHE_PATH;
114+
const fetchFn = options._fetchLatest || fetchLatestVersion;
115+
116+
try {
117+
const cache = loadCache(cachePath);
118+
119+
// Cache is fresh — use it
120+
if (cache && Date.now() - cache.lastCheckedAt < CACHE_TTL_MS) {
121+
if (semverCompare(currentVersion, cache.latestVersion) < 0) {
122+
return { current: currentVersion, latest: cache.latestVersion };
123+
}
124+
return null;
125+
}
126+
127+
// Cache is stale or missing — fetch
128+
const latest = await fetchFn();
129+
if (!latest) return null;
130+
131+
// Update cache regardless of result
132+
saveCache({ lastCheckedAt: Date.now(), latestVersion: latest }, cachePath);
133+
134+
if (semverCompare(currentVersion, latest) < 0) {
135+
return { current: currentVersion, latest };
136+
}
137+
return null;
138+
} catch {
139+
return null;
140+
}
141+
}
142+
143+
/**
144+
* Print a visible update notification box to stderr.
145+
*/
146+
export function printUpdateNotification(current, latest) {
147+
const msg1 = `Update available: ${current}${latest}`;
148+
const msg2 = 'Run `npm i -g @optave/codegraph` to update';
149+
const width = Math.max(msg1.length, msg2.length) + 4;
150+
151+
const top = `┌${'─'.repeat(width)}┐`;
152+
const bot = `└${'─'.repeat(width)}┘`;
153+
const pad1 = ' '.repeat(width - msg1.length - 2);
154+
const pad2 = ' '.repeat(width - msg2.length - 2);
155+
const line1 = `│ ${msg1}${pad1}│`;
156+
const line2 = `│ ${msg2}${pad2}│`;
157+
158+
process.stderr.write(`\n${top}\n${line1}\n${line2}\n${bot}\n\n`);
159+
}

0 commit comments

Comments
 (0)