Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
groups:
dev-dependencies:
dependency-type: 'development'
patterns:
- '@types/*'
- 'eslint*'
- 'prettier*'
- 'vitest*'
- '@trivago/*'
commit-message:
prefix: 'chore'
include: 'scope'
open-pull-requests-limit: 20

- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'
commit-message:
prefix: 'ci'
166 changes: 166 additions & 0 deletions src/cli/__tests__/update-notifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from '../update-notifier.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { mockReadFile, mockWriteFile, mockMkdir } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockMkdir: vi.fn(),
}));

vi.mock('fs/promises', () => ({
readFile: mockReadFile,
writeFile: mockWriteFile,
mkdir: mockMkdir,
}));

vi.mock('../constants.js', () => ({
PACKAGE_VERSION: '1.0.0',
}));

const { mockFetchLatestVersion, mockCompareVersions } = vi.hoisted(() => ({
mockFetchLatestVersion: vi.fn(),
mockCompareVersions: vi.fn(),
}));

vi.mock('../commands/update/action.js', () => ({
fetchLatestVersion: mockFetchLatestVersion,
compareVersions: mockCompareVersions,
}));

describe('checkForUpdate', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(1708646400000);
mockWriteFile.mockResolvedValue(undefined);
mockMkdir.mockResolvedValue(undefined);
});

afterEach(() => {
vi.restoreAllMocks();
mockReadFile.mockReset();
mockWriteFile.mockReset();
mockMkdir.mockReset();
mockFetchLatestVersion.mockReset();
mockCompareVersions.mockReset();
});

it('fetches from registry when no cache exists', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));
mockFetchLatestVersion.mockResolvedValue('2.0.0');
mockCompareVersions.mockReturnValue(1);

const result = await checkForUpdate();

expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' });
expect(mockFetchLatestVersion).toHaveBeenCalled();
});

it('uses cache when last check was less than 24 hours ago', async () => {
const cache = JSON.stringify({
lastCheck: 1708646400000 - 1000, // 1 second ago
latestVersion: '2.0.0',
});
mockReadFile.mockResolvedValue(cache);
mockCompareVersions.mockReturnValue(1);

const result = await checkForUpdate();

expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' });
expect(mockFetchLatestVersion).not.toHaveBeenCalled();
});

it('fetches from registry when cache is expired', async () => {
const cache = JSON.stringify({
lastCheck: 1708646400000 - 25 * 60 * 60 * 1000, // 25 hours ago
latestVersion: '1.5.0',
});
mockReadFile.mockResolvedValue(cache);
mockFetchLatestVersion.mockResolvedValue('2.0.0');
mockCompareVersions.mockReturnValue(1);

const result = await checkForUpdate();

expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' });
expect(mockFetchLatestVersion).toHaveBeenCalled();
});

it('writes cache after fetching', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));
mockFetchLatestVersion.mockResolvedValue('2.0.0');
mockCompareVersions.mockReturnValue(1);

await checkForUpdate();

expect(mockMkdir).toHaveBeenCalled();
expect(mockWriteFile).toHaveBeenCalledWith(
expect.stringContaining('update-check.json'),
JSON.stringify({ lastCheck: 1708646400000, latestVersion: '2.0.0' }),
'utf-8'
);
});

it('returns updateAvailable: false when versions match', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));
mockFetchLatestVersion.mockResolvedValue('1.0.0');
mockCompareVersions.mockReturnValue(0);

const result = await checkForUpdate();

expect(result).toEqual({ updateAvailable: false, latestVersion: '1.0.0' });
});

it('returns updateAvailable: false when current is newer', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));
mockFetchLatestVersion.mockResolvedValue('0.9.0');
mockCompareVersions.mockReturnValue(-1);

const result = await checkForUpdate();

expect(result).toEqual({ updateAvailable: false, latestVersion: '0.9.0' });
});

it('returns null on fetch error', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));
mockFetchLatestVersion.mockRejectedValue(new Error('network error'));

const result = await checkForUpdate();

expect(result).toBeNull();
});

it('returns null on cache parse error and fetch error', async () => {
mockReadFile.mockResolvedValue('invalid json');
mockFetchLatestVersion.mockRejectedValue(new Error('network error'));

const result = await checkForUpdate();

expect(result).toBeNull();
});

it('succeeds even when cache write fails', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));
mockFetchLatestVersion.mockResolvedValue('2.0.0');
mockCompareVersions.mockReturnValue(1);
mockWriteFile.mockRejectedValue(new Error('EACCES'));

const result = await checkForUpdate();

expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' });
});
});

describe('printUpdateNotification', () => {
it('writes notification to stderr', () => {
const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);

const result: UpdateCheckResult = { updateAvailable: true, latestVersion: '2.0.0' };
printUpdateNotification(result);

const output = stderrSpy.mock.calls.map(c => c[0]).join('');
expect(output).toContain('Update available:');
expect(output).toContain('1.0.0');
expect(output).toContain('2.0.0');
expect(output).toContain('npm install -g @aws/agentcore@latest');

stderrSpy.mockRestore();
});
});
26 changes: 22 additions & 4 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { COMMAND_DESCRIPTIONS } from './tui/copy';
import { clearExitMessage, getExitMessage } from './tui/exit-message';
import { CommandListScreen } from './tui/screens/home';
import { getCommandsForUI } from './tui/utils';
import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from './update-notifier';
import { Command } from '@commander-js/extra-typings';
import { render } from 'ink';
import React from 'react';
Expand Down Expand Up @@ -54,13 +55,13 @@ function setupGlobalCleanup() {
/**
* Render the TUI in alternate screen buffer mode.
*/
function renderTUI() {
function renderTUI(updateCheck: Promise<UpdateCheckResult | null>) {
inAltScreen = true;
process.stdout.write(ENTER_ALT_SCREEN);

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

void waitUntilExit().then(() => {
void waitUntilExit().then(async () => {
inAltScreen = false;
process.stdout.write(EXIT_ALT_SCREEN);
process.stdout.write(SHOW_CURSOR);
Expand All @@ -71,6 +72,12 @@ function renderTUI() {
console.log(exitMessage);
clearExitMessage();
}

// Print update notification after TUI exits
const result = await updateCheck;
if (result?.updateAvailable) {
printUpdateNotification(result);
}
});
}

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

const program = createProgram();

// Show TUI for no arguments, commander handles --help via configureHelp()
const args = argv.slice(2);

// Fire off non-blocking update check (skip for `update` command)
const isUpdateCommand = args[0] === 'update';
const updateCheck = isUpdateCommand ? Promise.resolve(null) : checkForUpdate();

// Show TUI for no arguments, commander handles --help via configureHelp()
if (args.length === 0) {
renderTUI();
renderTUI(updateCheck);
return;
}

await program.parseAsync(argv);

// Print notification after command finishes
const result = await updateCheck;
if (result?.updateAvailable) {
printUpdateNotification(result);
}
};
74 changes: 74 additions & 0 deletions src/cli/update-notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { compareVersions, fetchLatestVersion } from './commands/update/action.js';
import { PACKAGE_VERSION } from './constants.js';
import { mkdir, readFile, writeFile } from 'fs/promises';
import { homedir } from 'os';
import { join } from 'path';

const CACHE_DIR = join(homedir(), '.agentcore');
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // every 24 hours

interface CacheData {
lastCheck: number;
latestVersion: string;
}

export interface UpdateCheckResult {
updateAvailable: boolean;
latestVersion: string;
}

async function readCache(): Promise<CacheData | null> {
try {
const data = await readFile(CACHE_FILE, 'utf-8');
return JSON.parse(data) as CacheData;
} catch {
return null;
}
}

async function writeCache(data: CacheData): Promise<void> {
try {
await mkdir(CACHE_DIR, { recursive: true });
await writeFile(CACHE_FILE, JSON.stringify(data), 'utf-8');
} catch {
// Silently ignore cache write failures
}
}

export async function checkForUpdate(): Promise<UpdateCheckResult | null> {
try {
const cache = await readCache();
const now = Date.now();

if (cache && now - cache.lastCheck < CHECK_INTERVAL_MS) {
const comparison = compareVersions(PACKAGE_VERSION, cache.latestVersion);
return {
updateAvailable: comparison > 0,
latestVersion: cache.latestVersion,
};
}

const latestVersion = await fetchLatestVersion();
await writeCache({ lastCheck: now, latestVersion });

const comparison = compareVersions(PACKAGE_VERSION, latestVersion);
return {
updateAvailable: comparison > 0,
latestVersion,
};
} catch {
return null;
}
}

export function printUpdateNotification(result: UpdateCheckResult): void {
const yellow = '\x1b[33m';
const cyan = '\x1b[36m';
const reset = '\x1b[0m';

process.stderr.write(
`\n${yellow}Update available:${reset} ${PACKAGE_VERSION} → ${cyan}${result.latestVersion}${reset}\n` +
`Run ${cyan}\`npm install -g @aws/agentcore@latest\`${reset} to update.\n`
);
}
Loading