Skip to content

Commit dd17167

Browse files
notgitikagitikavj
andauthored
feat: show version update notification on CLI startup (#380)
* chore: add Dependabot configuration * feat: add update notification on CLI startup * fix: import UpdateCheckResult at top of file --------- Co-authored-by: gitikavj <gitikavj@amazon.com>
1 parent 47da675 commit dd17167

File tree

3 files changed

+262
-4
lines changed

3 files changed

+262
-4
lines changed
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from '../update-notifier.js';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const { mockReadFile, mockWriteFile, mockMkdir } = vi.hoisted(() => ({
5+
mockReadFile: vi.fn(),
6+
mockWriteFile: vi.fn(),
7+
mockMkdir: vi.fn(),
8+
}));
9+
10+
vi.mock('fs/promises', () => ({
11+
readFile: mockReadFile,
12+
writeFile: mockWriteFile,
13+
mkdir: mockMkdir,
14+
}));
15+
16+
vi.mock('../constants.js', () => ({
17+
PACKAGE_VERSION: '1.0.0',
18+
}));
19+
20+
const { mockFetchLatestVersion, mockCompareVersions } = vi.hoisted(() => ({
21+
mockFetchLatestVersion: vi.fn(),
22+
mockCompareVersions: vi.fn(),
23+
}));
24+
25+
vi.mock('../commands/update/action.js', () => ({
26+
fetchLatestVersion: mockFetchLatestVersion,
27+
compareVersions: mockCompareVersions,
28+
}));
29+
30+
describe('checkForUpdate', () => {
31+
beforeEach(() => {
32+
vi.spyOn(Date, 'now').mockReturnValue(1708646400000);
33+
mockWriteFile.mockResolvedValue(undefined);
34+
mockMkdir.mockResolvedValue(undefined);
35+
});
36+
37+
afterEach(() => {
38+
vi.restoreAllMocks();
39+
mockReadFile.mockReset();
40+
mockWriteFile.mockReset();
41+
mockMkdir.mockReset();
42+
mockFetchLatestVersion.mockReset();
43+
mockCompareVersions.mockReset();
44+
});
45+
46+
it('fetches from registry when no cache exists', async () => {
47+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
48+
mockFetchLatestVersion.mockResolvedValue('2.0.0');
49+
mockCompareVersions.mockReturnValue(1);
50+
51+
const result = await checkForUpdate();
52+
53+
expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' });
54+
expect(mockFetchLatestVersion).toHaveBeenCalled();
55+
});
56+
57+
it('uses cache when last check was less than 24 hours ago', async () => {
58+
const cache = JSON.stringify({
59+
lastCheck: 1708646400000 - 1000, // 1 second ago
60+
latestVersion: '2.0.0',
61+
});
62+
mockReadFile.mockResolvedValue(cache);
63+
mockCompareVersions.mockReturnValue(1);
64+
65+
const result = await checkForUpdate();
66+
67+
expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' });
68+
expect(mockFetchLatestVersion).not.toHaveBeenCalled();
69+
});
70+
71+
it('fetches from registry when cache is expired', async () => {
72+
const cache = JSON.stringify({
73+
lastCheck: 1708646400000 - 25 * 60 * 60 * 1000, // 25 hours ago
74+
latestVersion: '1.5.0',
75+
});
76+
mockReadFile.mockResolvedValue(cache);
77+
mockFetchLatestVersion.mockResolvedValue('2.0.0');
78+
mockCompareVersions.mockReturnValue(1);
79+
80+
const result = await checkForUpdate();
81+
82+
expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' });
83+
expect(mockFetchLatestVersion).toHaveBeenCalled();
84+
});
85+
86+
it('writes cache after fetching', async () => {
87+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
88+
mockFetchLatestVersion.mockResolvedValue('2.0.0');
89+
mockCompareVersions.mockReturnValue(1);
90+
91+
await checkForUpdate();
92+
93+
expect(mockMkdir).toHaveBeenCalled();
94+
expect(mockWriteFile).toHaveBeenCalledWith(
95+
expect.stringContaining('update-check.json'),
96+
JSON.stringify({ lastCheck: 1708646400000, latestVersion: '2.0.0' }),
97+
'utf-8'
98+
);
99+
});
100+
101+
it('returns updateAvailable: false when versions match', async () => {
102+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
103+
mockFetchLatestVersion.mockResolvedValue('1.0.0');
104+
mockCompareVersions.mockReturnValue(0);
105+
106+
const result = await checkForUpdate();
107+
108+
expect(result).toEqual({ updateAvailable: false, latestVersion: '1.0.0' });
109+
});
110+
111+
it('returns updateAvailable: false when current is newer', async () => {
112+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
113+
mockFetchLatestVersion.mockResolvedValue('0.9.0');
114+
mockCompareVersions.mockReturnValue(-1);
115+
116+
const result = await checkForUpdate();
117+
118+
expect(result).toEqual({ updateAvailable: false, latestVersion: '0.9.0' });
119+
});
120+
121+
it('returns null on fetch error', async () => {
122+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
123+
mockFetchLatestVersion.mockRejectedValue(new Error('network error'));
124+
125+
const result = await checkForUpdate();
126+
127+
expect(result).toBeNull();
128+
});
129+
130+
it('returns null on cache parse error and fetch error', async () => {
131+
mockReadFile.mockResolvedValue('invalid json');
132+
mockFetchLatestVersion.mockRejectedValue(new Error('network error'));
133+
134+
const result = await checkForUpdate();
135+
136+
expect(result).toBeNull();
137+
});
138+
139+
it('succeeds even when cache write fails', async () => {
140+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
141+
mockFetchLatestVersion.mockResolvedValue('2.0.0');
142+
mockCompareVersions.mockReturnValue(1);
143+
mockWriteFile.mockRejectedValue(new Error('EACCES'));
144+
145+
const result = await checkForUpdate();
146+
147+
expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' });
148+
});
149+
});
150+
151+
describe('printUpdateNotification', () => {
152+
it('writes notification to stderr', () => {
153+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
154+
155+
const result: UpdateCheckResult = { updateAvailable: true, latestVersion: '2.0.0' };
156+
printUpdateNotification(result);
157+
158+
const output = stderrSpy.mock.calls.map(c => c[0]).join('');
159+
expect(output).toContain('Update available:');
160+
expect(output).toContain('1.0.0');
161+
expect(output).toContain('2.0.0');
162+
expect(output).toContain('npm install -g @aws/agentcore@latest');
163+
164+
stderrSpy.mockRestore();
165+
});
166+
});

src/cli/cli.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { COMMAND_DESCRIPTIONS } from './tui/copy';
1616
import { clearExitMessage, getExitMessage } from './tui/exit-message';
1717
import { CommandListScreen } from './tui/screens/home';
1818
import { getCommandsForUI } from './tui/utils';
19+
import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from './update-notifier';
1920
import { Command } from '@commander-js/extra-typings';
2021
import { render } from 'ink';
2122
import React from 'react';
@@ -54,13 +55,13 @@ function setupGlobalCleanup() {
5455
/**
5556
* Render the TUI in alternate screen buffer mode.
5657
*/
57-
function renderTUI() {
58+
function renderTUI(updateCheck: Promise<UpdateCheckResult | null>) {
5859
inAltScreen = true;
5960
process.stdout.write(ENTER_ALT_SCREEN);
6061

6162
const { waitUntilExit } = render(React.createElement(App));
6263

63-
void waitUntilExit().then(() => {
64+
void waitUntilExit().then(async () => {
6465
inAltScreen = false;
6566
process.stdout.write(EXIT_ALT_SCREEN);
6667
process.stdout.write(SHOW_CURSOR);
@@ -71,6 +72,12 @@ function renderTUI() {
7172
console.log(exitMessage);
7273
clearExitMessage();
7374
}
75+
76+
// Print update notification after TUI exits
77+
const result = await updateCheck;
78+
if (result?.updateAvailable) {
79+
printUpdateNotification(result);
80+
}
7481
});
7582
}
7683

@@ -135,12 +142,23 @@ export const main = async (argv: string[]) => {
135142

136143
const program = createProgram();
137144

138-
// Show TUI for no arguments, commander handles --help via configureHelp()
139145
const args = argv.slice(2);
146+
147+
// Fire off non-blocking update check (skip for `update` command)
148+
const isUpdateCommand = args[0] === 'update';
149+
const updateCheck = isUpdateCommand ? Promise.resolve(null) : checkForUpdate();
150+
151+
// Show TUI for no arguments, commander handles --help via configureHelp()
140152
if (args.length === 0) {
141-
renderTUI();
153+
renderTUI(updateCheck);
142154
return;
143155
}
144156

145157
await program.parseAsync(argv);
158+
159+
// Print notification after command finishes
160+
const result = await updateCheck;
161+
if (result?.updateAvailable) {
162+
printUpdateNotification(result);
163+
}
146164
};

src/cli/update-notifier.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { compareVersions, fetchLatestVersion } from './commands/update/action.js';
2+
import { PACKAGE_VERSION } from './constants.js';
3+
import { mkdir, readFile, writeFile } from 'fs/promises';
4+
import { homedir } from 'os';
5+
import { join } from 'path';
6+
7+
const CACHE_DIR = join(homedir(), '.agentcore');
8+
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
9+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // every 24 hours
10+
11+
interface CacheData {
12+
lastCheck: number;
13+
latestVersion: string;
14+
}
15+
16+
export interface UpdateCheckResult {
17+
updateAvailable: boolean;
18+
latestVersion: string;
19+
}
20+
21+
async function readCache(): Promise<CacheData | null> {
22+
try {
23+
const data = await readFile(CACHE_FILE, 'utf-8');
24+
return JSON.parse(data) as CacheData;
25+
} catch {
26+
return null;
27+
}
28+
}
29+
30+
async function writeCache(data: CacheData): Promise<void> {
31+
try {
32+
await mkdir(CACHE_DIR, { recursive: true });
33+
await writeFile(CACHE_FILE, JSON.stringify(data), 'utf-8');
34+
} catch {
35+
// Silently ignore cache write failures
36+
}
37+
}
38+
39+
export async function checkForUpdate(): Promise<UpdateCheckResult | null> {
40+
try {
41+
const cache = await readCache();
42+
const now = Date.now();
43+
44+
if (cache && now - cache.lastCheck < CHECK_INTERVAL_MS) {
45+
const comparison = compareVersions(PACKAGE_VERSION, cache.latestVersion);
46+
return {
47+
updateAvailable: comparison > 0,
48+
latestVersion: cache.latestVersion,
49+
};
50+
}
51+
52+
const latestVersion = await fetchLatestVersion();
53+
await writeCache({ lastCheck: now, latestVersion });
54+
55+
const comparison = compareVersions(PACKAGE_VERSION, latestVersion);
56+
return {
57+
updateAvailable: comparison > 0,
58+
latestVersion,
59+
};
60+
} catch {
61+
return null;
62+
}
63+
}
64+
65+
export function printUpdateNotification(result: UpdateCheckResult): void {
66+
const yellow = '\x1b[33m';
67+
const cyan = '\x1b[36m';
68+
const reset = '\x1b[0m';
69+
70+
process.stderr.write(
71+
`\n${yellow}Update available:${reset} ${PACKAGE_VERSION}${cyan}${result.latestVersion}${reset}\n` +
72+
`Run ${cyan}\`npm install -g @aws/agentcore@latest\`${reset} to update.\n`
73+
);
74+
}

0 commit comments

Comments
 (0)