Compare commits

..

1 Commits

Author SHA1 Message Date
Hermes Agent
fa5fc3cd80 feat(#795): bake git into CI base image
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-17 12:57:37 -05:00
9 changed files with 14 additions and 252 deletions

View File

@@ -22,10 +22,10 @@
FROM node:24-alpine FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the # Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`, # postgresql-client used by the test step's pg_isready readiness probe. `bash`
# `git`, and `jq` are baked here too — framework shell tests and the shipped # and `git` are baked here too — framework shell tests require both without
# Codex review wrappers require them without per-run installation in ci.yml. # paying for per-run package installation in ci.yml.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq RUN apk add --no-cache python3 make g++ postgresql-client bash git
# Pin pnpm to the repo's packageManager version via corepack. # Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate RUN corepack enable && corepack prepare pnpm@10.6.2 --activate

View File

@@ -1,38 +0,0 @@
# 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 Commanders 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.

View File

@@ -150,17 +150,6 @@ describe('projectRosterV2AgentGeneratedEnv', (): void => {
}); });
describe('mosaic fleet regen', (): void => { describe('mosaic fleet regen', (): void => {
it('reports a missing canonical roster with the shared initialization hint', async (): Promise<void> => {
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<void> => { it('is dry-run by default: reports the plan and writes nothing', async (): Promise<void> => {
const home = await fleetHome(); const home = await fleetHome();
const calls: string[][] = []; const calls: string[][] = [];

View File

@@ -1,4 +1,4 @@
import { stat } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join, relative, resolve } from 'node:path'; import { join, relative, resolve } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
@@ -18,7 +18,6 @@ import {
validateRosterV2Semantics, validateRosterV2Semantics,
type FleetRosterV2, type FleetRosterV2,
} from '../fleet/roster-v2.js'; } from '../fleet/roster-v2.js';
import { FleetRosterConfigurationError, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
/** /**
* `mosaic fleet regen` — recovery-framed regeneration of the roster-derived * `mosaic fleet regen` — recovery-framed regeneration of the roster-derived
@@ -304,7 +303,7 @@ function defaultReadRoster(
mosaicHome: string, mosaicHome: string,
): (rosterPath: string) => Promise<FleetRosterV2> { ): (rosterPath: string) => Promise<FleetRosterV2> {
return async (rosterPath: string): Promise<FleetRosterV2> => { return async (rosterPath: string): Promise<FleetRosterV2> => {
const roster = parseRosterV2(await readFleetRosterText(rosterPath), 'yaml'); const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
// Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution // Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution
// + protected-class tool-policy match) so regen cannot project a roster the // + protected-class tool-policy match) so regen cannot project a roster the
// rest of the fleet surface would reject. // rest of the fleet surface would reject.
@@ -434,10 +433,6 @@ export function registerFleetRegenCommand(fleetCommand: Command, deps: FleetRege
// the operator must finish/verify (and clear the lock) before restarting. // the operator must finish/verify (and clear the lock) before restarting.
if (result.incomplete || result.cleanup) process.exitCode = 1; if (result.incomplete || result.cleanup) process.exitCode = 1;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof FleetRosterConfigurationError) {
fleetCommand.error(error.message, { code: 'fleet.roster', exitCode: 1 });
return;
}
process.exitCode = 1; process.exitCode = 1;
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet regen failed: ${message}\n`); process.stderr.write(`mosaic fleet regen failed: ${message}\n`);

View File

@@ -60,7 +60,6 @@ import {
type SleepFn, type SleepFn,
} from './fleet.js'; } from './fleet.js';
import { registerAgentCommand } from './agent.js'; import { registerAgentCommand } from './agent.js';
import { parseFleetRosterDocument, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
function buildProgram(): Command { function buildProgram(): Command {
const program = new Command(); const program = new Command();
@@ -167,91 +166,6 @@ 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', () => { describe('fleet roster parsing', () => {
let cleanup: string | undefined; let cleanup: string | undefined;

View File

@@ -19,11 +19,8 @@ import * as readline from 'node:readline';
import type { Command } from 'commander'; import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import { import {
FleetRosterConfigurationError,
getRosterAgent, getRosterAgent,
loadFleetRoster, loadFleetRoster,
parseFleetRosterDocument,
readFleetRosterText,
resolveInstalledFleetRosterPath, resolveInstalledFleetRosterPath,
type FleetAgent, type FleetAgent,
type FleetRoster, type FleetRoster,
@@ -1916,7 +1913,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
const roster = await loadRosterAtPath(cmd, rosterPath); const roster = await loadFleetRoster(rosterPath);
const newAgent: FleetAgent = { const newAgent: FleetAgent = {
name, name,
@@ -1976,7 +1973,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
const roster = await loadRosterAtPath(cmd, rosterPath); const roster = await loadFleetRoster(rosterPath);
// Guard: throws if removing leaves 0 orchestrators or agent not in roster // Guard: throws if removing leaves 0 orchestrators or agent not in roster
const updatedRoster = removeAgentFromRoster(roster, name); const updatedRoster = removeAgentFromRoster(roster, name);
@@ -2405,19 +2402,14 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> { async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
return loadRosterAtPath(cmd, await resolveRosterPath(opts.mosaicHome, opts.roster)); return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
} }
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */ /** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> { async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const path = await resolveRosterPath(opts.mosaicHome, opts.roster); const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
let parsed: unknown; const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
try {
parsed = parseFleetRosterDocument(await readFleetRosterText(path), path);
} catch (error) {
reportFleetRosterConfigurationError(cmd, error);
}
return ( return (
typeof parsed === 'object' && typeof parsed === 'object' &&
parsed !== null && parsed !== null &&
@@ -2433,22 +2425,7 @@ async function loadRosterFromAgentCommand(
): Promise<FleetRoster> { ): Promise<FleetRoster> {
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>(); const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome(); const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
return loadRosterAtPath(command, await resolveRosterPath(mosaicHome, opts.roster)); return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
}
async function loadRosterAtPath(command: Command, path: string): Promise<FleetRoster> {
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 { function resolveMosaicHomeFromCommand(command: Command, override?: string): string {

View File

@@ -1,17 +0,0 @@
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)"');
});
});

View File

@@ -104,10 +104,6 @@ export interface FleetRoster {
export type FleetRosterInputFormat = 'yaml' | 'json'; export type FleetRosterInputFormat = 'yaml' | 'json';
export class FleetRosterConfigurationError extends Error {
override name = 'FleetRosterConfigurationError';
}
export function resolveInstalledFleetRosterPath(mosaicHome: string): string { export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml'); const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
try { try {
@@ -142,52 +138,8 @@ export function parseFleetRosterV1(
} }
export async function loadFleetRoster(path: string): Promise<FleetRoster> { export async function loadFleetRoster(path: string): Promise<FleetRoster> {
const source = await readFleetRosterText(path); const source = await readFile(path, 'utf8');
try { return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
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<string> {
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 { export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
@@ -197,16 +149,6 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
} }
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster { 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'); assertObject(raw, 'Fleet roster');
assertKnownKeys(raw, 'Fleet roster', [ assertKnownKeys(raw, 'Fleet roster', [
'version', 'version',

View File

@@ -212,7 +212,7 @@ ok() { echo "${G}✔${RESET} $*"; }
warn() { echo "${Y}${RESET} $*"; } warn() { echo "${Y}${RESET} $*"; }
fail() { echo "${R}${RESET} $*" >&2; } fail() { echo "${R}${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; } dim() { echo "${DIM}$*${RESET}"; }
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; } step() { echo ""; echo "${BOLD}$*${RESET}"; }
# ─── helpers ────────────────────────────────────────────────────────────────── # ─── helpers ──────────────────────────────────────────────────────────────────