Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node

import chalk from 'chalk';
import { Command } from 'commander';
import { registerDaemon } from './commands/daemon-cmd.js';
import { registerMcp } from './commands/mcp.js';
Expand All @@ -9,6 +10,7 @@ import { registerStatus } from './commands/status.js';
import { registerUpdate } from './commands/update.js';
import { registerVault } from './commands/vault.js';
import { disableDaemon } from './lib/daemon-pref.js';
import { refreshUpdateCache, updateHint } from './lib/update-cache.js';
import { VERSION } from './utils/version.js';

const program = new Command();
Expand All @@ -23,6 +25,20 @@ program.hook('preAction', () => {
if (program.opts().daemon === false) disableDaemon();
});

// After any command (except the update-aware ones, which report richer state themselves), print a
// one-line hint from the cache, then kick a fire-and-forget refresh. Never awaited, never throws -
// the command output is already done and the cache-only read keeps this instant. The hint is
// suppressed under --json so it never corrupts machine-readable output.
program.hook('postAction', (_thisCommand, actionCommand) => {
const name = actionCommand.name();
if (name === 'status' || name === 'update') return;
if (!actionCommand.opts()['json']) {
const hint = updateHint();
if (hint) console.log(chalk.dim(hint));
}
void refreshUpdateCache();
});

registerSetup(program);
registerStatus(program);
registerVault(program);
Expand Down
94 changes: 89 additions & 5 deletions src/commands/update.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import type { UpdateInfo } from '../lib/update-check.js';
import { runUpdate, type UpdateDeps } from './update.js';
import { restartDaemonIfRunning, runUpdate, type UpdateDeps } from './update.js';

