Skip to content

Commit 5a230d8

Browse files
committed
refactor: split update checking into cache-only check and background refresh
1 parent 334a17c commit 5a230d8

File tree

3 files changed

+179
-107
lines changed

3 files changed

+179
-107
lines changed

src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { videoViewsCommand } from './commands/video-views/index.ts';
2626
import { webhooksCommand } from './commands/webhooks/index.ts';
2727
import { whoamiCommand } from './commands/whoami.ts';
2828
import { setAgentMode } from './lib/context.ts';
29-
import { checkForUpdate } from './lib/update-notifier.ts';
29+
import { checkForUpdate, refreshUpdateCache } from './lib/update-notifier.ts';
3030

3131
const VERSION = pkg.version;
3232

@@ -90,9 +90,10 @@ const cli = new Command()
9090

9191
// Run the CLI
9292
if (import.meta.main) {
93-
// Resolve the update check early so the notice is available on exit.
94-
// This reads from a local cache (fast) or fetches with a 3s timeout.
93+
// Read cached update info (no network, instant) for the exit notice.
94+
// Refresh the cache in the background so the next run has fresh data.
9595
const updateNotice = await checkForUpdate(VERSION).catch(() => null);
96+
refreshUpdateCache().catch(() => {});
9697

9798
process.on('exit', () => {
9899
if (updateNotice) console.error(updateNotice);

src/lib/update-notifier.test.ts

Lines changed: 145 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
formatUpdateNotice,
1111
getUpgradeCommand,
1212
readUpdateCache,
13+
refreshUpdateCache,
1314
type UpdateCache,
1415
writeUpdateCache,
1516
} from './update-notifier.ts';
@@ -223,10 +224,9 @@ describe('update-notifier', () => {
223224
});
224225
});
225226

226-
describe('checkForUpdate', () => {
227+
describe('checkForUpdate (cache-only)', () => {
227228
let testCacheDir: string;
228229
let originalXdgCacheHome: string | undefined;
229-
let originalFetch: typeof globalThis.fetch;
230230
let originalCI: string | undefined;
231231
let originalNoUpdateCheck: string | undefined;
232232

@@ -235,7 +235,6 @@ describe('update-notifier', () => {
235235
originalXdgCacheHome = process.env.XDG_CACHE_HOME;
236236
process.env.XDG_CACHE_HOME = testCacheDir;
237237

238-
originalFetch = globalThis.fetch;
239238
originalCI = process.env.CI;
240239
originalNoUpdateCheck = process.env.MUX_NO_UPDATE_CHECK;
241240

@@ -250,8 +249,6 @@ describe('update-notifier', () => {
250249
process.env.XDG_CACHE_HOME = originalXdgCacheHome;
251250
}
252251

253-
globalThis.fetch = originalFetch;
254-
255252
if (originalCI === undefined) {
256253
delete process.env.CI;
257254
} else {
@@ -280,135 +277,206 @@ describe('update-notifier', () => {
280277
});
281278

282279
it('should return null when not a TTY', async () => {
283-
globalThis.fetch = mock(() =>
284-
Promise.resolve(
285-
new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }),
286-
),
287-
) as unknown as typeof fetch;
288280
const result = await checkForUpdate('1.0.0', { isTTY: false });
289281
expect(result).toBeNull();
290282
});
291283

292-
it('should return notice when newer version is available', async () => {
293-
globalThis.fetch = mock(() =>
294-
Promise.resolve(
295-
new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }),
296-
),
297-
) as unknown as typeof fetch;
284+
it('should return null when no cache exists', async () => {
285+
const result = await checkForUpdate('1.0.0', { isTTY: true });
286+
expect(result).toBeNull();
287+
});
288+
289+
it('should return notice when cache has newer version', async () => {
290+
await writeUpdateCache({
291+
latestVersion: '2.0.0',
292+
lastChecked: Date.now(),
293+
firstSeenAt: Date.now() - 49 * 60 * 60 * 1000,
294+
});
298295
const result = await checkForUpdate('1.0.0', { isTTY: true });
299296
expect(result).not.toBeNull();
300297
expect(result).toContain('2.0.0');
301298
});
302299

303-
it('should return null when already on latest version', async () => {
300+
it('should return null when cache version equals current', async () => {
301+
await writeUpdateCache({
302+
latestVersion: '1.0.0',
303+
lastChecked: Date.now(),
304+
firstSeenAt: Date.now(),
305+
});
306+
const result = await checkForUpdate('1.0.0', { isTTY: true });
307+
expect(result).toBeNull();
308+
});
309+
310+
it('should suppress notification for Homebrew when version is recent', async () => {
311+
await writeUpdateCache({
312+
latestVersion: '2.0.0',
313+
lastChecked: Date.now(),
314+
firstSeenAt: Date.now(), // just discovered
315+
});
316+
const result = await checkForUpdate('1.0.0', {
317+
isTTY: true,
318+
execPath: '/opt/homebrew/bin/mux',
319+
});
320+
expect(result).toBeNull();
321+
});
322+
323+
it('should show notification for Homebrew when version is old enough', async () => {
324+
await writeUpdateCache({
325+
latestVersion: '2.0.0',
326+
lastChecked: Date.now(),
327+
firstSeenAt: Date.now() - 49 * 60 * 60 * 1000, // 49 hours ago
328+
});
329+
const result = await checkForUpdate('1.0.0', {
330+
isTTY: true,
331+
execPath: '/opt/homebrew/bin/mux',
332+
});
333+
expect(result).not.toBeNull();
334+
expect(result).toContain('2.0.0');
335+
});
336+
337+
it('should not apply Homebrew delay for npm installs', async () => {
338+
await writeUpdateCache({
339+
latestVersion: '2.0.0',
340+
lastChecked: Date.now(),
341+
firstSeenAt: Date.now(), // just discovered, but npm — no delay
342+
});
343+
const result = await checkForUpdate('1.0.0', {
344+
isTTY: true,
345+
execPath: '/usr/local/lib/node_modules/@mux/cli/bin/mux',
346+
});
347+
expect(result).not.toBeNull();
348+
expect(result).toContain('2.0.0');
349+
});
350+
});
351+
352+
describe('refreshUpdateCache', () => {
353+
let testCacheDir: string;
354+
let originalXdgCacheHome: string | undefined;
355+
let originalFetch: typeof globalThis.fetch;
356+
357+
beforeEach(async () => {
358+
testCacheDir = join(tmpdir(), `mux-cli-test-cache-${Date.now()}`);
359+
originalXdgCacheHome = process.env.XDG_CACHE_HOME;
360+
process.env.XDG_CACHE_HOME = testCacheDir;
361+
originalFetch = globalThis.fetch;
362+
});
363+
364+
afterEach(async () => {
365+
if (originalXdgCacheHome === undefined) {
366+
delete process.env.XDG_CACHE_HOME;
367+
} else {
368+
process.env.XDG_CACHE_HOME = originalXdgCacheHome;
369+
}
370+
globalThis.fetch = originalFetch;
371+
await rm(testCacheDir, { recursive: true, force: true });
372+
});
373+
374+
it('should fetch and write cache when no cache exists', async () => {
304375
globalThis.fetch = mock(() =>
305376
Promise.resolve(
306-
new Response(JSON.stringify({ version: '1.0.0' }), { status: 200 }),
377+
new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }),
307378
),
308379
) as unknown as typeof fetch;
309-
const result = await checkForUpdate('1.0.0', { isTTY: true });
310-
expect(result).toBeNull();
380+
381+
await refreshUpdateCache();
382+
383+
const cache = await readUpdateCache();
384+
expect(cache).not.toBeNull();
385+
expect(cache?.latestVersion).toBe('2.0.0');
311386
});
312387

313-
it('should use cached version when cache is fresh', async () => {
314-
const now = Date.now();
315-
const cache: UpdateCache = {
316-
latestVersion: '3.0.0',
317-
lastChecked: now,
318-
firstSeenAt: now - 49 * 60 * 60 * 1000, // 49 hours ago (past Homebrew delay)
319-
};
320-
await writeUpdateCache(cache);
388+
it('should skip fetch when cache is fresh', async () => {
389+
await writeUpdateCache({
390+
latestVersion: '2.0.0',
391+
lastChecked: Date.now(),
392+
firstSeenAt: Date.now(),
393+
});
321394

322-
// fetch should NOT be called
323395
const fetchMock = mock(() =>
324396
Promise.resolve(
325-
new Response(JSON.stringify({ version: '4.0.0' }), { status: 200 }),
397+
new Response(JSON.stringify({ version: '3.0.0' }), { status: 200 }),
326398
),
327399
) as unknown as typeof fetch;
328400
globalThis.fetch = fetchMock;
329401

330-
const result = await checkForUpdate('1.0.0', { isTTY: true });
331-
expect(result).toContain('3.0.0'); // cached version, not 4.0.0
402+
await refreshUpdateCache();
403+
332404
expect(fetchMock).not.toHaveBeenCalled();
405+
const cache = await readUpdateCache();
406+
expect(cache?.latestVersion).toBe('2.0.0');
333407
});
334408

335409
it('should fetch when cache is stale', async () => {
336-
const staleTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
337-
const cache: UpdateCache = {
410+
const staleTime = Date.now() - 25 * 60 * 60 * 1000;
411+
await writeUpdateCache({
338412
latestVersion: '2.0.0',
339413
lastChecked: staleTime,
340414
firstSeenAt: staleTime,
341-
};
342-
await writeUpdateCache(cache);
415+
});
343416

344417
globalThis.fetch = mock(() =>
345418
Promise.resolve(
346419
new Response(JSON.stringify({ version: '3.0.0' }), { status: 200 }),
347420
),
348421
) as unknown as typeof fetch;
349422

350-
const result = await checkForUpdate('1.0.0', { isTTY: true });
351-
expect(result).toContain('3.0.0'); // fetched version
352-
});
423+
await refreshUpdateCache();
353424

354-
it('should return null when fetch fails and no cache exists', async () => {
355-
globalThis.fetch = mock(() =>
356-
Promise.reject(new Error('network error')),
357-
) as unknown as typeof fetch;
358-
const result = await checkForUpdate('1.0.0', { isTTY: true });
359-
expect(result).toBeNull();
425+
const cache = await readUpdateCache();
426+
expect(cache?.latestVersion).toBe('3.0.0');
360427
});
361428

362-
it('should suppress notification for Homebrew when version is recent', async () => {
429+
it('should preserve firstSeenAt when version is unchanged', async () => {
430+
const originalFirstSeen = Date.now() - 10 * 60 * 60 * 1000;
431+
const staleTime = Date.now() - 25 * 60 * 60 * 1000;
432+
await writeUpdateCache({
433+
latestVersion: '2.0.0',
434+
lastChecked: staleTime,
435+
firstSeenAt: originalFirstSeen,
436+
});
437+
363438
globalThis.fetch = mock(() =>
364439
Promise.resolve(
365440
new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }),
366441
),
367442
) as unknown as typeof fetch;
368-
const result = await checkForUpdate('1.0.0', {
369-
isTTY: true,
370-
execPath: '/opt/homebrew/bin/mux',
371-
});
372-
// Version was just discovered (firstSeenAt = now), so Homebrew delay kicks in
373-
expect(result).toBeNull();
443+
444+
await refreshUpdateCache();
445+
446+
const cache = await readUpdateCache();
447+
expect(cache?.firstSeenAt).toBe(originalFirstSeen);
374448
});
375449

376-
it('should show notification for Homebrew when version is old enough', async () => {
377-
const oldTime = Date.now() - 49 * 60 * 60 * 1000; // 49 hours ago
378-
const cache: UpdateCache = {
450+
it('should reset firstSeenAt when version changes', async () => {
451+
const staleTime = Date.now() - 25 * 60 * 60 * 1000;
452+
await writeUpdateCache({
379453
latestVersion: '2.0.0',
380-
lastChecked: Date.now(),
381-
firstSeenAt: oldTime,
382-
};
383-
await writeUpdateCache(cache);
454+
lastChecked: staleTime,
455+
firstSeenAt: staleTime,
456+
});
384457

385458
globalThis.fetch = mock(() =>
386459
Promise.resolve(
387-
new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }),
460+
new Response(JSON.stringify({ version: '3.0.0' }), { status: 200 }),
388461
),
389462
) as unknown as typeof fetch;
390463

391-
const result = await checkForUpdate('1.0.0', {
392-
isTTY: true,
393-
execPath: '/opt/homebrew/bin/mux',
394-
});
395-
expect(result).not.toBeNull();
396-
expect(result).toContain('2.0.0');
464+
const before = Date.now();
465+
await refreshUpdateCache();
466+
467+
const cache = await readUpdateCache();
468+
expect(cache?.firstSeenAt).toBeGreaterThanOrEqual(before);
397469
});
398470

399-
it('should not apply Homebrew delay for npm installs', async () => {
471+
it('should not write cache when fetch fails', async () => {
400472
globalThis.fetch = mock(() =>
401-
Promise.resolve(
402-
new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }),
403-
),
473+
Promise.reject(new Error('network error')),
404474
) as unknown as typeof fetch;
405-
const result = await checkForUpdate('1.0.0', {
406-
isTTY: true,
407-
execPath: '/usr/local/lib/node_modules/@mux/cli/bin/mux',
408-
});
409-
// npm install — no delay, should show notice immediately
410-
expect(result).not.toBeNull();
411-
expect(result).toContain('2.0.0');
475+
476+
await refreshUpdateCache();
477+
478+
const cache = await readUpdateCache();
479+
expect(cache).toBeNull();
412480
});
413481
});
414482
});

0 commit comments

Comments
 (0)