import { readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import type { Command } from 'commander'; import type { CommandRunner } from './fleet.js'; import { executeFleetReconcile, FleetReconcileError, type FleetReconcileCommand, type FleetReconcileDeps, } from '../fleet/fleet-reconciler.js'; import { parseRosterV2 } from '../fleet/roster-v2.js'; export interface FleetReconcilerCommandDeps { readonly runner: CommandRunner; readonly mosaicHome?: string; readonly reconcileDeps?: Omit; } interface ReconcileOptions { readonly expectedGeneration?: string; readonly dryRun?: boolean; } /** Registers roster-v2 reconciliation commands on the canonical fleet control plane. */ export function registerFleetReconcilerCommands( fleetCommand: Command, deps: FleetReconcilerCommandDeps, ): void { for (const operation of ['apply', 'reconcile'] as const) { fleetCommand .command(operation) .description(`${operation} local roster-owned projections and desired lifecycle`) .requiredOption('--expected-generation ', 'Authoritative roster generation') .option('--dry-run', 'Plan and preflight without writing projections or lifecycle state') .action(async (opts: ReconcileOptions): Promise => { await writeOutcome(async (): Promise => { await executeReconcilerCommand(fleetCommand, deps, operation, opts); }); }); } fleetCommand .command('doctor') .description('Classify local roster-owned drift without mutation') .action(async (): Promise => { await writeOutcome(async (): Promise => { await executeReconcilerCommand(fleetCommand, deps, 'doctor', {}); }); }); } export async function executeReconcilerCommandJson( fleetCommand: Command, deps: FleetReconcilerCommandDeps, operation: FleetReconcileCommand, opts: ReconcileOptions, agentName?: string, ): Promise { await writeOutcome(async (): Promise => { await executeReconcilerCommand(fleetCommand, deps, operation, opts, agentName); }); } export async function executeReconcilerCommand( fleetCommand: Command, deps: FleetReconcilerCommandDeps, operation: FleetReconcileCommand, opts: ReconcileOptions, agentName?: string, ): Promise { const mosaicHome = resolveMosaicHome(fleetCommand, deps); const rosterPath = resolveRosterPath(fleetCommand, mosaicHome); const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'); const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation); const expectedGeneration = mutating ? parseExpectedGeneration(opts.expectedGeneration) : undefined; const result = await executeFleetReconcile({ roster, command: opts.dryRun === true ? 'plan' : operation, ...(agentName === undefined ? {} : { agentName }), ...(expectedGeneration === undefined ? {} : { expectedGeneration }), deps: { runner: async (command: string, args: readonly string[]) => deps.runner(command, [...args]), mosaicHome, rolesDir: join(mosaicHome, 'fleet', 'roles'), overrideDir: join(mosaicHome, 'fleet', 'roles.local'), readRoster: async (): Promise => parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'), ...(deps.reconcileDeps ?? {}), }, }); printJson(result); process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1; } function isLifecycle(operation: FleetReconcileCommand): boolean { return operation === 'start' || operation === 'stop' || operation === 'restart'; } function parseExpectedGeneration(value: string | undefined): number { const generation = Number(value); if (!Number.isSafeInteger(generation) || generation < 1) { throw new FleetReconcileError( 'lifecycle-precondition-failed', '--expected-generation must be a positive safe integer.', ); } return generation; } function resolveMosaicHome(fleetCommand: Command, deps: FleetReconcilerCommandDeps): string { const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>(); return ( options.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic') ); } function resolveRosterPath(fleetCommand: Command, mosaicHome: string): string { const options = fleetCommand.optsWithGlobals<{ roster?: string }>(); const canonical = join(mosaicHome, 'fleet', 'roster.yaml'); if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) { throw new FleetReconcileError( 'lifecycle-precondition-failed', 'Roster-v2 reconciliation requires the canonical roster path.', ); } return canonical; } async function writeOutcome(action: () => Promise): Promise { try { await action(); } catch (error: unknown) { process.exitCode = 1; printJson({ error: { code: error instanceof FleetReconcileError ? error.code : 'reconcile-failed', }, }); } } function printJson(value: object): void { console.log(JSON.stringify(value)); }