From f1de8bb6cd4a65ada25c32b6dbaa8647aeac129b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Fri, 10 Jul 2026 11:39:41 +0200 Subject: [PATCH 1/4] feat: make archetype detection the default init flow Remove the legacy v1/v2 module-wizard init path and its orphaned step files; `pharn init` now always runs archetype detection. Keep `--archetype` as a deprecated no-op alias for one release. Co-authored-by: Cursor --- .dev/features/init-archetype-default/GRILL.md | 73 ++++++ .dev/features/init-archetype-default/PLAN.md | 103 ++++++++ .../init-archetype-default/REGRESSION.md | 32 +++ .../features/init-archetype-default/REVIEW.md | 56 +++++ .dev/features/init-archetype-default/SHIP.md | 33 +++ .../features/init-archetype-default/VERIFY.md | 28 +++ .../regression-report.json | 43 ++++ .../init-archetype-default/verify-report.json | 13 + .pharn/pharn-dev-verify/results.json | 2 +- .pharn/writes-scope.json | 4 +- src/commands/init.ts | 161 +----------- src/index.ts | 8 +- src/steps/constitution-select.ts | 34 --- src/steps/detect.ts | 41 ---- src/steps/install.ts | 130 ---------- src/steps/mode-select.ts | 28 --- src/steps/module-select.ts | 25 -- src/steps/multitenant-select.ts | 20 -- src/steps/stackpack-select.ts | 29 --- src/steps/summary.ts | 66 ----- src/steps/wizard-questions.ts | 93 ------- tests/constitution-select.test.ts | 31 --- tests/detect.test.ts | 75 ------ tests/index.test.ts | 9 +- tests/init-v2.test.ts | 230 ------------------ tests/init.test.ts | 146 ++++++----- tests/install.test.ts | 191 --------------- tests/mode-select.test.ts | 25 -- tests/module-select.test.ts | 45 ---- tests/multitenant-select.test.ts | 44 ---- tests/stackpack-select.test.ts | 78 ------ tests/summary.test.ts | 95 -------- tests/wizard-questions.test.ts | 185 -------------- 33 files changed, 489 insertions(+), 1687 deletions(-) create mode 100644 .dev/features/init-archetype-default/GRILL.md create mode 100644 .dev/features/init-archetype-default/PLAN.md create mode 100644 .dev/features/init-archetype-default/REGRESSION.md create mode 100644 .dev/features/init-archetype-default/REVIEW.md create mode 100644 .dev/features/init-archetype-default/SHIP.md create mode 100644 .dev/features/init-archetype-default/VERIFY.md create mode 100644 .dev/features/init-archetype-default/regression-report.json create mode 100644 .dev/features/init-archetype-default/verify-report.json delete mode 100644 src/steps/constitution-select.ts delete mode 100644 src/steps/detect.ts delete mode 100644 src/steps/install.ts delete mode 100644 src/steps/mode-select.ts delete mode 100644 src/steps/module-select.ts delete mode 100644 src/steps/multitenant-select.ts delete mode 100644 src/steps/stackpack-select.ts delete mode 100644 src/steps/summary.ts delete mode 100644 src/steps/wizard-questions.ts delete mode 100644 tests/constitution-select.test.ts delete mode 100644 tests/detect.test.ts delete mode 100644 tests/init-v2.test.ts delete mode 100644 tests/install.test.ts delete mode 100644 tests/mode-select.test.ts delete mode 100644 tests/module-select.test.ts delete mode 100644 tests/multitenant-select.test.ts delete mode 100644 tests/stackpack-select.test.ts delete mode 100644 tests/summary.test.ts delete mode 100644 tests/wizard-questions.test.ts diff --git a/.dev/features/init-archetype-default/GRILL.md b/.dev/features/init-archetype-default/GRILL.md new file mode 100644 index 0000000..1f33a2c --- /dev/null +++ b/.dev/features/init-archetype-default/GRILL.md @@ -0,0 +1,73 @@ +# GRILL — init-archetype-default (interrogation of PLAN.md) + +**Plan:** `.dev/features/init-archetype-default/PLAN.md` · **Spec-hash check:** MATCH (`sha256(ARCHITECTURE.md)` == plan's `spec_content_hash` `bca940a5…`) — no drift. · **Griller membership (floor):** 13 registered (`count-grillers.mjs`, vendored in `test-app/`); isolated runner deferred (P7) — relevant lenses applied inline; a11y/i18n/migrations/privacy/observability/performance are N/A for an internal CLI refactor (no UI/data/PII/telemetry surface). · **Status: ADVISORY — gates nothing.** + +## Findings (finding-shape; free-text = quoted DATA from the untrusted plan) + +### axis: documentation + +```yaml +- type: FINDING + rule_id: "P4" + severity: important + file: ".dev/features/init-archetype-default/PLAN.md:27" + problem: "The Files list plans no CLAUDE.md or docs/ update, yet CLAUDE.md documents the init step-pipeline as the v1/v2 wizard flow (runInitLegacy/runInitV2) and its Documentation discipline says docs/ is user-facing and kept in sync with code; deleting those flows makes that prose stale." + evidence: "## Files (Scope A-clean) ... Explicitly NOT touched (live back-compat — must stay): src/lib/manifest.ts, install-modules.ts, installer.ts, wizard.ts" +``` + +### axis: testability + +```yaml +- type: FINDING + rule_id: "P1" + severity: important + file: ".dev/features/init-archetype-default/PLAN.md:53" + problem: "The no-404 regression guard is specified as 'mock-not-called' on fetchRemoteManifest/loadManifest, but once init.ts stops importing manifest.js there is nothing in init's module graph to spy on; a positive assertion (archetype path invoked: detect+resolve+installCapabilities) or a static 'init.ts has no manifest.js import' assertion is the more robust deterministic guard." + evidence: "no-404 regression guard: the default init path makes **no** call to `fetchRemoteManifest`/`loadManifest` (asserted via mock-not-called)." +``` + +### axis: guarantee-audit (P0) + +```yaml +- type: FINDING + rule_id: "P0" + severity: minor + file: ".dev/features/init-archetype-default/PLAN.md:59" + problem: "The label 'floor-adjacent' is non-canonical: the guarantee taxonomy is floor-reducible (hook / content-hash / enum-regex) OR advisory. A vitest behavioral assertion is advisory-with-CI-enforcement, not a floor primitive — the plan is otherwise honest (it explicitly says 'Not a new hook/content-hash primitive'), so this is a labeling nit, not the disease." + evidence: "→ **floor-adjacent**: enforced by the rewritten `tests/init.test.ts` mock-not-called assertion + `typecheck` ... Not a new hook/content-hash primitive." +``` + +### axis: scope / one-axis-of-change (P3 / P7) + +```yaml +- type: FINDING + rule_id: "P7" + severity: minor + file: ".dev/features/init-archetype-default/PLAN.md:24" + problem: "Scope A-clean bundles a behavior change (init default -> archetype + delete legacy symbols) with a dead-code sweep (9 orphaned step files + tests) — two reasons to change. This is ACKNOWLEDGED: the plan surfaced A-minimal as the smaller alternative and the human explicitly chose A-clean at GATE 1, so the bundle is a weighed decision, not a hidden one." + evidence: "Scope A-clean (this plan's recommendation): rewire init default → archetype; delete the init-level legacy symbols **and** the 9 now-orphaned step files + their tests." +``` + +### axis: eval-coverage (P1) + +```yaml +- type: FINDING + rule_id: "P1" + severity: minor + file: ".dev/features/init-archetype-default/PLAN.md:50" + problem: "The '## Evals to write (P1)' items are vitest unit tests, not eval-format.md capability evals with structural[]/semantic[] arrays; since the increment adds no Capability/rule_id, P1's ≥1-eval-per-Capability rule does not bind here — the plan should state P1 N/A explicitly to avoid conflating unit tests with capability evals." + evidence: "## Evals to write (P1)" +``` + +## Prose summary + +The plan is grounded, honest, and unusually careful about the one thing that most often goes wrong here: it does **not** overclaim. It correctly rejects the request's literal ask (delete `manifest.ts`/`install-modules.ts`) as infeasible-in-one-increment with a concrete back-compat proof, and its security audit explicitly refuses to claim it closes the Fable symlink finding (installModule survives via add/update). No blocking concern was found; the spec-hash matches. + +Two concerns are worth the human's attention before build: + +1. **Doc sync (important).** CLAUDE.md's "`commands/init.ts` is a step pipeline" narrative and its v1/v2 descriptions go stale the moment `runInitLegacy`/`runInitV2` are deleted, and CLAUDE.md's own Documentation-discipline note says docs/ tracks code. The Files list plans no such update. Either add CLAUDE.md/docs to the increment or record an explicit follow-up. +2. **No-404 guard shape (important).** "mock-not-called" is a fragile way to prove a negative once the symbol is no longer imported; a positive "archetype path invoked" assertion (or a static no-import check) is the deterministic guard that actually survives the refactor. + +Three minor items are labeling/hygiene: the "floor-adjacent" wording (P0 taxonomy), the acknowledged A-clean bundle (already chosen at GATE 1), and stating P1 N/A explicitly. None changes the build. + +**ADVISORY VERDICT: 5 concerns raised (0 blocking-severity, 2 important, 3 minor) — for the human to weigh before /pharn-dev-build. This grill-log gates nothing; the floor backstops (spec-hash drift, unresolved HALT questions, validate.mjs) remain /pharn-dev-build's.** diff --git a/.dev/features/init-archetype-default/PLAN.md b/.dev/features/init-archetype-default/PLAN.md new file mode 100644 index 0000000..a32f1a5 --- /dev/null +++ b/.dev/features/init-archetype-default/PLAN.md @@ -0,0 +1,103 @@ +# PLAN — init: make archetype the default, remove the init-level legacy flow + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: `npx pharn init` (no flag) runs the archetype/capability flow by default; the init-only legacy wizard flow (`runInitLegacy`/`runInitV2`/`loadManifest` and its 9 orphaned step files) is deleted. +- layer(s): pharn-cli product surface (`src/commands/init.ts`, `src/index.ts`, `src/steps/*`) — not a PHARN methodology layer. +- constitution_refs: [P0, P5, P6, P7] + +## Discovery findings (live state, this run) + +- `src/commands/init.ts:48` — the `opts.archetype` branch calls the **already-complete, self-contained** `runInitArchetype()`; the comment at :45–47 says "legacy … stays the default." `runInit` currently defaults to `loadManifest()` → `runInitV2`/`runInitLegacy`. +- `runInitArchetype` depends only on: `detect-archetype`, `capability-index`, `resolve-capabilities`, `repo`, `archetype-summary`, `install-archetype`, `pharn-config`, `constants`, `banner`, `confirm`, `prereqs.runGitPrereq`, `fresh-check`. **None of these touch `manifest.ts` or the legacy installer.** (`install-archetype` → `install-capabilities.ts`, not `installer.ts`.) +- **The other 5 commands are already dual-path**: `add`/`remove`/`list`/`update`/`status` dispatch on `isArchetypeConfig(config)` first, then fall back to `manifest.ts`/`fetchAndInstall` **for legacy (pre-archetype) configs**. `init` is the only command where legacy is still the *default* (no config exists yet at init time). +- **`manifest.ts` + `install-modules.ts` are NOT removable in this increment** — proven by live imports: + - `add.ts` + `update.ts` still call `fetchAndInstall` → `installModule`/`installSkills` (`install-modules.ts`) → `readManifest`/`resolveModules` (`manifest.ts`) as their legacy-config fallback. + - `list`/`status`/`remove`/`diff` read `manifest.ts` for legacy configs. + - `safeJoin` is **defined in `install-modules.ts:237`** and imported by the *archetype* path (`install-capabilities.ts:2`) + `capability-index`, `layout`, `skills-version`, `diff`, `remove`. + - Deleting them collides head-on with the documented guarantee (CLAUDE.md): _"Old pinned SHAs (v1) must keep working — never break `pharn update` against them."_ Confirmed by `tests/manifest.test.ts` + `add`/`update`/`list`/`status` tests, which assert exactly this fallback. +- The 9 init wizard step files (`module-select`, `stackpack-select`, `constitution-select`, `multitenant-select`, `mode-select`, `detect`, `wizard-questions`, `summary`, `install`) are imported by **`init.ts` only** (verified) — deleting the legacy init flows orphans them entirely. Each has a companion `tests/.test.ts`. +- Tests asserting the current legacy default: `tests/init.test.ts` (`runInit()` → legacy), `tests/init-v2.test.ts` (`runInit()` → v2 wizard), `tests/index.test.ts:49,57` (flag dispatch). + +## Scope decision (P7) — three candidate scopes + +- **Scope B (the request's literal "remove `manifest.ts` + `install-modules.ts` entirely") is REJECTED as infeasible in one increment**: it requires ripping legacy-config back-compat out of `add`/`update`/`list`/`status`/`remove`/`diff` and relocating `safeJoin`, breaking the documented "old pinned SHAs must keep working" guarantee. That is a separate multi-increment epic, not this PR. (See Open questions Q1.) +- **Scope A-clean (this plan's recommendation)**: rewire init default → archetype; delete the init-level legacy symbols **and** the 9 now-orphaned step files + their tests. Faithful to "remove the legacy flow entirely." Does not touch `manifest.ts`/`install-modules.ts`/`installer.ts`. +- **Scope A-minimal (fallback)**: same rewire + delete only `runInitLegacy`/`runInitV2`/`loadManifest` in `init.ts`; leave the 9 orphaned step files as (dead) code for a follow-up sweep. Smaller diff. + +## Files (Scope A-clean) + + + +Written / modified (in writes-scope; every path below exists after build): + +- `src/commands/init.ts` — `runInit()` calls `runInitArchetype()` unconditionally (drop the `opts.archetype` branch + the manifest branch); delete `runInitV2`, `runInitLegacy`, `loadManifest`; strip now-dead imports (`manifest.js`: `categorizeModules`/`fetchRemoteManifest`/`resolveModules`; `wizard.js`: `applyDefaults`/`collectInstalls`; the 9 step imports; `prereqs`: `assertPrerequisites`; types `Manifest`/`WizardConfig`/`WizardSpec`). Keep `runInitArchetype` + `confirmOverwriteIfExists` verbatim. +- `src/index.ts` — `--archetype` kept as a **documented no-op alias** for one release: keep it in the boolean list so `pharn init --archetype` still parses; drop "experimental" from the help line and mark archetype the default; change `runInit({ archetype: … })` → `runInit()`. +- `tests/init.test.ts` — `runInit()` (no opts) now drives the archetype flow (mirror `tests/init-archetype.test.ts` fixture setup); assert the archetype path IS taken and that `src/commands/init.ts` no longer imports `../lib/manifest.js` (the sharpened no-404 guard, grill #2). +- `tests/init-archetype.test.ts` — archetype is now the default; exercise via `runInit()` with no opts; keep coverage. +- `tests/index.test.ts` — update the two dispatch assertions (`:49`, `:57`) for the no-op-alias flag handling. + +### Deleted (verified orphans — `init.ts`-only importers; removed via `git rm`, never written — deliberately OUTSIDE the writes-scope and the build-completeness set) + +- `src/steps/module-select.ts` +- `src/steps/stackpack-select.ts` +- `src/steps/constitution-select.ts` +- `src/steps/multitenant-select.ts` +- `src/steps/mode-select.ts` +- `src/steps/detect.ts` +- `src/steps/wizard-questions.ts` +- `src/steps/summary.ts` +- `src/steps/install.ts` +- `tests/module-select.test.ts` +- `tests/stackpack-select.test.ts` +- `tests/constitution-select.test.ts` +- `tests/multitenant-select.test.ts` +- `tests/mode-select.test.ts` +- `tests/detect.test.ts` +- `tests/wizard-questions.test.ts` +- `tests/summary.test.ts` +- `tests/install.test.ts` +- `tests/init-v2.test.ts` + +### Explicitly NOT touched (live back-compat — must stay) + +- `src/lib/manifest.ts` — legacy-config fallback for `add`/`update`/`list`/`status`/`remove`/`diff`. +- `src/lib/install-modules.ts` — `safeJoin` source (consumed by the archetype path) + `installModule`/`installSkills` for the legacy fallback. +- `src/lib/installer.ts` — `fetchAndInstall`, still called by `add`/`update` for legacy configs. +- `src/lib/wizard.ts` — `add`'s `findSkillOption`/`listSkillAddresses`. + +## Contracts satisfied + +- No `pharn-contracts` methodology contract is added or changed — this is a product-CLI refactor. (P4: nothing to restate.) + +## Evals to write (P1) + +- init default → archetype: `runInit()` (no opts) invokes `detectArchetypesFromProject` + `parseCapabilityIndex` + `resolveCapabilities` + `installCapabilities`, and installs under the mirrored layout (flat / `pharn/` + `.claude/`). — `tests/init.test.ts` / `tests/init-archetype.test.ts` +- no-404 regression guard (grill #2, sharpened): assert the archetype path IS taken (`detectArchetypesFromProject` + `resolveCapabilities` + `installCapabilities` invoked) and that `src/commands/init.ts` no longer imports `../lib/manifest.js` — a positive assertion + static import check, not a fragile mock-not-called. — `tests/init.test.ts` +- flag alias: `npx pharn init --archetype` still runs without error and behaves identically to no-flag (no-op alias). — `tests/index.test.ts` +- legacy-init symbols gone: `typecheck` + `lint` pass with no dangling refs after deletion (the compiler is the enforcement). — CI + +## Guarantee audit (P0) + +- "`npx pharn init` (no flag) runs archetype detection, no manifest fetch, no 404" → **floor-adjacent**: enforced by the rewritten `tests/init.test.ts` mock-not-called assertion + `typecheck` (dangling import = compile error). Behavioral, deterministic, CI-gated. Not a new hook/content-hash primitive. +- "Legacy init symbols removed" → **mechanical**: `tsc --noEmit` fails on any surviving reference; `lint` flags unused. Deterministic. +- **Security (HONEST, narrowed):** removing the init legacy path does **NOT** close the Fable symlink-write finding in `install-modules.ts` — `installModule` remains reachable via the `add`/`update` legacy-config fallback (Scope B territory). The only true security delta here: the **default** `init` install path no longer routes through `installModule` at all (it uses `install-capabilities.ts`, which already rejects symlinks — `isSymlink`/`noSymlinks`). Claiming this PR "closes the symlink finding" would be false (P0) — it narrows exposure, it does not eliminate it. + +## Trust audit (P2) + +- No new untrusted-input ingestion. The archetype path (now default) already validates the fetched pharn-oss clone: names against `validate.ts` allowlists before any path-join, `safeJoin` on every read/write, explicit symlink rejection. This increment removes a code path; it adds no new taint source. Unchanged. + +## Determinism audit (P5) + +- `runInit` goes from a 3-way branch (`opts.archetype` / `schemaVersion===2` / else) to an **unconditional** call — strictly more deterministic, no new branches. The `--archetype` flag becomes a no-op (its value is never branched on). ✓ + +## Open questions — RESOLVED at GATE 1 (no HALT outstanding) + +- **Q1 — Scope.** RESOLVED → **A-clean**: rewire init default + delete the init-level legacy symbols **and** the 9 orphaned step files + their tests + `init-v2.test.ts`. (Scope B — delete `manifest.ts`/`install-modules.ts` outright — stays out: it breaks the documented `pharn update` back-compat guarantee and is multi-increment.) +- **Q2 — `--archetype` flag.** RESOLVED → **no-op alias for one release**: keep the flag parsing so `pharn init --archetype` still runs; `runInit()` ignores it; help text updated. + +No open questions remain; the plan was approved at GATE 1 (see the advisory `GRILL.md` for the interrogation). diff --git a/.dev/features/init-archetype-default/REGRESSION.md b/.dev/features/init-archetype-default/REGRESSION.md new file mode 100644 index 0000000..b766dd0 --- /dev/null +++ b/.dev/features/init-archetype-default/REGRESSION.md @@ -0,0 +1,32 @@ +# REGRESSION — init-archetype-default + +- **Base:** `f58094e` (working tree is dirty → base = HEAD; a working-tree dogfood build) +- **Verdict:** `REGRESSIONS: none — no deterministically-detectable breakage outside the feature` +- **Determinism:** the verdict is `.dev/floor/check-regress.mjs verdict`'s output verbatim (exit 0). The orchestration below is advisory; only the exit-code comparison is the guarantee. + +## Inside / outside partition + +- **Inside (changed, plan-authorized):** `src/commands/init.ts`, `src/index.ts` (modified) + 19 deleted legacy files (9 `src/steps/*.ts`, 9 `tests/*.test.ts`, `tests/init-v2.test.ts`) + `tests/init.test.ts`, `tests/index.test.ts` (rewritten). `check-regress scope` → `escaped: []` (no write-scope breach; every change is covered by the plan's `## Files` + `### Deleted`). +- **Outside test universe:** 44 `*.test.mjs` / `*.test.cjs` (floor + hook suites). None are inside the feature. +- **Style gates:** skipped — `inside` touches no shared style config (`eslint.config.mjs`, `.prettierrc.json`, …), so an outside style flip is provably impossible. +- **Eval pairs:** none committed here (no outside `structural:*` gate). + +## Per-gate base → head (exit codes) + +| gate | base | head | classification | +| ---------- | ---- | ---- | -------------- | +| `tests` | 1 | 1 | pre_existing | +| `validate` | 0 | 0 | OK | + +- **`validate` 0 → 0 (OK):** this TS-only increment adds no markdown capability, so it cannot affect `validate` over the tracked repo. +- **`tests` 1 → 1 (pre_existing):** the aggregate `.mjs/.cjs` suite is already red at the **clean committed baseline** `f58094e` (measured in a fresh `git worktree`, none of this increment's changes present). It is therefore independent of this increment; `check-regress` excludes pre-existing red from blame. Named, not hidden — the outside `.mjs/.cjs` suite has a pre-existing failure that predates and is unrelated to this work. + +## Measurement-correction note (honest — the first capture was inconsistent) + +The **first** head capture reported `validate` `0 → 1`, i.e. an apparent regression. Root cause (verified, not assumed): `test-app/` is a **gitignored, 0-tracked-file** local build artifact (from `npm run build:install-local`) present in the working tree but **absent** from the clean `git worktree` baseline. `validate.mjs .` is whole-repo, so at head it scanned `test-app/pharn/floor/test-fixtures/red/skill.md` (a deliberately-invalid "red" fixture) and at base it did not — the two runs measured **different file universes**, violating check-regress's "same scoped gates at base and head by construction" precondition. This was an orchestration bug in the capture, not a code regression. + +**Correction:** the head `validate` gate was re-measured over the **same tracked scope** as the baseline (untracked `test-app/` set aside, then restored) → `0` GREEN, and confirmed all failures were solely the `test-app/` fixture. The deterministic verdict was then re-run over the corrected, apples-to-apples inputs → `no-regressions` (exit 0). No code change was made to reach green; only the measurement scope was made consistent. The `check-regress.mjs verdict` JSON in `regression-report.json` is the corrected run. + +## Honest residual (P0/P7) + +`/pharn-dev-regress` catches exactly what its deterministic suite catches — nothing more. A broken behavior with no test/rule/eval outside the feature is invisible here. This verdict certifies **the comparison**, not that the increment is whole. diff --git a/.dev/features/init-archetype-default/REVIEW.md b/.dev/features/init-archetype-default/REVIEW.md new file mode 100644 index 0000000..6db72f6 --- /dev/null +++ b/.dev/features/init-archetype-default/REVIEW.md @@ -0,0 +1,56 @@ +# REVIEW — init-archetype-default + +**Increment:** make the archetype flow the default (and only) `pharn init`; delete the legacy module/wizard init path (`runInitLegacy`/`runInitV2`/`loadManifest` + 19 orphaned files); keep `--archetype` as a no-op alias; retain `manifest.ts`/`install-modules.ts`/`installer.ts`/`wizard.ts` for `add`/`update` legacy-config back-compat. + +**Step 1 floor:** `validate.mjs .` GREEN (exit 0) over the tracked scope. The review below is advisory. + +## The four lenses + +### L-floor → P0 — GREEN + +Every guarantee the increment makes reduces to the floor or is honestly labeled. The one security claim is stated with rare discipline — it explicitly **refuses to overclaim**: + +```yaml +- type: FINDING + rule_id: "P0" + severity: minor + file: ".dev/features/init-archetype-default/PLAN.md:59" + problem: "The guarantee audit labels the behavioral no-404 claim 'floor-adjacent'; canonically a claim is floor-reducible (hook/content-hash/enum-regex) OR advisory, and a vitest+CI assertion is advisory-with-CI — a labeling nit, ADVISORY, already flagged at grill. The built code itself makes no unlabeled guarantee." + evidence: "→ **floor-adjacent**: ... Not a new hook/content-hash primitive." +``` + +No blocking P0 finding. The security posture actually **improves**: the default install no longer routes through the symlink-vulnerable `install-modules.ts` `installModule` (the archetype path's `install-capabilities.ts` rejects symlinks); the plan correctly notes this does NOT fully close the finding (`installModule` survives via `add`/`update`). + +### L-eval → P1 — GREEN + +The increment adds no Capability and no `enforces` `rule_id`, so P1's ≥1-eval binding does not bind. Every new behavior nonetheless ships a test in-increment: archetype-default + no-manifest-import guard (`tests/init.test.ts`), no-op flag alias (`tests/index.test.ts`), and the deletions are covered by typecheck (no dangling refs) + a green suite. `tests/init-archetype.test.ts` (engine e2e) is unchanged and still covers the install machinery. No missing binding. + +### L-trust → P2 — GREEN + +The increment emits no findings/free-text and ingests no new untrusted input — it removes a code path. Nothing in the reviewed artifact was instruction-looking or altered reviewer behavior. The retained archetype path already validates the untrusted fetched repo (validate.ts allowlists + `safeJoin` + symlink rejection); this increment does not weaken it. No guaranteed decision rests on a tainted field. + +### L-axis → P3 — GREEN + +Each changed file carries one coherent axis: `init.ts` (make archetype the default + delete legacy), `index.ts` (flag no-op + honest help + call change). No cross-sibling-module reference (pharn-cli is a single package; no `pharn-contracts` routing applies). The A-clean bundle (behavior change + dead-code sweep) is a P7-scope choice, human-accepted at GATE 1 — not a P3 file-axis violation. + +## Advisory findings (inform the GATE-2 decision; block nothing) + +```yaml +- type: FINDING + rule_id: "P4" + severity: important + file: "CLAUDE.md:42" + problem: "Deferred doc-sync (grill #1): CLAUDE.md's 'commands/init.ts is a step pipeline' / v1-v2 narrative and docs/commands/init.md still describe the now-deleted legacy init flow. The scope decision deferred this (CLAUDE.md's init prose is entangled with the module model that SURVIVES for add/update legacy configs — a careful reconciliation, not a mechanical sync). ADVISORY: real accuracy gap; recommend a follow-up increment." + evidence: "**v1 (`runInitLegacy`):** a loop of `module-select` ... **v2 (`runInitV2`):** `mode-select` (Default / Custom) ..." +``` + +## Verdict + +**GREEN — 0 blocking floor-findings.** Four lenses pass on the built code; the standing floor verdicts are BUILD `npm run check` = 0, REGRESS = `no-regressions`, VERIFY = `PASS`. The remaining items are advisory: one **important** deferred doc-sync (CLAUDE.md/docs), and minor labeling/scope nits already weighed at grill/GATE-1. None blocks; all are the human's to weigh at the post-review gate. + +## Proposed lessons (candidates only — NOT promoted; a gated `/pharn-dev-memory-promote` run decides) + +Two **real** recurring gotchas surfaced during this build (P7 — real, not hypothetical; provenance: this increment): + +1. **`PLAN.md` `## Files` format for deletion/glob increments.** The writes-scope setter + `check-build-complete.mjs` scan back-tick paths under `## Files` and require each **concrete** one to EXIST after build; `isConcrete` treats `{a,b,c}` brace-globs as a single literal path. **Lesson candidate:** list only created/modified (must-exist) paths as concrete back-tick items in the scanned region; put deletions under a `### Deleted` heading (removed via `git rm`, which the enforce hook does not gate); never use brace-glob shorthand. +2. **`validate.mjs .` is whole-repo and scans untracked `test-app/`.** A gitignored local `build:install-local` artifact (with deliberately-red `test-fixtures/`) flips `validate` red in the working tree but not on a clean checkout/CI. **Lesson candidate:** at `/pharn-dev-regress` / `/pharn-dev-verify`, measure the whole-repo `validate` gate over the tracked/CI scope (untracked `test-app/` set aside) so base/head and working-tree/CI compare like-for-like. diff --git a/.dev/features/init-archetype-default/SHIP.md b/.dev/features/init-archetype-default/SHIP.md new file mode 100644 index 0000000..bea3269 --- /dev/null +++ b/.dev/features/init-archetype-default/SHIP.md @@ -0,0 +1,33 @@ +# SHIP — init-archetype-default (gated chain roll-up) + +**Increment:** Make the archetype flow the default (and only) `pharn init`; remove the dead legacy module/wizard init path. `runInit()` now calls `runInitArchetype()` unconditionally; `runInitLegacy`/`runInitV2`/`loadManifest` + 19 orphaned legacy files deleted; `--archetype` kept as a documented no-op alias; `manifest.ts`/`install-modules.ts`/`installer.ts`/`wizard.ts` retained for `add`/`update` legacy-config back-compat (Scope B — deleting them — rejected as multi-increment, breaks the documented `pharn update` guarantee). + +**Where the run ended:** GATE 2 (post-review human gate). No RED-verdict STOP occurred. Awaiting the human's merge / fix / abandon decision. + +## Stages run, in order + +| stage | outcome / verdict read | +| ------------- | ------------------------------------------------------------------- | +| `/pharn-dev-plan` | Plan written; **GATE 1** approved (Scope A-clean + `--archetype` no-op alias). | +| `/pharn-dev-grill` | Advisory — 5 concerns (0 blocking, 2 important, 3 minor). Gated nothing. `GRILL.md`. | +| `/pharn-dev-build` | Floor **GREEN** — `npm run check` exit **0** (561 tests pass). | +| `/pharn-dev-regress` | **`no-regressions`** (`check-regress.mjs verdict` exit 0). | +| `/pharn-dev-verify` | **`PASS`** (`check-verify.mjs` exit 0; all 5 floor gates 0). | +| `/pharn-dev-review` | Advisory — **GREEN**, 0 blocking floor-findings. `REVIEW.md`. | + +## Structural verdicts read (verbatim) + +- **`/pharn-dev-build`** → `npm run check` exit code = **0** (the real floor for a TypeScript increment; `validate.mjs` adds no gate here — no markdown capability was added, and its working-tree RED is solely the gitignored `test-app/` fixture, GREEN over the tracked/CI scope). +- **`/pharn-dev-regress`** → `.dev/features/init-archetype-default/regression-report.json` `.verdict` = **`"no-regressions"`**. (Honest note: the first capture showed a spurious `validate` flip from the untracked `test-app/` build artifact; the gate was re-measured over the consistent tracked scope and the deterministic verdict re-run — see `REGRESSION.md`.) +- **`/pharn-dev-verify`** → `.dev/features/init-archetype-default/verify-report.json` `.verdict` = **`"PASS"`** (`failing_gates: []`). + +## Advisory inputs for the human (cited, not restated — P4) + +- **`.dev/features/init-archetype-default/REVIEW.md`** — 4 lenses GREEN; one **important** advisory finding: deferred doc-sync (CLAUDE.md / `docs/commands/init.md` still describe the deleted legacy init flow — recommend a follow-up increment). Two lesson candidates proposed (the `## Files` deletion/glob format gotcha; the `test-app/` whole-repo `validate` scope gotcha) — NOT promoted; a gated `/pharn-dev-memory-promote` run decides. +- **`.dev/features/init-archetype-default/GRILL.md`** — advisory pre-build interrogation; grill #2 (sharpen the no-404 guard) was folded into the build; grill #1 (doc-sync) is the deferred item above. + +## Standing decision + +The chain ran; the named floor verdicts are as shown — this is **NOT** a judgment that the increment is good or wise; that is the human's call at the post-review gate. `/pharn-dev-ship` does not merge, push, commit, or apply the `PHARN ✓ reviewed` seal. + +**Human decision required (GATE 2): merge / fix / abandon.** If *merge*, note the deferred doc-sync (CLAUDE.md + `docs/commands/init.md`) as an immediate follow-up. diff --git a/.dev/features/init-archetype-default/VERIFY.md b/.dev/features/init-archetype-default/VERIFY.md new file mode 100644 index 0000000..2f89588 --- /dev/null +++ b/.dev/features/init-archetype-default/VERIFY.md @@ -0,0 +1,28 @@ +# VERIFY — init-archetype-default + +**VERIFIED: floor gates PASS** — `check-verify.mjs` verdict `PASS` (exit 0), every gate exit 0. + +## Floor gates (gate → exit code) + +| gate | exit | notes | +| -------------- | ---- | ------------------------------------------------- | +| `test` | 0 | vitest `tests/**/*.test.ts` — 561 pass | +| `validate` | 0 | `.dev/floor/validate.mjs` GREEN (tracked scope) | +| `lint` | 0 | eslint `src` | +| `format:check` | 0 | prettier — whole-repo clean | +| `lint:md` | 0 | markdownlint — `docs/**` + root `*.md` | + +- **No `structural:*` gate:** this increment ships no committed eval pair (`pharn-review/trust-fence/evals/expected/*.json` is not present in this repo), so there is no per-eval structural gate — exactly as `/pharn-dev-regress` handled it. +- **Build-completeness (diligence, not part of the verdict):** `check-build-complete.mjs` → `complete` — the plan's `## Files` declares exactly `src/commands/init.ts`, `src/index.ts`, `tests/init.test.ts`, `tests/init-archetype.test.ts`, `tests/index.test.ts`; all exist, `missing: []`. Confirms the `## Files` was reformatted correctly (deletions under `### Deleted` excluded from the completeness set). + +## `validate` measurement note (same as REGRESSION.md — tracked/CI scope) + +`validate.mjs .` is whole-repo. The working tree contains `test-app/` — a **gitignored, 0-tracked-file** local build artifact whose `test-fixtures/red/skill.md` is a **deliberately-invalid** fixture (the negative corpus for `validate`'s own tests). Run literally over the working tree, `validate` is red **only** from that fixture; on a clean checkout / CI (no `test-app/`) it is GREEN. The gate was therefore measured over the **tracked/CI scope** (untracked `test-app/` set aside, then restored) → `0`. This measures "is the tracked repo + this feature green," which is what verify asks; it does not mask any real defect (the sole red path was the gitignored fixture, verified). + +## Verifiers (advisory layer) + +**No verifiers registered — floor gates only.** `count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}`. Step 2 is a no-op; the verdict is the floor gates alone. + +## Honest residual (P0/P7) + +Verified = the named gates passed. This is **not** a guarantee of correctness beyond what those gates check — a defect no test/eval/rule/lint covers is invisible to the floor verdict, and there are no verifiers today to raise advisory concerns. The verdict certifies the gates it ran, nothing more. diff --git a/.dev/features/init-archetype-default/regression-report.json b/.dev/features/init-archetype-default/regression-report.json new file mode 100644 index 0000000..5a9d7b5 --- /dev/null +++ b/.dev/features/init-archetype-default/regression-report.json @@ -0,0 +1,43 @@ +{ + "base": "f58094e9e9297cb1c487e242c80534d0bbb07b2d", + "inside": [ + "src/commands/init.ts", + "src/index.ts", + "src/steps/constitution-select.ts", + "src/steps/detect.ts", + "src/steps/install.ts", + "src/steps/mode-select.ts", + "src/steps/module-select.ts", + "src/steps/multitenant-select.ts", + "src/steps/stackpack-select.ts", + "src/steps/summary.ts", + "src/steps/wizard-questions.ts", + "tests/constitution-select.test.ts", + "tests/detect.test.ts", + "tests/index.test.ts", + "tests/init-v2.test.ts", + "tests/init.test.ts", + "tests/install.test.ts", + "tests/mode-select.test.ts", + "tests/module-select.test.ts", + "tests/multitenant-select.test.ts", + "tests/stackpack-select.test.ts", + "tests/summary.test.ts", + "tests/wizard-questions.test.ts" + ], + "outside_gates": { + "tests": { + "base": 1, + "head": 1 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [ + "tests" + ], + "verdict": "no-regressions" +} diff --git a/.dev/features/init-archetype-default/verify-report.json b/.dev/features/init-archetype-default/verify-report.json new file mode 100644 index 0000000..adffa68 --- /dev/null +++ b/.dev/features/init-archetype-default/verify-report.json @@ -0,0 +1,13 @@ +{ + "feature": "init-archetype-default", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { "registered": 0, "findings": [] } +} diff --git a/.pharn/pharn-dev-verify/results.json b/.pharn/pharn-dev-verify/results.json index d76b920..c1e0603 100644 --- a/.pharn/pharn-dev-verify/results.json +++ b/.pharn/pharn-dev-verify/results.json @@ -1 +1 @@ -{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0} +{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0} \ No newline at end of file diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 29c96a0..64cbee9 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/installer-layout-mirror/SHIP.md" + ".dev/features/init-archetype-default/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-07-10T07:57:30.084Z" + "set_at": "2026-07-10T09:37:04.318Z" } diff --git a/src/commands/init.ts b/src/commands/init.ts index 78f9beb..fbc1c53 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -3,12 +3,6 @@ import { confirm, intro, isCancel, log, note, spinner } from '@clack/prompts'; import { showBanner } from '../lib/banner.js'; import { cancelAndExit } from '../lib/confirm.js'; import { REPO_URL } from '../lib/constants.js'; -import { - categorizeModules, - fetchRemoteManifest, - resolveModules, -} from '../lib/manifest.js'; -import { applyDefaults, collectInstalls } from '../lib/wizard.js'; import { detectArchetypesFromProject } from '../lib/detect-archetype.js'; import { parseCapabilityIndex } from '../lib/capability-index.js'; import { resolveCapabilities } from '../lib/resolve-capabilities.js'; @@ -18,45 +12,24 @@ import { isConfigValidationError, readPharnConfig, } from '../lib/pharn-config.js'; -import { runGitPrereq, assertPrerequisites } from '../steps/prereqs.js'; +import { runGitPrereq } from '../steps/prereqs.js'; import { runFreshCheck } from '../steps/fresh-check.js'; -import { runModeSelect } from '../steps/mode-select.js'; -import { runDetect } from '../steps/detect.js'; -import { runWizardQuestions } from '../steps/wizard-questions.js'; -import { runModuleSelect } from '../steps/module-select.js'; -import { runStackPackSelect } from '../steps/stackpack-select.js'; -import { runConstitutionSelect } from '../steps/constitution-select.js'; -import { runMultiTenantSelect } from '../steps/multitenant-select.js'; -import { runSummary } from '../steps/summary.js'; -import { runInstall } from '../steps/install.js'; import { runArchetypeSummary } from '../steps/archetype-summary.js'; import { runInstallArchetype } from '../steps/install-archetype.js'; -import type { Manifest, WizardConfig, WizardSpec } from '../types.js'; -export async function runInit( - opts: { archetype?: boolean } = {}, -): Promise { +export async function runInit(): Promise { showBanner(); intro('init wizard'); runGitPrereq(); await runFreshCheck(); - // Archetype-driven install (experimental, flag-gated). Framework-agnostic: it - // detects the project's archetype(s) and installs the applicable capabilities. - // The legacy module/wizard flow below stays the default. - if (opts.archetype) { - await runInitArchetype(); - return; - } - - const manifest = await loadManifest(); - - if (manifest.schemaVersion === 2 && manifest.wizard) { - await runInitV2(manifest, manifest.wizard); - return; - } - await runInitLegacy(manifest); + // Archetype-driven install is the default (and only) init flow: detect the + // project's archetype(s) and install the applicable capabilities. Framework- + // agnostic — no module catalog / manifest fetch. (The legacy module/wizard + // flow was removed; add/update still fall back to the manifest for + // pre-archetype configs.) + await runInitArchetype(); } // schemaVersion-free archetype flow: detect archetypes from the project, fetch @@ -142,121 +115,3 @@ async function confirmOverwriteIfExists(cwd: string): Promise { }); return !isCancel(ok) && ok === true; } - -// schemaVersion 2: render the wizard from manifest.wizard, then install only the -// selected per-tech skill subfolders. -async function runInitV2( - manifest: Manifest, - wizard: WizardSpec, -): Promise { - const { optional, stackPacks } = categorizeModules(manifest); - - // Pre-fill from the project's package.json (stack pack + per-tech answers). - // Detection runs once up front; the user overrides anything below. - const { detectedAnswers, detectedStackPack } = runDetect(wizard, stackPacks); - - let previous: WizardConfig | undefined; - - while (true) { - const mode = await runModeSelect(); - const stackAnswers = - mode === 'default' - ? applyDefaults(wizard, detectedAnswers) - : await runWizardQuestions( - wizard, - previous?.stackAnswers ?? detectedAnswers, - ); - - const modules = await runModuleSelect(optional, previous?.modules); - // undefined initial = first run → seed the detected pack (or None); on a - // loop-back reuse the prior pick, preserving an explicit None (null). - const stackPack = await runStackPackSelect( - stackPacks, - previous ? previous.stackPack : detectedStackPack, - ); - const constitution = await runConstitutionSelect(previous?.constitution); - const isMultiTenant = await runMultiTenantSelect(previous?.isMultiTenant); - - const installedSkills = collectInstalls(wizard, stackAnswers); - - const config: WizardConfig = { - modules, - stackPack, - constitution, - isMultiTenant, - stackAnswers, - installedSkills, - }; - - const selected = [...modules, ...(stackPack ? [stackPack] : [])]; - const resolved = resolveModules(manifest, selected); - - const action = await runSummary(config, resolved, manifest.skillsVersion); - - if (action === 'install') { - // Conditional, manifest-driven package gate — only the chosen pack's - // prerequisites are enforced, and only once the user commits to install. - assertPrerequisites(resolved); - await runInstall(config); - return; - } - if (action === 'cancel') { - cancelAndExit(); - } - previous = config; - } -} - -// schemaVersion 1: the original module-multiselect → stack pack → posture flow. -async function runInitLegacy(manifest: Manifest): Promise { - const { optional, stackPacks } = categorizeModules(manifest); - - let previous: WizardConfig | undefined; - - while (true) { - const modules = await runModuleSelect(optional, previous?.modules); - const stackPack = await runStackPackSelect(stackPacks, previous?.stackPack); - const constitution = await runConstitutionSelect(previous?.constitution); - const isMultiTenant = await runMultiTenantSelect(previous?.isMultiTenant); - - const config: WizardConfig = { - modules, - stackPack, - constitution, - isMultiTenant, - }; - - const selected = [...modules, ...(stackPack ? [stackPack] : [])]; - const resolved = resolveModules(manifest, selected); - - const action = await runSummary(config, resolved, manifest.skillsVersion); - - if (action === 'install') { - // Conditional, manifest-driven package gate — only the chosen pack's - // prerequisites are enforced, and only once the user commits to install. - assertPrerequisites(resolved); - await runInstall(config); - return; - } - if (action === 'cancel') { - cancelAndExit(); - } - previous = config; - } -} - -async function loadManifest(): Promise { - const s = spinner(); - s.start('Fetching module catalog'); - try { - const manifest = await fetchRemoteManifest(); - s.stop(`Module catalog loaded (skills v${manifest.skillsVersion})`); - return manifest; - } catch (err) { - s.stop('Failed to load module catalog'); - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ Could not reach ${REPO_URL}: ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } -} diff --git a/src/index.ts b/src/index.ts index 04fe47d..58e8114 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { runStatus } from './commands/status.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; -const USAGE = `Pharn - Installs PHARN (an audit-grade methodology for Claude Code) into your project. npx pharn init picks your modules + stack pack and copies them into .claude/; pharn add installs another module later; pharn update bumps to the latest skills version. +const USAGE = `Pharn - Installs PHARN (an audit-grade methodology for Claude Code) into your project. npx pharn init detects your project's archetype and installs the applicable PHARN capabilities into .claude/; pharn add installs another module later; pharn update bumps to the latest skills version. Usage: pharn [command] [options] @@ -28,7 +28,7 @@ Commands: status Show version + local-drift status (read-only) Options: - --archetype init: detect archetypes + install applicable capabilities (experimental) + --archetype init: deprecated no-op — archetype detection is now the default -y, --yes Skip the remove confirmation prompt --strict Make status exit 1 on any outdated/modified/missing file --no-drift Skip the status byte-level drift check @@ -39,6 +39,8 @@ Options: export async function main(): Promise { const argv = minimist(process.argv.slice(2), { boolean: ['help', 'version', 'json', 'yes', 'strict', 'drift', 'archetype'], + // `archetype` is retained as a no-op alias for one release: archetype + // detection is now init's default, so the flag still parses but is not read. // `status` drifts by default; `--no-drift` flips it off. minimist defaults // bare booleans to false, so set the on-by-default here explicitly. default: { drift: true }, @@ -59,7 +61,7 @@ export async function main(): Promise { switch (cmd) { case 'init': - await runInit({ archetype: Boolean(argv.archetype) }); + await runInit(); return; case 'add': await runAdd(argv._[1]); diff --git a/src/steps/constitution-select.ts b/src/steps/constitution-select.ts deleted file mode 100644 index b7fd6f8..0000000 --- a/src/steps/constitution-select.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { isCancel, select } from '@clack/prompts'; -import { cancelAndExit } from '../lib/confirm.js'; -import type { Constitution } from '../types.js'; - -// Maps a "privacy posture" answer to a constitution variant shipped in -// pharn-core/templates/constitution/. See that folder's README.md. -export async function runConstitutionSelect( - initial?: Constitution, -): Promise { - const result = await select({ - message: 'Privacy posture? (sets your project constitution)', - options: [ - { - value: 'gdpr-strict', - label: 'GDPR / EU users / strict compliance', - hint: 'principles 1–6; export + deletion enforced at the gate', - }, - { - value: 'standard', - label: 'Standard SaaS with user data', - hint: 'principles 1–4; a11y + data-lifecycle as rules, not gate', - }, - { - value: 'minimal', - label: 'Internal tools / B2B, no end-user PII', - hint: 'principles 2–4', - }, - ], - initialValue: initial ?? 'standard', - }); - - if (isCancel(result)) cancelAndExit(); - return result as Constitution; -} diff --git a/src/steps/detect.ts b/src/steps/detect.ts deleted file mode 100644 index 1f7136a..0000000 --- a/src/steps/detect.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { note } from '@clack/prompts'; -import { detectStackPack } from '../lib/manifest.js'; -import { detectAnswers, describeAnswers } from '../lib/wizard.js'; -import type { Answers } from '../lib/wizard.js'; -import { readProjectPackages } from './prereqs.js'; -import type { ManifestModule, WizardSpec } from '../types.js'; - -export interface Detected { - // questionId → detected option value (questions with no match are omitted). - detectedAnswers: Answers; - // Stack pack preselected from prerequisites, or null for none. - detectedStackPack: string | null; -} - -/** - * schemaVersion 2 pre-fill: read the project's package.json and derive the - * stack pack + per-tech answers used to seed the wizard. The pure derivation - * lives in lib (detectStackPack / detectAnswers); this step does the I/O and - * surfaces what was found so the pre-fills aren't silent. Every choice can - * still be overridden downstream. - */ -export function runDetect( - wizard: WizardSpec, - stackPacks: ManifestModule[], - cwd: string = process.cwd(), -): Detected { - const packages = readProjectPackages(cwd); - const detectedAnswers = detectAnswers(wizard, packages); - const detectedStackPack = detectStackPack(stackPacks, packages); - - // Show friendly labels for the answers; the pack keeps its module name to - // match the stack-pack prompt that follows (where packs are listed by name). - const found = [ - ...(detectedStackPack ? [detectedStackPack] : []), - ...describeAnswers(wizard, detectedAnswers), - ]; - if (found.length > 0) { - note(found.join(', '), 'Detected from package.json'); - } - return { detectedAnswers, detectedStackPack }; -} diff --git a/src/steps/install.ts b/src/steps/install.ts deleted file mode 100644 index 256c8a1..0000000 --- a/src/steps/install.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { confirm, isCancel, log, outro, spinner } from '@clack/prompts'; -import { createRequire } from 'node:module'; -import pc from 'picocolors'; -import { cancelAndExit } from '../lib/confirm.js'; -import { FIRST_FEATURE_COMMAND, REPO_URL } from '../lib/constants.js'; -import { fetchAndInstall } from '../lib/installer.js'; -import { DEFAULT_MODEL_ROUTING } from '../lib/model-routing.js'; -import { DEFAULT_SEAM_CONFIG } from '../lib/seam-config.js'; -import { - configPath, - isConfigValidationError, - readPharnConfig, - toInstalledModules, - writePharnConfig, -} from '../lib/pharn-config.js'; -import type { PharnConfig, WizardConfig } from '../types.js'; - -const require = createRequire(import.meta.url); -const pkg = require('../../package.json') as { version: string }; -const PHARN_VERSION = pkg.version; - -export async function runInstall(config: WizardConfig): Promise { - const startedAt = Date.now(); - const cwd = process.cwd(); - const claudeDir = resolve(cwd, '.claude'); - - if (existsSync(configPath(cwd))) { - try { - const existing = readPharnConfig(cwd); - if (existing) { - log.info( - `Existing pharn.config.json found (skillsVersion ${existing.skillsVersion ?? 'unknown'}).`, - ); - } - } catch (e) { - // Present-but-invalid existing config: name it, don't crash / silently - // treat-as-absent. Overwrite is still offered (install is the repair path). - if (isConfigValidationError(e)) { - log.warn(`Existing pharn.config.json is invalid: ${e.message}`); - } else { - throw e; - } - } - if (!(await confirmOverwrite('Overwrite existing pharn.config.json?'))) { - cancelAndExit(); - } - } - - // The optional modules plus the stack pack; pharn-core and transitive deps - // are added during resolution. - const selected = [ - ...config.modules, - ...(config.stackPack ? [config.stackPack] : []), - ]; - - const s = spinner(); - s.start(`Fetching skills from ${REPO_URL}`); - let skillsVersion: string; - let commit: string | null; - let resolved: { name: string; version: string }[]; - try { - const result = await fetchAndInstall({ - claudeDir, - selected, - constitution: config.constitution, - wizardSkills: config.installedSkills, - isMultiTenant: config.isMultiTenant, - }); - skillsVersion = result.skillsVersion; - commit = result.commit; - resolved = result.resolved; - s.stop(`Skills installed from ${REPO_URL}`); - } catch (err) { - s.stop('Failed to install skills'); - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - else log.info('Re-run with PHARN_DEBUG=1 for full error output.'); - process.exit(1); - } - - const configFile: PharnConfig = { - pharnVersion: PHARN_VERSION, - skillsVersion, - repo: REPO_URL.replace(/^github\.com\//, ''), - commit, - constitution: config.constitution, - isMultiTenant: config.isMultiTenant, - modules: toInstalledModules(resolved), - installedAt: new Date().toISOString(), - // Per-stage model routing, written on every fresh install (P7 — additive). - models: DEFAULT_MODEL_ROUTING, - // Seam-resolution policy, written on every fresh install (P7 — additive). - seam: DEFAULT_SEAM_CONFIG, - // schemaVersion 2: persist the wizard answers + selected skills so add and - // update can re-resolve without re-asking. Omitted entirely on legacy installs. - ...(config.stackAnswers ? { stackAnswers: config.stackAnswers } : {}), - ...(config.installedSkills && config.installedSkills.length > 0 - ? { installedSkills: config.installedSkills } - : {}), - }; - await writePharnConfig(cwd, configFile); - - const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); - const check = pc.green('✔'); - outro( - [ - `${check} ${resolved.length} module${resolved.length === 1 ? '' : 's'} installed → ${pc.dim('.claude/')}`, - ...(config.installedSkills && config.installedSkills.length > 0 - ? [ - `${check} ${config.installedSkills.length} skill${config.installedSkills.length === 1 ? '' : 's'} installed → ${pc.dim('.claude/skills/')} ${pc.dim(`(${config.installedSkills.map((s) => s.skill).join(', ')})`)}`, - ] - : []), - `${check} CONSTITUTION.md + memory-bank written`, - `${check} pharn.config.json written ${pc.dim(`(skills v${skillsVersion})`)}`, - `${pc.dim(`Done in ${elapsed}s`)}`, - '', - pc.bold('Next steps'), - ` ${pc.cyan('1.')} ${pc.bold('claude')} ${pc.dim('open Claude Code')}`, - ` ${pc.cyan('2.')} ${pc.bold(FIRST_FEATURE_COMMAND)} ${pc.dim('plan your first feature')}`, - ].join('\n'), - ); -} - -async function confirmOverwrite(message: string): Promise { - const ok = await confirm({ message, initialValue: false }); - return !isCancel(ok) && ok === true; -} diff --git a/src/steps/mode-select.ts b/src/steps/mode-select.ts deleted file mode 100644 index 7e79cfb..0000000 --- a/src/steps/mode-select.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { isCancel, select } from '@clack/prompts'; -import { cancelAndExit } from '../lib/confirm.js'; - -export type WizardMode = 'default' | 'custom'; - -// schemaVersion 2: Default takes every answer from manifest.wizard.defaults and -// asks nothing per-tech; Custom renders each wizard question. -export async function runModeSelect(): Promise { - const result = await select({ - message: 'How do you want to configure your stack?', - options: [ - { - value: 'default' as WizardMode, - label: 'Default', - hint: 'recommended stack, no per-tech questions', - }, - { - value: 'custom' as WizardMode, - label: 'Custom', - hint: 'choose each technology', - }, - ], - initialValue: 'default' as WizardMode, - }); - - if (isCancel(result)) cancelAndExit(); - return result as WizardMode; -} diff --git a/src/steps/module-select.ts b/src/steps/module-select.ts deleted file mode 100644 index 54cea5d..0000000 --- a/src/steps/module-select.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isCancel, multiselect } from '@clack/prompts'; -import { cancelAndExit } from '../lib/confirm.js'; -import { shortDescription } from '../lib/format.js'; -import type { ManifestModule } from '../types.js'; - -export async function runModuleSelect( - optional: ManifestModule[], - initial?: string[], -): Promise { - if (optional.length === 0) return []; - - const result = await multiselect({ - message: 'Which modules do you want? (pharn-core is always installed)', - options: optional.map((m) => ({ - value: m.name, - label: `${m.name} ${shortDescription(m.description)}`, - hint: `v${m.version}`, - })), - initialValues: initial ?? optional.map((m) => m.name), - required: false, - }); - - if (isCancel(result)) cancelAndExit(); - return result as string[]; -} diff --git a/src/steps/multitenant-select.ts b/src/steps/multitenant-select.ts deleted file mode 100644 index 73d6219..0000000 --- a/src/steps/multitenant-select.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { confirm, isCancel } from '@clack/prompts'; -import { cancelAndExit } from '../lib/confirm.js'; - -// Asks whether the project is a multi-tenant SaaS. Recorded as `isMultiTenant` -// in pharn.config.json and used to gate Principle 2 (Multi-Tenant Isolation) in -// the installed constitution: when false, P2 is stripped at materialize time so -// the project is not blocked by a principle that doesn't apply. Defaults to true -// (keeps P2), preserving the behavior that shipped before this flag existed. -export async function runMultiTenantSelect( - initial?: boolean, -): Promise { - const result = await confirm({ - message: - 'Is this a multi-tenant SaaS? (keeps Principle 2: Multi-Tenant Isolation)', - initialValue: initial ?? true, - }); - - if (isCancel(result)) cancelAndExit(); - return result; -} diff --git a/src/steps/stackpack-select.ts b/src/steps/stackpack-select.ts deleted file mode 100644 index 25ce0e2..0000000 --- a/src/steps/stackpack-select.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { isCancel, select } from '@clack/prompts'; -import { cancelAndExit } from '../lib/confirm.js'; -import { shortDescription } from '../lib/format.js'; -import type { ManifestModule } from '../types.js'; - -export async function runStackPackSelect( - stackPacks: ManifestModule[], - initial?: string | null, -): Promise { - if (stackPacks.length === 0) return null; - - const result = await select({ - message: 'Which stack pack? (adds framework-specific rules and skills)', - options: [ - ...stackPacks.map((m) => ({ - value: m.name, - label: m.name, - hint: `${shortDescription(m.description)} · v${m.version}`, - })), - { value: '', label: 'None', hint: 'framework-agnostic only' }, - ], - // undefined = first run (preselect the first pack); null = an explicit "None" - // carried back on loop-back, which maps to the '' sentinel. - initialValue: initial === undefined ? stackPacks[0]!.name : (initial ?? ''), - }); - - if (isCancel(result)) cancelAndExit(); - return result === '' ? null : (result as string); -} diff --git a/src/steps/summary.ts b/src/steps/summary.ts deleted file mode 100644 index cf372f4..0000000 --- a/src/steps/summary.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { isCancel, note, select } from '@clack/prompts'; -import { cancelAndExit } from '../lib/confirm.js'; -import { row } from '../lib/format.js'; -import type { ManifestModule } from '../types.js'; -import type { WizardConfig } from '../types.js'; - -export type SummaryAction = 'install' | 'back' | 'cancel'; - -const CONSTITUTION_LABELS: Record = { - 'gdpr-strict': 'GDPR-strict (principles 1–6)', - standard: 'Standard (principles 1–4)', - minimal: 'Minimal (principles 2–4)', -}; - -export async function runSummary( - config: WizardConfig, - resolved: ManifestModule[], - skillsVersion: string, -): Promise { - const lines: string[] = [ - '────────────────────────────────────────', - ' PHARN Install Summary', - '────────────────────────────────────────', - '', - row('Skills version', `v${skillsVersion}`), - row( - 'Constitution', - CONSTITUTION_LABELS[config.constitution] ?? config.constitution, - ), - row('Multi-tenant SaaS', config.isMultiTenant ? 'Yes' : 'No'), - ...(config.isMultiTenant - ? [] - : [' Principle 2 (Multi-Tenant Isolation) will be omitted.']), - row('Stack pack', config.stackPack ?? 'None'), - '', - ' MODULES (resolved, incl. dependencies)', - ...resolved.map((m) => row(m.name, `v${m.version}`)), - // schemaVersion 2: the per-tech skills resolved from wizard answers. - ...(config.installedSkills && config.installedSkills.length > 0 - ? [ - '', - ' SKILLS (selected)', - ...config.installedSkills.map((s) => - row(s.skill, s.from.split('/')[0]!), - ), - ] - : []), - '', - '────────────────────────────────────────', - ]; - - note(lines.join('\n')); - - const action = await select({ - message: 'Ready to install?', - initialValue: 'install' as SummaryAction, - options: [ - { value: 'install' as SummaryAction, label: 'Yes, install' }, - { value: 'back' as SummaryAction, label: 'Go back and change something' }, - { value: 'cancel' as SummaryAction, label: 'Cancel' }, - ], - }); - - if (isCancel(action)) cancelAndExit(); - return action; -} diff --git a/src/steps/wizard-questions.ts b/src/steps/wizard-questions.ts deleted file mode 100644 index 2712012..0000000 --- a/src/steps/wizard-questions.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { isCancel, log, note, select } from '@clack/prompts'; -import pc from 'picocolors'; -import { cancelAndExit, confirmWarning } from '../lib/confirm.js'; -import { applyRulesToQuestion, pendingWarnings } from '../lib/wizard.js'; -import type { Answers } from '../lib/wizard.js'; -import type { WizardOption, WizardQuestion, WizardSpec } from '../types.js'; - -/** - * Custom mode: walk manifest.wizard sections in order, applying hide / - * hideQuestion / relabel rules against the answers gathered so far, and firing - * warn rules (soft-confirm) after each answer. Returns the full answer map - * (questionId → value, with "skip" for questions hidden by rule). - */ -export async function runWizardQuestions( - wizard: WizardSpec, - initial?: Answers, -): Promise { - const answers: Answers = {}; - - for (const section of wizard.sections) { - note(section.title); - for (const question of section.questions) { - const { hidden, options } = applyRulesToQuestion(question, answers); - if (hidden) { - answers[question.id] = 'skip'; - continue; - } - // Re-ask the same question (current pick preselected) whenever a warn rule - // is declined, instead of aborting the whole wizard. - let preselect = initial?.[question.id]; - while (true) { - const answer = await askQuestion(question, options, preselect); - answers[question.id] = answer; - if (!(await anyWarningDeclined(question, answers))) break; - preselect = answer; - } - } - } - - return answers; -} - -async function anyWarningDeclined( - question: WizardQuestion, - answers: Answers, -): Promise { - for (const warning of pendingWarnings(question, answers)) { - if (!(await confirmWarning(warning, 'Continue anyway?', true))) return true; - } - return false; -} - -async function askQuestion( - question: WizardQuestion, - options: WizardOption[], - initialValue: string | undefined, -): Promise { - const selectable = options.filter((o) => !o.comingSoon); - if (selectable.length === 0) { - // Every option is hidden by a rule or marked coming-soon — mirror the - // manifest-validation posture and hard-fail rather than trap the user. - log.error( - `Question "${question.id}" has no selectable options (all hidden or coming soon). Check the manifest wizard rules.`, - ); - process.exit(1); - } - const valid = new Set(selectable.map((o) => o.value)); - const preselect = - initialValue !== undefined && valid.has(initialValue) - ? initialValue - : (selectable.find((o) => o.default)?.value ?? selectable[0]!.value); - - // comingSoon options are shown (dimmed) but not selectable; re-prompt if one - // is chosen. - while (true) { - const choice = await select({ - message: question.prompt, - options: options.map((o) => ({ - value: o.value, - label: o.comingSoon ? `${o.label} ${pc.dim('(coming soon)')}` : o.label, - hint: o.comingSoon ? 'coming soon' : undefined, - })), - initialValue: preselect, - }); - if (isCancel(choice)) cancelAndExit(); - const picked = options.find((o) => o.value === choice); - if (picked?.comingSoon) { - log.warn(`${picked.label} is coming soon — pick another option.`); - continue; - } - return choice as string; - } -} diff --git a/tests/constitution-select.test.ts b/tests/constitution-select.test.ts deleted file mode 100644 index f36f2e0..0000000 --- a/tests/constitution-select.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; - -vi.mock('@clack/prompts', () => ({ - isCancel: (v: unknown) => v === CANCEL, - select: vi.fn(), - log: { info: vi.fn() }, -})); - -const { runConstitutionSelect } = - await import('../src/steps/constitution-select.js'); -const prompts = await import('@clack/prompts'); - -describe('runConstitutionSelect', () => { - stubProcessExit(); - - it.each(['gdpr-strict', 'standard', 'minimal'] as const)( - 'returns the %s variant', - async (variant) => { - vi.mocked(prompts.select).mockResolvedValue(variant); - await expect(runConstitutionSelect()).resolves.toBe(variant); - }, - ); - - it('exits when the prompt is cancelled', async () => { - vi.mocked(prompts.select).mockResolvedValue(CANCEL); - await expect(runConstitutionSelect()).rejects.toMatchObject( - new ProcessExit(0), - ); - }); -}); diff --git a/tests/detect.test.ts b/tests/detect.test.ts deleted file mode 100644 index f8d2a62..0000000 --- a/tests/detect.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { note } from '@clack/prompts'; -import { describe, expect, it, vi } from 'vitest'; -import { useTmpDir } from './helpers.js'; -import { wizardSpec } from './wizard-fixture.js'; -import type { ManifestModule } from '../src/types.js'; - -vi.mock('@clack/prompts', () => ({ note: vi.fn() })); - -const { runDetect } = await import('../src/steps/detect.js'); - -const stackPacks: ManifestModule[] = [ - { - name: 'pharn-stack-nextjs', - version: '0.1.0', - required: false, - dependsOn: [], - exclusiveWith: ['pharn-stack-*'], - description: 'nextjs', - prerequisites: [{ package: 'next', reason: 'needs next' }], - }, -]; - -describe('runDetect', () => { - const tmp = useTmpDir(); - - function withPackages(deps: Record): void { - writeFileSync( - join(tmp.path(), 'package.json'), - JSON.stringify({ dependencies: deps }), - ); - } - - it('pre-fills the stack pack and tech answers from package.json', () => { - withPackages({ - next: '16', - 'drizzle-orm': '1', - '@supabase/supabase-js': '2', - }); - const { detectedAnswers, detectedStackPack } = runDetect( - wizardSpec(), - stackPacks, - tmp.path(), - ); - expect(detectedStackPack).toBe('pharn-stack-nextjs'); - expect(detectedAnswers).toEqual({ database: 'supabase', orm: 'drizzle' }); - // the note lists the pack by name and the answers by their friendly labels. - expect(vi.mocked(note)).toHaveBeenCalledWith( - 'pharn-stack-nextjs, Supabase, Drizzle', - 'Detected from package.json', - ); - }); - - it('detects nothing for an unknown stack', () => { - withPackages({ express: '4' }); - const { detectedAnswers, detectedStackPack } = runDetect( - wizardSpec(), - stackPacks, - tmp.path(), - ); - expect(detectedStackPack).toBeNull(); - expect(detectedAnswers).toEqual({}); - }); - - it('detects nothing when package.json is missing', () => { - const { detectedAnswers, detectedStackPack } = runDetect( - wizardSpec(), - stackPacks, - tmp.path(), - ); - expect(detectedStackPack).toBeNull(); - expect(detectedAnswers).toEqual({}); - }); -}); diff --git a/tests/index.test.ts b/tests/index.test.ts index a0e6813..6ffc5a7 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -42,19 +42,20 @@ describe('main (argv dispatch)', () => { vi.restoreAllMocks(); }); - it('defaults to init (archetype:false) when no command is given', async () => { + it('defaults to init when no command is given', async () => { setArgv(); await main(); expect(runInit).toHaveBeenCalledTimes(1); - expect(runInit).toHaveBeenCalledWith({ archetype: false }); + expect(runInit).toHaveBeenCalledWith(); expect(runAdd).not.toHaveBeenCalled(); expect(runUpdate).not.toHaveBeenCalled(); }); - it('routes `init --archetype` to runInit with archetype:true', async () => { + it('accepts `init --archetype` as a no-op alias (archetype is now the default)', async () => { setArgv('init', '--archetype'); await main(); - expect(runInit).toHaveBeenCalledWith({ archetype: true }); + expect(runInit).toHaveBeenCalledTimes(1); + expect(runInit).toHaveBeenCalledWith(); }); it('routes `add ` to runAdd with the argument', async () => { diff --git a/tests/init-v2.test.ts b/tests/init-v2.test.ts deleted file mode 100644 index 1de08bc..0000000 --- a/tests/init-v2.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ProcessExit, stubProcessExit } from './helpers.js'; -import { v2Manifest } from './wizard-fixture.js'; -import type { WizardConfig } from '../src/types.js'; -import type { Detected } from '../src/steps/detect.js'; -import type { Answers } from '../src/lib/wizard.js'; - -vi.mock('@clack/prompts', () => ({ - intro: vi.fn(), - log: { info: vi.fn(), error: vi.fn() }, - spinner: () => ({ start: vi.fn(), stop: vi.fn() }), -})); -vi.mock('../src/lib/banner.js', () => ({ showBanner: vi.fn() })); - -const fetchRemoteManifest = vi.fn(); -const categorizeModules = vi.fn(() => ({ - core: [], - optional: [], - stackPacks: [], -})); -const resolveModules = vi.fn(() => []); -vi.mock('../src/lib/manifest.js', () => ({ - fetchRemoteManifest, - categorizeModules, - resolveModules, -})); - -// Real wizard.js (applyDefaults/collectInstalls) runs. -const runGitPrereq = vi.fn(); -const assertPrerequisites = vi.fn(); -const runFreshCheck = vi.fn(async () => undefined); -const runModeSelect = vi.fn(); -const runDetect = vi.fn<() => Detected>(() => ({ - detectedAnswers: {}, - detectedStackPack: null, -})); -const runWizardQuestions = - vi.fn<(wizard: unknown, initial?: Answers) => Promise>(); -const runModuleSelect = vi.fn(async () => [] as string[]); -const runStackPackSelect = vi.fn< - (packs: unknown, initial?: string | null) => Promise ->(async () => null); -const runConstitutionSelect = vi.fn(async () => 'standard' as const); -const runMultiTenantSelect = vi.fn(async () => true); -const runSummary = vi.fn(); -const runInstall = vi.fn<(config: WizardConfig) => Promise>( - async () => undefined, -); -vi.mock('../src/steps/prereqs.js', () => ({ - runGitPrereq, - assertPrerequisites, -})); -vi.mock('../src/steps/fresh-check.js', () => ({ runFreshCheck })); -vi.mock('../src/steps/mode-select.js', () => ({ runModeSelect })); -vi.mock('../src/steps/detect.js', () => ({ runDetect })); -vi.mock('../src/steps/wizard-questions.js', () => ({ runWizardQuestions })); -vi.mock('../src/steps/module-select.js', () => ({ runModuleSelect })); -vi.mock('../src/steps/stackpack-select.js', () => ({ runStackPackSelect })); -vi.mock('../src/steps/constitution-select.js', () => ({ - runConstitutionSelect, -})); -vi.mock('../src/steps/multitenant-select.js', () => ({ runMultiTenantSelect })); -vi.mock('../src/steps/summary.js', () => ({ runSummary })); -vi.mock('../src/steps/install.js', () => ({ runInstall })); - -const { runInit } = await import('../src/commands/init.js'); - -describe('runInit (schemaVersion 2)', () => { - stubProcessExit(); - afterEach(() => vi.clearAllMocks()); - - it('Default mode asks nothing per-tech and installs the wizard defaults', async () => { - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - runModeSelect.mockResolvedValue('default'); - runSummary.mockResolvedValue('install'); - - await runInit(); - - expect(runWizardQuestions).not.toHaveBeenCalled(); - expect(assertPrerequisites).toHaveBeenCalledTimes(1); - expect(runInstall).toHaveBeenCalledTimes(1); - const config = runInstall.mock.calls[0]![0]; - expect(config.stackAnswers).toEqual({ - database: 'supabase', - orm: 'drizzle', - auth: 'better-auth', - email: 'resend', - payments: 'stripe', - }); - // supabase installs nothing; drizzle/better-auth/resend/stripe install. - expect(config.installedSkills?.map((s) => s.skill)).toEqual([ - 'drizzle', - 'better-auth', - 'resend', - 'stripe', - ]); - }); - - it('Custom mode renders the wizard questions', async () => { - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - runModeSelect.mockResolvedValue('custom'); - runWizardQuestions.mockResolvedValue({ - database: 'neon', - orm: 'prisma', - auth: 'clerk', - email: 'skip', - payments: 'skip', - }); - runSummary.mockResolvedValue('install'); - - await runInit(); - - expect(runWizardQuestions).toHaveBeenCalledTimes(1); - const config = runInstall.mock.calls[0]![0]; - expect(config.installedSkills?.map((s) => s.skill)).toEqual([ - 'neon', - 'prisma', - 'clerk', - ]); - }); - - it('preserves prior answers across a summary loop-back', async () => { - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - runModeSelect.mockResolvedValue('default'); - runSummary.mockResolvedValueOnce('back').mockResolvedValueOnce('install'); - - await runInit(); - - // Two summary passes (back → install); install runs once with the resolved - // skills intact — the loop-back preserved the prior answers (non-vendor - // signal, replacing the removed vendor-consent assertion). - expect(runSummary).toHaveBeenCalledTimes(2); - expect(runInstall).toHaveBeenCalledTimes(1); - const config = runInstall.mock.calls[0]![0]; - expect(config.installedSkills?.map((s) => s.skill)).toEqual([ - 'drizzle', - 'better-auth', - 'resend', - 'stripe', - ]); - }); - - it('cancels from the summary', async () => { - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - runModeSelect.mockResolvedValue('default'); - runSummary.mockResolvedValue('cancel'); - await expect(runInit()).rejects.toMatchObject(new ProcessExit(0)); - expect(runInstall).not.toHaveBeenCalled(); - }); - - it('pre-fills Default mode from detection (overrides defaults, seeds pack)', async () => { - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - runDetect.mockReturnValueOnce({ - detectedAnswers: { orm: 'prisma' }, - detectedStackPack: 'pharn-stack-nextjs', - }); - runModeSelect.mockResolvedValue('default'); - runSummary.mockResolvedValue('install'); - - await runInit(); - - const config = runInstall.mock.calls[0]![0]; - // orm detected → prisma overrides the default drizzle; others stay default. - expect(config.stackAnswers).toEqual({ - database: 'supabase', - orm: 'prisma', - auth: 'better-auth', - email: 'resend', - payments: 'stripe', - }); - expect(config.installedSkills?.map((s) => s.skill)).toEqual([ - 'prisma', - 'better-auth', - 'resend', - 'stripe', - ]); - // the detected pack seeds the stack-pack prompt's initial value. - expect(runStackPackSelect.mock.calls[0]![1]).toBe('pharn-stack-nextjs'); - }); - - it('seeds the Custom wizard with detected answers on first run', async () => { - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - runDetect.mockReturnValueOnce({ - detectedAnswers: { database: 'neon' }, - detectedStackPack: null, - }); - runModeSelect.mockResolvedValue('custom'); - runWizardQuestions.mockResolvedValue({ - database: 'neon', - orm: 'drizzle', - auth: 'better-auth', - email: 'skip', - payments: 'skip', - }); - runSummary.mockResolvedValue('install'); - - await runInit(); - - expect(runWizardQuestions.mock.calls[0]![1]).toEqual({ database: 'neon' }); - }); - - it('uses previous answers and pack on a loop-back, not detection', async () => { - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - runDetect.mockReturnValueOnce({ - detectedAnswers: { database: 'neon' }, - detectedStackPack: null, - }); - runModeSelect.mockResolvedValue('custom'); - const answers = { - database: 'supabase', - orm: 'prisma', - auth: 'clerk', - email: 'skip', - payments: 'skip', - }; - runWizardQuestions.mockResolvedValue(answers); - runStackPackSelect.mockResolvedValueOnce('pharn-stack-nextjs'); - runSummary.mockResolvedValueOnce('back').mockResolvedValueOnce('install'); - - await runInit(); - - expect(runWizardQuestions).toHaveBeenCalledTimes(2); - // first run seeds from detection; the loop-back reuses the prior answers. - expect(runWizardQuestions.mock.calls[0]![1]).toEqual({ database: 'neon' }); - expect(runWizardQuestions.mock.calls[1]![1]).toEqual(answers); - // stack pack: first run = detected (None here); loop-back = the prior pick. - expect(runStackPackSelect.mock.calls[0]![1]).toBeNull(); - expect(runStackPackSelect.mock.calls[1]![1]).toBe('pharn-stack-nextjs'); - }); -}); diff --git a/tests/init.test.ts b/tests/init.test.ts index a5f0ee6..796a4df 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -1,91 +1,119 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ProcessExit, stubProcessExit } from './helpers.js'; +// Archetype is now the DEFAULT (and only) init flow. runInit() drives it with no +// module catalog / manifest fetch. These are command-level control-flow tests +// with the archetype deps mocked; the fixture install e2e lives in +// tests/init-archetype.test.ts (the engine, unchanged by this increment). + vi.mock('@clack/prompts', () => ({ intro: vi.fn(), - log: { info: vi.fn(), error: vi.fn() }, - spinner: () => ({ start: vi.fn(), stop: vi.fn() }), + note: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }), })); vi.mock('../src/lib/banner.js', () => ({ showBanner: vi.fn() })); -const fetchRemoteManifest = vi.fn(); -const categorizeModules = vi.fn(() => ({ - core: [], - optional: [], - stackPacks: [], -})); -const resolveModules = vi.fn(() => []); -vi.mock('../src/lib/manifest.js', () => ({ - fetchRemoteManifest, - categorizeModules, - resolveModules, -})); - const runGitPrereq = vi.fn(); -const assertPrerequisites = vi.fn(); const runFreshCheck = vi.fn(async () => undefined); -const runModuleSelect = vi.fn(async () => [] as string[]); -const runStackPackSelect = vi.fn(async () => null); -const runConstitutionSelect = vi.fn(async () => 'standard' as const); -const runMultiTenantSelect = vi.fn(async () => true); -const runSummary = vi.fn(); -const runInstall = vi.fn(async () => undefined); -vi.mock('../src/steps/prereqs.js', () => ({ - runGitPrereq, - assertPrerequisites, -})); +vi.mock('../src/steps/prereqs.js', () => ({ runGitPrereq })); vi.mock('../src/steps/fresh-check.js', () => ({ runFreshCheck })); -vi.mock('../src/steps/module-select.js', () => ({ runModuleSelect })); -vi.mock('../src/steps/stackpack-select.js', () => ({ runStackPackSelect })); -vi.mock('../src/steps/constitution-select.js', () => ({ - runConstitutionSelect, + +const detectArchetypesFromProject = vi.fn(() => ({ archetypes: ['ssr'] })); +vi.mock('../src/lib/detect-archetype.js', () => ({ + detectArchetypesFromProject, })); -vi.mock('../src/steps/multitenant-select.js', () => ({ runMultiTenantSelect })); -vi.mock('../src/steps/summary.js', () => ({ runSummary })); -vi.mock('../src/steps/install.js', () => ({ runInstall })); -const { runInit } = await import('../src/commands/init.js'); +const cleanup = vi.fn(); +const fetchRepo = vi.fn(async () => ({ + dir: '/fake/repo', + sha: 'sha123', + cleanup, +})); +vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); -const manifest = { schemaVersion: 1, skillsVersion: '0.68.1', modules: [] }; +const parseCapabilityIndex = vi.fn(() => ({ capabilities: [] })); +vi.mock('../src/lib/capability-index.js', () => ({ parseCapabilityIndex })); -describe('runInit', () => { - stubProcessExit(); - afterEach(() => vi.clearAllMocks()); +const resolveCapabilities = vi.fn(() => ({ selected: [], skipped: [] })); +vi.mock('../src/lib/resolve-capabilities.js', () => ({ resolveCapabilities })); - it('runs the wizard and installs', async () => { - fetchRemoteManifest.mockResolvedValue(manifest); - runSummary.mockResolvedValue('install'); +const runArchetypeSummary = vi.fn( + async (): Promise<'install' | 'cancel'> => 'install', +); +vi.mock('../src/steps/archetype-summary.js', () => ({ runArchetypeSummary })); - await runInit(); +const runInstallArchetype = vi.fn(async () => undefined); +vi.mock('../src/steps/install-archetype.js', () => ({ runInstallArchetype })); - expect(runGitPrereq).toHaveBeenCalled(); - // Package prerequisites are checked on the install path, not up front. - expect(assertPrerequisites).toHaveBeenCalledTimes(1); - expect(runInstall).toHaveBeenCalledTimes(1); - }); +// No existing config → confirmOverwriteIfExists returns true without prompting. +vi.mock('../src/lib/pharn-config.js', () => ({ + configPath: () => '/__no_such_dir__/pharn.config.json', + readPharnConfig: vi.fn(), + isConfigValidationError: () => false, +})); + +const { runInit } = await import('../src/commands/init.js'); - it('loops back then installs, preserving previous answers', async () => { - fetchRemoteManifest.mockResolvedValue(manifest); - runSummary.mockResolvedValueOnce('back').mockResolvedValueOnce('install'); +describe('runInit (archetype default)', () => { + stubProcessExit(); + afterEach(() => vi.clearAllMocks()); + + it('drives the archetype flow and installs — no module/manifest fetch', async () => { + runArchetypeSummary.mockResolvedValue('install'); await runInit(); - expect(runModuleSelect).toHaveBeenCalledTimes(2); - expect(runInstall).toHaveBeenCalledTimes(1); + expect(runGitPrereq).toHaveBeenCalledTimes(1); + expect(runFreshCheck).toHaveBeenCalledTimes(1); + // The archetype pipeline is taken: detect → fetch → index → resolve → summary. + expect(detectArchetypesFromProject).toHaveBeenCalledTimes(1); + expect(fetchRepo).toHaveBeenCalledTimes(1); + expect(parseCapabilityIndex).toHaveBeenCalledWith('/fake/repo'); + expect(resolveCapabilities).toHaveBeenCalledTimes(1); + expect(runArchetypeSummary).toHaveBeenCalledTimes(1); + // Install ran with the pinned SHA; the temp clone was cleaned up. + expect(runInstallArchetype).toHaveBeenCalledTimes(1); + expect(runInstallArchetype).toHaveBeenCalledWith( + '/fake/repo', + expect.any(String), + ['ssr'], + { selected: [], skipped: [] }, + 'sha123', + ); + expect(cleanup).toHaveBeenCalledTimes(1); }); - it('cancels from the summary', async () => { - fetchRemoteManifest.mockResolvedValue(manifest); - runSummary.mockResolvedValue('cancel'); + it('cancels from the summary without installing', async () => { + runArchetypeSummary.mockResolvedValue('cancel'); await expect(runInit()).rejects.toMatchObject(new ProcessExit(0)); - expect(assertPrerequisites).not.toHaveBeenCalled(); - expect(runInstall).not.toHaveBeenCalled(); + + expect(runInstallArchetype).not.toHaveBeenCalled(); + // Cleanup still runs in the finally, before the cancel exit. + expect(cleanup).toHaveBeenCalledTimes(1); }); - it('exits(1) when the manifest cannot be loaded', async () => { - fetchRemoteManifest.mockRejectedValue(new Error('offline')); + it('exits(1) when PHARN cannot be fetched', async () => { + fetchRepo.mockRejectedValueOnce(new Error('offline')); await expect(runInit()).rejects.toMatchObject(new ProcessExit(1)); + + expect(runInstallArchetype).not.toHaveBeenCalled(); + }); + + // no-404 regression guard (grill #2, sharpened): a static assertion that init + // no longer imports the module manifest — the source of the old default's 404. + it('init.ts imports no module manifest (static no-404 guard)', () => { + const here = fileURLToPath(import.meta.url); + const src = readFileSync( + join(here, '..', '..', 'src', 'commands', 'init.ts'), + 'utf8', + ); + expect(src).not.toMatch(/from ['"][^'"]*manifest\.js['"]/); + expect(src).not.toContain('fetchRemoteManifest'); }); }); diff --git a/tests/install.test.ts b/tests/install.test.ts deleted file mode 100644 index 895c377..0000000 --- a/tests/install.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; -import type { WizardConfig } from '../src/types.js'; -import { - DEFAULT_MODEL_ROUTING, - ModelRoutingError, -} from '../src/lib/model-routing.js'; -import { SeamConfigError } from '../src/lib/seam-config.js'; - -const { existsSync } = vi.hoisted(() => ({ existsSync: vi.fn() })); -vi.mock('node:fs', async (importOriginal) => ({ - ...(await importOriginal()), - existsSync, -})); - -vi.mock('@clack/prompts', () => ({ - isCancel: (v: unknown) => v === CANCEL, - confirm: vi.fn(), - log: { info: vi.fn(), error: vi.fn(), warn: vi.fn() }, - outro: vi.fn(), - spinner: () => ({ start: vi.fn(), stop: vi.fn() }), -})); - -const fetchAndInstall = vi.fn(); -vi.mock('../src/lib/installer.js', () => ({ fetchAndInstall })); - -const writePharnConfig = vi.fn(); -const readPharnConfig = vi.fn(); -vi.mock('../src/lib/pharn-config.js', () => ({ - configPath: (cwd: string) => `${cwd}/pharn.config.json`, - readPharnConfig, - writePharnConfig, - toInstalledModules: (m: { name: string; version: string }[]) => - m.map(({ name, version }) => ({ name, version })), - // Real config-error discriminator so the invalid-existing-config branch is faithful. - isConfigValidationError: (e: unknown) => - e instanceof ModelRoutingError || e instanceof SeamConfigError, -})); - -const { runInstall } = await import('../src/steps/install.js'); -const prompts = await import('@clack/prompts'); - -const config: WizardConfig = { - modules: ['pharn-pipeline'], - stackPack: 'pharn-stack-nextjs', - constitution: 'standard', - isMultiTenant: true, -}; - -const okResult = { - skillsVersion: '0.68.1', - commit: 'deadbeef', - resolved: [ - { name: 'pharn-core', version: '0.2.0' }, - { name: 'pharn-pipeline', version: '0.5.0' }, - ], -}; - -describe('runInstall', () => { - stubProcessExit(); - beforeEach(() => { - vi.spyOn(process, 'cwd').mockReturnValue('/proj'); - fetchAndInstall.mockReset(); - writePharnConfig.mockReset(); - readPharnConfig.mockReset(); - existsSync.mockReset(); - }); - afterEach(() => vi.restoreAllMocks()); - - it('installs and writes the config when none exists', async () => { - existsSync.mockReturnValue(false); - fetchAndInstall.mockResolvedValue(okResult); - - await runInstall(config); - - expect(fetchAndInstall).toHaveBeenCalledWith({ - claudeDir: '/proj/.claude', - selected: ['pharn-pipeline', 'pharn-stack-nextjs'], - constitution: 'standard', - isMultiTenant: true, - }); - const [, written] = writePharnConfig.mock.calls[0]!; - expect(written).toMatchObject({ - skillsVersion: '0.68.1', - commit: 'deadbeef', - repo: 'pharn-dev/pharn-oss', - constitution: 'standard', - isMultiTenant: true, - modules: [ - { name: 'pharn-core', version: '0.2.0' }, - { name: 'pharn-pipeline', version: '0.5.0' }, - ], - }); - // Model routing is written on every fresh install with sensible defaults. - expect(written.models).toEqual(DEFAULT_MODEL_ROUTING); - // Legacy install: the schemaVersion 2 fields are omitted entirely. - expect(written.stackAnswers).toBeUndefined(); - expect(written.installedSkills).toBeUndefined(); - expect(prompts.outro).toHaveBeenCalled(); - }); - - it('persists the schemaVersion 2 fields and passes skills to fetchAndInstall', async () => { - existsSync.mockReturnValue(false); - fetchAndInstall.mockResolvedValue(okResult); - const v2Config: WizardConfig = { - modules: ['pharn-pipeline'], - stackPack: 'pharn-stack-nextjs', - constitution: 'standard', - isMultiTenant: true, - stackAnswers: { database: 'supabase', orm: 'drizzle', payments: 'skip' }, - installedSkills: [ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - { skill: 'stripe', from: 'pharn-skills-payments/skills/stripe' }, - ], - }; - - await runInstall(v2Config); - - expect(fetchAndInstall).toHaveBeenCalledWith({ - claudeDir: '/proj/.claude', - selected: ['pharn-pipeline', 'pharn-stack-nextjs'], - constitution: 'standard', - wizardSkills: v2Config.installedSkills, - isMultiTenant: true, - }); - const [, written] = writePharnConfig.mock.calls[0]!; - expect(written.stackAnswers).toEqual(v2Config.stackAnswers); - expect(written.installedSkills).toEqual(v2Config.installedSkills); - }); - - it('forwards and persists isMultiTenant: false', async () => { - existsSync.mockReturnValue(false); - fetchAndInstall.mockResolvedValue(okResult); - - await runInstall({ ...config, isMultiTenant: false }); - - expect(fetchAndInstall).toHaveBeenCalledWith( - expect.objectContaining({ isMultiTenant: false }), - ); - const [, written] = writePharnConfig.mock.calls[0]!; - expect(written.isMultiTenant).toBe(false); - }); - - it('overwrites after confirmation when a config already exists', async () => { - existsSync.mockReturnValue(true); - readPharnConfig.mockReturnValue({ skillsVersion: '0.1.0' }); - vi.mocked(prompts.confirm).mockResolvedValue(true); - fetchAndInstall.mockResolvedValue(okResult); - - await runInstall(config); - - expect(prompts.log.info).toHaveBeenCalled(); - expect(writePharnConfig).toHaveBeenCalled(); - }); - - it('cancels when the user declines to overwrite', async () => { - existsSync.mockReturnValue(true); - readPharnConfig.mockReturnValue({ skillsVersion: '0.1.0' }); - vi.mocked(prompts.confirm).mockResolvedValue(false); - - await expect(runInstall(config)).rejects.toMatchObject(new ProcessExit(0)); - expect(fetchAndInstall).not.toHaveBeenCalled(); - }); - - it('warns (naming it) and still offers overwrite when the existing config is INVALID (BUG 1)', async () => { - // Mirrors init.ts's confirmOverwriteIfExists (identical tolerate-and-warn - // branch): a present-but-invalid config must be NAMED, never crash and never - // be silently treated as absent (then clobbered). - existsSync.mockReturnValue(true); - readPharnConfig.mockImplementationOnce(() => { - throw new SeamConfigError('seam.resolutionOrder must contain "ask"'); - }); - vi.mocked(prompts.confirm).mockResolvedValue(true); - fetchAndInstall.mockResolvedValue(okResult); - - await runInstall(config); - - const warn = String(vi.mocked(prompts.log.warn).mock.calls[0]?.[0] ?? ''); - expect(warn).toMatch(/invalid/); - expect(warn).toMatch(/ask/); - expect(writePharnConfig).toHaveBeenCalled(); // proceeded, did not crash - }); - - it('exits(1) when the install fails', async () => { - existsSync.mockReturnValue(false); - fetchAndInstall.mockRejectedValue(new Error('network down')); - - await expect(runInstall(config)).rejects.toMatchObject(new ProcessExit(1)); - expect(prompts.log.error).toHaveBeenCalled(); - }); -}); diff --git a/tests/mode-select.test.ts b/tests/mode-select.test.ts deleted file mode 100644 index 6838929..0000000 --- a/tests/mode-select.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; - -vi.mock('@clack/prompts', () => ({ - isCancel: (v: unknown) => v === CANCEL, - select: vi.fn(), - log: { info: vi.fn() }, -})); - -const { runModeSelect } = await import('../src/steps/mode-select.js'); -const prompts = await import('@clack/prompts'); - -describe('runModeSelect', () => { - stubProcessExit(); - - it.each(['default', 'custom'] as const)('returns %s', async (mode) => { - vi.mocked(prompts.select).mockResolvedValue(mode); - await expect(runModeSelect()).resolves.toBe(mode); - }); - - it('exits when cancelled', async () => { - vi.mocked(prompts.select).mockResolvedValue(CANCEL); - await expect(runModeSelect()).rejects.toMatchObject(new ProcessExit(0)); - }); -}); diff --git a/tests/module-select.test.ts b/tests/module-select.test.ts deleted file mode 100644 index 0e61cf5..0000000 --- a/tests/module-select.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; -import type { ManifestModule } from '../src/types.js'; - -vi.mock('@clack/prompts', () => ({ - isCancel: (v: unknown) => v === CANCEL, - multiselect: vi.fn(), - log: { info: vi.fn() }, -})); - -const { runModuleSelect } = await import('../src/steps/module-select.js'); -const prompts = await import('@clack/prompts'); - -const optional: ManifestModule[] = [ - { - name: 'pharn-pipeline', - version: '0.5.0', - required: false, - dependsOn: ['pharn-core'], - description: 'plan → grill → build pipeline', - }, -]; - -describe('runModuleSelect', () => { - stubProcessExit(); - - it('returns [] without prompting when there are no optional modules', async () => { - await expect(runModuleSelect([])).resolves.toEqual([]); - expect(prompts.multiselect).not.toHaveBeenCalled(); - }); - - it('returns the chosen module names', async () => { - vi.mocked(prompts.multiselect).mockResolvedValue(['pharn-pipeline']); - await expect(runModuleSelect(optional)).resolves.toEqual([ - 'pharn-pipeline', - ]); - }); - - it('exits when the prompt is cancelled', async () => { - vi.mocked(prompts.multiselect).mockResolvedValue(CANCEL); - await expect(runModuleSelect(optional)).rejects.toMatchObject( - new ProcessExit(0), - ); - }); -}); diff --git a/tests/multitenant-select.test.ts b/tests/multitenant-select.test.ts deleted file mode 100644 index e436eb1..0000000 --- a/tests/multitenant-select.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; - -vi.mock('@clack/prompts', () => ({ - isCancel: (v: unknown) => v === CANCEL, - confirm: vi.fn(), - log: { info: vi.fn() }, -})); - -const { runMultiTenantSelect } = - await import('../src/steps/multitenant-select.js'); -const prompts = await import('@clack/prompts'); - -describe('runMultiTenantSelect', () => { - stubProcessExit(); - - it.each([true, false])('returns the answer (%s)', async (answer) => { - vi.mocked(prompts.confirm).mockResolvedValue(answer); - await expect(runMultiTenantSelect()).resolves.toBe(answer); - }); - - it('defaults to true (keeps Principle 2)', async () => { - vi.mocked(prompts.confirm).mockResolvedValue(true); - await runMultiTenantSelect(); - expect(prompts.confirm).toHaveBeenCalledWith( - expect.objectContaining({ initialValue: true }), - ); - }); - - it('seeds the initial value from the prior answer', async () => { - vi.mocked(prompts.confirm).mockResolvedValue(false); - await runMultiTenantSelect(false); - expect(prompts.confirm).toHaveBeenCalledWith( - expect.objectContaining({ initialValue: false }), - ); - }); - - it('exits when the prompt is cancelled', async () => { - vi.mocked(prompts.confirm).mockResolvedValue(CANCEL); - await expect(runMultiTenantSelect()).rejects.toMatchObject( - new ProcessExit(0), - ); - }); -}); diff --git a/tests/stackpack-select.test.ts b/tests/stackpack-select.test.ts deleted file mode 100644 index b198c86..0000000 --- a/tests/stackpack-select.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; -import type { ManifestModule } from '../src/types.js'; - -vi.mock('@clack/prompts', () => ({ - isCancel: (v: unknown) => v === CANCEL, - select: vi.fn(), - log: { info: vi.fn() }, -})); - -const { runStackPackSelect } = await import('../src/steps/stackpack-select.js'); -const prompts = await import('@clack/prompts'); - -const stackPacks: ManifestModule[] = [ - { - name: 'pharn-stack-nextjs', - version: '0.30.0', - required: false, - dependsOn: ['pharn-core', 'pharn-stack-react'], - exclusiveWith: ['pharn-stack-*'], - description: 'Next.js + Supabase + Better Auth + Vercel', - }, -]; - -describe('runStackPackSelect', () => { - stubProcessExit(); - - it('returns null without prompting when there are no stack packs', async () => { - await expect(runStackPackSelect([])).resolves.toBeNull(); - expect(prompts.select).not.toHaveBeenCalled(); - }); - - it('returns the chosen stack pack name', async () => { - vi.mocked(prompts.select).mockResolvedValue('pharn-stack-nextjs'); - await expect(runStackPackSelect(stackPacks)).resolves.toBe( - 'pharn-stack-nextjs', - ); - }); - - it('maps the "None" sentinel to null', async () => { - vi.mocked(prompts.select).mockResolvedValue(''); - await expect(runStackPackSelect(stackPacks)).resolves.toBeNull(); - }); - - it('preselects the first pack on the first pass (initial undefined)', async () => { - vi.mocked(prompts.select).mockResolvedValue('pharn-stack-nextjs'); - await runStackPackSelect(stackPacks); - const arg = vi.mocked(prompts.select).mock.calls.at(-1)![0] as { - initialValue: string; - }; - expect(arg.initialValue).toBe('pharn-stack-nextjs'); - }); - - it('preselects the None sentinel when None is carried back (initial null)', async () => { - vi.mocked(prompts.select).mockResolvedValue(''); - await runStackPackSelect(stackPacks, null); - const arg = vi.mocked(prompts.select).mock.calls.at(-1)![0] as { - initialValue: string; - }; - expect(arg.initialValue).toBe(''); - }); - - it('renders the description hint without beheading "Next.js"', async () => { - vi.mocked(prompts.select).mockResolvedValue('pharn-stack-nextjs'); - await runStackPackSelect(stackPacks); - const arg = vi.mocked(prompts.select).mock.calls.at(-1)![0] as { - options: { hint?: string }[]; - }; - expect(arg.options[0]!.hint).toContain('Next.js + Supabase'); - }); - - it('exits when the prompt is cancelled', async () => { - vi.mocked(prompts.select).mockResolvedValue(CANCEL); - await expect(runStackPackSelect(stackPacks)).rejects.toMatchObject( - new ProcessExit(0), - ); - }); -}); diff --git a/tests/summary.test.ts b/tests/summary.test.ts deleted file mode 100644 index d19ef5e..0000000 --- a/tests/summary.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; -import type { ManifestModule, WizardConfig } from '../src/types.js'; - -vi.mock('@clack/prompts', () => ({ - isCancel: (v: unknown) => v === CANCEL, - note: vi.fn(), - select: vi.fn(), - log: { info: vi.fn() }, -})); - -const { runSummary } = await import('../src/steps/summary.js'); -const prompts = await import('@clack/prompts'); - -const config: WizardConfig = { - modules: ['pharn-pipeline'], - stackPack: 'pharn-stack-nextjs', - constitution: 'standard', - isMultiTenant: true, -}; -const resolved: ManifestModule[] = [ - { - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - }, -]; - -describe('runSummary', () => { - stubProcessExit(); - - it.each(['install', 'back', 'cancel'] as const)( - 'returns the %s action', - async (action) => { - vi.mocked(prompts.select).mockResolvedValue(action); - await expect(runSummary(config, resolved, '0.68.1')).resolves.toBe( - action, - ); - expect(prompts.note).toHaveBeenCalled(); - }, - ); - - it('exits when the prompt is cancelled', async () => { - vi.mocked(prompts.select).mockResolvedValue(CANCEL); - await expect(runSummary(config, resolved, '0.68.1')).rejects.toMatchObject( - new ProcessExit(0), - ); - }); - - it('renders the stack pack row (named pack and None)', async () => { - vi.mocked(prompts.select).mockResolvedValue('install'); - await runSummary(config, resolved, '0.68.1'); - let note = vi.mocked(prompts.note).mock.calls.at(-1)![0] as string; - expect(note).toContain('Stack pack'); - expect(note).toContain('pharn-stack-nextjs'); - - await runSummary({ ...config, stackPack: null }, resolved, '0.68.1'); - note = vi.mocked(prompts.note).mock.calls.at(-1)![0] as string; - expect(note).toMatch(/Stack pack\s+None/); - }); - - it('renders the skills block for a schemaVersion 2 config', async () => { - vi.mocked(prompts.select).mockResolvedValue('install'); - const v2: WizardConfig = { - ...config, - stackAnswers: { orm: 'drizzle', database: 'supabase' }, - installedSkills: [ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ], - }; - await runSummary(v2, resolved, '0.69.0'); - const note = vi.mocked(prompts.note).mock.calls.at(-1)![0] as string; - expect(note).toContain('SKILLS (selected)'); - expect(note).toContain('drizzle'); - expect(note).not.toContain('VENDOR SKILLS'); - }); - - it('renders the multi-tenant SaaS row (Yes; No adds a P2 note)', async () => { - vi.mocked(prompts.select).mockResolvedValue('install'); - - await runSummary(config, resolved, '0.68.1'); - let note = vi.mocked(prompts.note).mock.calls.at(-1)![0] as string; - expect(note).toMatch(/Multi-tenant SaaS\s+Yes/); - expect(note).not.toContain('Principle 2 (Multi-Tenant Isolation)'); - - await runSummary({ ...config, isMultiTenant: false }, resolved, '0.68.1'); - note = vi.mocked(prompts.note).mock.calls.at(-1)![0] as string; - expect(note).toMatch(/Multi-tenant SaaS\s+No/); - expect(note).toContain( - 'Principle 2 (Multi-Tenant Isolation) will be omitted.', - ); - }); -}); diff --git a/tests/wizard-questions.test.ts b/tests/wizard-questions.test.ts deleted file mode 100644 index 5af35f2..0000000 --- a/tests/wizard-questions.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; -import { wizardSpec } from './wizard-fixture.js'; -import type { WizardSpec } from '../src/types.js'; - -vi.mock('@clack/prompts', () => ({ - isCancel: (v: unknown) => v === CANCEL, - select: vi.fn(), - note: vi.fn(), - confirm: vi.fn(async () => true), - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})); - -const { runWizardQuestions } = await import('../src/steps/wizard-questions.js'); -const prompts = await import('@clack/prompts'); - -const wizard = wizardSpec(); - -// Drive select() from a FIFO script of return values. -function script(values: unknown[]): void { - let i = 0; - vi.mocked(prompts.select).mockImplementation(async () => values[i++]); -} - -describe('runWizardQuestions', () => { - stubProcessExit(); - afterEach(() => vi.clearAllMocks()); - - it('asks each question and returns the full answer map', async () => { - script(['neon', 'drizzle', 'better-auth', 'resend', 'stripe']); - const answers = await runWizardQuestions(wizard); - expect(answers).toEqual({ - database: 'neon', - orm: 'drizzle', - auth: 'better-auth', - email: 'resend', - payments: 'stripe', - }); - expect(prompts.select).toHaveBeenCalledTimes(5); - }); - - it('records "skip" and does not prompt a hideQuestion-hidden question', async () => { - // A selectable trigger (the spec's convex is comingSoon, so it can only be - // exercised at the rule-engine level — see wizard.test.ts). - const inline: WizardSpec = { - sections: [ - { - id: 's', - title: 'S', - questions: [ - { - id: 'a', - prompt: 'A', - options: [ - { value: 'x', label: 'X', install: null }, - { value: 'y', label: 'Y', install: null }, - ], - }, - { - id: 'b', - prompt: 'B', - options: [{ value: 'p', label: 'P', install: null }], - rules: [{ type: 'hideQuestion', if: { a: 'x' } }], - }, - ], - }, - ], - defaults: {}, - }; - script(['x']); - const answers = await runWizardQuestions(inline); - expect(answers).toEqual({ a: 'x', b: 'skip' }); - expect(prompts.select).toHaveBeenCalledTimes(1); - }); - - it('hides the supabase-auth option when database is not supabase', async () => { - script(['neon', 'drizzle', 'better-auth', 'resend', 'stripe']); - await runWizardQuestions(wizard); - const authCall = vi.mocked(prompts.select).mock.calls[2]![0] as { - options: { value: string }[]; - }; - expect(authCall.options.map((o) => o.value)).not.toContain('supabase-auth'); - }); - - it('re-prompts when a coming-soon option is chosen', async () => { - // database: first pick convex (coming soon) → warn + re-prompt → neon. - script(['convex', 'neon', 'drizzle', 'better-auth', 'resend', 'stripe']); - const answers = await runWizardQuestions(wizard); - expect(answers.database).toBe('neon'); - expect(prompts.log.warn).toHaveBeenCalled(); - }); - - it('soft-confirms a warn rule (better-auth + orm:skip)', async () => { - script(['supabase', 'skip', 'better-auth', 'resend', 'stripe']); - await runWizardQuestions(wizard); - expect(prompts.confirm).toHaveBeenCalledTimes(1); - }); - - it('re-asks the question when a warn rule is declined', async () => { - // auth=better-auth with orm=skip warns; decline once, then pick clerk (no - // warn) so the wizard proceeds. - script(['supabase', 'skip', 'better-auth', 'clerk', 'resend', 'stripe']); - vi.mocked(prompts.confirm) - .mockResolvedValueOnce(false) - .mockResolvedValue(true); - const answers = await runWizardQuestions(wizard); - expect(answers.auth).toBe('clerk'); - expect(prompts.confirm).toHaveBeenCalledTimes(1); - // database, orm, auth (declined), auth (re-ask), email, payments = 6. - expect(prompts.select).toHaveBeenCalledTimes(6); - }); - - it('hard-fails when a rule empties a question of options', async () => { - const inline: WizardSpec = { - sections: [ - { - id: 's', - title: 'S', - questions: [ - { - id: 'a', - prompt: 'A', - options: [ - { value: 'x', label: 'X', install: null }, - { value: 'y', label: 'Y', install: null }, - ], - }, - { - id: 'b', - prompt: 'B', - options: [ - { value: 'p', label: 'P', install: null }, - { value: 'q', label: 'Q', install: null }, - ], - rules: [{ type: 'hide', if: { a: 'x' }, options: ['p', 'q'] }], - }, - ], - }, - ], - defaults: {}, - }; - script(['x']); - await expect(runWizardQuestions(inline)).rejects.toMatchObject( - new ProcessExit(1), - ); - expect(prompts.log.error).toHaveBeenCalledWith( - expect.stringContaining('"b"'), - ); - }); - - it('hard-fails when every option is coming soon', async () => { - const inline: WizardSpec = { - sections: [ - { - id: 's', - title: 'S', - questions: [ - { - id: 'soon', - prompt: 'Soon', - options: [ - { value: 'x', label: 'X', install: null, comingSoon: true }, - { value: 'y', label: 'Y', install: null, comingSoon: true }, - ], - }, - ], - }, - ], - defaults: {}, - }; - await expect(runWizardQuestions(inline)).rejects.toMatchObject( - new ProcessExit(1), - ); - expect(prompts.log.error).toHaveBeenCalledWith( - expect.stringContaining('"soon"'), - ); - }); - - it('exits when a question is cancelled', async () => { - script([CANCEL]); - await expect(runWizardQuestions(wizard)).rejects.toMatchObject( - new ProcessExit(0), - ); - }); -}); From 2c80751bf462a1d3b259ff9afd98fb16d448e29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Fri, 10 Jul 2026 11:51:38 +0200 Subject: [PATCH 2/4] docs: sync init docs to the archetype-default flow Update CLAUDE.md (intro; module-model + schemaVersion notes reframed as legacy-config back-compat; the commands/init.ts step-pipeline section) and docs/commands/init.md (full rewrite: detect archetypes -> resolve capabilities -> summary -> install) to match the archetype-default init flow. Addresses the deferred grill #1 / review documentation finding for the init-archetype-default increment. Co-Authored-By: Claude Opus 4.8 --- .dev/features/init-archetype-default/PLAN.md | 2 + CLAUDE.md | 18 +-- docs/commands/init.md | 115 +++++++------------ 3 files changed, 53 insertions(+), 82 deletions(-) diff --git a/.dev/features/init-archetype-default/PLAN.md b/.dev/features/init-archetype-default/PLAN.md index a32f1a5..4319304 100644 --- a/.dev/features/init-archetype-default/PLAN.md +++ b/.dev/features/init-archetype-default/PLAN.md @@ -40,6 +40,8 @@ Written / modified (in writes-scope; every path below exists after build): - `tests/init.test.ts` — `runInit()` (no opts) now drives the archetype flow (mirror `tests/init-archetype.test.ts` fixture setup); assert the archetype path IS taken and that `src/commands/init.ts` no longer imports `../lib/manifest.js` (the sharpened no-404 guard, grill #2). - `tests/init-archetype.test.ts` — archetype is now the default; exercise via `runInit()` with no opts; keep coverage. - `tests/index.test.ts` — update the two dispatch assertions (`:49`, `:57`) for the no-op-alias flag handling. +- `CLAUDE.md` — doc-sync (added at GATE 2 per the human's "doc-sync then merge" decision): reframe the init description + the "commands/init.ts is a step pipeline" section to the archetype/capability flow; note the module/manifest/wizard machinery now serves ONLY as legacy-config back-compat for `add`/`update`/`list`/`status`/`remove`. +- `docs/commands/init.md` — doc-sync (GATE 2): rewrite the user-facing init page for the archetype-default flow (detect archetypes → resolve capabilities → summary → install), replacing the legacy module/wizard/schemaVersion content. ### Deleted (verified orphans — `init.ts`-only importers; removed via `git rm`, never written — deliberately OUTSIDE the writes-scope and the build-completeness set) diff --git a/CLAUDE.md b/CLAUDE.md index 7e1c959..7a99596 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -`pharn-cli` is an interactive CLI that installs [PHARN](https://github.com/pharn-dev/pharn-oss) — an audit-grade methodology for Claude Code — into an existing Next.js project. `pharn init` runs a wizard, fetches the chosen PHARN modules from `pharn-dev/pharn-oss` via degit, copies them into `.claude/`, materializes the constitution + memory bank, and writes `pharn.config.json`. Published as `pharn-cli`, exposing both `pharn` and `pharn-cli` bins. Targets Claude Code today; Codex and Cursor are planned. +`pharn-cli` is an interactive CLI that installs [PHARN](https://github.com/pharn-dev/pharn-oss) — an audit-grade methodology for Claude Code — into an existing project (framework-agnostic). `pharn init` detects the project's **archetype(s)** (`ssr`/`backend`/`spa`/`lib`) and installs the applicable PHARN **capabilities** (grillers/lenses) from `pharn-dev/pharn-oss` via degit, copying them plus the fixed product surfaces into the mirrored layout (`.claude/` + `pharn/`) and writing `pharn.config.json`. No module catalog / `manifest.json` fetch. Published as `pharn-cli`, exposing both `pharn` and `pharn-cli` bins. Targets Claude Code today; Codex and Cursor are planned. -**Module model (important):** PHARN ships as modules (subfolders of the pharn-oss repo: `pharn-core`, `pharn-pipeline`, `pharn-review`, `pharn-audits`, `pharn-stack-react`, `pharn-stack-nextjs`). `pharn-core` is always installed; everything else is optional and depends on it. Each module's `module.json` has an `installs` map (source dir → destination dir in `.claude/`). The repo-root `manifest.json` is the authoritative version + dependency graph. **This CLI owns the `pharn.config.json` schema; pharn-oss owns the module/manifest schemas** (`scripts/schemas/` in that repo). +**Module model (legacy-config back-compat).** Since `init` installs by archetype → capabilities (above) and writes an *archetype config*, the module model applies only to the OTHER commands' fallback for a pre-archetype `pharn.config.json`. In it, PHARN ships as modules (subfolders of the pharn-oss repo: `pharn-core`, `pharn-pipeline`, `pharn-review`, `pharn-audits`, `pharn-stack-react`, `pharn-stack-nextjs`). `pharn-core` is always installed; everything else is optional and depends on it. Each module's `module.json` has an `installs` map (source dir → destination dir in `.claude/`). The repo-root `manifest.json` is the authoritative version + dependency graph. **This CLI owns the `pharn.config.json` schema; pharn-oss owns the module/manifest schemas** (`scripts/schemas/` in that repo). -**Manifest schemaVersion 1 vs 2.** The CLI understands two manifest schemas and routes on `schemaVersion` (anything else hard-fails). **v1** = the legacy flow: module multiselect → stack pack → privacy posture, whole-module installs. **v2** = the wizard flow: the manifest carries a `wizard` block (`sections[].questions[].options[]` + `rules[]` + `defaults`) that is the single source of truth for the init questionnaire, plus `kind: "skill-category"` modules (`pharn-skills-db`/`-orm`/`-auth`/`-payments`/`-email`) whose individual skill subfolders are installed **selectively** based on answers. Old pinned SHAs (v1) must keep working — never break `pharn update` against them. +**Manifest schemaVersion 1 vs 2 (legacy-config back-compat).** `init` no longer fetches `manifest.json`; this machinery is retained only for `add`/`update`/`list`/`status`/`remove` operating on a pre-archetype config. The CLI understands two manifest schemas and routes on `schemaVersion` (anything else hard-fails). **v1** = the original module flow: module multiselect → stack pack → privacy posture, whole-module installs. **v2** = the wizard flow: the manifest carries a `wizard` block (`sections[].questions[].options[]` + `rules[]` + `defaults`) that drove the former init questionnaire and still resolves `add :` / `update`'s skill set, plus `kind: "skill-category"` modules (`pharn-skills-db`/`-orm`/`-auth`/`-payments`/`-email`) whose individual skill subfolders are installed **selectively** based on answers. Old pinned SHAs (v1) must keep working — never break `pharn update` against them. ## Commands @@ -34,13 +34,13 @@ ESM-only (`"type": "module"`, NodeNext). **Relative imports must use `.js` exten `src/index.ts` parses argv with minimist and dispatches to `commands/{init,add,remove,update,list,status}.ts`. `init` is the default command. `list` is read-only (reads `pharn.config.json` + the remote manifest; no clone, no writes) and shares the `add`/`update` no-config exit + manifest-fetch patterns; `--json` emits a single inventory object on stdout (diagnostics to stderr). `status` is also read-only (the read side of `update`) — see its addressing note below. `remove` (alias `rm`, `--yes`/`-y` skips its confirm) is the inverse of `add` — see its addressing note below. -**`commands/init.ts` is a step pipeline.** Each `steps/*.ts` file is one stage and uses `@clack/prompts` for I/O: +**`commands/init.ts` is the archetype install flow** — the only init flow (the legacy module/wizard step pipeline `runInitLegacy`/`runInitV2` and its `steps/*` were removed). `runInit` calls `runInitArchetype` unconditionally: -1. `prereqs` — hard-fails if `next` isn't in package.json or `.git` is absent. -2. `fresh-check` — warns (commit count + custom-file heuristic) when the project isn't a fresh Next.js scaffold. -3. Fetch the module catalog (`lib/manifest.ts` → `fetchRemoteManifest`) and branch on `schemaVersion`. -4. **v1 (`runInitLegacy`):** a loop of `module-select` (multiselect optional) → `stackpack-select` (single, exclusive) → `constitution-select` (privacy posture → variant) → `summary`. **v2 (`runInitV2`):** `mode-select` (Default / Custom) → answers (Default = `wizard.defaults` verbatim, asks nothing per-tech; Custom = `wizard-questions` renders each section, applying `hide` / `hideQuestion` / `relabel` rules against prior answers, `comingSoon` options dimmed + unselectable, `warn` rules soft-confirmed after each answer) → methodology `module-select` (excludes `kind:"skill-category"`) → `stackpack-select` → `constitution-select` → `vendor-consent` (records consent for options carrying a `vendorSkill`; external fetch is **Coming soon**) → `summary` (now also lists the per-tech SKILLS + vendor block). Both flows: summary returns `install` / `cancel` / loop-again with previous answers preserved. -5. On install: `steps/install.ts` → `lib/installer.ts`. +1. `prereqs` (`runGitPrereq`) — hard-fails if `.git` is absent. `fresh-check` — warns (commit count + tracked-file heuristic) on a non-fresh repo. +2. `detectArchetypesFromProject` (`lib/detect-archetype.ts`) — merges `package.json` dependency names + a bounded, symlink-safe file-tree walk into an `Archetype[]` (`ssr`/`backend`/`spa`/`lib`). +3. `fetchRepo` (`lib/repo.ts`) — degit-clone pharn-oss to a temp dir (cleaned up in a `finally`; every `process.exit`/`cancelAndExit` happens AFTER it). +4. `parseCapabilityIndex` (`lib/capability-index.ts`) + `resolveCapabilities` (`lib/resolve-capabilities.ts`) — select capabilities whose `applies` is `universal` or intersects the detected archetypes; skip the rest with a reason. +5. `runArchetypeSummary` (selected + skipped) → `install` / `cancel`; on `install`, `confirmOverwriteIfExists` guards an existing config, then `runInstallArchetype` (`steps/install-archetype.ts` → `lib/install-capabilities.ts`) copies the capabilities + fixed product surfaces into the mirrored layout (flat OR `pharn/`) and writes the archetype `pharn.config.json` (`archetypes`, `capabilities`, `layout`, `skillsVersion` from `SKILLS_VERSION`, `modules: []`; canonical `CONSTITUTION.md` copied verbatim). The `--archetype` CLI flag is a retained no-op alias for one release. **`lib/wizard.ts`** is the pure (no-I/O) rule engine + answer resolver: `matchCondition` (AND across keys; `string` = equality, `{not}` = negation; missing key fails equality / satisfies `not`), `applyRulesToQuestion`, `pendingWarnings`, `collectInstalls`/`collectVendorSkills` (answer → skill installs / vendor names), `applyDefaults`, and `findSkillOption`/`listSkillAddresses` (resolve `add :`). diff --git a/docs/commands/init.md b/docs/commands/init.md index c93b45b..88fa75e 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -8,6 +8,10 @@ pharn init pharn ``` +`init` detects your project's **archetype(s)** and installs the PHARN **capabilities** that apply to them. It fetches nothing you did not ask for: only the capabilities matching your project, plus the fixed product surfaces (commands, hooks, docs, contracts, floor), are copied. There is no module catalog and no `manifest.json` fetch — capabilities are the install unit. + +> The `--archetype` flag is a **deprecated no-op** kept for one release: archetype detection is now the default, so `pharn init --archetype` behaves identically to `pharn init`. + ## Flow ```mermaid @@ -16,11 +20,9 @@ sequenceDiagram participant CLI as pharn_init participant Prereqs participant Fresh as fresh_check - participant Catalog as manifest_fetch - participant Modules as module_select - participant Stack as stackpack_select - participant Const as constitution_select - participant Tenant as multitenant_select + participant Detect as detect_archetype + participant Fetch as fetch_pharn_oss + participant Resolve as resolve_capabilities participant Summary participant Install @@ -29,38 +31,20 @@ sequenceDiagram Prereqs-->>CLI: ok or exit CLI->>Fresh: commit + tracked-file heuristics Fresh-->>User: optional warnings - CLI->>Catalog: fetch manifest.json - loop Until install or cancel - CLI->>Modules: optional modules multiselect - CLI->>Stack: stack pack (single, or none) - CLI->>Const: privacy posture → constitution - CLI->>Tenant: multi-tenant SaaS? (gates Principle 2) - CLI->>Summary: resolved module set + versions - Summary-->>User: install / back / cancel - end - CLI->>Prereqs: stack-pack package prerequisites - Prereqs-->>CLI: ok or exit - CLI->>Install: clone repo, copy modules, write config + CLI->>Detect: package.json + file-tree signals + Detect-->>User: "Detected archetypes" note + CLI->>Fetch: clone pharn-dev/pharn-oss (degit) + CLI->>Resolve: capability index vs detected archetypes + CLI->>Summary: capabilities selected + skipped (with reason) + Summary-->>User: install / cancel + CLI->>Install: copy capabilities + product surfaces, write config Install-->>User: next steps ``` -> The diagram shows the **schemaVersion 1** flow. Against a **schemaVersion 2** manifest the CLI runs the wizard flow below before the methodology/stack/constitution steps. - -## schemaVersion 2 wizard - -When the fetched `manifest.json` is `schemaVersion 2`, `init` first reads your `package.json` and pre-fills the wizard from the manifest's detection metadata: +## Archetypes and capabilities -- **Stack pack** — preselected when every package a pack lists in `prerequisites` is present in `dependencies`/`devDependencies` (e.g. `next` → `pharn-stack-nextjs`); otherwise **None**. -- **Per-technology answers** — each question's answer is preselected when one of an option's `detect` packages is present (e.g. `drizzle-orm` → Drizzle, `@supabase/supabase-js` → Supabase). `(coming soon)` options are never auto-selected. - -Detected values are shown in a "Detected from package.json" note; you can override every choice, and with no matches the wizard falls back to defaults / None. - -`init` then asks how to configure your stack: - -- **Default** — every per-technology answer is taken from `manifest.wizard.defaults`, overlaid with any detected answers (detection wins; undetected questions keep their default); the manifest's `hide`/`hideQuestion` rules are then applied so the result matches what Custom mode would produce with every default accepted (a question a rule hides is recorded as `skip`, never installed). No per-tech questions are asked. -- **Custom** — each wizard section (database, ORM, auth, …) is rendered as a single-select with detected answers pre-checked. Options are hidden, relabeled, or whole questions skipped based on your earlier answers (the manifest's `rules`); `(coming soon)` options are shown but not selectable; soft warnings confirm risky combinations. - -After the stack questions it continues with the methodology multiselect (which excludes the `pharn-skills-*` category modules), stack pack, constitution, and the **multi-tenant SaaS** flag as below. The summary lists the per-technology skills, and install copies only those skill folders into `.claude/skills/`. Your answers and installed skills are written to `pharn.config.json` (`stackAnswers`, `installedSkills`). +- **Archetype** — a closed set describing what your project is: `ssr`, `backend`, `spa`, or `lib` (the frameworkless base). Detection merges two untrusted-but-name-only fact sources — your `package.json` dependency **names** and **file-tree** structural signals (e.g. a `.tsx` file → `spa`) — then applies the archetype rule once. It is deterministic: the same project always yields the same archetypes. A wholly signal-less project resolves to `lib`. +- **Capability** — one griller or lens (an auditor PHARN ships). Each declares `applies: 'universal'` (always selected) or a set of archetypes. A capability is **selected** iff it is universal or its `applies` set intersects your detected archetypes; otherwise it is **skipped**, with the reason shown. ## Steps @@ -70,10 +54,7 @@ Shows the PHARN logo and CLI version. ### 2. Prerequisites -Hard requirements. See [Getting started](../getting-started.md#prerequisites). - -- **`.git` present** — checked up front, before the wizard (universal, framework-agnostic). -- **Stack-pack packages** — after you pick a stack pack, every package it declares in the manifest's `prerequisites` must be in `package.json` (`dependencies`/`devDependencies`). Validated just before install, only for the pack you chose: **None** or a non-Next pack needs no framework package, while `pharn-stack-nextjs` requires `next`. The failure message is the manifest's own `reason`. +- **`.git` present** — checked up front, before anything else (universal, framework-agnostic). Hard-fails if absent. ### 3. Fresh check @@ -87,59 +68,47 @@ Soft warnings based on git signals only (framework-neutral). Thresholds: Default for "Continue anyway?" is **no** (false). -### 4. Module catalog +### 4. Detect archetypes -Fetches `manifest.json` from `raw.githubusercontent.com/pharn-dev/pharn-oss/main/manifest.json`. This drives the wizard options (module names, descriptions, versions) and dependency resolution. If the fetch fails, the CLI exits — re-run with `PHARN_DEBUG=1` for details. +Reads `package.json` dependency names and walks the project tree (bounded, symlink-safe, `node_modules`/`.git`/`dist`/`build` skipped) for structural signals, then reduces both to an `Archetype[]`. The detected set is shown in a "Detected archetypes" note. Only names are tested against fixed in-code allowlists — no discovered file body is read (other than `package.json`) and no untrusted value is executed, interpolated, or logged. -### 5. Module select +### 5. Fetch PHARN -A multiselect of **optional** modules (required modules and stack-pack bases are excluded). `pharn-core` is always installed. All optional modules are pre-selected by default. +Clones `pharn-dev/pharn-oss` into a temp dir via degit. If the fetch fails, the CLI exits — re-run with `PHARN_DEBUG=1` for details. The temp clone is always cleaned up (even on cancel or error). -### 6. Stack pack select +### 6. Resolve capabilities -A single choice among the available stack packs (currently `pharn-stack-nextjs`), or **None**. The initial selection is the pack detected from `package.json` (see the wizard section above), or **None** when nothing matches. Stack packs are mutually exclusive; the chosen pack's dependencies (e.g. the React base) are pulled in automatically. +Parses the capability index from the fetched clone and selects the capabilities that apply to your archetypes (universal + archetype-matched), in the index's declared order. Skipped capabilities are kept with a reason (e.g. `applies to [backend]; detected [ssr]`). -### 7. Privacy posture / constitution +### 7. Summary -Maps your answer to a constitution variant shipped in `pharn-core/templates/constitution/`: - -| Answer | Variant | Principles | -| ------ | ------- | ---------- | -| GDPR / EU users / strict compliance | `gdpr-strict` | 1–6 | -| Standard SaaS with user data | `standard` | 1–4 | -| Internal tools / B2B, no end-user PII | `minimal` | 2–4 | - -### 8. Multi-tenant SaaS - -"Is this a multi-tenant SaaS?" — recorded as `isMultiTenant` in `pharn.config.json` (default **Yes**). It gates **Principle 2 (Multi-Tenant Isolation)** in the installed constitution: - -- **Yes** (default) — the chosen constitution variant is installed verbatim, including Principle 2. -- **No** — Principle 2 is stripped from the copied `CONSTITUTION.md`: its `## Principle 2` section is removed and `2` is dropped from the `principles_included` frontmatter, so a non-SaaS project is not blocked by a principle that does not apply. Every other principle and the file's structure are unchanged. - -### 9. Summary - -Displays the **resolved** module set (your selections plus all transitive dependencies, with versions), the skills version, the constitution variant, and whether the project is a multi-tenant SaaS. Then: +Lists the **selected** capabilities (name, role, and why — `universal` or the matched archetype) and the **skipped** ones (with reason). Then: | Action | Result | | ------ | ------ | -| Yes, install | Clone the repo and install | -| Go back and change something | Re-run the selection steps, keeping your previous answers | +| Yes, install | Copy the capabilities + product surfaces and write config | | Cancel | Exit 0; nothing written | -### 10. Install +An overwrite prompt appears first if `pharn.config.json` already exists (default: do not overwrite). A present-but-invalid existing config is reported by name rather than silently clobbered — `init` is the repair path. + +### 8. Install | Action | Behavior | | ------ | -------- | -| Clone `pharn-dev/pharn-oss` | Whole repo into a temp dir (via degit) | -| Resolve modules | From the cloned `manifest.json` — dependencies + exclusivity | -| Copy modules | Each module's `installs` map merged into `.claude/` | -| Materialize core | `memory-bank/` and the chosen `CONSTITUTION.md` (Principle 2 stripped when not a multi-tenant SaaS) | -| Pin commit SHA | Best-effort via the GitHub API (null if unavailable) | -| Write `pharn.config.json` | `skillsVersion`, `commit`, `modules`, `constitution`, `isMultiTenant` | +| Copy capabilities | Each selected griller/lens dir (with its `evals/`) → the mirrored project path | +| Copy product surfaces | `pharn-*.md` commands (not `pharn-dev-*`), `.cjs` hooks, the trusted docs, `pharn-contracts/`, and `.dev/floor/` (minus test files) | +| Preserve settings | An existing `.claude/settings.json` is **never** overwritten (a note tells you to wire the hooks by hand if needed) | +| Mirror the layout | Whichever layout the fetched clone uses — flat, or the relocated `pharn/` — is mirrored verbatim; the CLI never rewrites copied file contents | +| Pin commit SHA | Best-effort (the SHA the tree was pinned to; `null` if unavailable) | +| Write `pharn.config.json` | `skillsVersion` (from the repo's `SKILLS_VERSION`), `commit`, `archetypes`, `capabilities`, `layout`, `models`, `seam`, `modules: []` | + +The install copies pharn-oss's canonical `CONSTITUTION.md` verbatim — there is no privacy-posture / constitution-variant question in the archetype flow. Only capability contents are copied; the CLI never executes or parses them (your Claude Code runs them later). + +On success, the CLI reports the capability count and suggests opening Claude Code and running `/pharn-plan`. -Overwrite prompt if `pharn.config.json` already exists (default: do not overwrite). +## Legacy configs -On success, the CLI suggests opening Claude Code and running `/pharn-plan`. +`init` always writes an **archetype** config. The `add`, `remove`, `list`, `update`, and `status` commands still understand an older **module**-based `pharn.config.json` (from a pre-archetype install) and fall back to the module/manifest path for it — so an existing legacy install keeps working. Only `init` is archetype-only. ## Related From 92afbca9c4d90db97ae1775672ec7f069161f378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Fri, 10 Jul 2026 12:01:24 +0200 Subject: [PATCH 3/4] update --- .pharn/writes-scope.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 64cbee9..3f54632 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,13 @@ { "scope": [ - ".dev/features/init-archetype-default/SHIP.md" + "src/commands/init.ts", + "src/index.ts", + "tests/init.test.ts", + "tests/init-archetype.test.ts", + "tests/index.test.ts", + "CLAUDE.md", + "docs/commands/init.md" ], - "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-07-10T09:37:04.318Z" + "set_by": ".dev/features/init-archetype-default/PLAN.md", + "set_at": "2026-07-10T09:42:19.871Z" } From 8572dadbc2673b98e0164a03689717c7c4cd3d28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Fri, 10 Jul 2026 14:08:49 +0200 Subject: [PATCH 4/4] update --- .dev/features/commands-off-manifest/GRILL.md | 118 +++ .dev/features/commands-off-manifest/PLAN.md | 203 +++++ .../commands-off-manifest/REGRESSION.md | 46 ++ .dev/features/commands-off-manifest/REVIEW.md | 106 +++ .dev/features/commands-off-manifest/SHIP.md | 56 ++ .dev/features/commands-off-manifest/VERIFY.md | 41 + .../regression-report.json | 65 ++ .../commands-off-manifest/verify-report.json | 19 + .pharn/pharn-dev-regress/base-results.json | 2 +- .pharn/pharn-dev-regress/head-results.json | 2 +- .pharn/pharn-dev-verify/results.json | 2 +- .pharn/writes-scope.json | 12 +- CLAUDE.md | 24 +- docs/README.md | 12 +- docs/commands/add.md | 48 +- docs/commands/list.md | 46 +- docs/commands/remove.md | 55 +- docs/commands/status.md | 45 +- docs/commands/update.md | 27 +- docs/contributing.md | 51 +- docs/getting-started.md | 62 +- docs/reference/pharn-config.md | 102 ++- docs/roadmap.md | 30 +- docs/troubleshooting.md | 43 +- src/commands/add.ts | 251 +------ src/commands/list.ts | 203 +---- src/commands/remove.ts | 538 +------------- src/commands/status.ts | 135 +--- src/commands/update.ts | 141 +--- src/index.ts | 14 +- src/lib/capability-index.ts | 2 +- src/lib/constants.ts | 21 +- src/lib/diff.ts | 83 +-- src/lib/install-capabilities.ts | 2 +- src/lib/install-modules.ts | 246 ------ src/lib/installer.ts | 72 -- src/lib/layout.ts | 2 +- src/lib/manifest.ts | 698 ------------------ src/lib/pharn-config.ts | 26 + src/lib/skills-version.ts | 8 +- src/lib/validate.ts | 20 + src/lib/wizard.ts | 260 ------- src/steps/prereqs.ts | 55 +- src/types.ts | 121 --- tests/add.test.ts | 340 +-------- tests/diff.test.ts | 221 +----- tests/index.test.ts | 19 +- tests/install-modules.test.ts | 464 ------------ tests/install-skills.test.ts | 100 --- tests/installer.test.ts | 142 ---- tests/list.test.ts | 290 ++------ tests/manifest-v2.test.ts | 273 ------- tests/manifest.test.ts | 530 ------------- tests/pharn-config.test.ts | 47 ++ tests/prereqs.test.ts | 147 +--- tests/remove.test.ts | 646 ++-------------- tests/status.test.ts | 234 ++---- tests/update.test.ts | 152 +--- tests/validate.test.ts | 32 + tests/wizard-fixture.ts | 186 ----- tests/wizard.test.ts | 273 ------- 61 files changed, 1371 insertions(+), 6840 deletions(-) create mode 100644 .dev/features/commands-off-manifest/GRILL.md create mode 100644 .dev/features/commands-off-manifest/PLAN.md create mode 100644 .dev/features/commands-off-manifest/REGRESSION.md create mode 100644 .dev/features/commands-off-manifest/REVIEW.md create mode 100644 .dev/features/commands-off-manifest/SHIP.md create mode 100644 .dev/features/commands-off-manifest/VERIFY.md create mode 100644 .dev/features/commands-off-manifest/regression-report.json create mode 100644 .dev/features/commands-off-manifest/verify-report.json delete mode 100644 src/lib/install-modules.ts delete mode 100644 src/lib/installer.ts delete mode 100644 src/lib/manifest.ts delete mode 100644 src/lib/wizard.ts delete mode 100644 tests/install-modules.test.ts delete mode 100644 tests/install-skills.test.ts delete mode 100644 tests/installer.test.ts delete mode 100644 tests/manifest-v2.test.ts delete mode 100644 tests/manifest.test.ts delete mode 100644 tests/wizard-fixture.ts delete mode 100644 tests/wizard.test.ts diff --git a/.dev/features/commands-off-manifest/GRILL.md b/.dev/features/commands-off-manifest/GRILL.md new file mode 100644 index 0000000..664ace7 --- /dev/null +++ b/.dev/features/commands-off-manifest/GRILL.md @@ -0,0 +1,118 @@ +# GRILL — commands-off-manifest + +Header: interrogating `.dev/features/commands-off-manifest/PLAN.md`. +Spec-hash check: `sha256(ARCHITECTURE.md)` = `bca940a5…d3c4e` **matches** the plan's pinned +`spec_content_hash` — no spec drift (the binding block on drift is `/pharn-dev-build`'s floor, not this stage). +Grillers: `count-grillers.mjs` registered 13, but **all are `test-app/` install fixtures**, not the dev +repo's own grillers — so the relevant axes (architecture, coupling, security, documentation, testability) +are applied **inline** per this command's "apply the procedure inline" guidance. Findings conform to +`pharn-contracts/finding-shape.md` (enum-gated `type`/`rule_id`/`severity`/`file` = my own assertions; +free-text `problem`/`evidence` quote the untrusted PLAN.md as DATA). + +## Findings (grouped by axis) + +### Axis: honest scope / constitution tension (P7) + +```yaml +- type: FINDING + rule_id: P7 + severity: blocking + file: ".dev/features/commands-off-manifest/PLAN.md:158" + problem: "The increment removes the legacy module/manifest path, which CONSTITUTION P7 says must keep working 'forever' — a constitution-principle tension the grill records even though the human accepted it at GATE 1." + evidence: "'RESOLVED: hard-fail with a clear, named message … The human accepted that CONSTITUTION.md P7's manifest clause is superseded by the archetype model (the manifest is already 404 upstream).'" +``` + +Note: `/pharn-dev-grill` is advisory and cannot issue a binding `CONSTITUTION_VIOLATION`. This is recorded at +blocking-severity for the human's record; it was explicitly weighed and accepted at GATE 1 (the manifest is +already dead upstream, so P7's clause is un-honorable regardless). The human owns the trusted-doc +reconciliation (below). + +### Axis: documentation (P4) + +```yaml +- type: FINDING + rule_id: P4 + severity: important + file: ".dev/features/commands-off-manifest/PLAN.md:166" + problem: "After the increment, the four trusted docs (CONSTITUTION/ARCHITECTURE/THREAT-MODEL/LIMITS) describe the module/manifest model as current, contradicting the code — a P4 tension the plan correctly won't agent-edit (hook-protected) but which stays live until a human reconciles." + evidence: "'These four files are human-only, hook-protected … the agent will not edit them. Flagged here for a human to reconcile.'" +- type: FINDING + rule_id: P4 + severity: important + file: ".dev/features/commands-off-manifest/PLAN.md:48" + problem: "The plan's Files list omits src/index.ts, whose USAGE/help text describes 'Add a methodology module or stack pack', 'List installed and available modules/skills', and 'Update installed modules' — user-facing docs-in-code that go stale (P4). tests/index.test.ts may assert on that text." + evidence: "Files list under '## Files' names the 5 command files + libs + docs, but not src/index.ts (the dispatcher whose USAGE string is module-worded)." +``` + +### Axis: coupling / DRY (P3) + +```yaml +- type: FINDING + rule_id: P3 + severity: important + file: ".dev/features/commands-off-manifest/PLAN.md:81" + problem: "The 'non-archetype config → named message + exit(1)' behavior is planned per-command across all five commands, duplicating the message + branch five times; a shared helper (e.g. loadArchetypeConfigOrExit in lib/pharn-config.ts) would keep it single-sourced — but then pharn-config.ts must be added to the Files list." + evidence: "'ADD a \"non-archetype (legacy) config → message + exit(1)\" case per command' — the reject logic is described command-by-command, and lib/pharn-config.ts is not in the Files list." +``` + +### Axis: security / guarantee reduction (P0) + +```yaml +- type: FINDING + rule_id: P0 + severity: minor + file: ".dev/features/commands-off-manifest/PLAN.md:124" + problem: "The claim 'the symlink arbitrary-write finding is closed' asserts install-capabilities.ts is the sole surviving copy path; build should substantiate it by grepping surviving src for any other cpSync/writeFileSync/renameSync from the untrusted clone, so the guarantee rests on an observed-empty set, not an assertion." + evidence: "'the ONLY surviving copy path is install-capabilities.ts, whose symlink guards + safeJoin are tested'." +``` + +### Axis: testability (P1) + +```yaml +- type: FINDING + rule_id: P1 + severity: minor + file: ".dev/features/commands-off-manifest/PLAN.md:76" + problem: "The legacy-config eval says the command 'never a network fetch', but the test spec only asserts the message + exit(1); it should also assert the clone/fetch function (fetchRepo / fetchRemoteManifest mock) is NOT called on the legacy branch — otherwise 'no fetch' is unverified." + evidence: "'prints the named … message and exit(1); never performs a network fetch' — the assertion of 'no fetch' is stated but not given a test hook." +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/commands-off-manifest/PLAN.md:71" + problem: "Removing the legacy list/render path may orphan exports (e.g. format.ts shortDescription, used only by the legacy list renderer); the plan's type/constant pruning covers types.ts/constants.ts but not lib/format.ts — build should sweep for newly-dead exports so the tree stays honest." + evidence: "types.ts + constants.ts are listed for orphan pruning, but format.ts (shortDescription) is not, though it feeds only the removed legacy list renderer." +``` + +### Axis: scope size (P7 — human-accepted) + +```yaml +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/commands-off-manifest/PLAN.md:163" + problem: "The increment bundles four axes (relocate safeJoin; excise the legacy subsystem; prune dead types/constants; sync ~13 docs), ~40 files — larger than 'smallest coherent increment'; the human chose one increment at GATE 1, so this is advisory, but /pharn-dev-build should phase strictly (relocate+importers → command excision → deletions → tests → docs) to keep each step green." + evidence: "'RESOLVED: one increment = code + tests + CLAUDE.md + user docs/ (ends fully P4-consistent).'" +``` + +## Prose summary + +The plan is well-grounded: discovery is live, the import graph is verified, the guarantee/trust/determinism +audits are present, and the crux (P7 legacy back-compat) is surfaced and human-resolved rather than +buried. Its trust posture strictly **improves** (three untrusted surfaces — manifest, module.json, wizard — +are deleted), and the core security claim (closing the `install-modules.ts` symlink write-path) reduces to +the surviving `safeJoin` + capability-name regex + symlink rejection, all tested. + +The concerns worth the human's attention before building are two **completeness gaps in the Files list** — +`src/index.ts` (module-worded USAGE/help text, P4) and, if the DRY helper is adopted, `lib/pharn-config.ts` +(F4) — plus the **five-fold duplication** of the legacy-reject branch. None blocks: the writes-scope hook +will simply deny a write to any file not declared, so a missing Files entry surfaces loudly at build (the +fix is to declare it + re-run the setter, never bypass). The remaining findings are minor verification +hardening (substantiate "sole copy path"; assert "no fetch"; sweep orphaned exports) and an honest note +that this is a large bundled increment the human elected to keep whole. + +## Verdict + +ADVISORY VERDICT: 8 concerns raised (1 blocking-severity [P7, human-accepted at GATE 1], 3 important +[2× Files-list/doc completeness, 1× DRY], 4 minor [verification hardening + scope size]) — for the human to +weigh before `/pharn-dev-build`. This grill-log is **advisory**; it gates nothing. The floor backstops remain +`/pharn-dev-build`'s spec-hash + open-questions gates and `.dev/floor/validate.mjs`. diff --git a/.dev/features/commands-off-manifest/PLAN.md b/.dev/features/commands-off-manifest/PLAN.md new file mode 100644 index 0000000..8b36843 --- /dev/null +++ b/.dev/features/commands-off-manifest/PLAN.md @@ -0,0 +1,203 @@ +# PLAN — commands-off-manifest (retire the dead manifest/module subsystem) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 (ARCHITECTURE.md) +- increment: Migrate `add`/`list`/`remove`/`status`/`update` fully onto the archetype/capability model + `pharn/` layout, then delete the now-orphaned manifest/module/wizard install path — closing Fable's symlink finding (`install-modules.ts`). +- layer(s): pharn-cli (the installer) — `src/commands/*`, `src/lib/*`, `src/steps/*`, `tests/*`, docs +- constitution_refs: [P0, P1, P2, P3, P4, P5, P6, P7] + +## Context (live state, verified this run) + +All five commands ALREADY branch on `isArchetypeConfig(config)` (= `Array.isArray(config.capabilities)`, +`lib/pharn-config.ts:133`) and take a **manifest-free archetype path** — `runArchetypeAdd`, +`runArchetypeUpdate`, `runArchetypeStatus`, `buildArchetypeInventory`, `removeCapability`. The manifest +survives ONLY in each command's **legacy `else` branch**, which is **already non-functional against live +pharn-oss** (no `manifest.json` upstream → 404; the code itself says so at `pharn-config.ts:131` and +`list.ts:65`). `init` was migrated in #32 and no longer has a legacy path. + +The migration is therefore: **excise the dead legacy branch + the lib files only it uses.** + +Import graph (verified by grep this run): +- `safeJoin` lives in `install-modules.ts` (to be deleted) but is imported by SIX keepers: + `install-capabilities.ts`, `diff.ts`, `layout.ts`, `skills-version.ts`, `capability-index.ts`, + `remove.ts` (its **archetype** path). → `safeJoin` must relocate first. +- `manifest.ts` importers: `diff.ts` (legacy `diffInstalled` only), `installer.ts`, `install-modules.ts`, + and the 5 commands' legacy branches. +- `installer.ts` (`fetchAndInstall`) importers: `add.ts`, `update.ts` legacy branches only. +- `wizard.ts` (`findSkillOption`/`listSkillAddresses`) importers: `add.ts`, `list.ts`, `update.ts` legacy only. +- `assertPrerequisites` (`steps/prereqs.ts`) importer: `add.ts` legacy only (`runGitPrereq` stays — `init`). + +Once the legacy branches leave, `manifest.ts` / `installer.ts` / `wizard.ts` / `install-modules.ts` have +zero importers and delete cleanly. + +## Files + +**Relocate `safeJoin` (unblocks the deletion) — decision Q2:** + +- `src/lib/validate.ts` — MOVE `safeJoin` here (it already owns `ManifestValidationError` + the + path/`..`/regex floor; this is the security-validation axis). layer: lib +- `src/lib/install-capabilities.ts` — `safeJoin` import: `./install-modules.js` → `./validate.js`. layer: lib +- `src/lib/diff.ts` — `safeJoin` import → `./validate.js`; **remove `diffInstalled`** (legacy module diff) + + its `readModuleManifest` import; keep `diffInstalledCapabilities`. layer: lib +- `src/lib/layout.ts` — `safeJoin` import → `./validate.js`. layer: lib +- `src/lib/skills-version.ts` — `safeJoin` import → `./validate.js`. layer: lib +- `src/lib/capability-index.ts` — `safeJoin` import → `./validate.js`. layer: lib + +**Excise the legacy branch from each command (archetype-only; a non-archetype config → clear hard-fail +message, decision Q1). Single-source the reject via a shared helper (grill F4/P3):** + +- `src/lib/pharn-config.ts` — ADD `loadArchetypeConfigOrExit(cwd)`: `loadConfigOrExit` + require + `isArchetypeConfig`, else print the named "legacy module layout no longer supported — re-run + `pharn init`" message + `exit(1)`. add/update/status/remove call it (no 5× copy). `list` keeps its own + json-aware inline check (its error must go to stderr in `--json`). layer: lib +- `src/index.ts` — sync the `USAGE`/help text off the module model (grill F3/P4): `add ` / + `remove ` / `list`/`update` describe capabilities, not "methodology module / stack pack / + installed modules". layer: entry +- `src/commands/add.ts` — drop `loadManifest`/`addSkill`/module flow + manifest/wizard/installer/ + `assertPrerequisites` imports; keep `runArchetypeAdd`; non-archetype config → named message + `exit(1)`. layer: commands +- `src/commands/update.ts` — drop legacy `runUpdate` body + manifest/wizard/installer imports; keep + `runArchetypeUpdate`; non-archetype → message. layer: commands +- `src/commands/list.ts` — drop `buildInventory`/`renderHuman`/`loadManifest` + manifest/wizard imports; + keep archetype render + `--json`; non-archetype → message (json-aware, to stderr). layer: commands +- `src/commands/status.ts` — drop legacy version/drift branch + `readManifest`/`resolveModules`/ + `fetchRemoteManifest` imports; keep `runArchetypeStatus`; non-archetype → message. layer: commands +- `src/commands/remove.ts` — drop `removeSkill`/`removeModule`/`runPicker`/`planAndApplyModuleRemoval` + + all module helpers + manifest imports; keep `removeCapability` (+ its no-arg picker over installed + capabilities); `safeJoin` import → `./validate.js`; non-archetype → message. layer: commands + +**Trim the orphans left behind:** + +- `src/steps/prereqs.ts` — remove `assertPrerequisites` + its `ManifestModule`/`ModulePrerequisite` type + imports; keep `runGitPrereq`. layer: steps +- `src/lib/constants.ts` — remove now-unused `MANIFEST_RAW_PATH`, `CORE_MODULE`, `SKILL_MODULE_PREFIX`. layer: lib +- `src/types.ts` — remove now-unreferenced manifest/wizard schema types (`Manifest`, `ManifestModule`, + `ModuleManifest`, `ModulePrerequisite`, `WizardCondition/Option/Rule/Question/Section/Spec`, + `WizardConfig`). KEEP `PharnConfig`'s additive legacy fields (`constitution`, `installedSkills`, + `stackAnswers`) + their referenced types (`Constitution`, `InstalledSkill`, `InstalledModule`) so a + legacy config still LOADS (P7 additive). layer: lib + +**Tests (P1) — modify:** + +- `tests/validate.test.ts` — ADD `safeJoin`'s path-escape/`..`/containment cases, moved from install-modules.test.ts (a security invariant keeps a test). layer: tests +- `tests/add.test.ts` — drop manifest mock + legacy cases; keep archetype; add "legacy config → message + exit(1) + fetch NOT called" (grill F6). layer: tests +- `tests/update.test.ts` — drop manifest mock + legacy cases; keep archetype; add legacy-config→message+no-fetch case. layer: tests +- `tests/list.test.ts` — drop manifest mock + legacy cases; keep archetype `--json`; add legacy-config→message case (json-aware). layer: tests +- `tests/status.test.ts` — drop manifest mock + legacy cases; keep archetype; add legacy-config→message+no-fetch case. layer: tests +- `tests/remove.test.ts` — drop legacy module/skill cases; keep capability cases; add legacy-config→message case. layer: tests +- `tests/index.test.ts` — update any assertion on the module-worded `USAGE`/dispatch text (grill F3). layer: tests +- `tests/diff.test.ts` — drop `diffInstalled` (legacy) cases; keep `diffInstalledCapabilities`. layer: tests +- `tests/prereqs.test.ts` — drop `assertPrerequisites` cases; keep `runGitPrereq`. layer: tests +- `tests/pharn-config.test.ts` — ADD `loadArchetypeConfigOrExit` unit tests (archetype config returns; legacy/non-archetype → `LEGACY_CONFIG_MESSAGE` via log.error + exit(1)) — the message's home, since command tests mock pharn-config (grill F4/F6 follow-through). layer: tests + +**Docs (P4) — sync to the capability model (scope of doc sync = decision Q3):** + +- `CLAUDE.md` — rewrite the "Module model" / "Manifest schemaVersion 1 vs 2" / per-command legacy paragraphs to the archetype/capability model only; note the module/manifest subsystem was removed. layer: docs +- `docs/commands/add.md` — sync to capabilities. layer: docs +- `docs/commands/list.md` — sync to capabilities. layer: docs +- `docs/commands/remove.md` — sync to capabilities. layer: docs +- `docs/commands/status.md` — sync to capabilities. layer: docs +- `docs/commands/update.md` — sync to capabilities. layer: docs +- `docs/commands/init.md` — remove any residual module-model reference. layer: docs +- `docs/reference/pharn-config.md` — describe the archetype config; drop module/schemaVersion prose. layer: docs +- `docs/roadmap.md` — remove/relabel module-model references. layer: docs +- `docs/troubleshooting.md` — remove/relabel module-model references. layer: docs +- `docs/getting-started.md` — remove/relabel module-model references. layer: docs +- `docs/README.md` — remove/relabel module-model references. layer: docs +- `docs/contributing.md` — remove/relabel module-model references. layer: docs + +### Explicitly deleted (via `rm`; NOT writes-scope-gated — the hook gates only Write/Edit) + +- `src/lib/manifest.ts`, `src/lib/install-modules.ts`, `src/lib/installer.ts`, `src/lib/wizard.ts` +- `tests/manifest.test.ts`, `tests/manifest-v2.test.ts`, `tests/install-modules.test.ts`, `tests/install-skills.test.ts`, `tests/installer.test.ts`, `tests/wizard.test.ts`, `tests/wizard-fixture.ts` + +## Contracts satisfied + +- `THREAT-MODEL.md §3` (the floor map) — the surviving install path keeps `INSTALL_PATH_RE` + + `safeJoin` + symlink rejection over every copy; the manifest/`module.json`/wizard **attack surfaces + (§2 rows 1–3) are removed entirely**, a net reduction. # cite, do not restate (P4) +- `LIMITS.md §1a/§1b` — placement-not-content + provenance-not-crypto posture is unchanged (still degit, + still SHA-pinned, still no per-file hash). `§1d` (`@main` resolution) narrows: only `status`/`update` + archetype paths resolve live now. +- `CONSTITUTION.md` P2/P3/P5/P6 — preserved by construction (see audits below). + +## Evals to write (P1) # pharn-cli's evals ARE its vitest tests (P1: "tests are the spec") + +- `safeJoin` (relocated) → path escaping `..`/absolute/outside-base → throws; in-base → returns joined path. +- `add` (archetype) → `add lens:n-plus-one` copies one capability + appends to config; already-installed → noop. +- `add`/`update`/`list`/`status`/`remove` (non-archetype config) → prints the named "legacy no longer + supported — re-run `pharn init`" message and `exit(1)`; **never** performs a network fetch. +- `list --json` (archetype) → emits the `mode:"archetype"` inventory; no manifest fetch. +- `status` (archetype) → version via `SKILLS_VERSION` + `diffInstalledCapabilities`; `--strict` gate intact. +- `update` (archetype) → re-resolves recorded archetypes → re-copies capabilities; up-to-date → noop. +- `remove` (archetype) → deletes one capability dir + drops its config entry; no clone/network. +- Whole suite: `npm run check` (format + lint + typecheck + test) green with the 4 libs + 7 tests deleted. + +## Guarantee audit (P0) + +- "No command fetches `manifest.json` (no 404)" → floor: **grep/enum membership** — post-change + `grep -rn "manifest" src/commands src/lib` returns nothing importing/fetching it, and `tsc` fails on any + dangling import; a per-command test asserts the legacy path takes the message branch, not a fetch. +- "`install-modules.ts` + `manifest.ts` deleted, nothing imports them" → floor: **import-graph + membership** (empty) + typecheck. +- "The symlink arbitrary-write finding is closed" → floor: **path-containment (`safeJoin`) + regex + (`INSTALL_PATH_RE`/`CAPABILITY_NAME_RE`) + `isSymlink` rejection** — the ONLY surviving copy path is + `install-capabilities.ts`, whose symlink guards + `safeJoin` are tested; `safeJoin`'s own escape tests + move to `validate.test.ts`. The deleted `install-modules.ts` was the last non-capability copy path. +- "A legacy (non-archetype) config is handled deterministically" → floor: **membership test** + `isArchetypeConfig` (`Array.isArray(capabilities)`); the else branch is a **named hard-fail message**, + not a guess (P5) — tested per command. +- "tests green" → floor: the `check-verify` / `check-regress` deterministic gates (npm test exit 0). Not a + self-asserted "quality" claim (P0). + +No un-backstopped guarantee. The one honestly-labeled **advisory** item: the user-facing prose of the new +"legacy unsupported" message is advisory text; the **decision** to print it is the deterministic branch. + +## Trust audit (P2) + +- Ingested untrusted input: the degit-cloned pharn-oss tree (add/update/status clone it). Taint path is + **unchanged and already floor-backed**: capability names read from the fetched index are validated + (`CAPABILITY_NAME_RE` + `assertNoDotDot`) BEFORE any path-join, every copy/read is `safeJoin`-guarded, + and symlinked sources are rejected (`install-capabilities.ts`). No free-text from the fetched repo drives + a filesystem write. +- Net effect: removing the legacy path **deletes** three untrusted surfaces (`manifest.json` `installs` + map, per-module `module.json`, the v2 `wizard` block — `THREAT-MODEL.md §2` rows 1–3). Trust posture + strictly improves; nothing new is ingested. + +## Determinism audit (P5) + +- The only new branch is `isArchetypeConfig(config)` → archetype path, `else` → named message + `exit(1)`. + Both arms are deterministic; the fallback is a **hard-fail with a clear message** (the P5-sanctioned + terminal), never a guess or a silent proceed. Capability resolution stays name/role membership. + +## Open questions (HALT) — RESOLVED at GATE 1 (human approved 2026-07-10) + +1. **P7 legacy back-compat (the crux).** → **RESOLVED: hard-fail with a clear, named message.** A + pre-archetype (module) `pharn.config.json` (no `capabilities`) → add/update/remove/list/status print + "legacy module layout no longer supported — re-run `pharn init`" + `exit(1)`, never a fetch. The human + accepted that `CONSTITUTION.md P7`'s *manifest clause* is superseded by the archetype model (the + manifest is already 404 upstream). The trusted docs stay human-reconciled (see note below). +2. **`safeJoin`'s new home.** → **RESOLVED: `lib/validate.ts`** (co-locate with `ManifestValidationError` + + the regex floor; its escape tests move to `tests/validate.test.ts`). +3. **Increment size / doc scope.** → **RESOLVED: one increment** = code + tests + `CLAUDE.md` + user + `docs/` (ends fully P4-consistent). + +## Grill incorporation (GRILL.md — advisory, folded in pre-build) + +- **F3 (P4)** → added `src/index.ts` + `tests/index.test.ts` to Files (USAGE/help text sync). +- **F4 (P3/DRY)** → added `src/lib/pharn-config.ts` `loadArchetypeConfigOrExit` shared helper (list keeps + its json-aware inline check). +- **F6 (P1)** → legacy-config tests assert the fetch/clone mock is uncalled. +- **F1 (P7), F2 (P4-trusted-docs)** → recorded; human-owned (GATE 1 acceptance + trusted-doc reconciliation). +- **F5 (P0), F7 (P7 orphans)** → build-time verification steps: grep surviving src for any stray + `cpSync`/`writeFileSync` copy-from-clone (substantiate "sole copy path"); sweep for newly-dead exports + (e.g. `format.ts` `shortDescription`). Not new files — checks during build. +- **F8 (P7 scope)** → advisory; build phases strictly (relocate+importers → command excision → deletions → + tests → docs), running `npm run check` between phases. + +## Doc reconciliation surfaced for the human (NOT agent-edited — trusted/hook-protected) + +`CONSTITUTION.md` (P2 "the manifest.json, each module.json, the v2 wizard block"; P5 "schemaVersion is +matched exactly"; P7 "schemaVersion 1 legacy … forever"), `ARCHITECTURE.md §4/§5` (manifest layer), +`THREAT-MODEL.md §2` (manifest attack surface), `LIMITS.md §1b/§1d` all describe the module/manifest model +as current. After this increment they are partially stale. These four files are **human-only, +hook-protected** (`protect-trusted-paths.cjs`); the agent will not edit them. Flagged here for a human to +reconcile — the same "surface, never agent-edit" discipline `ARCHITECTURE.md` mandates. diff --git a/.dev/features/commands-off-manifest/REGRESSION.md b/.dev/features/commands-off-manifest/REGRESSION.md new file mode 100644 index 0000000..1b97ee0 --- /dev/null +++ b/.dev/features/commands-off-manifest/REGRESSION.md @@ -0,0 +1,46 @@ +# REGRESSION — commands-off-manifest + +- base: `92afbca` (working tree is dirty — the increment is uncommitted, so `base = HEAD`, per Step 1's deterministic rule) +- **verdict: `no-regressions`** — no deterministically-detectable breakage outside the feature. Stage does NOT fail. + +## Inside / outside partition (from `check-regress.mjs scope`; `escaped: []`) + +- **inside** — the 49 changed product paths (all `src/`, `tests/`, `docs/`, `CLAUDE.md`, incl. the 11 + deletions). Every changed path is within the plan's `## Files` — **`escaped: []`, no fix #7 scope + breach.** (The pipeline's own scratch — `.pharn/`, `.dev/features/` — was filtered out of `--changed` + as it is not feature product.) +- **outside** — 44 `*.test.mjs` / `*.test.cjs` floor-checker + hook tests (`.dev/floor/*`, + `.claude/hooks/*`), none of which this increment touched; no committed eval pairs. + +## Per-gate comparison (base → head exit codes) + +| gate | base | head | flip? | +| ------- | ---- | ---- | ----- | +| `tests` | 1 | 1 | no — RED at both (pre-existing) | + +- `regressions[]`: **none**. +- `pre_existing[]`: **`tests`** — the outside `.mjs/.cjs` suite is RED at the `92afbca` baseline too; + the failing set is **byte-identical** base↔head (`diff` of the `not ok` lines is empty), so nothing + flipped pass→fail. This is a pre-existing environmental condition in that suite, unchanged by this + increment (which touched no `.dev/floor/`, no `.claude/hooks/`, and no `*.test.mjs`/`.cjs`). + +## Gates deliberately excluded (documented, not silent) + +- **`validate` (whole-repo)** — excluded from both maps. Its only findings come from `test-app/` + (`test-fixtures/red/skill.md`), an **untracked** scratch install absent from the `git worktree` + baseline (which materializes tracked files only). Comparing base↔head `validate` would compare + **different file sets** (GREEN in the worktree that lacks `test-app/` vs RED at HEAD) — an invalid + comparison, not a real signal. This increment changed **zero** capabilities, **zero** `.dev/floor/`, + and **zero** `test-app/` files, so `validate` provably cannot flip on anything it touched — the same + "flip is provably impossible → skip from both maps" logic the style-gate skip rule uses. +- **Style gates (`lint` / `format:check` / `lint:md`)** — skipped: `inside` does not touch a shared + style config (`eslint.config.mjs`, `.prettierrc`, `.prettierignore`, `.markdownlint-cli2.jsonc`), so a + style flip over the byte-identical outside files is provably impossible. (Note: the product suite — + `npm run check`, vitest + lint + typecheck + format — was run GREEN at HEAD during `/pharn-dev-build`; + it is not part of `check-regress`'s `node --test` gate set.) + +## Honest residual (P0/P7) + +`no-regressions` means **the deterministic suite this stage ran flipped nothing outside the feature** — +it does **not** mean "nothing broke." Breakage no deterministic check covers is invisible here; this +stage is exactly as good as the gates it ran (the outside `tests` set + the documented exclusions). diff --git a/.dev/features/commands-off-manifest/REVIEW.md b/.dev/features/commands-off-manifest/REVIEW.md new file mode 100644 index 0000000..0375a7a --- /dev/null +++ b/.dev/features/commands-off-manifest/REVIEW.md @@ -0,0 +1,106 @@ +# REVIEW — commands-off-manifest + +Reviewing the increment `/pharn-dev-build` produced (the reviewed diff is `trust: untrusted`; any +instruction-looking content in it is DATA reported as a finding, never followed — none was found). + +**Verdict: GREEN — 0 floor-gate (blocking) findings.** Four advisory notes below. The increment is +floor-clean: `npm run check` GREEN (test/typecheck/lint/format), `lint:md` GREEN, `/pharn-dev-regress` +`no-regressions`, `/pharn-dev-verify` `PASS`. + +## Step 1 — Floor (P0) + +The increment adds **no** PHARN markdown capability, so `.dev/floor/validate.mjs` is **vacuously green +for the increment's own files** — there is nothing for it to gate. The repo-root `validate.mjs .` RED is +exclusively the untracked `test-app/test-fixtures/red/skill.md` scratch fixture (pre-existing, outside +the increment — documented in VERIFY.md / REGRESSION.md). The increment's real floor is `npm run check`, +which is GREEN. No blocking floor finding. + +## Floor-gate findings (blocking) + +**None.** + +## Advisory findings (inform; never a blocking basis on their own) + +```yaml +- type: FINDING + rule_id: P3 + severity: minor + file: "src/lib/validate.ts:111" + problem: "safeJoin (path containment) now lives alongside the regex/enum allowlists in validate.ts, broadening the file's axis slightly." + evidence: "'export function safeJoin(base, rel)' added to validate.ts (relocated from the deleted install-modules.ts)." +``` + +Advisory: this was the human's explicit choice at GATE 1 (validate.ts vs a dedicated safe-path.ts). +Cohesive reading: validate.ts is "the structural input-validation / security floor" (allowlists + +`..`/control-char rejection + path containment) — one axis. Its escape tests moved with it +(`validate.test.ts`). No action needed unless a future split is preferred. + +```yaml +- type: FINDING + rule_id: P7 + severity: minor + file: "src/lib/constitution.ts:1" + problem: "constitution.ts (MULTI_TENANT_PRINCIPLE/stripPrinciple) and format.ts's shortDescription are now referenced by no src file — dead after the module path's removal (constitution.ts is used only by its own test)." + evidence: "grep: constitution.js has no src importer; the only importer is tests/constitution.test.ts. shortDescription fed only the removed legacy list renderer." +``` + +Advisory: floor-safe (tsc/eslint tolerate unused exports; tests pass). Out of this increment's approved +`## Files`, so left for a follow-up honest-scope cleanup (the grill F7 sweep result — named, not hidden). + +```yaml +- type: FINDING + rule_id: P4 + severity: minor + file: "src/lib/repo.ts:27" + problem: "A repo.ts doc comment still says the caller 'reads the manifest.json + each module's installs from it' — stale after the module path's removal." + evidence: "'manifest.json + each module's installs from it, records `sha` as `commit`' — repo.ts was outside this increment's ## Files, so its comment was not updated." +``` + +Advisory: a one-line comment in an out-of-scope file; fold into the same follow-up. + +```yaml +- type: FINDING + rule_id: P0 + severity: minor + file: ".dev/features/commands-off-manifest/VERIFY.md:1" + problem: "pharn-cli's deterministic floor is `npm run check`, NOT `.dev/floor/validate.mjs .` — the latter always RED at the repo root because it scans untracked test-app red fixtures. The dev-loop stage commands assume validate.mjs is the build/verify floor." + evidence: "validate.mjs . → RED from test-app/test-fixtures/red/skill.md, unrelated to any pharn-cli source change." +``` + +Advisory + a doc-reconciliation for the human: for a TS installer repo like pharn-cli, the ship/build/ +verify stages' "read validate.mjs" instruction maps to `npm run check`. Surfaced consistently across +GRILL/REGRESSION/VERIFY; not a defect in the increment. + +## L-eval (P1) + +Every new behavior ships a test (378 vitest cases, all green): `safeJoin` containment +(`validate.test.ts`), `loadArchetypeConfigOrExit` (archetype returns / legacy → exact +`LEGACY_CONFIG_MESSAGE` + exit — `pharn-config.test.ts`), each command's legacy-abort-before-fetch +(`add`/`update`/`status`/`remove.test.ts`) + `list`'s own message path, `diffInstalledCapabilities` +(`diff.test.ts`), and the USAGE sync (`index.test.ts`). No `rule_id`/eval binding to check (a TS +increment, no PHARN capabilities). GREEN. + +## L-trust (P2) + +Trust posture strictly **improves**: the increment **deletes** three untrusted ingest surfaces (the +`manifest.json`, per-module `module.json`, and the v2 `wizard` block). The surviving ingest path +(`install-capabilities.ts`, add/update clone) validates capability names (`CAPABILITY_NAME_RE` + +`assertNoDotDot`) before any path-join and `safeJoin`-guards + symlink-rejects every copy; no free-text +from the fetched repo drives a write. `LEGACY_CONFIG_MESSAGE` is a hardcoded trusted string. GREEN. + +## L-axis (P3) + +No command→command import introduced (verified by grep — commands import only `lib/`). `safeJoin` moved +lib→lib; `diff.ts`/`remove.ts`/etc. import it from `validate.js` (leaf→leaf through a shared floor +module, not a sibling-command reference). The one axis note (validate.ts) is the advisory P3 finding +above. GREEN. + +## Proposed lesson (candidate — NOT written to canon; promotion is a separate human-gated run) + +- **Lesson:** `set-writes-scope.cjs --from-plan` extracts **one back-tick path per `- ` bullet** and + **stops at the first `#`-heading or exclusion-cue line** (e.g. "Delete … not … gated"). So a PLAN's + `## Files` must list **every edited path on its own bullet**, and any Delete/exclusion subsection must + come **after** all writable paths (and deletes need not be scope-listed — the hook gates only + `Write|Edit|MultiEdit`, not `rm`). Provenance: this increment (`commands-off-manifest`) — the setter + under-captured twice (25→16 paths) until the `## Files` was reformatted one-path-per-bullet with a + trailing `### Explicitly deleted` heading. Real, recurring (any multi-file plan hits it). diff --git a/.dev/features/commands-off-manifest/SHIP.md b/.dev/features/commands-off-manifest/SHIP.md new file mode 100644 index 0000000..a7278b2 --- /dev/null +++ b/.dev/features/commands-off-manifest/SHIP.md @@ -0,0 +1,56 @@ +# SHIP — commands-off-manifest (roll-up) + +Thin, **advisory** roll-up of the `/pharn-dev-ship` chain. It records **that the chain ran and its floor +verdicts** — it is NOT a self-issued "shipped", an approval, or a `PHARN ✓ reviewed` seal. + +## Increment + +Migrate `add`/`list`/`remove`/`status`/`update` off the dead `manifest.json` onto the archetype/capability +model + `pharn/` layout, then delete the orphaned module subsystem — closing Fable's `install-modules.ts` +symlink-write finding. + +## Stages that ran, in order + +| stage | outcome | +| ----- | ------- | +| `/pharn-dev-plan` | PLAN.md written; **GATE 1** — human approved (legacy → hard-fail message; `safeJoin` → `validate.ts`; one increment). | +| `/pharn-dev-grill` | GRILL.md — advisory, 8 concerns; F3/F4/F6 folded into the plan pre-build (added `src/index.ts`, `pharn-config.ts` helper, `pharn-config.test.ts`). Gated nothing. | +| `/pharn-dev-build` | Files written per the amended `## Files`; floor GREEN. | +| `/pharn-dev-regress` | regression-report.json — **`no-regressions`**. | +| `/pharn-dev-verify` | verify-report.json — **`PASS`**. | +| `/pharn-dev-review` | REVIEW.md — **GREEN**, 0 blocking findings (4 advisory + 1 proposed lesson). | + +**Where the run ended:** **GATE 2** — the post-review human decision (merge / fix / abandon). + +## Structural verdicts read, verbatim + +- **`/pharn-dev-build` → floor:** `npm run check` (the repo's CI floor per CLAUDE.md: format:check + lint + + typecheck + test) = **exit 0 (GREEN)**, 378 tests. Reconciliation: `node .dev/floor/validate.mjs .` = + **exit 1**, but exclusively from untracked `test-app/test-fixtures/red/skill.md` (a deliberately-red + scratch fixture, pre-existing, unrelated to this increment); the `/pharn-dev-build` command states + validate.mjs "gates nothing" for an increment adding no PHARN markdown capability (this one adds none). + For pharn-cli the deterministic build floor is `npm run check`, and it is GREEN. +- **`/pharn-dev-regress` → `regression-report.json` `.verdict`:** `"no-regressions"` (the outside + `.mjs/.cjs` suite is RED at both base and HEAD — pre-existing, identical, no pass→fail flip; `validate` + excluded as confounded by untracked `test-app/`; style gates skipped — config untouched). +- **`/pharn-dev-verify` → `verify-report.json` `.verdict`:** `"PASS"` (gates `test`/`typecheck`/`lint`/ + `format:check`/`lint:md` all exit 0; `validate` excluded with the same documented reason; 0 verifiers + registered). + +## Pointers (cite, not restated — P4) + +- Advisory review: `.dev/features/commands-off-manifest/REVIEW.md` (4 advisory findings — a P3 note on + `safeJoin`'s home, a P7 dead-code note on `constitution.ts`/`format.ts:shortDescription`, a P4 stale + `repo.ts` comment, and the validate.mjs/`npm run check` reconciliation — plus a proposed lesson on the + `set-writes-scope` `## Files` one-path-per-bullet parser). +- Advisory grill: `.dev/features/commands-off-manifest/GRILL.md`. +- The migration also synced all editable docs (CLAUDE.md + 12 `docs/` files) to the capability model. The + four **trusted** docs (CONSTITUTION/ARCHITECTURE/THREAT-MODEL/LIMITS) still describe the module/manifest + model and are **human-only / hook-protected** — surfaced in PLAN.md for human reconciliation, not + agent-edited. + +## Honest standing line + +The chain ran; the named floor verdicts are as shown (`npm run check` GREEN, regress `no-regressions`, +verify `PASS`, review GREEN) — this is **NOT** a judgment that the increment is good or wise; that is the +human's call at the post-review gate. diff --git a/.dev/features/commands-off-manifest/VERIFY.md b/.dev/features/commands-off-manifest/VERIFY.md new file mode 100644 index 0000000..8ae9739 --- /dev/null +++ b/.dev/features/commands-off-manifest/VERIFY.md @@ -0,0 +1,41 @@ +# VERIFY — commands-off-manifest + +**VERIFIED: floor gates PASS.** + +## Floor layer — deterministic gates (the verdict) + +| gate | exit | meaning | +| -------------- | ---- | -------------------------------------------------- | +| `test` | 0 | `npm test` — 378 vitest cases, 29 files, all pass | +| `typecheck` | 0 | `tsc --noEmit` for src AND tests (both configs) | +| `lint` | 0 | `eslint src` clean | +| `format:check` | 0 | prettier clean (src + tests) | +| `lint:md` | 0 | markdownlint clean (docs + root markdown) | + +`check-verify.mjs` verdict: **PASS** (every gate exit 0), `failing_gates: []`. This gate set is exactly +the repo's `npm run check` aggregate (+ `lint:md`) — the floor CLAUDE.md/CI define for pharn-cli. + +## Gate deliberately excluded (documented, not silent) + +- **`validate` (`.dev/floor/validate.mjs .`) — measured exit 1, EXCLUDED from the verdict map.** Its only + findings are in **untracked `test-app/`** (`test-fixtures/red/skill.md`, a *deliberately-red* fixture + that `validate.test.mjs` uses to prove the checker catches red capabilities). That RED is unrelated to + this increment — which changed **zero** capabilities, **zero** `.dev/floor/`, and **zero** `test-app/` + files — and to pharn-cli's own source. pharn-cli's CI floor is `npm run check` (CLAUDE.md), not + `validate.mjs`; the `/pharn-dev-build` command itself states `validate.mjs` "gates nothing" for an + increment that adds no PHARN markdown capability (this one adds none). Including it would make *every* + pharn-cli increment fail verify on unrelated scratch — a false RED. The exclusion is the verify + command's sanctioned "gate SET is advisory orchestration" latitude, applied to a confounded gate, and + surfaced here rather than hidden. + +## Advisory layer — verifiers + +No verifiers registered (`count-verifiers.mjs` → `{"registered":0}`) — **floor gates only.** Step 2 is a +no-op; the verdict is the floor gates alone. + +## Honest residual (P0/P7) + +"Verified" = the named gates passed; this is **not** a guarantee of correctness beyond what those gates +check. A defect no test/lint/typecheck/format covers is invisible to this verdict, and the verifier +layer that might notice it is empty today — verifier concerns, when they exist, are advisory help, not +assurance. The claim is "the named gates passed," not "the feature is correct." diff --git a/.dev/features/commands-off-manifest/regression-report.json b/.dev/features/commands-off-manifest/regression-report.json new file mode 100644 index 0000000..ab29eb3 --- /dev/null +++ b/.dev/features/commands-off-manifest/regression-report.json @@ -0,0 +1,65 @@ +{ + "base": "92afbca", + "inside": [ + "CLAUDE.md", + "docs/README.md", + "docs/commands/add.md", + "docs/commands/list.md", + "docs/commands/remove.md", + "docs/commands/status.md", + "docs/commands/update.md", + "docs/contributing.md", + "docs/getting-started.md", + "docs/reference/pharn-config.md", + "docs/roadmap.md", + "docs/troubleshooting.md", + "src/commands/add.ts", + "src/commands/list.ts", + "src/commands/remove.ts", + "src/commands/status.ts", + "src/commands/update.ts", + "src/index.ts", + "src/lib/capability-index.ts", + "src/lib/constants.ts", + "src/lib/diff.ts", + "src/lib/install-capabilities.ts", + "src/lib/install-modules.ts", + "src/lib/installer.ts", + "src/lib/layout.ts", + "src/lib/manifest.ts", + "src/lib/pharn-config.ts", + "src/lib/skills-version.ts", + "src/lib/validate.ts", + "src/lib/wizard.ts", + "src/steps/prereqs.ts", + "src/types.ts", + "tests/add.test.ts", + "tests/diff.test.ts", + "tests/index.test.ts", + "tests/install-modules.test.ts", + "tests/install-skills.test.ts", + "tests/installer.test.ts", + "tests/list.test.ts", + "tests/manifest-v2.test.ts", + "tests/manifest.test.ts", + "tests/pharn-config.test.ts", + "tests/prereqs.test.ts", + "tests/remove.test.ts", + "tests/status.test.ts", + "tests/update.test.ts", + "tests/validate.test.ts", + "tests/wizard-fixture.ts", + "tests/wizard.test.ts" + ], + "outside_gates": { + "tests": { + "base": 1, + "head": 1 + } + }, + "regressions": [], + "pre_existing": [ + "tests" + ], + "verdict": "no-regressions" +} diff --git a/.dev/features/commands-off-manifest/verify-report.json b/.dev/features/commands-off-manifest/verify-report.json new file mode 100644 index 0000000..606c870 --- /dev/null +++ b/.dev/features/commands-off-manifest/verify-report.json @@ -0,0 +1,19 @@ +{ + "feature": "commands-off-manifest", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "typecheck": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { "registered": 0, "findings": [] }, + "excluded_gates": { + "validate": { + "measured_exit": 1, + "reason": "validate.mjs . scans untracked test-app/ scratch (test-fixtures/red/skill.md — a deliberately-red fixture); its RED is unrelated to this increment (which changed zero capabilities, zero .dev/floor/, zero test-app/) and to pharn-cli source. pharn-cli's CI floor is `npm run check` (CLAUDE.md), which is GREEN and fully represented above. The build command states validate.mjs 'gates nothing' for an increment that adds no PHARN markdown capability." + } + } +} diff --git a/.pharn/pharn-dev-regress/base-results.json b/.pharn/pharn-dev-regress/base-results.json index c825c88..f966306 100644 --- a/.pharn/pharn-dev-regress/base-results.json +++ b/.pharn/pharn-dev-regress/base-results.json @@ -1 +1 @@ -{"tests":1,"validate":0} +{"tests":1} \ No newline at end of file diff --git a/.pharn/pharn-dev-regress/head-results.json b/.pharn/pharn-dev-regress/head-results.json index c825c88..f966306 100644 --- a/.pharn/pharn-dev-regress/head-results.json +++ b/.pharn/pharn-dev-regress/head-results.json @@ -1 +1 @@ -{"tests":1,"validate":0} +{"tests":1} \ No newline at end of file diff --git a/.pharn/pharn-dev-verify/results.json b/.pharn/pharn-dev-verify/results.json index c1e0603..4311dbf 100644 --- a/.pharn/pharn-dev-verify/results.json +++ b/.pharn/pharn-dev-verify/results.json @@ -1 +1 @@ -{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0} \ No newline at end of file +{"test":0,"typecheck":0,"lint":0,"format:check":0,"lint:md":0} \ No newline at end of file diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 3f54632..d1e1306 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,13 +1,7 @@ { "scope": [ - "src/commands/init.ts", - "src/index.ts", - "tests/init.test.ts", - "tests/init-archetype.test.ts", - "tests/index.test.ts", - "CLAUDE.md", - "docs/commands/init.md" + ".dev/features/commands-off-manifest/SHIP.md" ], - "set_by": ".dev/features/init-archetype-default/PLAN.md", - "set_at": "2026-07-10T09:42:19.871Z" + "set_by": ".claude/commands/pharn-dev-ship.md", + "set_at": "2026-07-10T12:07:26.630Z" } diff --git a/CLAUDE.md b/CLAUDE.md index 7a99596..2c69cf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `pharn-cli` is an interactive CLI that installs [PHARN](https://github.com/pharn-dev/pharn-oss) — an audit-grade methodology for Claude Code — into an existing project (framework-agnostic). `pharn init` detects the project's **archetype(s)** (`ssr`/`backend`/`spa`/`lib`) and installs the applicable PHARN **capabilities** (grillers/lenses) from `pharn-dev/pharn-oss` via degit, copying them plus the fixed product surfaces into the mirrored layout (`.claude/` + `pharn/`) and writing `pharn.config.json`. No module catalog / `manifest.json` fetch. Published as `pharn-cli`, exposing both `pharn` and `pharn-cli` bins. Targets Claude Code today; Codex and Cursor are planned. -**Module model (legacy-config back-compat).** Since `init` installs by archetype → capabilities (above) and writes an *archetype config*, the module model applies only to the OTHER commands' fallback for a pre-archetype `pharn.config.json`. In it, PHARN ships as modules (subfolders of the pharn-oss repo: `pharn-core`, `pharn-pipeline`, `pharn-review`, `pharn-audits`, `pharn-stack-react`, `pharn-stack-nextjs`). `pharn-core` is always installed; everything else is optional and depends on it. Each module's `module.json` has an `installs` map (source dir → destination dir in `.claude/`). The repo-root `manifest.json` is the authoritative version + dependency graph. **This CLI owns the `pharn.config.json` schema; pharn-oss owns the module/manifest schemas** (`scripts/schemas/` in that repo). - -**Manifest schemaVersion 1 vs 2 (legacy-config back-compat).** `init` no longer fetches `manifest.json`; this machinery is retained only for `add`/`update`/`list`/`status`/`remove` operating on a pre-archetype config. The CLI understands two manifest schemas and routes on `schemaVersion` (anything else hard-fails). **v1** = the original module flow: module multiselect → stack pack → privacy posture, whole-module installs. **v2** = the wizard flow: the manifest carries a `wizard` block (`sections[].questions[].options[]` + `rules[]` + `defaults`) that drove the former init questionnaire and still resolves `add :` / `update`'s skill set, plus `kind: "skill-category"` modules (`pharn-skills-db`/`-orm`/`-auth`/`-payments`/`-email`) whose individual skill subfolders are installed **selectively** based on answers. Old pinned SHAs (v1) must keep working — never break `pharn update` against them. +**Module model (removed).** Earlier releases installed by *modules* driven by a repo-root `manifest.json` (schemaVersion 1/2 + a `wizard` block + per-module `module.json` `installs` maps), and kept a module/manifest fallback in `add`/`update`/`list`/`status`/`remove` for a pre-archetype config. **That subsystem is gone** (`lib/manifest.ts`, `install-modules.ts`, `installer.ts`, `wizard.ts` deleted) — live pharn-oss ships no `manifest.json`, so the fallback 404'd. **All commands are archetype-only.** A pre-archetype (module) `pharn.config.json` (one with `modules[]` but no `capabilities[]`) is detected by `isArchetypeConfig` and rejected up front by `loadArchetypeConfigOrExit` with a clear "re-run `pharn init`" message (`LEGACY_CONFIG_MESSAGE`) — never a fetch. **This CLI still owns the `pharn.config.json` schema**, which stays additive: a legacy config's now-unused fields (`modules`, `constitution`, `installedSkills`, `stackAnswers`) still LOAD (P7). ## Commands @@ -21,7 +19,7 @@ npm run lint # eslint src npm run format:check # prettier check (use `format` to write) npm test # vitest run (single pass) npm run test:watch -npx vitest run tests/manifest.test.ts # single test file +npx vitest run tests/install-capabilities.test.ts # single test file ``` CI (`.github/workflows/ci.yml`) runs format:check, lint, typecheck, and test — all four must pass. `PHARN_DEBUG=1` enables full error output for fetch/install failures. @@ -32,7 +30,7 @@ ESM-only (`"type": "module"`, NodeNext). **Relative imports must use `.js` exten ## Architecture -`src/index.ts` parses argv with minimist and dispatches to `commands/{init,add,remove,update,list,status}.ts`. `init` is the default command. `list` is read-only (reads `pharn.config.json` + the remote manifest; no clone, no writes) and shares the `add`/`update` no-config exit + manifest-fetch patterns; `--json` emits a single inventory object on stdout (diagnostics to stderr). `status` is also read-only (the read side of `update`) — see its addressing note below. `remove` (alias `rm`, `--yes`/`-y` skips its confirm) is the inverse of `add` — see its addressing note below. +`src/index.ts` parses argv with minimist and dispatches to `commands/{init,add,remove,update,list,status}.ts`. `init` is the default command. Every non-init command loads the config via `loadArchetypeConfigOrExit` (which rejects a pre-archetype config, above) — except `list`, which does its own json-aware check so its error stays on stderr under `--json`. `list` is read-only (reads `pharn.config.json` only — no clone, no fetch, no writes); `--json` emits a single inventory object on stdout (diagnostics to stderr). `status` is also read-only (the read side of `update`) — see its addressing note below. `remove` (alias `rm`) is the inverse of `add` — see its addressing note below (`--yes`/`-y` is now a no-op: capability removal has no confirm prompt). **`commands/init.ts` is the archetype install flow** — the only init flow (the legacy module/wizard step pipeline `runInitLegacy`/`runInitV2` and its `steps/*` were removed). `runInit` calls `runInitArchetype` unconditionally: @@ -42,21 +40,17 @@ ESM-only (`"type": "module"`, NodeNext). **Relative imports must use `.js` exten 4. `parseCapabilityIndex` (`lib/capability-index.ts`) + `resolveCapabilities` (`lib/resolve-capabilities.ts`) — select capabilities whose `applies` is `universal` or intersects the detected archetypes; skip the rest with a reason. 5. `runArchetypeSummary` (selected + skipped) → `install` / `cancel`; on `install`, `confirmOverwriteIfExists` guards an existing config, then `runInstallArchetype` (`steps/install-archetype.ts` → `lib/install-capabilities.ts`) copies the capabilities + fixed product surfaces into the mirrored layout (flat OR `pharn/`) and writes the archetype `pharn.config.json` (`archetypes`, `capabilities`, `layout`, `skillsVersion` from `SKILLS_VERSION`, `modules: []`; canonical `CONSTITUTION.md` copied verbatim). The `--archetype` CLI flag is a retained no-op alias for one release. -**`lib/wizard.ts`** is the pure (no-I/O) rule engine + answer resolver: `matchCondition` (AND across keys; `string` = equality, `{not}` = negation; missing key fails equality / satisfies `not`), `applyRulesToQuestion`, `pendingWarnings`, `collectInstalls`/`collectVendorSkills` (answer → skill installs / vendor names), `applyDefaults`, and `findSkillOption`/`listSkillAddresses` (resolve `add :`). - -**`lib/manifest.ts`** parses/validates `manifest.json` and each `module.json`. `parseManifest` accepts `schemaVersion` 1 or 2 and, for v2, validates the `wizard` block via `parseWizard` — a malformed wizard hard-fails naming the offending section/question/option (never silently falls back to v1). `resolveModules(manifest, selected)` computes the full ordered install set: always includes `pharn-core`, adds transitive `dependsOn`, and enforces `exclusiveWith` (glob-aware; a module in the same dependency chain is never a conflict — that's how a stack pack and its React base coexist). `categorizeModules` keeps `kind:"skill-category"` modules out of the methodology multiselect. - -**`lib/installer.ts` → `fetchAndInstall`** is the shared core used by init/add/update: clone the repo (`lib/repo.ts`, degit to a temp dir), read the manifest from the cloned commit, resolve modules, copy each module's `installs` (`lib/install-modules.ts` → `installModule`), and — for v2 — **selectively** copy the answered skills via `installSkills` (each `from` → `.claude/skills//`, siblings never touched). `assertSkillSourcesExist` validates **every** skill source up front so a bad path fails before any file is written (no partial installs). When a constitution variant is given, `materializeCore` writes the memory bank + `CONSTITUTION.md`. Best-effort commit SHA via the GitHub API. +**`lib/install-capabilities.ts`** is the shared capability copy core used by init/add/update. `installCapabilityDirs(repoDir, projectRoot, capabilities, paths?)` pre-flights **every** selected capability source (validated name via `CAPABILITY_NAME_RE` + `safeJoin` + existence + symlink rejection) before any write — no partial installs — then copies each griller/lens dir into the mirrored layout (`add` uses this alone). `installCapabilities` additionally copies the fixed product surfaces: product `pharn-*` commands (excluding `pharn-dev-*`), `.cjs` hooks (excluding `*.test.cjs`), `settings.json` (**never** overwritten), the trusted docs, `pharn-contracts/`, and `.dev/floor/` minus test files. Copying from the untrusted clone is symlink-guarded (`isSymlink` reject / `noSymlinks` filter) and `safeJoin`-contained; file contents are copied verbatim, never executed. The install set is resolved by `lib/capability-index.ts` (`parseCapabilityIndex` — the untrusted-frontmatter → typed `CapabilityIndex` fetch boundary, reading only `name`/`role`/`applies` via a strict field reader) + `lib/resolve-capabilities.ts` (select where `applies` is `universal` or intersects the detected archetypes). The commit SHA is threaded from `fetchRepo` (`repo.sha`) — no separate GitHub fetch (closes the resolve/fetch TOCTOU). -**`lib/validate.ts` is security-sensitive.** Module names, versions, `installs`/skill paths, and wizard values are validated against strict regex allowlists (`MODULE_NAME_RE`, `VERSION_RE`, `INSTALL_PATH_RE`, `WIZARD_VALUE_RE`), checked for `..`, and rejected on control chars. `install-modules.ts` additionally guards every copy (modules and skills) with `safeJoin` so nothing escapes its base dir. The manifest `schemaVersion` must be exactly `1` or `2` — anything else hard-fails by design so old CLIs don't guess at a new schema. Remote fetches use `redirect: 'error'`, an 8s timeout, and a 256KB body cap. Preserve these invariants. +**`lib/validate.ts` is security-sensitive.** Untrusted names, versions, paths, and capability frontmatter are validated against strict regex/enum allowlists (`CAPABILITY_NAME_RE`, `VERSION_RE`, `INSTALL_PATH_RE`, `COPY_FILENAME_RE`, the `role`/`applies` enums), checked for `..`, and rejected on control chars. **`safeJoin` lives here** (relocated from the deleted `install-modules.ts`) — the lexical path-containment gate that `install-capabilities.ts`, `diff.ts`, `capability-index.ts`, `layout.ts`, `skills-version.ts`, and `remove.ts` all guard their fs access with, so nothing escapes its base dir (`install-capabilities.ts` adds a symlink-aware backstop at the write sites). Remote fetches (`skills-version.ts`) use `redirect: 'error'`, an 8s timeout, and a 256KB body cap. Preserve these invariants. -**`lib/pharn-config.ts`** reads/writes `pharn.config.json` (`pharnVersion`, `skillsVersion`, `repo`, `commit`, `constitution`, `modules[]`, `installedAt`, plus the v2-only additive fields `stackAnswers` (questionId → value, incl. `"skip"`), `installedSkills[]` (`{skill, from}`), and `vendorSkills[]`). Schema is additive — legacy configs omit the v2 fields. `add` and `update` re-resolve and update it in place; neither touches `CONSTITUTION.md`. +**`lib/pharn-config.ts`** reads/writes `pharn.config.json` (`pharnVersion`, `skillsVersion`, `repo`, `commit`, `installedAt`, and — for an archetype install — `archetypes[]`, `capabilities[]` (`{name, role}`), `layout`, plus the `models`/`seam` blocks). Schema is additive — a legacy config's now-unused `modules[]`/`constitution`/`stackAnswers`/`installedSkills[]` still load (P7). `isArchetypeConfig` (= `Array.isArray(config.capabilities)`) is the deterministic discriminator; **`loadArchetypeConfigOrExit`** is the shared load-or-reject surface for `add`/`update`/`status`/`remove` (a pre-archetype config → `LEGACY_CONFIG_MESSAGE` + exit(1), never a fetch; `list` keeps its own json-aware check so `--json` stderr stays clean). `add`/`update`/`remove` update the config in place; none touches `CONSTITUTION.md`. -**`pharn add` addressing.** `add ` installs a whole methodology module / stack pack (v1 + v2). `add :` (v2 only, e.g. `add orm:prisma`) maps `` → `pharn-skills-`, resolves the wizard option, and installs just that skill: already-installed → no-op; a different sibling of the same category already installed → confirm before installing alongside (appends to `installedSkills`, **never** edits `stackAnswers`); unknown → lists valid addresses. `pharn update` re-resolves `installedSkills` against the new wizard and drops (reporting, never guessing) any `from` path that no longer exists upstream. +**`pharn add` addressing** (`commands/add.ts` + `lib/capability-address.ts`). `add ` or `add :` (e.g. `add a11y`, `add lens:n-plus-one`) installs one capability into an archetype project — a manual override of archetype auto-selection. It clones pharn-oss (SHA-pinned), resolves the arg against `parseCapabilityIndex`, and if it uniquely names a not-yet-installed capability, copies it via `installCapabilityDirs` and **appends** to `capabilities` (never touches `archetypes`). Already-installed → no-op; unknown/ambiguous → lists the valid `role:name` addresses. `pharn update` re-resolves the **recorded archetypes** against the latest index and re-copies (mirrors the legacy update's re-resolve-recorded step). -**`pharn remove` addressing** (`commands/remove.ts`) is the inverse, with the same two forms. `remove :` deletes that one isolated `.claude/skills//` dir and drops its `installedSkills` entry — **no clone, no network** (everything is derivable from `installedSkills` + the filesystem), **never** edits `stackAnswers`/`modules`; not-installed → no-op listing the installed (removable) skills. `remove ` clones once and reads the manifest **from the clone** (`@main` HEAD, not the pinned `commit` — hence a possible orphan if a path was renamed upstream; documented), then **refuses** (before any write/delete) `pharn-core`, an uninstalled module, or one with installed dependents (named). Because modules merge into shared dest dirs, it never `rm -rf`s a dest dir: it `statSync`-branches each `installs` entry (file vs. dir), walks the source to map the **exact** contributed files into `.claude/`, deletes only those, prunes emptied dirs (never one holding a surviving module's files), and **never** touches `CONSTITUTION.md`/`memory-bank/`. No arg → an interactive picker that pre-applies these guards so it only offers genuinely removable items. The shared delete core is `planAndApplyModuleRemoval`; cleanup runs in a `finally` and all `process.exit`/`cancelAndExit` calls happen *after* it (Node skips `finally` on `process.exit`). +**`pharn remove` addressing** (`commands/remove.ts`) is the inverse of `add`. `remove ` / `remove :` (no arg → an interactive picker over the installed capabilities) deletes that one isolated capability dir — addressed at the project's recorded `layout` (flat `pharn-review` / `pharn-pipeline/grillers/`, OR the same under `pharn/`, via `configLayout` + `layoutPaths`) — and drops its `capabilities` entry. **No clone, no network** — everything is derivable from `config.capabilities` + the filesystem, so `remove.ts` imports no repo module at all; `archetypes` is never touched, and `CONSTITUTION.md`/`memory-bank/` are never in a capability dir. Not-installed → benign no-op listing the removable capabilities; a name installed in both roles → hard-fail (ambiguous). `--yes`/`-y` is a no-op (there is no confirm prompt to skip). Every delete path is `safeJoin`-contained. -**`pharn status` (`commands/status.ts`) is strictly read-only** — it never writes, deletes, or overwrites (fixing is `update`/`add`). Two sections: a **version** check (reuses `update`'s `skillsVersion` + per-module `changes` diff; overlaps `list` by design) and a **drift** check. Default clones `@main` once and reuses it for both (the HEAD manifest is the "latest" for version *and* the source for drift); `--no-drift` skips the clone and uses `fetchRemoteManifest` for the version section only; `--strict` exits 1 on any outdated/modified/missing (CI gate, default exit 0). Cleanup runs in a `finally`, and every `process.exit` happens *after* it. The pure (no I/O) engine is **`lib/diff.ts` → `diffInstalled`**: it mirrors `installModule`/`installSkills` to derive the **expected** file set (`statSync`-branching each `installs` entry — file vs. dir — and walking dirs; skills → `skills//`), then `sha256`-compares each against `.claude/`, returning `{modified, missing, okCount}`. It reuses `install-modules.ts`'s exported `safeJoin` to guard every read. **It excludes the two user-owned surfaces — `CONSTITUTION.md` and `memory-bank/` — anchored at the `.claude/` root** (so the PHARN-owned `templates/memory-bank/` sources are still diffed); this exclusion is a correctness requirement. Drift is derived live from the clone (no stored hashes), always against `@main`, never the pinned `commit`. +**`pharn status` (`commands/status.ts`) is strictly read-only** — it never writes, deletes, or overwrites (fixing is `update`/`add`). Two sections: a **version** check (installed `skillsVersion` vs upstream `SKILLS_VERSION`, plus an archetype + capability-count summary) and a **drift** check. Default clones `@main` once and reuses it for both; `--no-drift` skips the clone and uses `fetchRemoteSkillsVersion` for the version section only; `--strict` exits 1 on any outdated/modified/missing (CI gate, default exit 0). Cleanup runs in a `finally`, and every `process.exit` happens *after* it. The pure (no I/O) engine is **`lib/diff.ts` → `diffInstalledCapabilities`**: it mirrors `installCapabilities` to derive the **expected** file set — the selected capability dirs + the fixed product surfaces, at the recorded `layout` — then `sha256`-compares each against the project root, returning `{modified, missing, okCount}`. Every read is `safeJoin`-guarded (from `lib/validate.ts`). `.claude/settings.json` is user-owned (preserved at install) and excluded; the copied-verbatim trusted docs, hooks, contracts, and floor checkers ARE compared. Drift is derived live from the clone (no stored hashes), always against `@main`, never the pinned `commit`. ## Testing diff --git a/docs/README.md b/docs/README.md index b7ee471..6b4a151 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # PHARN CLI documentation -`pharn-cli` installs [PHARN](https://github.com/pharn-dev/pharn-oss) — an audit-grade methodology for Claude Code — into your project. Run `pharn init` to pick which modules and stack pack you want; the CLI fetches them from `pharn-dev/pharn-oss`, copies them into `.claude/`, materializes your constitution + memory bank, and writes `pharn.config.json`. +`pharn-cli` installs [PHARN](https://github.com/pharn-dev/pharn-oss) — an audit-grade methodology for Claude Code — into your project. Run `pharn init` to detect your project's archetype(s) and install the applicable PHARN **capabilities** (grillers + lenses) from `pharn-dev/pharn-oss` into the mirrored layout (`.claude/` + `pharn/`), copying the canonical constitution and writing `pharn.config.json`. ## Getting started @@ -8,11 +8,11 @@ ## Commands -- [init](commands/init.md) — interactive setup wizard -- [add](commands/add.md) — add a module to an existing project -- [remove](commands/remove.md) — remove a module or skill from an existing project -- [update](commands/update.md) — update installed modules to the latest skills version -- [list](commands/list.md) — list installed and available modules/skills +- [init](commands/init.md) — detect archetypes and install capabilities (the default command) +- [add](commands/add.md) — add a single capability to an existing project +- [remove](commands/remove.md) — remove an installed capability from an existing project +- [update](commands/update.md) — re-fetch installed capabilities at the latest skills version +- [list](commands/list.md) — list installed archetypes + capabilities - [status](commands/status.md) — read-only version + local-drift audit ## Reference diff --git a/docs/commands/add.md b/docs/commands/add.md index 1847328..847322c 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -1,43 +1,35 @@ # pharn add -Add a PHARN module to an existing project (one with a `pharn.config.json`). +Add a single PHARN **capability** — a griller or a lens — to an existing project (one with a +`pharn.config.json`). This is a manual override of the archetype auto-selection [`init`](init.md) +performs. ```bash -pharn add # whole methodology module / stack pack -pharn add : # a single technology skill (schemaVersion 2) -pharn add # interactive module picker +pharn add # e.g. pharn add a11y +pharn add : # e.g. pharn add lens:n-plus-one (role disambiguates) ``` ## Behavior -1. Reads `pharn.config.json`. If none exists, exits with a hint to run `pharn init` first. -2. Fetches `manifest.json` to list available modules. -3. Resolves the **union** of already-installed modules plus the new one, so any dependencies of the new module are pulled in too. -4. **Checks prerequisites** for the newly-introduced modules: any package a module declares (in the manifest's `prerequisites`) must already be in your `package.json`. This is the same gate as [`init`](init.md) — e.g. `pharn add pharn-stack-nextjs` requires `next` — so a pack can't bypass it by being added after init. Missing prerequisites print their `reason` and exit **1** (see [Troubleshooting](../troubleshooting.md#stack-pack-prerequisite-missing)). -5. Clones `pharn-dev/pharn-oss`, copies the resolved modules' `installs` into `.claude/`, and updates `pharn.config.json` (`modules`, `skillsVersion`, `commit`). +1. Reads `pharn.config.json`. If none exists — or it is a pre-archetype (module) config — it exits with + a hint to run `pharn init` first. +2. Clones `pharn-dev/pharn-oss` (SHA-pinned) and reads the capability index from the clone. +3. Resolves your argument against that index. If it uniquely names a capability you don't already have, + it copies that capability's directory into the mirrored layout and **appends** it to `capabilities` + in `pharn.config.json` (also refreshing `skillsVersion` and `commit`). -`CONSTITUTION.md` is **not** touched — `add` never changes your constitution. +`CONSTITUTION.md` is **not** touched — `add` never changes your constitution. Your detected `archetypes` +are left unchanged; `add` only appends to `capabilities`. -## Module argument +## The capability argument -`` must be a full module name (e.g. `pharn-review`, `pharn-stack-nextjs`). If you pass an unknown or already-installed name, the CLI tells you and falls back to the interactive picker, which lists only modules you don't already have. +`` is a capability's directory name (e.g. `a11y`, `security`, `n-plus-one`). Use the +`:` form (`griller:` or `lens:`) when the same name exists in both roles, or to be explicit. -## Category skill argument (schemaVersion 2) - -`:` installs one technology skill from a category module — for example: - -```bash -pharn add orm:prisma # installs pharn-skills-orm/skills/prisma -pharn add auth:clerk # installs pharn-skills-auth/skills/clerk -``` - -`` maps to `pharn-skills-`; `` must be one of that category's wizard options. Behavior: - -- **Already installed** → no-op with a message. -- **Conflicting sibling** (e.g. `orm:drizzle` already installed, you add `orm:prisma`) → the CLI asks before installing the second one alongside it. On yes, it appends to `installedSkills` but **does not** change your recorded `stackAnswers`. -- **Unknown category or skill** → the CLI lists the valid `category:skill` values. - -This form requires a `schemaVersion 2` manifest (skills v0.69+). Against an older manifest, use a plain module name. +- **Already installed** → a no-op with a message. +- **Unknown name** → the CLI lists every valid `role:name` address. +- **Ambiguous** (a name in both roles, given without a role) → the CLI asks you to disambiguate with + `griller:` / `lens:`. ## Related diff --git a/docs/commands/list.md b/docs/commands/list.md index 9e53835..d8a10f3 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -1,6 +1,8 @@ # pharn list -Show what PHARN modules and skills are installed in this project (one with a `pharn.config.json`) and what is still available to add. **Read-only** — it never writes files and never clones the repo. +Show which PHARN **archetypes** and **capabilities** are installed in this project (one with a +`pharn.config.json`). **Read-only** — it never writes files, never clones the repo, and never fetches +over the network. ```bash pharn list # human-readable inventory @@ -9,43 +11,35 @@ pharn list --json # machine-readable JSON (for scripts / CI) ## Behavior -1. Reads `pharn.config.json`. If none exists, exits with a hint to run `pharn init` first. -2. Fetches `manifest.json` to learn the latest versions and the available catalog. (No clone — only the same lightweight fetch `pharn update` uses.) -3. Prints **INSTALLED**: - - your skills version, flagged `→ vX (update available, run pharn update)` when the manifest is newer; - - each installed module with its version, flagged `→ vX` when the manifest has a newer one; - - any individually installed technology skills (schemaVersion 2). -4. Prints **AVAILABLE TO ADD**: - - optional modules and stack packs you don't have yet — the same set `pharn add` offers; - - on a `schemaVersion 2` manifest, every `category:skill` you haven't installed. +1. Reads `pharn.config.json`. If none exists — or it is a pre-archetype (module) config — it exits with + a hint to run `pharn init` first. +2. Prints **INSTALLED (archetype)**, entirely from the config and offline: + - your skills version; + - the detected `archetypes`; + - the installed `capabilities`, grouped by role (grillers, lenses). - A group with nothing left shows `(all installed)`. - -Nothing is ever written, and the repo is never cloned. +Nothing is ever written, fetched, or cloned. To see what more you could add, use `pharn add +`; for version currency and drift, use [`pharn status`](status.md). ## JSON output -`pharn list --json` prints a single JSON object and nothing else (no spinner, intro, or outro), suitable for scripting: +`pharn list --json` prints a single JSON object and nothing else (no spinner, intro, or outro), suitable +for scripting: ```json { - "skillsVersion": "0.69.0", - "latestSkillsVersion": "0.70.0", - "installed": { - "modules": [{ "name": "pharn-core", "version": "0.1.0", "latest": "0.1.0" }], - "skills": [{ "skill": "prisma", "from": "pharn-skills-orm/skills/prisma" }] - }, - "available": { - "modules": [{ "name": "pharn-review", "version": "0.4.0", "description": "…" }], - "skills": [{ "category": "orm", "skill": "drizzle", "install": "pharn-skills-orm/skills/drizzle" }] - } + "mode": "archetype", + "skillsVersion": "1.0.0", + "archetypes": ["ssr"], + "capabilities": [{ "name": "a11y", "role": "griller" }] } ``` -Diagnostics (such as a missing config or a failed fetch) go to **stderr**, and the exit code is non-zero on failure — so stdout always parses cleanly. +Diagnostics (a missing config, or a pre-archetype config that is no longer supported) go to **stderr**, +and the exit code is non-zero on failure — so stdout always parses cleanly. ## Related - [add](add.md) -- [update](update.md) +- [status](status.md) - [pharn.config.json](../reference/pharn-config.md) diff --git a/docs/commands/remove.md b/docs/commands/remove.md index 199156a..9ba7ad2 100644 --- a/docs/commands/remove.md +++ b/docs/commands/remove.md @@ -1,52 +1,41 @@ # pharn remove -Remove a PHARN module or a single technology skill from an existing project (one with a `pharn.config.json`). The inverse of [`add`](add.md): it deletes exactly the files the module or skill contributed and updates `pharn.config.json` to match. +Remove a single installed **capability** — a griller or a lens — from an existing project (one with a +`pharn.config.json`). The inverse of [`add`](add.md): it deletes exactly that capability's directory and +drops its entry from `pharn.config.json`. ```bash -pharn remove # a whole methodology module / stack pack -pharn remove : # a single technology skill (schemaVersion 2) -pharn remove # interactive picker -pharn remove --yes # skip the confirmation prompt +pharn remove # e.g. pharn remove a11y +pharn remove : # e.g. pharn remove lens:n-plus-one +pharn remove # interactive picker over installed capabilities ``` `rm` is accepted as an alias for `remove`. ## Behavior -1. Reads `pharn.config.json`. If none exists, exits with a hint to run `pharn init` first. -2. An argument containing `:` removes a **skill** (§A); any other argument removes a **module** (§B); no argument opens the **interactive picker**, which lists only items that are genuinely removable (so you never pick something the command would refuse). +1. Reads `pharn.config.json`. If none exists — or it is a pre-archetype (module) config — it exits with + a hint to run `pharn init` first. +2. With no argument, opens an interactive picker listing the capabilities you have installed. With an + argument, resolves it to one installed capability. +3. Deletes that capability's isolated directory and drops its entry from `capabilities`. -`CONSTITUTION.md` and everything under `memory-bank/` are **never** deleted — they are user-owned and materialized only at `init`. +Removal needs **no network and no clone** — everything is derivable from `capabilities` plus your +filesystem. `CONSTITUTION.md`, `memory-bank/`, and your detected `archetypes` are **never** touched. -## Removing a skill (`:`) +## The capability argument -Each skill lives in its own isolated `.claude/skills//` directory, so removal is precise and needs **no network and no clone** — the CLI works entirely from `installedSkills` plus your filesystem. +Each capability lives in its own directory, addressed at your project's recorded layout — flat +(`pharn-review/` for a lens, `pharn-pipeline/grillers/` for a griller) or the same paths +under `pharn/`. Removal is therefore precise; siblings are never touched. -```bash -pharn remove orm:prisma # deletes .claude/skills/prisma/ -pharn remove auth:clerk # deletes .claude/skills/clerk/ -``` - -- Deletes the skill's directory and drops its entry from `installedSkills`. -- **Does not** touch `stackAnswers`, `modules`, `skillsVersion`, or `commit` — your recorded wizard answer stays authoritative. -- **Not installed** → a no-op (nothing is written). For an unrecognized address, the CLI lists the skills you actually have installed as the valid values. +- **Not installed** → a no-op (nothing is written); the CLI lists the capabilities you actually have as + the valid values. +- **Ambiguous** (a name installed in both roles, given without a role) → the CLI asks you to + disambiguate with `griller:` / `lens:`. - Already-deleted directory → treated as done (idempotent). -## Removing a module (``) - -Modules share destination directories (`pharn-core`, `pharn-pipeline`, `pharn-review`, and `pharn-audits` all merge into `commands/`, `skills/`, `hooks/`, …), so `remove` never wipes a directory. It clones `pharn-dev/pharn-oss` once, reads the module's `installs` from the clone, computes the **exact** set of files that module contributed, and deletes only those — then prunes any directories left empty (never one that still holds another module's files). - -Refusals (checked **before** anything is deleted or written — no partial removals): - -- **`pharn-core`** is required by every other module and cannot be removed. -- A module that **is not installed** is reported and nothing happens. -- A module with **installed dependents** is refused, naming them — remove those first. (There is no automatic cascade.) This also blocks removing a stack pack's base, e.g. `pharn-stack-react` while `pharn-stack-nextjs` is installed. - -Unless `--yes` / `-y` is given, the CLI confirms before deleting. `modules` in `pharn.config.json` is rewritten to the resolved survivor set; `skillsVersion`, `commit`, `stackAnswers`, and `installedSkills` are left unchanged. - -### Orphan caveat - -The contributed file set is computed from the repository's `main` branch, **not** the pinned `commit` recorded at install. If a file was renamed or removed upstream since you installed, an orphaned copy may remain in `.claude/`. This is acceptable for now; `pharn status` is planned to surface such orphans (see the [roadmap](../roadmap.md)). +`--yes` / `-y` is accepted but has no effect — capability removal has no confirmation prompt to skip. ## Related diff --git a/docs/commands/status.md b/docs/commands/status.md index 3e2e610..1b15428 100644 --- a/docs/commands/status.md +++ b/docs/commands/status.md @@ -1,6 +1,7 @@ # pharn status -Read-only audit of your install: is it on the latest version, and have any PHARN-owned files drifted from upstream? +Read-only audit of your install: is it on the latest version, and have any PHARN-owned files drifted +from upstream? ```bash pharn status @@ -8,33 +9,45 @@ pharn status --no-drift # version check only (skips the clone) pharn status --strict # exit 1 if outdated, modified, or missing (for CI) ``` -`status` is the read side of [`update`](update.md): it surfaces the same state `update` would overwrite, but **never writes, deletes, or overwrites anything**. It is a report, not a guard. +`status` is the read side of [`update`](update.md): it surfaces the same state `update` would overwrite, +but **never writes, deletes, or overwrites anything**. It is a report, not a guard. ## Behavior -1. Reads `pharn.config.json`. If none exists, exits with a hint to run `pharn init` first. -2. By default, clones `pharn-dev/pharn-oss@main` once and reuses it for both sections below (the temporary clone is always cleaned up). `--no-drift` skips the clone and uses the lightweight `manifest.json` fetch for the version section only. -3. **Version** — compares your `skillsVersion` and per-module versions against the manifest, then reports either "up to date" or that an update is available (run `pharn update`). This overlaps with part of [`pharn list`](list.md) by design. -4. **Drift** — derives the set of files each installed module and skill is expected to contribute (mirroring how `init` / `add` / `update` install them), then byte-compares each against your `.claude/`: - - **Locally modified (PHARN-owned)** — files present but whose contents differ. `pharn update` will overwrite these. - - **Missing (expected but absent)** — files an installed module/skill expects that aren't on disk. Re-run `pharn update` (or `pharn add`) to restore them. +1. Reads `pharn.config.json`. If none exists — or it is a pre-archetype (module) config — it exits with + a hint to run `pharn init` first. +2. By default, clones `pharn-dev/pharn-oss@main` once and reuses it for both sections below (the + temporary clone is always cleaned up). `--no-drift` skips the clone and uses the lightweight + `SKILLS_VERSION` fetch for the version section only. +3. **Version** — compares your `skillsVersion` against the upstream `SKILLS_VERSION` and summarizes your + detected `archetypes` and installed capability count: either "up to date" or an update is available + (run `pharn update`). +4. **Drift** — derives the set of files your installed capabilities and the fixed product surfaces are + expected to contribute (mirroring how `init` / `add` / `update` install them, at your recorded + layout), then byte-compares each against your project: + - **Locally modified (PHARN-owned)** — files present but whose contents differ. `pharn update` will + overwrite these. + - **Missing (expected but absent)** — expected files that aren't on disk. Re-run `pharn update` (or + `pharn add`) to restore them. - If neither, reports **No drift**. -The comparison is always against `pharn-dev/pharn-oss@main` (the same ref the rest of the CLI installs from), not the `commit` pinned in your config. +The comparison is always against `pharn-dev/pharn-oss@main` (the same ref the CLI installs from), not the +`commit` pinned in your config. ## What is intentionally excluded -`.claude/CONSTITUTION.md` and everything under `.claude/memory-bank/` are **never** flagged. They are materialized once at `init` and are meant to be hand-edited — `update` never touches them, so reporting local edits to them would be noise. - -This exclusion is anchored at the `.claude/` root. The pristine template **sources** under `.claude/templates/` (including `templates/memory-bank/` and `templates/constitution/`) are PHARN-owned and **are** diffed — only the materialized working copies at the root are skipped. +`.claude/settings.json` is **never** flagged — it is your Claude Code configuration, which the install +preserves (never overwrites). The copied-verbatim trusted docs, `.cjs` hooks, `pharn-contracts/`, and +`.dev/floor/` checkers **are** compared, so an edit to any of those surfaces shows up as drift. ## Exit code -Exits `0` by default, even when drift or an available update is found (it is a report). Pass `--strict` to exit `1` whenever anything is outdated, modified, or missing — useful as a CI gate. +Exits `0` by default, even when drift or an available update is found (it is a report). Pass `--strict` +to exit `1` whenever anything is outdated, modified, or missing — useful as a CI gate. ## Related - [update](update.md) — apply the fixes `status` reports (overwrite drift, restore missing) -- [list](list.md) — read-only inventory of installed vs. available modules/skills -- [add](add.md) — install a module or a single `category:skill` -- [pharn.config.json](../reference/pharn-config.md) — `skillsVersion`, `modules`, `installedSkills` +- [list](list.md) — read-only inventory of installed archetypes + capabilities +- [add](add.md) — install a capability +- [pharn.config.json](../reference/pharn-config.md) — `skillsVersion`, `archetypes`, `capabilities` diff --git a/docs/commands/update.md b/docs/commands/update.md index 89ce3f4..aaec3d8 100644 --- a/docs/commands/update.md +++ b/docs/commands/update.md @@ -1,6 +1,6 @@ # pharn update -Update the modules already installed in your project to the latest skills version. +Re-fetch the capabilities installed in your project at the latest skills version. ```bash pharn update @@ -8,17 +8,26 @@ pharn update ## Behavior -1. Reads `pharn.config.json`. If none exists, exits with a hint to run `pharn init` first. -2. Fetches the latest `manifest.json` and compares its `skillsVersion` and per-module versions against what's pinned in your config. -3. If nothing changed, reports "Already up to date" and exits. -4. Otherwise shows a diff (skills version + per-module version changes) with a pointer to `CHANGELOG.md`, and asks for confirmation. -5. On confirm, re-fetches your installed modules at the latest version, copies them into `.claude/`, and updates `pharn.config.json`. +1. Reads `pharn.config.json`. If none exists — or it is a pre-archetype (module) config — it exits with + a hint to run `pharn init` first. +2. Fetches the latest `SKILLS_VERSION` from `pharn-dev/pharn-oss@main` (a lightweight check, no clone) + and compares it to your recorded `skillsVersion`. +3. If they match, reports "Already up to date" and exits. +4. Otherwise shows the version bump with a pointer to `CHANGELOG.md`, and asks for confirmation. +5. On confirm, clones the repo (SHA-pinned), **re-resolves your recorded `archetypes`** against the + latest capability index, re-copies the resulting capabilities into the mirrored layout, and updates + `pharn.config.json` (`skillsVersion`, `commit`, `capabilities`). -For `schemaVersion 2` installs, `update` also re-resolves your `installedSkills` against the new manifest. A recorded skill whose source path no longer exists upstream (moved or renamed) is **reported and skipped** — never guessed at a new location — and dropped from `installedSkills`. Your `stackAnswers` are left untouched. +Because `update` re-resolves your archetypes against the latest index, a capability upstream added for +one of your archetypes since your last install is picked up, and one it removed is dropped — your +`archetypes` list itself is never changed. -`CONSTITUTION.md` is left untouched — it is human-edited only. Review breaking changes in [pharn-oss `CHANGELOG.md`](https://github.com/pharn-dev/pharn-oss/blob/main/CHANGELOG.md) before updating. +`CONSTITUTION.md` is left untouched — it is human-edited only. Review breaking changes in +[pharn-oss `CHANGELOG.md`](https://github.com/pharn-dev/pharn-oss/blob/main/CHANGELOG.md) before +updating. ## Related -- [pharn.config.json](../reference/pharn-config.md) — `skillsVersion` and `modules` fields +- [pharn.config.json](../reference/pharn-config.md) — `skillsVersion`, `archetypes`, `capabilities` - [add](add.md) +- [status](status.md) diff --git a/docs/contributing.md b/docs/contributing.md index fc6b5d0..4890a23 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -60,44 +60,45 @@ A separate CodeQL workflow analyzes the JavaScript/TypeScript surface on PRs, pu pharn-cli/ src/ index.ts CLI entry, command routing - commands/ init, add, update - steps/ wizard steps (prereqs, fresh-check, mode-select, wizard-questions, module/stackpack/constitution select, summary, install) - lib/ manifest, wizard, repo, installer, install-modules, pharn-config, validate, constants, banner, confirm, format - types.ts Manifest / ModuleManifest / WizardConfig / PharnConfig + commands/ init, add, remove, update, list, status + steps/ init stages (prereqs, fresh-check, archetype-summary, install-archetype) + lib/ install-capabilities, capability-index, resolve-capabilities, detect-archetype, layout, repo, diff, skills-version, pharn-config, validate, constants, banner, confirm, format + types.ts Archetype / CapabilityEntry / Selection / PharnConfig tests/ vitest specs docs/ user + maintainer documentation scripts/install-local.mjs ``` -See [`CLAUDE.md`](../CLAUDE.md) for the architecture in depth (the init step pipeline, dependency resolution, and the security-sensitive libs). +See [`CLAUDE.md`](../CLAUDE.md) for the architecture in depth (the archetype install flow, capability resolution, and the security-sensitive libs). ## Security-sensitive files -`lib/validate.ts`, `lib/manifest.ts`, and `lib/install-modules.ts` handle all remote input (manifest, module names, install/skill paths, wizard values). Preserve their invariants when editing: +`lib/validate.ts` and `lib/install-capabilities.ts` handle all untrusted remote input (capability names, install paths, frontmatter). Preserve their invariants when editing: -- Strict regex allowlists (`MODULE_NAME_RE`, `VERSION_RE`, `INSTALL_PATH_RE`, `WIZARD_VALUE_RE`), `..` rejection, and control-char rejection. -- Every copy — modules and skills — is guarded by `safeJoin` so nothing escapes its base directory. -- Remote fetches use `redirect: 'error'`, an 8s timeout, and a 256KB body cap. -- The manifest `schemaVersion` must be exactly `1` or `2` — anything else hard-fails by design so old CLIs don't guess at a new schema. +- Strict regex/enum allowlists (`CAPABILITY_NAME_RE`, `VERSION_RE`, `INSTALL_PATH_RE`, `COPY_FILENAME_RE`, the `role`/`applies` enums), `..` rejection, and control-char rejection. +- `safeJoin` (in `lib/validate.ts`) guards every read/copy so nothing escapes its base directory; `install-capabilities.ts` adds a symlink-aware backstop at the write sites and rejects symlinked sources. +- Remote fetches (`lib/skills-version.ts`) use `redirect: 'error'`, an 8s timeout, and a 256KB body cap. ## Test map | Test file | Behavior covered | | --------- | ---------------- | -| `manifest.test.ts` / `manifest-v2.test.ts` | v1 parse/validate of manifest + module.json, dependency resolution, exclusivity, categorization; `parseManifest` schemaVersion routing + `wizard` block validation | -| `wizard.test.ts` | `matchCondition` and the pure rule engine / answer resolver (`lib/wizard.ts`) | -| `wizard-questions.test.ts` | `runWizardQuestions` Custom-mode rendering (hide / relabel / coming-soon / warn rules) | -| `mode-select.test.ts` | Default vs Custom stack mode select | -| `install-modules.test.ts` / `install-skills.test.ts` | Copy `installs` maps, materialize memory-bank + constitution; selective `installSkills`; path-escape guards | -| `installer.test.ts` / `install.test.ts` | `fetchAndInstall` shared core; `runInstall` step | -| `init.test.ts` / `init-v2.test.ts` | v1 and v2 init pipelines end to end | -| `add.test.ts` / `update.test.ts` | `runAdd` (module + `category:skill`) and `runUpdate` re-resolution | -| `validate.test.ts` | `assertSafeString` allowlists, `..` and control-char rejection | -| `pharn-config.test.ts` | Read/write/round-trip `pharn.config.json`, incl. v2 additive fields | -| `prereqs.test.ts` | Next.js and git checks | -| `fresh-check.test.ts` | Commit counts, custom file heuristic | -| `confirm.test.ts` | Cancel and warn helpers | -| `repo.test.ts` / `banner.test.ts` / `format.test.ts` | degit clone wrapper; banner; format helpers | +| `detect-archetype.test.ts` / `archetype.test.ts` | Archetype detection from `package.json` names + file-tree signals | +| `capability-index.test.ts` | Parse/validate the untrusted capability index (frontmatter → typed entries) | +| `resolve-capabilities.test.ts` | Select capabilities by `applies` against detected archetypes | +| `install-capabilities.test.ts` | Copy capability dirs + fixed product surfaces; symlink + path-escape guards | +| `init.test.ts` / `init-archetype.test.ts` | The archetype init flow end to end | +| `add.test.ts` / `update.test.ts` | `runAdd` (capability add) and `runUpdate` (archetype re-resolve) | +| `remove.test.ts` | `runRemove` capability deletion (flat + `pharn/` layouts) | +| `list.test.ts` / `status.test.ts` | Read-only inventory + version/drift audit | +| `diff.test.ts` | `diffInstalledCapabilities` expected-set derivation + byte compare | +| `layout.test.ts` | `detectLayout` / `configLayout` / `layoutPaths` | +| `validate.test.ts` | Allowlists, `..`/control-char rejection, and `safeJoin` containment | +| `pharn-config.test.ts` | Round-trip `pharn.config.json`; `loadArchetypeConfigOrExit` legacy reject | +| `skills-version.test.ts` | Read/fetch + validate `SKILLS_VERSION` | +| `prereqs.test.ts` / `fresh-check.test.ts` | git check; commit counts + custom-file heuristic | +| `model-routing.test.ts` / `seam-config.test.ts` | `models` / `seam` config validation | +| `confirm.test.ts` / `repo.test.ts` / `banner.test.ts` / `format.test.ts` | helpers; degit clone wrapper; banner; format | When changing behavior, add or update tests before docs. @@ -108,7 +109,7 @@ Keep [`docs/`](./README.md) aligned with code when you change: | Code change | Update docs | | ----------- | ----------- | | Install output or config shape | [reference/pharn-config.md](./reference/pharn-config.md) | -| Wizard steps or resolution | [commands/init.md](./commands/init.md), [getting-started.md](./getting-started.md) | +| Archetype detection or capability resolution | [commands/init.md](./commands/init.md), [getting-started.md](./getting-started.md) | | New validation or warning | [troubleshooting.md](./troubleshooting.md) | | New command or behavior | `commands/*.md`, [roadmap.md](./roadmap.md) | | CLI `--help` text | [commands/init.md](./commands/init.md), [README.md](../README.md) | diff --git a/docs/getting-started.md b/docs/getting-started.md index 678f427..12419f4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,10 +6,11 @@ PHARN does not scaffold your app. You create your project (e.g. with `create-nex | Requirement | How PHARN checks | When | | ----------- | ---------------- | ---- | -| Git | A `.git` directory exists in the project root | Always — checked up front, before the wizard | -| Stack-pack packages | Every package a selected stack pack declares (via the manifest's `prerequisites`) is in `package.json` `dependencies` or `devDependencies` | Only when you pick a pack that declares one — e.g. `pharn-stack-nextjs` requires `next` | +| Git | A `.git` directory exists in the project root | Always — checked up front, before detection | -`.git` is required for every install. Package prerequisites are **conditional on your stack-pack choice**: selecting **None** (or a pack with no prerequisites) installs without any framework package. If a check fails, the CLI prints the stack pack's own fix instructions and exits. See [Troubleshooting](troubleshooting.md). +`.git` is required for every install. `pharn init` then detects your project's archetype(s) from +`package.json` dependency names plus a bounded file-tree scan — there is no stack-pack selection and no +package prerequisite to satisfy. See [Troubleshooting](troubleshooting.md). PHARN works best on **fresh** projects. The wizard may warn (framework-neutral, git-based) when: @@ -41,46 +42,37 @@ npx pharn init `pharn` with no subcommand runs `init` (same as `pharn init`). -## The wizard +## The install flow -The wizard adapts to the manifest the CLI fetches. Newer manifests (`schemaVersion 2`) drive the per-tech questions from the manifest itself; older pinned versions (`schemaVersion 1`) use the original three-question flow. The CLI picks the right flow automatically. +`pharn init` is archetype-driven — there is no questionnaire: -### Stack mode (schemaVersion 2) +1. **Detect archetypes.** The CLI merges your `package.json` dependency names with a bounded, + symlink-safe file-tree scan (names only, never file bodies) into an archetype set — `ssr`, `backend`, + `spa`, `lib` (a project may match several, e.g. Next + Express → `ssr` + `backend`). It shows what it + detected. +2. **Fetch + resolve.** It degit-clones `pharn-dev/pharn-oss` at a pinned SHA and selects the + capabilities whose `applies` is `universal` or intersects your detected archetypes — skipping the rest + with a reason. +3. **Confirm + install.** After a summary (selected + skipped), it copies the selected capabilities plus + the fixed product surfaces (commands, hooks, contracts, floor checkers, and the canonical + constitution) into the mirrored layout and writes `pharn.config.json`. -First the CLI reads your `package.json` and pre-fills the wizard from the manifest's detection metadata — the stack pack (when a pack's `prerequisites` are all present, e.g. `next` → `pharn-stack-nextjs`) and each technology answer (when an option's `detect` package is present, e.g. `drizzle-orm` → Drizzle). What it found is shown in a note; you can override anything, and any question with no match keeps its normal default (the recommended value in Default mode, or the option marked default in Custom mode). - -Then you choose a mode: - -- **Default** — takes the recommended stack from the manifest, overlaid with any detected answers (detection wins; undetected questions keep the recommended default), and asks **no** per-technology questions. -- **Custom** — walks each section (database, ORM, auth, email, payments, …) with detected answers pre-selected. Options that don't apply are hidden based on earlier answers, some options are relabeled for context, and anything marked **(coming soon)** is shown but not selectable. - -Then, regardless of mode, the wizard asks: - -1. **Methodology modules** — a multiselect of optional modules (`pharn-pipeline`, `pharn-review`, `pharn-audits`). `pharn-core` is always included. -2. **Stack pack** — a single choice (`pharn-stack-nextjs`, or none), pre-selected from what was detected. The stack pack pulls in its React base automatically. -3. **Privacy posture** — picks your constitution variant (`gdpr-strict`, `standard`, or `minimal`). -4. **Vendor skills** — for technologies whose skill is published by the vendor (e.g. Supabase), the wizard records your consent to use it. On install, any consented skill with a known source is fetched automatically from the vendor's registry into `.claude/skills/`; a vendor with no known source yet is shown as **(manual install)** and recorded in `pharn.config.json` for you to install by hand. A vendor fetch failure is non-fatal — the rest of the install still completes. - -For each answered technology, the CLI copies only the matching skill folder into `.claude/skills//` — never the sibling options you didn't pick. - -### Three questions (schemaVersion 1) - -Against an older pinned manifest, `pharn init` asks just the three module/stack-pack/posture questions above. - -Dependencies are resolved from the repo's `manifest.json`, so selecting a stack pack or any module also installs whatever it depends on. +To add a capability the detection didn't select — or remove one it did — use +[`pharn add`](commands/add.md) / [`pharn remove`](commands/remove.md) afterward. ## What you get -After a successful install, your project's `.claude/` contains the selected modules merged together: +After a successful install, your project contains the selected capabilities plus the fixed product +surfaces: | Artifact | Description | | -------- | ----------- | -| `commands/`, `skills/`, `rules/`, `hooks/` | The slash commands and logic for your chosen modules | -| `skills//` | The per-technology skills selected in the wizard (schemaVersion 2) | -| `ai_docs/`, `templates/`, … | Stack-pack reference docs and templates | -| `memory-bank/` | Markdown files that persist project context across sessions | -| `CONSTITUTION.md` | Your chosen constitution variant | -| `pharn.config.json` | `skillsVersion`, commit SHA, installed modules, constitution variant, and (schemaVersion 2) your stack answers + installed skills | +| `pharn-pipeline/grillers//`, `pharn-review//` | The installed grillers + lenses (flat layout; or the same under `pharn/`) | +| `.claude/commands/` | The `pharn-*` product slash commands | +| `.claude/hooks/` | The deterministic `.cjs` floor hooks | +| `pharn-contracts/`, `.dev/floor/` | Inter-layer schemas + the floor checkers the commands invoke | +| `CONSTITUTION.md` | The canonical PHARN constitution, copied verbatim | +| `pharn.config.json` | `skillsVersion`, commit SHA, detected archetypes, installed capabilities, and the layout | See [pharn.config.json](reference/pharn-config.md) for the exact schema. @@ -94,5 +86,5 @@ The day-to-day loop: `/pharn-plan → /pharn-grill → /pharn-build → /pharn-r ## Next steps - Review what was written: [pharn.config.json](reference/pharn-config.md) -- Add a module later: [add command](commands/add.md) +- Add a capability later: [add command](commands/add.md) - Refresh to the latest skills: [update command](commands/update.md) diff --git a/docs/reference/pharn-config.md b/docs/reference/pharn-config.md index aa515d4..4181e42 100644 --- a/docs/reference/pharn-config.md +++ b/docs/reference/pharn-config.md @@ -1,77 +1,71 @@ # pharn.config.json -Written to the project root on successful `pharn init` install, and updated by `pharn add` / `pharn update`. Source: [`pharn-config.ts`](../../src/lib/pharn-config.ts) and [`install.ts`](../../src/steps/install.ts). - -PHARN skills in your project read this file at runtime (e.g. to discover the installed module set or pinned commit). - -## Top-level fields - -| Field | Type | Description | -| ----- | ---- | ----------- | -| `pharnVersion` | string | Version of the PHARN CLI that ran the install | -| `skillsVersion` | string | `skillsVersion` from the repo's `manifest.json` at the installed commit | -| `repo` | string | Source repo (`pharn-dev/pharn-oss`) | -| `commit` | string \| null | Pinned commit SHA of the install; `null` if the GitHub API was unavailable | -| `constitution` | string | Chosen variant: `gdpr-strict`, `standard`, or `minimal` | -| `isMultiTenant` | boolean | Whether the project is a multi-tenant SaaS. Written on every fresh install (absent on legacy installs, where it reads as `true`). When `false`, **Principle 2 (Multi-Tenant Isolation)** was stripped from the installed `CONSTITUTION.md` | -| `modules` | array | Installed modules (resolved, incl. dependencies), each `{ name, version }` | -| `installedAt` | string | ISO timestamp of the install / last update | -| `stackAnswers` | object | _schemaVersion 2 only._ Wizard answers, `questionId → value` (including `"skip"`) | -| `installedSkills` | array | _schemaVersion 2 only._ Per-technology skills copied into `.claude/skills/`, each `{ skill, from }` (`from` is the repo-relative source path) | - -The two `schemaVersion 2` fields are **additive** — installs against an older (`schemaVersion 1`) manifest omit them entirely, and existing configs stay valid. +Written to the project root on a successful `pharn init`, and updated by `pharn add` / `pharn update`. +Source: [`pharn-config.ts`](../../src/lib/pharn-config.ts) and +[`install-archetype.ts`](../../src/steps/install-archetype.ts). + +PHARN skills in your project read this file at runtime (e.g. to discover the installed +archetypes/capabilities and the pinned commit). + +## Top-level fields (archetype install) + +| Field | Type | Description | +| --------------- | -------------- | ------------------------------------------------------------------------------ | +| `pharnVersion` | string | Version of the PHARN CLI that ran the install | +| `skillsVersion` | string | The repo's `SKILLS_VERSION` at the installed commit | +| `repo` | string | Source repo (`pharn-dev/pharn-oss`) | +| `commit` | string \| null | Pinned commit SHA of the install; `null` if the SHA was unavailable | +| `installedAt` | string | ISO timestamp of the install / last update | +| `archetypes` | array | Detected project archetypes (`ssr` / `backend` / `spa` / `lib`) | +| `capabilities` | array | Installed capabilities, each `{ name, role }` (`role` is `griller` or `lens`) | +| `layout` | string | Install layout mirrored from the clone: `flat` or `pharn` (absent → `flat`) | +| `modules` | array | Always `[]` for an archetype install (the install unit is capabilities) | +| `models` | object | Per-stage model routing ([`model-routing.ts`](../../src/lib/model-routing.ts)) | +| `seam` | object | Seam-resolution policy ([`seam-config.ts`](../../src/lib/seam-config.ts)) | + +`isArchetypeConfig` treats the presence of a `capabilities` array as the marker of an archetype install. ## Example ```json { "pharnVersion": "0.2.0", - "skillsVersion": "0.70.0", + "skillsVersion": "1.0.0", "repo": "pharn-dev/pharn-oss", "commit": "daa06788…", - "constitution": "standard", - "isMultiTenant": true, - "modules": [ - { "name": "pharn-core", "version": "0.2.0" }, - { "name": "pharn-stack-react", "version": "0.1.2" }, - { "name": "pharn-stack-nextjs", "version": "0.31.0" }, - { "name": "pharn-pipeline", "version": "0.6.0" }, - { "name": "pharn-review", "version": "0.4.0" }, - { "name": "pharn-audits", "version": "0.11.1" } + "installedAt": "2026-06-11T00:00:00.000Z", + "archetypes": ["ssr", "backend"], + "capabilities": [ + { "name": "a11y", "role": "griller" }, + { "name": "security", "role": "griller" }, + { "name": "n-plus-one", "role": "lens" } ], - "installedAt": "2026-06-11T00:00:00.000Z" + "layout": "flat", + "modules": [] } ``` -The `modules` array lists the **resolved** set: your explicit selections plus every transitive dependency, in install order (dependencies before dependents). +## Legacy fields (pre-archetype configs still load) -### schemaVersion 2 example (added fields) +The schema is **additive** (P7): a `pharn.config.json` written by an older, module-based CLI still loads, +and its now-unused fields are preserved on read. -```json -{ - "stackAnswers": { - "database": "supabase", - "orm": "drizzle", - "auth": "better-auth", - "email": "resend", - "payments": "skip" - }, - "installedSkills": [ - { "skill": "drizzle", "from": "pharn-skills-orm/skills/drizzle" }, - { "skill": "better-auth", "from": "pharn-skills-auth/skills/better-auth" }, - { "skill": "resend", "from": "pharn-skills-email/skills/resend" } - ] -} -``` +| Field | Type | Note | +| ----------------- | ------ | ---------------------------------------------------------------- | +| `constitution` | string | Legacy constitution variant (`gdpr-strict`/`standard`/`minimal`) | +| `installedSkills` | array | Legacy per-technology skills, each `{ skill, from }` | +| `stackAnswers` | object | Legacy wizard answers, `questionId → value` | -`stackAnswers` records every answered question (including `"skip"`); `installedSkills` records only what was actually copied. Both are used by `pharn add` (dedupe / conflict detection) and `pharn update` (skill re-resolution). +The module/manifest install path itself has been **removed**, so `add` / `update` / `remove` / `list` / +`status` no longer operate on a pre-archetype config — they exit with a message pointing you to re-run +`pharn init`. ## Overwrite behavior -| Command | Existing `pharn.config.json` | Prompt | If declined | -| ------- | ---------------------------- | ------ | ----------- | -| `init` | present | "Overwrite existing pharn.config.json?" (default no) | Cancel install (exit 0) | -| `add` / `update` | required | none — updated in place | n/a | +| Command | Existing `pharn.config.json` | Prompt | If declined | +| ---------------- | ---------------------------- | ---------------------------------------------------- | ----------------------- | +| `init` | present | "Overwrite existing pharn.config.json?" (default no) | Cancel install (exit 0) | +| `add` / `update` | required (archetype) | none — updated in place | n/a | `init` shows the previous `skillsVersion` before asking. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8440d0e..4429d4e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,30 +6,24 @@ What PHARN CLI does today versus what is planned. | Capability | Status | | ---------- | ------ | -| Interactive `pharn init` wizard (modules + stack pack + constitution) | Shipped | -| Manifest-driven wizard (`schemaVersion 2`): Default/Custom stack questions + per-tech skills | Shipped | -| Selective skill install + `pharn add :` | Shipped | -| Fetch module catalog from `pharn-dev/pharn-oss` `manifest.json` | Shipped | -| Dependency resolution + stack-pack exclusivity | Shipped | -| Clone repo and copy each module's `installs` into `.claude/` | Shipped | -| Materialize `memory-bank/` and the chosen `CONSTITUTION.md` | Shipped | -| Pin commit SHA + write `pharn.config.json` | Shipped | -| `pharn add ` — add a module to an existing project | Shipped | -| `pharn remove ` — remove a module or skill | Shipped | -| `pharn update` — refresh installed modules to the latest version | Shipped | -| `pharn list` — show installed + available modules/skills (read-only, `--json`) | Shipped | -| `pharn status` — read-only version + local-drift report (modified/missing PHARN-owned files; `--strict`, `--no-drift`) | Shipped | -| Vendor official-skill fetching — `init` auto-fetches consented vendor skills (e.g. Supabase) from their declared `source` into `.claude/skills/`, degrading to manual install where no source is known | Shipped | +| Archetype-driven `pharn init` — detect `ssr`/`backend`/`spa`/`lib` from `package.json` names + a bounded, symlink-safe file-tree scan | Shipped | +| Resolve applicable capabilities (grillers + lenses) by `applies` (`universal` or an intersecting archetype), skipping the rest with a reason | Shipped | +| Degit-clone `pharn-dev/pharn-oss` (SHA-pinned) and copy capabilities + fixed product surfaces into the mirrored layout (`flat` or `pharn/`) | Shipped | +| Copy the canonical `CONSTITUTION.md` verbatim + write `pharn.config.json` (archetypes, capabilities, layout) | Shipped | +| `pharn add ` — add one capability to an existing project | Shipped | +| `pharn remove ` — remove an installed capability (no clone, no network) | Shipped | +| `pharn update` — re-resolve recorded archetypes and re-fetch capabilities at the latest version | Shipped | +| `pharn list` — read-only inventory of installed archetypes + capabilities (`--json`) | Shipped | +| `pharn status` — read-only version + capability-drift report (modified/missing PHARN-owned files; `--strict`, `--no-drift`) | Shipped | ## Planned | Capability | Description | | ---------- | ----------- | -| Vendor skill SHA pinning | Vendor fetches are by `source` reference today; pinning each fetched vendor skill to a commit SHA is not built yet | -| Stack scaffolding | Install npm packages / generate app code from the stack pack | -| Additional stack packs | Beyond `pharn-stack-nextjs` | +| Framework-specific capabilities | Beyond today's universal + archetype-triggered set | +| Stack scaffolding | Install npm packages / generate app code for a detected framework | | Migration for existing projects | Onboard repos with significant git history (today the CLI only warns) | -| Orphaned-file detection in `pharn status` | `status` today reports modified + missing PHARN-owned files; flagging files left orphaned in `.claude/` after an upstream rename (see [remove](commands/remove.md)) is not built yet | +| Orphaned-file detection in `pharn status` | `status` today reports modified + missing PHARN-owned files; flagging files left orphaned after an upstream rename is not built yet | | Other agents | Codex and Cursor in addition to Claude Code | ## Related diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 908a985..f08c67d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,25 +4,18 @@ | Situation | Exit code | | --------- | --------- | -| Prerequisite failure (no `.git`, or a selected stack pack's required package is missing) | 1 | -| Module catalog / install failure | 1 | +| Prerequisite failure (no `.git`) | 1 | +| Capability fetch / install failure | 1 | | Unknown command | 1 | -| `add` / `update` with no `pharn.config.json` | 1 | +| `add` / `update` / `remove` / `list` / `status` with no `pharn.config.json` (or a pre-archetype config) | 1 | | User cancel at summary, or overwrite declined | 0 | | Successful install | 0 | ## Prerequisites failed -### Stack-pack prerequisite missing - -When you select a stack pack, every package it declares as a prerequisite must already be in your `package.json` (`dependencies` or `devDependencies`). If any are missing, the CLI prints each one's `reason` and exits with code **1**. The exact wording is defined by the manifest (PHARN owns it), so it may differ from the example below, and multiple missing packages are listed together. For `pharn-stack-nextjs`, for instance: - -```text -✗ pharn-stack-nextjs targets Next.js. Run: npx create-next-app@latest - Then re-run: npx pharn init -``` - -Install the package and re-run — or pick a different stack pack, or **None**. The check is conditional on your choice: a no-pack (or non-Next) install has no package prerequisite. The same gate runs for [`pharn add `](commands/add.md) (its hint says `npx pharn add …`), so a pack can't bypass it by being added after init. +`pharn init` has one prerequisite — a git repository. There is no stack-pack or package prerequisite: +archetype detection reads `package.json` names and the file tree, and installs whatever capabilities +apply. ### Git not found @@ -38,7 +31,7 @@ Exits with code **1**. ### Monorepos / workspaces -`pharn init` checks the **current directory** for a `.git` directory, reads the `package.json` there for any stack-pack prerequisites, and installs `.claude/` there. It does not walk up to a workspace root or into workspace packages. In a monorepo, run it from the directory that contains both `.git` and the app's `package.json`. Split layouts (`.git` at the root, the app's `package.json` in `apps/web/`) are unsupported in v1. +`pharn init` checks the **current directory** for a `.git` directory, reads the `package.json` there for archetype detection, and installs into that directory. It does not walk up to a workspace root or into workspace packages. In a monorepo, run it from the directory that contains both `.git` and the app's `package.json`. Split layouts (`.git` at the root, the app's `package.json` in `apps/web/`) are unsupported in v1. ## Fresh-project warnings @@ -52,24 +45,24 @@ Checks run in order; only the first matching rule applies: | Existing commits | 2–5 commits | | Customized scaffold | 0–1 commits but > 3 unrecognized files | -## Module catalog could not be loaded +## Capabilities could not be fetched Symptoms: -- Spinner stops with "Failed to load module catalog" +- Spinner stops with "Failed to fetch PHARN" / "Failed to fetch capabilities" - Message references `github.com/pharn-dev/pharn-oss` - Exit code 1 -The wizard fetches `manifest.json` from `raw.githubusercontent.com/pharn-dev/pharn-oss/main/manifest.json`. Check network access to GitHub and that the repo is reachable. +`init` / `add` / `update` degit-clone `pharn-dev/pharn-oss`; `update` and `status --no-drift` also fetch the root `SKILLS_VERSION` from `raw.githubusercontent.com`. Check network access to GitHub and that the repo is reachable. ## Install failed Symptoms: -- Spinner stops with "Failed to install skills" +- Spinner stops with "Failed to install capabilities" - Exit code 1 -Causes include a degit clone failure (network/GitHub), a module declaring an `installs` path it doesn't ship, or an unknown constitution variant. Set `PHARN_DEBUG=1` and re-run for the full stack trace: +Causes include a degit clone failure (network/GitHub), or a selected capability missing at its expected path (`//.md`) in the fetched repo. Set `PHARN_DEBUG=1` and re-run for the full stack trace: ```bash PHARN_DEBUG=1 npx pharn init @@ -85,7 +78,15 @@ If you decline overwriting `pharn.config.json`, the wizard cancels with exit 0 a No pharn.config.json found. Run `pharn init` first. ``` -Both commands operate on an already-installed project. Run `pharn init` to create `pharn.config.json`. +All non-`init` commands operate on an already-installed project. Run `pharn init` to create `pharn.config.json`. + +### Legacy (pre-archetype) config + +```text +This project uses the legacy module layout (pre-archetype), which is no longer supported. Re-run `pharn init` to reinstall with the archetype/capability model. +``` + +If your `pharn.config.json` predates the archetype model (it has `modules` but no `capabilities`), the module/manifest install path it relied on has been removed (live pharn-oss ships no `manifest.json`). Re-run `pharn init` to reinstall using archetype detection. ## A command rejects an invalid config (does NOT say "run init") @@ -106,7 +107,7 @@ out-of-enum value, an unknown key (e.g. a typo'd `stgaes` / `haltOnUnknwon`), a Unknown command: ... ``` -Run `pharn --help`. Valid commands: `init`, `add`, `update`. +Run `pharn --help`. Valid commands: `init`, `add`, `remove`, `update`, `list`, `status`. ## Local development issues diff --git a/src/commands/add.ts b/src/commands/add.ts index f67e839..edb950f 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,242 +1,27 @@ -import { resolve } from 'node:path'; -import { - confirm, - intro, - isCancel, - log, - outro, - select, - spinner, -} from '@clack/prompts'; +import { intro, log, outro, spinner } from '@clack/prompts'; import pc from 'picocolors'; -import { cancelAndExit } from '../lib/confirm.js'; import { REPO_URL } from '../lib/constants.js'; -import { - categorizeModules, - fetchRemoteManifest, - resolveModules, -} from '../lib/manifest.js'; -import { findSkillOption, listSkillAddresses } from '../lib/wizard.js'; import { parseCapabilityArg } from '../lib/capability-address.js'; import { parseCapabilityIndex } from '../lib/capability-index.js'; import { installCapabilityDirs } from '../lib/install-capabilities.js'; import { fetchRepo } from '../lib/repo.js'; import { readSkillsVersion } from '../lib/skills-version.js'; -import { assertPrerequisites } from '../steps/prereqs.js'; -import { fetchAndInstall } from '../lib/installer.js'; import { - isArchetypeConfig, - loadConfigOrExit, - toInstalledModules, + loadArchetypeConfigOrExit, writePharnConfig, } from '../lib/pharn-config.js'; -import type { - InstalledSkill, - Manifest, - ManifestModule, - PharnConfig, -} from '../types.js'; +import type { PharnConfig } from '../types.js'; -export async function runAdd(moduleArg: string | undefined): Promise { +// `pharn add ` / `add :` installs one capability into an +// archetype project. The legacy module/manifest flow was removed (live pharn-oss +// ships no manifest.json); a pre-archetype config is rejected up front by +// loadArchetypeConfigOrExit with the single LEGACY_CONFIG_MESSAGE. +export async function runAdd(capabilityArg: string | undefined): Promise { intro('pharn add'); const cwd = process.cwd(); - const config = loadConfigOrExit(cwd); - - // Archetype (capability) install: `add ` / `add :` installs - // one capability. Separate path (there is no manifest); legacy flow unchanged. - if (isArchetypeConfig(config)) { - await runArchetypeAdd(config, cwd, moduleArg); - return; - } - - const manifest = await loadManifest(); - - // schemaVersion 2: `add :` installs one wizard skill. - if (moduleArg !== undefined && moduleArg.includes(':')) { - await addSkill(cwd, config, manifest, moduleArg); - return; - } - - const { optional, stackPacks } = categorizeModules(manifest); - const installed = new Set(config.modules.map((m) => m.name)); - - // Anything not yet installed: optional modules + stack packs. - const addable = [...optional, ...stackPacks].filter( - (m) => !installed.has(m.name), - ); - if (addable.length === 0) { - outro('Everything available is already installed.'); - return; - } - - let name = moduleArg; - if (name && !addable.some((m) => m.name === name)) { - if (installed.has(name)) { - outro(`${name} is already installed.`); - return; - } - log.warn(`"${name}" is not an addable module. Pick one below.`); - name = undefined; - } - if (!name) { - const choice = await select({ - message: 'Which module do you want to add?', - options: addable.map((m) => ({ - value: m.name, - label: m.name, - hint: `v${m.version}`, - })), - }); - if (isCancel(choice)) cancelAndExit(); - name = choice as string; - } - const moduleName: string = name; - const union = [...installed, moduleName]; - - // Resolve up front to discover the new module's transitive deps; a conflict - // (ResolutionError, e.g. a second stack pack) fails here before any network - // work, sharing the install-failure exit path below. - let newlyResolved: ManifestModule[]; - try { - newlyResolved = resolveModules(manifest, union).filter( - (m) => !installed.has(m.name), - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } - // Same prerequisite gate as init: the newly-introduced modules' declared - // packages must already be in package.json, so `add pharn-stack-nextjs` into - // a non-Next project fails identically instead of bypassing the requirement. - assertPrerequisites(newlyResolved, cwd, `npx pharn add ${moduleName}`); - - const claudeDir = resolve(cwd, '.claude'); - const s = spinner(); - s.start(`Fetching ${moduleName} from ${REPO_URL}`); - let resolved: { name: string; version: string }[]; - let skillsVersion: string; - let commit: string | null; - try { - // Re-resolve the union so dependencies of the new module are pulled in too. - const result = await fetchAndInstall({ claudeDir, selected: union }); - resolved = result.resolved; - skillsVersion = result.skillsVersion; - commit = result.commit; - s.stop(`${moduleName} installed`); - } catch (err) { - s.stop(`Failed to add ${moduleName}`); - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } - - await writePharnConfig(cwd, { - ...config, - skillsVersion, - commit, - modules: toInstalledModules(resolved), - installedAt: new Date().toISOString(), - }); - - const added = resolved.map((m) => m.name).filter((n) => !installed.has(n)); - outro( - `${pc.green('✔')} Added ${added.join(', ')} → ${pc.dim('.claude/')} ${pc.dim(`(skills v${skillsVersion})`)}`, - ); -} - -// Install a single category skill (e.g. `add orm:prisma`). Records it in -// installedSkills WITHOUT touching stackAnswers — the recorded wizard answer -// stays authoritative; the wizard never auto-edits it. -async function addSkill( - cwd: string, - config: PharnConfig, - manifest: Manifest, - arg: string, -): Promise { - if (manifest.schemaVersion !== 2 || !manifest.wizard) { - log.error( - 'category:skill requires a schemaVersion 2 manifest (skills v0.69+). Run `pharn update` or pass a plain module name.', - ); - process.exit(1); - } - const wizard = manifest.wizard; - - const idx = arg.indexOf(':'); - const category = arg.slice(0, idx); - const skill = arg.slice(idx + 1); - const addr = findSkillOption(wizard, category, skill); - if (!addr) { - const valid = listSkillAddresses(wizard) - .map((a) => `${a.category}:${a.skill}`) - .sort(); - log.error( - `Unknown skill "${arg}". Valid options:\n ${valid.join('\n ')}`, - ); - process.exit(1); - } - - const installedSkills = config.installedSkills ?? []; - if (installedSkills.some((s) => s.from === addr.install)) { - outro(`${addr.skill} is already installed.`); - return; - } - - // A different skill of the same category already installed → confirm before - // installing alongside it. - const sibling = installedSkills.find( - (s) => s.from.split('/')[0] === addr.module && s.skill !== addr.skill, - ); - if (sibling) { - const ok = await confirm({ - message: `Your project is configured with ${sibling.skill}. Install ${addr.skill} alongside?`, - initialValue: false, - }); - if (isCancel(ok) || ok !== true) cancelAndExit(); - } - - const claudeDir = resolve(cwd, '.claude'); - const newSkill: InstalledSkill = { skill: addr.skill, from: addr.install }; - const installedNames = config.modules.map((m) => m.name); - - const s = spinner(); - s.start(`Fetching ${addr.skill} from ${REPO_URL}`); - let resolved: { name: string; version: string }[]; - let skillsVersion: string; - let commit: string | null; - try { - const result = await fetchAndInstall({ - claudeDir, - selected: installedNames, - wizardSkills: [newSkill], - }); - resolved = result.resolved; - skillsVersion = result.skillsVersion; - commit = result.commit; - s.stop(`${addr.skill} installed`); - } catch (err) { - s.stop(`Failed to add ${addr.skill}`); - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } - - await writePharnConfig(cwd, { - ...config, - skillsVersion, - commit, - modules: toInstalledModules(resolved), - installedSkills: [...installedSkills, newSkill], - installedAt: new Date().toISOString(), - }); - - outro( - `${pc.green('✔')} Added ${addr.skill} → ${pc.dim('.claude/skills/')} ${pc.dim(`(skills v${skillsVersion})`)}`, - ); + const config = loadArchetypeConfigOrExit(cwd); + await runArchetypeAdd(config, cwd, capabilityArg); } // Install one capability into an archetype project (a manual override of @@ -362,19 +147,3 @@ async function resolveArchetypeAdd( }); return { kind: 'added', name: cap.name, version }; } - -async function loadManifest(): Promise { - const s = spinner(); - s.start('Fetching module catalog'); - try { - const manifest = await fetchRemoteManifest(); - s.stop('Module catalog loaded'); - return manifest; - } catch (err) { - s.stop('Failed to load module catalog'); - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } -} diff --git a/src/commands/list.ts b/src/commands/list.ts index 0e67c18..dcec837 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,33 +1,16 @@ -import { intro, log, note, outro, spinner } from '@clack/prompts'; +import { intro, log, note, outro } from '@clack/prompts'; import pc from 'picocolors'; -import { categorizeModules, fetchRemoteManifest } from '../lib/manifest.js'; -import { listSkillAddresses } from '../lib/wizard.js'; -import { row, shortDescription } from '../lib/format.js'; +import { row } from '../lib/format.js'; import { isArchetypeConfig, isConfigValidationError, + LEGACY_CONFIG_MESSAGE, readPharnConfig, } from '../lib/pharn-config.js'; -import type { Archetype, Manifest, PharnConfig } from '../types.js'; +import type { Archetype, PharnConfig } from '../types.js'; -// A single, JSON-serializable snapshot of what's installed vs. available. Both -// the human renderer and `--json` derive from this, so the two never disagree. -interface ListInventory { - skillsVersion: string; - latestSkillsVersion: string; - installed: { - modules: { name: string; version: string; latest: string | null }[]; - skills: { skill: string; from: string }[]; - }; - available: { - modules: { name: string; version: string; description: string }[]; - skills: { category: string; skill: string; install: string }[]; - }; -} - -// Archetype (capability) install inventory — the offline view for an -// `isArchetypeConfig` project (no modules/skills, no manifest). `mode` -// discriminates it from the module ListInventory in `--json` output. +// Archetype (capability) install inventory — the offline view derived entirely +// from pharn.config.json (no manifest, no clone). `mode` tags the `--json` shape. interface ArchetypeInventory { mode: 'archetype'; skillsVersion: string; @@ -35,8 +18,10 @@ interface ArchetypeInventory { capabilities: { name: string; role: 'griller' | 'lens' }[]; } -// Read-only: shows installed + available modules/skills. Never writes, never -// clones — only the lightweight manifest fetch that add/update already do. +// Read-only: shows the installed archetypes + capabilities from the config. +// Never writes, never clones, never fetches (the module/manifest catalog was +// removed — live pharn-oss ships no manifest.json). `--json` emits the inventory +// object on stdout; diagnostics go to stderr. export async function runList(opts: { json?: boolean } = {}): Promise { const json = opts.json ?? false; const cwd = process.cwd(); @@ -60,85 +45,19 @@ export async function runList(opts: { json?: boolean } = {}): Promise { process.exit(1); } - // Archetype (capability) install: render offline from the config — no manifest - // fetch (live pharn-oss has none) and no clone, so it cannot crash on the - // missing manifest. Cross-checking installed vs. upstream (available/updatable - // capabilities) arrives with the status/update archetype slices (labeled in the - // render, P4/P7). The legacy module path below is unchanged. - if (isArchetypeConfig(config)) { - const inventory = buildArchetypeInventory(config); - if (json) { - console.log(JSON.stringify(inventory, null, 2)); - return; - } - renderArchetypeHuman(inventory); - return; + // A pre-archetype (module) config is no longer listable — the module/manifest + // path was removed. json-aware so --json keeps stdout pure (error → stderr). + if (!isArchetypeConfig(config)) { + emitError(LEGACY_CONFIG_MESSAGE, json); + process.exit(1); } - const manifest = await loadManifest(json); - const inventory = buildInventory(config, manifest); - + const inventory = buildArchetypeInventory(config); if (json) { - // stdout carries only the JSON object; diagnostics went to stderr above. console.log(JSON.stringify(inventory, null, 2)); return; } - - const hasWizard = manifest.schemaVersion === 2 && Boolean(manifest.wizard); - renderHuman(inventory, hasWizard); -} - -function buildInventory( - config: PharnConfig, - manifest: Manifest, -): ListInventory { - const latestByName = new Map( - manifest.modules.map((m) => [m.name, m.version]), - ); - - const installedModules = config.modules.map((m) => ({ - name: m.name, - version: m.version, - latest: latestByName.get(m.name) ?? null, - })); - const installedSkills = (config.installedSkills ?? []).map((s) => ({ - skill: s.skill, - from: s.from, - })); - - // Mirror `add`'s addable set exactly so the two commands never disagree. - const { optional, stackPacks } = categorizeModules(manifest); - const installedNames = new Set(config.modules.map((m) => m.name)); - const availableModules = [...optional, ...stackPacks] - .filter((m) => !installedNames.has(m.name)) - .map((m) => ({ - name: m.name, - version: m.version, - description: m.description, - })); - - // schemaVersion 2 only: every wizard skill not already recorded (matched by - // its `install` path against installedSkills `from`). - let availableSkills: ListInventory['available']['skills'] = []; - if (manifest.schemaVersion === 2 && manifest.wizard) { - const installedFroms = new Set( - (config.installedSkills ?? []).map((s) => s.from), - ); - availableSkills = listSkillAddresses(manifest.wizard) - .filter((a) => !installedFroms.has(a.install)) - .map((a) => ({ - category: a.category, - skill: a.skill, - install: a.install, - })); - } - - return { - skillsVersion: config.skillsVersion, - latestSkillsVersion: manifest.skillsVersion, - installed: { modules: installedModules, skills: installedSkills }, - available: { modules: availableModules, skills: availableSkills }, - }; + renderArchetypeHuman(inventory); } function buildArchetypeInventory(config: PharnConfig): ArchetypeInventory { @@ -175,67 +94,7 @@ function renderArchetypeHuman(inv: ArchetypeInventory): void { note(lines.join('\n'), 'INSTALLED (archetype)'); outro( pc.dim( - 'Read-only — nothing changed. Available/updatable capabilities: coming with `pharn status`/`update` archetype support.', - ), - ); -} - -function renderHuman(inv: ListInventory, hasWizard: boolean): void { - const skillsLine = row('Skills version', `v${inv.skillsVersion}`); - const installed: string[] = [ - inv.latestSkillsVersion !== inv.skillsVersion - ? `${skillsLine} ${pc.dim( - `→ v${inv.latestSkillsVersion} (update available, run \`pharn update\`)`, - )}` - : skillsLine, - '', - ' MODULES', - ]; - for (const m of inv.installed.modules) { - const base = row(m.name, `v${m.version}`); - installed.push( - m.latest && m.latest !== m.version - ? `${base} ${pc.dim(`→ v${m.latest}`)}` - : base, - ); - } - if (inv.installed.skills.length > 0) { - installed.push('', ' SKILLS'); - for (const s of inv.installed.skills) { - installed.push(row(` ${s.skill}`, pc.dim(s.from))); - } - } - note(installed.join('\n'), 'INSTALLED'); - - const available: string[] = [' MODULES']; - if (inv.available.modules.length > 0) { - for (const m of inv.available.modules) { - available.push( - row( - m.name, - `v${m.version} ${pc.dim(shortDescription(m.description))}`, - ), - ); - } - } else { - available.push(' (all installed)'); - } - // schemaVersion 1 has no wizard, so there is no skills concept to list. - if (hasWizard) { - available.push('', ' SKILLS'); - if (inv.available.skills.length > 0) { - for (const a of inv.available.skills) { - available.push(` ${a.category}:${a.skill}`); - } - } else { - available.push(' (all installed)'); - } - } - note(available.join('\n'), 'AVAILABLE TO ADD'); - - outro( - pc.dim( - 'Read-only — nothing changed. `pharn add ` to install, `pharn update` to upgrade.', + 'Read-only — nothing changed. `pharn add ` to install more, `pharn status` for drift, `pharn update` to upgrade.', ), ); } @@ -245,29 +104,3 @@ function emitError(message: string, json: boolean): void { if (json) console.error(message); else log.error(message); } - -async function loadManifest(json: boolean): Promise { - if (json) { - try { - return await fetchRemoteManifest(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(message); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } - } - const s = spinner(); - s.start('Fetching module catalog'); - try { - const manifest = await fetchRemoteManifest(); - s.stop('Module catalog loaded'); - return manifest; - } catch (err) { - s.stop('Failed to load module catalog'); - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } -} diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 3211015..a5cdd33 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -1,140 +1,36 @@ -import { existsSync, readdirSync, rmSync, rmdirSync, statSync } from 'node:fs'; -import { basename, dirname, resolve, sep } from 'node:path'; -import { - confirm, - intro, - isCancel, - log, - outro, - select, - spinner, -} from '@clack/prompts'; +import { existsSync, rmSync } from 'node:fs'; +import { intro, isCancel, log, outro, select } from '@clack/prompts'; import pc from 'picocolors'; import { cancelAndExit } from '../lib/confirm.js'; -import { - CORE_MODULE, - REPO_URL, - SKILL_MODULE_PREFIX, -} from '../lib/constants.js'; import { configLayout, layoutPaths } from '../lib/layout.js'; -import { - readManifest, - readModuleManifest, - resolveModules, -} from '../lib/manifest.js'; -import { fetchRepo, type FetchedRepo } from '../lib/repo.js'; import { parseCapabilityArg } from '../lib/capability-address.js'; -import { safeJoin } from '../lib/install-modules.js'; +import { safeJoin } from '../lib/validate.js'; import { - isArchetypeConfig, - loadConfigOrExit, + loadArchetypeConfigOrExit, writePharnConfig, } from '../lib/pharn-config.js'; -import { ManifestValidationError } from '../lib/validate.js'; -import type { InstalledSkill, ModuleManifest, PharnConfig } from '../types.js'; +import type { PharnConfig } from '../types.js'; -// The inverse of `pharn add`. Removes a methodology module (clones to learn the -// exact files it contributed) or a single category:skill (precise single-dir -// delete — no clone, no network), then updates pharn.config.json to match. +// The inverse of `pharn add`: removes one installed capability from an archetype +// project (no clone, no network — everything is derivable from config.capabilities +// + the filesystem). The legacy module/skill removal (which read the manifest) +// was removed; a pre-archetype config is rejected up front. `_opts.yes` is +// accepted for CLI compat but unused — capability removal has no confirm prompt. export async function runRemove( arg: string | undefined, - opts: { yes?: boolean } = {}, + _opts: { yes?: boolean } = {}, ): Promise { intro('pharn remove'); const cwd = process.cwd(); - const config = loadConfigOrExit(cwd); - const claudeDir = resolve(cwd, '.claude'); - - // Archetype (capability) install: remove one capability (no clone, no network). - // Comes before the skill/module dispatch — an archetype config has neither. - if (isArchetypeConfig(config)) { - await removeCapability(cwd, config, arg); - return; - } - - // A `category:skill` address (has a colon) removes one wizard skill; a plain - // name removes a whole module; no arg opens the interactive picker. - if (arg !== undefined && arg.includes(':')) { - await removeSkill(cwd, claudeDir, config, arg); - return; - } - if (arg !== undefined) { - await removeModule(cwd, claudeDir, config, arg, opts); - return; - } - await runPicker(cwd, claudeDir, config); -} - -// --------------------------------------------------------------------------- -// §A — remove a category:skill (no clone, no network) -// --------------------------------------------------------------------------- - -// What is installed is fully derivable from installedSkills + the filesystem, so -// this path never fetches the manifest or clones the repo. -async function removeSkill( - cwd: string, - claudeDir: string, - config: PharnConfig, - arg: string, -): Promise { - const idx = arg.indexOf(':'); - const category = arg.slice(0, idx); - const skill = arg.slice(idx + 1); - const moduleRoot = `${SKILL_MODULE_PREFIX}${category}`; - - const installedSkills = config.installedSkills ?? []; - const target = installedSkills.find( - (s) => s.from.split('/')[0] === moduleRoot && s.skill === skill, - ); - - // Not installed → benign no-op (no config write). Without a manifest we can't - // tell "valid but absent" from "unknown", so list the installed skills (the - // only things actually removable) as the valid values. - if (!target) { - const valid = installedSkills.map(skillAddress).sort(); - const hint = valid.length - ? ` Installed skills: ${valid.join(', ')}.` - : ' No skills are installed.'; - log.warn(`"${arg}" is not an installed skill.${hint}`); - outro('Nothing was removed.'); - return; - } - - // Each skill lives in its own isolated dir, so removal is a precise recursive - // delete; siblings in the same category are never touched. - const skillDir = safeUnderClaude( - claudeDir, - `skills/${basename(target.from)}`, - ); - let note = ''; - if (existsSync(skillDir)) { - rmSync(skillDir, { recursive: true, force: true }); - } else { - note = pc.dim(' (its files were already gone)'); - } - - // Drop only this entry. stackAnswers / modules / skillsVersion / commit are - // left untouched — the recorded wizard answer stays authoritative. - await writePharnConfig(cwd, { - ...config, - installedSkills: installedSkills.filter((s) => s.from !== target.from), - installedAt: new Date().toISOString(), - }); - - outro( - `${pc.green('✔')} Removed ${target.skill}${note} ${pc.dim('from .claude/skills/')}`, - ); + const config = loadArchetypeConfigOrExit(cwd); + await removeCapability(cwd, config, arg); } -// --------------------------------------------------------------------------- -// §A2 — remove a capability (archetype install; no clone, no network) -// --------------------------------------------------------------------------- - // Installed capabilities are fully derivable from config.capabilities + the // filesystem, so this never clones. Each capability is an isolated dir, so -// removal is a precise recursive delete (siblings never touched) — mirrors -// removeSkill. Touches only `capabilities` (never archetypes / modules). +// removal is a precise recursive delete (siblings never touched). Touches only +// `capabilities` (never archetypes / modules). async function removeCapability( cwd: string, config: PharnConfig, @@ -213,407 +109,3 @@ async function removeCapability( outro(`${pc.green('✔')} Removed ${target.name} (${target.role})${note}`); } - -// --------------------------------------------------------------------------- -// §B — remove a module (clones once) -// --------------------------------------------------------------------------- - -type RemoveOutcome = - | { kind: 'refused'; message: string } - | { kind: 'cancelled' } - | { kind: 'removed'; removed: string[] }; - -async function removeModule( - cwd: string, - claudeDir: string, - config: PharnConfig, - name: string, - opts: { yes?: boolean }, -): Promise { - // Cheap refusals first — decidable from the config alone, before any clone. - if (name === CORE_MODULE) { - log.error( - `${CORE_MODULE} is required by every other module and cannot be removed.`, - ); - process.exit(1); - } - const installedNames = config.modules.map((m) => m.name); - if (!installedNames.includes(name)) { - log.error(`${name} is not installed. Run \`pharn list\` to see what is.`); - process.exit(1); - } - - // Clone once and read the manifest FROM the clone (@main HEAD) — one source of - // truth, no raw-vs-HEAD race. Always cleanup() before any process.exit, since - // process.exit skips finally blocks in production. - const repo = await cloneRepo(); - let outcome: RemoveOutcome | undefined; - let failure: unknown; - try { - outcome = await planAndApplyModuleRemoval( - cwd, - claudeDir, - config, - name, - opts.yes ?? false, - repo.dir, - ); - } catch (err) { - failure = err; - } finally { - repo.cleanup(); - } - - // Surface a clean one-line message (parity with add/update) instead of letting - // a manifest/resolve error propagate as a raw stack — but only after cleanup. - if (failure !== undefined) { - reportError(failure); - process.exit(1); - } - - const result = outcome!; - switch (result.kind) { - case 'refused': - log.error(result.message); - process.exit(1); - break; - case 'cancelled': - cancelAndExit(); - break; - case 'removed': - outro( - `${pc.green('✔')} Removed ${result.removed.join(', ')} ${pc.dim('from .claude/')}`, - ); - break; - } -} - -// Resolve the survivor set, confirm, then delete exactly the target's files and -// rewrite the config. Returns an outcome instead of exiting so the caller can -// cleanup() the clone first. Performs no writes/deletes on a refusal or cancel. -async function planAndApplyModuleRemoval( - cwd: string, - claudeDir: string, - config: PharnConfig, - name: string, - yes: boolean, - repoDir: string, -): Promise { - const manifest = readManifest(repoDir); - const installedNames = config.modules.map((m) => m.name); - - // Dependents guard: refuse while any other installed module still needs this - // one (directly or transitively). No automatic cascade in v1. - const dependents = installedDependents(manifest, installedNames, name); - if (dependents.length > 0) { - return { - kind: 'refused', - message: `${name} is required by ${dependents.join(', ')} — remove ${ - dependents.length > 1 ? 'those' : 'that' - } first.`, - }; - } - - // The survivor set is the safe source of truth for what to delete: modules - // present before but absent after. Resolved before any file is touched. - const remaining = installedNames.filter((n) => n !== name); - const survivors = resolveModules(manifest, remaining); - const survivorNames = new Set(survivors.map((m) => m.name)); - const toRemove = installedNames.filter((n) => !survivorNames.has(n)); - - // An empty toRemove means `name` survived re-resolution despite being dropped - // from the explicit set — i.e. resolveModules force-includes it because the - // manifest marks it `required`. Refuse rather than no-op with a false success. - if (toRemove.length === 0) { - return { - kind: 'refused', - message: `${name} is a required module and cannot be removed.`, - }; - } - - if (!yes) { - const ok = await confirm({ - message: `Remove ${toRemove.join(', ')} from .claude/?`, - initialValue: false, - }); - if (isCancel(ok) || ok !== true) return { kind: 'cancelled' }; - } - - // Compute the exact contributed file set up front (fail-before-write). - const files = new Set(); - const dirs = new Set(); - for (const mod of toRemove) { - const mm = readModuleManifest(repoDir, mod); - collectModuleFiles(repoDir, claudeDir, mod, mm, files, dirs); - } - - for (const file of files) { - if (existsSync(file)) rmSync(file, { force: true }); - } - pruneEmptyDirs(dirs, claudeDir); - - // Keep the survivors' RECORDED versions — remove deletes files but never - // reinstalls, so resampling versions from the clone's @main manifest would - // misreport what's on disk and mask pending per-module updates. - await writePharnConfig(cwd, { - ...config, - modules: config.modules.filter((m) => survivorNames.has(m.name)), - installedAt: new Date().toISOString(), - }); - - return { kind: 'removed', removed: toRemove }; -} - -// --------------------------------------------------------------------------- -// Interactive picker (no arg) -// --------------------------------------------------------------------------- - -// Lists only genuinely removable items — installed optional modules (excluding -// pharn-core and any module with an installed dependent) plus installed skills — -// so we never offer something the command would immediately refuse. Clones once -// (the dependents guard needs the manifest) and reuses that clone for the delete. -async function runPicker( - cwd: string, - claudeDir: string, - config: PharnConfig, -): Promise { - const installedNames = config.modules.map((m) => m.name); - const removableSkills = (config.installedSkills ?? []) - .map(skillAddress) - .sort(); - const hasOptionalModules = installedNames.some((n) => n !== CORE_MODULE); - - // Only the module dependents-guard needs the manifest; skill removal is - // cloneless. Skip the clone entirely when no optional module is installed. - const repo = hasOptionalModules ? await cloneRepo() : undefined; - let cancelled = false; - let none = false; - let skillArg: string | undefined; - let moduleOutcome: RemoveOutcome | undefined; - let failure: unknown; - try { - let removableModules: string[] = []; - if (repo) { - const manifest = readManifest(repo.dir); - removableModules = installedNames.filter( - (n) => - n !== CORE_MODULE && - installedDependents(manifest, installedNames, n).length === 0, - ); - } - - if (removableModules.length === 0 && removableSkills.length === 0) { - none = true; - } else { - const choice = await select({ - message: 'What do you want to remove?', - options: [ - ...removableModules.map((n) => ({ - value: n, - label: n, - hint: 'module', - })), - ...removableSkills.map((a) => ({ - value: a, - label: a, - hint: 'skill', - })), - ], - }); - if (isCancel(choice)) { - cancelled = true; - } else if ((choice as string).includes(':')) { - // A skill address carries a colon; a module name never does. - skillArg = choice as string; - } else { - // A bare module name can only come from removableModules, which is empty - // unless the clone exists — so repo is non-null here. Remove without a - // second confirm (the pick is the intent), while the clone is held. - moduleOutcome = await planAndApplyModuleRemoval( - cwd, - claudeDir, - config, - choice as string, - true, - repo!.dir, - ); - } - } - } catch (err) { - failure = err; - } finally { - repo?.cleanup(); - } - - // Act only after the clone is cleaned up (so process.exit can't skip cleanup). - if (failure !== undefined) { - reportError(failure); - process.exit(1); - } - if (none) { - outro( - 'Nothing to remove — pharn-core is required and no skills are installed.', - ); - return; - } - if (cancelled) cancelAndExit(); - if (skillArg !== undefined) { - await removeSkill(cwd, claudeDir, config, skillArg); - return; - } - if (moduleOutcome?.kind === 'removed') { - outro( - `${pc.green('✔')} Removed ${moduleOutcome.removed.join(', ')} ${pc.dim('from .claude/')}`, - ); - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -async function cloneRepo(): Promise { - const s = spinner(); - s.start(`Fetching module catalog from ${REPO_URL}`); - try { - const repo = await fetchRepo(); - s.stop('Module catalog loaded'); - return repo; - } catch (err) { - s.stop('Failed to fetch repository'); - reportError(err); - process.exit(1); - } -} - -// Clean one-line error (parity with add/update/status); full detail under -// PHARN_DEBUG. Used by every catch in this command. -function reportError(err: unknown): void { - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); -} - -// `category:skill` rendering of an installed skill, e.g. orm:prisma. -function skillAddress(s: InstalledSkill): string { - const root = s.from.split('/')[0] ?? ''; - const category = root.startsWith(SKILL_MODULE_PREFIX) - ? root.slice(SKILL_MODULE_PREFIX.length) - : root; - return `${category}:${s.skill}`; -} - -// Installed modules whose transitive dependsOn includes `target`. -function installedDependents( - manifest: { modules: { name: string; dependsOn: string[] }[] }, - installedNames: string[], - target: string, -): string[] { - const byName = new Map(manifest.modules.map((m) => [m.name, m])); - const dependsOnTarget = (start: string): boolean => { - const seen = new Set(); - const stack = [...(byName.get(start)?.dependsOn ?? [])]; - while (stack.length) { - const dep = stack.pop()!; - if (dep === target) return true; - if (seen.has(dep)) continue; - seen.add(dep); - stack.push(...(byName.get(dep)?.dependsOn ?? [])); - } - return false; - }; - return installedNames.filter((n) => n !== target && dependsOnTarget(n)); -} - -// Map a module's `installs` entries to the exact files they contributed under -// .claude/, mirroring installModule's cpSync(from, to) semantics. statSync the -// source and branch — the schema permits a single-file `from`, not only dirs. -function collectModuleFiles( - repoDir: string, - claudeDir: string, - moduleName: string, - mm: ModuleManifest, - files: Set, - dirs: Set, -): void { - const moduleDir = resolve(repoDir, moduleName); - for (const [src, dest] of Object.entries(mm.installs)) { - const from = resolve(moduleDir, src); - const to = safeUnderClaude(claudeDir, dest); - // A `from` missing at @main HEAD means we can't know which files it once - // contributed — leave them (the documented orphan caveat) rather than guess. - if (!existsSync(from)) continue; - - if (statSync(from).isDirectory()) { - for (const rel of walkFiles(from)) { - const file = resolve(to, rel); - if (isProtected(claudeDir, file)) continue; - files.add(file); - dirs.add(dirname(file)); - } - dirs.add(to); - } else { - if (isProtected(claudeDir, to)) continue; - files.add(to); - dirs.add(dirname(to)); - } - } -} - -// Relative paths of every file (not directory) under `dir`, recursively. -function* walkFiles(dir: string, prefix = ''): Generator { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const rel = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - yield* walkFiles(resolve(dir, entry.name), rel); - } else { - yield rel; - } - } -} - -// CONSTITUTION.md and the memory bank are user-owned (materialized only at init) -// and must never be deleted, even if a module's file set somehow references them. -function isProtected(claudeDir: string, target: string): boolean { - const memoryBank = resolve(claudeDir, 'memory-bank'); - return ( - target === resolve(claudeDir, 'CONSTITUTION.md') || - target === memoryBank || - target.startsWith(memoryBank + sep) - ); -} - -// Remove now-empty directories bottom-up, but never one that still holds a -// surviving module's files (re-checked with readdirSync before each rmdir). -function pruneEmptyDirs(dirs: Set, claudeDir: string): void { - const candidates = new Set(); - for (const dir of dirs) { - let cur = dir; - while (cur.startsWith(claudeDir + sep)) { - candidates.add(cur); - cur = dirname(cur); - } - } - const depth = (p: string): number => p.split(sep).length; - for (const dir of [...candidates].sort((a, b) => depth(b) - depth(a))) { - if (isProtected(claudeDir, dir) || !existsSync(dir)) continue; - try { - if (readdirSync(dir).length === 0) rmdirSync(dir); - } catch { - // Non-empty (surviving files) or a race — leave it in place. - } - } -} - -// Defense-in-depth: never let a delete escape .claude/ (mirrors install-modules -// safeJoin). dest paths are already validated by INSTALL_PATH_RE upstream. -function safeUnderClaude(claudeDir: string, rel: string): string { - const target = resolve(claudeDir, rel); - const root = resolve(claudeDir); - if (target !== root && !target.startsWith(root + sep)) { - throw new ManifestValidationError( - `Refusing path escape: ${rel} resolves outside ${claudeDir}`, - ); - } - return target; -} diff --git a/src/commands/status.ts b/src/commands/status.ts index e794238..ca8f1c1 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,32 +1,28 @@ -import { resolve } from 'node:path'; import { intro, log, note, outro, spinner } from '@clack/prompts'; import pc from 'picocolors'; import { REPO, REPO_BRANCH } from '../lib/constants.js'; -import { - fetchRemoteManifest, - readManifest, - resolveModules, -} from '../lib/manifest.js'; import { fetchRepo } from '../lib/repo.js'; -import { diffInstalled, diffInstalledCapabilities } from '../lib/diff.js'; +import { diffInstalledCapabilities } from '../lib/diff.js'; import { configLayout } from '../lib/layout.js'; import { row } from '../lib/format.js'; -import { isArchetypeConfig, loadConfigOrExit } from '../lib/pharn-config.js'; +import { loadArchetypeConfigOrExit } from '../lib/pharn-config.js'; import { fetchRemoteSkillsVersion, readSkillsVersion, } from '../lib/skills-version.js'; -import type { Manifest, PharnConfig } from '../types.js'; +import type { PharnConfig } from '../types.js'; const REF = `${REPO}@${REPO_BRANCH}`; /** - * Read-only audit: is the install current (version section) and have any - * PHARN-owned files drifted from `pharn-dev/pharn-oss@main` (drift section)? - * Never writes, deletes, or overwrites — fixing is `pharn update` / `pharn add`. + * Read-only audit of an archetype install: is it current (version section) and + * have any PHARN-owned files drifted from `pharn-dev/pharn-oss@main` (drift + * section)? Never writes, deletes, or overwrites — fixing is `pharn update` / + * `pharn add`. The module/manifest flow was removed; a pre-archetype config is + * rejected up front by loadArchetypeConfigOrExit. * * Default clones the repo once and reuses it for both sections. `--no-drift` - * skips the clone and only checks the version (via the lightweight manifest + * skips the clone and only checks the version (via the lightweight SKILLS_VERSION * fetch). `--strict` exits 1 when anything is outdated, modified, or missing. */ export async function runStatus( @@ -38,88 +34,13 @@ export async function runStatus( intro('pharn status'); const cwd = process.cwd(); - const config = loadConfigOrExit(cwd); - const claudeDir = resolve(cwd, '.claude'); - - // Archetype (capability) install: version via SKILLS_VERSION (there is no - // manifest) + capability drift. Separate path so the legacy audit is unchanged. - if (isArchetypeConfig(config)) { - await runArchetypeStatus(config, { strict, drift }, cwd); - return; - } - - // Version-only: no clone, just the manifest fetch list/update already use. - if (!drift) { - const s = spinner(); - s.start('Checking for updates'); - let manifest; - try { - manifest = await fetchRemoteManifest(); - s.stop(`Latest skills v${manifest.skillsVersion}`); - } catch (err) { - s.stop('Failed to check for updates'); - reportError(err); - process.exit(1); - } - const { outdated } = printVersionSection(config, manifest); - if (strict && outdated) process.exit(1); - outro(pc.dim('Read-only — nothing changed (drift check skipped).')); - return; - } - - // Default: clone once; the HEAD manifest is both the "latest" for the version - // section and the source for the byte-level drift comparison (no double fetch). - const s = spinner(); - s.start(`Comparing against ${REF}`); - let repo; - try { - repo = await fetchRepo(); - s.stop(`Compared against ${REF}`); - } catch (err) { - s.stop(`Failed to reach ${REPO}`); - reportError(err); - process.exit(1); - } - - // cleanup() must run before any process.exit (Node skips finally on exit), so - // record the desired code and exit only after the finally has cleaned up. - let exitCode = 0; - try { - const manifest = readManifest(repo.dir); - const { outdated } = printVersionSection(config, manifest); - - const resolved = resolveModules( - manifest, - config.modules.map((m) => m.name), - ); - const result = diffInstalled({ - repoDir: repo.dir, - claudeDir, - moduleNames: resolved.map((m) => m.name), - skills: config.installedSkills ?? [], - }); - printDriftSection(result); - - if ( - strict && - (outdated || result.modified.length || result.missing.length) - ) { - exitCode = 1; - } - } catch (err) { - reportError(err); - exitCode = 1; - } finally { - repo.cleanup(); - } - - if (exitCode) process.exit(exitCode); - outro(pc.dim('Read-only — nothing changed.')); + const config = loadArchetypeConfigOrExit(cwd); + await runArchetypeStatus(config, { strict, drift }, cwd); } -// Archetype install audit: version via SKILLS_VERSION + capability drift. Mirrors -// the module path's structure (cleanup before exit; --strict gate) but reads the -// version from SKILLS_VERSION and diffs the copied capabilities/product surfaces. +// Archetype install audit: version via SKILLS_VERSION + capability drift. +// Cleanup runs before any process.exit (Node skips finally on exit), so the +// desired exit code is recorded and applied only after the finally. async function runArchetypeStatus( config: PharnConfig, opts: { strict: boolean; drift: boolean }, @@ -203,34 +124,6 @@ function printArchetypeVersion(config: PharnConfig, latest: string): boolean { return outdated; } -// VERSION note: skillsVersion + any per-module version bumps. Returns whether -// the install is behind upstream (drives the --strict gate). Mirrors the diff -// computation in `pharn update`. -function printVersionSection( - config: PharnConfig, - manifest: Manifest, -): { outdated: boolean } { - const latest = new Map(manifest.modules.map((m) => [m.name, m.version])); - const changes = config.modules - .filter((m) => latest.has(m.name) && latest.get(m.name) !== m.version) - .map((m) => ({ name: m.name, from: m.version, to: latest.get(m.name)! })); - const outdated = - config.skillsVersion !== manifest.skillsVersion || changes.length > 0; - - const skillsLine = - config.skillsVersion === manifest.skillsVersion - ? `${row('Skills version', `v${config.skillsVersion}`)} ${pc.dim('(up to date)')}` - : `${row('Skills version', `v${config.skillsVersion} → v${manifest.skillsVersion}`)} ${pc.dim('(update available, run `pharn update`)')}`; - - const lines = [skillsLine]; - if (changes.length) { - lines.push('', ' MODULE UPDATES'); - for (const c of changes) lines.push(row(c.name, `v${c.from} → v${c.to}`)); - } - note(lines.join('\n'), 'VERSION'); - return { outdated }; -} - // DRIFT note: locally-modified and missing PHARN-owned files, or a clean bill. function printDriftSection(result: { modified: string[]; diff --git a/src/commands/update.ts b/src/commands/update.ts index fcac3e9..604ebb0 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,4 +1,3 @@ -import { resolve } from 'node:path'; import { confirm, intro, @@ -11,9 +10,6 @@ import { import pc from 'picocolors'; import { cancelAndExit } from '../lib/confirm.js'; import { REPO_URL } from '../lib/constants.js'; -import { fetchRemoteManifest } from '../lib/manifest.js'; -import { listSkillAddresses } from '../lib/wizard.js'; -import { fetchAndInstall } from '../lib/installer.js'; import { parseCapabilityIndex } from '../lib/capability-index.js'; import { resolveCapabilities } from '../lib/resolve-capabilities.js'; import { installCapabilities } from '../lib/install-capabilities.js'; @@ -24,142 +20,21 @@ import { } from '../lib/skills-version.js'; import { row } from '../lib/format.js'; import { - isArchetypeConfig, - loadConfigOrExit, - toInstalledModules, + loadArchetypeConfigOrExit, writePharnConfig, } from '../lib/pharn-config.js'; -import type { - InstalledCapability, - InstalledSkill, - PharnConfig, -} from '../types.js'; +import type { InstalledCapability, PharnConfig } from '../types.js'; +// `pharn update` refreshes an archetype install: re-resolve the recorded +// archetypes against the latest capability index and re-copy. The legacy +// module/manifest flow was removed (live pharn-oss ships no manifest.json); a +// pre-archetype config is rejected up front by loadArchetypeConfigOrExit. export async function runUpdate(): Promise { intro('pharn update'); const cwd = process.cwd(); - const config = loadConfigOrExit(cwd); - - // Archetype (capability) install: refresh via SKILLS_VERSION + re-resolved - // capabilities (there is no manifest). Separate path; legacy flow unchanged. - if (isArchetypeConfig(config)) { - await runArchetypeUpdate(config, cwd); - return; - } - - const s = spinner(); - s.start('Checking for updates'); - let manifest; - try { - manifest = await fetchRemoteManifest(); - s.stop(`Latest skills v${manifest.skillsVersion}`); - } catch (err) { - s.stop('Failed to check for updates'); - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } - - const latest = new Map(manifest.modules.map((m) => [m.name, m.version])); - const changes = config.modules - .filter((m) => latest.has(m.name) && latest.get(m.name) !== m.version) - .map((m) => ({ name: m.name, from: m.version, to: latest.get(m.name)! })); - - // schemaVersion 2: re-resolve installed skills against the new wizard. A - // recorded `from` path that no longer exists upstream (skill moved/renamed) is - // reported and dropped — never guessed at a new location. - const recordedSkills = config.installedSkills ?? []; - let keptSkills: InstalledSkill[] = recordedSkills; - let droppedSkills: InstalledSkill[] = []; - if ( - manifest.schemaVersion === 2 && - manifest.wizard && - recordedSkills.length - ) { - const validPaths = new Set( - listSkillAddresses(manifest.wizard).map((a) => a.install), - ); - keptSkills = recordedSkills.filter((s) => validPaths.has(s.from)); - droppedSkills = recordedSkills.filter((s) => !validPaths.has(s.from)); - } - - if ( - config.skillsVersion === manifest.skillsVersion && - changes.length === 0 && - droppedSkills.length === 0 - ) { - outro(`Already up to date (skills v${config.skillsVersion}).`); - return; - } - - if (droppedSkills.length > 0) { - log.warn( - [ - 'These installed skills no longer exist upstream and will be skipped', - '(their files in .claude/skills/ are left untouched):', - ...droppedSkills.map((s) => ` ${s.skill} (${s.from})`), - ].join('\n'), - ); - } - - note( - [ - row( - 'Skills version', - `v${config.skillsVersion} → v${manifest.skillsVersion}`, - ), - '', - ' MODULE CHANGES', - ...(changes.length - ? changes.map((c) => row(c.name, `v${c.from} → v${c.to}`)) - : [' (no per-module version changes)']), - '', - pc.dim(' Review CHANGELOG.md for breaking changes before updating.'), - pc.dim(' https://github.com/pharn-dev/pharn-oss/blob/main/CHANGELOG.md'), - ].join('\n'), - ); - - const ok = await confirm({ - message: 'Re-fetch installed modules at the latest version?', - initialValue: true, - }); - if (isCancel(ok) || ok !== true) cancelAndExit(); - - const claudeDir = resolve(cwd, '.claude'); - const installedNames = config.modules.map((m) => m.name); - const s2 = spinner(); - s2.start(`Updating from ${REPO_URL}`); - let result; - try { - result = await fetchAndInstall({ - claudeDir, - selected: installedNames, - wizardSkills: keptSkills, - }); - s2.stop('Modules updated'); - } catch (err) { - s2.stop('Update failed'); - const message = err instanceof Error ? err.message : String(err); - log.error(`⚠ ${message}`); - if (process.env.PHARN_DEBUG) console.error(err); - process.exit(1); - } - - await writePharnConfig(cwd, { - ...config, - skillsVersion: result.skillsVersion, - commit: result.commit, - modules: toInstalledModules(result.resolved), - installedAt: new Date().toISOString(), - // Persist only the surviving skills; stackAnswers stays untouched. - ...(recordedSkills.length > 0 ? { installedSkills: keptSkills } : {}), - }); - - outro( - `${pc.green('✔')} Updated to skills v${result.skillsVersion}. ${pc.dim('CONSTITUTION.md left untouched (edit it by hand).')}`, - ); + const config = loadArchetypeConfigOrExit(cwd); + await runArchetypeUpdate(config, cwd); } // Archetype install refresh: check SKILLS_VERSION, then (on a bump + confirm) diff --git a/src/index.ts b/src/index.ts index 58e8114..0d4acfa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,23 +13,21 @@ import { runStatus } from './commands/status.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; -const USAGE = `Pharn - Installs PHARN (an audit-grade methodology for Claude Code) into your project. npx pharn init detects your project's archetype and installs the applicable PHARN capabilities into .claude/; pharn add installs another module later; pharn update bumps to the latest skills version. +const USAGE = `Pharn - Installs PHARN (an audit-grade methodology for Claude Code) into your project. npx pharn init detects your project's archetype and installs the applicable PHARN capabilities; pharn add installs another capability later; pharn update bumps to the latest skills version. Usage: pharn [command] [options] Commands: - init Run the setup wizard (default) - add Add a methodology module or stack pack - add : Add one technology skill (e.g. orm:prisma) - remove Remove a module or skill from this project - update Update installed modules to the latest version - list List installed and available modules/skills + init Detect archetypes and install capabilities (default) + add Add one capability, e.g. a11y or lens:n-plus-one + remove Remove an installed capability (no arg: pick one) + update Re-fetch installed capabilities at the latest version + list List installed archetypes + capabilities status Show version + local-drift status (read-only) Options: --archetype init: deprecated no-op — archetype detection is now the default - -y, --yes Skip the remove confirmation prompt --strict Make status exit 1 on any outdated/modified/missing file --no-drift Skip the status byte-level drift check --json Emit list output as JSON diff --git a/src/lib/capability-index.ts b/src/lib/capability-index.ts index a86df95..f0b173c 100644 --- a/src/lib/capability-index.ts +++ b/src/lib/capability-index.ts @@ -1,5 +1,4 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { safeJoin } from './install-modules.js'; import { detectLayout, layoutPaths } from './layout.js'; import { assertAppliesToken, @@ -8,6 +7,7 @@ import { assertSafeString, CAPABILITY_NAME_RE, ManifestValidationError, + safeJoin, } from './validate.js'; import type { Archetype, CapabilityEntry, CapabilityIndex } from '../types.js'; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1a2a547..9b30656 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,26 +1,13 @@ -// PHARN OSS lives in a single repo with module subfolders (pharn-core, -// pharn-pipeline, …). The CLI fetches the whole repo, then copies the -// selected modules' `installs` maps into the user's `.claude/`. +// PHARN OSS lives in a single repo with capability subtrees (pharn-pipeline/ +// grillers, pharn-review, …). The CLI degit-clones the whole repo at a pinned +// SHA, then copies the resolved capabilities + the fixed product surfaces into +// the user's project (lib/install-capabilities.ts). export const REPO = 'pharn-dev/pharn-oss'; export const REPO_BRANCH = 'main'; export const REPO_URL = 'github.com/pharn-dev/pharn-oss'; -// raw.githubusercontent.com path to the authoritative version + dependency -// manifest. Used by `pharn update` to check the latest skillsVersion without -// cloning the whole repo. -export const MANIFEST_RAW_PATH = `${REPO}/${REPO_BRANCH}/manifest.json`; - export const FIRST_FEATURE_COMMAND = '/pharn-plan'; -// pharn-core is always installed; it is the foundation every other module -// depends on. -export const CORE_MODULE = 'pharn-core'; - -// schemaVersion 2 skill-category modules follow the `pharn-skills-` -// convention (e.g. `orm` → `pharn-skills-orm`). Every installable wizard option -// is rooted at one of these so `add :` can address it. -export const SKILL_MODULE_PREFIX = 'pharn-skills-'; - // --------------------------------------------------------------------------- // Archetype (capability) install (pharn init --archetype). Repo-relative source // paths in the fetched pharn-oss clone. Each capability lives at diff --git a/src/lib/diff.ts b/src/lib/diff.ts index 0c6c489..06e45dd 100644 --- a/src/lib/diff.ts +++ b/src/lib/diff.ts @@ -1,8 +1,6 @@ import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { basename, join, resolve, sep } from 'node:path'; -import { safeJoin } from './install-modules.js'; -import { readModuleManifest } from './manifest.js'; +import { join, resolve, sep } from 'node:path'; import { CLAUDE_COMMANDS_DIR, CLAUDE_HOOKS_DIR, @@ -10,7 +8,8 @@ import { PRODUCT_COMMAND_PREFIX, } from './constants.js'; import { layoutPaths } from './layout.js'; -import type { InstalledCapability, InstalledSkill, Layout } from '../types.js'; +import { safeJoin } from './validate.js'; +import type { InstalledCapability, Layout } from '../types.js'; export interface InstallDiff { // .claude-relative paths present on disk but whose bytes differ from upstream. @@ -21,71 +20,6 @@ export interface InstallDiff { okCount: number; } -/** - * Read-only comparison of the installed `.claude/` tree against a fetched - * pharn-oss clone. Derives the *expected* file set by mirroring installModule / - * installSkills (lib/install-modules.ts), then byte-compares each file with disk. - * - * Pure: no console, no process.exit, no network, no writes — the caller owns - * I/O and presentation. The two user-owned surfaces (`.claude/CONSTITUTION.md` - * and `.claude/memory-bank/`) are materialized at init and hand-edited, so they - * are excluded; the PHARN-owned template *sources* under `.claude/templates/` - * are not (the exclusion is anchored at the .claude/ root). - */ -export function diffInstalled(params: { - repoDir: string; - claudeDir: string; - moduleNames: string[]; - skills: InstalledSkill[]; -}): InstallDiff { - const { repoDir, claudeDir, moduleNames, skills } = params; - - // .claude-relative posix path → repo absolute source-file path. Modules are - // collected first, then skills, last-write-wins — mirroring the installer's - // cpSync(force) order so an overlapping dest is resolved deterministically. - const expected = new Map(); - const add = (rel: string, repoPath: string): void => { - const norm = toPosix(rel); - if (isExcluded(norm)) return; - expected.set(norm, repoPath); - }; - - // Modules: each installs `from`→`to`. statSync the source and branch — the - // schema permits a single-file `from`, not only directories. - for (const name of moduleNames) { - const mm = readModuleManifest(repoDir, name); - const moduleDir = resolve(repoDir, name); - for (const [src, dest] of Object.entries(mm.installs)) { - const from = safeJoin(moduleDir, src); - // A `from` missing at @main HEAD is an upstream packaging inconsistency, - // not local drift — there is nothing to compare, so skip it (never throw). - if (!existsSync(from)) continue; - if (statSync(from).isDirectory()) { - for (const rel of walkFiles(from)) - add(join(dest, rel), resolve(from, rel)); - } else { - add(dest, from); - } - } - } - - // Skills: each `from` lands in .claude/skills// in isolation. - for (const skill of skills) { - const from = safeJoin(repoDir, skill.from); - const destBase = `skills/${basename(skill.from)}`; - if (!existsSync(from)) continue; - if (statSync(from).isDirectory()) { - for (const rel of walkFiles(from)) { - add(join(destBase, rel), resolve(from, rel)); - } - } else { - add(destBase, from); - } - } - - return compareExpected(expected, claudeDir); -} - /** * Read-only comparison of an archetype install's PHARN-owned files against a * fetched clone. Derives the expected set by mirroring installCapabilities @@ -179,17 +113,6 @@ function compareExpected( return { modified, missing, okCount }; } -// User-owned surfaces, anchored at the .claude/ root: the materialized working -// constitution + memory bank. Template sources nested under templates/ (e.g. -// templates/memory-bank/) start with "templates/" and are deliberately NOT matched. -function isExcluded(rel: string): boolean { - return ( - rel === 'CONSTITUTION.md' || - rel === 'memory-bank' || - rel.startsWith('memory-bank/') - ); -} - function hash(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } diff --git a/src/lib/install-capabilities.ts b/src/lib/install-capabilities.ts index 8de9bea..e5fa522 100644 --- a/src/lib/install-capabilities.ts +++ b/src/lib/install-capabilities.ts @@ -1,11 +1,11 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs'; -import { safeJoin } from './install-modules.js'; import { assertNoDotDot, assertSafeString, CAPABILITY_NAME_RE, COPY_FILENAME_RE, ManifestValidationError, + safeJoin, } from './validate.js'; import { CLAUDE_COMMANDS_DIR, diff --git a/src/lib/install-modules.ts b/src/lib/install-modules.ts deleted file mode 100644 index 7a7ebca..0000000 --- a/src/lib/install-modules.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { - cpSync, - existsSync, - lstatSync, - mkdirSync, - readFileSync, - realpathSync, - writeFileSync, -} from 'node:fs'; -import { basename, dirname, resolve, sep } from 'node:path'; -import { CORE_MODULE, DEV_COMMAND_PREFIX } from './constants.js'; -import { ManifestValidationError } from './validate.js'; -import { readModuleManifest } from './manifest.js'; -import { MULTI_TENANT_PRINCIPLE, stripPrinciple } from './constitution.js'; -import type { Constitution, InstalledSkill, ManifestModule } from '../types.js'; - -// Materialized (not just copied via installs map) by the installer for -// pharn-core. Kept in sync with pharn-core/templates/. -const MEMORY_BANK_SRC = 'templates/memory-bank'; -const CONSTITUTION_SRC = 'templates/constitution'; - -// --------------------------------------------------------------------------- -// Untrusted-copy guards (P2) — the LEGACY install path, brought to the same -// posture as the archetype path (install-capabilities.ts). The fetched repo is -// untrusted; a recursive cpSync copies symlinks VERBATIM by default, and the -// pure-string safeJoin below cannot see a symlink planted in a destination -// parent — so a malicious clone could (a) MATERIALIZE a symlink into .claude/, -// or (b) write a file THROUGH a planted symlink to escape .claude/ with no "..". -// Three structural guards close both, mirroring install-capabilities.ts. -// --------------------------------------------------------------------------- - -// True if the path is a symlink (lstat: does not follow it). -const isSymlink = (p: string): boolean => lstatSync(p).isSymbolicLink(); -// A *.test.{mjs,cjs} file — never part of a product install (matches -// install-capabilities.ts's isTestFile). -const isTestFile = (p: string): boolean => /\.test\.(mjs|cjs)$/.test(p); -// A pharn-dev-*.md dev-loop command — never part of a product install -// (Surface A dev/product split; the archetype path excludes DEV_COMMAND_PREFIX). -const isDevCommand = (p: string): boolean => { - const b = basename(p); - return b.startsWith(DEV_COMMAND_PREFIX) && b.endsWith('.md'); -}; -// The cpSync filter for the legacy path: skip symlinks (never materialize one), -// dev-loop commands, and test files. Structural (P0) — set/prefix membership, -// no judgment. Applied to every copy from the untrusted repo below. -const copyFilter = (src: string): boolean => - !isSymlink(src) && !isDevCommand(src) && !isTestFile(src); - -// Reject a destination whose existing parent chain, with symlinks RESOLVED, -// escapes claudeDir. The lexical safeJoin (resolve + startsWith) cannot see a -// planted symlink; realpathSync can. This defends against a symlink already -// present under .claude/ (from a prior run or planted out-of-band) that a copy -// would otherwise write THROUGH. The threat is hostile REMOTE content -// (THREAT-MODEL.md §1 surface B), not a local race during a single-process -// install, so the check-then-write window here is out of scope. -function assertRealDestWithin(claudeDir: string, to: string): void { - // The base must exist to realpath it (on a fresh install .claude/ does not - // exist yet; mkdirSync is idempotent and creates it as a real directory). - mkdirSync(claudeDir, { recursive: true }); - const realBase = realpathSync(claudeDir); - // Walk up to the deepest ALREADY-existing ancestor of `to` (which may not - // exist yet); `to` is lexically under claudeDir, so this stops at claudeDir - // at the latest. - let existing = to; - while (!existsSync(existing)) existing = dirname(existing); - const real = realpathSync(existing); - if (real !== realBase && !real.startsWith(realBase + sep)) { - throw new ManifestValidationError( - `Refusing path escape via symlink: ${to} resolves outside ${claudeDir}`, - ); - } -} - -/** - * Copy a single module's `installs` entries from the fetched repo into the - * user's `.claude/`. Directories merge; files overwrite. Every copy is guarded - * against a symlinked source root (rejected), a nested symlink / dev command / - * test file (skipped), and a destination that escapes `.claude/` via a symlink - * (rejected) — the untrusted-repo posture from install-capabilities.ts (P2). - */ -export function installModule( - repoDir: string, - claudeDir: string, - module: ManifestModule, -): void { - const manifest = readModuleManifest(repoDir, module.name); - for (const [src, dest] of Object.entries(manifest.installs)) { - const from = safeJoin(resolve(repoDir, module.name), src); - const to = safeJoin(claudeDir, dest); - if (!existsSync(from)) { - // A module declaring a path it doesn't ship is a packaging bug upstream; - // surface it rather than silently skipping. - throw new ManifestValidationError( - `${module.name} declares installs "${src}" but it is missing in the fetched repo.`, - ); - } - // Never copy from a symlinked source ROOT (the untrusted repo). - if (isSymlink(from)) { - throw new ManifestValidationError( - `${module.name} installs "${src}" is a symlink; refusing to copy from the untrusted repo.`, - ); - } - // Never write THROUGH a symlink that escapes .claude/ (safeJoin is lexical). - assertRealDestWithin(claudeDir, to); - // Skip nested symlinks, dev commands, and test files during the copy. - cpSync(from, to, { recursive: true, force: true, filter: copyFilter }); - } -} - -/** - * Pre-flight for selective install (schemaVersion 2): assert every skill's - * source folder exists (and is not a symlink) in the fetched repo BEFORE any - * copy, so a bad `from` path fails the whole install with nothing written (no - * partial installs). - */ -export function assertSkillSourcesExist( - repoDir: string, - skills: InstalledSkill[], -): void { - // Skills all land in .claude/skills//, so two sources sharing a - // basename would silently overwrite each other. Reject the whole batch. - const seenBasenames = new Map(); - for (const skill of skills) { - const from = safeJoin(repoDir, skill.from); - if (!existsSync(from)) { - throw new ManifestValidationError( - `Skill "${skill.skill}" declares source "${skill.from}" but it is missing in the fetched repo.`, - ); - } - // A symlinked skill source is refused before any copy (untrusted repo, P2). - if (isSymlink(from)) { - throw new ManifestValidationError( - `Skill "${skill.skill}" source "${skill.from}" is a symlink; refusing to copy from the untrusted repo.`, - ); - } - const name = basename(skill.from); - const prior = seenBasenames.get(name); - if (prior !== undefined && prior !== skill.from) { - throw new ManifestValidationError( - `Duplicate skill basename "${name}": "${prior}" and "${skill.from}" would both install to .claude/skills/${name}/.`, - ); - } - seenBasenames.set(name, skill.from); - } -} - -/** - * Copy each selected skill's subfolder into .claude/skills//. Only the - * exact `from` directory is copied — sibling skills in the same category module - * are never touched. Call assertSkillSourcesExist first (it rejects a symlinked - * source root); nested symlinks are skipped and no copy escapes .claude/ (P2). - */ -export function installSkills( - repoDir: string, - claudeDir: string, - skills: InstalledSkill[], -): void { - for (const skill of skills) { - const from = safeJoin(repoDir, skill.from); - const to = safeJoin(claudeDir, `skills/${basename(skill.from)}`); - assertRealDestWithin(claudeDir, to); - cpSync(from, to, { recursive: true, force: true, filter: copyFilter }); - } -} - -/** - * pharn-core post-install: materialize the memory bank at .claude/memory-bank/ - * and the chosen constitution variant at .claude/CONSTITUTION.md. The template - * sources are still untrusted fetched content, so the same symlink guards apply. - */ -export function materializeCore( - repoDir: string, - claudeDir: string, - constitution: Constitution, - isMultiTenant = true, -): void { - const coreDir = resolve(repoDir, CORE_MODULE); - - const memoryFrom = resolve(coreDir, MEMORY_BANK_SRC); - if (!existsSync(memoryFrom)) { - // pharn-core not shipping its memory bank is an upstream packaging bug; - // surface it rather than silently producing an install without one. - throw new ManifestValidationError( - `pharn-core is missing its memory bank at ${MEMORY_BANK_SRC} in the fetched repo.`, - ); - } - if (isSymlink(memoryFrom)) { - throw new ManifestValidationError( - `pharn-core memory bank at ${MEMORY_BANK_SRC} is a symlink; refusing to copy from the untrusted repo.`, - ); - } - const memoryTo = resolve(claudeDir, 'memory-bank'); - assertRealDestWithin(claudeDir, memoryTo); - cpSync(memoryFrom, memoryTo, { - recursive: true, - force: true, - filter: (s) => copyFilter(s) && !s.endsWith(`${sep}.gitkeep`), - }); - - const constitutionFrom = resolve( - coreDir, - CONSTITUTION_SRC, - `CONSTITUTION.${constitution}.md`, - ); - if (!existsSync(constitutionFrom)) { - throw new ManifestValidationError( - `Constitution variant "${constitution}" not found in pharn-core templates.`, - ); - } - if (isSymlink(constitutionFrom)) { - throw new ManifestValidationError( - `Constitution variant "${constitution}" is a symlink; refusing to copy from the untrusted repo.`, - ); - } - const constitutionTo = resolve(claudeDir, 'CONSTITUTION.md'); - assertRealDestWithin(claudeDir, constitutionTo); - if (isMultiTenant) { - cpSync(constitutionFrom, constitutionTo, { force: true }); - } else { - // Non-SaaS project: drop Principle 2 (Multi-Tenant Isolation) so it is not - // a blocking principle. principles_included + the `## Principle N` headings - // are kept consistent (pharn-init's [A2] check). - const stripped = stripPrinciple( - readFileSync(constitutionFrom, 'utf8'), - MULTI_TENANT_PRINCIPLE, - ); - writeFileSync(constitutionTo, stripped); - } -} - -// Defense-in-depth against path traversal in installs maps (already validated -// by INSTALL_PATH_RE, but never let a copy escape its base directory). This is -// the LEXICAL first gate (resolve + startsWith); it does NOT resolve symlinks — -// assertRealDestWithin above is the symlink-aware backstop at the write sites. -// Exported so the read-only drift check (lib/diff.ts) guards its reads the same -// way. -export function safeJoin(base: string, rel: string): string { - const target = resolve(base, rel); - const root = resolve(base); - if (target !== root && !target.startsWith(root + sep)) { - throw new ManifestValidationError( - `Refusing path escape: ${rel} resolves outside ${base}`, - ); - } - return target; -} diff --git a/src/lib/installer.ts b/src/lib/installer.ts deleted file mode 100644 index 1b1314f..0000000 --- a/src/lib/installer.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { fetchRepo } from './repo.js'; -import { readManifest, resolveModules } from './manifest.js'; -import { - assertSkillSourcesExist, - installModule, - installSkills, - materializeCore, -} from './install-modules.js'; -import type { Constitution, InstalledSkill, ManifestModule } from '../types.js'; - -export interface InstallResult { - skillsVersion: string; - commit: string | null; - resolved: ManifestModule[]; - installedSkills: InstalledSkill[]; -} - -/** - * Clone pharn-oss, resolve the full module set from the selected names, copy - * each module's installs into `claudeDir`, and (when a constitution variant is - * given) materialize the memory bank + CONSTITUTION.md. - * - * Reads the manifest from the cloned commit so the installed versions exactly - * match what shipped at that SHA. - */ -export async function fetchAndInstall(params: { - claudeDir: string; - selected: string[]; - constitution?: Constitution; - // schemaVersion 2: specific skill subfolders resolved from wizard answers. - wizardSkills?: InstalledSkill[]; - // Whether the project is a multi-tenant SaaS. When false, Principle 2 is - // stripped from the materialized constitution. Defaults to true. - isMultiTenant?: boolean; -}): Promise { - const repo = await fetchRepo(); - const wizardSkills = params.wizardSkills ?? []; - try { - const manifest = readManifest(repo.dir); - const resolved = resolveModules(manifest, params.selected); - - // Validate every skill source up front so a bad path fails before any file - // is written — no partial installs. - assertSkillSourcesExist(repo.dir, wizardSkills); - - for (const mod of resolved) { - installModule(repo.dir, params.claudeDir, mod); - } - installSkills(repo.dir, params.claudeDir, wizardSkills); - if (params.constitution) { - materializeCore( - repo.dir, - params.claudeDir, - params.constitution, - params.isMultiTenant, - ); - } - - // The SHA the tree was pinned to (recorded == fetched, or null when the SHA - // was unresolved and the branch was floated — LIMITS.md §3b). No separate - // fetch: reusing repo.sha closes the resolve/fetch TOCTOU. - const commit = repo.sha; - return { - skillsVersion: manifest.skillsVersion, - commit, - resolved, - installedSkills: wizardSkills, - }; - } finally { - repo.cleanup(); - } -} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index 30a907d..df97a53 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { safeJoin } from './install-modules.js'; +import { safeJoin } from './validate.js'; import { CONTRACTS_DIR, FLOOR_DIR, diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts deleted file mode 100644 index c6d5ee9..0000000 --- a/src/lib/manifest.ts +++ /dev/null @@ -1,698 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { - MANIFEST_RAW_PATH, - CORE_MODULE, - SKILL_MODULE_PREFIX, -} from './constants.js'; -import { - ManifestValidationError, - MODULE_NAME_RE, - VERSION_RE, - INSTALL_PATH_RE, - WIZARD_VALUE_RE, - PACKAGE_NAME_RE, - assertSafeString, - assertNoDotDot, - isPlainObject, -} from './validate.js'; -import type { - Manifest, - ManifestModule, - ModulePrerequisite, - ModuleManifest, - WizardCondition, - WizardOption, - WizardQuestion, - WizardRule, - WizardSection, - WizardSpec, -} from '../types.js'; - -const SKILL_CATEGORY_KIND = 'skill-category'; - -const RAW = 'https://raw.githubusercontent.com'; -export const MAX_BODY_BYTES = 256 * 1024; -export const FETCH_TIMEOUT_MS = 8000; - -// --------------------------------------------------------------------------- -// Parsing / validation -// --------------------------------------------------------------------------- - -export function parseManifest(raw: unknown): Manifest { - if (!isPlainObject(raw)) { - throw new ManifestValidationError('manifest must be an object'); - } - // Hard rejection on schema mismatch is intentional: bumping schemaVersion - // requires a coordinated CLI release. Old CLIs MUST fail loudly rather than - // guess at a new schema. This CLI understands 1 (legacy) and 2 (wizard). - if (raw.schemaVersion !== 1 && raw.schemaVersion !== 2) { - throw new ManifestValidationError( - `Unsupported manifest schemaVersion: ${JSON.stringify(raw.schemaVersion)}. Upgrade the CLI: npm i -g pharn-cli@latest`, - ); - } - const schemaVersion = raw.schemaVersion; - const skillsVersion = assertSafeString( - raw.skillsVersion, - 'manifest.skillsVersion', - VERSION_RE, - ); - if (!Array.isArray(raw.modules) || raw.modules.length === 0) { - throw new ManifestValidationError( - 'manifest.modules must be a non-empty array', - ); - } - const modules = raw.modules.map((m, i) => - parseManifestModule(m, `manifest.modules[${i}]`), - ); - if (schemaVersion === 1) { - return { schemaVersion: 1, skillsVersion, modules }; - } - // schemaVersion 2 carries the wizard block (single source of truth for the - // init questionnaire). A malformed wizard is a hard failure — never silently - // fall back to the legacy flow. - const wizard = parseWizard(raw.wizard, new Set(modules.map((m) => m.name))); - return { schemaVersion: 2, skillsVersion, modules, wizard }; -} - -// Fields shared by the repo-root manifest's module entries and each module.json -// (name, version, required, dependsOn, exclusiveWith, description). -interface CommonModuleFields { - name: string; - version: string; - required: boolean; - dependsOn: string[]; - exclusiveWith: string[] | undefined; - description: string; -} - -// exclusiveWith entries are glob patterns (e.g. `pharn-stack-*`), so the -// allowlist tolerates `*` on top of the module-name charset. -const EXCLUSIVE_WITH_RE = /^[a-z0-9*-]+$/; - -function parseCommonModuleFields( - raw: Record, - path: string, -): CommonModuleFields { - const name = assertSafeString(raw.name, `${path}.name`, MODULE_NAME_RE); - const version = assertSafeString(raw.version, `${path}.version`, VERSION_RE); - if (typeof raw.required !== 'boolean') { - throw new ManifestValidationError(`${path}.required must be a boolean`); - } - const dependsOn = parseStringArray(raw.dependsOn, `${path}.dependsOn`, [ - MODULE_NAME_RE, - ]); - const exclusiveWith = - raw.exclusiveWith === undefined - ? undefined - : parseStringArray(raw.exclusiveWith, `${path}.exclusiveWith`, [ - EXCLUSIVE_WITH_RE, - ]); - const description = assertSafeString( - raw.description, - `${path}.description`, - /.+/s, - ); - return { - name, - version, - required: raw.required, - dependsOn, - exclusiveWith, - description, - }; -} - -function parseManifestModule(raw: unknown, path: string): ManifestModule { - if (!isPlainObject(raw)) { - throw new ManifestValidationError(`${path} must be an object`); - } - const common = parseCommonModuleFields(raw, path); - // Optional `kind` (schemaVersion 2): "skill-category" modules are wizard- - // driven. Unknown kinds are tolerated but carry no special behavior. - const kind = - raw.kind === undefined - ? undefined - : assertSafeString(raw.kind, `${path}.kind`, WIZARD_VALUE_RE); - const prerequisites = parsePrerequisites( - raw.prerequisites, - `${path}.prerequisites`, - ); - return { ...common, kind, prerequisites }; -} - -// Optional `prerequisites`: npm packages the module requires to already be -// installed in the user's project, each with a user-facing reason. A -// schemaVersion 2 concept, but parsed whenever present (like `kind`); absent on -// legacy manifests — tolerated, never inferred. -function parsePrerequisites( - raw: unknown, - path: string, -): ModulePrerequisite[] | undefined { - if (raw === undefined) return undefined; - if (!Array.isArray(raw)) { - throw new ManifestValidationError(`${path} must be an array`); - } - return raw.map((entry, i) => { - const p = `${path}[${i}]`; - if (!isPlainObject(entry)) { - throw new ManifestValidationError(`${p} must be an object`); - } - const pkg = assertSafeString( - entry.package, - `${p}.package`, - PACKAGE_NAME_RE, - ); - assertNoDotDot(pkg, `${p}.package`); - // reason is shown verbatim, so it must be single-line (control chars, - // including newlines, are rejected by assertSafeString). - const reason = assertSafeString(entry.reason, `${p}.reason`, /.+/s); - return { package: pkg, reason }; - }); -} - -export function parseModuleManifest( - raw: unknown, - path = 'module.json', -): ModuleManifest { - if (!isPlainObject(raw)) { - throw new ManifestValidationError(`${path} must be an object`); - } - const common = parseCommonModuleFields(raw, path); - if (!isPlainObject(raw.installs)) { - throw new ManifestValidationError(`${path}.installs must be an object`); - } - const installs: Record = {}; - for (const [src, dest] of Object.entries(raw.installs)) { - const safeSrc = assertSafeString( - src, - `${path}.installs key`, - INSTALL_PATH_RE, - ); - assertNoDotDot(safeSrc, `${path}.installs key`); - const safeDest = assertSafeString( - dest, - `${path}.installs[${src}]`, - INSTALL_PATH_RE, - ); - assertNoDotDot(safeDest, `${path}.installs[${src}]`); - installs[safeSrc] = safeDest; - } - if (Object.keys(installs).length === 0) { - throw new ManifestValidationError(`${path}.installs must not be empty`); - } - return { ...common, installs }; -} - -function parseStringArray( - value: unknown, - label: string, - patterns: RegExp[] = [], -): string[] { - if (!Array.isArray(value)) { - throw new ManifestValidationError(`${label} must be an array`); - } - return value.map((v, i) => { - if (typeof v !== 'string') { - throw new ManifestValidationError(`${label}[${i}] must be a string`); - } - for (const p of patterns) { - assertSafeString(v, `${label}[${i}]`, p); - } - return v; - }); -} - -// --------------------------------------------------------------------------- -// Wizard block parsing / validation (schemaVersion 2) -// --------------------------------------------------------------------------- - -const RULE_TYPES = new Set(['hide', 'hideQuestion', 'relabel', 'warn']); - -export function parseWizard( - raw: unknown, - moduleNames: Set, -): WizardSpec { - if (!isPlainObject(raw)) { - throw new ManifestValidationError('manifest.wizard must be an object'); - } - if (!Array.isArray(raw.sections) || raw.sections.length === 0) { - throw new ManifestValidationError( - 'manifest.wizard.sections must be a non-empty array', - ); - } - const sections = raw.sections.map((s, i) => - parseWizardSection(s, `wizard.sections[${i}]`, moduleNames), - ); - if (!isPlainObject(raw.defaults)) { - throw new ManifestValidationError( - 'manifest.wizard.defaults must be an object', - ); - } - // Answers live in a flat questionId -> value map, so a question id reused - // across sections would silently collide. Hard-fail naming the id. - const questionOptions = new Map>(); - for (const section of sections) { - for (const question of section.questions) { - if (questionOptions.has(question.id)) { - throw new ManifestValidationError( - `manifest.wizard has a duplicate question id "${question.id}" — ids must be unique across sections.`, - ); - } - questionOptions.set( - question.id, - new Set(question.options.map((o) => o.value)), - ); - } - } - // `defaults` maps question id -> chosen option value. Keys that are not - // question ids (e.g. a `comment` documentation key, a common JSON idiom) are - // metadata and ignored — only real answers are validated and stored. A - // question-id key whose value matches no option is a packaging bug (Default - // mode would silently install nothing for it), so hard-fail. - const defaults: Record = {}; - for (const [key, value] of Object.entries(raw.defaults)) { - const optionValues = questionOptions.get(key); - if (!optionValues) { - continue; - } - const safe = assertSafeString( - value, - `wizard.defaults["${key}"]`, - WIZARD_VALUE_RE, - ); - if (!optionValues.has(safe)) { - throw new ManifestValidationError( - `wizard.defaults["${key}"] = ${JSON.stringify(safe)} is not an option of question "${key}".`, - ); - } - defaults[key] = safe; - } - return { sections, defaults }; -} - -function parseWizardSection( - raw: unknown, - path: string, - moduleNames: Set, -): WizardSection { - if (!isPlainObject(raw)) { - throw new ManifestValidationError(`${path} must be an object`); - } - const id = assertSafeString(raw.id, `${path}.id`, WIZARD_VALUE_RE); - const title = assertSafeString(raw.title, `${path}.title`, /.+/s); - if (!Array.isArray(raw.questions) || raw.questions.length === 0) { - throw new ManifestValidationError( - `${path} (section "${id}") questions must be a non-empty array`, - ); - } - const questions = raw.questions.map((q, i) => - parseWizardQuestion(q, `${path}.questions[${i}]`, id, moduleNames), - ); - return { id, title, questions }; -} - -function parseWizardQuestion( - raw: unknown, - path: string, - sectionId: string, - moduleNames: Set, -): WizardQuestion { - if (!isPlainObject(raw)) { - throw new ManifestValidationError( - `${path} (section "${sectionId}") must be an object`, - ); - } - const id = assertSafeString(raw.id, `${path}.id`, WIZARD_VALUE_RE); - const prompt = assertSafeString(raw.prompt, `${path}.prompt`, /.+/s); - if (!Array.isArray(raw.options) || raw.options.length === 0) { - throw new ManifestValidationError( - `${path} (question "${id}") options must be a non-empty array`, - ); - } - const options = raw.options.map((o, i) => - parseWizardOption(o, `${path}.options[${i}]`, id, moduleNames), - ); - const rules = - raw.rules === undefined - ? undefined - : parseWizardRules(raw.rules, `${path}.rules`, id); - return { id, prompt, options, rules }; -} - -function parseWizardOption( - raw: unknown, - path: string, - questionId: string, - moduleNames: Set, -): WizardOption { - if (!isPlainObject(raw)) { - throw new ManifestValidationError( - `${path} (question "${questionId}") must be an object`, - ); - } - const value = assertSafeString(raw.value, `${path}.value`, WIZARD_VALUE_RE); - const label = assertSafeString(raw.label, `${path}.label`, /.+/s); - if (raw.default !== undefined && typeof raw.default !== 'boolean') { - throw new ManifestValidationError(`${path}.default must be a boolean`); - } - if (raw.comingSoon !== undefined && typeof raw.comingSoon !== 'boolean') { - throw new ManifestValidationError(`${path}.comingSoon must be a boolean`); - } - let install: string | null = null; - if (raw.install !== null && raw.install !== undefined) { - install = assertSafeString(raw.install, `${path}.install`, INSTALL_PATH_RE); - assertNoDotDot(install, `${path}.install`); - // The first path segment must be a known module so `add` can resolve it and - // selective install copies from a real source. - const root = install.split('/')[0]!; - if (!moduleNames.has(root)) { - throw new ManifestValidationError( - `${path}.install "${install}" does not start with a known module name`, - ); - } - // It must also be a pharn-skills-* category so the option is addressable via - // `add :`; listSkillAddresses relies on this prefix. - if (!root.startsWith(SKILL_MODULE_PREFIX)) { - throw new ManifestValidationError( - `${path}.install "${install}" must be rooted at a ${SKILL_MODULE_PREFIX}* category module`, - ); - } - } - // Optional `detect` (schemaVersion 2 / oss-6): npm packages whose presence in - // the project marks this option as detected for wizard pre-fill. Validated - // like prerequisites — package-name charset and no '..' (compared against - // package.json keys, never path-joined). Absent on older manifests. - let detect: string[] | undefined; - if (raw.detect !== undefined) { - detect = parseStringArray(raw.detect, `${path}.detect`, [PACKAGE_NAME_RE]); - for (const pkg of detect) assertNoDotDot(pkg, `${path}.detect`); - } - return { - value, - label, - default: raw.default as boolean | undefined, - install, - comingSoon: raw.comingSoon as boolean | undefined, - detect, - }; -} - -function parseWizardRules( - raw: unknown, - path: string, - questionId: string, -): WizardRule[] { - if (!Array.isArray(raw)) { - throw new ManifestValidationError( - `${path} (question "${questionId}") must be an array`, - ); - } - return raw.map((r, i) => parseWizardRule(r, `${path}[${i}]`, questionId)); -} - -function parseWizardRule( - raw: unknown, - path: string, - questionId: string, -): WizardRule { - if (!isPlainObject(raw)) { - throw new ManifestValidationError( - `${path} (question "${questionId}") must be an object`, - ); - } - const type = raw.type; - if (typeof type !== 'string' || !RULE_TYPES.has(type)) { - throw new ManifestValidationError( - `${path}.type must be one of hide | hideQuestion | relabel | warn`, - ); - } - const cond = parseWizardCondition(raw.if, `${path}.if`); - switch (type) { - case 'hideQuestion': - return { type, if: cond }; - case 'hide': - return { - type, - if: cond, - options: parseStringArray(raw.options, `${path}.options`, [ - WIZARD_VALUE_RE, - ]), - }; - case 'relabel': - return { - type, - if: cond, - options: parseStringArray(raw.options, `${path}.options`, [ - WIZARD_VALUE_RE, - ]), - label: assertSafeString(raw.label, `${path}.label`, /.+/s), - }; - default: - return { - type: 'warn', - if: cond, - message: assertSafeString(raw.message, `${path}.message`, /.+/s), - }; - } -} - -function parseWizardCondition(raw: unknown, path: string): WizardCondition { - if (!isPlainObject(raw) || Object.keys(raw).length === 0) { - throw new ManifestValidationError(`${path} must be a non-empty object`); - } - const cond: WizardCondition = {}; - for (const [key, value] of Object.entries(raw)) { - assertSafeString(key, `${path} key`, WIZARD_VALUE_RE); - if (typeof value === 'string') { - cond[key] = assertSafeString(value, `${path}["${key}"]`, WIZARD_VALUE_RE); - } else if (isPlainObject(value) && typeof value.not === 'string') { - cond[key] = { - not: assertSafeString( - value.not, - `${path}["${key}"].not`, - WIZARD_VALUE_RE, - ), - }; - } else { - throw new ManifestValidationError( - `${path}["${key}"] must be a string or { not: string }`, - ); - } - } - return cond; -} - -// --------------------------------------------------------------------------- -// Reading from a fetched repo on disk -// --------------------------------------------------------------------------- - -export function readManifest(repoDir: string): Manifest { - const path = resolve(repoDir, 'manifest.json'); - if (!existsSync(path)) { - throw new ManifestValidationError(`manifest.json not found at ${path}`); - } - return parseManifest(JSON.parse(readFileSync(path, 'utf8'))); -} - -export function readModuleManifest( - repoDir: string, - name: string, -): ModuleManifest { - assertSafeString(name, 'module name', MODULE_NAME_RE); - const path = resolve(repoDir, name, 'module.json'); - if (!existsSync(path)) { - throw new ManifestValidationError( - `${name}/module.json not found at ${path}`, - ); - } - return parseModuleManifest( - JSON.parse(readFileSync(path, 'utf8')), - `${name}/module.json`, - ); -} - -// --------------------------------------------------------------------------- -// Dependency resolution -// --------------------------------------------------------------------------- - -export class ResolutionError extends Error { - constructor(message: string) { - super(message); - this.name = 'ResolutionError'; - } -} - -function globMatch(pattern: string, name: string): boolean { - if (!pattern.includes('*')) return pattern === name; - const re = new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`); - return re.test(name); -} - -function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Resolve the full, ordered set of modules to install from a set of explicit - * selections. Always includes pharn-core, pulls in transitive dependsOn, and - * enforces exclusiveWith. Order places dependencies before dependents. - */ -export function resolveModules( - manifest: Manifest, - selected: string[], -): ManifestModule[] { - const byName = new Map(manifest.modules.map((m) => [m.name, m])); - - // Always include required modules + pharn-core. - const requested = new Set([CORE_MODULE]); - for (const m of manifest.modules) if (m.required) requested.add(m.name); - for (const name of selected) { - if (!byName.has(name)) { - throw new ResolutionError(`Unknown module: ${name}`); - } - requested.add(name); - } - - // Transitive dependency closure. - const resolved = new Set(); - const visiting = new Set(); - const order: ManifestModule[] = []; - - const visit = (name: string): void => { - if (resolved.has(name)) return; - if (visiting.has(name)) { - throw new ResolutionError(`Dependency cycle involving ${name}`); - } - const mod = byName.get(name); - if (!mod) throw new ResolutionError(`Unknown module: ${name}`); - visiting.add(name); - for (const dep of mod.dependsOn) visit(dep); - visiting.delete(name); - resolved.add(name); - order.push(mod); - }; - for (const name of requested) visit(name); - - // Exclusivity: no two resolved modules may exclude each other. A module in - // the same dependency chain (e.g. a stack pack and its own base) is never a - // conflict, even if it matches the glob. - const depsClosure = (name: string): Set => { - const seen = new Set(); - const stack = [...(byName.get(name)?.dependsOn ?? [])]; - while (stack.length) { - const dep = stack.pop()!; - if (seen.has(dep)) continue; - seen.add(dep); - stack.push(...(byName.get(dep)?.dependsOn ?? [])); - } - return seen; - }; - for (const mod of order) { - const modDeps = depsClosure(mod.name); - for (const pattern of mod.exclusiveWith ?? []) { - for (const other of order) { - if (other.name === mod.name) continue; - if (modDeps.has(other.name) || depsClosure(other.name).has(mod.name)) { - continue; - } - if (globMatch(pattern, other.name)) { - throw new ResolutionError( - `${mod.name} is exclusive with ${other.name} — pick only one.`, - ); - } - } - } - } - - return order; -} - -// --------------------------------------------------------------------------- -// Categorization for the wizard -// --------------------------------------------------------------------------- - -export interface ModuleCategories { - // Required foundation (pharn-core); always installed, never offered. - core: ManifestModule[]; - // User-facing optional modules offered as a multiselect. - optional: ManifestModule[]; - // Mutually-exclusive stack packs (e.g. pharn-stack-nextjs) offered as a - // single choice. Their shared bases (e.g. pharn-stack-react) are pulled in - // as dependencies, not offered directly. - stackPacks: ManifestModule[]; -} - -export function isStackPack(m: ManifestModule): boolean { - return (m.exclusiveWith ?? []).some((p) => p.includes('*')); -} - -export function categorizeModules(manifest: Manifest): ModuleCategories { - const stackPacks = manifest.modules.filter(isStackPack); - - // Bases that exist only to back a stack pack — kept out of the optional list. - const stackBases = new Set(); - for (const pack of stackPacks) { - for (const dep of pack.dependsOn) stackBases.add(dep); - } - - const core = manifest.modules.filter((m) => m.required); - const optional = manifest.modules.filter( - (m) => - !m.required && - !isStackPack(m) && - !stackBases.has(m.name) && - // schemaVersion 2: category modules are driven by the wizard, never the - // methodology multiselect. No-op on v1 (kind is absent there). - m.kind !== SKILL_CATEGORY_KIND, - ); - return { core, optional, stackPacks }; -} - -/** - * The stack pack to preselect from the project's installed packages: the first - * pack whose (non-empty) prerequisites are all present. A pack with no - * prerequisites never auto-matches. Returns null when none qualify (→ "None"). - */ -export function detectStackPack( - stackPacks: ManifestModule[], - packages: Set, -): string | null { - for (const pack of stackPacks) { - const pres = pack.prerequisites ?? []; - if (pres.length > 0 && pres.every((p) => packages.has(p.package))) { - return pack.name; - } - } - return null; -} - -// --------------------------------------------------------------------------- -// Remote manifest fetch (for `pharn update`, avoids cloning the repo) -// --------------------------------------------------------------------------- - -export async function fetchRemoteManifest(): Promise { - const url = `${RAW}/${MANIFEST_RAW_PATH}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - let res: Response; - try { - res = await fetch(url, { redirect: 'error', signal: controller.signal }); - } finally { - clearTimeout(timer); - } - if (!res.ok) { - throw new Error(`Manifest fetch failed (${res.status}) from ${url}`); - } - // Reject oversized bodies before buffering when the server advertises a size; - // the post-read length check below stays as the fallback for chunked replies. - const declared = Number(res.headers.get('content-length')); - if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { - throw new Error(`Manifest too large (${declared} bytes) from ${url}`); - } - const text = await res.text(); - if (text.length > MAX_BODY_BYTES) { - throw new Error(`Manifest too large (${text.length} bytes) from ${url}`); - } - return parseManifest(JSON.parse(text)); -} diff --git a/src/lib/pharn-config.ts b/src/lib/pharn-config.ts index 022e73c..d6bb097 100644 --- a/src/lib/pharn-config.ts +++ b/src/lib/pharn-config.ts @@ -133,3 +133,29 @@ export function toInstalledModules( export function isArchetypeConfig(config: PharnConfig): boolean { return Array.isArray(config.capabilities); } + +// The single message shown when a command runs against a pre-archetype (module) +// config, which is no longer operable: the module/manifest install path was +// removed (live pharn-oss ships no manifest.json), so add/update/status/remove +// cannot resolve it. `list` emits this json-aware (to stderr in --json); the +// other four go through loadArchetypeConfigOrExit. Deterministic (P5): the branch +// is the isArchetypeConfig membership test and its terminal is this named +// hard-fail, never a guess or a silent proceed (P6). +export const LEGACY_CONFIG_MESSAGE = + 'This project uses the legacy module layout (pre-archetype), which is no longer supported. Re-run `pharn init` to reinstall with the archetype/capability model.'; + +/** + * Load pharn.config.json and require it be an archetype (capability) install, or + * exit(1) with the single `LEGACY_CONFIG_MESSAGE`. The shared load surface for + * `add`/`update`/`status`/`remove` after the module/manifest path was removed — + * it single-sources the legacy reject (P3, no 5× copy). `list` keeps its own + * json-aware check because its error must stay on stderr in `--json`. + */ +export function loadArchetypeConfigOrExit(cwd: string): PharnConfig { + const config = loadConfigOrExit(cwd); + if (!isArchetypeConfig(config)) { + log.error(LEGACY_CONFIG_MESSAGE); + process.exit(1); + } + return config; +} diff --git a/src/lib/skills-version.ts b/src/lib/skills-version.ts index 2ef46f1..967b5f8 100644 --- a/src/lib/skills-version.ts +++ b/src/lib/skills-version.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { REPO, REPO_BRANCH, SKILLS_VERSION_FILE } from './constants.js'; -import { safeJoin } from './install-modules.js'; import { assertSafeString, ManifestValidationError, + safeJoin, VERSION_RE, } from './validate.js'; @@ -36,9 +36,9 @@ export function readSkillsVersion(repoDir: string): string { /** * Fetch the latest `SKILLS_VERSION` from `@main` without cloning — the - * lightweight currency check `status --no-drift` uses. Mirrors - * `fetchRemoteManifest`'s three network guards (redirect:'error', an 8s timeout, - * a 256KB body cap) and validates the result (P2). + * lightweight currency check `status --no-drift` / `update` uses. Applies the + * three network guards (redirect:'error', an 8s timeout, a 256KB body cap) and + * validates the result (P2). */ export async function fetchRemoteSkillsVersion(): Promise { const url = `${RAW}/${REPO}/${REPO_BRANCH}/${SKILLS_VERSION_FILE}`; diff --git a/src/lib/validate.ts b/src/lib/validate.ts index e22951e..17f2362 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -1,3 +1,5 @@ +import { resolve, sep } from 'node:path'; + export class ManifestValidationError extends Error { constructor(message: string) { super(message); @@ -107,3 +109,21 @@ export function assertNoDotDot(value: string, label: string): void { export function isPlainObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } + +// Defense-in-depth against path traversal in fetched/copied paths (already +// validated by INSTALL_PATH_RE / CAPABILITY_NAME_RE upstream, but never let a +// copy or read escape its base directory). This is the LEXICAL gate (resolve + +// startsWith); it does NOT resolve symlinks — the symlink-aware backstop lives +// at the copy write sites in install-capabilities.ts (P2). Co-located with the +// other structural-validation primitives (one security-validation axis, P3); +// the archetype install + drift check + remove all guard their fs access with it. +export function safeJoin(base: string, rel: string): string { + const target = resolve(base, rel); + const root = resolve(base); + if (target !== root && !target.startsWith(root + sep)) { + throw new ManifestValidationError( + `Refusing path escape: ${rel} resolves outside ${base}`, + ); + } + return target; +} diff --git a/src/lib/wizard.ts b/src/lib/wizard.ts deleted file mode 100644 index 201f287..0000000 --- a/src/lib/wizard.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { basename } from 'node:path'; -import { SKILL_MODULE_PREFIX } from './constants.js'; -import type { - InstalledSkill, - WizardCondition, - WizardOption, - WizardQuestion, - WizardRule, - WizardSpec, -} from '../types.js'; - -type WarnRule = Extract; - -// --------------------------------------------------------------------------- -// Pure wizard logic (no I/O): rule evaluation + answer → install resolution. -// `manifest.wizard` is the single source of truth; this module never hardcodes -// questions, options, or installs. -// --------------------------------------------------------------------------- - -export type Answers = Record; - -/** - * Evaluate a rule condition against the answers gathered so far. AND across - * keys; each value is an exact match or a negation. A missing answer key fails - * an equality check and satisfies a negation (rules read prior answers, so the - * referenced question is normally already answered). - */ -export function matchCondition( - cond: WizardCondition, - answers: Answers, -): boolean { - for (const [key, expected] of Object.entries(cond)) { - const actual = answers[key]; - if (typeof expected === 'string') { - if (actual !== expected) return false; - } else if (actual === expected.not) { - return false; - } - } - return true; -} - -export interface ResolvedQuestion { - // True when a hideQuestion rule matched — skip the prompt, record "skip". - hidden: boolean; - // Options after hide/relabel rules are applied (comingSoon flags preserved). - options: WizardOption[]; -} - -/** - * Apply hide / hideQuestion / relabel rules to a question given prior answers. - * `warn` rules are evaluated separately (after the answer) by pendingWarnings. - */ -export function applyRulesToQuestion( - question: WizardQuestion, - answers: Answers, -): ResolvedQuestion { - let options = question.options; - let hidden = false; - - for (const rule of question.rules ?? []) { - if (!matchCondition(rule.if, answers)) continue; - switch (rule.type) { - case 'hideQuestion': - hidden = true; - break; - case 'hide': { - const drop = new Set(rule.options); - options = options.filter((o) => !drop.has(o.value)); - break; - } - case 'relabel': { - const targets = new Set(rule.options); - options = options.map((o) => - targets.has(o.value) ? { ...o, label: rule.label } : o, - ); - break; - } - // warn handled by pendingWarnings. - } - } - return { hidden, options }; -} - -/** - * Warn messages whose condition matches the answers (including the answer just - * given for this question). The caller soft-confirms each before continuing. - */ -export function pendingWarnings( - question: WizardQuestion, - answers: Answers, -): string[] { - return (question.rules ?? []) - .filter( - (r): r is WarnRule => r.type === 'warn' && matchCondition(r.if, answers), - ) - .map((r) => r.message); -} - -function findChosenOption( - question: WizardQuestion, - answers: Answers, -): WizardOption | undefined { - const value = answers[question.id]; - if (value === undefined) return undefined; - return question.options.find((o) => o.value === value); -} - -function* eachQuestion(wizard: WizardSpec): Generator { - for (const section of wizard.sections) { - for (const question of section.questions) yield question; - } -} - -/** - * The skill subfolders to copy for the given answers: every answered option - * carrying a non-null `install`. Each becomes `.claude/skills//`. - */ -export function collectInstalls( - wizard: WizardSpec, - answers: Answers, -): InstalledSkill[] { - const skills: InstalledSkill[] = []; - for (const question of eachQuestion(wizard)) { - const option = findChosenOption(question, answers); - if (option?.install) { - skills.push({ skill: basename(option.install), from: option.install }); - } - } - return skills; -} - -/** - * Detected answers from the project's installed packages: for each question, - * the first selectable (non-comingSoon) option whose `detect` list names an - * installed package. Questions with no match are omitted — the caller falls - * back to defaults (Default mode) or the prompt's own default (Custom mode). - */ -export function detectAnswers( - wizard: WizardSpec, - packages: Set, -): Answers { - const detected: Answers = {}; - for (const question of eachQuestion(wizard)) { - for (const option of question.options) { - if (option.comingSoon) continue; - if (option.detect?.some((pkg) => packages.has(pkg))) { - detected[question.id] = option.value; - break; - } - } - } - return detected; -} - -/** - * Default mode answers: wizard.defaults with any detected answers overlaid - * (detection wins; undetected questions keep their default), then the wizard - * rules applied so the result is what a Custom run would produce if every - * default were accepted. Questions hidden by a hideQuestion rule become "skip" - * (exactly as Custom mode records them); a value a hide rule removed — or a - * coming-soon option — snaps to the question's own default, so Default mode - * never carries an unselectable answer (and never installs a skill for a - * question the rules would have hidden). Rules read answers in question order, - * matching runWizardQuestions. - */ -export function applyDefaults( - wizard: WizardSpec, - detected: Answers = {}, -): Answers { - const merged: Answers = { ...wizard.defaults, ...detected }; - const resolved: Answers = {}; - for (const question of eachQuestion(wizard)) { - const { hidden, options } = applyRulesToQuestion(question, resolved); - if (hidden) { - resolved[question.id] = 'skip'; - continue; - } - const selectable = options.filter((o) => !o.comingSoon); - const want = merged[question.id]; - resolved[question.id] = - want !== undefined && selectable.some((o) => o.value === want) - ? want - : (selectable.find((o) => o.default)?.value ?? - selectable[0]?.value ?? - 'skip'); - } - return resolved; -} - -/** - * Human-readable labels for the given answers, in question order — used to - * surface detections to the user. Falls back to the raw value if no option - * matches (e.g. a stale answer no longer in the wizard). - */ -export function describeAnswers( - wizard: WizardSpec, - answers: Answers, -): string[] { - const labels: string[] = []; - for (const question of eachQuestion(wizard)) { - const value = answers[question.id]; - if (value === undefined) continue; - const option = question.options.find((o) => o.value === value); - labels.push(option?.label ?? value); - } - return labels; -} - -export interface SkillAddress { - // e.g. "orm" - category: string; - // module name, e.g. "pharn-skills-orm" - module: string; - // e.g. "drizzle" (the option value AND install basename) - skill: string; - questionId: string; - install: string; -} - -function categoryToModule(category: string): string { - return `${SKILL_MODULE_PREFIX}${category}`; -} - -/** All installable `category:skill` addresses across the wizard, for `add`. */ -export function listSkillAddresses(wizard: WizardSpec): SkillAddress[] { - const out: SkillAddress[] = []; - for (const question of eachQuestion(wizard)) { - for (const option of question.options) { - if (!option.install) continue; - // parseWizardOption guarantees every install is rooted at a - // pharn-skills-* module, so the prefix strip is safe. - const module = option.install.split('/')[0]!; - const category = module.slice(SKILL_MODULE_PREFIX.length); - out.push({ - category, - module, - skill: basename(option.install), - questionId: question.id, - install: option.install, - }); - } - } - return out; -} - -/** - * Resolve `add orm:prisma`: the skill option under `pharn-skills-` - * whose install basename is `skill`. Returns undefined when no match exists. - */ -export function findSkillOption( - wizard: WizardSpec, - category: string, - skill: string, -): SkillAddress | undefined { - const module = categoryToModule(category); - return listSkillAddresses(wizard).find( - (a) => a.module === module && a.skill === skill, - ); -} diff --git a/src/steps/prereqs.ts b/src/steps/prereqs.ts index f268f63..c9a7515 100644 --- a/src/steps/prereqs.ts +++ b/src/steps/prereqs.ts @@ -1,7 +1,6 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { cancel } from '@clack/prompts'; -import type { ManifestModule, ModulePrerequisite } from '../types.js'; // Universal, framework-agnostic gate. Run up-front, before the wizard. export function runGitPrereq(): void { @@ -14,58 +13,6 @@ export function runGitPrereq(): void { } } -/** - * Conditional gate: fail when a module in the resolved install set declares a - * prerequisite package that is absent from the project's dependencies. The - * package set is driven entirely by the manifest (e.g. pharn-stack-nextjs - * declares `next`), so a no-pack / non-Next install simply has nothing to - * satisfy. Every missing prerequisite is collected and reported together. - * - * Used by `init` (after stack-pack selection) and `add` (before any fetch), so - * both fail before a single file is written. `rerun` tailors the closing hint - * to the command the user actually ran. - */ -export function assertPrerequisites( - modules: ManifestModule[], - cwd: string = process.cwd(), - rerun = 'npx pharn init', -): void { - const installed = readProjectPackages(cwd); - const missing: ModulePrerequisite[] = []; - const seen = new Set(); - for (const mod of modules) { - for (const pre of mod.prerequisites ?? []) { - if (!installed.has(pre.package) && !seen.has(pre.package)) { - seen.add(pre.package); - missing.push(pre); - } - } - } - if (missing.length > 0) { - const body = missing.map((pre) => `✗ ${pre.reason}`).join('\n'); - fail(`${body}\n Then re-run: ${rerun}`); - } -} - -// Names of every package in the project's dependencies + devDependencies. A -// missing or malformed package.json yields an empty set (nothing satisfied). -export function readProjectPackages(cwd: string): Set { - const pkgPath = resolve(cwd, 'package.json'); - if (!existsSync(pkgPath)) return new Set(); - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { - dependencies?: Record; - devDependencies?: Record; - }; - return new Set([ - ...Object.keys(pkg.dependencies ?? {}), - ...Object.keys(pkg.devDependencies ?? {}), - ]); - } catch { - return new Set(); - } -} - function fail(message: string): never { cancel(message); process.exit(1); diff --git a/src/types.ts b/src/types.ts index c3495d9..5fea0fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,130 +1,9 @@ -// --------------------------------------------------------------------------- -// PHARN OSS manifest (manifest.json at the repo root) -// --------------------------------------------------------------------------- - -// A package that must already be in the user's project (deps or devDeps) for a -// module to apply. Checked after stack-pack selection; `reason` is shown to the -// user verbatim on failure. -export interface ModulePrerequisite { - package: string; - reason: string; -} - -export interface ManifestModule { - name: string; - version: string; - required: boolean; - dependsOn: string[]; - exclusiveWith?: string[]; - description: string; - // schemaVersion 2: category modules (pharn-skills-*) are marked - // "skill-category" and are driven by the wizard, never the module multiselect. - kind?: string; - // npm packages this module requires to already be installed in the user's - // project (e.g. pharn-stack-nextjs requires `next`). A schemaVersion 2 - // concept, but parsed whenever present (like `kind`); absent on legacy - // manifests, where it is simply never enforced. - prerequisites?: ModulePrerequisite[]; -} - -export interface Manifest { - // 1 = legacy (no wizard block); 2 = wizard-driven + skill categories. - schemaVersion: 1 | 2; - skillsVersion: string; - modules: ManifestModule[]; - // Present only on schemaVersion 2 manifests. - wizard?: WizardSpec; -} - -// --------------------------------------------------------------------------- -// Wizard block (manifest.wizard) — schemaVersion 2. pharn-oss owns this schema; -// the CLI renders questions and resolves installs from it. -// --------------------------------------------------------------------------- - -// A condition object evaluated against prior answers. AND across keys; each -// value is an exact match (string) or a negation ({ not: value }). -export type WizardCondition = Record; - -export interface WizardOption { - // Answer key recorded in pharn.config.json stackAnswers. - value: string; - label: string; - // Preselected in Custom mode. - default?: boolean; - // Repo-relative skill subfolder to copy, or null (nothing to install). - install: string | null; - // Rendered dimmed + "(coming soon)"; not selectable. - comingSoon?: boolean; - // npm packages whose presence in package.json (deps or devDeps) marks this - // option as detected, used to pre-fill the wizard. Absent = not detectable. - detect?: string[]; -} - -export type WizardRule = - | { type: 'hide'; if: WizardCondition; options: string[] } - | { type: 'hideQuestion'; if: WizardCondition } - | { type: 'relabel'; if: WizardCondition; options: string[]; label: string } - | { type: 'warn'; if: WizardCondition; message: string }; - -export interface WizardQuestion { - id: string; - prompt: string; - options: WizardOption[]; - rules?: WizardRule[]; -} - -export interface WizardSection { - id: string; - title: string; - questions: WizardQuestion[]; -} - -export interface WizardSpec { - sections: WizardSection[]; - // questionId → default value, used verbatim in Default mode. - defaults: Record; -} - -// Per-module manifest (/module.json). The `installs` map is -// source-dir → destination-dir-in-.claude/. -export interface ModuleManifest { - name: string; - version: string; - required: boolean; - dependsOn: string[]; - exclusiveWith?: string[]; - description: string; - installs: Record; -} - // --------------------------------------------------------------------------- // Constitution variants (pharn-core/templates/constitution/CONSTITUTION.*.md) // --------------------------------------------------------------------------- export type Constitution = 'gdpr-strict' | 'standard' | 'minimal'; -// --------------------------------------------------------------------------- -// Wizard selections -// --------------------------------------------------------------------------- - -export interface WizardConfig { - // Optional, non-required modules the user opted into (does NOT include - // pharn-core, which is always installed, or transitive dependencies — those - // are resolved at install time). - modules: string[]; - // The chosen stack pack (e.g. 'pharn-stack-nextjs'), or null for none. - stackPack: string | null; - constitution: Constitution; - // Whether the project is a multi-tenant SaaS. Default true (today's - // behavior). When false, Principle 2 (Multi-Tenant Isolation) is stripped - // from the installed constitution at materialize time. - isMultiTenant: boolean; - // schemaVersion 2 only: per-tech wizard answers (questionId → value, incl. - // "skip") and the skill subfolders to copy. - stackAnswers?: Record; - installedSkills?: InstalledSkill[]; -} - // --------------------------------------------------------------------------- // pharn.config.json — written into the user's project. The CLI owns this // schema; PHARN skills read it at runtime. diff --git a/tests/add.test.ts b/tests/add.test.ts index 4a30e45..e60dd3e 100644 --- a/tests/add.test.ts +++ b/tests/add.test.ts @@ -1,73 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; -import { v2Manifest } from './wizard-fixture.js'; -import type { - InstalledSkill, - ManifestModule, - PharnConfig, -} from '../src/types.js'; +import { ProcessExit, stubProcessExit } from './helpers.js'; +import type { PharnConfig } from '../src/types.js'; vi.mock('@clack/prompts', () => ({ intro: vi.fn(), - isCancel: (v: unknown) => v === CANCEL, - confirm: vi.fn(), log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, outro: vi.fn(), - select: vi.fn(), spinner: () => ({ start: vi.fn(), stop: vi.fn() }), })); -const fetchRemoteManifest = vi.fn(); -const optional: ManifestModule[] = [ - { - name: 'pharn-pipeline', - version: '0.5.0', - required: false, - dependsOn: [], - description: 'pipeline', - }, - { - name: 'pharn-review', - version: '0.4.0', - required: false, - dependsOn: [], - description: 'review', - }, -]; -const stackPacks: ManifestModule[] = [ - { - name: 'pharn-stack-nextjs', - version: '0.30.0', - required: false, - dependsOn: [], - exclusiveWith: ['pharn-stack-*'], - description: 'nextjs', - }, -]; -const categorizeModules = vi.fn(() => ({ core: [], optional, stackPacks })); -// The add prereq gate resolves the union; return prereq-less modules so the gate -// is a no-op (prerequisite enforcement itself is covered in prereqs.test.ts). -const resolveModules = vi.fn((_manifest: unknown, selected: string[]) => - selected.map((name) => ({ - name, - version: '0.1.0', - required: false, - dependsOn: [], - description: name, - })), -); -vi.mock('../src/lib/manifest.js', () => ({ - fetchRemoteManifest, - categorizeModules, - resolveModules, -})); - -const fetchAndInstall = vi.fn(); -vi.mock('../src/lib/installer.js', () => ({ fetchAndInstall })); - const fetchRepo = vi.fn(); -const fetchCommitSha = vi.fn(); -vi.mock('../src/lib/repo.js', () => ({ fetchRepo, fetchCommitSha })); +vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); const parseCapabilityIndex = vi.fn(); vi.mock('../src/lib/capability-index.js', () => ({ parseCapabilityIndex })); @@ -80,257 +23,18 @@ vi.mock('../src/lib/install-capabilities.js', () => ({ const readSkillsVersion = vi.fn(); vi.mock('../src/lib/skills-version.js', () => ({ readSkillsVersion })); -const loadConfigOrExit = vi.fn(); +const loadArchetypeConfigOrExit = vi.fn(); const writePharnConfig = vi.fn(); vi.mock('../src/lib/pharn-config.js', () => ({ - loadConfigOrExit, + loadArchetypeConfigOrExit, writePharnConfig, - isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), - toInstalledModules: (m: { name: string; version: string }[]) => - m.map(({ name, version }) => ({ name, version })), })); +// capability-address.js is intentionally NOT mocked — add uses the real +// parseCapabilityArg (name / role:name parsing). const { runAdd } = await import('../src/commands/add.js'); const prompts = await import('@clack/prompts'); -function config(modules: string[]): PharnConfig { - return { - pharnVersion: '0.2.0', - skillsVersion: '0.68.0', - repo: 'pharn-dev/pharn-oss', - commit: 'old', - constitution: 'standard', - modules: modules.map((name) => ({ name, version: '0.1.0' })), - installedAt: '2026-06-11T00:00:00.000Z', - }; -} - -describe('runAdd', () => { - stubProcessExit(); - beforeEach(() => { - vi.spyOn(process, 'cwd').mockReturnValue('/proj'); - fetchRemoteManifest.mockResolvedValue({}); - }); - afterEach(() => vi.clearAllMocks()); - - it('exits(1) when there is no config', async () => { - loadConfigOrExit.mockImplementationOnce(() => { - throw new ProcessExit(1); - }); - await expect(runAdd(undefined)).rejects.toMatchObject(new ProcessExit(1)); - }); - - it('reports when everything is already installed', async () => { - loadConfigOrExit.mockReturnValue( - config([ - 'pharn-core', - 'pharn-pipeline', - 'pharn-review', - 'pharn-stack-nextjs', - ]), - ); - await runAdd(undefined); - expect(prompts.outro).toHaveBeenCalledWith( - 'Everything available is already installed.', - ); - expect(fetchAndInstall).not.toHaveBeenCalled(); - }); - - it('reports when the requested module is already installed', async () => { - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-pipeline'])); - await runAdd('pharn-pipeline'); - expect(prompts.outro).toHaveBeenCalledWith( - 'pharn-pipeline is already installed.', - ); - }); - - it('installs a valid module argument and updates the config', async () => { - loadConfigOrExit.mockReturnValue(config(['pharn-core'])); - fetchAndInstall.mockResolvedValue({ - skillsVersion: '0.68.1', - commit: 'new', - resolved: [ - { name: 'pharn-core', version: '0.2.0' }, - { name: 'pharn-review', version: '0.4.0' }, - ], - }); - - await runAdd('pharn-review'); - - expect(fetchAndInstall).toHaveBeenCalledWith({ - claudeDir: '/proj/.claude', - selected: ['pharn-core', 'pharn-review'], - }); - const [, written] = writePharnConfig.mock.calls[0]!; - expect(written).toMatchObject({ - skillsVersion: '0.68.1', - commit: 'new', - constitution: 'standard', - }); - }); - - it('prompts for a module when the argument is not addable', async () => { - loadConfigOrExit.mockReturnValue(config(['pharn-core'])); - vi.mocked(prompts.select).mockResolvedValue('pharn-review'); - fetchAndInstall.mockResolvedValue({ - skillsVersion: '0.68.1', - commit: 'new', - resolved: [{ name: 'pharn-review', version: '0.4.0' }], - }); - - await runAdd('pharn-nope'); - - expect(prompts.log.warn).toHaveBeenCalled(); - expect(prompts.select).toHaveBeenCalled(); - expect(fetchAndInstall).toHaveBeenCalled(); - }); - - it('exits(1) when the install fails', async () => { - loadConfigOrExit.mockReturnValue(config(['pharn-core'])); - fetchAndInstall.mockRejectedValue(new Error('boom')); - await expect(runAdd('pharn-review')).rejects.toMatchObject( - new ProcessExit(1), - ); - }); - - it('exits(1) when the module catalog cannot be loaded', async () => { - loadConfigOrExit.mockReturnValue(config(['pharn-core'])); - fetchRemoteManifest.mockRejectedValueOnce(new Error('offline')); - await expect(runAdd('pharn-review')).rejects.toMatchObject( - new ProcessExit(1), - ); - }); -}); - -// schemaVersion 2: `add :`. -function configWithSkills( - skills: InstalledSkill[], - stackAnswers?: Record, -): PharnConfig { - return { - ...config(['pharn-core']), - ...(stackAnswers ? { stackAnswers } : {}), - installedSkills: skills, - }; -} - -describe('runAdd category:skill', () => { - stubProcessExit(); - beforeEach(() => { - vi.spyOn(process, 'cwd').mockReturnValue('/proj'); - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - }); - afterEach(() => vi.clearAllMocks()); - - function mockInstallOk(): void { - fetchAndInstall.mockResolvedValue({ - skillsVersion: '0.69.0', - commit: 'new', - resolved: [{ name: 'pharn-core', version: '0.2.0' }], - installedSkills: [], - }); - } - - it('installs a category:skill and appends it to installedSkills', async () => { - loadConfigOrExit.mockReturnValue(configWithSkills([])); - mockInstallOk(); - - await runAdd('orm:prisma'); - - expect(fetchAndInstall).toHaveBeenCalledWith({ - claudeDir: '/proj/.claude', - selected: ['pharn-core'], - wizardSkills: [ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ], - }); - const [, written] = writePharnConfig.mock.calls[0]!; - expect((written as PharnConfig).installedSkills).toEqual([ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ]); - }); - - it('no-ops when the skill is already installed', async () => { - loadConfigOrExit.mockReturnValue( - configWithSkills([ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ]), - ); - await runAdd('orm:prisma'); - expect(prompts.outro).toHaveBeenCalledWith('prisma is already installed.'); - expect(fetchAndInstall).not.toHaveBeenCalled(); - }); - - it('warns on a conflicting sibling, installs on yes, leaves stackAnswers untouched', async () => { - loadConfigOrExit.mockReturnValue( - configWithSkills( - [{ skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }], - { orm: 'drizzle' }, - ), - ); - vi.mocked(prompts.confirm).mockResolvedValue(true); - mockInstallOk(); - - await runAdd('orm:prisma'); - - expect(prompts.confirm).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('configured with drizzle'), - }), - ); - const [, written] = writePharnConfig.mock.calls[0]!; - expect((written as PharnConfig).installedSkills).toEqual([ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ]); - // stackAnswers is never auto-edited. - expect((written as PharnConfig).stackAnswers).toEqual({ orm: 'drizzle' }); - }); - - it('cancels when the conflict prompt is declined', async () => { - loadConfigOrExit.mockReturnValue( - configWithSkills([ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ]), - ); - vi.mocked(prompts.confirm).mockResolvedValue(false); - await expect(runAdd('orm:prisma')).rejects.toMatchObject( - new ProcessExit(0), - ); - expect(fetchAndInstall).not.toHaveBeenCalled(); - }); - - it('exits(1) listing valid options for an unknown skill', async () => { - loadConfigOrExit.mockReturnValue(configWithSkills([])); - await expect(runAdd('orm:sequelize')).rejects.toMatchObject( - new ProcessExit(1), - ); - const msg = vi.mocked(prompts.log.error).mock.calls[0]![0] as string; - expect(msg).toContain('orm:prisma'); - }); - - it('exits(1) when the skill install fails', async () => { - loadConfigOrExit.mockReturnValue(configWithSkills([])); - fetchAndInstall.mockRejectedValue(new Error('boom')); - await expect(runAdd('orm:prisma')).rejects.toMatchObject( - new ProcessExit(1), - ); - expect(writePharnConfig).not.toHaveBeenCalled(); - }); - - it('exits(1) when the manifest is schemaVersion 1', async () => { - loadConfigOrExit.mockReturnValue(configWithSkills([])); - fetchRemoteManifest.mockResolvedValue({ - schemaVersion: 1, - skillsVersion: '0.68.1', - modules: [], - }); - await expect(runAdd('orm:prisma')).rejects.toMatchObject( - new ProcessExit(1), - ); - }); -}); - describe('runAdd (archetype)', () => { stubProcessExit(); beforeEach(() => vi.spyOn(process, 'cwd').mockReturnValue('/proj')); @@ -361,16 +65,26 @@ describe('runAdd (archetype)', () => { function mockClone(): ReturnType { const cleanup = vi.fn(); - // fetchRepo now carries the pinned SHA; the archetype-add path records - // repo.sha as `commit` (FIX 1), no separate fetchCommitSha call. + // fetchRepo carries the pinned SHA; the archetype-add path records repo.sha + // as `commit`, no separate fetch. fetchRepo.mockResolvedValue({ dir: '/repo', sha: 'sha', cleanup }); parseCapabilityIndex.mockReturnValue(index); readSkillsVersion.mockReturnValue('1.0.0'); return cleanup; } + it('aborts before any fetch when the config is not an archetype install', async () => { + // loadArchetypeConfigOrExit prints LEGACY_CONFIG_MESSAGE + exit(1) for a + // legacy config (asserted in pharn-config.test.ts); here: no network. + loadArchetypeConfigOrExit.mockImplementationOnce(() => { + throw new ProcessExit(1); + }); + await expect(runAdd('a11y')).rejects.toMatchObject(new ProcessExit(1)); + expect(fetchRepo).not.toHaveBeenCalled(); + }); + it('installs a capability by name and appends it (archetypes untouched)', async () => { - loadConfigOrExit.mockReturnValue(archConfig()); + loadArchetypeConfigOrExit.mockReturnValue(archConfig()); const cleanup = mockClone(); await runAdd('a11y'); @@ -388,7 +102,7 @@ describe('runAdd (archetype)', () => { }); it('resolves role:name addressing', async () => { - loadConfigOrExit.mockReturnValue(archConfig()); + loadArchetypeConfigOrExit.mockReturnValue(archConfig()); mockClone(); await runAdd('lens:n-plus-one'); @@ -399,7 +113,7 @@ describe('runAdd (archetype)', () => { }); it('is a no-op when the capability is already installed', async () => { - loadConfigOrExit.mockReturnValue( + loadArchetypeConfigOrExit.mockReturnValue( archConfig([{ name: 'a11y', role: 'griller' }]), ); const cleanup = mockClone(); @@ -412,7 +126,7 @@ describe('runAdd (archetype)', () => { }); it('exits(1) listing valid capabilities for an unknown name (cleans up)', async () => { - loadConfigOrExit.mockReturnValue(archConfig()); + loadArchetypeConfigOrExit.mockReturnValue(archConfig()); const cleanup = mockClone(); await expect(runAdd('bogus')).rejects.toMatchObject(new ProcessExit(1)); @@ -422,8 +136,14 @@ describe('runAdd (archetype)', () => { }); it('exits(1) with no arg, before any fetch', async () => { - loadConfigOrExit.mockReturnValue(archConfig()); + loadArchetypeConfigOrExit.mockReturnValue(archConfig()); await expect(runAdd(undefined)).rejects.toMatchObject(new ProcessExit(1)); expect(fetchRepo).not.toHaveBeenCalled(); }); + + it('exits(1) on an invalid role prefix, before any fetch', async () => { + loadArchetypeConfigOrExit.mockReturnValue(archConfig()); + await expect(runAdd('bogus:x')).rejects.toMatchObject(new ProcessExit(1)); + expect(fetchRepo).not.toHaveBeenCalled(); + }); }); diff --git a/tests/diff.test.ts b/tests/diff.test.ts index 2eaa431..65a7ee1 100644 --- a/tests/diff.test.ts +++ b/tests/diff.test.ts @@ -1,9 +1,8 @@ -import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { useTmpDir } from './helpers.js'; -import { diffInstalled, diffInstalledCapabilities } from '../src/lib/diff.js'; -import { ManifestValidationError } from '../src/lib/validate.js'; +import { diffInstalledCapabilities } from '../src/lib/diff.js'; import type { InstalledCapability } from '../src/types.js'; function write(path: string, content = 'x'): void { @@ -11,222 +10,6 @@ function write(path: string, content = 'x'): void { writeFileSync(path, content); } -// Write repoDir//module.json with the given installs map. Names + install -// paths must satisfy MODULE_NAME_RE / INSTALL_PATH_RE (readModuleManifest -// validates them), so: `pharn-*` names, lowercase dot-free install paths. -function writeModule( - repoDir: string, - name: string, - installs: Record, -): void { - write( - join(repoDir, name, 'module.json'), - JSON.stringify({ - name, - version: '0.1.0', - required: false, - dependsOn: [], - description: 'test module', - installs, - }), - ); -} - -// relpath -> content for every file under dir (used to prove zero writes). -function snapshot(dir: string): Record { - const out: Record = {}; - const walk = (d: string, prefix: string): void => { - for (const e of readdirSync(d, { withFileTypes: true })) { - const rel = prefix ? `${prefix}/${e.name}` : e.name; - if (e.isDirectory()) walk(join(d, e.name), rel); - else out[rel] = readFileSync(join(d, e.name), 'utf8'); - } - }; - walk(dir, ''); - return out; -} - -describe('diffInstalled', () => { - const tmp = useTmpDir(); - - it('reports ok / modified / missing across directory and single-file installs', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - // Directory install (commands/) + single-file install (notes, no extension). - writeModule(repoDir, 'pharn-core', { - commands: 'commands/', - notes: 'notes', - }); - write(join(repoDir, 'pharn-core', 'commands', 'a.md'), 'A'); - write(join(repoDir, 'pharn-core', 'commands', 'sub', 'b.md'), 'B'); - write(join(repoDir, 'pharn-core', 'notes'), 'N'); - - write(join(claudeDir, 'commands', 'a.md'), 'A'); // identical - write(join(claudeDir, 'commands', 'sub', 'b.md'), 'CHANGED'); // modified - // .claude/notes is absent → missing (file branch). - - const diff = diffInstalled({ - repoDir, - claudeDir, - moduleNames: ['pharn-core'], - skills: [], - }); - - expect(diff.okCount).toBe(1); - expect(diff.modified).toEqual(['commands/sub/b.md']); - expect(diff.missing).toEqual(['notes']); - }); - - it('diffs selectively-installed skills under skills//', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - write( - join(repoDir, 'pharn-skills-orm', 'skills', 'prisma', 'SKILL.md'), - 'S', - ); - write(join(claudeDir, 'skills', 'prisma', 'SKILL.md'), 'EDITED'); - - const diff = diffInstalled({ - repoDir, - claudeDir, - moduleNames: [], - skills: [{ skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }], - }); - - expect(diff.modified).toEqual(['skills/prisma/SKILL.md']); - expect(diff.missing).toEqual([]); - expect(diff.okCount).toBe(0); - }); - - it('diffs a single-file skill source (file branch)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - write(join(repoDir, 'pharn-skills-orm', 'loose'), 'L'); - write(join(claudeDir, 'skills', 'loose'), 'L'); - - const diff = diffInstalled({ - repoDir, - claudeDir, - moduleNames: [], - skills: [{ skill: 'loose', from: 'pharn-skills-orm/loose' }], - }); - - expect(diff.okCount).toBe(1); - expect(diff.modified).toEqual([]); - expect(diff.missing).toEqual([]); - }); - - it('never reports CONSTITUTION.md or memory-bank/ even when present and differing', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - // A module mapping into the user-owned memory bank — both a directory and a - // bare-file form (the file form exercises the `=== 'memory-bank'` branch). - writeModule(repoDir, 'pharn-core', { - mbdir: 'memory-bank/', - mbfile: 'memory-bank', - }); - write(join(repoDir, 'pharn-core', 'mbdir', 'progress.md'), 'UPSTREAM'); - write(join(repoDir, 'pharn-core', 'mbfile'), 'UPSTREAM'); - - // Hand-edited working copies on disk — and a CONSTITUTION.md that no install - // maps. None of these may surface as drift. - write(join(claudeDir, 'memory-bank', 'progress.md'), 'MY NOTES'); - write(join(claudeDir, 'CONSTITUTION.md'), 'MY CONSTITUTION'); - - const diff = diffInstalled({ - repoDir, - claudeDir, - moduleNames: ['pharn-core'], - skills: [], - }); - - expect(diff.okCount).toBe(0); - expect(diff.modified).toEqual([]); - expect(diff.missing).toEqual([]); - expect([...diff.modified, ...diff.missing]).not.toContainEqual( - expect.stringContaining('memory-bank'), - ); - expect([...diff.modified, ...diff.missing]).not.toContain( - 'CONSTITUTION.md', - ); - }); - - it('reports template sources under templates/ (exclusion is anchored at the .claude/ root)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - // templates/memory-bank/ is a PHARN-owned source copy — it must NOT be - // swallowed by the memory-bank/ exclusion (which only anchors at root). - writeModule(repoDir, 'pharn-core', { templates: 'templates/' }); - write(join(repoDir, 'pharn-core', 'templates', 'memory-bank', 't.md'), 'R'); - write(join(claudeDir, 'templates', 'memory-bank', 't.md'), 'DIFFERENT'); - - const diff = diffInstalled({ - repoDir, - claudeDir, - moduleNames: ['pharn-core'], - skills: [], - }); - - expect(diff.modified).toEqual(['templates/memory-bank/t.md']); - }); - - it('skips installs sources that are absent upstream (never reports them missing)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - writeModule(repoDir, 'pharn-core', { ghost: 'ghost/' }); // no ghost/ in repo - - const diff = diffInstalled({ - repoDir, - claudeDir, - moduleNames: ['pharn-core'], - skills: [], - }); - - expect(diff).toEqual({ modified: [], missing: [], okCount: 0 }); - }); - - it('guards path-escape inputs (a crafted skill.from cannot read outside repoDir)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - write(join(repoDir, 'pharn-core', 'module.json'), '{}'); // unused - - expect(() => - diffInstalled({ - repoDir, - claudeDir, - moduleNames: [], - skills: [{ skill: 'x', from: '../escape' }], - }), - ).toThrow(ManifestValidationError); - }); - - it('performs zero writes or deletes', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - writeModule(repoDir, 'pharn-core', { - commands: 'commands/', - notes: 'notes', - }); - write(join(repoDir, 'pharn-core', 'commands', 'a.md'), 'A'); - write(join(repoDir, 'pharn-core', 'notes'), 'N'); - write(join(claudeDir, 'commands', 'a.md'), 'CHANGED'); - write(join(claudeDir, 'extra.md'), 'untouched'); - - const repoBefore = snapshot(repoDir); - const claudeBefore = snapshot(claudeDir); - - diffInstalled({ - repoDir, - claudeDir, - moduleNames: ['pharn-core'], - skills: [], - }); - - expect(snapshot(repoDir)).toEqual(repoBefore); - expect(snapshot(claudeDir)).toEqual(claudeBefore); - }); -}); - describe('diffInstalledCapabilities', () => { const tmp = useTmpDir(); diff --git a/tests/index.test.ts b/tests/index.test.ts index 6ffc5a7..3b58460 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -59,27 +59,27 @@ describe('main (argv dispatch)', () => { }); it('routes `add ` to runAdd with the argument', async () => { - setArgv('add', 'orm:prisma'); + setArgv('add', 'lens:n-plus-one'); await main(); - expect(runAdd).toHaveBeenCalledWith('orm:prisma'); + expect(runAdd).toHaveBeenCalledWith('lens:n-plus-one'); }); it('routes `remove ` to runRemove with the argument and yes:false', async () => { - setArgv('remove', 'pharn-review'); + setArgv('remove', 'a11y'); await main(); - expect(runRemove).toHaveBeenCalledWith('pharn-review', { yes: false }); + expect(runRemove).toHaveBeenCalledWith('a11y', { yes: false }); }); it('passes yes:true through for `remove --yes`', async () => { - setArgv('remove', 'pharn-review', '--yes'); + setArgv('remove', 'a11y', '--yes'); await main(); - expect(runRemove).toHaveBeenCalledWith('pharn-review', { yes: true }); + expect(runRemove).toHaveBeenCalledWith('a11y', { yes: true }); }); it('routes the `rm` alias to runRemove', async () => { - setArgv('rm', 'orm:prisma'); + setArgv('rm', 'lens:n-plus-one'); await main(); - expect(runRemove).toHaveBeenCalledWith('orm:prisma', { yes: false }); + expect(runRemove).toHaveBeenCalledWith('lens:n-plus-one', { yes: false }); }); it('routes `update` to runUpdate', async () => { @@ -132,6 +132,9 @@ describe('main (argv dispatch)', () => { .map((c: unknown[]) => String(c[0] ?? '')) .join('\n'); expect(printed).toContain('Usage:'); + // USAGE is synced to the capability model (grill F3) — no module wording. + expect(printed).toMatch(/capabilit/i); + expect(printed).not.toMatch(/methodology module|stack pack/i); expect(runInit).not.toHaveBeenCalled(); }); diff --git a/tests/install-modules.test.ts b/tests/install-modules.test.ts deleted file mode 100644 index bcac6ed..0000000 --- a/tests/install-modules.test.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { - mkdirSync, - readFileSync, - writeFileSync, - existsSync, - lstatSync, - rmSync, - symlinkSync, -} from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { useTmpDir } from './helpers.js'; -import { - installModule, - installSkills, - assertSkillSourcesExist, - materializeCore, -} from '../src/lib/install-modules.js'; -import { ManifestValidationError } from '../src/lib/validate.js'; -import type { ManifestModule } from '../src/types.js'; - -function write(path: string, content = 'x'): void { - mkdirSync(join(path, '..'), { recursive: true }); - writeFileSync(path, content); -} - -// True if ANYTHING exists at `path` (file, dir, OR symlink) — lstat does not -// follow the link, so a skipped-but-somehow-present symlink is still detected -// (existsSync would follow a dangling link and report it missing). -function lexists(path: string): boolean { - try { - lstatSync(path); - return true; - } catch { - return false; - } -} - -// Build a fake fetched-repo on disk with a pharn-core module. -function scaffoldCore(repoDir: string): void { - write( - join(repoDir, 'pharn-core', 'module.json'), - JSON.stringify({ - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs: { - commands: 'commands/', - skills: 'skills/', - templates: 'templates/', - }, - }), - ); - write(join(repoDir, 'pharn-core', 'commands', 'pharn-init.md'), 'init'); - write(join(repoDir, 'pharn-core', 'skills', 'a', 'SKILL.md'), 'skill'); - write( - join( - repoDir, - 'pharn-core', - 'templates', - 'memory-bank', - 'architecture-context.md', - ), - 'mb', - ); - write( - join(repoDir, 'pharn-core', 'templates', 'memory-bank', '.gitkeep'), - '', - ); - write( - join( - repoDir, - 'pharn-core', - 'templates', - 'constitution', - 'CONSTITUTION.standard.md', - ), - 'STANDARD', - ); - write( - join( - repoDir, - 'pharn-core', - 'templates', - 'constitution', - 'CONSTITUTION.minimal.md', - ), - 'MINIMAL', - ); - write( - join( - repoDir, - 'pharn-core', - 'templates', - 'constitution', - 'CONSTITUTION.gdpr-strict.md', - ), - 'GDPR', - ); -} - -const coreModule: ManifestModule = { - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', -}; - -describe('installModule', () => { - const tmp = useTmpDir(); - - it('copies each installs entry into .claude/', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldCore(repoDir); - - installModule(repoDir, claudeDir, coreModule); - - expect(existsSync(join(claudeDir, 'commands', 'pharn-init.md'))).toBe(true); - expect(existsSync(join(claudeDir, 'skills', 'a', 'SKILL.md'))).toBe(true); - expect( - existsSync( - join( - claudeDir, - 'templates', - 'constitution', - 'CONSTITUTION.standard.md', - ), - ), - ).toBe(true); - }); - - it('rejects an absolute installs destination at parse time', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - // '/evil/' has a leading slash, so INSTALL_PATH_RE rejects it before any - // copy is attempted (safeJoin remains the defense-in-depth backstop, but the - // path allowlist now refuses absolute paths up front). - write( - join(repoDir, 'pharn-core', 'module.json'), - JSON.stringify({ - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs: { commands: '/evil/' }, - }), - ); - write(join(repoDir, 'pharn-core', 'commands', 'x.md'), 'x'); - expect(() => installModule(repoDir, claudeDir, coreModule)).toThrow( - ManifestValidationError, - ); - }); - - it('throws when a declared installs path is missing in the repo', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - write( - join(repoDir, 'pharn-core', 'module.json'), - JSON.stringify({ - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs: { commands: 'commands/' }, - }), - ); - expect(() => installModule(repoDir, claudeDir, coreModule)).toThrow( - ManifestValidationError, - ); - }); -}); - -describe('materializeCore', () => { - const tmp = useTmpDir(); - - it('writes memory-bank and the chosen constitution variant', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldCore(repoDir); - - materializeCore(repoDir, claudeDir, 'standard'); - - expect(readFileSync(join(claudeDir, 'CONSTITUTION.md'), 'utf8')).toBe( - 'STANDARD', - ); - expect( - existsSync(join(claudeDir, 'memory-bank', 'architecture-context.md')), - ).toBe(true); - // .gitkeep is filtered out of the materialized memory bank. - expect(existsSync(join(claudeDir, 'memory-bank', '.gitkeep'))).toBe(false); - }); - - it('writes the gdpr-strict constitution variant', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldCore(repoDir); - - materializeCore(repoDir, claudeDir, 'gdpr-strict'); - - expect(readFileSync(join(claudeDir, 'CONSTITUTION.md'), 'utf8')).toBe( - 'GDPR', - ); - }); - - it('strips Principle 2 when the project is not multi-tenant', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldCore(repoDir); - // Overwrite the standard variant with realistic structure so the strip has - // numbered headings + a principles_included list to operate on. - write( - join( - repoDir, - 'pharn-core', - 'templates', - 'constitution', - 'CONSTITUTION.standard.md', - ), - [ - '---', - 'principles_included: [1, 2, 3, 4]', - '---', - '', - '## Principle 1: Privacy by Default', - '', - '- p1', - '', - '## Principle 2: Multi-Tenant Isolation', - '', - '- p2', - '', - '## Principle 3: Layer Integrity', - '', - '- p3', - '', - '## How this file is enforced', - '', - 'x', - '', - ].join('\n'), - ); - - materializeCore(repoDir, claudeDir, 'standard', false); - - const out = readFileSync(join(claudeDir, 'CONSTITUTION.md'), 'utf8'); - expect(out).toContain('principles_included: [1, 3, 4]'); - expect(out).not.toMatch(/## Principle 2:/); - expect(out).toContain('## Principle 1: Privacy by Default'); - expect(out).toContain('## Principle 3: Layer Integrity'); - }); - - it('keeps the constitution verbatim when multi-tenant (the default)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldCore(repoDir); - - materializeCore(repoDir, claudeDir, 'standard', true); - - expect(readFileSync(join(claudeDir, 'CONSTITUTION.md'), 'utf8')).toBe( - 'STANDARD', - ); - }); - - it('throws on an unknown constitution variant', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldCore(repoDir); - // @ts-expect-error testing an invalid variant at runtime - expect(() => materializeCore(repoDir, claudeDir, 'hipaa')).toThrow( - ManifestValidationError, - ); - }); - - it('throws when the memory-bank template is missing', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldCore(repoDir); - // A missing declared source is an upstream packaging bug — surface it - // rather than silently producing an install with no memory bank. - rmSync(join(repoDir, 'pharn-core', 'templates', 'memory-bank'), { - recursive: true, - }); - expect(() => materializeCore(repoDir, claudeDir, 'standard')).toThrow( - ManifestValidationError, - ); - }); - - it('rejects a symlinked memory-bank source (untrusted repo, P2)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldCore(repoDir); - const outside = join(tmp.path(), 'outside-mb'); - write(join(outside, 'x.md'), 'x'); - // Swap the real memory-bank template dir for a symlink onto an out-of-tree - // dir — a malicious clone plants it; materializeCore must refuse. - rmSync(join(repoDir, 'pharn-core', 'templates', 'memory-bank'), { - recursive: true, - }); - symlinkSync( - outside, - join(repoDir, 'pharn-core', 'templates', 'memory-bank'), - ); - expect(() => materializeCore(repoDir, claudeDir, 'standard')).toThrow( - ManifestValidationError, - ); - }); -}); - -// --- FIX 2: symlink-escape defense (P2), mirroring install-capabilities.ts ---- -describe('installModule — untrusted-copy guards (P2)', () => { - const tmp = useTmpDir(); - - function writeModuleJson(repoDir: string, installs: Record) { - write( - join(repoDir, 'pharn-core', 'module.json'), - JSON.stringify({ - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs, - }), - ); - } - - it('rejects an installs source that is a symlink (Layer 1 root)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - const outside = join(tmp.path(), 'outside'); - write(join(outside, 'x.md'), 'x'); - writeModuleJson(repoDir, { commands: 'commands/' }); - // `commands` is a symlink onto a real dir outside the module. - symlinkSync(outside, join(repoDir, 'pharn-core', 'commands')); - expect(() => installModule(repoDir, claudeDir, coreModule)).toThrow( - ManifestValidationError, - ); - }); - - it('skips a nested symlink inside an installs dir (never materialized)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - const outside = join(tmp.path(), 'outside'); - write(join(outside, 'secret.md'), 'secret'); - writeModuleJson(repoDir, { commands: 'commands/' }); - write(join(repoDir, 'pharn-core', 'commands', 'real.md'), 'real'); - symlinkSync( - join(outside, 'secret.md'), - join(repoDir, 'pharn-core', 'commands', 'link.md'), - ); - installModule(repoDir, claudeDir, coreModule); - // real sibling copied; the nested symlink skipped by the copy filter. - expect(existsSync(join(claudeDir, 'commands', 'real.md'))).toBe(true); - expect(lexists(join(claudeDir, 'commands', 'link.md'))).toBe(false); - }); - - it('refuses to write through a pre-planted symlink that escapes .claude/ (Layer 2)', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - const outside = join(tmp.path(), 'outside'); - mkdirSync(outside, { recursive: true }); - // Pre-plant .claude/escape as a symlink onto an out-of-tree dir (a prior run - // / out-of-band). A single-entry write through it has no ".." and passes the - // lexical safeJoin — the realpath backstop must still refuse it. - mkdirSync(claudeDir, { recursive: true }); - symlinkSync(outside, join(claudeDir, 'escape')); - writeModuleJson(repoDir, { real: 'escape/pwned' }); - write(join(repoDir, 'pharn-core', 'real'), 'pwn'); - expect(() => installModule(repoDir, claudeDir, coreModule)).toThrow( - ManifestValidationError, - ); - // Nothing was written outside .claude/. - expect(existsSync(join(outside, 'pwned'))).toBe(false); - }); -}); - -describe('assertSkillSourcesExist / installSkills — symlink guard (P2)', () => { - const tmp = useTmpDir(); - - it('rejects a skill source that is a symlink (pre-flight, nothing written)', () => { - const repoDir = join(tmp.path(), 'repo'); - const outside = join(tmp.path(), 'outside'); - write(join(outside, 'SKILL.md'), 'x'); - mkdirSync(join(repoDir, 'pharn-skills-orm', 'skills'), { recursive: true }); - symlinkSync( - outside, - join(repoDir, 'pharn-skills-orm', 'skills', 'drizzle'), - ); - expect(() => - assertSkillSourcesExist(repoDir, [ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ]), - ).toThrow(ManifestValidationError); - }); - - it('skips a nested symlink when installing a real skill dir', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - const outside = join(tmp.path(), 'outside'); - write(join(outside, 'secret.md'), 'secret'); - write( - join(repoDir, 'pharn-skills-orm', 'skills', 'drizzle', 'SKILL.md'), - 'skill', - ); - symlinkSync( - join(outside, 'secret.md'), - join(repoDir, 'pharn-skills-orm', 'skills', 'drizzle', 'link.md'), - ); - installSkills(repoDir, claudeDir, [ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ]); - expect(existsSync(join(claudeDir, 'skills', 'drizzle', 'SKILL.md'))).toBe( - true, - ); - expect(lexists(join(claudeDir, 'skills', 'drizzle', 'link.md'))).toBe( - false, - ); - }); -}); - -// --- FIX 3: dev/product allowlist on the legacy path (Surface A) -------------- -describe('installModule — dev/product allowlist (Surface A)', () => { - const tmp = useTmpDir(); - - it('excludes pharn-dev-* commands and *.test.* files from a legacy install', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - write( - join(repoDir, 'pharn-core', 'module.json'), - JSON.stringify({ - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs: { commands: 'commands/' }, - }), - ); - write(join(repoDir, 'pharn-core', 'commands', 'pharn-plan.md'), 'product'); - write( - join(repoDir, 'pharn-core', 'commands', 'pharn-dev-build.md'), - 'devloop', - ); - write( - join(repoDir, 'pharn-core', 'commands', 'set-writes-scope.test.cjs'), - 'test', - ); - installModule(repoDir, claudeDir, coreModule); - // product command copied; dev-loop command + test file structurally skipped. - expect(existsSync(join(claudeDir, 'commands', 'pharn-plan.md'))).toBe(true); - expect(existsSync(join(claudeDir, 'commands', 'pharn-dev-build.md'))).toBe( - false, - ); - expect( - existsSync(join(claudeDir, 'commands', 'set-writes-scope.test.cjs')), - ).toBe(false); - }); -}); diff --git a/tests/install-skills.test.ts b/tests/install-skills.test.ts deleted file mode 100644 index c0cf0cc..0000000 --- a/tests/install-skills.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { useTmpDir } from './helpers.js'; -import { - assertSkillSourcesExist, - installSkills, -} from '../src/lib/install-modules.js'; -import { ManifestValidationError } from '../src/lib/validate.js'; -import type { InstalledSkill } from '../src/types.js'; - -function write(path: string, content = 'x'): void { - mkdirSync(join(path, '..'), { recursive: true }); - writeFileSync(path, content); -} - -// Build a category module on disk with two sibling skills. -function scaffoldOrm(repoDir: string): void { - write( - join(repoDir, 'pharn-skills-orm', 'skills', 'drizzle', 'SKILL.md'), - 'd', - ); - write(join(repoDir, 'pharn-skills-orm', 'skills', 'prisma', 'SKILL.md'), 'p'); - write(join(repoDir, 'pharn-skills-orm', 'module.json'), '{}'); -} - -const drizzle: InstalledSkill = { - skill: 'drizzle', - from: 'pharn-skills-orm/skills/drizzle', -}; - -describe('installSkills', () => { - const tmp = useTmpDir(); - - it('copies only the selected skill, not its siblings or module.json', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldOrm(repoDir); - - installSkills(repoDir, claudeDir, [drizzle]); - - expect(existsSync(join(claudeDir, 'skills', 'drizzle', 'SKILL.md'))).toBe( - true, - ); - expect(existsSync(join(claudeDir, 'skills', 'prisma'))).toBe(false); - expect(existsSync(join(claudeDir, 'skills', 'module.json'))).toBe(false); - }); - - it('refuses a source path that escapes the repo dir', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldOrm(repoDir); - expect(() => - installSkills(repoDir, claudeDir, [{ skill: 'evil', from: '/evil/' }]), - ).toThrow(/path escape/); - }); -}); - -describe('assertSkillSourcesExist', () => { - const tmp = useTmpDir(); - - it('passes when every source exists', () => { - const repoDir = join(tmp.path(), 'repo'); - scaffoldOrm(repoDir); - expect(() => assertSkillSourcesExist(repoDir, [drizzle])).not.toThrow(); - }); - - it('throws (before any copy) when a source is missing', () => { - const repoDir = join(tmp.path(), 'repo'); - const claudeDir = join(tmp.path(), '.claude'); - scaffoldOrm(repoDir); - const missing: InstalledSkill = { - skill: 'ghost', - from: 'pharn-skills-orm/skills/ghost', - }; - expect(() => assertSkillSourcesExist(repoDir, [drizzle, missing])).toThrow( - ManifestValidationError, - ); - // Nothing was written. - expect(existsSync(join(claudeDir, 'skills'))).toBe(false); - }); - - it('throws when two skills resolve to the same basename', () => { - const repoDir = join(tmp.path(), 'repo'); - scaffoldOrm(repoDir); - // A second category ships a skill whose basename collides with drizzle — - // both would land in .claude/skills/drizzle/ and overwrite each other. - write( - join(repoDir, 'pharn-skills-db', 'skills', 'drizzle', 'SKILL.md'), - 'x', - ); - const collision: InstalledSkill = { - skill: 'drizzle', - from: 'pharn-skills-db/skills/drizzle', - }; - expect(() => - assertSkillSourcesExist(repoDir, [drizzle, collision]), - ).toThrow(/duplicate skill/i); - }); -}); diff --git a/tests/installer.test.ts b/tests/installer.test.ts deleted file mode 100644 index 0a7ebd9..0000000 --- a/tests/installer.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { ManifestModule } from '../src/types.js'; - -const cleanup = vi.fn(); -// fetchRepo now returns the pinned SHA; installer records it as `commit` -// (repo.sha), no separate fetchCommitSha call (FIX 1 — closes the TOCTOU). -const fetchRepo = vi.fn(async () => ({ - dir: '/tmp/repo', - sha: 'sha123', - cleanup, -})); -const readManifest = vi.fn(() => ({ - schemaVersion: 1, - skillsVersion: '0.68.1', - modules: [], -})); -const resolved: ManifestModule[] = [ - { - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - }, -]; -const resolveModules = vi.fn(() => resolved); -const installModule = vi.fn(); -const installSkills = vi.fn(); -const assertSkillSourcesExist = vi.fn(); -const materializeCore = vi.fn(); - -vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); -vi.mock('../src/lib/manifest.js', () => ({ readManifest, resolveModules })); -vi.mock('../src/lib/install-modules.js', () => ({ - installModule, - installSkills, - assertSkillSourcesExist, - materializeCore, -})); - -const { fetchAndInstall } = await import('../src/lib/installer.js'); - -describe('fetchAndInstall', () => { - afterEach(() => vi.clearAllMocks()); - - it('installs each resolved module and materializes core when a constitution is given', async () => { - const result = await fetchAndInstall({ - claudeDir: '/proj/.claude', - selected: [], - constitution: 'standard', - }); - - expect(installModule).toHaveBeenCalledTimes(resolved.length); - expect(installModule).toHaveBeenCalledWith( - '/tmp/repo', - '/proj/.claude', - resolved[0], - ); - expect(materializeCore).toHaveBeenCalledWith( - '/tmp/repo', - '/proj/.claude', - 'standard', - undefined, - ); - expect(cleanup).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - skillsVersion: '0.68.1', - commit: 'sha123', - resolved, - installedSkills: [], - }); - }); - - it('skips materializeCore when no constitution is given', async () => { - await fetchAndInstall({ claudeDir: '/proj/.claude', selected: [] }); - expect(materializeCore).not.toHaveBeenCalled(); - }); - - it('forwards isMultiTenant to materializeCore', async () => { - await fetchAndInstall({ - claudeDir: '/proj/.claude', - selected: [], - constitution: 'standard', - isMultiTenant: false, - }); - expect(materializeCore).toHaveBeenCalledWith( - '/tmp/repo', - '/proj/.claude', - 'standard', - false, - ); - }); - - it('validates skill sources before any module copy, then installs them', async () => { - const wizardSkills = [ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ]; - const result = await fetchAndInstall({ - claudeDir: '/proj/.claude', - selected: [], - wizardSkills, - }); - - expect(assertSkillSourcesExist).toHaveBeenCalledWith( - '/tmp/repo', - wizardSkills, - ); - expect(installSkills).toHaveBeenCalledWith( - '/tmp/repo', - '/proj/.claude', - wizardSkills, - ); - expect(result.installedSkills).toEqual(wizardSkills); - }); - - it('fails before copying any module when a skill source is missing', async () => { - assertSkillSourcesExist.mockImplementationOnce(() => { - throw new Error('missing skill source'); - }); - await expect( - fetchAndInstall({ - claudeDir: '/proj/.claude', - selected: [], - wizardSkills: [ - { skill: 'ghost', from: 'pharn-skills-orm/skills/ghost' }, - ], - }), - ).rejects.toThrow('missing skill source'); - expect(installModule).not.toHaveBeenCalled(); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('always cleans up, even when a module fails to install', async () => { - installModule.mockImplementationOnce(() => { - throw new Error('copy failed'); - }); - await expect( - fetchAndInstall({ claudeDir: '/proj/.claude', selected: [] }), - ).rejects.toThrow('copy failed'); - expect(cleanup).toHaveBeenCalledTimes(1); - }); -}); diff --git a/tests/list.test.ts b/tests/list.test.ts index 7b08817..290fb71 100644 --- a/tests/list.test.ts +++ b/tests/list.test.ts @@ -1,12 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProcessExit, stubProcessExit } from './helpers.js'; -import { v2Manifest } from './wizard-fixture.js'; -import type { - InstalledSkill, - Manifest, - ManifestModule, - PharnConfig, -} from '../src/types.js'; +import type { PharnConfig } from '../src/types.js'; import { ModelRoutingError } from '../src/lib/model-routing.js'; import { SeamConfigError } from '../src/lib/seam-config.js'; @@ -18,71 +12,49 @@ vi.mock('@clack/prompts', () => ({ spinner: () => ({ start: vi.fn(), stop: vi.fn() }), })); -const fetchRemoteManifest = vi.fn(); -const optional: ManifestModule[] = [ - { - name: 'pharn-pipeline', - version: '0.5.0', - required: false, - dependsOn: [], - description: 'The spec to ship pipeline', - }, - { - name: 'pharn-review', - version: '0.4.0', - required: false, - dependsOn: [], - description: 'Context-lens review', - }, -]; -const stackPacks: ManifestModule[] = [ - { - name: 'pharn-stack-nextjs', - version: '0.30.0', - required: false, - dependsOn: [], - exclusiveWith: ['pharn-stack-*'], - description: 'Next.js stack pack', - }, -]; -const categorizeModules = vi.fn(() => ({ core: [], optional, stackPacks })); -// wizard.js + format.js are intentionally NOT mocked — list uses the real -// listSkillAddresses / row / shortDescription against the v2 fixture wizard. -vi.mock('../src/lib/manifest.js', () => ({ - fetchRemoteManifest, - categorizeModules, -})); - +// Sentinel for the shared legacy message; the real text is asserted in +// pharn-config.test.ts. list emits its imported LEGACY_CONFIG_MESSAGE verbatim. +const LEGACY = 'legacy layout no longer supported (test sentinel)'; const readPharnConfig = vi.fn(); +// format.js is intentionally NOT mocked — list uses the real row(). vi.mock('../src/lib/pharn-config.js', () => ({ readPharnConfig, - // Real discriminator logic so the archetype branch is exercised faithfully. + // Real discriminators so list's branches are exercised faithfully. isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), - // Real config-error discriminator so list's invalid-config branch is faithful. isConfigValidationError: (e: unknown) => e instanceof ModelRoutingError || e instanceof SeamConfigError, + LEGACY_CONFIG_MESSAGE: LEGACY, })); const { runList } = await import('../src/commands/list.js'); const prompts = await import('@clack/prompts'); -function config( - modules: string[], - extra: Partial = {}, -): PharnConfig { +function archetypeConfig(extra: Partial = {}): PharnConfig { return { pharnVersion: '0.2.0', - skillsVersion: '0.68.0', + skillsVersion: '1.0.0', repo: 'pharn-dev/pharn-oss', commit: 'old', - constitution: 'standard', - modules: modules.map((name) => ({ name, version: '0.1.0' })), + modules: [], installedAt: '2026-06-11T00:00:00.000Z', + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], ...extra, }; } -// The body string passed to the note() call rendered under the given title. +// A pre-archetype (module) config: has `modules` but NO `capabilities` array. +function legacyConfig(): PharnConfig { + return { + pharnVersion: '0.2.0', + skillsVersion: '0.68.0', + repo: 'pharn-dev/pharn-oss', + commit: 'old', + modules: [{ name: 'pharn-core', version: '0.1.0' }], + installedAt: '2026-06-11T00:00:00.000Z', + }; +} + function noteBody(title: string): string { const call = vi.mocked(prompts.note).mock.calls.find((c) => c[1] === title); return (call?.[0] as string | undefined) ?? ''; @@ -90,17 +62,12 @@ function noteBody(title: string): string { describe('runList', () => { stubProcessExit(); - beforeEach(() => { - vi.spyOn(process, 'cwd').mockReturnValue('/proj'); - categorizeModules.mockReturnValue({ core: [], optional, stackPacks }); - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - }); + beforeEach(() => vi.spyOn(process, 'cwd').mockReturnValue('/proj')); afterEach(() => vi.clearAllMocks()); it('exits(1) when there is no config', async () => { readPharnConfig.mockReturnValue(null); await expect(runList()).rejects.toMatchObject(new ProcessExit(1)); - expect(fetchRemoteManifest).not.toHaveBeenCalled(); }); it('surfaces the loud validator error (NOT "run init") when the config is invalid (BUG 1)', async () => { @@ -114,121 +81,19 @@ describe('runList', () => { .join('\n'); expect(msg).toMatch(/ask/); expect(msg).not.toMatch(/pharn init/); - expect(fetchRemoteManifest).not.toHaveBeenCalled(); }); - it('exits(1) when the module catalog cannot be loaded', async () => { - readPharnConfig.mockReturnValue(config(['pharn-core'])); - fetchRemoteManifest.mockRejectedValueOnce(new Error('offline')); + it('rejects a legacy (non-archetype) config with the legacy message + exit(1)', async () => { + readPharnConfig.mockReturnValue(legacyConfig()); await expect(runList()).rejects.toMatchObject(new ProcessExit(1)); + expect(prompts.log.error).toHaveBeenCalledWith(LEGACY); + // Never the crash/lie paths — no note rendered, no "run init". + expect(prompts.note).not.toHaveBeenCalled(); }); - it('shows installed modules and flags available updates', async () => { - readPharnConfig.mockReturnValue( - config(['pharn-core', 'pharn-pipeline'], { skillsVersion: '0.68.0' }), - ); - const manifest = v2Manifest(); - manifest.skillsVersion = '0.69.0'; - manifest.modules = manifest.modules.map((m) => - m.name === 'pharn-pipeline' ? { ...m, version: '0.9.0' } : m, - ); - fetchRemoteManifest.mockResolvedValue(manifest); - - await runList(); - - const installed = noteBody('INSTALLED'); - expect(installed).toContain('pharn-pipeline'); - expect(installed).toContain('v0.1.0'); - expect(installed).toContain('→ v0.9.0'); - expect(installed).toContain('update available'); - }); - - it('omits update markers when everything is current', async () => { - // v2Manifest is skillsVersion 0.69.0 with every module at 0.1.0. - readPharnConfig.mockReturnValue( - config(['pharn-core', 'pharn-pipeline'], { skillsVersion: '0.69.0' }), - ); - - await runList(); - - const installed = noteBody('INSTALLED'); - expect(installed).not.toContain('update available'); - expect(installed).not.toContain('→ v'); - }); - - it('lists available modules, excluding installed ones (parity with add)', async () => { - readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-pipeline'])); - - await runList(); - - const available = noteBody('AVAILABLE TO ADD'); - expect(available).toContain('pharn-review'); - expect(available).toContain('pharn-stack-nextjs'); - expect(available).not.toContain('pharn-pipeline'); - }); - - it('shows "(all installed)" when no modules are available', async () => { - categorizeModules.mockReturnValue({ - core: [], - optional: [optional[0]!], - stackPacks: [], - }); - readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-pipeline'])); - - await runList(); - - expect(noteBody('AVAILABLE TO ADD')).toContain('(all installed)'); - }); - - it('lists available category:skill, excluding installed skills', async () => { - readPharnConfig.mockReturnValue( - config(['pharn-core'], { - installedSkills: [ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ], - }), - ); - - await runList(); - - const available = noteBody('AVAILABLE TO ADD'); - expect(available).toContain('orm:drizzle'); - expect(available).toContain('db:neon'); - expect(available).not.toContain('orm:prisma'); - - const installed = noteBody('INSTALLED'); - expect(installed).toContain('prisma'); - expect(installed).toContain('pharn-skills-orm/skills/prisma'); - }); - - it('shows "(all installed)" for skills when every wizard skill is installed', async () => { - const all: InstalledSkill[] = [ - 'pharn-skills-db/skills/neon', - 'pharn-skills-orm/skills/drizzle', - 'pharn-skills-orm/skills/prisma', - 'pharn-skills-auth/skills/better-auth', - 'pharn-skills-auth/skills/clerk', - 'pharn-skills-auth/skills/supabase-auth', - 'pharn-skills-email/skills/resend', - 'pharn-skills-payments/skills/stripe', - ].map((from) => ({ skill: from.split('/').pop()!, from })); - readPharnConfig.mockReturnValue( - config(['pharn-core'], { installedSkills: all }), - ); - - await runList(); - - const available = noteBody('AVAILABLE TO ADD'); - expect(available).toContain('SKILLS'); - expect(available).toContain('(all installed)'); - expect(available).not.toContain('orm:'); - }); - - it('renders an archetype install offline, WITHOUT fetching the manifest', async () => { + it('renders an archetype install offline from the config', async () => { readPharnConfig.mockReturnValue( - config([], { - skillsVersion: '1.0.0', - archetypes: ['ssr'], + archetypeConfig({ capabilities: [ { name: 'a11y', role: 'griller' }, { name: 'security', role: 'griller' }, @@ -239,8 +104,6 @@ describe('runList', () => { await expect(runList()).resolves.toBeUndefined(); - // The crash point (manifest fetch) is never reached for an archetype config. - expect(fetchRemoteManifest).not.toHaveBeenCalled(); const installed = noteBody('INSTALLED (archetype)'); expect(installed).toContain('ssr'); expect(installed).toContain('a11y'); @@ -248,29 +111,6 @@ describe('runList', () => { expect(installed).toContain('n-plus-one'); expect(installed).toContain('v1.0.0'); }); - - it('handles a schemaVersion 1 manifest with no skills section', async () => { - const manifest: Manifest = { - schemaVersion: 1, - skillsVersion: '0.70.0', - modules: [ - { - name: 'pharn-core', - version: '0.1.0', - required: true, - dependsOn: [], - description: 'core', - }, - ], - }; - fetchRemoteManifest.mockResolvedValue(manifest); - readPharnConfig.mockReturnValue(config(['pharn-core'])); - - await expect(runList()).resolves.toBeUndefined(); - - expect(noteBody('AVAILABLE TO ADD')).not.toContain('SKILLS'); - expect(noteBody('INSTALLED')).not.toContain('SKILLS'); - }); }); describe('runList --json', () => { @@ -279,8 +119,6 @@ describe('runList --json', () => { let errSpy: ReturnType; beforeEach(() => { vi.spyOn(process, 'cwd').mockReturnValue('/proj'); - categorizeModules.mockReturnValue({ core: [], optional, stackPacks }); - fetchRemoteManifest.mockResolvedValue(v2Manifest()); logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); }); @@ -289,55 +127,14 @@ describe('runList --json', () => { vi.restoreAllMocks(); }); - it('emits a single JSON object and no decorative output', async () => { + it('emits an archetype-mode JSON object on stdout', async () => { readPharnConfig.mockReturnValue( - config(['pharn-core', 'pharn-pipeline'], { - skillsVersion: '0.68.0', - installedSkills: [ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ], - }), + archetypeConfig({ capabilities: [{ name: 'a11y', role: 'griller' }] }), ); await runList({ json: true }); expect(prompts.intro).not.toHaveBeenCalled(); - expect(prompts.outro).not.toHaveBeenCalled(); - expect(prompts.note).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledTimes(1); - - const payload = JSON.parse(logSpy.mock.calls[0]![0] as string); - expect(payload).toMatchObject({ - skillsVersion: '0.68.0', - latestSkillsVersion: '0.69.0', - }); - expect(payload.installed.modules).toContainEqual({ - name: 'pharn-pipeline', - version: '0.1.0', - latest: '0.1.0', - }); - expect(payload.installed.skills).toEqual([ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ]); - const addrs = payload.available.skills.map( - (s: { category: string; skill: string }) => `${s.category}:${s.skill}`, - ); - expect(addrs).toContain('orm:drizzle'); - expect(addrs).not.toContain('orm:prisma'); - }); - - it('emits an archetype-mode JSON object with no manifest fetch', async () => { - readPharnConfig.mockReturnValue( - config([], { - skillsVersion: '1.0.0', - archetypes: ['ssr'], - capabilities: [{ name: 'a11y', role: 'griller' }], - }), - ); - - await runList({ json: true }); - - expect(fetchRemoteManifest).not.toHaveBeenCalled(); expect(prompts.note).not.toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledTimes(1); const payload = JSON.parse(logSpy.mock.calls[0]![0] as string); @@ -358,25 +155,24 @@ describe('runList --json', () => { expect(errSpy).toHaveBeenCalled(); }); - it('emits the loud validator error to stderr (stdout clean) when config is invalid (BUG 1)', async () => { - readPharnConfig.mockImplementationOnce(() => { - throw new ModelRoutingError('models.default has invalid model "gpt-4"'); - }); + it('rejects a legacy config to stderr (stdout clean) + exit(1)', async () => { + readPharnConfig.mockReturnValue(legacyConfig()); await expect(runList({ json: true })).rejects.toMatchObject( new ProcessExit(1), ); expect(logSpy).not.toHaveBeenCalled(); - expect(errSpy).toHaveBeenCalled(); - expect(String(errSpy.mock.calls[0]![0])).toMatch(/gpt-4/); + expect(errSpy).toHaveBeenCalledWith(LEGACY); }); - it('keeps stdout clean and exits(1) when the fetch fails', async () => { - readPharnConfig.mockReturnValue(config(['pharn-core'])); - fetchRemoteManifest.mockRejectedValueOnce(new Error('offline')); + it('emits the loud validator error to stderr (stdout clean) when config is invalid (BUG 1)', async () => { + readPharnConfig.mockImplementationOnce(() => { + throw new ModelRoutingError('models.default has invalid model "gpt-4"'); + }); await expect(runList({ json: true })).rejects.toMatchObject( new ProcessExit(1), ); expect(logSpy).not.toHaveBeenCalled(); - expect(errSpy).toHaveBeenCalledWith('offline'); + expect(errSpy).toHaveBeenCalled(); + expect(String(errSpy.mock.calls[0]![0])).toMatch(/gpt-4/); }); }); diff --git a/tests/manifest-v2.test.ts b/tests/manifest-v2.test.ts deleted file mode 100644 index 6d70723..0000000 --- a/tests/manifest-v2.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { categorizeModules, parseManifest } from '../src/lib/manifest.js'; -import { ManifestValidationError } from '../src/lib/validate.js'; -import { rawV2Manifest } from './wizard-fixture.js'; - -describe('parseManifest schemaVersion routing', () => { - it('parses a schemaVersion 1 manifest with no wizard block', () => { - const m = parseManifest({ - schemaVersion: 1, - skillsVersion: '0.68.1', - modules: [ - { - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - }, - ], - }); - expect(m.schemaVersion).toBe(1); - expect(m.wizard).toBeUndefined(); - }); - - it('parses a valid schemaVersion 2 manifest with a wizard block', () => { - const m = parseManifest(rawV2Manifest()); - expect(m.schemaVersion).toBe(2); - expect(m.wizard?.sections[0]?.id).toBe('core'); - expect(m.wizard?.defaults.database).toBe('supabase'); - expect(m.modules.find((x) => x.name === 'pharn-skills-orm')?.kind).toBe( - 'skill-category', - ); - }); - - it('round-trips an option detect array', () => { - const m = parseManifest(rawV2Manifest()); - const db = m.wizard?.sections[0]?.questions[0]; - const supabase = db?.options.find((o) => o.value === 'supabase'); - expect(supabase?.detect).toEqual(['@supabase/supabase-js']); - }); - - it('tolerates stray vendorSkill/source keys on an option (forward-compat)', () => { - // A pinned/older schemaVersion 2 manifest may still ship these now-removed - // keys; parse must not throw (P7 — old pins never break), and must not - // surface them on the parsed option. - const raw = rawV2Manifest(); - const options = ( - raw.wizard as { - sections: { questions: { options: Record[] }[] }[]; - } - ).sections[0]!.questions[0]!.options; - options[0]!.vendorSkill = 'supabase'; - options[0]!.source = 'github:supabase/supabase-skills/database'; - const supabase = parseManifest( - raw, - ).wizard?.sections[0]?.questions[0]?.options.find( - (o) => o.value === 'supabase', - ) as Record | undefined; - expect(supabase).toBeDefined(); - expect(supabase?.vendorSkill).toBeUndefined(); - expect(supabase?.source).toBeUndefined(); - }); - - it('rejects unknown schema versions', () => { - expect(() => - parseManifest({ schemaVersion: 3, skillsVersion: '1.0.0', modules: [] }), - ).toThrow(ManifestValidationError); - }); - - it('ignores non-question metadata keys in wizard.defaults', () => { - const raw = rawV2Manifest(); - (raw.wizard as { defaults: Record }).defaults.comment = - 'Default mode answers — wizard skips all questions and uses these.'; - const m = parseManifest(raw); - expect(m.wizard?.defaults.comment).toBeUndefined(); - expect(m.wizard?.defaults.database).toBe('supabase'); - }); - - it('still validates the value of a real question-id default', () => { - const raw = rawV2Manifest(); - (raw.wizard as { defaults: Record }).defaults.database = - 'Bad Value'; - expect(() => parseManifest(raw)).toThrow(/wizard\.defaults\["database"\]/); - }); - - it('rejects a default pointing at a value that is not an option', () => { - const raw = rawV2Manifest(); - // 'mysql' is a valid value shape but not an option of the database question. - (raw.wizard as { defaults: Record }).defaults.database = - 'mysql'; - expect(() => parseManifest(raw)).toThrow( - /is not an option of question "database"/, - ); - }); - - it('rejects a duplicate question id across sections', () => { - const raw = rawV2Manifest(); - const sections = ( - raw.wizard as { sections: Array<{ questions: Array<{ id: string }> }> } - ).sections; - // Collide the ORM question's id onto the database question's id. - sections[0]!.questions[1]!.id = 'database'; - expect(() => parseManifest(raw)).toThrow( - /duplicate question id "database"/, - ); - }); -}); - -describe('parseWizard validation', () => { - function withWizard( - mutate: (w: Record) => void, - ): () => void { - const raw = rawV2Manifest(); - mutate(raw.wizard as Record); - return () => parseManifest(raw); - } - - it('throws naming the section when sections are missing', () => { - expect( - withWizard((w) => { - delete w.sections; - }), - ).toThrow(/wizard\.sections/); - }); - - it('throws naming the question when a question id is invalid', () => { - expect( - withWizard((w) => { - const sections = w.sections as Array<{ - questions: Array<{ id: unknown }>; - }>; - sections[0]!.questions[0]!.id = 'Bad Id'; - }), - ).toThrow(/wizard\.sections\[0\]\.questions\[0\]\.id/); - }); - - it('throws when an option install points at an unknown module', () => { - expect( - withWizard((w) => { - const sections = w.sections as Array<{ - questions: Array<{ options: Array<{ install: unknown }> }>; - }>; - sections[0]!.questions[1]!.options[0]!.install = - 'pharn-skills-ghost/skills/x'; - }), - ).toThrow(/does not start with a known module/); - }); - - it('throws when an option install is rooted at a non-skill module', () => { - expect( - withWizard((w) => { - const sections = w.sections as Array<{ - questions: Array<{ options: Array<{ install: unknown }> }>; - }>; - // pharn-core is a known module but not a pharn-skills-* category, so the - // option would not be addressable via `add :`. - sections[0]!.questions[1]!.options[0]!.install = 'pharn-core/skills/x'; - }), - ).toThrow(/pharn-skills-/); - }); - - it('throws when a relabel rule omits its label', () => { - expect( - withWizard((w) => { - const sections = w.sections as Array<{ - questions: Array<{ rules?: Array> }>; - }>; - const emailRules = sections[0]!.questions[3]!.rules!; - delete emailRules[0]!.label; - }), - ).toThrow(ManifestValidationError); - }); - - it('throws on an unknown rule type', () => { - expect( - withWizard((w) => { - const sections = w.sections as Array<{ - questions: Array<{ rules?: Array> }>; - }>; - sections[0]!.questions[1]!.rules![0]!.type = 'destroy'; - }), - ).toThrow(/hide \| hideQuestion \| relabel \| warn/); - }); - - it('throws on a malformed condition value', () => { - expect( - withWizard((w) => { - const sections = w.sections as Array<{ - questions: Array<{ rules?: Array<{ if: Record }> }>; - }>; - sections[0]!.questions[1]!.rules![0]!.if = { database: { nope: 'x' } }; - }), - ).toThrow(/must be a string or \{ not: string \}/); - }); - - type Mut = (w: Record) => void; - const sectionsOf = (w: Record) => - w.sections as Array<{ - title?: unknown; - questions: Array<{ - prompt?: unknown; - options: Array>; - rules?: Array>; - }>; - }>; - - it('throws when the wizard block is not an object', () => { - const raw = rawV2Manifest(); - raw.wizard = 'nope'; - expect(() => parseManifest(raw)).toThrow(/wizard must be an object/); - }); - - it.each<[string, Mut]>([ - ['defaults missing', (w) => delete w.defaults], - ['section title missing', (w) => delete sectionsOf(w)[0]!.title], - [ - 'question prompt missing', - (w) => delete sectionsOf(w)[0]!.questions[0]!.prompt, - ], - [ - 'option value invalid', - (w) => (sectionsOf(w)[0]!.questions[0]!.options[0]!.value = 'Bad Value'), - ], - [ - 'option default not boolean', - (w) => (sectionsOf(w)[0]!.questions[0]!.options[0]!.default = 'yes'), - ], - [ - 'option comingSoon not boolean', - (w) => (sectionsOf(w)[0]!.questions[0]!.options[2]!.comingSoon = 'soon'), - ], - [ - 'hide rule missing options', - (w) => delete sectionsOf(w)[0]!.questions[2]!.rules![0]!.options, - ], - [ - 'warn rule missing message', - (w) => delete sectionsOf(w)[0]!.questions[2]!.rules![1]!.message, - ], - [ - 'condition empty object', - (w) => (sectionsOf(w)[0]!.questions[1]!.rules![0]!.if = {}), - ], - [ - 'option detect not an array', - (w) => (sectionsOf(w)[0]!.questions[0]!.options[0]!.detect = 'next'), - ], - [ - 'option detect with a bad package name', - (w) => - (sectionsOf(w)[0]!.questions[0]!.options[0]!.detect = ['Bad Name']), - ], - [ - 'option detect containing ..', - (w) => (sectionsOf(w)[0]!.questions[0]!.options[0]!.detect = ['a..b']), - ], - ])('rejects: %s', (_label, mutate) => { - const raw = rawV2Manifest(); - mutate(raw.wizard as Record); - expect(() => parseManifest(raw)).toThrow(ManifestValidationError); - }); -}); - -describe('categorizeModules excludes skill categories', () => { - it('keeps skill-category modules out of the optional list', () => { - const m = parseManifest(rawV2Manifest()); - const { optional } = categorizeModules(m); - const names = optional.map((x) => x.name); - expect(names).toContain('pharn-pipeline'); - expect(names).not.toContain('pharn-skills-orm'); - expect(names).not.toContain('pharn-skills-db'); - }); -}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts deleted file mode 100644 index 703d803..0000000 --- a/tests/manifest.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { useTmpDir } from './helpers.js'; -import { - parseManifest, - parseModuleManifest, - resolveModules, - categorizeModules, - isStackPack, - detectStackPack, - ResolutionError, - fetchRemoteManifest, - readManifest, - readModuleManifest, -} from '../src/lib/manifest.js'; -import { ManifestValidationError } from '../src/lib/validate.js'; -import type { Manifest, ManifestModule } from '../src/types.js'; - -function manifest(): Manifest { - return parseManifest({ - schemaVersion: 1, - skillsVersion: '0.68.0', - modules: [ - { - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - }, - { - name: 'pharn-pipeline', - version: '0.5.0', - required: false, - dependsOn: ['pharn-core'], - description: 'pipeline', - }, - { - name: 'pharn-review', - version: '0.4.0', - required: false, - dependsOn: ['pharn-core'], - description: 'review', - }, - { - name: 'pharn-stack-react', - version: '0.1.1', - required: false, - dependsOn: ['pharn-core'], - description: 'react base', - }, - { - name: 'pharn-stack-nextjs', - version: '0.30.0', - required: false, - dependsOn: ['pharn-core', 'pharn-stack-react'], - exclusiveWith: ['pharn-stack-*'], - description: 'nextjs', - }, - { - name: 'pharn-stack-remix', - version: '0.1.0', - required: false, - dependsOn: ['pharn-core', 'pharn-stack-react'], - exclusiveWith: ['pharn-stack-*'], - description: 'remix', - }, - ], - }); -} - -describe('parseManifest', () => { - it('rejects an unsupported schemaVersion', () => { - expect(() => - parseManifest({ schemaVersion: 2, skillsVersion: '1.0.0', modules: [] }), - ).toThrow(ManifestValidationError); - }); - - it('rejects a malformed skillsVersion', () => { - expect(() => - parseManifest({ - schemaVersion: 1, - skillsVersion: 'latest', - modules: [ - { - name: 'pharn-core', - version: '0.1.0', - required: true, - dependsOn: [], - description: 'x', - }, - ], - }), - ).toThrow(ManifestValidationError); - }); - - it('rejects an empty modules array', () => { - expect(() => - parseManifest({ schemaVersion: 1, skillsVersion: '0.1.0', modules: [] }), - ).toThrow(ManifestValidationError); - }); - - it('rejects a non-string entry in a string array (dependsOn)', () => { - expect(() => - parseManifest({ - schemaVersion: 1, - skillsVersion: '0.1.0', - modules: [ - { - name: 'pharn-core', - version: '0.1.0', - required: true, - dependsOn: [42], - description: 'x', - }, - ], - }), - ).toThrow(ManifestValidationError); - }); - - it('accepts a glob exclusiveWith pattern but rejects invalid characters', () => { - const base = { - name: 'pharn-core', - version: '0.1.0', - required: true, - dependsOn: [], - description: 'core', - }; - expect(() => - parseManifest({ - schemaVersion: 1, - skillsVersion: '0.1.0', - modules: [ - base, - { - name: 'pharn-stack-x', - version: '0.1.0', - required: false, - dependsOn: [], - exclusiveWith: ['pharn-stack-*'], - description: 'x', - }, - ], - }), - ).not.toThrow(); - expect(() => - parseManifest({ - schemaVersion: 1, - skillsVersion: '0.1.0', - modules: [ - base, - { - name: 'pharn-stack-x', - version: '0.1.0', - required: false, - dependsOn: [], - exclusiveWith: ['bad/value'], - description: 'x', - }, - ], - }), - ).toThrow(ManifestValidationError); - }); - - it('parses a valid prerequisites array onto the module', () => { - const reason = 'Next.js required. Run: npx create-next-app@latest'; - const parsed = parseManifest({ - schemaVersion: 1, - skillsVersion: '0.1.0', - modules: [ - { - name: 'pharn-stack-nextjs', - version: '0.1.0', - required: false, - dependsOn: [], - description: 'nextjs', - prerequisites: [{ package: 'next', reason }], - }, - ], - }); - expect(parsed.modules[0]!.prerequisites).toEqual([ - { package: 'next', reason }, - ]); - }); - - it('accepts a scoped npm package name in prerequisites', () => { - expect(() => - parseManifest({ - schemaVersion: 1, - skillsVersion: '0.1.0', - modules: [ - { - name: 'pharn-core', - version: '0.1.0', - required: true, - dependsOn: [], - description: 'core', - prerequisites: [{ package: '@org/pkg', reason: 'needs it' }], - }, - ], - }), - ).not.toThrow(); - }); - - it('rejects malformed prerequisites', () => { - const withPre = (prerequisites: unknown) => () => - parseManifest({ - schemaVersion: 1, - skillsVersion: '0.1.0', - modules: [ - { - name: 'pharn-core', - version: '0.1.0', - required: true, - dependsOn: [], - description: 'core', - prerequisites, - }, - ], - }); - expect(withPre('next')).toThrow(ManifestValidationError); // not an array - expect(withPre([{ package: 'Bad_UPPER', reason: 'x' }])).toThrow( - ManifestValidationError, // uppercase fails the package allowlist - ); - expect(withPre([{ package: 'a..b', reason: 'x' }])).toThrow( - ManifestValidationError, // '..' rejected - ); - expect(withPre([{ package: 'next' }])).toThrow( - ManifestValidationError, // missing reason - ); - }); -}); - -describe('parseModuleManifest', () => { - it('parses a valid installs map', () => { - const m = parseModuleManifest({ - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs: { commands: 'commands/', skills: 'skills/' }, - }); - expect(m.installs).toEqual({ commands: 'commands/', skills: 'skills/' }); - }); - - it('rejects path traversal in installs', () => { - expect(() => - parseModuleManifest({ - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs: { '../evil': 'skills/' }, - }), - ).toThrow(ManifestValidationError); - }); - - it('rejects an empty installs map', () => { - expect(() => - parseModuleManifest({ - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs: {}, - }), - ).toThrow(ManifestValidationError); - }); - - it('rejects an exclusiveWith entry with invalid characters', () => { - expect(() => - parseModuleManifest({ - name: 'pharn-stack-x', - version: '0.1.0', - required: false, - dependsOn: [], - exclusiveWith: ['bad/value'], - description: 'x', - installs: { commands: 'commands/' }, - }), - ).toThrow(ManifestValidationError); - }); -}); - -describe('resolveModules', () => { - it('always includes pharn-core even with no selection', () => { - const r = resolveModules(manifest(), []); - expect(r.map((m) => m.name)).toEqual(['pharn-core']); - }); - - it('pulls in transitive dependencies before dependents', () => { - const r = resolveModules(manifest(), ['pharn-stack-nextjs']); - const names = r.map((m) => m.name); - expect(names).toContain('pharn-stack-react'); - expect(names.indexOf('pharn-core')).toBeLessThan( - names.indexOf('pharn-stack-react'), - ); - expect(names.indexOf('pharn-stack-react')).toBeLessThan( - names.indexOf('pharn-stack-nextjs'), - ); - }); - - it('throws on an unknown module', () => { - expect(() => resolveModules(manifest(), ['pharn-nope'])).toThrow( - ResolutionError, - ); - }); - - it('enforces stack-pack exclusivity', () => { - expect(() => - resolveModules(manifest(), ['pharn-stack-nextjs', 'pharn-stack-remix']), - ).toThrow(ResolutionError); - }); - - it('does NOT treat a stack pack and its own base as a conflict', () => { - // pharn-stack-nextjs has exclusiveWith ['pharn-stack-*'] and depends on - // pharn-stack-react, which matches that glob. They must not false-conflict. - expect(() => - resolveModules(manifest(), ['pharn-stack-nextjs']), - ).not.toThrow(); - }); - - it('throws on a dependency cycle', () => { - const cyclic = parseManifest({ - schemaVersion: 1, - skillsVersion: '0.1.0', - modules: [ - { - name: 'pharn-core', - version: '0.1.0', - required: true, - dependsOn: [], - description: 'core', - }, - { - name: 'pharn-a', - version: '0.1.0', - required: false, - dependsOn: ['pharn-b'], - description: 'a', - }, - { - name: 'pharn-b', - version: '0.1.0', - required: false, - dependsOn: ['pharn-a'], - description: 'b', - }, - ], - }); - expect(() => resolveModules(cyclic, ['pharn-a'])).toThrow(/cycle/i); - }); - - it('does not duplicate shared dependencies', () => { - const r = resolveModules(manifest(), ['pharn-pipeline', 'pharn-review']); - const cores = r.filter((m) => m.name === 'pharn-core'); - expect(cores).toHaveLength(1); - }); -}); - -describe('categorizeModules', () => { - it('splits core, optional, and stack packs', () => { - const { core, optional, stackPacks } = categorizeModules(manifest()); - expect(core.map((m) => m.name)).toEqual(['pharn-core']); - expect(optional.map((m) => m.name).sort()).toEqual([ - 'pharn-pipeline', - 'pharn-review', - ]); - expect(stackPacks.map((m) => m.name).sort()).toEqual([ - 'pharn-stack-nextjs', - 'pharn-stack-remix', - ]); - }); - - it('keeps a stack base out of the optional list', () => { - const { optional } = categorizeModules(manifest()); - expect(optional.map((m) => m.name)).not.toContain('pharn-stack-react'); - }); -}); - -describe('isStackPack', () => { - it('detects a glob exclusivity rule', () => { - const m = manifest().modules.find((x) => x.name === 'pharn-stack-nextjs')!; - expect(isStackPack(m)).toBe(true); - }); - - it('treats a plain module as not a stack pack', () => { - const m = manifest().modules.find((x) => x.name === 'pharn-pipeline')!; - expect(isStackPack(m)).toBe(false); - }); -}); - -describe('detectStackPack', () => { - const nextjs: ManifestModule = { - name: 'pharn-stack-nextjs', - version: '0.1.0', - required: false, - dependsOn: [], - exclusiveWith: ['pharn-stack-*'], - description: 'nextjs', - prerequisites: [{ package: 'next', reason: 'needs next' }], - }; - - it('preselects the pack when all its prerequisites are present', () => { - expect(detectStackPack([nextjs], new Set(['next', 'react']))).toBe( - 'pharn-stack-nextjs', - ); - }); - - it('returns null when a prerequisite is absent', () => { - expect(detectStackPack([nextjs], new Set(['react']))).toBeNull(); - }); - - it('never auto-matches a pack that declares no prerequisites', () => { - const noPre: ManifestModule = { ...nextjs, prerequisites: undefined }; - expect(detectStackPack([noPre], new Set(['next']))).toBeNull(); - }); - - it('returns the first matching pack', () => { - const remix: ManifestModule = { - ...nextjs, - name: 'pharn-stack-remix', - prerequisites: [{ package: 'next', reason: 'x' }], - }; - expect(detectStackPack([nextjs, remix], new Set(['next']))).toBe( - 'pharn-stack-nextjs', - ); - }); -}); - -describe('readManifest / readModuleManifest', () => { - const tmp = useTmpDir(); - - const coreManifest = { - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - installs: { commands: 'commands/' }, - }; - - it('reads a manifest.json from a repo dir', () => { - writeFileSync( - join(tmp.path(), 'manifest.json'), - JSON.stringify({ - schemaVersion: 1, - skillsVersion: '0.68.1', - modules: [coreManifest], - }), - ); - expect(readManifest(tmp.path()).skillsVersion).toBe('0.68.1'); - }); - - it('throws when manifest.json is absent', () => { - expect(() => readManifest(tmp.path())).toThrow(ManifestValidationError); - }); - - it('reads a module.json from a repo dir', () => { - mkdirSync(join(tmp.path(), 'pharn-core'), { recursive: true }); - writeFileSync( - join(tmp.path(), 'pharn-core', 'module.json'), - JSON.stringify(coreManifest), - ); - expect(readModuleManifest(tmp.path(), 'pharn-core').installs).toEqual({ - commands: 'commands/', - }); - }); - - it('throws when a module.json is absent', () => { - expect(() => readModuleManifest(tmp.path(), 'pharn-core')).toThrow( - ManifestValidationError, - ); - }); -}); - -describe('fetchRemoteManifest', () => { - afterEach(() => vi.unstubAllGlobals()); - - const body = JSON.stringify({ - schemaVersion: 1, - skillsVersion: '0.68.1', - modules: [ - { - name: 'pharn-core', - version: '0.2.0', - required: true, - dependsOn: [], - description: 'core', - }, - ], - }); - - function stubFetch( - res: Partial & { text?: () => Promise }, - ) { - vi.stubGlobal( - 'fetch', - vi.fn(async () => res), - ); - } - - it('parses a valid remote manifest', async () => { - stubFetch({ - ok: true, - headers: new Headers(), - text: async () => body, - }); - const m = await fetchRemoteManifest(); - expect(m.skillsVersion).toBe('0.68.1'); - }); - - it('throws on a non-ok response', async () => { - stubFetch({ ok: false, status: 503, headers: new Headers() }); - await expect(fetchRemoteManifest()).rejects.toThrow(/fetch failed/); - }); - - it('rejects an oversized body advertised via content-length', async () => { - stubFetch({ - ok: true, - headers: new Headers({ 'content-length': String(1024 * 1024) }), - text: async () => body, - }); - await expect(fetchRemoteManifest()).rejects.toThrow(/too large/); - }); -}); diff --git a/tests/pharn-config.test.ts b/tests/pharn-config.test.ts index d8bbc44..578537c 100644 --- a/tests/pharn-config.test.ts +++ b/tests/pharn-config.test.ts @@ -6,10 +6,12 @@ import { ProcessExit, stubProcessExit, useTmpDir } from './helpers.js'; import { readPharnConfig, loadConfigOrExit, + loadArchetypeConfigOrExit, isConfigValidationError, writePharnConfig, toInstalledModules, isArchetypeConfig, + LEGACY_CONFIG_MESSAGE, } from '../src/lib/pharn-config.js'; import type { PharnConfig } from '../src/types.js'; import { @@ -289,3 +291,48 @@ describe('isConfigValidationError', () => { expect(isConfigValidationError('nope')).toBe(false); }); }); + +describe('loadArchetypeConfigOrExit', () => { + const tmp = useTmpDir(); + stubProcessExit(); + afterEach(() => vi.mocked(log.error).mockClear()); + + it('returns an archetype (capability) config unchanged', () => { + const archetype: PharnConfig = { + ...sample, + modules: [], + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], + }; + writeFileSync( + join(tmp.path(), 'pharn.config.json'), + JSON.stringify(archetype), + ); + expect(loadArchetypeConfigOrExit(tmp.path())).toEqual(archetype); + }); + + it('rejects a legacy (module) config with the exact LEGACY_CONFIG_MESSAGE + exit(1)', () => { + // `sample` is a legacy module install: modules present, no capabilities. + writeFileSync( + join(tmp.path(), 'pharn.config.json'), + JSON.stringify(sample), + ); + expect(() => loadArchetypeConfigOrExit(tmp.path())).toThrow(ProcessExit); + const msg = vi + .mocked(log.error) + .mock.calls.map((c) => String(c[0])) + .join('\n'); + expect(msg).toBe(LEGACY_CONFIG_MESSAGE); + expect(msg).toMatch(/no longer supported/); + expect(msg).toMatch(/pharn init/); // the remediation is to re-run init + }); + + it('exits(1) via loadConfigOrExit when the config is absent (unchanged)', () => { + expect(() => loadArchetypeConfigOrExit(tmp.path())).toThrow(ProcessExit); + const msg = vi + .mocked(log.error) + .mock.calls.map((c) => String(c[0])) + .join('\n'); + expect(msg).toMatch(/pharn init/); + }); +}); diff --git a/tests/prereqs.test.ts b/tests/prereqs.test.ts index 1f0f19b..bff50c9 100644 --- a/tests/prereqs.test.ts +++ b/tests/prereqs.test.ts @@ -1,61 +1,13 @@ -import { writeFileSync, mkdirSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { cancel } from '@clack/prompts'; import { ProcessExit, stubProcessExit, useTmpDir } from './helpers.js'; -import type { ManifestModule, ModulePrerequisite } from '../src/types.js'; vi.mock('@clack/prompts', () => ({ cancel: vi.fn(), })); -const { readProjectPackages, runGitPrereq, assertPrerequisites } = - await import('../src/steps/prereqs.js'); - -function mod( - name: string, - prerequisites?: ModulePrerequisite[], -): ManifestModule { - return { - name, - version: '1.0.0', - required: false, - dependsOn: [], - description: name, - ...(prerequisites ? { prerequisites } : {}), - }; -} - -const nextPre: ModulePrerequisite = { - package: 'next', - reason: 'pharn-stack-nextjs targets Next.js. Run: npx create-next-app@latest', -}; - -describe('readProjectPackages', () => { - const tmp = useTmpDir(); - - it('returns an empty set when package.json is missing', () => { - expect(readProjectPackages(tmp.path()).size).toBe(0); - }); - - it('unions dependencies and devDependencies', () => { - writeFileSync( - join(tmp.path(), 'package.json'), - JSON.stringify({ - dependencies: { next: '16' }, - devDependencies: { typescript: '5' }, - }), - ); - const pkgs = readProjectPackages(tmp.path()); - expect(pkgs.has('next')).toBe(true); - expect(pkgs.has('typescript')).toBe(true); - }); - - it('returns an empty set when package.json is malformed', () => { - writeFileSync(join(tmp.path(), 'package.json'), '{ not json'); - expect(readProjectPackages(tmp.path()).size).toBe(0); - }); -}); +const { runGitPrereq } = await import('../src/steps/prereqs.js'); describe('runGitPrereq', () => { const tmp = useTmpDir(); @@ -74,98 +26,3 @@ describe('runGitPrereq', () => { cwd.mockRestore(); }); }); - -describe('assertPrerequisites', () => { - const tmp = useTmpDir(); - stubProcessExit(); - - function withPackages(deps: Record): void { - writeFileSync( - join(tmp.path(), 'package.json'), - JSON.stringify({ dependencies: deps }), - ); - } - - it('passes for an empty module set (the no-pack / ungate case)', () => { - expect(() => assertPrerequisites([], tmp.path())).not.toThrow(); - }); - - it('passes for modules that declare no prerequisites', () => { - expect(() => - assertPrerequisites( - [mod('pharn-core'), mod('pharn-pipeline')], - tmp.path(), - ), - ).not.toThrow(); - }); - - it('passes when the declared package is in dependencies', () => { - withPackages({ next: '16' }); - expect(() => - assertPrerequisites([mod('pharn-stack-nextjs', [nextPre])], tmp.path()), - ).not.toThrow(); - }); - - it('passes when the declared package is in devDependencies', () => { - writeFileSync( - join(tmp.path(), 'package.json'), - JSON.stringify({ devDependencies: { next: '16' } }), - ); - expect(() => - assertPrerequisites([mod('pharn-stack-nextjs', [nextPre])], tmp.path()), - ).not.toThrow(); - }); - - it('fails when a declared package is absent', () => { - withPackages({ react: '19' }); - expect(() => - assertPrerequisites([mod('pharn-stack-nextjs', [nextPre])], tmp.path()), - ).toThrow(ProcessExit); - }); - - it('fails when package.json is missing entirely', () => { - expect(() => - assertPrerequisites([mod('pharn-stack-nextjs', [nextPre])], tmp.path()), - ).toThrow(ProcessExit); - }); - - it('surfaces the prerequisite reason to the user', () => { - withPackages({ react: '19' }); - vi.mocked(cancel).mockClear(); - expect(() => - assertPrerequisites([mod('pharn-stack-nextjs', [nextPre])], tmp.path()), - ).toThrow(ProcessExit); - expect(cancel).toHaveBeenCalledTimes(1); - expect(vi.mocked(cancel).mock.calls[0]![0] as string).toContain( - nextPre.reason, - ); - }); - - it('reports every missing prerequisite across modules at once', () => { - withPackages({}); // neither next nor prisma present - vi.mocked(cancel).mockClear(); - const prismaPre: ModulePrerequisite = { - package: 'prisma', - reason: 'Prisma is required. Run: npm i -D prisma', - }; - expect(() => - assertPrerequisites( - [mod('pharn-stack-nextjs', [nextPre]), mod('pharn-orm', [prismaPre])], - tmp.path(), - ), - ).toThrow(ProcessExit); - const msg = vi.mocked(cancel).mock.calls[0]![0] as string; - expect(msg).toContain(nextPre.reason); - expect(msg).toContain(prismaPre.reason); - }); - - it('passes when one module is satisfied and a sibling declares nothing', () => { - withPackages({ next: '16' }); - expect(() => - assertPrerequisites( - [mod('pharn-stack-nextjs', [nextPre]), mod('pharn-core')], - tmp.path(), - ), - ).not.toThrow(); - }); -}); diff --git a/tests/remove.test.ts b/tests/remove.test.ts index 94489e0..6f27f0e 100644 --- a/tests/remove.test.ts +++ b/tests/remove.test.ts @@ -7,29 +7,22 @@ import type { PharnConfig } from '../src/types.js'; vi.mock('@clack/prompts', () => ({ intro: vi.fn(), isCancel: (v: unknown) => v === CANCEL, - confirm: vi.fn(), select: vi.fn(), log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, outro: vi.fn(), - spinner: () => ({ start: vi.fn(), stop: vi.fn() }), })); -// Only fetchRepo is mocked — §B exercises the REAL readManifest / -// readModuleManifest / resolveModules against a real tmp repo on disk. -const cleanup = vi.fn(); -const fetchRepo = vi.fn(); -vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); - -const loadConfigOrExit = vi.fn(); +const loadArchetypeConfigOrExit = vi.fn(); const writePharnConfig = vi.fn(); vi.mock('../src/lib/pharn-config.js', () => ({ - loadConfigOrExit, + loadArchetypeConfigOrExit, writePharnConfig, - isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), - toInstalledModules: (m: { name: string; version: string }[]) => - m.map(({ name, version }) => ({ name, version })), })); +// remove.ts imports NO repo/network module — capability removal is a pure +// filesystem delete (drop the config entry + rm the isolated dir), so "no clone, +// no network" is now STRUCTURAL, not just an assertion. layout / capability-address +// / safeJoin run for real. const { runRemove } = await import('../src/commands/remove.js'); const prompts = await import('@clack/prompts'); @@ -38,583 +31,48 @@ function write(path: string, content = 'x'): void { writeFileSync(path, content); } -interface ModSpec { - name: string; - version?: string; - required?: boolean; - dependsOn?: string[]; - exclusiveWith?: string[]; - installs?: Record; - files?: Record; -} - -// Build a real fetched-repo tree: manifest.json catalog + each module's -// module.json + its source files. -function scaffoldRepo(repoDir: string, mods: ModSpec[]): void { - write( - join(repoDir, 'manifest.json'), - JSON.stringify({ - schemaVersion: 1, - skillsVersion: '0.70.0', - modules: mods.map((m) => ({ - name: m.name, - version: m.version ?? '0.1.0', - required: m.required ?? false, - dependsOn: m.dependsOn ?? [], - ...(m.exclusiveWith ? { exclusiveWith: m.exclusiveWith } : {}), - description: m.name, - })), - }), - ); - for (const m of mods) { - if (m.installs) { - write( - join(repoDir, m.name, 'module.json'), - JSON.stringify({ - name: m.name, - version: m.version ?? '0.1.0', - required: m.required ?? false, - dependsOn: m.dependsOn ?? [], - description: m.name, - installs: m.installs, - }), - ); - } - for (const [p, content] of Object.entries(m.files ?? {})) { - write(join(repoDir, m.name, p), content); - } - } -} - -function config( - modules: string[], +function archConfig( + caps: { name: string; role: 'griller' | 'lens' }[], extra: Partial = {}, ): PharnConfig { return { pharnVersion: '0.2.0', - skillsVersion: '0.70.0', + skillsVersion: '1.0.0', repo: 'pharn-dev/pharn-oss', commit: 'old', - constitution: 'standard', - modules: modules.map((name) => ({ name, version: '0.1.0' })), - installedAt: '2026-06-11T00:00:00.000Z', + modules: [], + installedAt: '2026-07-07T00:00:00.000Z', + archetypes: ['ssr'], + capabilities: caps, ...extra, }; } -// A core + review repo where both modules merge into commands/, used by the -// precise-deletion and picker tests. -function scaffoldCoreReview(repoDir: string, projDir: string): void { - scaffoldRepo(repoDir, [ - { - name: 'pharn-core', - required: true, - installs: { commands: 'commands/', skills: 'skills/' }, - files: { 'commands/pharn-init.md': 'init', 'skills/base/SKILL.md': 's' }, - }, - { - name: 'pharn-review', - dependsOn: ['pharn-core'], - installs: { commands: 'commands/', rules: 'rules/' }, - // rules/ has a nested subdir so removal exercises the recursive walk. - files: { 'commands/pharn-review.md': 'rev', 'rules/sub/lens.md': 'l' }, - }, - ]); - write(join(projDir, '.claude', 'commands', 'pharn-init.md'), 'init'); - write(join(projDir, '.claude', 'commands', 'pharn-review.md'), 'rev'); - write(join(projDir, '.claude', 'skills', 'base', 'SKILL.md'), 's'); - write(join(projDir, '.claude', 'rules', 'sub', 'lens.md'), 'l'); -} - function lastWritten(): PharnConfig { return writePharnConfig.mock.calls[0]![1] as PharnConfig; } const tmp = useTmpDir(); let proj = ''; -let repo = ''; -describe('runRemove', () => { +describe('runRemove (archetype)', () => { stubProcessExit(); beforeEach(() => { proj = join(tmp.path(), 'proj'); - repo = join(tmp.path(), 'repo'); vi.spyOn(process, 'cwd').mockReturnValue(proj); - fetchRepo.mockResolvedValue({ dir: repo, cleanup }); }); afterEach(() => vi.clearAllMocks()); - it('exits(1) when there is no config', async () => { - loadConfigOrExit.mockImplementationOnce(() => { + it('exits(1) when the config load rejects (e.g. a legacy config)', async () => { + loadArchetypeConfigOrExit.mockImplementationOnce(() => { throw new ProcessExit(1); }); - await expect(runRemove('pharn-review')).rejects.toMatchObject( - new ProcessExit(1), - ); - expect(fetchRepo).not.toHaveBeenCalled(); - }); - - // §A2 — capability removal (archetype install; no clone, no network) -------- - - it('removes a capability from a FLAT install (deletes pharn-review/)', async () => { - write(join(proj, 'pharn-review', 'n-plus-one', 'n-plus-one.md'), 'npo'); - loadConfigOrExit.mockReturnValue( - config([], { - archetypes: ['ssr'], - capabilities: [{ name: 'n-plus-one', role: 'lens' }], - }), - ); - - await runRemove('lens:n-plus-one'); - - expect(existsSync(join(proj, 'pharn-review', 'n-plus-one'))).toBe(false); - expect(fetchRepo).not.toHaveBeenCalled(); - expect(lastWritten().capabilities).toEqual([]); - }); - - it('removes a capability from a PHARN-layout install (deletes pharn/pharn-review/)', async () => { - write( - join(proj, 'pharn', 'pharn-review', 'n-plus-one', 'n-plus-one.md'), - 'npo', - ); - loadConfigOrExit.mockReturnValue( - config([], { - archetypes: ['ssr'], - capabilities: [{ name: 'n-plus-one', role: 'lens' }], - layout: 'pharn', - }), - ); - - await runRemove('lens:n-plus-one'); - - expect(existsSync(join(proj, 'pharn', 'pharn-review', 'n-plus-one'))).toBe( - false, - ); - expect(fetchRepo).not.toHaveBeenCalled(); - expect(lastWritten().capabilities).toEqual([]); - }); - - // §A — category:skill removal (no clone, no network) ----------------------- - - it('removes an installed skill and leaves siblings + config fields intact', async () => { - write(join(proj, '.claude', 'skills', 'prisma', 'SKILL.md'), 'p'); - write(join(proj, '.claude', 'skills', 'drizzle', 'SKILL.md'), 'd'); - loadConfigOrExit.mockReturnValue( - config(['pharn-core'], { - stackAnswers: { orm: 'prisma' }, - installedSkills: [ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ], - }), - ); - - await runRemove('orm:prisma'); - - expect(existsSync(join(proj, '.claude', 'skills', 'prisma'))).toBe(false); - expect( - existsSync(join(proj, '.claude', 'skills', 'drizzle', 'SKILL.md')), - ).toBe(true); - // No clone / network on the skill path. - expect(fetchRepo).not.toHaveBeenCalled(); - - const written = lastWritten(); - expect(written.installedSkills).toEqual([ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ]); - // stackAnswers + modules are never touched. - expect(written.stackAnswers).toEqual({ orm: 'prisma' }); - expect(written.modules).toEqual([{ name: 'pharn-core', version: '0.1.0' }]); - }); - - it('drops the config entry even when the skill dir is already gone', async () => { - loadConfigOrExit.mockReturnValue( - config(['pharn-core'], { - installedSkills: [ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ], - }), - ); - - await runRemove('orm:prisma'); - - expect(lastWritten().installedSkills).toEqual([]); - }); - - it('no-ops without writing when the skill is not installed', async () => { - loadConfigOrExit.mockReturnValue( - config(['pharn-core'], { - installedSkills: [ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ], - }), - ); - - await runRemove('orm:prisma'); - - expect(writePharnConfig).not.toHaveBeenCalled(); - expect(fetchRepo).not.toHaveBeenCalled(); - }); - - it('lists installed skills (not the manifest) for an unrecognized skill', async () => { - loadConfigOrExit.mockReturnValue( - config(['pharn-core'], { - installedSkills: [ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ], - }), - ); - - await runRemove('foo:bar'); - - const msg = vi.mocked(prompts.log.warn).mock.calls[0]![0] as string; - expect(msg).toContain('orm:drizzle'); - // A manifest-only skill that isn't installed is never offered as valid. - expect(msg).not.toContain('prisma'); - expect(writePharnConfig).not.toHaveBeenCalled(); - }); - - // §B — module removal (clones once) ---------------------------------------- - - it('refuses to remove pharn-core before any clone', async () => { - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - await expect(runRemove('pharn-core')).rejects.toMatchObject( - new ProcessExit(1), - ); - expect(fetchRepo).not.toHaveBeenCalled(); - expect(writePharnConfig).not.toHaveBeenCalled(); - }); - - it('refuses to remove a module that is not installed, before any clone', async () => { - loadConfigOrExit.mockReturnValue(config(['pharn-core'])); - await expect(runRemove('pharn-review')).rejects.toMatchObject( - new ProcessExit(1), - ); - expect(fetchRepo).not.toHaveBeenCalled(); - }); - - it('refuses to remove a module with installed dependents, naming them', async () => { - scaffoldRepo(repo, [ - { name: 'pharn-core', required: true }, - { name: 'pharn-stack-react', dependsOn: ['pharn-core'] }, - { - name: 'pharn-stack-nextjs', - dependsOn: ['pharn-core', 'pharn-stack-react'], - exclusiveWith: ['pharn-stack-*'], - }, - ]); - write(join(proj, '.claude', 'commands', 'init.md'), 'i'); - loadConfigOrExit.mockReturnValue( - config(['pharn-core', 'pharn-stack-react', 'pharn-stack-nextjs']), - ); - - await expect(runRemove('pharn-stack-react')).rejects.toMatchObject( - new ProcessExit(1), - ); - - const msg = vi.mocked(prompts.log.error).mock.calls[0]![0] as string; - expect(msg).toContain('pharn-stack-nextjs'); - expect(writePharnConfig).not.toHaveBeenCalled(); - // Nothing deleted; clone cleaned up after the post-clone refusal. - expect(existsSync(join(proj, '.claude', 'commands', 'init.md'))).toBe(true); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('refuses on a transitive dependent (not only direct ones)', async () => { - // pharn-top → pharn-mid → pharn-base: removing the base must be refused - // because pharn-top depends on it transitively. - scaffoldRepo(repo, [ - { name: 'pharn-core', required: true }, - { name: 'pharn-base', dependsOn: ['pharn-core'] }, - { name: 'pharn-mid', dependsOn: ['pharn-base'] }, - { name: 'pharn-top', dependsOn: ['pharn-mid'] }, - ]); - loadConfigOrExit.mockReturnValue( - config(['pharn-core', 'pharn-base', 'pharn-mid', 'pharn-top']), - ); - - await expect(runRemove('pharn-base')).rejects.toMatchObject( - new ProcessExit(1), - ); - - const msg = vi.mocked(prompts.log.error).mock.calls[0]![0] as string; - expect(msg).toContain('pharn-mid'); - expect(msg).toContain('pharn-top'); - expect(writePharnConfig).not.toHaveBeenCalled(); - }); - - it('deletes only the target files, keeps shared-dir siblings, prunes empties', async () => { - scaffoldCoreReview(repo, proj); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - - await runRemove('pharn-review', { yes: true }); - - // review's contributions are gone… - expect( - existsSync(join(proj, '.claude', 'commands', 'pharn-review.md')), - ).toBe(false); - // …and its now-empty private dir is pruned. - expect(existsSync(join(proj, '.claude', 'rules'))).toBe(false); - // core's files in the shared commands/ dir survive. - expect(existsSync(join(proj, '.claude', 'commands', 'pharn-init.md'))).toBe( - true, - ); - expect( - existsSync(join(proj, '.claude', 'skills', 'base', 'SKILL.md')), - ).toBe(true); - - expect(lastWritten().modules).toEqual([ - { name: 'pharn-core', version: '0.1.0' }, - ]); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('handles a module whose installs maps a single file', async () => { - scaffoldRepo(repo, [ - { - name: 'pharn-core', - required: true, - installs: { commands: 'commands/' }, - files: { 'commands/init.md': 'i' }, - }, - { - name: 'pharn-extras', - dependsOn: ['pharn-core'], - installs: { 'extras/license': 'license' }, - files: { 'extras/license': 'MIT' }, - }, - ]); - write(join(proj, '.claude', 'commands', 'init.md'), 'i'); - write(join(proj, '.claude', 'license'), 'MIT'); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-extras'])); - - await runRemove('pharn-extras', { yes: true }); - - expect(existsSync(join(proj, '.claude', 'license'))).toBe(false); - expect(existsSync(join(proj, '.claude', 'commands', 'init.md'))).toBe(true); - }); - - it('preserves survivors recorded versions (never resamples the @main manifest)', async () => { - // Clone @main ships core@0.2.0, but the install recorded 0.1.0. remove - // deletes files without reinstalling, so it must NOT bump the recorded - // version — doing so would mask a pending `pharn update`. - scaffoldRepo(repo, [ - { - name: 'pharn-core', - required: true, - version: '0.2.0', - installs: { commands: 'commands/' }, - files: { 'commands/init.md': 'i' }, - }, - { - name: 'pharn-review', - dependsOn: ['pharn-core'], - version: '0.2.0', - installs: { rules: 'rules/' }, - files: { 'rules/lens.md': 'l' }, - }, - ]); - write(join(proj, '.claude', 'commands', 'init.md'), 'i'); - write(join(proj, '.claude', 'rules', 'lens.md'), 'l'); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - - await runRemove('pharn-review', { yes: true }); - - expect(lastWritten().modules).toEqual([ - { name: 'pharn-core', version: '0.1.0' }, - ]); - }); - - it('refuses to remove a required (non-core) module instead of no-op success', async () => { - scaffoldRepo(repo, [ - { name: 'pharn-core', required: true }, - { - name: 'pharn-pipeline', - required: true, - dependsOn: ['pharn-core'], - installs: { commands: 'commands/' }, - files: { 'commands/plan.md': 'p' }, - }, - ]); - write(join(proj, '.claude', 'commands', 'plan.md'), 'p'); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-pipeline'])); - - await expect( - runRemove('pharn-pipeline', { yes: true }), - ).rejects.toMatchObject(new ProcessExit(1)); - - const msg = vi.mocked(prompts.log.error).mock.calls[0]![0] as string; - expect(msg).toContain('required'); + await expect(runRemove('a11y')).rejects.toMatchObject(new ProcessExit(1)); expect(writePharnConfig).not.toHaveBeenCalled(); - expect(existsSync(join(proj, '.claude', 'commands', 'plan.md'))).toBe(true); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('never deletes CONSTITUTION.md or the memory bank', async () => { - scaffoldRepo(repo, [ - { - name: 'pharn-core', - required: true, - installs: { commands: 'commands/' }, - files: { 'commands/init.md': 'i' }, - }, - { - name: 'pharn-rogue', - dependsOn: ['pharn-core'], - installs: { mb: 'memory-bank/' }, - files: { 'mb/architecture-context.md': 'x' }, - }, - ]); - write(join(proj, '.claude', 'commands', 'init.md'), 'i'); - write(join(proj, '.claude', 'CONSTITUTION.md'), 'user-owned'); - write( - join(proj, '.claude', 'memory-bank', 'architecture-context.md'), - 'user-owned', - ); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-rogue'])); - - await runRemove('pharn-rogue', { yes: true }); - - expect(existsSync(join(proj, '.claude', 'CONSTITUTION.md'))).toBe(true); - expect( - existsSync( - join(proj, '.claude', 'memory-bank', 'architecture-context.md'), - ), - ).toBe(true); - }); - - it('asks for confirmation and removes on yes', async () => { - scaffoldCoreReview(repo, proj); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - vi.mocked(prompts.confirm).mockResolvedValue(true); - - await runRemove('pharn-review'); - - expect(prompts.confirm).toHaveBeenCalled(); - expect( - existsSync(join(proj, '.claude', 'commands', 'pharn-review.md')), - ).toBe(false); - }); - - it('cancels without deleting or writing when the confirm is declined', async () => { - scaffoldCoreReview(repo, proj); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - vi.mocked(prompts.confirm).mockResolvedValue(false); - - await expect(runRemove('pharn-review')).rejects.toMatchObject( - new ProcessExit(0), - ); - - expect( - existsSync(join(proj, '.claude', 'commands', 'pharn-review.md')), - ).toBe(true); - expect(writePharnConfig).not.toHaveBeenCalled(); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('reports a clean error (not a raw throw) and cleans up when the manifest cannot be read', async () => { - write(join(repo, 'manifest.json'), 'not json'); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - - await expect( - runRemove('pharn-review', { yes: true }), - ).rejects.toMatchObject(new ProcessExit(1)); - expect(prompts.log.error).toHaveBeenCalled(); - expect(cleanup).toHaveBeenCalledTimes(1); }); - it('exits(1) when the clone fails', async () => { - fetchRepo.mockRejectedValue(new Error('offline')); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - await expect( - runRemove('pharn-review', { yes: true }), - ).rejects.toMatchObject(new ProcessExit(1)); - }); - - // Interactive picker (no arg) ---------------------------------------------- - - it('picker lists only removable items and removes the chosen module', async () => { - scaffoldCoreReview(repo, proj); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - vi.mocked(prompts.select).mockResolvedValue('pharn-review'); - - await runRemove(undefined); - - const options = ( - vi.mocked(prompts.select).mock.calls[0]![0] as { - options: { value: string }[]; - } - ).options.map((o) => o.value); - expect(options).toContain('pharn-review'); - expect(options).not.toContain('pharn-core'); - - expect( - existsSync(join(proj, '.claude', 'commands', 'pharn-review.md')), - ).toBe(false); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('picker can remove an installed skill', async () => { - scaffoldRepo(repo, [{ name: 'pharn-core', required: true }]); - write(join(proj, '.claude', 'skills', 'prisma', 'SKILL.md'), 'p'); - loadConfigOrExit.mockReturnValue( - config(['pharn-core'], { - installedSkills: [ - { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, - ], - }), - ); - vi.mocked(prompts.select).mockResolvedValue('orm:prisma'); - - await runRemove(undefined); - - expect(existsSync(join(proj, '.claude', 'skills', 'prisma'))).toBe(false); - expect(lastWritten().installedSkills).toEqual([]); - // No optional module installed → skill-only picker needs no clone. - expect(fetchRepo).not.toHaveBeenCalled(); - }); - - it('picker reports nothing to remove when only core is installed', async () => { - scaffoldRepo(repo, [{ name: 'pharn-core', required: true }]); - loadConfigOrExit.mockReturnValue(config(['pharn-core'])); - - await runRemove(undefined); - - expect(prompts.select).not.toHaveBeenCalled(); - expect(prompts.outro).toHaveBeenCalled(); - // Nothing module-shaped to resolve → no wasted clone. - expect(fetchRepo).not.toHaveBeenCalled(); - }); - - it('picker cancels cleanly, cleaning up the clone', async () => { - scaffoldCoreReview(repo, proj); - loadConfigOrExit.mockReturnValue(config(['pharn-core', 'pharn-review'])); - vi.mocked(prompts.select).mockResolvedValue(CANCEL); - - await expect(runRemove(undefined)).rejects.toMatchObject( - new ProcessExit(0), - ); - expect(cleanup).toHaveBeenCalledTimes(1); - expect(writePharnConfig).not.toHaveBeenCalled(); - }); -}); - -// §A2 — capability removal (archetype install; no clone, no network) ---------- -describe('runRemove (archetype)', () => { - stubProcessExit(); - beforeEach(() => { - proj = join(tmp.path(), 'proj'); - vi.spyOn(process, 'cwd').mockReturnValue(proj); - }); - afterEach(() => vi.clearAllMocks()); - - const archConfig = ( - caps: { name: string; role: 'griller' | 'lens' }[], - ): PharnConfig => - config([], { modules: [], archetypes: ['ssr'], capabilities: caps }); - - it('deletes the capability dir and drops it (siblings untouched, no clone)', async () => { - loadConfigOrExit.mockReturnValue( + it('deletes a capability dir from a FLAT install and drops it (siblings untouched)', async () => { + loadArchetypeConfigOrExit.mockReturnValue( archConfig([ { name: 'a11y', role: 'griller' }, { name: 'n-plus-one', role: 'lens' }, @@ -632,11 +90,22 @@ describe('runRemove (archetype)', () => { expect(lastWritten().capabilities).toEqual([ { name: 'n-plus-one', role: 'lens' }, ]); - expect(fetchRepo).not.toHaveBeenCalled(); + }); + + it('deletes from a PHARN-layout install (pharn/pharn-review/)', async () => { + loadArchetypeConfigOrExit.mockReturnValue( + archConfig([{ name: 'n-plus-one', role: 'lens' }], { layout: 'pharn' }), + ); + write(join(proj, 'pharn/pharn-review/n-plus-one/n-plus-one.md'), 'N'); + + await runRemove('lens:n-plus-one'); + + expect(existsSync(join(proj, 'pharn/pharn-review/n-plus-one'))).toBe(false); + expect(lastWritten().capabilities).toEqual([]); }); it('resolves role:name and leaves archetypes untouched', async () => { - loadConfigOrExit.mockReturnValue( + loadArchetypeConfigOrExit.mockReturnValue( archConfig([{ name: 'n-plus-one', role: 'lens' }]), ); write(join(proj, 'pharn-review/n-plus-one/n-plus-one.md'), 'N'); @@ -648,8 +117,17 @@ describe('runRemove (archetype)', () => { expect(lastWritten().capabilities).toEqual([]); }); - it('is a no-op for a capability that is not installed', async () => { - loadConfigOrExit.mockReturnValue( + it('drops the config entry even when the capability dir is already gone', async () => { + loadArchetypeConfigOrExit.mockReturnValue( + archConfig([{ name: 'a11y', role: 'griller' }]), + ); + // Nothing on disk — still drops the entry. + await runRemove('a11y'); + expect(lastWritten().capabilities).toEqual([]); + }); + + it('is a no-op (no write) for a capability that is not installed', async () => { + loadArchetypeConfigOrExit.mockReturnValue( archConfig([{ name: 'a11y', role: 'griller' }]), ); @@ -660,7 +138,7 @@ describe('runRemove (archetype)', () => { }); it('exits(1) on a name installed in both roles (ambiguous)', async () => { - loadConfigOrExit.mockReturnValue( + loadArchetypeConfigOrExit.mockReturnValue( archConfig([ { name: 'dup', role: 'griller' }, { name: 'dup', role: 'lens' }, @@ -671,8 +149,18 @@ describe('runRemove (archetype)', () => { expect(writePharnConfig).not.toHaveBeenCalled(); }); + it('exits(1) on an invalid role prefix', async () => { + loadArchetypeConfigOrExit.mockReturnValue( + archConfig([{ name: 'a11y', role: 'griller' }]), + ); + await expect(runRemove('bogus:a11y')).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); + it('no-arg picker selects from installed capabilities', async () => { - loadConfigOrExit.mockReturnValue( + loadArchetypeConfigOrExit.mockReturnValue( archConfig([{ name: 'a11y', role: 'griller' }]), ); write(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'), 'A'); @@ -683,4 +171,28 @@ describe('runRemove (archetype)', () => { expect(existsSync(join(proj, 'pharn-pipeline/grillers/a11y'))).toBe(false); expect(lastWritten().capabilities).toEqual([]); }); + + it('no-arg picker reports nothing when no capabilities are installed', async () => { + loadArchetypeConfigOrExit.mockReturnValue(archConfig([])); + + await runRemove(undefined); + + expect(prompts.select).not.toHaveBeenCalled(); + expect(prompts.outro).toHaveBeenCalledWith( + 'No capabilities are installed.', + ); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); + + it('no-arg picker cancels cleanly (no write)', async () => { + loadArchetypeConfigOrExit.mockReturnValue( + archConfig([{ name: 'a11y', role: 'griller' }]), + ); + vi.mocked(prompts.select).mockResolvedValue(CANCEL); + + await expect(runRemove(undefined)).rejects.toMatchObject( + new ProcessExit(0), + ); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); }); diff --git a/tests/status.test.ts b/tests/status.test.ts index b81732f..ccc06eb 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProcessExit, stubProcessExit } from './helpers.js'; -import type { Manifest, PharnConfig } from '../src/types.js'; +import type { PharnConfig } from '../src/types.js'; vi.mock('@clack/prompts', () => ({ intro: vi.fn(), @@ -10,30 +10,14 @@ vi.mock('@clack/prompts', () => ({ spinner: () => ({ start: vi.fn(), stop: vi.fn() }), })); -const fetchRemoteManifest = vi.fn(); -const readManifest = vi.fn(); -const resolveModules = vi.fn(); -vi.mock('../src/lib/manifest.js', () => ({ - fetchRemoteManifest, - readManifest, - resolveModules, -})); - const fetchRepo = vi.fn(); vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); -const diffInstalled = vi.fn(); const diffInstalledCapabilities = vi.fn(); -vi.mock('../src/lib/diff.js', () => ({ - diffInstalled, - diffInstalledCapabilities, -})); +vi.mock('../src/lib/diff.js', () => ({ diffInstalledCapabilities })); -const loadConfigOrExit = vi.fn(); -vi.mock('../src/lib/pharn-config.js', () => ({ - loadConfigOrExit, - isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), -})); +const loadArchetypeConfigOrExit = vi.fn(); +vi.mock('../src/lib/pharn-config.js', () => ({ loadArchetypeConfigOrExit })); const fetchRemoteSkillsVersion = vi.fn(); const readSkillsVersion = vi.fn(); @@ -48,32 +32,17 @@ const prompts = await import('@clack/prompts'); function config(extra: Partial = {}): PharnConfig { return { pharnVersion: '0.2.0', - skillsVersion: '0.68.0', + skillsVersion: '1.0.0', repo: 'pharn-dev/pharn-oss', commit: 'old', - constitution: 'standard', - modules: [{ name: 'pharn-core', version: '0.1.0' }], + modules: [], installedAt: '2026-06-11T00:00:00.000Z', + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], ...extra, }; } -function manifest(skillsVersion = '0.68.0', coreVersion = '0.1.0'): Manifest { - return { - schemaVersion: 2, - skillsVersion, - modules: [ - { - name: 'pharn-core', - version: coreVersion, - required: true, - dependsOn: [], - description: 'core', - }, - ], - }; -} - // The body string of the note() rendered under the given title. function noteBody(title: string): string { const call = vi.mocked(prompts.note).mock.calls.find((c) => c[1] === title); @@ -82,158 +51,45 @@ function noteBody(title: string): string { const CLEAN = { modified: [] as string[], missing: [] as string[], okCount: 5 }; -describe('runStatus', () => { +describe('runStatus (archetype)', () => { stubProcessExit(); - let cleanup: ReturnType; beforeEach(() => { vi.spyOn(process, 'cwd').mockReturnValue('/proj'); - cleanup = vi.fn(); - loadConfigOrExit.mockReturnValue(config()); - fetchRepo.mockResolvedValue({ dir: '/clone', cleanup }); - readManifest.mockReturnValue(manifest()); - fetchRemoteManifest.mockResolvedValue(manifest()); - resolveModules.mockReturnValue([{ name: 'pharn-core', version: '0.1.0' }]); - diffInstalled.mockReturnValue(CLEAN); + loadArchetypeConfigOrExit.mockReturnValue(config()); }); afterEach(() => vi.clearAllMocks()); - it('exits(1) when there is no config', async () => { - loadConfigOrExit.mockImplementationOnce(() => { + it('aborts before any fetch when the config is not an archetype install', async () => { + // loadArchetypeConfigOrExit prints LEGACY_CONFIG_MESSAGE + exit(1) for a + // legacy config (asserted in pharn-config.test.ts); here: no network. + loadArchetypeConfigOrExit.mockImplementationOnce(() => { throw new ProcessExit(1); }); await expect(runStatus()).rejects.toMatchObject(new ProcessExit(1)); expect(fetchRepo).not.toHaveBeenCalled(); - expect(fetchRemoteManifest).not.toHaveBeenCalled(); - }); - - it('exits(1) when the clone cannot be fetched', async () => { - fetchRepo.mockRejectedValueOnce(new Error('offline')); - await expect(runStatus()).rejects.toMatchObject(new ProcessExit(1)); - expect(diffInstalled).not.toHaveBeenCalled(); - }); - - it('cleans up the clone and exits(1) when reading the manifest fails', async () => { - readManifest.mockImplementationOnce(() => { - throw new Error('bad manifest'); - }); - await expect(runStatus()).rejects.toMatchObject(new ProcessExit(1)); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('prints both sections and cleans up on a normal run', async () => { - diffInstalled.mockReturnValue({ - modified: ['commands/x.md'], - missing: ['skills/prisma/SKILL.md'], - okCount: 3, - }); - await runStatus(); - expect(noteBody('VERSION')).toContain('Skills version'); - const drift = noteBody('DRIFT'); - expect(drift).toContain('commands/x.md'); - expect(drift).toContain('skills/prisma/SKILL.md'); - expect(drift).toContain('LOCALLY MODIFIED'); - expect(drift).toContain('MISSING'); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('reports "No drift" when nothing differs', async () => { - await runStatus(); - expect(noteBody('DRIFT')).toContain('No drift'); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('flags an available update in the version section', async () => { - readManifest.mockReturnValue(manifest('0.69.0')); - await runStatus(); - expect(noteBody('VERSION')).toContain('update available'); + expect(fetchRemoteSkillsVersion).not.toHaveBeenCalled(); }); - it('lists per-module version bumps in the version section', async () => { - readManifest.mockReturnValue(manifest('0.69.0', '0.2.0')); - await runStatus(); - const version = noteBody('VERSION'); - expect(version).toContain('MODULE UPDATES'); - expect(version).toContain('pharn-core'); - expect(version).toContain('v0.1.0 → v0.2.0'); - }); - - it('--no-drift exits(1) when the manifest fetch fails', async () => { - fetchRemoteManifest.mockRejectedValueOnce(new Error('offline')); - await expect(runStatus({ drift: false })).rejects.toMatchObject( - new ProcessExit(1), - ); - expect(fetchRepo).not.toHaveBeenCalled(); - }); + it('--no-drift: version via SKILLS_VERSION fetch, no clone', async () => { + fetchRemoteSkillsVersion.mockResolvedValue('1.0.0'); - it('--no-drift skips the clone and prints only the version section', async () => { await runStatus({ drift: false }); + + expect(fetchRemoteSkillsVersion).toHaveBeenCalled(); expect(fetchRepo).not.toHaveBeenCalled(); - expect(diffInstalled).not.toHaveBeenCalled(); - expect(fetchRemoteManifest).toHaveBeenCalledTimes(1); - expect(noteBody('VERSION')).toContain('Skills version'); + expect(noteBody('VERSION')).toContain('ssr'); expect(noteBody('DRIFT')).toBe(''); }); - it('--strict exits(1) when drift is present (and still cleans up)', async () => { - diffInstalled.mockReturnValue({ - modified: ['commands/x.md'], - missing: [], - okCount: 1, - }); - await expect(runStatus({ strict: true })).rejects.toMatchObject( + it('--no-drift exits(1) when the SKILLS_VERSION fetch fails', async () => { + fetchRemoteSkillsVersion.mockRejectedValueOnce(new Error('offline')); + await expect(runStatus({ drift: false })).rejects.toMatchObject( new ProcessExit(1), ); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('a default (non-strict) run with drift resolves without exiting', async () => { - diffInstalled.mockReturnValue({ - modified: ['commands/x.md'], - missing: [], - okCount: 1, - }); - await expect(runStatus()).resolves.toBeUndefined(); - expect(cleanup).toHaveBeenCalledTimes(1); - }); - - it('--no-drift --strict exits(1) when the version is behind', async () => { - fetchRemoteManifest.mockResolvedValue(manifest('0.69.0')); - await expect( - runStatus({ strict: true, drift: false }), - ).rejects.toMatchObject(new ProcessExit(1)); - expect(fetchRepo).not.toHaveBeenCalled(); - }); -}); - -describe('runStatus (archetype)', () => { - stubProcessExit(); - beforeEach(() => { - vi.spyOn(process, 'cwd').mockReturnValue('/proj'); - }); - afterEach(() => vi.clearAllMocks()); - - const archConfig = (): PharnConfig => - config({ - skillsVersion: '1.0.0', - modules: [], - archetypes: ['ssr'], - capabilities: [{ name: 'a11y', role: 'griller' }], - }); - - it('--no-drift: version via SKILLS_VERSION fetch, no manifest fetch, no clone', async () => { - loadConfigOrExit.mockReturnValue(archConfig()); - fetchRemoteSkillsVersion.mockResolvedValue('1.0.0'); - - await runStatus({ drift: false }); - - expect(fetchRemoteSkillsVersion).toHaveBeenCalled(); - expect(fetchRemoteManifest).not.toHaveBeenCalled(); expect(fetchRepo).not.toHaveBeenCalled(); - expect(noteBody('VERSION')).toContain('ssr'); }); it('default: clones, reads SKILLS_VERSION, diffs capabilities, cleans up', async () => { - loadConfigOrExit.mockReturnValue(archConfig()); const cleanup = vi.fn(); fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); readSkillsVersion.mockReturnValue('1.0.0'); @@ -250,21 +106,18 @@ describe('runStatus (archetype)', () => { layout: 'flat', }), ); - // The legacy module-diff path is not taken for an archetype config. - expect(diffInstalled).not.toHaveBeenCalled(); - expect(cleanup).toHaveBeenCalled(); + expect(noteBody('DRIFT')).toContain('No drift'); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('exits(1) and cleans up when the clone cannot be fetched', async () => { + fetchRepo.mockRejectedValueOnce(new Error('offline')); + await expect(runStatus({})).rejects.toMatchObject(new ProcessExit(1)); + expect(diffInstalledCapabilities).not.toHaveBeenCalled(); }); it('passes the recorded layout: pharn through to the capability diff', async () => { - loadConfigOrExit.mockReturnValue( - config({ - skillsVersion: '1.0.0', - modules: [], - archetypes: ['ssr'], - capabilities: [{ name: 'a11y', role: 'griller' }], - layout: 'pharn', - }), - ); + loadArchetypeConfigOrExit.mockReturnValue(config({ layout: 'pharn' })); const cleanup = vi.fn(); fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); readSkillsVersion.mockReturnValue('1.0.0'); @@ -279,7 +132,6 @@ describe('runStatus (archetype)', () => { }); it('--strict exits 1 on capability drift, cleaning up first', async () => { - loadConfigOrExit.mockReturnValue(archConfig()); const cleanup = vi.fn(); fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); readSkillsVersion.mockReturnValue('1.0.0'); @@ -294,4 +146,26 @@ describe('runStatus (archetype)', () => { ); expect(cleanup).toHaveBeenCalled(); }); + + it('a default (non-strict) run with drift resolves without exiting', async () => { + const cleanup = vi.fn(); + fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); + readSkillsVersion.mockReturnValue('1.0.0'); + diffInstalledCapabilities.mockReturnValue({ + modified: ['pharn-pipeline/grillers/a11y/a11y.md'], + missing: [], + okCount: 3, + }); + + await expect(runStatus({})).resolves.toBeUndefined(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('--no-drift --strict exits(1) when the version is behind', async () => { + fetchRemoteSkillsVersion.mockResolvedValue('1.1.0'); + await expect( + runStatus({ strict: true, drift: false }), + ).rejects.toMatchObject(new ProcessExit(1)); + expect(fetchRepo).not.toHaveBeenCalled(); + }); }); diff --git a/tests/update.test.ts b/tests/update.test.ts index 52bd4e0..427a50a 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; -import { v2Manifest } from './wizard-fixture.js'; import type { PharnConfig } from '../src/types.js'; vi.mock('@clack/prompts', () => ({ @@ -13,15 +12,8 @@ vi.mock('@clack/prompts', () => ({ spinner: () => ({ start: vi.fn(), stop: vi.fn() }), })); -const fetchRemoteManifest = vi.fn(); -vi.mock('../src/lib/manifest.js', () => ({ fetchRemoteManifest })); - -const fetchAndInstall = vi.fn(); -vi.mock('../src/lib/installer.js', () => ({ fetchAndInstall })); - const fetchRepo = vi.fn(); -const fetchCommitSha = vi.fn(); -vi.mock('../src/lib/repo.js', () => ({ fetchRepo, fetchCommitSha })); +vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); const fetchRemoteSkillsVersion = vi.fn(); const readSkillsVersion = vi.fn(); @@ -39,149 +31,46 @@ vi.mock('../src/lib/resolve-capabilities.js', () => ({ resolveCapabilities })); const installCapabilities = vi.fn(); vi.mock('../src/lib/install-capabilities.js', () => ({ installCapabilities })); -const loadConfigOrExit = vi.fn(); +const loadArchetypeConfigOrExit = vi.fn(); const writePharnConfig = vi.fn(); vi.mock('../src/lib/pharn-config.js', () => ({ - loadConfigOrExit, + loadArchetypeConfigOrExit, writePharnConfig, - isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), - toInstalledModules: (m: { name: string; version: string }[]) => - m.map(({ name, version }) => ({ name, version })), })); const { runUpdate } = await import('../src/commands/update.js'); const prompts = await import('@clack/prompts'); -const config: PharnConfig = { +const archConfig: PharnConfig = { pharnVersion: '0.2.0', - skillsVersion: '0.68.0', + skillsVersion: '1.0.0', repo: 'pharn-dev/pharn-oss', commit: 'old', - constitution: 'standard', - modules: [{ name: 'pharn-core', version: '0.2.0' }], + modules: [], installedAt: '2026-06-11T00:00:00.000Z', + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], }; -function manifest(skillsVersion: string, coreVersion = '0.2.0') { - return { - schemaVersion: 1, - skillsVersion, - modules: [{ name: 'pharn-core', version: coreVersion }], - }; -} - -describe('runUpdate', () => { +describe('runUpdate (archetype)', () => { stubProcessExit(); beforeEach(() => vi.spyOn(process, 'cwd').mockReturnValue('/proj')); afterEach(() => vi.clearAllMocks()); - it('exits(1) when there is no config', async () => { - loadConfigOrExit.mockImplementationOnce(() => { + it('aborts before any fetch when the config is not an archetype install', async () => { + // loadArchetypeConfigOrExit rejects a legacy (module) config with + // LEGACY_CONFIG_MESSAGE + exit(1) — that message is asserted in + // pharn-config.test.ts; here we assert the command never reaches the network. + loadArchetypeConfigOrExit.mockImplementationOnce(() => { throw new ProcessExit(1); }); await expect(runUpdate()).rejects.toMatchObject(new ProcessExit(1)); + expect(fetchRemoteSkillsVersion).not.toHaveBeenCalled(); + expect(fetchRepo).not.toHaveBeenCalled(); }); - it('reports when already up to date', async () => { - loadConfigOrExit.mockReturnValue(config); - fetchRemoteManifest.mockResolvedValue(manifest('0.68.0')); - await runUpdate(); - expect(prompts.outro).toHaveBeenCalledWith( - 'Already up to date (skills v0.68.0).', - ); - expect(fetchAndInstall).not.toHaveBeenCalled(); - }); - - it('re-fetches modules after confirmation', async () => { - loadConfigOrExit.mockReturnValue(config); - fetchRemoteManifest.mockResolvedValue(manifest('0.68.1', '0.3.0')); - vi.mocked(prompts.confirm).mockResolvedValue(true); - fetchAndInstall.mockResolvedValue({ - skillsVersion: '0.68.1', - commit: 'new', - resolved: [{ name: 'pharn-core', version: '0.3.0' }], - }); - - await runUpdate(); - - expect(prompts.note).toHaveBeenCalled(); - expect(fetchAndInstall).toHaveBeenCalledWith({ - claudeDir: '/proj/.claude', - selected: ['pharn-core'], - wizardSkills: [], - }); - expect(writePharnConfig).toHaveBeenCalled(); - }); - - it('cancels when the user declines', async () => { - loadConfigOrExit.mockReturnValue(config); - fetchRemoteManifest.mockResolvedValue(manifest('0.68.1')); - vi.mocked(prompts.confirm).mockResolvedValue(false); - await expect(runUpdate()).rejects.toMatchObject(new ProcessExit(0)); - expect(fetchAndInstall).not.toHaveBeenCalled(); - }); - - it('exits(1) when the manifest check fails', async () => { - loadConfigOrExit.mockReturnValue(config); - fetchRemoteManifest.mockRejectedValue(new Error('offline')); - await expect(runUpdate()).rejects.toMatchObject(new ProcessExit(1)); - }); - - it('exits(1) when re-fetching modules fails', async () => { - loadConfigOrExit.mockReturnValue(config); - fetchRemoteManifest.mockResolvedValue(manifest('0.68.1', '0.3.0')); - vi.mocked(prompts.confirm).mockResolvedValue(true); - fetchAndInstall.mockRejectedValue(new Error('boom')); - await expect(runUpdate()).rejects.toMatchObject(new ProcessExit(1)); - }); - - it('re-resolves installed skills, dropping ones that no longer exist upstream', async () => { - loadConfigOrExit.mockReturnValue({ - ...config, - installedSkills: [ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - { skill: 'ghost', from: 'pharn-skills-orm/skills/ghost' }, - ], - }); - fetchRemoteManifest.mockResolvedValue(v2Manifest()); - vi.mocked(prompts.confirm).mockResolvedValue(true); - fetchAndInstall.mockResolvedValue({ - skillsVersion: '0.69.0', - commit: 'new', - resolved: [{ name: 'pharn-core', version: '0.2.0' }], - installedSkills: [], - }); - - await runUpdate(); - - // ghost has no matching wizard option → reported and dropped. - expect(prompts.log.warn).toHaveBeenCalled(); - const [[opts]] = fetchAndInstall.mock.calls as unknown as [ - [{ wizardSkills: { skill: string }[] }], - ]; - expect(opts.wizardSkills.map((s) => s.skill)).toEqual(['drizzle']); - const [, written] = writePharnConfig.mock.calls[0]!; - expect((written as PharnConfig).installedSkills).toEqual([ - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - ]); - }); -}); - -describe('runUpdate (archetype)', () => { - stubProcessExit(); - beforeEach(() => vi.spyOn(process, 'cwd').mockReturnValue('/proj')); - afterEach(() => vi.clearAllMocks()); - - const archConfig: PharnConfig = { - ...config, - skillsVersion: '1.0.0', - modules: [], - archetypes: ['ssr'], - capabilities: [{ name: 'a11y', role: 'griller' }], - }; - it('reports already up to date without cloning', async () => { - loadConfigOrExit.mockReturnValue(archConfig); + loadArchetypeConfigOrExit.mockReturnValue(archConfig); fetchRemoteSkillsVersion.mockResolvedValue('1.0.0'); await runUpdate(); @@ -190,15 +79,14 @@ describe('runUpdate (archetype)', () => { 'Already up to date (skills v1.0.0).', ); expect(fetchRepo).not.toHaveBeenCalled(); - expect(fetchRemoteManifest).not.toHaveBeenCalled(); }); it('re-resolves + re-copies capabilities after confirmation', async () => { - loadConfigOrExit.mockReturnValue(archConfig); + loadArchetypeConfigOrExit.mockReturnValue(archConfig); fetchRemoteSkillsVersion.mockResolvedValue('1.1.0'); vi.mocked(prompts.confirm).mockResolvedValue(true); const cleanup = vi.fn(); - // fetchRepo now carries the pinned SHA; update records repo.sha as `commit`. + // fetchRepo carries the pinned SHA; update records repo.sha as `commit`. fetchRepo.mockResolvedValue({ dir: '/repo', sha: 'sha', cleanup }); parseCapabilityIndex.mockReturnValue({ capabilities: [] }); resolveCapabilities.mockReturnValue({ @@ -231,7 +119,7 @@ describe('runUpdate (archetype)', () => { }); it('cancels when declined — no clone, no copy', async () => { - loadConfigOrExit.mockReturnValue(archConfig); + loadArchetypeConfigOrExit.mockReturnValue(archConfig); fetchRemoteSkillsVersion.mockResolvedValue('1.1.0'); vi.mocked(prompts.confirm).mockResolvedValue(false); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 4dab8c9..bebd451 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { ManifestValidationError, @@ -10,6 +11,7 @@ import { assertRole, assertAppliesToken, isPlainObject, + safeJoin, } from '../src/lib/validate.js'; describe('assertSafeString', () => { @@ -135,3 +137,33 @@ describe('assertAppliesToken', () => { expect(() => assertAppliesToken('mobile', 'a11y')).toThrow(/a11y/); }); }); + +// safeJoin is the lexical path-containment floor shared by the archetype install +// (install-capabilities.ts), the drift check (diff.ts), and remove.ts — the last +// non-capability copy path (install-modules.ts) was deleted, so this is where the +// escape invariant is demonstrated directly (P1/P2). +describe('safeJoin (path containment)', () => { + const base = resolve('/tmp/pharn-base'); + + it('returns the joined path for an in-base relative path', () => { + expect(safeJoin(base, 'a/b')).toBe(resolve(base, 'a/b')); + expect(safeJoin(base, '.')).toBe(base); + }); + + it('throws on `..` traversal that escapes the base', () => { + expect(() => safeJoin(base, '../evil')).toThrow(ManifestValidationError); + expect(() => safeJoin(base, 'a/../../evil')).toThrow(/escape/); + }); + + it('throws on an absolute path outside the base', () => { + expect(() => safeJoin(base, '/etc/passwd')).toThrow( + ManifestValidationError, + ); + }); + + it('rejects a sibling dir sharing the base prefix (the + sep guard)', () => { + // resolve(base, '../pharn-base-evil') lexically startsWith the base string + // but is NOT under base/ — the `root + sep` check must still reject it. + expect(() => safeJoin(base, '../pharn-base-evil')).toThrow(/escape/); + }); +}); diff --git a/tests/wizard-fixture.ts b/tests/wizard-fixture.ts deleted file mode 100644 index 14d6424..0000000 --- a/tests/wizard-fixture.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { Manifest, ManifestModule, WizardSpec } from '../src/types.js'; - -// Canonical schemaVersion 2 wizard used across tests. Mirrors the spec examples: -// database/orm/auth/email/payments with hide / hideQuestion / relabel / warn rules. -export function wizardSpec(): WizardSpec { - return { - sections: [ - { - id: 'core', - title: 'CORE', - questions: [ - { - id: 'database', - prompt: 'Database', - options: [ - { - value: 'supabase', - label: 'Supabase', - default: true, - install: null, - detect: ['@supabase/supabase-js'], - }, - { - value: 'neon', - label: 'Neon', - install: 'pharn-skills-db/skills/neon', - detect: ['@neondatabase/serverless'], - }, - { - value: 'convex', - label: 'Convex', - install: null, - comingSoon: true, - detect: ['convex'], - }, - { value: 'skip', label: 'skip', install: null }, - ], - }, - { - id: 'orm', - prompt: 'ORM', - options: [ - { - value: 'drizzle', - label: 'Drizzle', - default: true, - install: 'pharn-skills-orm/skills/drizzle', - detect: ['drizzle-orm'], - }, - { - value: 'prisma', - label: 'Prisma', - install: 'pharn-skills-orm/skills/prisma', - detect: ['prisma', '@prisma/client'], - }, - { value: 'skip', label: 'skip', install: null }, - ], - rules: [{ type: 'hideQuestion', if: { database: 'convex' } }], - }, - { - id: 'auth', - prompt: 'Auth', - options: [ - { - value: 'better-auth', - label: 'Better Auth', - default: true, - install: 'pharn-skills-auth/skills/better-auth', - }, - { - value: 'clerk', - label: 'Clerk', - install: 'pharn-skills-auth/skills/clerk', - }, - { - value: 'supabase-auth', - label: 'Supabase Auth', - install: 'pharn-skills-auth/skills/supabase-auth', - }, - { value: 'skip', label: 'skip', install: null }, - ], - rules: [ - { - type: 'hide', - if: { database: { not: 'supabase' } }, - options: ['supabase-auth'], - }, - { - type: 'warn', - if: { auth: 'better-auth', orm: 'skip' }, - message: 'No ORM selected — better-auth expects one.', - }, - ], - }, - { - id: 'email', - prompt: 'Email', - options: [ - { - value: 'resend', - label: 'Resend', - default: true, - install: 'pharn-skills-email/skills/resend', - }, - { value: 'skip', label: 'skip', install: null }, - ], - rules: [ - { - type: 'relabel', - if: { auth: 'clerk' }, - options: ['resend'], - label: 'Resend (via Clerk)', - }, - ], - }, - { - id: 'payments', - prompt: 'Payments', - options: [ - { - value: 'stripe', - label: 'Stripe', - default: true, - install: 'pharn-skills-payments/skills/stripe', - }, - { value: 'skip', label: 'skip', install: null }, - ], - }, - ], - }, - ], - defaults: { - database: 'supabase', - orm: 'drizzle', - auth: 'better-auth', - email: 'resend', - payments: 'stripe', - }, - }; -} - -function mod( - name: string, - extra: Partial = {}, -): ManifestModule { - return { - name, - version: '0.1.0', - required: false, - dependsOn: [], - description: name, - ...extra, - }; -} - -export function v2Modules(): ManifestModule[] { - return [ - mod('pharn-core', { required: true }), - mod('pharn-pipeline', { dependsOn: ['pharn-core'] }), - mod('pharn-skills-db', { kind: 'skill-category' }), - mod('pharn-skills-orm', { kind: 'skill-category' }), - mod('pharn-skills-auth', { kind: 'skill-category' }), - mod('pharn-skills-email', { kind: 'skill-category' }), - mod('pharn-skills-payments', { kind: 'skill-category' }), - ]; -} - -// A parsed-shape v2 manifest (already validated upstream by parseManifest). -export function v2Manifest(): Manifest { - return { - schemaVersion: 2, - skillsVersion: '0.69.0', - modules: v2Modules(), - wizard: wizardSpec(), - }; -} - -// Raw (unparsed) v2 manifest object for exercising parseManifest/parseWizard. -export function rawV2Manifest(): Record { - return { - schemaVersion: 2, - skillsVersion: '0.69.0', - modules: v2Modules(), - wizard: wizardSpec(), - }; -} diff --git a/tests/wizard.test.ts b/tests/wizard.test.ts deleted file mode 100644 index f436799..0000000 --- a/tests/wizard.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - applyDefaults, - applyRulesToQuestion, - collectInstalls, - describeAnswers, - detectAnswers, - findSkillOption, - listSkillAddresses, - matchCondition, - pendingWarnings, -} from '../src/lib/wizard.js'; -import { wizardSpec } from './wizard-fixture.js'; -import type { WizardQuestion, WizardSpec } from '../src/types.js'; - -const wizard = wizardSpec(); - -function question(id: string): WizardQuestion { - const q = wizard.sections[0]!.questions.find((x) => x.id === id); - if (!q) throw new Error(`no question ${id}`); - return q; -} - -describe('matchCondition', () => { - it.each([ - [{ database: 'supabase' }, { database: 'supabase' }, true], - [{ database: 'supabase' }, { database: 'neon' }, false], - [{ database: 'supabase' }, {}, false], // missing key fails equality - [{ database: { not: 'supabase' } }, { database: 'neon' }, true], - [{ database: { not: 'supabase' } }, { database: 'supabase' }, false], - [{ database: { not: 'supabase' } }, {}, true], // missing key satisfies not - [ - { auth: 'better-auth', orm: 'skip' }, - { auth: 'better-auth', orm: 'skip' }, - true, - ], - [ - { auth: 'better-auth', orm: 'skip' }, - { auth: 'better-auth', orm: 'drizzle' }, - false, - ], - ] as const)('matches %o against %o → %s', (cond, answers, expected) => { - expect(matchCondition(cond, { ...answers })).toBe(expected); - }); -}); - -describe('applyRulesToQuestion', () => { - it('hideQuestion: ORM is hidden when database is convex', () => { - expect( - applyRulesToQuestion(question('orm'), { database: 'convex' }).hidden, - ).toBe(true); - expect( - applyRulesToQuestion(question('orm'), { database: 'supabase' }).hidden, - ).toBe(false); - }); - - it('hide: supabase-auth option drops when database is not supabase', () => { - const neon = applyRulesToQuestion(question('auth'), { database: 'neon' }); - expect(neon.options.map((o) => o.value)).not.toContain('supabase-auth'); - const supa = applyRulesToQuestion(question('auth'), { - database: 'supabase', - }); - expect(supa.options.map((o) => o.value)).toContain('supabase-auth'); - }); - - it('relabel: resend is relabeled when auth is clerk', () => { - const relabeled = applyRulesToQuestion(question('email'), { - auth: 'clerk', - }); - expect(relabeled.options.find((o) => o.value === 'resend')!.label).toBe( - 'Resend (via Clerk)', - ); - const plain = applyRulesToQuestion(question('email'), { - auth: 'better-auth', - }); - expect(plain.options.find((o) => o.value === 'resend')!.label).toBe( - 'Resend', - ); - }); - - it('preserves comingSoon flags after rule application', () => { - const { options } = applyRulesToQuestion(question('database'), {}); - expect(options.find((o) => o.value === 'convex')!.comingSoon).toBe(true); - }); -}); - -describe('pendingWarnings', () => { - it('fires the no-ORM warning for better-auth + orm:skip', () => { - expect( - pendingWarnings(question('auth'), { auth: 'better-auth', orm: 'skip' }), - ).toEqual(['No ORM selected — better-auth expects one.']); - }); - - it('stays silent when an ORM is chosen', () => { - expect( - pendingWarnings(question('auth'), { - auth: 'better-auth', - orm: 'drizzle', - }), - ).toEqual([]); - }); -}); - -describe('collectInstalls', () => { - it('returns one skill per answered option with a non-null install', () => { - const skills = collectInstalls(wizard, { - database: 'neon', - orm: 'drizzle', - auth: 'better-auth', - email: 'resend', - payments: 'skip', - }); - expect(skills).toEqual([ - { skill: 'neon', from: 'pharn-skills-db/skills/neon' }, - { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, - { skill: 'better-auth', from: 'pharn-skills-auth/skills/better-auth' }, - { skill: 'resend', from: 'pharn-skills-email/skills/resend' }, - ]); - }); - - it('ignores skip and install:null answers', () => { - expect( - collectInstalls(wizard, { database: 'supabase', payments: 'skip' }), - ).toEqual([]); - }); -}); - -describe('applyDefaults', () => { - it('returns wizard.defaults verbatim', () => { - expect(applyDefaults(wizard)).toEqual({ - database: 'supabase', - orm: 'drizzle', - auth: 'better-auth', - email: 'resend', - payments: 'stripe', - }); - }); - - it('overlays detected answers over the defaults', () => { - expect(applyDefaults(wizard, { orm: 'prisma' })).toEqual({ - database: 'supabase', - orm: 'prisma', - auth: 'better-auth', - email: 'resend', - payments: 'stripe', - }); - }); - - it('snaps a choice a hide rule removed back to the default option', () => { - // database=neon triggers `hide supabase-auth`; the overlaid supabase-auth is - // no longer selectable, so auth snaps to its default (better-auth). - expect( - applyDefaults(wizard, { database: 'neon', auth: 'supabase-auth' }), - ).toEqual({ - database: 'neon', - orm: 'drizzle', - auth: 'better-auth', - email: 'resend', - payments: 'stripe', - }); - }); - - it('records a hideQuestion-hidden question as skip instead of its default', () => { - const w: WizardSpec = { - sections: [ - { - id: 's', - title: 'S', - questions: [ - { - id: 'database', - prompt: 'DB', - options: [ - { - value: 'supabase', - label: 'Supabase', - default: true, - install: null, - }, - { value: 'sqlite', label: 'SQLite', install: null }, - ], - }, - { - id: 'orm', - prompt: 'ORM', - options: [ - { - value: 'drizzle', - label: 'Drizzle', - default: true, - install: null, - }, - { value: 'skip', label: 'skip', install: null }, - ], - rules: [{ type: 'hideQuestion', if: { database: 'sqlite' } }], - }, - ], - }, - ], - defaults: { database: 'supabase', orm: 'drizzle' }, - }; - // sqlite hides the orm question, so its default (drizzle) is dropped to skip. - expect(applyDefaults(w, { database: 'sqlite' })).toEqual({ - database: 'sqlite', - orm: 'skip', - }); - }); -}); - -describe('describeAnswers', () => { - it('maps answer values to option labels in question order', () => { - // input order is irrelevant — output follows the wizard's question order. - expect( - describeAnswers(wizard, { orm: 'prisma', database: 'supabase' }), - ).toEqual(['Supabase', 'Prisma']); - }); - - it('falls back to the raw value when no option matches', () => { - expect(describeAnswers(wizard, { database: 'mystery' })).toEqual([ - 'mystery', - ]); - }); -}); - -describe('detectAnswers', () => { - it('maps installed packages to the matching option per question', () => { - expect( - detectAnswers(wizard, new Set(['drizzle-orm', '@supabase/supabase-js'])), - ).toEqual({ database: 'supabase', orm: 'drizzle' }); - }); - - it('returns no answers when nothing matches', () => { - expect(detectAnswers(wizard, new Set(['express']))).toEqual({}); - }); - - it('ignores coming-soon options', () => { - // convex carries detect: ['convex'] but is comingSoon — never auto-picked. - expect(detectAnswers(wizard, new Set(['convex']))).toEqual({}); - }); -}); - -describe('findSkillOption / listSkillAddresses', () => { - it('resolves a category:skill to its install path', () => { - expect(findSkillOption(wizard, 'orm', 'prisma')).toMatchObject({ - category: 'orm', - module: 'pharn-skills-orm', - skill: 'prisma', - questionId: 'orm', - install: 'pharn-skills-orm/skills/prisma', - }); - }); - - it('returns undefined for unknown category or skill', () => { - expect(findSkillOption(wizard, 'orm', 'sequelize')).toBeUndefined(); - expect(findSkillOption(wizard, 'nope', 'drizzle')).toBeUndefined(); - }); - - it('lists every installable address', () => { - const addrs = listSkillAddresses(wizard).map( - (a) => `${a.category}:${a.skill}`, - ); - expect(addrs).toEqual([ - 'db:neon', - 'orm:drizzle', - 'orm:prisma', - 'auth:better-auth', - 'auth:clerk', - 'auth:supabase-auth', - 'email:resend', - 'payments:stripe', - ]); - }); -});