diff --git a/src/cli/index.ts b/src/cli/index.ts index 1f9cdb4..b59cdc0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -317,7 +317,7 @@ async function run(args: Args): Promise { return commandUse(args); case 'sync': { const sync = await import('../client/sync.ts'); - const sub = args.positional[1] ?? 'status'; + const sub = args.positional[0] ?? 'status'; const server = flagString(args, 'server', 's'); const options = { ...(server !== undefined ? { server } : {}), userAgent: userAgent() }; const force = Boolean(args.flags['force']); diff --git a/test/cli-sync.test.ts b/test/cli-sync.test.ts new file mode 100644 index 0000000..c692dfb --- /dev/null +++ b/test/cli-sync.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { VERSION } from '../dist/config.js'; +import { calls } from './fixtures/cli-sync.ts'; + +const entry = new URL('../dist/cli/index.js', import.meta.url); +const fixture = new URL('./fixtures/cli-sync.ts', import.meta.url); +const statusOutput = 'here never synced\nboard nothing yet\n'; +const savedOutput = 'Saved revision 7: the boards you use.\n'; +const plannedOutput = 'added boards.json\nWould take revision 7; nothing written.\n'; +const cases = [ + { argv: ['sync'], method: 'status', options: {}, stdout: statusOutput }, + { argv: ['sync', 'status'], method: 'status', options: {}, stdout: statusOutput }, + { argv: ['sync', 'save'], method: 'save', options: { force: false }, stdout: savedOutput }, + { argv: ['sync', 'load'], method: 'load', options: { force: false, dryRun: false }, stdout: 'Loaded revision 7.\n' }, + { argv: ['sync', 'revisions'], method: 'revisions', options: {}, stdout: ' 7 2026-09-15 00:00 fixture 42 bytes\n' }, + { argv: ['sync', 'unknown-action'], method: null, options: {}, stdout: '', exitCode: 1, + stderr: 'Unknown: agenticjobs sync unknown-action. Try status, save, load or revisions.\n' }, + { argv: ['sync', 'save', '--force'], method: 'save', options: { force: true }, stdout: savedOutput }, + { argv: ['sync', 'load', '--dry-run'], method: 'load', options: { force: false, dryRun: true }, stdout: plannedOutput }, + { argv: ['sync', 'load', '--force', '--dry-run'], method: 'load', options: { force: true, dryRun: true }, stdout: plannedOutput }, +]; + +test('compiled CLI dispatches settings sync subcommands and flags', async (t) => { + // Redirect only this CLI import. The compiled entry point and parser run + // unchanged; other tests can still import the real sync module. + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === '../client/sync.js' && context.parentURL?.startsWith(`${entry.href}?`)) { + return { url: fixture.href, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + }); + t.after(() => hooks.deregister()); + const fetch = t.mock.method(globalThis, 'fetch', async () => { + throw new Error('Unexpected network request in sync dispatch test'); + }); + + // Sequential subtests restore process state before reporting their results. + for (const [index, example] of cases.entries()) { + await t.test(example.argv.join(' '), async (t) => { + const argv = process.argv; + const exitCode = process.exitCode; + t.after(() => { + process.argv = argv; + process.exitCode = exitCode; + calls.length = 0; + }); + calls.length = 0; + process.argv = [process.execPath, fileURLToPath(entry), ...example.argv]; + process.exitCode = 0; + let stdout = ''; + let stderr = ''; + t.mock.method(process.stdout, 'write', (chunk) => { stdout += String(chunk); return true; }); + t.mock.method(process.stderr, 'write', (chunk) => { stderr += String(chunk); return true; }); + + // A fresh URL reruns the actual top-level CLI for each invocation. + await import(`${entry.href}?sync-dispatch=${index}`); + + assert.equal(process.exitCode, example.exitCode ?? 0, 'exit code'); + assert.deepEqual([...calls], example.method === null ? [] : [{ + method: example.method, + options: { userAgent: `agenticjobs-cli/${VERSION}`, ...example.options }, + }], 'selected sync operation and options'); + assert.equal(stdout, example.stdout, 'stdout'); + assert.equal(stderr, example.stderr ?? '', 'stderr'); + assert.equal(fetch.mock.callCount(), 0, 'no network requests'); + }); + } +}); diff --git a/test/fixtures/cli-sync.ts b/test/fixtures/cli-sync.ts new file mode 100644 index 0000000..bd16764 --- /dev/null +++ b/test/fixtures/cli-sync.ts @@ -0,0 +1,28 @@ +import type { SyncOptions } from '../../dist/client/sync.js'; + +type Options = SyncOptions & { force?: boolean; dryRun?: boolean }; +export const calls: { method: string; options: Options }[] = []; + +export async function syncStatus(options: Options) { + calls.push({ method: 'status', options }); + return { marker: null, drifted: [], behind: false }; +} + +export async function syncSave(options: Options) { + calls.push({ method: 'save', options }); + return { status: 'saved', revision: 7 }; +} + +export async function syncLoad(options: Options) { + calls.push({ method: 'load', options }); + return options.dryRun + ? { status: 'planned', revision: 7, plan: [{ status: 'added', path: 'boards.json' }] } + : { status: 'loaded', revision: 7, added: [], directoriesAdded: [] }; +} + +export function syncContext(options: Options) { + return { client: { async revisions() { + calls.push({ method: 'revisions', options }); + return [{ revision: 7, savedAt: '2026-09-15T00:00:00.000Z', host: 'fixture', size: 42 }]; + } } }; +}