Compare commits
3 Commits
fix/795-co
...
50276943cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50276943cd | ||
|
|
1d4a94d66f | ||
|
|
22bf43178d |
@@ -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
|
||||||
|
|||||||
36
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
36
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- 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.
|
||||||
@@ -70,8 +70,6 @@ Security vulnerability review focusing on:
|
|||||||
~/.config/mosaic/tools/codex/codex-security-review.sh -n 42
|
~/.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
|
### Review Against Base Branch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -255,7 +253,7 @@ Run the script from inside a git repository.
|
|||||||
|
|
||||||
### "No changes found to review"
|
### "No changes found 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.
|
The specified mode (--uncommitted, --base, etc.) found no changes to review.
|
||||||
|
|
||||||
### "Codex produced no output"
|
### "Codex produced no output"
|
||||||
|
|
||||||
|
|||||||
@@ -44,47 +44,38 @@ build_diff_context() {
|
|||||||
diff_text=$(git show "$value" 2>/dev/null)
|
diff_text=$(git show "$value" 2>/dev/null)
|
||||||
;;
|
;;
|
||||||
pr)
|
pr)
|
||||||
# Provider detection writes its result to stdout; suppress it so it cannot
|
# For PRs, we need to fetch the PR diff
|
||||||
# be mistaken for diff content when this function is used in a substitution.
|
detect_platform
|
||||||
detect_platform >/dev/null
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
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
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
local pr_base base_ref pr_head_ref
|
# tea doesn't have a direct pr diff command, use git
|
||||||
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | awk -v pr="$value" '$1 == pr { print $2; exit }')
|
local pr_base
|
||||||
if [[ -z "$pr_base" ]]; then
|
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | grep "^${value}" | awk '{print $2}')
|
||||||
echo "Error: Could not resolve the base branch for Gitea PR #${value}." >&2
|
if [[ -n "$pr_base" ]]; then
|
||||||
return 1
|
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)
|
||||||
|
fi
|
||||||
fi
|
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
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ "$mode" == "pr" && -z "${diff_text//[[:space:]]/}" ]]; then
|
echo "$diff_text"
|
||||||
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
|
# Format JSON findings as markdown for PR comments
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -24,8 +24,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests"
|
||||||
"test:framework-shell": "bash framework/tools/codex/test-pr-diff-context.sh"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -150,6 +150,17 @@ 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[][] = [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFile, stat } from 'node:fs/promises';
|
import { 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,6 +18,7 @@ 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
|
||||||
@@ -303,7 +304,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 readFile(rosterPath, 'utf8'), 'yaml');
|
const roster = parseRosterV2(await readFleetRosterText(rosterPath), '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.
|
||||||
@@ -433,6 +434,10 @@ 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`);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ 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();
|
||||||
@@ -166,6 +167,55 @@ 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`.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fleet roster parsing', () => {
|
describe('fleet roster parsing', () => {
|
||||||
let cleanup: string | undefined;
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ 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,
|
||||||
@@ -1913,7 +1916,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 loadFleetRoster(rosterPath);
|
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||||
|
|
||||||
const newAgent: FleetAgent = {
|
const newAgent: FleetAgent = {
|
||||||
name,
|
name,
|
||||||
@@ -1973,7 +1976,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 loadFleetRoster(rosterPath);
|
const roster = await loadRosterAtPath(cmd, 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);
|
||||||
@@ -2402,14 +2405,19 @@ 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 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. */
|
/** 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);
|
||||||
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 (
|
return (
|
||||||
typeof parsed === 'object' &&
|
typeof parsed === 'object' &&
|
||||||
parsed !== null &&
|
parsed !== null &&
|
||||||
@@ -2425,7 +2433,22 @@ 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 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 {
|
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
||||||
|
|||||||
17
packages/mosaic/src/commands/install-heading.spec.ts
Normal file
17
packages/mosaic/src/commands/install-heading.spec.ts
Normal 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)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,6 +104,10 @@ 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 {
|
||||||
@@ -138,8 +142,52 @@ export function parseFleetRosterV1(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||||
const source = await readFile(path, 'utf8');
|
const source = await readFleetRosterText(path);
|
||||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
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 {
|
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||||
|
|||||||
@@ -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() { echo ""; echo "${BOLD}$*${RESET}"; }
|
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
|
||||||
|
|
||||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user