diff --git a/AGENTS.md b/AGENTS.md index 0078a670fd9..afdc5a8c2a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ Package-specific guides: | Task | Command | |------|---------| -| Install all deps | `npm install && npm link && cd nemoclaw && npm install && npm run build && cd .. && cd nemoclaw-blueprint && uv sync && cd ..` | +| Install all deps | `npm install && npm link && cd nemoclaw && npm install && npm run build && cd .. && uv sync` | +| Check contributor environment | `npm run dev:doctor` | | Build plugin | `cd nemoclaw && npm run build` | | Watch mode | `cd nemoclaw && npm run dev` | | Run all tests | `npm test` | @@ -165,8 +166,8 @@ All hooks managed by [prek](https://prek.j178.dev/) (installed via `npm install` ### Before Making Changes 1. Read `CONTRIBUTING.md` for the full contributor guide -2. Run `make check` to verify your environment is set up correctly -3. Check that `npm test` passes before starting +2. Run `npm run dev:doctor` to verify the contributor environment without changing it +3. Run tests targeted to the area you plan to change; reserve the full suite for broad changes ### Git and GitHub Access Failures diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1af00b86b46..c5d577b7d74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ That section is a planning aid, not a commitment that a specific issue or featur Install the following before you begin. - Node.js 22.16+ and npm 10+ -- Python 3.11+ (for blueprint and documentation builds) +- Python 3.11+ (for documentation tooling) - Docker (running) - [uv](https://docs.astral.sh/uv/) (for Python dependency management) - [hadolint](https://github.com/hadolint/hadolint) (Dockerfile linter — `brew install hadolint` on macOS) @@ -77,10 +77,22 @@ npm install # Install and build the TypeScript plugin cd nemoclaw && npm install && npm run build && cd .. -# Install Python deps for the blueprint -cd nemoclaw-blueprint && uv sync && cd .. +# Install Python documentation dependencies from the repository root +uv sync ``` +Verify that the checkout is ready for contributor work: + +```bash +npm run dev:doctor +``` + +The contributor doctor is read-only. +It checks the toolchain, dependencies, build artifacts, Git hooks, contributor identity and signing, GitHub authentication, Docker availability, and the locally linked NemoClaw CLI. +It does not install packages, change configuration, start services, or create a sandbox. +It complements the end-user installer and coding-agent starter prompt; those paths install and operate NemoClaw but do not prepare a source checkout for contribution. +Fix any reported failures, then run the command again before creating a feature branch. + ## Building The TypeScript plugin lives in `nemoclaw/` and compiles with `tsc`: @@ -116,6 +128,7 @@ These are the primary `make` and `npm` targets for day-to-day development: | Task | Purpose | |------|---------| +| `npm run dev:doctor` | Run read-only contributor environment readiness checks | | `make check` | Run all linters (TypeScript + Python) | | `make lint` | Same as `make check` | | `make format` | Auto-format TypeScript and Python source | diff --git a/package.json b/package.json index d387f2ca3d2..0ac262c9a44 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "scripts": { "preinstall": "node scripts/check-node-version.js", + "dev:doctor": "bash scripts/dev-setup.sh --doctor", "test": "npm run clean:cli && npm --prefix nemoclaw run clean && npm run build:cli && npm --prefix nemoclaw run build && vitest run --project cli --project integration --project installer-integration --project package-contract --project plugin --project e2e-support", "test:spec": "npm test -- --reporter=tree", "test:fast": "npm run clean:cli && vitest run --project cli --project plugin --project e2e-support", diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100755 index 00000000000..5543d885707 --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${NEMOCLAW_DEV_DOCTOR_REPO_ROOT:-$(cd -- "${SCRIPT_DIR}/.." && pwd)}" +CLI_BUILD_ARTIFACT="${NEMOCLAW_DEV_DOCTOR_CLI_ARTIFACT:-${REPO_ROOT}/dist/nemoclaw.js}" +PLUGIN_BUILD_ARTIFACT="${NEMOCLAW_DEV_DOCTOR_PLUGIN_ARTIFACT:-${REPO_ROOT}/nemoclaw/dist/index.js}" + +PASS_COUNT=0 +WARN_COUNT=0 +FAIL_COUNT=0 + +usage() { + cat <<'EOF' +Usage: ./scripts/dev-setup.sh --doctor + +Run read-only checks for a NemoClaw contributor environment. +The doctor never installs packages, changes configuration, or starts services. +EOF +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf ' ✓ %s\n' "$1" +} + +warn() { + WARN_COUNT=$((WARN_COUNT + 1)) + printf ' ! %s\n' "$1" + if [ -n "${2:-}" ]; then + printf ' Next: %s\n' "$2" + fi +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + printf ' ✗ %s\n' "$1" + if [ -n "${2:-}" ]; then + printf ' Next: %s\n' "$2" + fi +} + +first_line() { + printf '%s\n' "$1" | sed -n '1p' +} + +extract_version() { + printf '%s\n' "$1" | sed -E 's/^[^0-9]*([0-9]+([.][0-9]+){0,2}).*/\1/' +} + +version_at_least() { + local actual="$1" + local required="$2" + local actual_major actual_minor actual_patch required_major required_minor required_patch + + IFS=. read -r actual_major actual_minor actual_patch <<<"${actual}" + IFS=. read -r required_major required_minor required_patch <<<"${required}" + actual_minor="${actual_minor:-0}" + actual_patch="${actual_patch:-0}" + required_minor="${required_minor:-0}" + required_patch="${required_patch:-0}" + + if ((actual_major != required_major)); then + ((actual_major > required_major)) + return + fi + if ((actual_minor != required_minor)); then + ((actual_minor > required_minor)) + return + fi + ((actual_patch >= required_patch)) +} + +check_minimum_version() { + local label="$1" + local command_name="$2" + local minimum="$3" + local remediation="$4" + local output version + + if ! command -v "${command_name}" >/dev/null 2>&1; then + fail "${label}: not found" "${remediation}" + return + fi + if ! output="$("${command_name}" --version 2>/dev/null)"; then + fail "${label}: version check failed" "${remediation}" + return + fi + version="$(extract_version "$(first_line "${output}")")" + if ! [[ "${version}" =~ ^[0-9]+([.][0-9]+){0,2}$ ]]; then + fail "${label}: could not parse version" "${remediation}" + return + fi + if version_at_least "${version}" "${minimum}"; then + pass "${label} ${version}" + else + fail "${label} ${version} is below ${minimum}" "${remediation}" + fi +} + +check_command() { + local label="$1" + local command_name="$2" + local remediation="$3" + local output + + if ! command -v "${command_name}" >/dev/null 2>&1; then + fail "${label}: not found" "${remediation}" + return + fi + if output="$("${command_name}" --version 2>/dev/null)"; then + pass "${label} $(first_line "${output}")" + else + fail "${label}: version check failed" "${remediation}" + fi +} + +check_build_artifact() { + local label="$1" + local file_path="$2" + local remediation="$3" + local source_path newer_source + shift 3 + + if [ ! -f "${file_path}" ]; then + fail "${label}: missing" "${remediation}" + return + fi + for source_path in "$@"; do + newer_source="" + if [ -d "${source_path}" ]; then + newer_source="$(find "${source_path}" -type f -newer "${file_path}" -print -quit 2>/dev/null || true)" + elif [ -f "${source_path}" ] && [ "${source_path}" -nt "${file_path}" ]; then + newer_source="${source_path}" + fi + if [ -n "${newer_source}" ]; then + fail "${label}: stale" "${remediation}" + return + fi + done + pass "${label}" +} + +check_executable() { + local label="$1" + local file_path="$2" + local remediation="$3" + + if [ -x "${file_path}" ]; then + pass "${label}" + else + fail "${label}: missing or not executable" "${remediation}" + fi +} + +git_config() { + git -C "${REPO_ROOT}" config --get "$1" 2>/dev/null || true +} + +check_git_configuration() { + local name email sign_enabled sign_format signing_key hooks_dir hook hooks_path + + name="$(git_config user.name)" + email="$(git_config user.email)" + if [ -n "${name}" ] && [ -n "${email}" ]; then + pass "Git contributor identity configured" + else + fail "Git contributor identity is incomplete" \ + "Set repository-local user.name and user.email before committing." + fi + + sign_enabled="$(git_config commit.gpgsign)" + sign_format="$(git_config gpg.format)" + signing_key="$(git_config user.signingkey)" + if [ "${sign_enabled}" = "true" ] && [ -n "${signing_key}" ]; then + pass "Git commit signing configured (${sign_format:-openpgp})" + else + fail "Git commit signing is incomplete" \ + "Configure user.signingkey and set commit.gpgsign=true before committing." + fi + + hooks_path="$(git_config core.hooksPath)" + if [ -n "${hooks_path}" ]; then + fail "Git core.hooksPath overrides repository hooks" \ + "Run: git config --unset core.hooksPath && npm install" + return + fi + hooks_dir="$(git -C "${REPO_ROOT}" rev-parse --git-path hooks 2>/dev/null || true)" + if [ -z "${hooks_dir}" ]; then + fail "Git hook directory could not be resolved" "Run: npm install" + return + fi + for hook in pre-commit commit-msg pre-push; do + if [ ! -x "${hooks_dir}/${hook}" ]; then + fail "Git ${hook} hook is missing" "Run: npm install" + return + fi + done + pass "Git hooks installed (pre-commit, commit-msg, pre-push)" +} + +check_github_authentication() { + if gh auth status >/dev/null 2>&1; then + pass "GitHub authentication" + else + fail "GitHub authentication failed" "Run: gh auth login -h github.com" + fi +} + +check_docker() { + local output server_version cpus memory_bytes storage_driver memory_gib + + if ! command -v docker >/dev/null 2>&1; then + fail "Docker CLI: not found" "Install and start Docker Desktop, Colima, or Docker Engine." + return + fi + if ! output="$(docker info --format '{{.ServerVersion}}|{{.NCPU}}|{{.MemTotal}}|{{.Driver}}' 2>/dev/null)"; then + fail "Docker daemon is not reachable" "Start the configured container runtime, then run this doctor again." + return + fi + IFS='|' read -r server_version cpus memory_bytes storage_driver <<<"${output}" + if ! [[ "${cpus}" =~ ^[0-9]+$ && "${memory_bytes}" =~ ^[0-9]+$ ]]; then + fail "Docker resource information is unavailable" "Run: docker info" + return + fi + memory_gib="$(awk -v bytes="${memory_bytes}" 'BEGIN { printf "%.1f", bytes / 1073741824 }')" + pass "Docker ${server_version}: ${cpus} vCPU, ${memory_gib} GiB, ${storage_driver} storage" + if ((cpus < 4)) || ((memory_bytes < 8589934592)); then + fail "Docker resources are below the minimum 4 vCPU and 8 GiB" \ + "Increase container-runtime resources before sandbox builds." + elif ((memory_bytes < 17179869184)); then + warn "Docker memory is below the recommended 16 GiB" \ + "Increase container-runtime memory for more reliable sandbox builds." + fi +} + +check_local_cli() { + local cli_path global_root global_link global_target + + cli_path="$(command -v nemoclaw 2>/dev/null || true)" + if [ -z "${cli_path}" ]; then + fail "Local NemoClaw CLI is not on PATH" "Run: npm install" + return + fi + if [ "${cli_path}" = "${REPO_ROOT}/bin/nemoclaw.js" ] || grep -Fq "${REPO_ROOT}/bin/nemoclaw.js" "${cli_path}" 2>/dev/null; then + pass "Local NemoClaw CLI resolves to this checkout" + return + fi + global_root="$(npm root -g 2>/dev/null || true)" + global_link="${global_root:+${global_root}/nemoclaw}" + if [ -n "${global_link}" ] && [ -d "${global_link}" ]; then + global_target="$(cd -- "${global_link}" 2>/dev/null && pwd -P || true)" + if [ "${global_target}" = "${REPO_ROOT}" ]; then + pass "Local NemoClaw CLI resolves to this checkout" + return + fi + fi + fail "NemoClaw CLI resolves to a different installation" "Run npm install from ${REPO_ROOT}." +} + +if [ "$#" -ne 1 ] || [ "$1" != "--doctor" ]; then + usage + exit 2 +fi + +printf '\nNemoClaw contributor environment\n\n' +printf ' Host: %s %s\n' "$(uname -s 2>/dev/null || printf unknown)" "$(uname -m 2>/dev/null || printf unknown)" +printf ' Repo: %s\n\n' "${REPO_ROOT}" + +if [ -f "${REPO_ROOT}/package.json" ] && [ -f "${REPO_ROOT}/AGENTS.md" ]; then + pass "NemoClaw source checkout" +else + fail "NemoClaw source checkout not found" "Run this command from a NemoClaw repository checkout." +fi + +check_minimum_version "Node.js" node "22.16.0" "Install Node.js 22.16 or newer." +check_minimum_version "npm" npm "10.0.0" "Install npm 10 or newer." +check_command "uv" uv "Install uv from https://docs.astral.sh/uv/." +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + check_minimum_version "Python repository environment" "${REPO_ROOT}/.venv/bin/python" "3.11.0" \ + "Run: uv sync --python 3.11" +else + fail "Python repository environment: missing" "Run: uv sync --python 3.11" +fi +check_command "Git" git "Install Git." +check_command "GitHub CLI" gh "Install GitHub CLI." +check_command "hadolint" hadolint "Install hadolint (macOS: brew install hadolint)." + +check_executable "Root TypeScript dependencies" "${REPO_ROOT}/node_modules/.bin/tsc" "Run: npm install" +check_executable "Prek dependency" "${REPO_ROOT}/node_modules/.bin/prek" "Run: npm install" +check_executable "Plugin TypeScript dependencies" "${REPO_ROOT}/nemoclaw/node_modules/.bin/tsc" \ + "Run: cd nemoclaw && npm install" +check_build_artifact "CLI build artifacts" "${CLI_BUILD_ARTIFACT}" "Run: npm run build:cli" \ + "${REPO_ROOT}/src" "${REPO_ROOT}/bin" "${REPO_ROOT}/nemoclaw-blueprint/scripts" \ + "${REPO_ROOT}/tsconfig.src.json" +check_build_artifact "Plugin build artifacts" "${PLUGIN_BUILD_ARTIFACT}" \ + "Run: cd nemoclaw && npm run build" "${REPO_ROOT}/nemoclaw/src" \ + "${REPO_ROOT}/nemoclaw/tsconfig.json" "${REPO_ROOT}/nemoclaw/package.json" + +check_git_configuration +check_github_authentication +check_docker +check_local_cli + +printf '\n Summary: %d passed, %d warning(s), %d failed\n\n' "${PASS_COUNT}" "${WARN_COUNT}" "${FAIL_COUNT}" + +if ((FAIL_COUNT > 0)); then + printf 'Contributor environment is not ready. Complete the actions above and run the doctor again.\n' + exit 1 +fi + +printf 'Ready to create a feature branch.\n' +printf 'Runtime sandbox: not required for contributor readiness.\n' diff --git a/test/dev-setup-doctor.test.ts b/test/dev-setup-doctor.test.ts new file mode 100644 index 00000000000..6d220a2c193 --- /dev/null +++ b/test/dev-setup-doctor.test.ts @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = path.join(import.meta.dirname, ".."); +const scriptUnderTest = path.join(repoRoot, "scripts", "dev-setup.sh"); +const tempRoots: string[] = []; + +type Fixture = { + cliArtifact: string; + env: NodeJS.ProcessEnv; + fakeBin: string; + pluginArtifact: string; + repo: string; +}; + +function writeExecutable(filePath: string, contents = "#!/usr/bin/env bash\nexit 0\n"): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, { mode: 0o755 }); +} + +function writeTool(fakeBin: string, name: string, body: string): void { + writeExecutable(path.join(fakeBin, name), `#!/usr/bin/env bash\nset -u\n${body}\n`); +} + +function createFixture(): Fixture { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dev-doctor-")); + tempRoots.push(tmp); + const repo = path.join(tmp, "NemoClaw"); + const fakeBin = path.join(tmp, "bin"); + const hooksDir = path.join(repo, ".git", "hooks"); + const globalRoot = path.join(tmp, "global-node-modules"); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.mkdirSync(globalRoot, { recursive: true }); + fs.writeFileSync(path.join(repo, "package.json"), "{}\n"); + fs.writeFileSync(path.join(repo, "AGENTS.md"), "# Agent Instructions\n"); + + for (const file of [ + "node_modules/.bin/tsc", + "node_modules/.bin/prek", + "nemoclaw/node_modules/.bin/tsc", + "bin/nemoclaw.js", + ".venv/bin/python", + ]) { + writeExecutable( + path.join(repo, file), + file === ".venv/bin/python" ? '#!/usr/bin/env bash\necho "Python 3.12.1"\n' : undefined, + ); + } + const cliArtifact = path.join(repo, "build-fixture", "cli.js"); + const pluginArtifact = path.join(repo, "build-fixture", "plugin.js"); + fs.mkdirSync(path.dirname(cliArtifact), { recursive: true }); + fs.writeFileSync(cliArtifact, "// built\n"); + fs.writeFileSync(pluginArtifact, "// built\n"); + for (const hook of ["pre-commit", "commit-msg", "pre-push"]) { + writeExecutable(path.join(hooksDir, hook)); + } + + writeTool(fakeBin, "node", 'echo "v22.16.0"'); + writeTool( + fakeBin, + "npm", + `if [ "\${1:-}" = "root" ] && [ "\${2:-}" = "-g" ]; then + echo "${globalRoot}" +else + echo "10.9.0" +fi`, + ); + writeTool(fakeBin, "python3", 'echo "Python 3.12.1"'); + writeTool(fakeBin, "uv", 'echo "uv 0.11.0"'); + writeTool(fakeBin, "hadolint", 'echo "Haskell Dockerfile Linter 2.14.0"'); + writeTool( + fakeBin, + "git", + `case " $* " in + *" --version "*) echo "git version 2.50.0" ;; + *" config --get user.name "*) + if [ "\${FAKE_GIT_IDENTITY_MISSING:-}" = "1" ]; then exit 1; fi + echo "Test Contributor" + ;; + *" config --get user.email "*) + if [ "\${FAKE_GIT_IDENTITY_MISSING:-}" = "1" ]; then exit 1; fi + echo "contributor@example.com" + ;; + *" config --get commit.gpgsign "*) + if [ "\${FAKE_GIT_SIGNING_MISSING:-}" = "1" ]; then exit 1; fi + echo "true" + ;; + *" config --get gpg.format "*) echo "ssh" ;; + *" config --get user.signingkey "*) + if [ "\${FAKE_GIT_SIGNING_MISSING:-}" = "1" ]; then exit 1; fi + echo "test-signing-key" + ;; + *" config --get core.hooksPath "*) exit 1 ;; + *" rev-parse --git-path hooks "*) echo "${hooksDir}" ;; + *) exit 1 ;; +esac`, + ); + writeTool( + fakeBin, + "gh", + `if [ "\${1:-}" = "--version" ]; then + echo "gh version 2.95.0" +elif [ "\${1:-}" = "auth" ] && [ "\${2:-}" = "status" ]; then + if [ "\${FAKE_GH_AUTH_FAIL:-}" = "1" ]; then + echo "token=should-not-appear" >&2 + exit 1 + fi +else + exit 1 +fi`, + ); + writeTool( + fakeBin, + "docker", + `if [ "\${FAKE_DOCKER_FAIL:-}" = "1" ]; then + echo "credential=should-not-appear" >&2 + exit 1 +fi +if [ "\${1:-}" = "info" ]; then + echo "29.6.1|\${FAKE_DOCKER_CPUS:-4}|\${FAKE_DOCKER_MEMORY:-17179869184}|overlay2" +else + echo "Docker version 29.6.1" +fi`, + ); + writeTool( + fakeBin, + "nemoclaw", + `# Managed checkout launcher: ${repo}/bin/nemoclaw.js +echo "nemoclaw v0.1.0"`, + ); + + return { + cliArtifact, + env: { + HOME: path.join(tmp, "home"), + NEMOCLAW_DEV_DOCTOR_CLI_ARTIFACT: cliArtifact, + NEMOCLAW_DEV_DOCTOR_PLUGIN_ARTIFACT: pluginArtifact, + NEMOCLAW_DEV_DOCTOR_REPO_ROOT: repo, + PATH: `${fakeBin}:/usr/bin:/bin`, + }, + fakeBin, + pluginArtifact, + repo, + }; +} + +function runDoctor( + fixture: Fixture, + env: NodeJS.ProcessEnv = {}, +): { + output: string; + status: number; +} { + const result = spawnSync("/bin/bash", [scriptUnderTest, "--doctor"], { + cwd: fixture.repo, + encoding: "utf-8", + env: { ...fixture.env, ...env }, + }); + return { + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + status: result.status ?? -1, + }; +} + +afterEach(() => { + for (const tempRoot of tempRoots.splice(0)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +describe("contributor environment doctor", () => { + it("reports a ready environment without mutating the fixture", () => { + const fixture = createFixture(); + const before = fs.readdirSync(fixture.repo, { recursive: true }).sort(); + + const result = runDoctor(fixture); + + expect(result.status).toBe(0); + expect(result.output).toContain("Ready to create a feature branch."); + expect(result.output).toContain("Python repository environment 3.12.1"); + expect(result.output).toContain("Git commit signing configured (ssh)"); + expect(result.output).toContain("Docker 29.6.1: 4 vCPU, 16.0 GiB, overlay2 storage"); + expect(result.output).toContain("0 failed"); + expect(fs.readdirSync(fixture.repo, { recursive: true }).sort()).toEqual(before); + }); + + it("rejects unsupported tool versions with a precise remediation", () => { + const fixture = createFixture(); + writeTool(fixture.fakeBin, "node", 'echo "v20.15.0"'); + + const result = runDoctor(fixture); + + expect(result.status).toBe(1); + expect(result.output).toContain("Node.js 20.15.0 is below 22.16.0"); + expect(result.output).toContain("Next: Install Node.js 22.16 or newer."); + }, 30_000); + + it("requires the uv-managed repository Python environment", () => { + const fixture = createFixture(); + fs.rmSync(path.join(fixture.repo, ".venv"), { recursive: true }); + + const result = runDoctor(fixture); + + expect(result.status).toBe(1); + expect(result.output).toContain("Python repository environment: missing"); + expect(result.output).toContain("Next: Run: uv sync --python 3.11"); + }); + + it("rejects build artifacts older than their source trees", () => { + const fixture = createFixture(); + const oldTime = new Date(Date.now() - 10_000); + fs.utimesSync(fixture.cliArtifact, oldTime, oldTime); + const changedSource = path.join(fixture.repo, "src", "changed.ts"); + fs.mkdirSync(path.dirname(changedSource), { recursive: true }); + fs.writeFileSync(changedSource, "export {};\n"); + + const result = runDoctor(fixture); + + expect(result.status).toBe(1); + expect(result.output).toContain("CLI build artifacts: stale"); + expect(result.output).toContain("Next: Run: npm run build:cli"); + }); + + it("redacts failed GitHub and Docker command output", () => { + const fixture = createFixture(); + + const result = runDoctor(fixture, { + FAKE_DOCKER_FAIL: "1", + FAKE_GH_AUTH_FAIL: "1", + }); + + expect(result.status).toBe(1); + expect(result.output).toContain("GitHub authentication failed"); + expect(result.output).toContain("Docker daemon is not reachable"); + expect(result.output).not.toContain("should-not-appear"); + }); + + it("reports missing signing configuration and required hooks", () => { + const fixture = createFixture(); + fs.rmSync(path.join(fixture.repo, ".git", "hooks", "pre-push")); + + const result = runDoctor(fixture, { FAKE_GIT_SIGNING_MISSING: "1" }); + + expect(result.status).toBe(1); + expect(result.output).toContain("Git commit signing is incomplete"); + expect(result.output).toContain("Git pre-push hook is missing"); + }); + + it("reports missing commands, dependencies, artifacts, and contributor identity", () => { + const fixture = createFixture(); + fs.rmSync(path.join(fixture.fakeBin, "hadolint")); + fs.rmSync(path.join(fixture.repo, "node_modules", ".bin", "tsc")); + fs.rmSync(fixture.pluginArtifact); + + const result = runDoctor(fixture, { FAKE_GIT_IDENTITY_MISSING: "1" }); + + expect(result.status).toBe(1); + expect(result.output).toContain("hadolint: not found"); + expect(result.output).toContain("Root TypeScript dependencies: missing or not executable"); + expect(result.output).toContain("Plugin build artifacts: missing"); + expect(result.output).toContain("Git contributor identity is incomplete"); + }); + + it("rejects a NemoClaw CLI linked to another checkout", () => { + const fixture = createFixture(); + writeTool(fixture.fakeBin, "nemoclaw", 'echo "nemoclaw v0.1.0"'); + + const result = runDoctor(fixture); + + expect(result.status).toBe(1); + expect(result.output).toContain("NemoClaw CLI resolves to a different installation"); + }); + + it("rejects Docker resources below the documented sandbox minimum", () => { + const fixture = createFixture(); + + const result = runDoctor(fixture, { + FAKE_DOCKER_CPUS: "2", + FAKE_DOCKER_MEMORY: "4294967296", + }); + + expect(result.status).toBe(1); + expect(result.output).toContain("below the minimum 4 vCPU and 8 GiB"); + expect(result.output).toContain("Increase container-runtime resources before sandbox builds."); + }); + + it("warns without failing when Docker memory is below the recommendation", () => { + const fixture = createFixture(); + + const result = runDoctor(fixture, { + FAKE_DOCKER_CPUS: "4", + FAKE_DOCKER_MEMORY: "8589934592", + }); + + expect(result.status).toBe(0); + expect(result.output).toContain("Docker memory is below the recommended 16 GiB"); + expect(result.output).toContain("1 warning(s)"); + }); + + it("rejects unsupported modes with usage and exit status 2", () => { + const fixture = createFixture(); + const result = spawnSync("/bin/bash", [scriptUnderTest, "--repair"], { + cwd: fixture.repo, + encoding: "utf-8", + env: fixture.env, + }); + + expect(result.status).toBe(2); + expect(result.stdout).toContain("Usage: ./scripts/dev-setup.sh --doctor"); + }); +});