# Verifying the `doctor` command (P0) This guide verifies the **`doctor`** command — a read-only, offline-by-default full checkup of nexus state: - `doctor` inspects the manifest, the two roots (`repos/`, `skills/`), every entry's symlink, orphaned clones / links, and each clone's `.git`, then prints an aligned report and exits **0** (clean), **1** (at least one error) or **2** (usage error). - `--json` emits a stable machine-readable contract (`version: 1`); `--updates` additionally compares **branch-pinned** entries against their remote (network); `--quiet` prints nothing unless there is at least one error (CI convenience). - `doctor` **changes nothing** — it never writes the manifest, clones or symlinks, and it is *not* wired into the plugin `apply()`, which stays a no-op. > **Shell warning — Windows users: run the walkthrough in Git Bash (MINGW64), > not PowerShell.** Every block below is POSIX / Bash. Do not paste it into a > PowerShell terminal. Commands use absolute paths or `git -C ` and a > `nexus()` shell function — nothing `cd`s into your project, so your working > tree cannot be polluted. Everything below is safe: it uses an isolated `DSH_HOME` under a temp dir, a local `file://` remote stands in for GitHub (no network for the local checks), and your real `~/.dsh` is never touched. Delete the temp dirs at the end. ## Prerequisites - Node.js ≥ 20 and git on `PATH` - Repo checked out and `npm install` done (tests run via `tsx` from `node_modules`) - If you changed `src/`, run `npm run build` first — the walkthrough runs the compiled CLI from `lib/` --- ## Part 1 — test suite (quality gates) ```bash npm run typecheck # tsc --noEmit (strict + noUncheckedIndexedAccess) npm run lint # ESLint 9 + typescript-eslint npm test # node:test + tsx — expected: all tests pass npm run build # tsc → lib/ npm run test:build # optional: compile src+test to test-dist/ for loader-free runs ``` The suite never touches your real environment: every test file uses a temp `DSH_HOME` plus `mkdtemp` directories that `after()` hooks remove. | test file | what it verifies | |---|---| | `test/doctor.test.ts` | fresh install is all-ok (exit 0); healthy install; broken symlink → `missing-target` (exit 1) with no orphan-link double-report; orphan-repo / orphan-link / dangling-link; corrupt manifest; **corrupt manifest suppresses orphan checks (no mass-delete false positives)**; **malformed entry is reported structurally, never throws**; `.corrupt-` backup; missing `.git`; `--json` version-1 contract; `--quiet`; usage errors (exit 2); `parseDoctorArgs`; `formatHuman` | | `test/health.test.ts` | `diagnoseEntry` (`ok` / `disabled` / `missing-target`); `findOrphanRepos` / `findOrphanLinks` three-way classification (`orphan-link` / `dangling-link` / `unreadable-link`); `checkHealth`; `formatWarning` | The two bolded `doctor.test.ts` cases are the regression anchors for the boundary-protection rule: when the manifest is unreadable, ownership of clones and links is *unknown*, so `doctor` must **skip** the orphan checks (a `warn` with no delete hint) rather than flag everything for deletion. --- ## Part 2 — end-to-end walkthrough One copy-paste block per platform. Set `PROJECT` to your checkout path. Each scenario starts from a known state; destructive ones rebuild it explicitly. ### Windows (Git Bash / MINGW64) ```bash PROJECT=~/Downloads/dsh-skills-nexus # ← your path (no trailing slash) nexus() { node "$PROJECT/lib/cli/index.js" "$@"; } UP="$(cygpath -m "$TEMP/demo")" # upstream repo dir is named "demo" HOME_DEMO="$(cygpath -m "$TEMP/nexus-doctor-home")" rm -rf "$UP" "$HOME_DEMO" # clean slate export DSH_HOME="$HOME_DEMO" URL="file:///$UP" # ---- build a local upstream repo (stands in for GitHub) ---- mkdir -p "$UP" git -C "$UP" init -b main # git ≥ 2.28 git -C "$UP" config user.email t@t git -C "$UP" config user.name t printf -- '---\nname: demo\ndescription: demo skill\n---\n# demo\n' > "$UP/SKILL.md" git -C "$UP" add . git -C "$UP" commit -m init # ---- S1) fresh install: empty DSH_HOME → all ok, exit 0 ---- nexus doctor; echo "exit=$?" # ---- S2) healthy install ---- nexus add "$URL" --name demo nexus doctor; echo "exit=$?" # all ok, exit 0 nexus doctor --quiet; echo "exit=$?" # silent, exit 0 nexus doctor --json | head -n 5 # "version": 1, "checks": [ … ] nexus doctor foo; echo "exit=$?" # usage error, exit 2 nexus doctor --json=x; echo "exit=$?" # usage error, exit 2 # ---- S3) orphan-repo: a clone no entry references (warn, exit 0) ---- mkdir -p "$DSH_HOME/skills-nexus/repos/leftover" nexus doctor; echo "exit=$?" # orphan-repo warn, exit 0 rmdir "$DSH_HOME/skills-nexus/repos/leftover" # ---- S4) missing-target: delete the clone, keep entry + symlink (error, exit 1) ---- rm -rf "$DSH_HOME/skills-nexus/repos/demo" nexus doctor; echo "exit=$?" # symlinks FAIL + git-sanity warn; orphan-link stays empty # restore by re-registering (update cannot re-clone a fully-deleted repo — see Pitfalls) nexus remove demo --yes nexus add "$URL" --name demo # ---- S5) corrupt manifest: unreadable file (error, exit 1; orphan checks skipped) ---- MANIFEST="$DSH_HOME/skills-nexus/manifest.json" cp "$MANIFEST" "$MANIFEST.bak" printf '{ not json' > "$MANIFEST" nexus doctor; echo "exit=$?" # manifest FAIL; orphan-repo/orphan-link = warn (not scanned, counts as 2 warnings); NO delete hints mv "$MANIFEST.bak" "$MANIFEST" # ---- S6) manifest drift: valid but empty manifest while clone + symlink remain ---- printf '{"version":1,"skills":[]}' > "$MANIFEST" nexus doctor; echo "exit=$?" # orphan-repo + orphan-link warn, exit 0 # ---- cleanup ---- rm -rf "$UP" "$HOME_DEMO" unset DSH_HOME ``` ### Linux / macOS ```bash PROJECT=~/dsh-skills-nexus # ← your path (no trailing slash) nexus() { node "$PROJECT/lib/cli/index.js" "$@"; } UP=/tmp/nexus-doctor/demo # upstream repo dir is named "demo" HOME_DEMO=/tmp/nexus-doctor/home rm -rf /tmp/nexus-doctor export DSH_HOME="$HOME_DEMO" URL="file://$UP" # ---- build a local upstream repo (stands in for GitHub) ---- mkdir -p "$UP" git -C "$UP" init -b main # git ≥ 2.28; older: git init && git -C "$UP" symbolic-ref HEAD refs/heads/main git -C "$UP" config user.email t@t git -C "$UP" config user.name t printf -- '---\nname: demo\ndescription: demo skill\n---\n# demo\n' > "$UP/SKILL.md" git -C "$UP" add . git -C "$UP" commit -m init # ---- S1) fresh install: empty DSH_HOME → all ok, exit 0 ---- nexus doctor; echo "exit=$?" # ---- S2) healthy install ---- nexus add "$URL" --name demo nexus doctor; echo "exit=$?" # all ok, exit 0 nexus doctor --quiet; echo "exit=$?" # silent, exit 0 nexus doctor --json | head -n 5 # "version": 1, "checks": [ … ] nexus doctor foo; echo "exit=$?" # usage error, exit 2 nexus doctor --json=x; echo "exit=$?" # usage error, exit 2 # ---- S3) orphan-repo: a clone no entry references (warn, exit 0) ---- mkdir -p "$DSH_HOME/skills-nexus/repos/leftover" nexus doctor; echo "exit=$?" # orphan-repo warn, exit 0 rmdir "$DSH_HOME/skills-nexus/repos/leftover" # ---- S4) missing-target: delete the clone, keep entry + symlink (error, exit 1) ---- rm -rf "$DSH_HOME/skills-nexus/repos/demo" nexus doctor; echo "exit=$?" # symlinks FAIL + git-sanity warn; orphan-link stays empty nexus remove demo --yes nexus add "$URL" --name demo # ---- S5) corrupt manifest: unreadable file (error, exit 1; orphan checks skipped) ---- MANIFEST="$DSH_HOME/skills-nexus/manifest.json" cp "$MANIFEST" "$MANIFEST.bak" printf '{ not json' > "$MANIFEST" nexus doctor; echo "exit=$?" # manifest FAIL; orphan-repo/orphan-link = warn (not scanned, counts as 2 warnings); NO delete hints mv "$MANIFEST.bak" "$MANIFEST" # ---- S6) manifest drift: valid but empty manifest while clone + symlink remain ---- printf '{"version":1,"skills":[]}' > "$MANIFEST" nexus doctor; echo "exit=$?" # orphan-repo + orphan-link warn, exit 0 # ---- cleanup ---- rm -rf /tmp/nexus-doctor unset DSH_HOME ``` --- ## Part 3 — `--updates` (version-lock aware) `--updates` is the only networked check. It compares each **branch-pinned** entry's recorded `commit` against the remote tip (`git ls-remote`). A **tag/commit pin** (detached HEAD) is an intentional fixed point: it is reported as `locked` (info) and never as "behind". `--updates` results are `info`-level — they never count as errors/warnings and never change the exit code. With the local `file://` remote from Part 2 this is fully hermetic: ```bash # (state: demo re-added on branch main, upstream unchanged) nexus doctor --updates; echo "exit=$?" # updates ok — local == remote, exit 0 # advance the upstream branch, then re-check printf -- '---\nname: demo\ndescription: v2\n---\n# demo v2\n' > "$UP/SKILL.md" git -C "$UP" add . git -C "$UP" commit -m second nexus doctor --updates; echo "exit=$?" # updates "update" — behind-remote (info), exit STILL 0 nexus update demo # fast-forwards and re-stamps the lock nexus doctor --updates; echo "exit=$?" # back to ok ``` A tag pin shows the `locked` path instead: ```bash git -C "$UP" tag v1.0.0 nexus remove demo --yes nexus add "$URL#v1.0.0" --name demo nexus doctor --updates # updates: demo → locked (info), never "behind" ``` Against a real GitHub repo the behaviour is identical; only the remote differs. --- ## Reading the report ### Checks (in report order) | check id | what it inspects | |---|---| | `manifest` | `manifest.json` presence, parseability, `version`/`skills` shape and per-entry structure; leftover `.corrupt-` backups | | `roots` | `repos/` and `skills/` exist and are readable | | `symlinks` | each entry's symlink resolves to an existing directory | | `orphan-repo` | clone dirs under `repos/` that no entry references | | `orphan-link` | symlinks into `repos/` that no entry claims | | `git-sanity` | each clone still has a `.git` | | `updates` | (only with `--updates`) branch pins vs their remote | ### Status → label → counting | status | human label | counts toward | affects exit code | |---|---|---|---| | `ok` | `ok` | — | no | | `warn` | `warn` | `warnings` | no | | `error` | `FAIL` | `errors` | yes → exit 1 | | `update-available` | `update` | `updates` | no | Exit codes: **0** = no error, **1** = at least one error, **2** = usage error (unknown option, positional argument, or an inline value on a boolean flag such as `--json=x`). ### Issue codes | code | severity | meaning | suggested fix | |---|---|---|---| | `corrupt-manifest` | error | `manifest.json` unparsable, wrong `version`, or a malformed entry | inspect the file; a `.corrupt-` backup (if any) holds the last raw content | | `corrupt-backup` | warn | a `manifest.json.corrupt-` backup exists | safe to delete once skills are re-added | | `root-unreadable` | error | `repos/` or `skills/` exists but cannot be read | fix directory permissions | | `missing-target` | error | an entry's symlink points at a missing directory | `remove ` then `add` again | | `orphan-repo` | warn | a clone under `repos/` that no entry references | remove the stale clone, or re-add it | | `orphan-link` | warn | a symlink into `repos/` that no entry claims | remove it, or re-add it | | `dangling-link` | error | a symlink into `repos/` whose target is gone and no entry claims | remove the dangling symlink | | `unreadable-link` | warn | `readlink` failed; ownership cannot be confirmed | inspect manually — do **not** delete blindly | | `orphan-check-skipped` | warn | the orphan scan was skipped because the manifest is unreadable | repair the manifest first (see `corrupt-manifest`) | | `missing-git` | warn | a clone is missing its `.git` | `remove ` then `add` again | | `behind-remote` | info | a branch pin is behind its remote (`--updates` only) | `update ` | | `locked` | info | a tag/commit pin, intentionally not tracking (`--updates` only) | none — this is expected | ### `--json` shape (stable contract, `version: 1`) ```json { "version": 1, "checks": [ { "id": "manifest", "status": "ok", "detail": "1 entry (version 1)", "issues": [] }, { "id": "symlinks", "status": "error", "issues": [ { "severity": "error", "code": "missing-target", "name": "demo", "fix": "dsh-skills-nexus update demo" } ] } ], "summary": { "errors": 1, "warnings": 0, "updates": 0 } } ``` Issues are **inlined** under their check (there is no top-level `issues` array). `severity` is `error` / `warn` / `info`; `status` is `ok` / `warn` / `error` / `update-available`. Consumers should key off `version` and treat unknown codes as forward-compatible. --- ## Pitfalls seen in practice 1. **Run the compiled CLI** — the walkthrough uses `lib/`; after changing `src/`, rebuild with `npm run build` first. 2. **`update` does not re-create a fully-deleted clone.** It fast-forwards a branch pin or restores a drifted tag/commit pin, but if `repos/` is gone entirely it errors. To repair a deleted clone, `remove ` then `add` again — which is why S4 restores state that way. 3. **A deleted clone trips two checks at once** — `symlinks` (`missing-target`, error) *and* `git-sanity` (`missing-git`, warn), because both look at the same missing directory. `orphan-link` deliberately stays empty: the link is still claimed by a live entry, so it is not an orphan (no double-report). 4. **Corrupt manifest ≠ missing manifest.** A missing manifest is a clean "nothing installed yet" (all ok). A present-but-unreadable one is an error, and because entry ownership is then unknown the orphan checks are **skipped** (warn, no delete hint) instead of flagging every healthy clone/link. 5. **The registered name is what you passed to `--name`** (here `demo`); the clone directory is the repo slug (also `demo`, since the upstream dir is named `demo`). Use `nexus list` to confirm both. 6. **Platform path formats.** Windows Git Bash: convert with `cygpath -m` and use `file:///C:/...` URLs. Linux / macOS: plain absolute paths and `file:///...` URLs. 7. **`git init -b main` needs git ≥ 2.28** (2019). Older git: `git init && git -C "$UP" symbolic-ref HEAD refs/heads/main`. 8. **Stale `$DSH_HOME`** — a previous run leaves entries behind. Delete the temp home (see cleanup) before starting fresh. --- ## Coverage boundaries What this guide does *not* cover (by design): - **Exhaustive orphan-link sub-cases** — `unreadable-link` and `dangling-link` need hand-crafted rogue symlinks that are fragile to create portably in a shell; they are covered deterministically by `test/doctor.test.ts` and `test/health.test.ts` instead. - **Real GitHub network** — the local `file://` remote exercises the same `git ls-remote` path without network flakiness. - **Plugin runtime** — `doctor` is a pure CLI command; the plugin `apply()` remains a no-op and never invokes it. - **Node 20 / 22 / 24 matrix** — CI (`.github/workflows/ci.yml`) runs the full quality-gate set on push/PR.