From 099f25b221e3790662d258df387c90d162431568 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Mon, 6 Jul 2026 01:02:20 +0200 Subject: [PATCH] feat(vault): offline vault list/add/remove + update verb (M1b) - vault list [--json], vault add --local / --git [--path] [--interval], vault remove (drops the index db, leaves markdown on disk) - update verb: --check reports the verdict; otherwise npm-installs the latest when the registry says we're behind (installer injected for testability) - couchdb-default `vault add` (no flag) is deferred to the provisioning slice (needs the V4 couch call + live API) - it errors with clear guidance for now - vault-registry.ts holds the pure add/remove/index-path/format logic - offline e2e tier (@p0): add -> list -> remove round-trips with no network - 109 unit tests + 2 e2e green; verified the built binary end to end --- e2e/vault-offline.test.ts | 42 ++++++++++++ src/cli.ts | 4 ++ src/commands/update.test.ts | 49 ++++++++++++++ src/commands/update.ts | 73 ++++++++++++++++++++ src/commands/vault.test.ts | 112 +++++++++++++++++++++++++++++++ src/commands/vault.ts | 117 +++++++++++++++++++++++++++++++++ src/lib/vault-registry.test.ts | 92 ++++++++++++++++++++++++++ src/lib/vault-registry.ts | 47 +++++++++++++ 8 files changed, 536 insertions(+) create mode 100644 e2e/vault-offline.test.ts create mode 100644 src/commands/update.test.ts create mode 100644 src/commands/update.ts create mode 100644 src/commands/vault.test.ts create mode 100644 src/commands/vault.ts create mode 100644 src/lib/vault-registry.test.ts create mode 100644 src/lib/vault-registry.ts diff --git a/e2e/vault-offline.test.ts b/e2e/vault-offline.test.ts new file mode 100644 index 0000000..79e40f8 --- /dev/null +++ b/e2e/vault-offline.test.ts @@ -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(); + } + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 06a2504..895cd29 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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(); @@ -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)); diff --git a/src/commands/update.test.ts b/src/commands/update.test.ts new file mode 100644 index 0000000..4c34fcd --- /dev/null +++ b/src/commands/update.test.ts @@ -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'); + }); +}); diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..4fed056 --- /dev/null +++ b/src/commands/update.ts @@ -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; + install: () => Promise; + 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 => { + 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; + } + }); +}; diff --git a/src/commands/vault.test.ts b/src/commands/vault.test.ts new file mode 100644 index 0000000..97d3955 --- /dev/null +++ b/src/commands/vault.test.ts @@ -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/', () => { + 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); + }); +}); diff --git a/src/commands/vault.ts b/src/commands/vault.ts new file mode 100644 index 0000000..f4bdee2 --- /dev/null +++ b/src/commands/vault.ts @@ -0,0 +1,117 @@ +import chalk from 'chalk'; +import { type Command } from 'commander'; +import { + addVault, + ensureVaultDir, + formatVaultLine, + removeIndexDb, + removeVault, +} from '../lib/vault-registry.js'; +import { loadVaultsConfig, saveVaultsConfig, type LoadedVaults } from '../lib/vaults.js'; +import { type VaultsConfig } from '../lib/vaults.schema.js'; + +export interface VaultDeps { + load: () => LoadedVaults; + save: (config: VaultsConfig) => string; + ensureDir: (path: string) => void; + removeIndex: (name: string) => void; + log: (msg: string) => void; +} + +const defaultDeps: VaultDeps = { + load: loadVaultsConfig, + save: saveVaultsConfig, + ensureDir: ensureVaultDir, + removeIndex: removeIndexDb, + log: (msg) => console.log(msg), +}; + +export interface VaultAddOptions { + local?: boolean; + git?: string; + path?: string; + interval?: string; +} + +// Builds the loosely-typed entry; addVault runs it through the schema (fills sync defaults). +const buildEntry = (name: string, opts: VaultAddOptions): object => { + if (opts.local && opts.git) throw new Error('choose one of --local or --git, not both'); + const path = opts.path ?? `~/vaults/${name}`; + if (opts.git) + return { + name, + path, + type: 'git', + remote: opts.git, + ...(opts.interval ? { sync: { interval: opts.interval } } : {}), + }; + if (opts.local) return { name, path, type: 'local' }; + throw new Error( + 'account (couchdb) vaults need provisioning - coming with the sync channel. For now use --local or --git .' + ); +}; + +export const runVaultAdd = ( + name: string, + opts: VaultAddOptions, + deps: VaultDeps = defaultDeps +): void => { + const entry = buildEntry(name, opts); + const { config, vault } = addVault(deps.load().config, entry); + deps.ensureDir(vault.path); + deps.save(config); + deps.log(chalk.green(`Added vault '${vault.name}' (${vault.type}) -> ${vault.path}`)); +}; + +export const runVaultRemove = (name: string, deps: VaultDeps = defaultDeps): void => { + const config = removeVault(deps.load().config, name); + deps.save(config); + deps.removeIndex(name); + deps.log(`Removed vault '${name}' (files left on disk).`); +}; + +export const runVaultList = (opts: { json?: boolean }, deps: VaultDeps = defaultDeps): void => { + const { vaults } = deps.load().config; + if (opts.json) { + deps.log(JSON.stringify(vaults, null, 2)); + return; + } + if (vaults.length === 0) { + deps.log('No vaults registered. Add one with `agentage vault add --local`.'); + return; + } + for (const v of vaults) deps.log(formatVaultLine(v)); +}; + +const guard = (fn: () => void): void => { + try { + fn(); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exitCode = 1; + } +}; + +export const registerVault = (program: Command): void => { + const vault = program.command('vault').description('Manage local memory vaults'); + + vault + .command('list') + .description('List registered vaults') + .option('--json', 'machine-readable output') + .action((opts: { json?: boolean }) => guard(() => runVaultList(opts))); + + vault + .command('add ') + .description('Register a new vault (files stay on disk)') + .option('--local', 'a local folder that never syncs') + .option('--git ', 'sync to an external git remote') + .option('--path ', 'markdown directory (default ~/vaults/)') + .option('--interval ', 'git auto-sync interval (default 5m)') + .action((name: string, opts: VaultAddOptions) => guard(() => runVaultAdd(name, opts))); + + vault + .command('remove ') + .description('Unregister a vault and drop its index (files stay)') + .action((name: string) => guard(() => runVaultRemove(name))); +}; diff --git a/src/lib/vault-registry.test.ts b/src/lib/vault-registry.test.ts new file mode 100644 index 0000000..3416dac --- /dev/null +++ b/src/lib/vault-registry.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { + addVault, + expandHome, + formatVaultLine, + indexDbPath, + removeVault, +} from './vault-registry.js'; +import { VaultsConfig } from './vaults.schema.js'; + +const base = (): VaultsConfig => VaultsConfig.parse({ version: 1 }); + +describe('addVault', () => { + it('appends a local vault verbatim', () => { + const { config, vault } = addVault(base(), { + name: 'scratch', + type: 'local', + path: '~/vaults/scratch', + }); + expect(vault).toEqual({ name: 'scratch', type: 'local', path: '~/vaults/scratch' }); + expect(config.vaults).toHaveLength(1); + }); + + it('fills the git sync defaults', () => { + const { vault } = addVault(base(), { + name: 'work', + type: 'git', + path: '~/w', + remote: 'git@github.com:me/w.git', + }); + expect(vault).toMatchObject({ + type: 'git', + sync: { auto: true, interval: '5m', message: 'vault: auto-sync' }, + }); + }); + + it('rejects a duplicate name', () => { + const { config } = addVault(base(), { name: 'a', type: 'local', path: '~/a' }); + expect(() => addVault(config, { name: 'a', type: 'local', path: '~/b' })).toThrow( + /already exists/ + ); + }); + + it('rejects an invalid name via the schema', () => { + expect(() => addVault(base(), { name: 'bad name', type: 'local', path: '~/x' })).toThrow(); + }); + + it('rejects a git vault with no remote', () => { + expect(() => addVault(base(), { name: 'g', type: 'git', path: '~/g' })).toThrow(); + }); +}); + +describe('removeVault', () => { + it('removes an existing vault', () => { + const { config } = addVault(base(), { name: 'a', type: 'local', path: '~/a' }); + expect(removeVault(config, 'a').vaults).toHaveLength(0); + }); + + it('throws when the vault is absent', () => { + expect(() => removeVault(base(), 'nope')).toThrow(/not found/); + }); +}); + +describe('paths + formatting', () => { + it('expands a leading ~/ against HOME', () => { + const prev = process.env['HOME']; + process.env['HOME'] = '/home/tester'; + expect(expandHome('~/vaults/x')).toBe('/home/tester/vaults/x'); + expect(expandHome('/abs/x')).toBe('/abs/x'); + process.env['HOME'] = prev; + }); + + it('places the index db under the config dir', () => { + const prev = process.env['AGENTAGE_CONFIG_DIR']; + process.env['AGENTAGE_CONFIG_DIR'] = '/tmp/cfg'; + expect(indexDbPath('work')).toBe('/tmp/cfg/index/work.db'); + process.env['AGENTAGE_CONFIG_DIR'] = prev; + }); + + it('formats a git line with its remote', () => { + const { vault } = addVault(base(), { + name: 'work', + type: 'git', + path: '~/w', + remote: 'git@github.com:me/w.git', + }); + const line = formatVaultLine(vault); + expect(line).toContain('work'); + expect(line).toContain('git'); + expect(line).toContain('git@github.com:me/w.git'); + }); +}); diff --git a/src/lib/vault-registry.ts b/src/lib/vault-registry.ts new file mode 100644 index 0000000..acded4b --- /dev/null +++ b/src/lib/vault-registry.ts @@ -0,0 +1,47 @@ +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { getConfigDir } from './config.js'; +import { Vault, VaultsConfig } from './vaults.schema.js'; + +// Offline registry operations over an in-memory VaultsConfig: no network, no provisioning. +// The couchdb (account) path lives in a later slice - these cover local + git vaults. + +export const expandHome = (p: string): string => + p.startsWith('~/') ? join(process.env['HOME'] || homedir(), p.slice(2)) : p; + +export const indexDir = (): string => join(getConfigDir(), 'index'); +export const indexDbPath = (name: string): string => join(indexDir(), `${name}.db`); + +// The markdown directory is created on `vault add` if missing (~ is expanded). +export const ensureVaultDir = (path: string): void => { + const dir = expandHome(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); +}; + +// The index is a rebuildable cache: dropping it on remove never touches the markdown. +export const removeIndexDb = (name: string): void => { + const db = indexDbPath(name); + if (existsSync(db)) rmSync(db, { force: true }); +}; + +export const formatVaultLine = (v: Vault): string => { + const extra = + v.type === 'git' ? ` <- ${v.remote}` : v.type === 'couchdb' ? ` (${v.server})` : ''; + return `${v.name.padEnd(16)} ${v.type.padEnd(8)} ${v.path}${extra}`; +}; + +export const addVault = ( + config: VaultsConfig, + entry: unknown +): { config: VaultsConfig; vault: Vault } => { + const vault = Vault.parse(entry); + if (config.vaults.some((v) => v.name === vault.name)) + throw new Error(`vault '${vault.name}' already exists`); + return { config: VaultsConfig.parse({ ...config, vaults: [...config.vaults, vault] }), vault }; +}; + +export const removeVault = (config: VaultsConfig, name: string): VaultsConfig => { + if (!config.vaults.some((v) => v.name === name)) throw new Error(`vault '${name}' not found`); + return VaultsConfig.parse({ ...config, vaults: config.vaults.filter((v) => v.name !== name) }); +};