diff --git a/docs/scratchpads/ms-792-fleet-enoent-installer.md b/docs/scratchpads/ms-792-fleet-enoent-installer.md new file mode 100644 index 0000000..a24cac4 --- /dev/null +++ b/docs/scratchpads/ms-792-fleet-enoent-installer.md @@ -0,0 +1,38 @@ +# ms-792 — Fleet roster error handling and installer heading + +## Objective + +Make expected missing or malformed fleet roster configuration fail with an actionable message and nonzero exit instead of a raw Node stack trace. Ensure the installer preserves the `@mosaicstack/mosaic` heading. + +## Plan + +1. Add failing coverage for missing and malformed roster input. +2. Centralize roster-file read and parse error translation; add the CLI async error boundary. +3. Sweep fleet command read paths that bypass the roster loader. +4. Replace the installer heading output with format-safe rendering and test it. +5. Run focused and repository quality checks; request independent review. + +## Progress + +- 2026-07-16: Confirmed issue #792 and branch base `9745bc3f`. +- 2026-07-16: Installed locked workspace dependencies using a worktree-local pnpm store; no `.mosaic/` files were changed intentionally. +- 2026-07-16: Added a shared roster read/parse guard and routed v1 fleet commands plus v1/v2 selection through Commander’s actionable nonzero error path. V2 command modules already return structured nonzero JSON errors for their guarded reads. +- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading. +- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace. +- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded. +- 2026-07-17: RoR found that semantically invalid v1 documents still escaped as plain `Error` values. `normalizeFleetRosterV1` now preserves each validation message while converting it to `FleetRosterConfigurationError`, so its command callers use the actionable nonzero Commander path. + +## Verification + +- `pnpm --filter @mosaicstack/mosaic test` — PASS (61 files, 1,046 tests; executed outside sandbox because CLI smoke tests spawn Node) +- `pnpm typecheck` — PASS +- `pnpm lint` — PASS +- `pnpm format:check` — PASS +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests) +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster) +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts -t "semantically invalid v1 roster"` — RED then PASS; verifies duplicate agent names are reported as `fleet.roster` exit 1 without a stack trace. +- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage. + +## Risks / blockers + +- Dependency installation is required before executing Vitest, TypeScript, lint, and formatting gates. diff --git a/packages/mosaic/src/commands/fleet-regen-command.spec.ts b/packages/mosaic/src/commands/fleet-regen-command.spec.ts index 8155516..575d7fa 100644 --- a/packages/mosaic/src/commands/fleet-regen-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-regen-command.spec.ts @@ -150,6 +150,17 @@ describe('projectRosterV2AgentGeneratedEnv', (): void => { }); describe('mosaic fleet regen', (): void => { + it('reports a missing canonical roster with the shared initialization hint', async (): Promise => { + const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-missing-roster-')); + cleanup = home; + + await expect( + program(home, recordingRunner([])).parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']), + ).rejects.toThrow( + `No fleet roster found at ${join(home, 'fleet', 'roster.yaml')}. Run \`mosaic fleet init\``, + ); + }); + it('is dry-run by default: reports the plan and writes nothing', async (): Promise => { const home = await fleetHome(); const calls: string[][] = []; diff --git a/packages/mosaic/src/commands/fleet-regen-command.ts b/packages/mosaic/src/commands/fleet-regen-command.ts index 9f3ce67..6f478b0 100644 --- a/packages/mosaic/src/commands/fleet-regen-command.ts +++ b/packages/mosaic/src/commands/fleet-regen-command.ts @@ -1,4 +1,4 @@ -import { readFile, stat } from 'node:fs/promises'; +import { stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, relative, resolve } from 'node:path'; import type { Command } from 'commander'; @@ -18,6 +18,7 @@ import { validateRosterV2Semantics, type FleetRosterV2, } from '../fleet/roster-v2.js'; +import { FleetRosterConfigurationError, readFleetRosterText } from '../fleet/fleet-roster-v1.js'; /** * `mosaic fleet regen` — recovery-framed regeneration of the roster-derived @@ -303,7 +304,7 @@ function defaultReadRoster( mosaicHome: string, ): (rosterPath: string) => Promise { return async (rosterPath: string): Promise => { - const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'); + const roster = parseRosterV2(await readFleetRosterText(rosterPath), 'yaml'); // Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution // + protected-class tool-policy match) so regen cannot project a roster the // rest of the fleet surface would reject. @@ -433,6 +434,10 @@ export function registerFleetRegenCommand(fleetCommand: Command, deps: FleetRege // the operator must finish/verify (and clear the lock) before restarting. if (result.incomplete || result.cleanup) process.exitCode = 1; } catch (error: unknown) { + if (error instanceof FleetRosterConfigurationError) { + fleetCommand.error(error.message, { code: 'fleet.roster', exitCode: 1 }); + return; + } process.exitCode = 1; const message = error instanceof Error ? error.message : String(error); process.stderr.write(`mosaic fleet regen failed: ${message}\n`); diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index e54cc0d..9aaaf05 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -60,6 +60,7 @@ import { type SleepFn, } from './fleet.js'; import { registerAgentCommand } from './agent.js'; +import { parseFleetRosterDocument, readFleetRosterText } from '../fleet/fleet-roster-v1.js'; function buildProgram(): Command { const program = new Command(); @@ -166,6 +167,91 @@ describe('registerFleetCommand', () => { }); }); +describe('fleet roster configuration failures', () => { + it('reports a missing roster with an actionable initialization hint', async () => { + const home = await tempDir(); + try { + const program = buildProgram(); + + await expect( + program.parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']), + ).rejects.toThrow( + `No fleet roster found at ${join(home, 'fleet', 'roster.json')}. Run \`mosaic fleet init\``, + ); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('reports a malformed roster with the path and repair hint', async () => { + const home = await tempDir(); + const rosterPath = join(home, 'fleet', 'roster.json'); + try { + await mkdir(dirname(rosterPath), { recursive: true }); + await writeFile(rosterPath, '{not valid json'); + + await expect(loadFleetRoster(rosterPath)).rejects.toThrow( + `Fleet roster at ${rosterPath} is invalid. Fix the file or run \`mosaic fleet init --force\``, + ); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('reports an unreadable roster without exposing the filesystem error', async () => { + const home = await tempDir(); + try { + await expect(readFleetRosterText(home)).rejects.toThrow( + `Could not read fleet roster at ${home}. Check the file exists and is readable.`, + ); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('reports a malformed roster while selecting the v1/v2 command path', () => { + expect(() => parseFleetRosterDocument('version: [', '/srv/mosaic/fleet/roster.yaml')).toThrow( + 'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.', + ); + }); + + it('reports a semantically invalid v1 roster as an actionable nonzero command error', async () => { + const home = await tempDir(); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await mkdir(dirname(rosterPath), { recursive: true }); + await writeFile( + rosterPath, + [ + 'version: 1', + 'transport: tmux', + 'agents:', + ' - name: canary-pi', + ' runtime: pi', + ' - name: canary-pi', + ' runtime: codex', + ].join('\n'), + ); + + await expect( + buildProgram().parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']), + ).rejects.toMatchObject({ + code: 'fleet.roster', + exitCode: 1, + message: 'Fleet roster has duplicate agent name: canary-pi.', + }); + expect(stderrSpy.mock.calls.flat().join('')).toContain( + 'Fleet roster has duplicate agent name: canary-pi.', + ); + expect(stderrSpy.mock.calls.flat().join('')).not.toMatch(/\n\s+at\s/); + } finally { + stderrSpy.mockRestore(); + await rm(home, { recursive: true, force: true }); + } + }); +}); + describe('fleet roster parsing', () => { let cleanup: string | undefined; diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 573106c..c344660 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -19,8 +19,11 @@ import * as readline from 'node:readline'; import type { Command } from 'commander'; import YAML from 'yaml'; import { + FleetRosterConfigurationError, getRosterAgent, loadFleetRoster, + parseFleetRosterDocument, + readFleetRosterText, resolveInstalledFleetRosterPath, type FleetAgent, type FleetRoster, @@ -1913,7 +1916,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); - const roster = await loadFleetRoster(rosterPath); + const roster = await loadRosterAtPath(cmd, rosterPath); const newAgent: FleetAgent = { name, @@ -1973,7 +1976,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); - const roster = await loadFleetRoster(rosterPath); + const roster = await loadRosterAtPath(cmd, rosterPath); // Guard: throws if removing leaves 0 orchestrators or agent not in roster const updatedRoster = removeAgentFromRoster(roster, name); @@ -2402,14 +2405,19 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise async function loadRosterForCommand(cmd: Command): Promise { const opts = cmd.opts<{ mosaicHome: string; roster?: string }>(); - return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster)); + return loadRosterAtPath(cmd, await resolveRosterPath(opts.mosaicHome, opts.roster)); } /** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */ async function usesRosterV2ControlPlane(cmd: Command): Promise { const opts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const path = await resolveRosterPath(opts.mosaicHome, opts.roster); - const parsed: unknown = YAML.parse(await readFile(path, 'utf8')); + let parsed: unknown; + try { + parsed = parseFleetRosterDocument(await readFleetRosterText(path), path); + } catch (error) { + reportFleetRosterConfigurationError(cmd, error); + } return ( typeof parsed === 'object' && parsed !== null && @@ -2425,7 +2433,22 @@ async function loadRosterFromAgentCommand( ): Promise { const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>(); const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome(); - return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster)); + return loadRosterAtPath(command, await resolveRosterPath(mosaicHome, opts.roster)); +} + +async function loadRosterAtPath(command: Command, path: string): Promise { + try { + return await loadFleetRoster(path); + } catch (error) { + reportFleetRosterConfigurationError(command, error); + } +} + +function reportFleetRosterConfigurationError(command: Command, error: unknown): never { + if (error instanceof FleetRosterConfigurationError) { + command.error(error.message, { code: 'fleet.roster', exitCode: 1 }); + } + throw error; } function resolveMosaicHomeFromCommand(command: Command, override?: string): string { diff --git a/packages/mosaic/src/commands/install-heading.spec.ts b/packages/mosaic/src/commands/install-heading.spec.ts new file mode 100644 index 0000000..1720471 --- /dev/null +++ b/packages/mosaic/src/commands/install-heading.spec.ts @@ -0,0 +1,17 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const INSTALLER_PATH = fileURLToPath(new URL('../../../../tools/install.sh', import.meta.url)); + +describe('installer CLI package heading', () => { + it('renders the scoped package name intact', async () => { + const installer = await readFile(INSTALLER_PATH, 'utf8'); + const step = installer.match(/^step\(\)\s*\{.*\}$/m)?.[0]; + + expect(step).toBeDefined(); + + expect(step).toContain('printf \'\\n%s%s%s\\n\' "$BOLD" "$*" "$RESET"'); + expect(installer).toContain('step "@mosaicstack/mosaic (npm package)"'); + }); +}); diff --git a/packages/mosaic/src/fleet/fleet-roster-v1.ts b/packages/mosaic/src/fleet/fleet-roster-v1.ts index e14a2d3..a07eb2a 100644 --- a/packages/mosaic/src/fleet/fleet-roster-v1.ts +++ b/packages/mosaic/src/fleet/fleet-roster-v1.ts @@ -104,6 +104,10 @@ export interface FleetRoster { export type FleetRosterInputFormat = 'yaml' | 'json'; +export class FleetRosterConfigurationError extends Error { + override name = 'FleetRosterConfigurationError'; +} + export function resolveInstalledFleetRosterPath(mosaicHome: string): string { const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml'); try { @@ -138,8 +142,52 @@ export function parseFleetRosterV1( } export async function loadFleetRoster(path: string): Promise { - const source = await readFile(path, 'utf8'); - return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml'); + const source = await readFleetRosterText(path); + try { + return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml'); + } catch (error) { + if (isRosterParserError(error)) throw invalidFleetRosterError(path); + throw error; + } +} + +/** Read an operator-owned roster with errors that say how to recover. */ +export async function readFleetRosterText(path: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch (error) { + if (isNodeErrorCode(error, 'ENOENT')) { + throw new FleetRosterConfigurationError( + `No fleet roster found at ${path}. Run \`mosaic fleet init\` to create one.`, + ); + } + throw new FleetRosterConfigurationError( + `Could not read fleet roster at ${path}. Check the file exists and is readable.`, + ); + } +} + +/** Parse a roster document needed only to select the v1/v2 command path. */ +export function parseFleetRosterDocument(source: string, path: string): unknown { + try { + return YAML.parse(source); + } catch (error) { + if (isRosterParserError(error)) throw invalidFleetRosterError(path); + throw error; + } +} + +function invalidFleetRosterError(path: string): FleetRosterConfigurationError { + return new FleetRosterConfigurationError( + `Fleet roster at ${path} is invalid. Fix the file or run \`mosaic fleet init --force\`.`, + ); +} + +function isRosterParserError(error: unknown): boolean { + return ( + error instanceof SyntaxError || + (error instanceof Error && (error.name === 'YAMLParseError' || error.name === 'YAMLWarning')) + ); } export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent { @@ -149,6 +197,16 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent { } export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster { + try { + return normalizeFleetRosterV1Unchecked(raw); + } catch (error) { + if (error instanceof FleetRosterConfigurationError) throw error; + if (error instanceof Error) throw new FleetRosterConfigurationError(error.message); + throw error; + } +} + +function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster { assertObject(raw, 'Fleet roster'); assertKnownKeys(raw, 'Fleet roster', [ 'version', diff --git a/tools/install.sh b/tools/install.sh index a1c509d..62c07a8 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -212,7 +212,7 @@ ok() { echo "${G}✔${RESET} $*"; } warn() { echo "${Y}⚠${RESET} $*"; } fail() { echo "${R}✖${RESET} $*" >&2; } dim() { echo "${DIM}$*${RESET}"; } -step() { echo ""; echo "${BOLD}$*${RESET}"; } +step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; } # ─── helpers ──────────────────────────────────────────────────────────────────