const makeDeps = (status: UpdateInfo['status']) => {
const makeDeps = (status: UpdateInfo['status'], over: Partial<UpdateDeps> = {}) => {
const logs: string[] = [];
const install = vi.fn(async () => {});
const install = over.install ?? vi.fn(async () => {});
const restartDaemon = over.restartDaemon ?? vi.fn(async () => 'not-running' as const);
const releaseLock = over.releaseLock ?? vi.fn(() => {});
const deps: UpdateDeps = {
check: async () => ({ status, message: null }),
install,
restartDaemon,
acquireLock: over.acquireLock ?? (() => true),
releaseLock,
log: (m) => logs.push(m),
};
return { deps, logs, install };
return { deps, logs, install, restartDaemon, releaseLock };
};

describe('update', () => {
it('installs when an update is available', async () => {
it('installs (and releases the lock) when an update is available', async () => {
const h = makeDeps({ kind: 'update-available', latest: '0.0.9' });
await runUpdate({}, h.deps);
expect(h.install).toHaveBeenCalledOnce();
expect(h.logs.join()).toContain('Updated');
expect(h.releaseLock).toHaveBeenCalledOnce();
});

it('installs when the running version is unsupported', async () => {
Expand Down Expand Up @@ -46,4 +52,82 @@ describe('update', () => {
expect(h.install).not.toHaveBeenCalled();
expect(h.logs.join()).toContain('Update available');
});

it('announces the restart after installing when the daemon was running', async () => {
const h = makeDeps(
{ kind: 'update-available', latest: '0.0.9' },
{ restartDaemon: vi.fn(async () => 'restarted' as const) }
);
await runUpdate({}, h.deps);
expect(h.restartDaemon).toHaveBeenCalledOnce();
expect(h.logs.join()).toContain('Restarted the daemon');
});

it('reports honestly when the daemon did not come back up', async () => {
const h = makeDeps(
{ kind: 'update-available', latest: '0.0.9' },
{ restartDaemon: vi.fn(async () => 'failed' as const) }
);
await runUpdate({}, h.deps);
expect(h.logs.join()).not.toContain('Restarted the daemon');
expect(h.logs.join()).toContain('did not restart cleanly');
});

it('does not announce a restart when the daemon was not running', async () => {
const h = makeDeps({ kind: 'update-available', latest: '0.0.9' });
await runUpdate({}, h.deps);
expect(h.restartDaemon).toHaveBeenCalledOnce();
expect(h.logs.join()).not.toContain('Restarted the daemon');
});

it('refuses to install when another update holds the lock', async () => {
const h = makeDeps({ kind: 'update-available', latest: '0.0.9' }, { acquireLock: () => false });
await runUpdate({}, h.deps);
expect(h.install).not.toHaveBeenCalled();
expect(h.logs.join()).toContain('in progress');
expect(h.releaseLock).not.toHaveBeenCalled();
});

it('releases the lock when the install throws (finally path)', async () => {
const h = makeDeps(
{ kind: 'update-available', latest: '0.0.9' },
{
install: vi.fn(async () => {
throw new Error('npm exploded');
}),
}
);
await expect(runUpdate({}, h.deps)).rejects.toThrow('npm exploded');
expect(h.releaseLock).toHaveBeenCalledOnce();
});
});

describe('restartDaemonIfRunning', () => {
it('stops the old daemon BEFORE starting the new one', async () => {
const order: string[] = [];
const stop = vi.fn(async () => {
order.push('stop');
return true;
});
const start = vi.fn(async () => {
order.push('start');
return true;
});
expect(await restartDaemonIfRunning({ running: () => true, stop, start })).toBe('restarted');
expect(order).toEqual(['stop', 'start']);
});

it('returns failed when the new daemon does not come up', async () => {
const stop = vi.fn(async () => true);
const start = vi.fn(async () => false);
expect(await restartDaemonIfRunning({ running: () => true, stop, start })).toBe('failed');
});

it('is a no-op when the daemon is not running', async () => {
const stop = vi.fn(async () => true);
const start = vi.fn(async () => true);
expect(await restartDaemonIfRunning({ running: () => false, stop, start })).toBe('not-running');
expect(stop).not.toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
});
});
61 changes: 53 additions & 8 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,39 @@ import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import chalk from 'chalk';
import { type Command } from 'commander';
import { links, siteFqdn } from '../lib/origins.js';
import { isDaemonRunning, resolvePort, stopDaemonAndWait } from '../daemon/lifecycle.js';
import { spawnDaemon } from '../lib/daemon-client.js';
import { checkForUpdate, INSTALL_HINT, type UpdateInfo } from '../lib/update-check.js';
import { acquireUpdateLock, releaseUpdateLock } from '../lib/update-lock.js';
import { VERSION } from '../utils/version.js';

const pexec = promisify(execFile);

export type RestartOutcome = 'restarted' | 'failed' | 'not-running';

export interface RestartDeps {
running?: () => boolean;
stop?: () => Promise<boolean>;
start?: (port: number) => Promise<boolean>;
}

// Restart a running daemon so it picks up the freshly installed binary; a stopped daemon is left
// stopped. Waits for the old process to exit (port free) before spawning, and reports honestly
// when the new daemon did not come up.
export const restartDaemonIfRunning = async (deps: RestartDeps = {}): Promise<RestartOutcome> => {
const running = deps.running ?? isDaemonRunning;
if (!running()) return 'not-running';
await (deps.stop ?? stopDaemonAndWait)();
const up = await (deps.start ?? spawnDaemon)(resolvePort());
return up ? 'restarted' : 'failed';
};

export interface UpdateDeps {
check: (apiUrl: string, installed: string) => Promise<UpdateInfo>;
check: (installed: string) => Promise<UpdateInfo>;
install: () => Promise<void>;
restartDaemon: () => Promise<RestartOutcome>;
acquireLock: () => boolean;
releaseLock: () => void;
log: (msg: string) => void;
}

Expand All @@ -19,6 +43,9 @@ const defaultDeps: UpdateDeps = {
install: async () => {
await pexec('npm', ['install', '-g', '@agentage/cli@latest']);
},
restartDaemon: () => restartDaemonIfRunning(),
acquireLock: () => acquireUpdateLock(),
releaseLock: releaseUpdateLock,
log: (msg) => console.log(msg),
};

Expand All @@ -42,19 +69,37 @@ const describe = (info: UpdateInfo): string => {
}
};

// --check reports the verdict and never installs. Otherwise, install only when the
// registry says we're behind (available/unsupported); current/unreachable just report.
// Install, then restart a running daemon so its in-process engine runs the new version.
const install = async (deps: UpdateDeps): Promise<void> => {
deps.log('Installing the latest @agentage/cli...');
await deps.install();
deps.log(chalk.green('Updated. Run `agentage status` to confirm.'));
const outcome = await deps.restartDaemon();
if (outcome === 'restarted') deps.log(chalk.green('Restarted the daemon on the new version.'));
if (outcome === 'failed')
deps.log(chalk.yellow('Daemon did not restart cleanly - run `agentage daemon start`.'));
};

// --check reports the verdict and never installs. Otherwise, install only when the registry says
// we're behind (available/unsupported), guarded by a single-writer lock; current/unreachable just
// report.
export const runUpdate = async (
opts: UpdateOptions,
deps: UpdateDeps = defaultDeps
): Promise<void> => {
const info = await deps.check(links(siteFqdn()).api, VERSION);
const info = await deps.check(VERSION);
deps.log(describe(info));
const behind = info.status.kind === 'update-available' || info.status.kind === 'unsupported';
if (opts.check || !behind) return;
deps.log('Installing the latest @agentage/cli...');
await deps.install();
deps.log(chalk.green('Updated. Run `agentage status` to confirm.'));
if (!deps.acquireLock()) {
deps.log(chalk.yellow('Another update is already in progress; skipping.'));
return;
}
try {
await install(deps);
} finally {
deps.releaseLock();
}
};

export const registerUpdate = (program: Command): void => {
Expand Down
19 changes: 19 additions & 0 deletions src/daemon/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { spawn } from 'node:child_process';
import {
DEFAULT_DAEMON_PORT,
isDaemonRunning,
Expand All @@ -10,6 +11,7 @@ import {
removePortFile,
resolvePort,
stopDaemon,
stopDaemonAndWait,
writePidFile,
writePortFile,
} from './lifecycle.js';
Expand Down Expand Up @@ -101,3 +103,20 @@ describe('stopDaemon', () => {
expect(isDaemonRunning()).toBe(false);
});
});

describe('stopDaemonAndWait', () => {
it('returns true immediately when nothing is running', async () => {
expect(await stopDaemonAndWait()).toBe(true);
writePidFile(DEAD_PID);
expect(await stopDaemonAndWait()).toBe(true);
});

it('signals a real process and waits for it to be gone', async () => {
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: 'ignore',
});
writePidFile(child.pid!);
expect(await stopDaemonAndWait(5000)).toBe(true);
expect(isProcessAlive(child.pid!)).toBe(false);
});
});
14 changes: 14 additions & 0 deletions src/daemon/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,17 @@ export const stopDaemon = (): boolean => {
removePortFile();
return alive;
};

// Stop, then wait (bounded) for the old process to actually exit so a restart can rebind the
// port without an EADDRINUSE window. Returns whether the process is confirmed gone.
export const stopDaemonAndWait = async (timeoutMs = 2000): Promise<boolean> => {
const pid = readPid();
const signalled = stopDaemon();
if (!signalled || pid === null) return true;
const deadline = Date.now() + timeoutMs;
while (isProcessAlive(pid)) {
if (Date.now() >= deadline) return false;
await new Promise((r) => setTimeout(r, 50));
}
return true;
};
10 changes: 9 additions & 1 deletion src/lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -42,6 +42,14 @@ describe('config store', () => {
expect(mode).toBe(0o600);
});

it('writes atomically, leaving no temp file behind', () => {
saveAuth(sample);
saveAuth({ ...sample, clientId: 'client-2' });
expect(existsSync(join(getConfigDir(), 'auth.json.tmp'))).toBe(false);
expect(readAuth()?.clientId).toBe('client-2');
expect(statSync(join(getConfigDir(), 'auth.json')).mode & 0o777).toBe(0o600);
});

it('returns null for corrupt auth files', () => {
ensureConfigDir();
writeFileSync(join(getConfigDir(), 'auth.json'), 'not json');
Expand Down
18 changes: 15 additions & 3 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -36,11 +44,15 @@ export const readAuth = (): AuthState | null => {
}
};

// Atomic 0600 write: a token file is never left half-written or briefly world-readable. Write a
// sibling temp, chmod it 0600 BEFORE the rename, then rename over the target in one step.
export const saveAuth = (state: AuthState): void => {
ensureConfigDir();
const path = authPath();
writeFileSync(path, JSON.stringify(state, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
chmodSync(path, 0o600);
const tmp = `${path}.tmp`;
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, path);
};

export const deleteAuth = (): void => {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/status-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });

const stubFetch = (routes: Record<string, () => Response>): void => {
// The update check now reads the npm registry; default it to "current" so it never adds noise.
const all = { 'cli/latest': () => jsonResponse(200, { version: '0.0.0' }), ...routes };
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
for (const [suffix, response] of Object.entries(routes)) {
for (const [suffix, response] of Object.entries(all)) {
if (url.endsWith(suffix)) return Promise.resolve(response());
}
return Promise.reject(new Error(`unmatched url: ${url}`));
Expand Down
6 changes: 3 additions & 3 deletions src/lib/status-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ 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.
// Endpoint reachability and the (npm-registry) update check are independent - run them
// together so `status` stays snappy.
const [reachable, update] = await Promise.all([
checkEndpoint(target.api),
checkForUpdate(target.api, VERSION),
checkForUpdate(VERSION),
]);
const report: StatusReport = {
version: VERSION,
Expand Down
Loading