diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..cbf73608 --- /dev/null +++ b/.github/dependabot.yml @@ -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' diff --git a/src/cli/__tests__/update-notifier.test.ts b/src/cli/__tests__/update-notifier.test.ts new file mode 100644 index 00000000..3713e51f --- /dev/null +++ b/src/cli/__tests__/update-notifier.test.ts @@ -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(); + }); +}); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 6774ceb9..cc719f72 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -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'; @@ -54,13 +55,13 @@ function setupGlobalCleanup() { /** * Render the TUI in alternate screen buffer mode. */ -function renderTUI() { +function renderTUI(updateCheck: Promise) { 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); @@ -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); + } }); } @@ -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); + } }; diff --git a/src/cli/update-notifier.ts b/src/cli/update-notifier.ts new file mode 100644 index 00000000..98c46829 --- /dev/null +++ b/src/cli/update-notifier.ts @@ -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 { + try { + const data = await readFile(CACHE_FILE, 'utf-8'); + return JSON.parse(data) as CacheData; + } catch { + return null; + } +} + +async function writeCache(data: CacheData): Promise { + 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 { + 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` + ); +}