diff --git a/src/cli.ts b/src/cli.ts index e5ed20b..91bd897 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,9 +16,14 @@ import { registerCompletions } from './commands/completions.js'; import { registerConfig } from './commands/config-cmd.js'; import { registerInit } from './commands/init.js'; import { createCreateCommand } from './commands/create.js'; +import { registerUpdate } from './commands/update.js'; +import { checkForUpdateSafe, type UpdateCheckResult } from './utils/update-checker.js'; const program = new Command(); +// Kick off background update check (non-blocking) +const updateCheckPromise: Promise = checkForUpdateSafe(); + program.name('agentage').description('Agentage CLI — control plane for AI agents').version(VERSION); registerRun(program); @@ -35,8 +40,20 @@ registerCompletions(program); registerConfig(program); registerInit(program); program.addCommand(createCreateCommand()); +registerUpdate(program); + +program.parseAsync().then(async () => { + // Show update notice if a newer version is available + const result = await updateCheckPromise; + if (result?.updateAvailable) { + const { default: chalk } = await import('chalk'); + console.log( + chalk.yellow( + `\nUpdate available: ${result.currentVersion} → ${result.latestVersion} — run ${chalk.white('agentage update')} to install.` + ) + ); + } -program.parseAsync().then(() => { // Force exit — forked daemon process can keep the event loop alive setTimeout(() => process.exit(process.exitCode ?? 0), 100); }); diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..837bc58 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,89 @@ +import { type Command } from 'commander'; +import { execSync } from 'node:child_process'; +import { existsSync, writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import chalk from 'chalk'; +import { getConfigDir } from '../daemon/config.js'; +import { isDaemonRunning, restartDaemon, getDaemonPid } from '../daemon/daemon.js'; +import { checkForUpdate } from '../utils/update-checker.js'; + +const LOCK_FILE = 'update.lock'; + +const getLockPath = (): string => join(getConfigDir(), LOCK_FILE); + +const acquireLock = (): boolean => { + const lockPath = getLockPath(); + if (existsSync(lockPath)) { + return false; + } + writeFileSync(lockPath, String(process.pid), 'utf-8'); + return true; +}; + +const releaseLock = (): void => { + const lockPath = getLockPath(); + if (existsSync(lockPath)) { + unlinkSync(lockPath); + } +}; + +export const registerUpdate = (program: Command): void => { + program + .command('update') + .description('Update @agentage/cli to the latest version') + .option('--check', 'Only check for updates, do not install') + .action(async (opts: { check?: boolean }) => { + try { + console.log(chalk.gray('Checking for updates...')); + const result = await checkForUpdate({ force: true }); + + if (!result.updateAvailable) { + console.log(chalk.green(`Already on the latest version (${result.currentVersion}).`)); + process.exit(0); + return; + } + + console.log( + `Update available: ${chalk.gray(result.currentVersion)} → ${chalk.green(result.latestVersion)}` + ); + + if (opts.check) { + console.log(chalk.gray(`Run ${chalk.white('agentage update')} to install.`)); + process.exit(0); + return; + } + + if (!acquireLock()) { + console.log(chalk.yellow('Another update is already in progress.')); + process.exit(1); + return; + } + + try { + console.log(chalk.gray('Installing update...')); + execSync('npm update -g @agentage/cli', { + stdio: 'inherit', + timeout: 120_000, + }); + + console.log(chalk.green(`Updated to ${result.latestVersion}.`)); + + if (isDaemonRunning()) { + console.log(chalk.gray('Restarting daemon...')); + await restartDaemon(); + const pid = getDaemonPid(); + console.log(chalk.green(`Daemon restarted (PID ${pid}).`)); + } + } finally { + releaseLock(); + } + } catch (err) { + releaseLock(); + const message = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`Update failed: ${message}`)); + process.exit(1); + } + + process.exit(0); + }); +}; diff --git a/src/hub/hub-client.test.ts b/src/hub/hub-client.test.ts index 1b8b4d7..97c1976 100644 --- a/src/hub/hub-client.test.ts +++ b/src/hub/hub-client.test.ts @@ -81,6 +81,7 @@ describe('hub-client', () => { const result = await client.heartbeat('machine-1', { agents: [{ name: 'hello', description: 'Hi' }], activeRunIds: ['run-1'], + daemonVersion: '0.12.3', }); expect(result.pendingCommands).toEqual([]); diff --git a/src/hub/hub-client.ts b/src/hub/hub-client.ts index 532a5e8..d276c8b 100644 --- a/src/hub/hub-client.ts +++ b/src/hub/hub-client.ts @@ -14,8 +14,9 @@ export interface HubClient { data: { agents: Array<{ name: string; description?: string; version?: string; tags?: string[] }>; activeRunIds: string[]; + daemonVersion: string; } - ) => Promise<{ pendingCommands: unknown[] }>; + ) => Promise<{ pendingCommands: unknown[]; latestCliVersion?: string }>; deregister: (machineId: string) => Promise; getMachines: () => Promise; getAgents: (machineId?: string) => Promise; @@ -114,7 +115,7 @@ export const createHubClient = (hubUrl: string, auth: AuthState): HubClient => { heartbeat: async (machineId, body) => { const data = await request('POST', `/machines/${machineId}/heartbeat`, body); - return data as { pendingCommands: unknown[] }; + return data as { pendingCommands: unknown[]; latestCliVersion?: string }; }, deregister: async (machineId) => { diff --git a/src/hub/hub-sync.test.ts b/src/hub/hub-sync.test.ts index f6fd4cb..d84db02 100644 --- a/src/hub/hub-sync.test.ts +++ b/src/hub/hub-sync.test.ts @@ -187,6 +187,7 @@ describe('hub-sync', () => { expect(mockHubClient.heartbeat).toHaveBeenCalledWith('machine-1', { agents: [{ name: 'hello', description: 'Hi', version: '1.0', tags: ['chat'] }], activeRunIds: ['run-1'], + daemonVersion: '0.7.1', }); }); diff --git a/src/hub/hub-sync.ts b/src/hub/hub-sync.ts index 07e1200..9af341d 100644 --- a/src/hub/hub-sync.ts +++ b/src/hub/hub-sync.ts @@ -75,6 +75,7 @@ export const createHubSync = (): HubSync => { const response = await hubClient.heartbeat(auth.hub.machineId, { agents, activeRunIds, + daemonVersion: VERSION, }); // Process pending commands from hub diff --git a/src/utils/ensure-daemon.ts b/src/utils/ensure-daemon.ts index 4276632..2337428 100644 --- a/src/utils/ensure-daemon.ts +++ b/src/utils/ensure-daemon.ts @@ -1,6 +1,30 @@ -import { isDaemonRunning, startDaemon } from '../daemon/daemon.js'; +import chalk from 'chalk'; +import { isDaemonRunning, startDaemon, restartDaemon } from '../daemon/daemon.js'; +import { get } from './daemon-client.js'; +import { VERSION } from './version.js'; + +interface HealthResponse { + version: string; +} export const ensureDaemon = async (): Promise => { - if (isDaemonRunning()) return; - await startDaemon(); + if (!isDaemonRunning()) { + await startDaemon(); + return; + } + + // Check if running daemon version matches CLI version + try { + const health = await get('/api/health'); + if (health.version !== VERSION) { + console.log( + chalk.yellow( + `Daemon version mismatch (daemon: ${health.version}, cli: ${VERSION}) — restarting...` + ) + ); + await restartDaemon(); + } + } catch { + // Health check failed — daemon might be starting up, proceed anyway + } }; diff --git a/src/utils/update-checker.test.ts b/src/utils/update-checker.test.ts new file mode 100644 index 0000000..1479646 --- /dev/null +++ b/src/utils/update-checker.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const testDir = join(tmpdir(), `agentage-test-update-${Date.now()}`); + +describe('update-checker', () => { + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + process.env['AGENTAGE_CONFIG_DIR'] = testDir; + vi.resetModules(); + }); + + afterEach(() => { + delete process.env['AGENTAGE_CONFIG_DIR']; + rmSync(testDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('compareVersions', () => { + it('returns true when latest is newer', async () => { + const { compareVersions } = await import('./update-checker.js'); + expect(compareVersions('0.12.3', '0.12.4')).toBe(true); + expect(compareVersions('0.12.3', '0.13.0')).toBe(true); + expect(compareVersions('0.12.3', '1.0.0')).toBe(true); + }); + + it('returns false when current is same or newer', async () => { + const { compareVersions } = await import('./update-checker.js'); + expect(compareVersions('0.12.3', '0.12.3')).toBe(false); + expect(compareVersions('0.12.4', '0.12.3')).toBe(false); + expect(compareVersions('1.0.0', '0.99.99')).toBe(false); + }); + + it('handles v prefix', async () => { + const { compareVersions } = await import('./update-checker.js'); + expect(compareVersions('v0.12.3', 'v0.12.4')).toBe(true); + }); + }); + + describe('checkForUpdate', () => { + it('uses cached result within TTL', async () => { + const cache = { + latestVersion: '0.99.0', + checkedAt: new Date().toISOString(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const { checkForUpdate } = await import('./update-checker.js'); + const result = await checkForUpdate(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.latestVersion).toBe('0.99.0'); + expect(result.updateAvailable).toBe(true); + }); + + it('queries npm when cache is expired', async () => { + const cache = { + latestVersion: '0.1.0', + checkedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ version: '0.99.0' }), { status: 200 }) + ); + + const { checkForUpdate } = await import('./update-checker.js'); + const result = await checkForUpdate(); + + expect(result.latestVersion).toBe('0.99.0'); + + // Verify cache was updated + const updatedCache = JSON.parse(readFileSync(join(testDir, 'update-check.json'), 'utf-8')); + expect(updatedCache.latestVersion).toBe('0.99.0'); + }); + + it('queries npm when forced', async () => { + const cache = { + latestVersion: '0.1.0', + checkedAt: new Date().toISOString(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ version: '0.99.0' }), { status: 200 }) + ); + + const { checkForUpdate } = await import('./update-checker.js'); + const result = await checkForUpdate({ force: true }); + + expect(result.latestVersion).toBe('0.99.0'); + }); + }); + + describe('checkForUpdateSafe', () => { + it('returns null on network failure', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); + + const { checkForUpdateSafe } = await import('./update-checker.js'); + const result = await checkForUpdateSafe({ force: true }); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts new file mode 100644 index 0000000..952d293 --- /dev/null +++ b/src/utils/update-checker.ts @@ -0,0 +1,112 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getConfigDir } from '../daemon/config.js'; +import { VERSION } from './version.js'; + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@agentage/cli/latest'; +const CACHE_FILE = 'update-check.json'; + +interface UpdateCheckCache { + latestVersion: string; + checkedAt: string; +} + +export interface UpdateCheckResult { + currentVersion: string; + latestVersion: string; + updateAvailable: boolean; +} + +const getCachePath = (): string => join(getConfigDir(), CACHE_FILE); + +const readCache = (): UpdateCheckCache | null => { + const cachePath = getCachePath(); + if (!existsSync(cachePath)) return null; + try { + const raw = readFileSync(cachePath, 'utf-8'); + return JSON.parse(raw) as UpdateCheckCache; + } catch { + return null; + } +}; + +const writeCache = (latestVersion: string): void => { + const cachePath = getCachePath(); + const data: UpdateCheckCache = { + latestVersion, + checkedAt: new Date().toISOString(), + }; + writeFileSync(cachePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +}; + +const isCacheValid = (cache: UpdateCheckCache): boolean => { + const checkedAt = new Date(cache.checkedAt).getTime(); + return Date.now() - checkedAt < CACHE_TTL_MS; +}; + +export const compareVersions = (current: string, latest: string): boolean => { + const parse = (v: string): number[] => v.replace(/^v/, '').split('.').map(Number); + const c = parse(current); + const l = parse(latest); + + for (let i = 0; i < Math.max(c.length, l.length); i++) { + const cv = c[i] ?? 0; + const lv = l[i] ?? 0; + if (lv > cv) return true; + if (lv < cv) return false; + } + + return false; +}; + +export const fetchLatestVersion = async (): Promise => { + const response = await fetch(NPM_REGISTRY_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + throw new Error(`npm registry returned ${response.status}`); + } + + const data = (await response.json()) as { version: string }; + return data.version; +}; + +export const checkForUpdate = async ( + options: { force?: boolean } = {} +): Promise => { + const currentVersion = VERSION; + + // Try cache first (unless forced) + if (!options.force) { + const cache = readCache(); + if (cache && isCacheValid(cache)) { + return { + currentVersion, + latestVersion: cache.latestVersion, + updateAvailable: compareVersions(currentVersion, cache.latestVersion), + }; + } + } + + const latestVersion = await fetchLatestVersion(); + writeCache(latestVersion); + + return { + currentVersion, + latestVersion, + updateAvailable: compareVersions(currentVersion, latestVersion), + }; +}; + +export const checkForUpdateSafe = async ( + options: { force?: boolean } = {} +): Promise => { + try { + return await checkForUpdate(options); + } catch { + return null; + } +};