Compare commits

..

5 Commits

Author SHA1 Message Date
d3bf52898b fix: reject unknown installer arguments (#825)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 22:16:51 +00:00
3f77229e88 fix(#792): fleet roster ENOENT actionable exit + installer heading printf (#818)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 21:36:36 +00:00
686c881fe4 fix: fetch actual Gitea PR head for Codex reviews (#815)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-17 19:44:56 +00:00
fe7a468c9d ci: bake jq into the prebuilt test image (#821)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 19:12:43 +00:00
cabf02e7b9 ci: bake git into the prebuilt test image (#819)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 18:11:16 +00:00
18 changed files with 625 additions and 43 deletions

View File

@@ -22,10 +22,10 @@
FROM node:24-alpine
# 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`
# and `git` are baked here too — framework shell tests require both without
# paying for per-run package installation in ci.yml.
RUN apk add --no-cache python3 make g++ postgresql-client bash git
# 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
# Codex review wrappers require them without per-run installation in ci.yml.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate

View File

@@ -349,6 +349,8 @@ bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Contributing
```bash

View File

@@ -183,6 +183,8 @@ non-interactive use:
--no-auto-launch # Skip auto-launch of wizard after install
```
Unrecognized flags or positional arguments fail before installation starts and print the supported-option usage.
Or if installed globally:
```bash

View File

@@ -0,0 +1,86 @@
# Issue #804 — fail closed on unknown installer arguments
## Objective
Implement Part 1 of Gitea issue #804 only: `tools/install.sh` must reject every unrecognized flag or argument with an actionable STDERR error and nonzero exit before installation starts.
## Scope and constraints
- Preserve all currently recognized options and behavior, including `-y` and `--ref <branch>`.
- No positional arguments are currently accepted by the parser.
- Do not add `--next`, `MOSAIC_NEXT`, prerelease routing, or any Part 2 behavior.
- TDD is mandatory: add and observe a failing process-level regression test before changing `tools/install.sh`.
- Worker lifecycle ends after branch push, PR creation, and coordinator notification; do not merge or close #804.
- Existing launcher-owned changes in `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are out of scope and must not be committed.
## Requirements and acceptance criteria
- Unknown input names the offending argument on STDERR.
- STDERR includes a short installer usage hint.
- Exit status is nonzero.
- The installer does not invoke npm or otherwise proceed into installation.
- Existing recognized flags remain unchanged.
## Plan
1. Add a process-level Vitest regression using the installer test location under `packages/mosaic/src/commands/`.
2. Run the focused test and record the expected RED failure.
3. Commit the RED test as `test(#804): ...`.
4. Replace the parser catch-all with a fail-closed STDERR error and usage hint.
5. Update concise installer-facing documentation without introducing prerelease behavior.
6. Run focused tests, shell syntax validation, package tests, lint, typecheck, and format checks.
7. Run independent review tooling and remediate findings.
8. Commit as `fix(#804): ...`, queue-guard, push, open a PR containing `Closes #804.`, notify the coordinator, and exit.
## Budget
- No explicit token cap supplied.
- Working estimate: 8K tokens; narrow two-file behavior/test change plus concise docs and delivery gates.
## Progress
- 2026-07-17: Loaded mission state, issue #804, delivery/QA/documentation rails, and relevant TDD/Vitest/pnpm/Gitea skills.
- 2026-07-17: Confirmed the parser has no legitimate positional arguments and currently drops all unmatched input via `*) shift ;;`.
- 2026-07-17: Installed locked workspace dependencies with a worktree-local pnpm store; no lockfile changes.
- 2026-07-17: Added the process-level unknown-argument regression with an isolated `$HOME` and npm shim.
- 2026-07-17: Replaced the silent catch-all with STDERR error + usage output and exit 2 before preflight or installation.
- 2026-07-17: Initial Codex code review found an unknown option could still be consumed as the `--ref` value. Added a second RED reproducer, then rejected option-shaped/missing `--ref` values without changing valid `--ref <branch>` behavior. The review's launcher-state note is handled by excluding both `.mosaic/orchestrator/` files from commits.
- 2026-07-17: Updated README, user guide, and packaged framework README with the fail-closed argument contract. No API, auth, admin, sitemap/navigation, or publishing surface changed.
## Verification
- RED: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts` — expected failure: installer exited `0` instead of nonzero at the exit-status assertion; confirms the test reproduces the silent-drop defect before production changes.
- Remediation RED: the added `--cli --ref --bogus` case exited `0`, proving `--ref` could swallow an unknown option before the guard was added.
- GREEN: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts src/commands/install-heading.spec.ts` — 2 files, 3 tests passed.
- Situational process check: unknown positional input exited 2, named the input on STDERR, printed usage, and did not call the npm shim.
- `bash -n tools/install.sh` — passed.
- Bare `--ref` process check — exited 2 with `Missing value for --ref` and usage.
- `pnpm --filter @mosaicstack/mosaic test` — 69 files, 1,287 tests passed; framework shell checks passed. The first attempt lacked generated `dist/cli.js`; `pnpm --filter @mosaicstack/mosaic build` restored the required test precondition and the full rerun passed.
- `pnpm lint` — 23/23 tasks passed.
- `pnpm typecheck` — 42/42 tasks passed.
- `pnpm format:check` — passed.
- Codex code re-review against `origin/main``approve`, 0 blockers/should-fix/suggestions.
- Codex security re-review against `origin/main` — risk `none`, 0 findings.
## Acceptance evidence
| Criterion | Evidence |
| --- | --- |
| Unknown input is named on STDERR | Process-level Vitest assertions for `--bogus`, including after `--ref` |
| Short usage hint is printed on STDERR | Vitest usage regex + manual process output |
| Exit is nonzero | Vitest status assertions and manual exit 2 |
| Installation does not proceed | Isolated npm shim marker remains absent |
| Recognized behavior is preserved | Parser cases are unchanged except validation of malformed `--ref`; full Mosaic package suite passed |
| Part 2 is excluded | No `--next`, `MOSAIC_NEXT`, dist-tag, or prerelease routing changes |
## Documentation checklist
- Current canonical `docs/PRD.md` remains unchanged; issue #804 and the coordinator brief supply this bounded defect's acceptance contract.
- Updated installer behavior in root README, user guide, and packaged framework README in the same logical change set.
- API/OpenAPI, auth/permissions, admin operations, developer architecture, sitemap/navigation, and external publishing are not affected.
- Scratchpad remains under `docs/scratchpads/`; no root-hygiene changes.
## Risks and blockers
- Part 2 remains owner-gated under #805 and is intentionally excluded.
- No implementation blocker remains. Independent coordinator RoR, CI, merge, and issue closure remain pending after worker handoff.

View File

@@ -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 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

@@ -177,6 +177,8 @@ bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Universal Skills
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.

View File

@@ -70,6 +70,8 @@ Security vulnerability review focusing on:
~/.config/mosaic/tools/codex/codex-security-review.sh -n 42
```
PR mode resolves the provider's PR diff rather than relying on the caller's checked-out branch. On Gitea, it fetches the base and `refs/pull/<number>/head` refs and diffs those explicit refs. If the refs cannot be fetched or the resulting diff is empty, the command exits nonzero before Codex runs or a review is posted.
### Review Against Base Branch
```bash
@@ -253,7 +255,7 @@ Run the script from inside a git repository.
### "No changes found to review"
The specified mode (--uncommitted, --base, etc.) found no changes to review.
The specified non-PR mode (`--uncommitted`, `--base`, etc.) found no changes to review. PR mode instead fails closed with an actionable error when it cannot construct a non-empty provider diff; verify the PR number, remote, provider login, and ref access before retrying.
### "Codex produced no output"

View File

@@ -44,38 +44,47 @@ build_diff_context() {
diff_text=$(git show "$value" 2>/dev/null)
;;
pr)
# For PRs, we need to fetch the PR diff
detect_platform
# Provider detection writes its result to stdout; suppress it so it cannot
# be mistaken for diff content when this function is used in a substitution.
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
diff_text=$(gh pr diff "$value" 2>/dev/null)
diff_text=$(gh pr diff "$value" 2>/dev/null) || {
echo "Error: Failed to fetch the diff for PR #${value}." >&2
return 1
}
elif [[ "$PLATFORM" == "gitea" ]]; then
# tea doesn't have a direct pr diff command, use git
local pr_base
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | grep "^${value}" | awk '{print $2}')
if [[ -n "$pr_base" ]]; then
diff_text=$(git diff "${pr_base}...HEAD" 2>/dev/null)
else
# Fallback: fetch PR info via API
local repo_info
repo_info=$(get_repo_info)
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null)
local host
host=$(echo "$remote_url" | sed -E 's|.*://([^/]+).*|\1|; s|.*@([^:]+).*|\1|')
diff_text=$(curl -s "https://${host}/api/v1/repos/${repo_info}/pulls/${value}" \
-H "Authorization: token $(tea login list --output simple 2>/dev/null | head -1 | awk '{print $2}')" \
2>/dev/null | jq -r '.diff_url // empty')
if [[ -n "$diff_text" && "$diff_text" != "null" ]]; then
diff_text=$(curl -s "$diff_text" 2>/dev/null)
else
diff_text=$(git diff "main...HEAD" 2>/dev/null)
local pr_base base_ref pr_head_ref
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | awk -v pr="$value" '$1 == pr { print $2; exit }')
if [[ -z "$pr_base" ]]; then
echo "Error: Could not resolve the base branch for Gitea PR #${value}." >&2
return 1
fi
base_ref="refs/remotes/origin/${pr_base}"
pr_head_ref="refs/remotes/origin/pr/${value}/head"
if ! git fetch --quiet origin \
"+refs/heads/${pr_base}:${base_ref}" \
"+refs/pull/${value}/head:${pr_head_ref}"; then
echo "Error: Failed to fetch the base and head refs for Gitea PR #${value}." >&2
return 1
fi
diff_text=$(git diff "${base_ref}...${pr_head_ref}") || {
echo "Error: Failed to diff the fetched refs for Gitea PR #${value}." >&2
return 1
}
else
echo "Error: Unsupported git platform while resolving PR #${value}." >&2
return 1
fi
;;
esac
echo "$diff_text"
if [[ "$mode" == "pr" && -z "${diff_text//[[:space:]]/}" ]]; then
echo "Error: Unable to construct a non-empty diff for PR #${value}; verify the PR refs and provider access." >&2
return 1
fi
printf '%s\n' "$diff_text"
}
# Format JSON findings as markdown for PR comments

View File

@@ -0,0 +1,158 @@
#!/bin/bash
# Hermetic regression coverage for Gitea PR diff construction and fail-closed reviews.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "not ok - $*" >&2
exit 1
}
assert_contains() {
local haystack="$1" needle="$2"
if [[ "$haystack" != *"$needle"* ]]; then
printf 'actual output:\n%s\n' "$haystack" >&2
fail "expected output to contain: $needle"
fi
}
# Prevent CI-provided repository context from leaking into the fixture repositories.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \
GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR
export GIT_AUTHOR_NAME="Codex Fixture"
export GIT_AUTHOR_EMAIL="codex-fixture@example.test"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
export GITEA_LOGIN="fixture"
export GITEA_TOKEN="fixture-token"
export GITEA_URL="file://$TMP_DIR"
create_pr_fixture() {
local fixture_root="$1" head_mode="$2"
local origin="$fixture_root/origin.git"
local seed="$fixture_root/seed"
local work="$fixture_root/work"
local base_sha head_sha
mkdir -p "$fixture_root"
git init --quiet --bare "$origin"
git init --quiet --initial-branch=release/next "$seed"
printf 'base\n' > "$seed/pr-change.ts"
git -C "$seed" add pr-change.ts
git -C "$seed" commit --quiet -m "fixture base"
base_sha=$(git -C "$seed" rev-parse HEAD)
git -C "$seed" remote add origin "$origin"
git -C "$seed" push --quiet origin release/next
git --git-dir="$origin" symbolic-ref HEAD refs/heads/release/next
if [[ "$head_mode" == "changed" ]]; then
git -C "$seed" switch --quiet -c feature/pr-795
printf 'actual-pr-change\n' > "$seed/pr-change.ts"
git -C "$seed" commit --quiet -am "fixture PR head"
head_sha=$(git -C "$seed" rev-parse HEAD)
git -C "$seed" push --quiet origin HEAD:refs/pull/795/head
else
head_sha="$base_sha"
git --git-dir="$origin" update-ref refs/pull/795/head "$head_sha"
fi
# Gitea's provider-owned PR head ref now exists in the local bare origin.
git clone --quiet "$origin" "$work"
printf '%s\n' "$work"
}
FAKE_BIN="$TMP_DIR/bin"
mkdir -p "$FAKE_BIN"
cat > "$FAKE_BIN/tea" <<'STUB'
#!/bin/bash
if [[ "$*" == "pr list --fields index,base --output simple" ]]; then
printf '795 release/next\n'
exit 0
fi
exit 1
STUB
cat > "$FAKE_BIN/codex" <<'STUB'
#!/bin/bash
printf 'CODEX %s\n' "$*" >> "$CODEX_LOG"
exit 99
STUB
chmod +x "$FAKE_BIN/tea" "$FAKE_BIN/codex"
export PATH="$FAKE_BIN:$PATH"
# The valid fixture is a fresh clone on the non-main base. The PR head exists only
# at refs/pull/795/head, so local HEAD cannot accidentally satisfy the assertion.
if [[ "${1:-all}" != "fail-closed" ]]; then
VALID_WORK=$(create_pr_fixture "$TMP_DIR/valid" changed)
(
cd "$VALID_WORK"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
diff_context=$(build_diff_context pr 795)
assert_contains "$diff_context" "actual-pr-change"
base_sha=$(git rev-parse refs/remotes/origin/release/next)
head_sha=$(git rev-parse refs/remotes/origin/pr/795/head)
local_sha=$(git rev-parse HEAD)
[[ "$local_sha" == "$base_sha" ]] || fail "fixture clone is not on the PR base"
[[ "$head_sha" != "$base_sha" ]] || fail "fixture PR head does not differ from its base"
git show-ref --verify --quiet refs/remotes/origin/pr/795/head || \
fail "fetched PR head ref is missing"
[[ "$(git diff --name-only "${base_sha}...${head_sha}")" == "pr-change.ts" ]] || \
fail "explicit PR refs do not contain the fixture change"
if git show-ref --verify --quiet refs/heads/main || \
git show-ref --verify --quiet refs/remotes/origin/main; then
fail "fixture unexpectedly contains a main ref"
fi
)
echo "ok - Gitea PR mode fetches and diffs explicit non-main base and PR head refs"
fi
# Build an empty PR entirely inside another local repository. Both review wrappers
# must emit the PR-numbered error before Codex or the stubbed post path can execute.
if [[ "${1:-all}" != "pr-head" ]]; then
EMPTY_WORK=$(create_pr_fixture "$TMP_DIR/empty" empty)
SANDBOX="$TMP_DIR/sandbox"
mkdir -p "$SANDBOX/tools/codex/schemas" "$SANDBOX/tools/git"
cp "$SCRIPT_DIR/common.sh" \
"$SCRIPT_DIR/codex-code-review.sh" \
"$SCRIPT_DIR/codex-security-review.sh" \
"$SANDBOX/tools/codex/"
cp "$SCRIPT_DIR/schemas/code-review-schema.json" \
"$SCRIPT_DIR/schemas/security-review-schema.json" \
"$SANDBOX/tools/codex/schemas/"
cp "$SCRIPT_DIR/../git/detect-platform.sh" "$SANDBOX/tools/git/"
cat > "$SANDBOX/tools/git/pr-review.sh" <<'STUB'
#!/bin/bash
printf 'POST %s\n' "$*" >> "$POST_LOG"
STUB
chmod +x "$SANDBOX/tools/git/pr-review.sh"
POST_LOG="$TMP_DIR/post.log"
CODEX_LOG="$TMP_DIR/codex.log"
export POST_LOG CODEX_LOG
for review_kind in code security; do
: > "$POST_LOG"
: > "$CODEX_LOG"
review_script="$SANDBOX/tools/codex/codex-${review_kind}-review.sh"
set +e
(
cd "$EMPTY_WORK"
"$review_script" -n 795
) >"$TMP_DIR/${review_kind}.stdout" 2>"$TMP_DIR/${review_kind}.stderr"
review_status=$?
set -e
stderr_text=$(cat "$TMP_DIR/${review_kind}.stderr")
[[ "$review_status" -ne 0 ]] || fail "${review_kind} review returned success for an empty PR diff"
[[ ! -s "$CODEX_LOG" ]] || fail "Codex ran for an empty ${review_kind} PR diff"
[[ ! -s "$POST_LOG" ]] || fail "${review_kind} review auto-post ran for an empty PR diff"
assert_contains "$stderr_text" "Error:"
assert_contains "$stderr_text" "PR #795"
echo "ok - empty ${review_kind} PR diff fails closed before Codex and auto-post"
done
fi

