From d8fd662350093d56b39877d50669f75c29cb4971 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Sun, 14 Jun 2026 22:43:53 +0200 Subject: [PATCH] feat(cli): check for updates on setup/status via /api/cli/latest Adds a self-update check: setup (which ends in a status print) and status now hit the public GET {api}/cli/latest and surface an 'update' line - up to date / update available (with 'npm i -g @agentage/cli@latest') / unsupported (below the server-set minSupported floor) / unavailable. Pure compare + evaluate logic, fetch swallows errors (offline = no-op), runs in parallel with the existing reachability probe. No new deps. Backend half: agentage/web#334. --- src/commands/status.test.ts | 23 ++++++++ src/commands/status.ts | 16 +++++ src/lib/status-info.ts | 11 +++- src/lib/update-check.test.ts | 109 +++++++++++++++++++++++++++++++++++ src/lib/update-check.ts | 77 +++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/lib/update-check.test.ts create mode 100644 src/lib/update-check.ts diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 9692ec1..d8e7aad 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -8,6 +8,7 @@ const baseReport: StatusReport = { env: 'production', auth: { signedIn: true, tokenExpiresAt: '2026-06-12T20:00:00Z' }, endpoint: { url: 'https://agentage.io/api', reachable: true }, + update: { status: { kind: 'current' }, message: null }, }; const captureLines = (report: StatusReport): string => { @@ -39,4 +40,26 @@ describe('printStatus', () => { expect(out).toContain('run: agentage setup'); expect(out).toContain('unreachable'); }); + + it('shows the install hint + server notice when an update is available', () => { + const out = captureLines({ + ...baseReport, + update: { status: { kind: 'update-available', latest: '0.3.0' }, message: 'heads up' }, + }); + expect(out).toContain('0.3.0 available'); + expect(out).toContain('npm i -g @agentage/cli@latest'); + expect(out).toContain('heads up'); + }); + + it('flags an unsupported (below-floor) version as update-required', () => { + const out = captureLines({ + ...baseReport, + update: { + status: { kind: 'unsupported', latest: '0.3.0', minSupported: '0.2.0' }, + message: null, + }, + }); + expect(out).toContain('unsupported'); + expect(out).toContain('npm i -g @agentage/cli@latest'); + }); }); diff --git a/src/commands/status.ts b/src/commands/status.ts index 5bf8312..02028d3 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -3,6 +3,7 @@ import { type Command } from 'commander'; import { readAuth } from '../lib/config.js'; import { siteFqdn } from '../lib/origins.js'; import { gatherStatus, type StatusReport } from '../lib/status-info.js'; +import { INSTALL_HINT, type UpdateInfo } from '../lib/update-check.js'; const mark = (good: boolean): string => (good ? chalk.green('✓') : chalk.red('✗')); @@ -16,8 +17,22 @@ const authLine = (auth: StatusReport['auth']): string => { return `${mark(true)} signed in${until}`; }; +const updateLine = (update: UpdateInfo): string => { + switch (update.status.kind) { + case 'current': + return `${mark(true)} up to date`; + case 'update-available': + return chalk.yellow(`↑ ${update.status.latest} available - ${INSTALL_HINT}`); + case 'unsupported': + return chalk.red(`${mark(false)} unsupported, update required - ${INSTALL_HINT}`); + case 'unknown': + return chalk.dim('- update check unavailable'); + } +}; + export const printStatus = (report: StatusReport): void => { row('version', report.version); + row('update', updateLine(report.update)); row('target', `${report.fqdn} (${report.env})`); row('auth', authLine(report.auth)); row( @@ -25,6 +40,7 @@ export const printStatus = (report: StatusReport): void => { `${mark(report.endpoint.reachable)} ${report.endpoint.url} ` + (report.endpoint.reachable ? 'reachable' : 'unreachable') ); + if (report.update.message) console.log(chalk.yellow(`\n${report.update.message}`)); }; export const runStatus = async (opts: { json?: boolean } = {}): Promise => { diff --git a/src/lib/status-info.ts b/src/lib/status-info.ts index 5a22655..8f1f78f 100644 --- a/src/lib/status-info.ts +++ b/src/lib/status-info.ts @@ -1,6 +1,7 @@ import { AuthRequiredError, introspectToken } from './api.js'; import { type AuthState } from './config.js'; import { environment, links, type Env } from './origins.js'; +import { checkForUpdate, type UpdateInfo } from './update-check.js'; import { VERSION } from '../utils/version.js'; export interface StatusReport { @@ -9,6 +10,7 @@ export interface StatusReport { env: Env; auth: { signedIn: boolean; tokenExpiresAt?: string; note?: string }; endpoint: { url: string; reachable: boolean }; + update: UpdateInfo; } const checkEndpoint = async (apiUrl: string): Promise => { @@ -22,12 +24,19 @@ const checkEndpoint = async (apiUrl: string): Promise => { export const gatherStatus = async (auth: AuthState | null, fqdn: string): Promise => { const target = links(fqdn); + // Reachability + update check share the api base and don't depend on each other - run + // them together so `status` stays snappy. + const [reachable, update] = await Promise.all([ + checkEndpoint(target.api), + checkForUpdate(target.api, VERSION), + ]); const report: StatusReport = { version: VERSION, fqdn, env: environment(fqdn), auth: { signedIn: false, note: 'not signed in - run: agentage setup' }, - endpoint: { url: target.api, reachable: await checkEndpoint(target.api) }, + endpoint: { url: target.api, reachable }, + update, }; if (!auth) return report; try { diff --git a/src/lib/update-check.test.ts b/src/lib/update-check.test.ts new file mode 100644 index 0000000..c154774 --- /dev/null +++ b/src/lib/update-check.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { compareVersions, evaluateUpdate, fetchCliLatest, type CliLatest } from './update-check.js'; + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +afterEach(() => vi.unstubAllGlobals()); + +describe('compareVersions', () => { + it('orders by major, minor, then patch', () => { + expect(compareVersions('0.0.1', '0.0.1')).toBe(0); + expect(compareVersions('0.0.1', '0.0.2')).toBe(-1); + expect(compareVersions('0.1.0', '0.0.9')).toBe(1); + expect(compareVersions('1.0.0', '0.9.9')).toBe(1); + expect(compareVersions('0.2.0', '0.10.0')).toBe(-1); // numeric, not lexical + }); + + it('ignores prerelease/build suffixes and missing parts', () => { + expect(compareVersions('0.1.0-rc.1', '0.1.0')).toBe(0); + expect(compareVersions('1', '1.0.0')).toBe(0); + }); +}); + +describe('fetchCliLatest', () => { + it('parses the data envelope', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + jsonResponse(200, { + success: true, + data: { version: '0.2.0', minSupported: '0.1.0', message: 'note' }, + }) + ) + ); + expect(await fetchCliLatest('https://x/api')).toEqual({ + version: '0.2.0', + minSupported: '0.1.0', + message: 'note', + }); + }); + + it('returns null on a non-2xx, a throw, or a malformed body', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(503, {})) + ); + expect(await fetchCliLatest('https://x/api')).toBeNull(); + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('net'))); + expect(await fetchCliLatest('https://x/api')).toBeNull(); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(200, { nope: true })) + ); + expect(await fetchCliLatest('https://x/api')).toBeNull(); + }); + + it('defaults missing fields (floor 0.0.0, no message) when version is present', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(200, { data: { version: '0.2.0' } })) + ); + expect(await fetchCliLatest('https://x/api')).toEqual({ + version: '0.2.0', + minSupported: '0.0.0', + message: null, + }); + }); +}); + +describe('evaluateUpdate', () => { + const latest = (over: Partial = {}): CliLatest => ({ + version: '0.2.0', + minSupported: '0.1.0', + message: null, + ...over, + }); + + it('unknown when the endpoint was unreachable', () => { + expect(evaluateUpdate('0.1.0', null)).toEqual({ status: { kind: 'unknown' }, message: null }); + }); + + it('unsupported when below the floor (and carries the notice)', () => { + expect(evaluateUpdate('0.0.5', latest({ message: 'EOL' }))).toEqual({ + status: { kind: 'unsupported', latest: '0.2.0', minSupported: '0.1.0' }, + message: 'EOL', + }); + }); + + it('update-available when below latest but at/above the floor', () => { + expect(evaluateUpdate('0.1.0', latest()).status).toEqual({ + kind: 'update-available', + latest: '0.2.0', + }); + }); + + it('current when at the latest', () => { + expect(evaluateUpdate('0.2.0', latest()).status).toEqual({ kind: 'current' }); + }); + + it('current when ahead of latest (local dev build)', () => { + expect(evaluateUpdate('0.3.0', latest()).status).toEqual({ kind: 'current' }); + }); + + it('current when the registry version is unknown but the floor is met', () => { + expect(evaluateUpdate('0.1.0', latest({ version: null })).status).toEqual({ kind: 'current' }); + }); +}); diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts new file mode 100644 index 0000000..4bd5ac0 --- /dev/null +++ b/src/lib/update-check.ts @@ -0,0 +1,77 @@ +// Self-update check for `setup`/`status`. Hits the PUBLIC GET {api}/cli/latest (no token - +// it's the one server call that works without auth) to learn the latest published version +// and the server-set support floor. Never throws: an unreachable endpoint = 'unknown'. + +export const INSTALL_HINT = 'npm i -g @agentage/cli@latest'; + +export interface CliLatest { + version: string | null; + minSupported: string; + message: string | null; +} + +export type UpdateStatus = + | { kind: 'current' } + | { kind: 'update-available'; latest: string } + | { kind: 'unsupported'; latest: string | null; minSupported: string } + | { kind: 'unknown' }; // couldn't reach the endpoint + +export interface UpdateInfo { + status: UpdateStatus; + message: string | null; // server notice, verbatim +} + +// Compare dotted numeric versions (major.minor.patch); prerelease/build suffixes are +// ignored - good enough for an update hint. Returns -1 / 0 / 1. +export const compareVersions = (a: string, b: string): number => { + const parts = (v: string): number[] => + v + .split('-')[0] + .split('.') + .map((n) => Number.parseInt(n, 10) || 0); + const pa = parts(a); + const pb = parts(b); + for (let i = 0; i < 3; i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d < 0 ? -1 : 1; + } + return 0; +}; + +export const fetchCliLatest = async ( + apiUrl: string, + timeoutMs = 3000 +): Promise => { + try { + const res = await fetch(`${apiUrl}/cli/latest`, { signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as { data?: Partial } | null; + const d = body?.data; + if (!d) return null; + return { + version: typeof d.version === 'string' ? d.version : null, + minSupported: typeof d.minSupported === 'string' ? d.minSupported : '0.0.0', + message: typeof d.message === 'string' ? d.message : null, + }; + } catch { + return null; + } +}; + +export const evaluateUpdate = (installed: string, latest: CliLatest | null): UpdateInfo => { + if (!latest) return { status: { kind: 'unknown' }, message: null }; + const message = latest.message; + if (compareVersions(installed, latest.minSupported) < 0) { + return { + status: { kind: 'unsupported', latest: latest.version, minSupported: latest.minSupported }, + message, + }; + } + if (latest.version && compareVersions(installed, latest.version) < 0) { + return { status: { kind: 'update-available', latest: latest.version }, message }; + } + return { status: { kind: 'current' }, message }; +}; + +export const checkForUpdate = async (apiUrl: string, installed: string): Promise => + evaluateUpdate(installed, await fetchCliLatest(apiUrl));