Compare commits

..

3 Commits

Author SHA1 Message Date
Hermes Agent
fb79bff91f chore(#812): WIP checkpoint — parked pending #789 terminal 2026-07-17 11:24:46 -05:00
Hermes Agent
c3a8383330 fix(#812): verify gitea review comment persistence 2026-07-17 11:19:31 -05:00
Hermes Agent
3585cdfad6 test(#812): reproduce false-positive gitea review comment 2026-07-17 11:18:29 -05:00
13 changed files with 268 additions and 254 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 # is baked here too — the sanitization step in ci.yml otherwise does a per-run
# Codex review wrappers require them without per-run installation in ci.yml. # `apk add bash`.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq RUN apk add --no-cache python3 make g++ postgresql-client bash
# 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

@@ -0,0 +1,49 @@
# Issue #812 — durable Gitea PR review comments
- **Lane:** ms-812
- **Branch:** `fix/812-pr-review-comment`
- **Issue:** mosaicstack/stack#812
- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change.
## Objective
Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use tea v0.11.1's supported `tea comment` command and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body.
## Plan
1. Add and commit a failing shell regression harness before production changes.
2. Verify RED against the nonexistent `tea pr comment` fallback false-positive.
3. Implement the minimal supported write plus ID-based provider read-back.
4. Document that wrapper write output is not durable provenance until read-back succeeds.
5. Run focused regression tests, touched-package tests, and repository quality gates.
6. Run independent review, remediate findings, queue-guard, push, and open a PR. Stop at PR-open for coordinator review/merge.
## Progress checkpoints
- [x] RED regression committed and reported to mosaic-100 (`3585cdfa`)
- [x] Initial minimal fix implemented (`c3a83833`)
- [x] Focused and package tests green after dependency build
- [ ] Independent review passed
- [ ] PR opened and reported with exact head SHA
## Tests run
- RED: `bash packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh` failed against the old path because `tea pr comment` fell back and the wrapper returned success.
- GREEN: all `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed.
- `pnpm build` passed (23/23 tasks).
- `pnpm --filter @mosaicstack/mosaic test` passed (67 files, 1278 tests).
- Package and root typecheck/lint/format gates passed.
- Codex security review found no issues.
- Codex code review requested changes: tea v0.11.1 has no supported `tea api`; replace read-back with the existing host-scoped authenticated curl/API path when work resumes.
## Risks / blockers
- **Coordination hold:** Mos directed #812 to remain parked until in-flight #789 reaches terminal state because #789 actively uses `pr-review.sh`. Branch must be pushed but no PR opened.
- Initial implementation's `tea api` read-back is not compatible with tea v0.11.1 and must be remediated before PR.
- Tea output shape must provide a created comment ID in JSON; failure to parse must fail closed.
- Read-back must not accept a pre-existing comment with the same body.
- Existing approve/request-changes behavior must remain unchanged and covered.
## Final verification evidence
Parked pending #789 terminal. No PR opened.

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

@@ -0,0 +1,9 @@
# Git provider wrappers
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
## Durable review provenance
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments count as durable provenance only after the wrapper reads the created provider record back and verifies that it belongs to the intended repository and pull request and contains the exact submitted body (or verifies the provider-returned record ID).
`pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes.

View File

@@ -101,8 +101,58 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required" echo "Error: Comment required"
exit 1 exit 1
fi fi
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
echo "Added comment to Gitea PR #$PR_NUMBER" repo=$(get_repo_slug)
host=$(get_remote_host)
login=$(get_gitea_login_for_host "$host")
comment_json=$(tea comment "$PR_NUMBER" "$COMMENT" --login "$login" --repo "$repo" --output json)
comment_id=$(printf '%s' "$comment_json" | python3 -c '
import json
import sys
try:
comment = json.load(sys.stdin)
if isinstance(comment, list) and len(comment) == 1:
comment = comment[0]
comment_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(comment_id, int) or comment_id <= 0:
raise ValueError("missing positive comment id")
except (json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
raise SystemExit(1)
print(comment_id)
')
readback_json=$(tea api --login "$login" "/repos/$repo/issues/comments/$comment_id")
EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$COMMENT" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$PR_NUMBER" \
python3 -c '
import json
import os
import sys
from urllib.parse import urlparse
try:
comment = json.load(sys.stdin)
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
expected_repo = os.environ["EXPECTED_REPO"]
expected_pr = os.environ["EXPECTED_PR_NUMBER"]
issue_url = comment.get("issue_url", "") if isinstance(comment, dict) else ""
issue_path = urlparse(issue_url).path.rstrip("/")
expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}"
if comment.get("id") != expected_id:
raise ValueError("comment id mismatch")
if comment.get("body") != expected_body:
raise ValueError("comment body mismatch")
if not issue_path.endswith(expected_suffix):
raise ValueError("repository or PR mismatch")
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
' <<< "$readback_json"
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
;; ;;
*) *)
echo "Error: Unknown action: $ACTION" echo "Error: Unknown action: $ACTION"

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Regression harness for durable Gitea PR review comments (#812).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/tea.log"
OUTPUT_FILE="$WORK_DIR/output.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$PR_REVIEW_TEST_LOG"
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
case "${PR_REVIEW_TEST_MODE:-}" in
approve)
[[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90
;;
request-changes)
[[ "$*" == "pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required" ]] || exit 91
;;
legacy-fallback)
if [[ "$*" == pr\ comment* ]]; then
# tea v0.11.1 treats the nonexistent subcommand as `tea pr list` and exits 0.
printf '%s\n' 'INDEX TITLE STATE'
exit 0
fi
if [[ "$*" == comment* ]]; then
printf '%s\n' 'supported comment write deliberately unavailable' >&2
exit 23
fi
exit 92
;;
comment-success|readback-failure)
if [[ "$*" == "comment 123 durable-body --login mosaicstack --repo mosaicstack/stack --output json" ]]; then
printf '%s\n' '{"id":456,"body":"durable-body"}'
exit 0
fi
if [[ "$*" == "api --login mosaicstack /repos/mosaicstack/stack/issues/comments/456" ]]; then
if [[ "$PR_REVIEW_TEST_MODE" == "readback-failure" ]]; then
printf '%s\n' '{"id":456,"body":"different-body","issue_url":"https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123"}'
else
printf '%s\n' '{"id":456,"body":"durable-body","issue_url":"https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123"}'
fi
exit 0
fi
if [[ "$*" == pr\ comment* ]]; then
# Reproduce the old false-positive fallback if the wrapper regresses.
printf '%s\n' 'INDEX TITLE STATE'
exit 0
fi
exit 93
;;
write-failure)
if [[ "$*" == "comment 123 durable-body --login mosaicstack --repo mosaicstack/stack --output json" ]]; then
printf '%s\n' 'provider rejected comment' >&2
exit 24
fi
exit 94
;;
*)
exit 95
;;
esac
SH
chmod +x "$BIN_DIR/tea"
run_review() {
local mode="$1" action="$2" comment="${3:-}"
: > "$LOG_FILE"
: > "$OUTPUT_FILE"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
PR_REVIEW_TEST_LOG="$LOG_FILE" \
PR_REVIEW_TEST_MODE="$mode" \
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"}
) > "$OUTPUT_FILE" 2>&1
}
run_review approve approve
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$LOG_FILE"
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
run_review request-changes request-changes changes-required
grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required$' "$LOG_FILE"
grep -q 'Requested changes on Gitea PR #123' "$OUTPUT_FILE"
if run_review legacy-fallback comment durable-body; then
echo "The old nonexistent tea pr comment fallback returned success" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q '^pr comment ' "$LOG_FILE"; then
echo "Wrapper invoked unsupported tea pr comment" >&2
exit 1
fi
if grep -q 'Added comment to Gitea PR' "$OUTPUT_FILE"; then
echo "Wrapper reported success without durable persistence" >&2
exit 1
fi
run_review comment-success comment durable-body
grep -q '^comment 123 durable-body --login mosaicstack --repo mosaicstack/stack --output json$' "$LOG_FILE"
grep -q '^api --login mosaicstack /repos/mosaicstack/stack/issues/comments/456$' "$LOG_FILE"
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
if run_review write-failure comment durable-body; then
echo "Expected provider write failure to return nonzero" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "Write failure reported durable success" >&2
exit 1
fi
if run_review readback-failure comment durable-body; then
echo "Expected mismatched provider read-back to return nonzero" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "Read-back mismatch reported durable success" >&2
exit 1
fi
echo "pr-review.sh durable Gitea comment regression passed"

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