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
19 changes: 18 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdateCheckResult | null> = checkForUpdateSafe();

program.name('agentage').description('Agentage CLI — control plane for AI agents').version(VERSION);

registerRun(program);
Expand All @@ -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);
});
89 changes: 89 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -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);
});
};
1 change: 1 addition & 0 deletions src/hub/hub-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
5 changes: 3 additions & 2 deletions src/hub/hub-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
getMachines: () => Promise<unknown[]>;
getAgents: (machineId?: string) => Promise<unknown[]>;
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/hub/hub-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});

Expand Down
1 change: 1 addition & 0 deletions src/hub/hub-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions src/utils/ensure-daemon.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
if (isDaemonRunning()) return;
await startDaemon();
if (!isDaemonRunning()) {
await startDaemon();
return;
}

// Check if running daemon version matches CLI version
try {
const health = await get<HealthResponse>('/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
}
};
108 changes: 108 additions & 0 deletions src/utils/update-checker.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading