Compare commits
5 Commits
fix/795-ci
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d3bf52898b | |||
| 3f77229e88 | |||
| 686c881fe4 | |||
| fe7a468c9d | |||
| cabf02e7b9 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
86
docs/scratchpads/804-install-unknown-flags.md
Normal file
86
docs/scratchpads/804-install-unknown-flags.md
Normal 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.
|
||||
38
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
38
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal 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 Commander’s actionable nonzero error path. V2 command modules already return structured nonzero JSON errors for their guarded reads.
|
||||
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
|
||||
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
|
||||
- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded.
|
||||
- 2026-07-17: RoR found that semantically invalid v1 documents still escaped as plain `Error` values. `normalizeFleetRosterV1` now preserves each validation message while converting it to `FleetRosterConfigurationError`, so its command callers use the actionable nonzero Commander path.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @mosaicstack/mosaic test` — PASS (61 files, 1,046 tests; executed outside sandbox because CLI smoke tests spawn Node)
|
||||
- `pnpm typecheck` — PASS
|
||||
- `pnpm lint` — PASS
|
||||
- `pnpm format:check` — PASS
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster)
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts -t "semantically invalid v1 roster"` — RED then PASS; verifies duplicate agent names are reported as `fleet.roster` exit 1 without a stack trace.
|
||||
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- Dependency installation is required before executing Vitest, TypeScript, lint, and formatting gates.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
fi
|
||||
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
|
||||
|
||||
158
packages/mosaic/framework/tools/codex/test-pr-diff-context.sh
Normal file
158
packages/mosaic/framework/tools/codex/test-pr-diff-context.sh
Normal 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
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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[][] = [];
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
61
packages/mosaic/src/commands/install-arguments.spec.ts
Normal file
61
packages/mosaic/src/commands/install-arguments.spec.ts
Normal 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']));
|
||||
});
|
||||
});
|
||||
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 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');
|
||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||
const source = await readFleetRosterText(path);
|
||||
try {
|
||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||
} catch (error) {
|
||||
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an operator-owned roster with errors that say how to recover. */
|
||||
export async function readFleetRosterText(path: string): Promise<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',
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user