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
42 changes: 42 additions & 0 deletions e2e/vault-offline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { assertCliBuilt, createCliMachine } from './helpers.js';

// The offline registry: add/list/remove work with no network and no auth. @p0
test.describe('offline vault registry @p0', () => {
test.beforeAll(() => assertCliBuilt());

test('add --local -> list -> remove round-trips with no network', async () => {
const machine = createCliMachine();
try {
const path = join(machine.configDir, 'scratch');
const added = await machine.exec(['vault', 'add', 'scratch', '--local', '--path', path]);
expect(added.code, added.stderr).toBe(0);

const listed = await machine.exec(['vault', 'list', '--json']);
expect(listed.code, listed.stderr).toBe(0);
const vaults = JSON.parse(listed.stdout) as Array<{ name: string; type: string }>;
expect(vaults).toHaveLength(1);
expect(vaults[0]).toMatchObject({ name: 'scratch', type: 'local' });

const removed = await machine.exec(['vault', 'remove', 'scratch']);
expect(removed.code, removed.stderr).toBe(0);

const after = await machine.exec(['vault', 'list', '--json']);
expect(JSON.parse(after.stdout)).toHaveLength(0);
} finally {
machine.cleanup();
}
});

test('vault add without --local/--git fails clearly (needs provisioning)', async () => {
const machine = createCliMachine();
try {
const res = await machine.exec(['vault', 'add', 'acct']);
expect(res.code).not.toBe(0);
expect(res.stderr + res.stdout).toContain('provisioning');
} finally {
machine.cleanup();
}
});
});
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { Command } from 'commander';
import { registerSetup } from './commands/setup.js';
import { registerStatus } from './commands/status.js';
import { registerUpdate } from './commands/update.js';
import { registerVault } from './commands/vault.js';
import { VERSION } from './utils/version.js';

const program = new Command();
Expand All @@ -11,6 +13,8 @@ program.name('agentage').description('The agentage CLI').version(VERSION);

registerSetup(program);
registerStatus(program);
registerVault(program);
registerUpdate(program);

program.parseAsync().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
Expand Down
49 changes: 49 additions & 0 deletions src/commands/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
import type { UpdateInfo } from '../lib/update-check.js';
import { runUpdate, type UpdateDeps } from './update.js';

const makeDeps = (status: UpdateInfo['status']) => {
const logs: string[] = [];
const install = vi.fn(async () => {});
const deps: UpdateDeps = {
check: async () => ({ status, message: null }),
install,
log: (m) => logs.push(m),
};
return { deps, logs, install };
};

describe('update', () => {
it('installs 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');
});

it('installs when the running version is unsupported', async () => {
const h = makeDeps({ kind: 'unsupported', latest: '0.0.9', minSupported: '0.0.5' });
await runUpdate({}, h.deps);
expect(h.install).toHaveBeenCalledOnce();
});

it('does not install when already current', async () => {
const h = makeDeps({ kind: 'current' });
await runUpdate({}, h.deps);
expect(h.install).not.toHaveBeenCalled();
expect(h.logs.join()).toContain('latest');
});

it('does not install when the registry is unreachable', async () => {
const h = makeDeps({ kind: 'unknown' });
await runUpdate({}, h.deps);
expect(h.install).not.toHaveBeenCalled();
});

it('--check reports without installing, even when behind', async () => {
const h = makeDeps({ kind: 'update-available', latest: '0.0.9' });
await runUpdate({ check: true }, h.deps);
expect(h.install).not.toHaveBeenCalled();
expect(h.logs.join()).toContain('Update available');
});
});
73 changes: 73 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 { checkForUpdate, INSTALL_HINT, type UpdateInfo } from '../lib/update-check.js';
import { VERSION } from '../utils/version.js';

const pexec = promisify(execFile);

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

const defaultDeps: UpdateDeps = {
check: checkForUpdate,
install: async () => {
await pexec('npm', ['install', '-g', '@agentage/cli@latest']);
},
log: (msg) => console.log(msg),
};

export interface UpdateOptions {
check?: boolean;
}

const describe = (info: UpdateInfo): string => {
const s = info.status;
switch (s.kind) {
case 'current':
return chalk.green(`Already on the latest version (${VERSION}).`);
case 'update-available':
return chalk.yellow(`Update available: ${VERSION} -> ${s.latest}.`);
case 'unsupported':
return chalk.red(
`Unsupported version ${VERSION} - update required (latest ${s.latest ?? '?'}).`
);
case 'unknown':
return chalk.yellow(`Couldn't reach the registry - try again later. ${INSTALL_HINT}`);
}
};

// --check reports the verdict and never installs. Otherwise, install only when the
// registry says we're behind (available/unsupported); current/unreachable just report.
export const runUpdate = async (
opts: UpdateOptions,
deps: UpdateDeps = defaultDeps
): Promise<void> => {
const info = await deps.check(links(siteFqdn()).api, 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.'));
};

export const registerUpdate = (program: Command): void => {
program
.command('update')
.description('Update @agentage/cli to the latest published version')
.option('--check', 'report whether an update is available, without installing')
.action(async (opts: UpdateOptions) => {
try {
await runUpdate(opts);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exitCode = 1;
}
});
};
112 changes: 112 additions & 0 deletions src/commands/vault.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { VaultsConfig } from '../lib/vaults.schema.js';
import { runVaultAdd, runVaultList, runVaultRemove, type VaultDeps } from './vault.js';

const makeDeps = (initial: VaultsConfig = VaultsConfig.parse({ version: 1 })) => {
let config = initial;
const logs: string[] = [];
const ensured: string[] = [];
const removedIndex: string[] = [];
const deps: VaultDeps = {
load: () => ({ config, source: null }),
save: (c) => {
config = c;
return '/tmp/vaults.json';
},
ensureDir: (p) => ensured.push(p),
removeIndex: (n) => removedIndex.push(n),
log: (m) => logs.push(m),
};
return { deps, logs, ensured, removedIndex, get: () => config };
};

describe('vault add', () => {
it('registers a --local vault, creates its dir, saves', () => {
const h = makeDeps();
runVaultAdd('scratch', { local: true, path: '/tmp/scratch' }, h.deps);
expect(h.get().vaults).toMatchObject([
{ name: 'scratch', type: 'local', path: '/tmp/scratch' },
]);
expect(h.ensured).toEqual(['/tmp/scratch']);
expect(h.logs.join()).toContain("Added vault 'scratch'");
});

it('registers a --git vault with a remote + interval', () => {
const h = makeDeps();
runVaultAdd(
'work',
{ git: 'git@github.com:me/w.git', interval: '10m', path: '/tmp/w' },
h.deps
);
expect(h.get().vaults[0]).toMatchObject({
type: 'git',
remote: 'git@github.com:me/w.git',
sync: { interval: '10m' },
});
});

it('defaults the path to ~/vaults/<name>', () => {
const h = makeDeps();
runVaultAdd('notes', { local: true }, h.deps);
expect(h.ensured).toEqual(['~/vaults/notes']);
expect(h.get().vaults[0]).toMatchObject({ path: '~/vaults/notes' });
});

it('rejects the couchdb default (no flag) as needing provisioning', () => {
const h = makeDeps();
expect(() => runVaultAdd('acct', {}, h.deps)).toThrow(/provisioning/);
});

it('rejects --local and --git together', () => {
const h = makeDeps();
expect(() => runVaultAdd('x', { local: true, git: 'g' }, h.deps)).toThrow(/one of/);
});

it('rejects a duplicate name', () => {
const h = makeDeps();
runVaultAdd('a', { local: true, path: '/tmp/a' }, h.deps);
expect(() => runVaultAdd('a', { local: true, path: '/tmp/a2' }, h.deps)).toThrow(
/already exists/
);
});
});

describe('vault remove', () => {
it('removes the vault, drops its index, keeps files', () => {
const h = makeDeps();
runVaultAdd('a', { local: true, path: '/tmp/a' }, h.deps);
runVaultRemove('a', h.deps);
expect(h.get().vaults).toHaveLength(0);
expect(h.removedIndex).toEqual(['a']);
});

it('throws when the vault is absent', () => {
const h = makeDeps();
expect(() => runVaultRemove('nope', h.deps)).toThrow(/not found/);
});
});

describe('vault list', () => {
it('prints a friendly hint when empty', () => {
const h = makeDeps();
runVaultList({}, h.deps);
expect(h.logs.join()).toContain('No vaults registered');
});

it('prints one line per vault', () => {
const h = makeDeps();
runVaultAdd('a', { local: true, path: '/tmp/a' }, h.deps);
runVaultAdd('b', { git: 'g@h:x.git', path: '/tmp/b' }, h.deps);
h.logs.length = 0;
runVaultList({}, h.deps);
expect(h.logs).toHaveLength(2);
});

it('emits JSON with --json', () => {
const h = makeDeps();
runVaultAdd('a', { local: true, path: '/tmp/a' }, h.deps);
h.logs.length = 0;
runVaultList({ json: true }, h.deps);
expect(JSON.parse(h.logs[0] ?? '[]')).toHaveLength(1);
});
});
Loading
Loading