View File

@@ -24,7 +24,8 @@
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/codex/test-pr-diff-context.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",

View File

@@ -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<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> => {
const home = await fleetHome();
const calls: string[][] = [];

View File

@@ -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<FleetRosterV2> {
return async (rosterPath: string): Promise<FleetRosterV2> => {
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`);

View File

@@ -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;

View File

@@ -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<void>
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
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<boolean> {
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<FleetRoster> {
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<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 {

View File

@@ -0,0 +1,61 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const INSTALLER_PATH = fileURLToPath(new URL('../../../../tools/install.sh', import.meta.url));
async function runInstaller(args: string[]): Promise<{
status: number | null;
stderr: string;
npmCalled: boolean;
}> {
const home = await mkdtemp(join(tmpdir(), 'mosaic-installer-args-'));
const bin = join(home, 'bin');
const npmMarker = join(home, 'npm-called');
try {
await mkdir(bin);
const npmShim = join(bin, 'npm');
await writeFile(npmShim, `#!/bin/sh\n: > "${npmMarker}"\n`);
await chmod(npmShim, 0o755);
const result = spawnSync('bash', [INSTALLER_PATH, ...args], {
encoding: 'utf8',
env: {
...process.env,
HOME: home,
MOSAIC_NO_COLOR: '1',
PATH: `${bin}:${process.env.PATH ?? ''}`,
},
});
return {
status: result.status,
stderr: result.stderr,
npmCalled: existsSync(npmMarker),
};
} finally {
await rm(home, { recursive: true, force: true });
}
}
function expectUnknownArgument(result: Awaited<ReturnType<typeof runInstaller>>): void {
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Unknown argument: --bogus');
expect(result.stderr).toMatch(/Usage: .*install\.sh/);
expect(result.npmCalled).toBe(false);
}
describe('installer arguments', () => {
it('rejects an unknown argument before installation starts', async () => {
expectUnknownArgument(await runInstaller(['--cli', '--bogus']));
});
it('does not let --ref consume an unknown option', async () => {
expectUnknownArgument(await runInstaller(['--cli', '--ref', '--bogus']));
});
});

View File

@@ -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)"');
});
});

View File

@@ -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<FleetRoster> {
const source = await readFile(path, 'utf8');
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<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 {
@@ -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',

View File

@@ -61,17 +61,38 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
installer_usage() {
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;;
--ref)
if [[ $# -lt 2 ]] || [[ -z "$2" ]]; then
printf 'Error: Missing value for --ref\n' >&2
installer_usage
exit 2
fi
if [[ "$2" == -* ]]; then
printf 'Error: Unknown argument: %s\n' "$2" >&2
installer_usage
exit 2
fi
GIT_REF="$2"
shift 2
;;
--dev) FLAG_DEV=true; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
*) shift ;;
*)
printf 'Error: Unknown argument: %s\n' "$1" >&2
installer_usage
exit 2
;;
esac
done
@@ -212,7 +233,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 ──────────────────────────────────────────────────────────────────