Skip to content
Draft
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
23 changes: 23 additions & 0 deletions src/commands/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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');
});
});
16 changes: 16 additions & 0 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('✗'));

Expand All @@ -16,15 +17,30 @@ 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(
'endpoint',
`${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<void> => {
Expand Down
11 changes: 10 additions & 1 deletion src/lib/status-info.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<boolean> => {
Expand All @@ -22,12 +24,19 @@ const checkEndpoint = async (apiUrl: string): Promise<boolean> => {

export const gatherStatus = async (auth: AuthState | null, fqdn: string): Promise<StatusReport> => {
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 {
Expand Down
109 changes: 109 additions & 0 deletions src/lib/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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' });
});
});
77 changes: 77 additions & 0 deletions src/lib/update-check.ts
Original file line number Diff line number Diff line change
@@ -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<CliLatest | null> => {
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<CliLatest> } | 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<UpdateInfo> =>
evaluateUpdate(installed, await fetchCliLatest(apiUrl));