Skip to content

Commit b41ee2b

Browse files
authored
feat(update-check): show extension update notice on exit (#1236)
* feat(update-check): show extension update notice on exit The CLI exit hook already prints "Update available" when a newer @jackwener/opencli is on npm. Extension updates were only surfaced inside `opencli doctor`, so users running normal browser commands had no signal that the Chrome extension was out of date. Solution piggybacks on the existing 24h background fetch: - Daemon writes the live extensionVersion + lastSeenAt into the shared cache on every hello handshake (rare event, one fs.writeFileSync). - CLI exit hook reads the cache it already loads and prints an extra extension notice when a newer release is available and the cache is fresh (<7d). - writeCache becomes a read-merge-write so the daemon's currentExtensionVersion and the CLI's npm latestVersion don't clobber each other. Net cost on the CLI hot path: zero new I/O, zero new daemon contact. The notice formatter is split into a pure helper (buildUpdateNotices) so the staleness window, equality, and combined-notice cases are unit-tested without touching disk or stderr. * fix(update-check): tolerate partial cache when daemon writes first Self-review caught a TypeError path: if the daemon's hello handler runs `recordExtensionVersion` before the CLI's npm fetch ever populated the cache, the resulting cache file has only `currentExtensionVersion` + `extensionLastSeenAt` and no `latestVersion`. The next CLI run then fed `undefined` into `isNewer`, which calls `.replace(...)` on it. - Mark `lastCheck` and `latestVersion` optional in the cache schema (the merge pattern means either side may write first). - Guard the CLI notice on `cache.latestVersion` being defined before comparing. - Guard `checkForUpdateBackground`'s 24h short-circuit on `lastCheck` being defined. - Add a test for the daemon-only cache case.
1 parent 6c07723 commit b41ee2b

3 files changed

Lines changed: 183 additions & 18 deletions

File tree

src/daemon.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { EXIT_CODES } from './errors.js';
2727
import { log } from './logger.js';
2828
import { PKG_VERSION } from './version.js';
2929
import { DEFAULT_CONTEXT_ID } from './browser/profile.js';
30+
import { recordExtensionVersion } from './update-check.js';
3031

3132
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
3233

@@ -390,6 +391,7 @@ wss.on('connection', (ws: WebSocket) => {
390391
connection.extensionVersion = typeof msg.version === 'string' ? msg.version : null;
391392
connection.extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null;
392393
connection.lastSeenAt = Date.now();
394+
if (connection.extensionVersion) recordExtensionVersion(connection.extensionVersion);
393395
log.info(`[daemon] Extension profile connected: ${connection.contextId}`);
394396
return;
395397
}

src/update-check.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest';
2-
import { _extractLatestExtensionVersionFromReleases as extractLatestExtensionVersionFromReleases } from './update-check.js';
2+
import {
3+
_extractLatestExtensionVersionFromReleases as extractLatestExtensionVersionFromReleases,
4+
_buildUpdateNotices as buildUpdateNotices,
5+
_EXTENSION_STALE_MS as EXTENSION_STALE_MS,
6+
} from './update-check.js';
37

48
describe('extractLatestExtensionVersionFromReleases', () => {
59
it('reads the extension version from a versioned asset on a normal CLI release', () => {
@@ -38,3 +42,96 @@ describe('extractLatestExtensionVersionFromReleases', () => {
3842
).toBeUndefined();
3943
});
4044
});
45+
46+
describe('buildUpdateNotices', () => {
47+
const now = 1_700_000_000_000;
48+
49+
it('returns nothing when cache is empty', () => {
50+
expect(buildUpdateNotices({ cliVersion: '1.0.0', cache: null, now })).toEqual({});
51+
});
52+
53+
it('emits a CLI notice when registry version is newer', () => {
54+
const lines = buildUpdateNotices({
55+
cliVersion: '1.0.0',
56+
cache: { lastCheck: now, latestVersion: '1.0.1' },
57+
now,
58+
});
59+
expect(lines.cli).toContain('v1.0.0 → v1.0.1');
60+
expect(lines.extension).toBeUndefined();
61+
});
62+
63+
it('emits an extension notice when current ext is older and cache is fresh', () => {
64+
const lines = buildUpdateNotices({
65+
cliVersion: '1.0.0',
66+
cache: {
67+
lastCheck: now,
68+
latestVersion: '1.0.0',
69+
latestExtensionVersion: '2.1.0',
70+
currentExtensionVersion: '2.0.0',
71+
extensionLastSeenAt: now - 60_000,
72+
},
73+
now,
74+
});
75+
expect(lines.cli).toBeUndefined();
76+
expect(lines.extension).toContain('v2.0.0 → v2.1.0');
77+
});
78+
79+
it('skips the extension notice when lastSeenAt is older than the stale window', () => {
80+
const lines = buildUpdateNotices({
81+
cliVersion: '1.0.0',
82+
cache: {
83+
lastCheck: now,
84+
latestVersion: '1.0.0',
85+
latestExtensionVersion: '2.1.0',
86+
currentExtensionVersion: '2.0.0',
87+
extensionLastSeenAt: now - EXTENSION_STALE_MS - 1,
88+
},
89+
now,
90+
});
91+
expect(lines.extension).toBeUndefined();
92+
});
93+
94+
it('skips the extension notice when current and latest are equal', () => {
95+
const lines = buildUpdateNotices({
96+
cliVersion: '1.0.0',
97+
cache: {
98+
lastCheck: now,
99+
latestVersion: '1.0.0',
100+
latestExtensionVersion: '2.0.0',
101+
currentExtensionVersion: '2.0.0',
102+
extensionLastSeenAt: now,
103+
},
104+
now,
105+
});
106+
expect(lines.extension).toBeUndefined();
107+
});
108+
109+
it('does not throw when cache has only daemon-written fields and no latestVersion', () => {
110+
const lines = buildUpdateNotices({
111+
cliVersion: '1.0.0',
112+
cache: {
113+
currentExtensionVersion: '2.0.0',
114+
extensionLastSeenAt: now,
115+
},
116+
now,
117+
});
118+
expect(lines.cli).toBeUndefined();
119+
expect(lines.extension).toBeUndefined();
120+
});
121+
122+
it('emits both notices when both are out of date', () => {
123+
const lines = buildUpdateNotices({
124+
cliVersion: '1.0.0',
125+
cache: {
126+
lastCheck: now,
127+
latestVersion: '1.1.0',
128+
latestExtensionVersion: '2.1.0',
129+
currentExtensionVersion: '2.0.0',
130+
extensionLastSeenAt: now,
131+
},
132+
now,
133+
});
134+
expect(lines.cli).toContain('v1.0.0 → v1.1.0');
135+
expect(lines.extension).toContain('v2.0.0 → v2.1.0');
136+
});
137+
});

src/update-check.ts

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
* - Check interval: 24 hours
88
* - Notice appears AFTER command output, not before (same as npm/gh/yarn)
99
* - Never delays or blocks the CLI command
10+
*
11+
* Cache is shared between the CLI process (writes latestVersion / latestExtensionVersion
12+
* via background fetch) and the daemon process (writes currentExtensionVersion /
13+
* extensionLastSeenAt via `recordExtensionVersion` on each hello). Writes use a
14+
* read-merge-write pattern so neither side clobbers the other.
1015
*/
1116

1217
import * as fs from 'node:fs';
@@ -18,13 +23,19 @@ import { PKG_VERSION } from './version.js';
1823
const CACHE_DIR = path.join(os.homedir(), '.opencli');
1924
const CACHE_FILE = path.join(CACHE_DIR, 'update-check.json');
2025
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
26+
const EXTENSION_STALE_MS = 7 * 24 * 60 * 60 * 1000; // 7d
2127
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@jackwener/opencli/latest';
2228
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jackwener/OpenCLI/releases?per_page=20';
2329

2430
interface UpdateCache {
25-
lastCheck: number;
26-
latestVersion: string;
31+
// CLI npm fetch fields — present once `checkForUpdateBackground` has succeeded.
32+
// Optional because the daemon may write the cache first via `recordExtensionVersion`.
33+
lastCheck?: number;
34+
latestVersion?: string;
2735
latestExtensionVersion?: string;
36+
// Daemon hello fields.
37+
currentExtensionVersion?: string;
38+
extensionLastSeenAt?: number;
2839
}
2940

3041
interface GitHubReleaseAsset {
@@ -36,21 +47,23 @@ interface GitHubRelease {
3647
assets?: GitHubReleaseAsset[];
3748
}
3849

39-
// Read cache once at module load — shared by both exported functions
40-
const _cache: UpdateCache | null = (() => {
50+
function readCacheSync(): UpdateCache | null {
4151
try {
4252
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')) as UpdateCache;
4353
} catch {
4454
return null;
4555
}
46-
})();
56+
}
57+
58+
// Read cache once at module load — shared by both exported functions
59+
const _cache: UpdateCache | null = readCacheSync();
4760

48-
function writeCache(latestVersion: string, latestExtensionVersion?: string): void {
61+
function writeCacheMerge(updates: Partial<UpdateCache>): void {
4962
try {
5063
fs.mkdirSync(CACHE_DIR, { recursive: true });
51-
const data: UpdateCache = { lastCheck: Date.now(), latestVersion };
52-
if (latestExtensionVersion) data.latestExtensionVersion = latestExtensionVersion;
53-
fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf-8');
64+
const existing = readCacheSync() ?? {};
65+
const merged = { ...existing, ...updates } as UpdateCache;
66+
fs.writeFileSync(CACHE_FILE, JSON.stringify(merged), 'utf-8');
5467
} catch {
5568
// Best-effort; never fail
5669
}
@@ -73,6 +86,41 @@ function isCI(): boolean {
7386
return !!(process.env.CI || process.env.CONTINUOUS_INTEGRATION);
7487
}
7588

89+
interface NoticeInputs {
90+
cliVersion: string;
91+
cache: UpdateCache | null;
92+
now: number;
93+
}
94+
95+
interface NoticeLines {
96+
cli?: string;
97+
extension?: string;
98+
}
99+
100+
/** Pure function: derive notice text from cache state. Exported for tests. */
101+
function buildUpdateNotices({ cliVersion, cache, now }: NoticeInputs): NoticeLines {
102+
if (!cache) return {};
103+
const lines: NoticeLines = {};
104+
if (cache.latestVersion && isNewer(cache.latestVersion, cliVersion)) {
105+
lines.cli =
106+
styleText('yellow', `\n Update available: v${cliVersion} → v${cache.latestVersion}\n`) +
107+
styleText('dim', ` Run: npm install -g @jackwener/opencli\n`);
108+
}
109+
const { currentExtensionVersion, latestExtensionVersion, extensionLastSeenAt } = cache;
110+
if (
111+
currentExtensionVersion &&
112+
latestExtensionVersion &&
113+
extensionLastSeenAt &&
114+
now - extensionLastSeenAt < EXTENSION_STALE_MS &&
115+
isNewer(latestExtensionVersion, currentExtensionVersion)
116+
) {
117+
lines.extension =
118+
styleText('yellow', `\n Extension update available: v${currentExtensionVersion} → v${latestExtensionVersion}\n`) +
119+
styleText('dim', ` Download: https://github.com/jackwener/opencli/releases\n`);
120+
}
121+
return lines;
122+
}
123+
76124
/**
77125
* Register a process exit hook that prints an update notice if a newer
78126
* version was found on the last background check.
@@ -85,13 +133,14 @@ export function registerUpdateNoticeOnExit(): void {
85133

86134
process.on('exit', (code) => {
87135
if (code !== 0) return; // Don't show update notice on error exit
88-
if (!_cache) return;
89-
if (!isNewer(_cache.latestVersion, PKG_VERSION)) return;
136+
const { cli, extension } = buildUpdateNotices({
137+
cliVersion: PKG_VERSION,
138+
cache: _cache,
139+
now: Date.now(),
140+
});
141+
if (!cli && !extension) return;
90142
try {
91-
process.stderr.write(
92-
styleText('yellow', `\n Update available: v${PKG_VERSION} → v${_cache.latestVersion}\n`) +
93-
styleText('dim', ` Run: npm install -g @jackwener/opencli\n\n`),
94-
);
143+
process.stderr.write(`${cli ?? ''}${extension ?? ''}\n`);
95144
} catch {
96145
// Ignore broken pipe (stderr closed before process exits)
97146
}
@@ -135,7 +184,7 @@ async function fetchLatestExtensionVersion(): Promise<string | undefined> {
135184
*/
136185
export function checkForUpdateBackground(): void {
137186
if (isCI()) return;
138-
if (_cache && Date.now() - _cache.lastCheck < CHECK_INTERVAL_MS) return;
187+
if (_cache?.lastCheck && Date.now() - _cache.lastCheck < CHECK_INTERVAL_MS) return;
139188

140189
void (async () => {
141190
try {
@@ -150,14 +199,29 @@ export function checkForUpdateBackground(): void {
150199
const data = await res.json() as { version?: string };
151200
if (typeof data.version === 'string') {
152201
const extVersion = await fetchLatestExtensionVersion();
153-
writeCache(data.version, extVersion);
202+
const updates: Partial<UpdateCache> = { lastCheck: Date.now(), latestVersion: data.version };
203+
if (extVersion) updates.latestExtensionVersion = extVersion;
204+
writeCacheMerge(updates);
154205
}
155206
} catch {
156207
// Network error: silently skip, try again next run
157208
}
158209
})();
159210
}
160211

212+
/**
213+
* Stash the current extension version into the shared cache. Called by the
214+
* daemon on each hello handshake. Lets the next CLI process compare against
215+
* the latest known release and print an exit notice without any extra I/O.
216+
*/
217+
export function recordExtensionVersion(version: string): void {
218+
if (typeof version !== 'string' || !version.trim()) return;
219+
writeCacheMerge({
220+
currentExtensionVersion: version.trim(),
221+
extensionLastSeenAt: Date.now(),
222+
});
223+
}
224+
161225
/**
162226
* Get the cached latest extension version (if available).
163227
* Used by `opencli doctor` to report extension updates.
@@ -168,4 +232,6 @@ export function getCachedLatestExtensionVersion(): string | undefined {
168232

169233
export {
170234
extractLatestExtensionVersionFromReleases as _extractLatestExtensionVersionFromReleases,
235+
buildUpdateNotices as _buildUpdateNotices,
236+
EXTENSION_STALE_MS as _EXTENSION_STALE_MS,
171237
};

0 commit comments

Comments
 (0)