import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Command } from 'commander'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { registerFleetMigrationCommand } from './fleet-migration-command.js'; let cleanup: string | undefined; afterEach(async (): Promise => { if (cleanup) await rm(cleanup, { recursive: true, force: true }); cleanup = undefined; }); describe('fleet migrate-v1 preview command', (): void => { it('emits one ready JSON result without any runtime or file mutation API', async () => { cleanup = await mkdtemp(join(tmpdir(), 'fleet-migration-command-')); const rolesDir = join(cleanup, 'roles'); const overrideDir = join(cleanup, 'roles.local'); await mkdir(rolesDir); await mkdir(overrideDir); await writeFile(join(rolesDir, 'code.md'), '# code\n'); const files: Record = { source: `version: 1\ntransport: tmux\ntmux:\n socket_name: test\ndefaults:\n working_directory: /srv\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n runtime: pi\n class: implementer\n`, decisions: JSON.stringify({ generation: 2, defaultRuntime: 'pi', agents: { coder0: { provider: 'openai', model: 'gpt-5.6-sol', reasoning: 'high', enabled: true, launchYolo: false, toolPolicyDisposition: { action: 'replace', className: 'code' }, }, }, }), observations: JSON.stringify({ coder0: { systemd: 'inactive', tmux: 'missing' } }), }; const printJson = vi.fn(); const setExitCode = vi.fn(); const program = new Command(); const fleet = program.command('fleet').option('--mosaic-home ', '', cleanup); registerFleetMigrationCommand(fleet, { mosaicHome: cleanup, rolesDir, overrideDir, readText: async (path): Promise => files[path] ?? '', printJson, setExitCode, }); await program.parseAsync([ 'node', 'test', 'fleet', 'migrate-v1', 'preview', '--source', 'source', '--decisions', 'decisions', '--observations', 'observations', ]); expect(printJson).toHaveBeenCalledTimes(1); expect(printJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'ready' })); expect(setExitCode).not.toHaveBeenCalled(); expect(fleet.helpInformation()).toContain('migrate-v1'); const migration = fleet.commands.find((command) => command.name() === 'migrate-v1'); expect(migration?.helpInformation()).not.toMatch(/--write|apply|canary|rollback/); }); it('emits one stable blocked JSON result when --observations is missing', async () => { const printJson = vi.fn(); const setExitCode = vi.fn(); const program = new Command().exitOverride(); program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn(), }); const fleet = program.command('fleet').option('--mosaic-home ', '', '/unused'); registerFleetMigrationCommand(fleet, { mosaicHome: '/unused', readText: vi.fn(), printJson, setExitCode, }); await expect( program.parseAsync([ 'node', 'test', 'fleet', 'migrate-v1', 'preview', '--source', 'source', '--decisions', 'decisions', ]), ).resolves.toBe(program); expect(printJson).toHaveBeenCalledTimes(1); expect(printJson).toHaveBeenCalledWith({ status: 'blocked', blockers: [ { code: 'missing-migration-preview-option', path: 'request.observations', detail: 'Required migration preview option is missing.', }, ], }); expect(setExitCode).toHaveBeenCalledWith(1); }); it.each(['source', 'decisions', 'observations'] as const)( 'emits one stable blocked JSON result when --%s is present-empty', async (emptyOption) => { const printJson = vi.fn(); const setExitCode = vi.fn(); const readText = vi.fn(); const program = new Command().exitOverride(); program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn() }); const fleet = program.command('fleet').option('--mosaic-home ', '', '/unused'); registerFleetMigrationCommand(fleet, { mosaicHome: '/unused', readText, printJson, setExitCode, }); const paths = { source: 'source', decisions: 'decisions', observations: 'observations' }; paths[emptyOption] = ''; await expect( program.parseAsync([ 'node', 'test', 'fleet', 'migrate-v1', 'preview', `--source=${paths.source}`, `--decisions=${paths.decisions}`, `--observations=${paths.observations}`, ]), ).resolves.toBe(program); expect(printJson).toHaveBeenCalledTimes(1); expect(printJson).toHaveBeenCalledWith({ status: 'blocked', blockers: [ { code: 'empty-migration-preview-option', path: `request.${emptyOption}`, detail: 'Required migration preview option must be a non-empty path.', }, ], }); expect(setExitCode).toHaveBeenCalledTimes(1); expect(setExitCode).toHaveBeenCalledWith(1); expect(readText).not.toHaveBeenCalled(); }, ); it('emits a blocked result and sets exit 1 for malformed evidence', async () => { const printJson = vi.fn(); const setExitCode = vi.fn(); const program = new Command(); const fleet = program.command('fleet').option('--mosaic-home ', '', '/unused'); registerFleetMigrationCommand(fleet, { mosaicHome: '/unused', readText: async (path): Promise => (path === 'decisions' ? 'not-json' : '{}'), printJson, setExitCode, }); await program.parseAsync([ 'node', 'test', 'fleet', 'migrate-v1', 'preview', '--source', 'source', '--decisions', 'decisions', '--observations', 'observations', ]); expect(printJson).toHaveBeenCalledWith( expect.objectContaining({ status: 'blocked', blockers: [expect.objectContaining({ code: 'migration-preview-failed' })], }), ); expect(setExitCode).toHaveBeenCalledWith(1); }); it('redacts adversarial values from validation failures', async () => { const secret = 'never-print-command-or-token'; const printJson = vi.fn(); const setExitCode = vi.fn(); const program = new Command(); const fleet = program.command('fleet').option('--mosaic-home ', '', '/unused'); registerFleetMigrationCommand(fleet, { mosaicHome: '/unused', readText: async (path): Promise => path === 'decisions' ? JSON.stringify({ generation: 2, agents: {}, [secret]: secret }) : '{}', printJson, setExitCode, }); await program.parseAsync([ 'node', 'test', 'fleet', 'migrate-v1', 'preview', '--source', 'source', '--decisions', 'decisions', '--observations', 'observations', ]); expect(JSON.stringify(printJson.mock.calls)).not.toContain(secret); expect(setExitCode).toHaveBeenCalledWith(1); }); });