diff --git a/docs/commands/add.md b/docs/commands/add.md index 9a7c7a2..1847328 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -13,7 +13,8 @@ pharn add # interactive module picker 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. Clones `pharn-dev/pharn-oss`, copies the resolved modules' `installs` into `.claude/`, and updates `pharn.config.json` (`modules`, `skillsVersion`, `commit`). +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`). `CONSTITUTION.md` is **not** touched — `add` never changes your constitution. diff --git a/docs/commands/init.md b/docs/commands/init.md index 1367e5c..ae0177a 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -20,22 +20,26 @@ sequenceDiagram participant Modules as module_select participant Stack as stackpack_select participant Const as constitution_select + participant Tenant as multitenant_select participant Summary participant Install User->>CLI: pharn init - CLI->>Prereqs: Next.js + git + CLI->>Prereqs: git (.git present) Prereqs-->>CLI: ok or exit - CLI->>Fresh: commit / file heuristics + 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 Install-->>User: next steps ``` @@ -44,12 +48,19 @@ sequenceDiagram ## schemaVersion 2 wizard -When the fetched `manifest.json` is `schemaVersion 2`, `init` first asks how to configure your stack: +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: + +- **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`; no per-tech questions are asked. -- **Custom** — each wizard section (database, ORM, auth, …) is rendered as a single-select. 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. +- **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, and constitution as below, then a **vendor-skills consent** step (records consent for vendor official skills; external fetch is [Coming soon](../roadmap.md)). The summary additionally 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`, `vendorSkills`). +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, then a **vendor-skills consent** step (records consent for vendor official skills). On install, consented skills with a known source are fetched automatically from the vendor's registry into `.claude/skills/`; those without a source are flagged **(manual install)**, and any single fetch failure is non-fatal. The summary additionally 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`, `vendorSkills`). ## Steps @@ -61,18 +72,18 @@ Shows the PHARN logo and CLI version. Hard requirements. See [Getting started](../getting-started.md#prerequisites). -- Next.js in `package.json` -- `.git` present +- **`.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`. ### 3. Fresh check -Soft warnings based on git history and file layout. Thresholds: +Soft warnings based on git signals only (framework-neutral). Thresholds: | Condition | Message intent | | --------- | -------------- | | `git rev-list --count HEAD` >= 6 | Significant history (only this warning) | | commit count 2–5 | Existing commits; may conflict with structure | -| 0–1 commits and > 3 custom root/app files | Project already customized | +| 0–1 commits and `git ls-files` > 40 | Populated repo, not a fresh scaffold | Default for "Continue anyway?" is **no** (false). @@ -86,7 +97,7 @@ A multiselect of **optional** modules (required modules and stack-pack bases are ### 6. Stack pack select -A single choice among the available stack packs (currently `pharn-stack-nextjs`), or **None**. Stack packs are mutually exclusive; the chosen pack's dependencies (e.g. the React base) are pulled in automatically. +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. ### 7. Privacy posture / constitution @@ -98,9 +109,16 @@ Maps your answer to a constitution variant shipped in `pharn-core/templates/cons | Standard SaaS with user data | `standard` | 1–4 | | Internal tools / B2B, no end-user PII | `minimal` | 2–4 | -### 8. Summary +### 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, and the constitution variant. Then: +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: | Action | Result | | ------ | ------ | @@ -108,16 +126,16 @@ Displays the **resolved** module set (your selections plus all transitive depend | Go back and change something | Re-run the selection steps, keeping your previous answers | | Cancel | Exit 0; nothing written | -### 9. Install +### 10. 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` | +| 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` | +| Write `pharn.config.json` | `skillsVersion`, `commit`, `modules`, `constitution`, `isMultiTenant` | Overwrite prompt if `pharn.config.json` already exists (default: do not overwrite). diff --git a/docs/getting-started.md b/docs/getting-started.md index 2e1c6c1..678f427 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,21 +1,21 @@ # Getting started -PHARN does not create a Next.js app from scratch. You scaffold with `create-next-app`, initialize git, then run PHARN in that directory. +PHARN does not scaffold your app. You create your project (e.g. with `create-next-app`), initialize git, then run PHARN in that directory. ## Prerequisites -| Requirement | How PHARN checks | -| ----------- | ---------------- | -| Next.js | `next` in `package.json` `dependencies` or `devDependencies` | -| Git | `.git` directory exists in the project root | +| 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` | -If either check fails, the CLI prints fix instructions and exits. See [Troubleshooting](troubleshooting.md). +`.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). -PHARN works best on **fresh** projects. The wizard may warn when: +PHARN works best on **fresh** projects. The wizard may warn (framework-neutral, git-based) when: - The repo has **6 or more** commits — checked first; repos with 6+ commits do not also see the 2+ warning -- The repo has **2–5** commits (designed for fresh scaffolds) -- There are **0–1** commits (a fresh scaffold; `create-next-app` makes one initial commit) but more than **3** custom files outside known Next.js paths +- The repo has **2–5** commits +- There are **0–1** commits but more than **40** tracked files (`git ls-files`) — a populated repo rather than a fresh scaffold You can continue after any warning by confirming. @@ -47,17 +47,19 @@ The wizard adapts to the manifest the CLI fetches. Newer manifests (`schemaVersi ### Stack mode (schemaVersion 2) -First you choose a mode: +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). -- **Default** — takes the recommended stack straight from the manifest and asks **no** per-technology questions. -- **Custom** — walks each section (database, ORM, auth, email, payments, …). 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 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). The stack pack pulls in its React base automatically. +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. Automatic fetching of those official skills is **Coming soon** ([roadmap](roadmap.md)); for now your choice is recorded in `pharn.config.json` and you install them by hand. +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. diff --git a/docs/reference/pharn-config.md b/docs/reference/pharn-config.md index 981c5b7..5730720 100644 --- a/docs/reference/pharn-config.md +++ b/docs/reference/pharn-config.md @@ -13,11 +13,12 @@ PHARN skills in your project read this file at runtime (e.g. to discover the ins | `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) | -| `vendorSkills` | array | _schemaVersion 2 only._ Vendor official skills you consented to (external fetch is [Coming soon](../roadmap.md)) | +| `vendorSkills` | array | _schemaVersion 2 only._ Names of the vendor official skills you consented to. At `init`, any with a known source are fetched automatically from the vendor's registry; only the names are stored here | The three `schemaVersion 2` fields are **additive** — installs against an older (`schemaVersion 1`) manifest omit them entirely, and existing configs stay valid. @@ -30,6 +31,7 @@ The three `schemaVersion 2` fields are **additive** — installs against an olde "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" }, diff --git a/docs/roadmap.md b/docs/roadmap.md index e9b21d8..8440d0e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,12 +19,13 @@ What PHARN CLI does today versus what is planned. | `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 | ## Planned | Capability | Description | | ---------- | ----------- | -| Vendor official-skill fetching | `init` records consent for vendor skills (e.g. Supabase) today; automatic fetching of those official skills from the vendor registry, with SHA pinning, is not built yet | +| 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` | | Migration for existing projects | Onboard repos with significant git history (today the CLI only warns) | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b8dacb3..af3e776 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,7 +4,7 @@ | Situation | Exit code | | --------- | --------- | -| Prerequisite failure (no Next.js or no `.git`) | 1 | +| Prerequisite failure (no `.git`, or a selected stack pack's required package is missing) | 1 | | Module catalog / install failure | 1 | | Unknown command | 1 | | `add` / `update` with no `pharn.config.json` | 1 | @@ -13,15 +13,16 @@ ## Prerequisites failed -### Next.js not found +### 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 -✗ Next.js not found. - Run: npx create-next-app@latest +✗ pharn-stack-nextjs targets Next.js. Run: npx create-next-app@latest Then re-run: npx pharn init ``` -Ensure `next` appears in `package.json` `dependencies` or `devDependencies`. Exits with code **1**. +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. ### Git not found @@ -37,7 +38,7 @@ Exits with code **1**. ### Monorepos / workspaces -`pharn init` checks the **current directory** for both `package.json` (with `next`) and a `.git` directory, 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 the Next.js `package.json` and `.git` — e.g. the app package if it has its own git, otherwise the repo root only works when `next` is in the root `package.json`. Split layouts (`.git` at the root, `next` in `apps/web/package.json`) are unsupported in v1. +`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. ## Fresh-project warnings diff --git a/src/commands/add.ts b/src/commands/add.ts index 1c71d82..89d39f7 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -11,15 +11,25 @@ import { import pc from 'picocolors'; import { cancelAndExit } from '../lib/confirm.js'; import { REPO_URL } from '../lib/constants.js'; -import { categorizeModules, fetchRemoteManifest } from '../lib/manifest.js'; +import { + categorizeModules, + fetchRemoteManifest, + resolveModules, +} from '../lib/manifest.js'; import { findSkillOption, listSkillAddresses } from '../lib/wizard.js'; +import { assertPrerequisites } from '../steps/prereqs.js'; import { fetchAndInstall } from '../lib/installer.js'; import { readPharnConfig, toInstalledModules, writePharnConfig, } from '../lib/pharn-config.js'; -import type { InstalledSkill, Manifest, PharnConfig } from '../types.js'; +import type { + InstalledSkill, + Manifest, + ManifestModule, + PharnConfig, +} from '../types.js'; export async function runAdd(moduleArg: string | undefined): Promise { intro('pharn add'); @@ -73,6 +83,26 @@ export async function runAdd(moduleArg: string | undefined): Promise { 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(); @@ -82,7 +112,6 @@ export async function runAdd(moduleArg: string | undefined): Promise { let commit: string | null; try { // Re-resolve the union so dependencies of the new module are pulled in too. - const union = [...installed, moduleName]; const result = await fetchAndInstall({ claudeDir, selected: union }); resolved = result.resolved; skillsVersion = result.skillsVersion; diff --git a/src/commands/init.ts b/src/commands/init.ts index f7e1361..3963b8e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -12,13 +12,15 @@ import { collectInstalls, collectVendorSkills, } from '../lib/wizard.js'; -import { runPrereqs } from '../steps/prereqs.js'; +import { runGitPrereq, assertPrerequisites } 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 { runVendorConsent } from '../steps/vendor-consent.js'; import { runSummary } from '../steps/summary.js'; import { runInstall } from '../steps/install.js'; @@ -28,7 +30,7 @@ export async function runInit(): Promise { showBanner(); intro('init wizard'); - runPrereqs(); + runGitPrereq(); await runFreshCheck(); const manifest = await loadManifest(); @@ -48,18 +50,31 @@ async function runInitV2( ): 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) - : await runWizardQuestions(wizard, previous?.stackAnswers); + ? applyDefaults(wizard, detectedAnswers) + : await runWizardQuestions( + wizard, + previous?.stackAnswers ?? detectedAnswers, + ); const modules = await runModuleSelect(optional, previous?.modules); - const stackPack = await runStackPackSelect(stackPacks, previous?.stackPack); + // 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 vendorSkills = await runVendorConsent( @@ -71,6 +86,7 @@ async function runInitV2( modules, stackPack, constitution, + isMultiTenant, stackAnswers, installedSkills, vendorSkills, @@ -82,6 +98,9 @@ async function runInitV2( 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; } @@ -102,8 +121,14 @@ async function runInitLegacy(manifest: Manifest): Promise { 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 }; + const config: WizardConfig = { + modules, + stackPack, + constitution, + isMultiTenant, + }; const selected = [...modules, ...(stackPack ? [stackPack] : [])]; const resolved = resolveModules(manifest, selected); @@ -111,6 +136,9 @@ async function runInitLegacy(manifest: Manifest): Promise { 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; } diff --git a/src/lib/constitution.ts b/src/lib/constitution.ts new file mode 100644 index 0000000..f91510f --- /dev/null +++ b/src/lib/constitution.ts @@ -0,0 +1,47 @@ +// Pure (no-I/O) constitution transforms. Sits with lib/wizard.ts and lib/diff.ts +// as a testable helper used by the installer when materializing CONSTITUTION.md. + +// Principle 2 is "Multi-Tenant Isolation" across every shipped constitution +// variant. It is gated on the project's multi-tenant SaaS flag: a non-SaaS +// project has it stripped so it is not blocked by a principle that doesn't apply. +export const MULTI_TENANT_PRINCIPLE = 2; + +/** + * Remove a single constitution principle by its canonical number, keeping the + * `principles_included` frontmatter array and the `## Principle N:` body + * headings consistent (pharn-init's [A2] check cross-validates the two). + * + * Idempotent and defensive: stripping a principle that isn't present, or a + * string that lacks the expected structure, returns the input unchanged. + */ +export function stripPrinciple(md: string, n: number): string { + return dropPrincipleSection(dropFromPrinciplesIncluded(md, n), n); +} + +// Drop `n` from the inline `principles_included: [...]` frontmatter array. +function dropFromPrinciplesIncluded(md: string, n: number): string { + return md.replace( + /^(principles_included:[ \t]*)\[([^\]]*)\]/m, + (full, prefix: string, inner: string) => { + const items = inner + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (!items.includes(String(n))) return full; + const kept = items.filter((s) => s !== String(n)); + return `${prefix}[${kept.join(', ')}]`; + }, + ); +} + +// Remove the `## Principle n:` heading and its body, collapsing to a single +// blank-line separator. The body runs up to the next *real* section boundary — +// another principle, the trailing `## How this file is enforced` section, or +// end of document — never an arbitrary `## ` that may appear inside the +// principle's own body (e.g. a fenced code block), which would leave a fragment. +function dropPrincipleSection(md: string, n: number): string { + const re = new RegExp( + String.raw`\n## Principle ${n}:[^\n]*\n[\s\S]*?(?=\n## Principle \d|\n## How this file is enforced|\s*$)`, + ); + return md.replace(re, ''); +} diff --git a/src/lib/install-modules.ts b/src/lib/install-modules.ts index 3edb9f2..43633d8 100644 --- a/src/lib/install-modules.ts +++ b/src/lib/install-modules.ts @@ -1,8 +1,9 @@ -import { cpSync, existsSync } from 'node:fs'; +import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, resolve, sep } from 'node:path'; import { CORE_MODULE } 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 @@ -89,6 +90,7 @@ export function materializeCore( repoDir: string, claudeDir: string, constitution: Constitution, + isMultiTenant = true, ): void { const coreDir = resolve(repoDir, CORE_MODULE); @@ -116,9 +118,19 @@ export function materializeCore( `Constitution variant "${constitution}" not found in pharn-core templates.`, ); } - cpSync(constitutionFrom, resolve(claudeDir, 'CONSTITUTION.md'), { - force: true, - }); + const constitutionTo = resolve(claudeDir, 'CONSTITUTION.md'); + 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 diff --git a/src/lib/installer.ts b/src/lib/installer.ts index 8527c12..36c3af3 100644 --- a/src/lib/installer.ts +++ b/src/lib/installer.ts @@ -29,6 +29,9 @@ export async function fetchAndInstall(params: { 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 ?? []; @@ -45,7 +48,12 @@ export async function fetchAndInstall(params: { } installSkills(repo.dir, params.claudeDir, wizardSkills); if (params.constitution) { - materializeCore(repo.dir, params.claudeDir, params.constitution); + materializeCore( + repo.dir, + params.claudeDir, + params.constitution, + params.isMultiTenant, + ); } const commit = await fetchCommitSha(); diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts index c20d52b..e47400d 100644 --- a/src/lib/manifest.ts +++ b/src/lib/manifest.ts @@ -11,6 +11,8 @@ import { VERSION_RE, INSTALL_PATH_RE, WIZARD_VALUE_RE, + VENDOR_SOURCE_RE, + PACKAGE_NAME_RE, assertSafeString, assertNoDotDot, isPlainObject, @@ -18,6 +20,7 @@ import { import type { Manifest, ManifestModule, + ModulePrerequisite, ModuleManifest, WizardCondition, WizardOption, @@ -132,7 +135,41 @@ function parseManifestModule(raw: unknown, path: string): ManifestModule { raw.kind === undefined ? undefined : assertSafeString(raw.kind, `${path}.kind`, WIZARD_VALUE_RE); - return { ...common, kind }; + 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( @@ -353,13 +390,34 @@ function parseWizardOption( } else { vendorSkill = raw.vendorSkill as null | undefined; } + // Optional `source` (oss-6): degit location for the vendor's official skill. + // Validated against a strict allowlist + '..' check since it is handed to + // degit at install time. Absent/null = no known location (manual install). + let source: string | null | undefined; + if (raw.source !== null && raw.source !== undefined) { + source = assertSafeString(raw.source, `${path}.source`, VENDOR_SOURCE_RE); + assertNoDotDot(source, `${path}.source`); + } else { + source = raw.source as null | undefined; + } + // 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, vendorSkill, + source, comingSoon: raw.comingSoon as boolean | undefined, + detect, }; } @@ -614,6 +672,24 @@ export function categorizeModules(manifest: Manifest): ModuleCategories { 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) // --------------------------------------------------------------------------- diff --git a/src/lib/validate.ts b/src/lib/validate.ts index f235ec2..9cef5ae 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -13,6 +13,15 @@ export const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; export const INSTALL_PATH_RE = /^[a-z0-9_-]+(\/[a-z0-9_-]+)*\/?$/; // Wizard answer values + ids (e.g. "supabase", "better-auth", "skip"). export const WIZARD_VALUE_RE = /^[a-z0-9-]+$/; +// degit-compatible vendor skill source (e.g. "github:supabase/skills/foo", +// "user/repo#main", "https://github.com/user/repo"). Handed to degit, so kept +// to a strict allowlist and additionally checked for '..' / control chars. +export const VENDOR_SOURCE_RE = /^[A-Za-z0-9@:/._#-]+$/; +// npm package names: optional @scope/, then lowercase letters, digits, '-', +// '_', '.'. Compared against package.json keys (never path-joined), but kept +// strict + checked for '..' for defense in depth. +export const PACKAGE_NAME_RE = + /^(@[a-z0-9][a-z0-9-._]*\/)?[a-z0-9][a-z0-9-._]*$/; // eslint-disable-next-line no-control-regex const CONTROL_CHARS_RE = /[\x00-\x1f\x7f-\x9f]/; diff --git a/src/lib/vendor-fetch.ts b/src/lib/vendor-fetch.ts new file mode 100644 index 0000000..c29aa22 --- /dev/null +++ b/src/lib/vendor-fetch.ts @@ -0,0 +1,54 @@ +import { basename } from 'node:path'; +import degit from 'degit'; +import { safeJoin } from './install-modules.js'; +import { VENDOR_SOURCE_RE } from './validate.js'; +import type { VendorSkill } from '../types.js'; + +export interface VendorFetchResult { + // Vendor skills cloned successfully into .claude/skills//. + fetched: string[]; + // Vendor skills with no known source — recorded for manual install. + manual: string[]; + // Vendor skills whose fetch failed (network/404/invalid source). Non-fatal. + failed: { name: string; message: string }[]; +} + +/** + * Auto-fetch consented vendor official skills from their declared degit + * `source` into `.claude/skills//`, mirroring the pharn-oss clone pattern + * in lib/repo.ts. Each skill is independent: a skill with no source falls back + * to manual, and any fetch failure is captured (never thrown) so it can never + * abort the surrounding install. The dest path is guarded with `safeJoin`. + */ +export async function fetchVendorSkills( + claudeDir: string, + vendors: VendorSkill[], +): Promise { + const result: VendorFetchResult = { fetched: [], manual: [], failed: [] }; + for (const vendor of vendors) { + if (vendor.source == null) { + result.manual.push(vendor.name); + continue; + } + // Defense in depth: the source came from the validated manifest, but it is + // handed to degit (which shells out), so re-check the allowlist. + if (!VENDOR_SOURCE_RE.test(vendor.source) || vendor.source.includes('..')) { + result.failed.push({ + name: vendor.name, + message: `invalid source "${vendor.source}"`, + }); + continue; + } + try { + const dest = safeJoin(claudeDir, `skills/${basename(vendor.name)}`); + await degit(vendor.source, { force: true, cache: false }).clone(dest); + result.fetched.push(vendor.name); + } catch (err) { + result.failed.push({ + name: vendor.name, + message: err instanceof Error ? err.message : String(err), + }); + } + } + return result; +} diff --git a/src/lib/wizard.ts b/src/lib/wizard.ts index 7238a41..e6e2ebd 100644 --- a/src/lib/wizard.ts +++ b/src/lib/wizard.ts @@ -7,6 +7,7 @@ import type { WizardQuestion, WizardRule, WizardSpec, + VendorSkill, } from '../types.js'; type WarnRule = Extract; @@ -131,24 +132,99 @@ export function collectInstalls( } /** - * Vendor official-skill names to feed the consent step: every answered option - * carrying a non-null `vendorSkill`. + * Vendor official skills to feed the consent step: every answered option + * carrying a non-null `vendorSkill`, paired with its degit `source` (or null + * when no official-skill location is known → manual install). */ export function collectVendorSkills( wizard: WizardSpec, answers: Answers, -): string[] { - const vendors: string[] = []; +): VendorSkill[] { + const vendors: VendorSkill[] = []; for (const question of eachQuestion(wizard)) { const option = findChosenOption(question, answers); - if (option?.vendorSkill) vendors.push(option.vendorSkill); + if (option?.vendorSkill) { + vendors.push({ name: option.vendorSkill, source: option.source ?? null }); + } } return vendors; } -/** Default mode answers: wizard.defaults verbatim (asks nothing per-tech). */ -export function applyDefaults(wizard: WizardSpec): Answers { - return { ...wizard.defaults }; +/** + * 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 { diff --git a/src/steps/detect.ts b/src/steps/detect.ts new file mode 100644 index 0000000..1f7136a --- /dev/null +++ b/src/steps/detect.ts @@ -0,0 +1,41 @@ +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/fresh-check.ts b/src/steps/fresh-check.ts index 2993c7b..d262a39 100644 --- a/src/steps/fresh-check.ts +++ b/src/steps/fresh-check.ts @@ -1,56 +1,10 @@ import { execSync } from 'node:child_process'; -import { existsSync, readdirSync, statSync } from 'node:fs'; -import { resolve } from 'node:path'; import { warnAndConfirm } from '../lib/confirm.js'; -export const CUSTOM_FILE_THRESHOLD = 3; - -const KNOWN_FILES = new Set([ - 'package.json', - 'package-lock.json', - 'pnpm-lock.yaml', - 'yarn.lock', - 'bun.lockb', - '.gitignore', - 'tsconfig.json', - 'tsconfig.tsbuildinfo', - 'README.md', - 'components.json', - 'next-env.d.ts', - '.eslintrc', - '.eslintrc.json', - 'biome.json', - 'biome.jsonc', -]); - -const KNOWN_PREFIXES = [ - 'next.config.', - 'eslint.config.', - 'postcss.config.', - 'tailwind.config.', - 'prettier.config.', -]; - -const KNOWN_DIRS = new Set([ - 'public', - 'app', - 'src', - 'pages', - 'node_modules', - '.git', - '.next', - 'components', - 'lib', - 'hooks', - 'styles', -]); - -const KNOWN_APP_FILES = new Set([ - 'page.tsx', - 'layout.tsx', - 'globals.css', - 'favicon.ico', -]); +// A fresh scaffold (e.g. create-next-app + shadcn init) tracks ~25 files in its +// initial commit, so this generous threshold won't fire on the common case — +// it flags a substantial existing codebase committed under a single commit. +export const TRACKED_FILE_THRESHOLD = 40; export async function runFreshCheck(): Promise { const cwd = process.cwd(); @@ -58,7 +12,7 @@ export async function runFreshCheck(): Promise { if (commits >= 6) { await warnAndConfirm( - '⚠ This project has significant history. PHARN works best on fresh Next.js projects. For existing projects, see /docs/migrate (coming in v2).', + '⚠ This project has significant git history. PHARN works best on fresh projects. For existing projects, see /docs/migrate (coming in v2).', 'Continue anyway?', false, ); @@ -75,12 +29,11 @@ export async function runFreshCheck(): Promise { } if (commits <= 1) { - // create-next-app makes one initial commit, so the common "fresh scaffold" - // case lands here — still run the custom-file heuristic. - const custom = countCustomFiles(cwd); - if (custom > CUSTOM_FILE_THRESHOLD) { + // A scaffold commonly lands here (create-next-app makes one initial commit), + // so distinguish a fresh scaffold from a populated repo by tracked-file count. + if (gitTrackedFileCount(cwd) > TRACKED_FILE_THRESHOLD) { await warnAndConfirm( - '⚠ This project looks customized already. PHARN init is designed for fresh Next.js scaffolds. Continuing may conflict with existing files.', + '⚠ This project looks customized already. PHARN init is designed for fresh projects. Continuing may conflict with existing files.', 'Continue anyway?', false, ); @@ -102,36 +55,14 @@ export function gitCommitCount(cwd: string): number { } } -export function countCustomFiles(cwd: string): number { - let count = 0; - for (const entry of readdirSync(cwd)) { - if (entry.startsWith('.')) continue; - if (KNOWN_DIRS.has(entry) || KNOWN_FILES.has(entry)) continue; - if (KNOWN_PREFIXES.some((p) => entry.startsWith(p))) continue; - const full = resolve(cwd, entry); - try { - if (statSync(full).isDirectory()) { - count += 1; - continue; - } - } catch { - continue; - } - count += 1; - } - count += countAppCustomFiles(cwd); - return count; -} - -function countAppCustomFiles(cwd: string): number { - // `--src-dir` scaffolds put the router under src/app instead of app/. - const rootApp = resolve(cwd, 'app'); - const appDir = existsSync(rootApp) ? rootApp : resolve(cwd, 'src', 'app'); - if (!existsSync(appDir)) return 0; - let count = 0; - for (const entry of readdirSync(appDir)) { - if (KNOWN_APP_FILES.has(entry)) continue; - count += 1; +export function gitTrackedFileCount(cwd: string): number { + try { + const out = execSync('git ls-files', { + cwd, + stdio: ['ignore', 'pipe', 'ignore'], + }).toString(); + return out.split('\n').filter((line) => line.trim() !== '').length; + } catch { + return 0; } - return count; } diff --git a/src/steps/install.ts b/src/steps/install.ts index 1cc7c95..1cb26f4 100644 --- a/src/steps/install.ts +++ b/src/steps/install.ts @@ -6,6 +6,8 @@ import pc from 'picocolors'; import { cancelAndExit } from '../lib/confirm.js'; import { DOCS_URL, FIRST_FEATURE_COMMAND, REPO_URL } from '../lib/constants.js'; import { fetchAndInstall } from '../lib/installer.js'; +import { fetchVendorSkills } from '../lib/vendor-fetch.js'; +import type { VendorFetchResult } from '../lib/vendor-fetch.js'; import { configPath, readPharnConfig, @@ -53,6 +55,7 @@ export async function runInstall(config: WizardConfig): Promise { selected, constitution: config.constitution, wizardSkills: config.installedSkills, + isMultiTenant: config.isMultiTenant, }); skillsVersion = result.skillsVersion; commit = result.commit; @@ -67,12 +70,39 @@ export async function runInstall(config: WizardConfig): Promise { process.exit(1); } + // Vendor official skills are fetched from each vendor's own registry, AFTER + // the (atomic) pharn-oss install. Intentionally outside the try/catch above: + // a vendor 404/network error is non-fatal and must never fail the install. + let vendorResult: VendorFetchResult = { fetched: [], manual: [], failed: [] }; + if (config.vendorSkills && config.vendorSkills.length > 0) { + const vs = spinner(); + vs.start('Fetching vendor skills'); + vendorResult = await fetchVendorSkills(claudeDir, config.vendorSkills); + vs.stop( + vendorResult.fetched.length > 0 + ? `Vendor skills fetched → ${pc.dim('.claude/skills/')}` + : 'Vendor skills processed', + ); + for (const f of vendorResult.failed) { + log.warn(`Vendor skill "${f.name}" could not be fetched: ${f.message}`); + } + if (vendorResult.manual.length > 0) { + log.info( + `Install by hand (no known source): ${vendorResult.manual.join(', ')}`, + ); + } + if (vendorResult.failed.length > 0 && !process.env.PHARN_DEBUG) { + log.info('Re-run with PHARN_DEBUG=1 for full error output.'); + } + } + 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(), // schemaVersion 2: persist the wizard answers + selected skills so add and @@ -82,7 +112,7 @@ export async function runInstall(config: WizardConfig): Promise { ? { installedSkills: config.installedSkills } : {}), ...(config.vendorSkills && config.vendorSkills.length > 0 - ? { vendorSkills: config.vendorSkills } + ? { vendorSkills: config.vendorSkills.map((v) => v.name) } : {}), }; await writePharnConfig(cwd, configFile); @@ -97,6 +127,11 @@ export async function runInstall(config: WizardConfig): Promise { `${check} ${config.installedSkills.length} skill${config.installedSkills.length === 1 ? '' : 's'} installed → ${pc.dim('.claude/skills/')} ${pc.dim(`(${config.installedSkills.map((s) => s.skill).join(', ')})`)}`, ] : []), + ...(vendorResult.fetched.length > 0 + ? [ + `${check} ${vendorResult.fetched.length} vendor skill${vendorResult.fetched.length === 1 ? '' : 's'} fetched → ${pc.dim('.claude/skills/')} ${pc.dim(`(${vendorResult.fetched.join(', ')})`)}`, + ] + : []), `${check} CONSTITUTION.md + memory-bank written`, `${check} pharn.config.json written ${pc.dim(`(skills v${skillsVersion})`)}`, `${pc.dim(`Done in ${elapsed}s`)}`, diff --git a/src/steps/multitenant-select.ts b/src/steps/multitenant-select.ts new file mode 100644 index 0000000..73d6219 --- /dev/null +++ b/src/steps/multitenant-select.ts @@ -0,0 +1,20 @@ +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/prereqs.ts b/src/steps/prereqs.ts index 88b7c7f..f268f63 100644 --- a/src/steps/prereqs.ts +++ b/src/steps/prereqs.ts @@ -1,16 +1,12 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { cancel } from '@clack/prompts'; +import type { ManifestModule, ModulePrerequisite } from '../types.js'; -export function runPrereqs(): void { +// Universal, framework-agnostic gate. Run up-front, before the wizard. +export function runGitPrereq(): void { const cwd = process.cwd(); - if (!hasNextDependency(cwd)) { - fail( - '✗ Next.js not found.\n Run: npx create-next-app@latest\n Then re-run: npx pharn init', - ); - } - if (!existsSync(resolve(cwd, '.git'))) { fail( "✗ git not found.\n Run: git init && git add -A && git commit -m 'init'\n Then re-run: npx pharn init", @@ -18,17 +14,55 @@ export function runPrereqs(): void { } } -export function hasNextDependency(cwd: string): boolean { +/** + * 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 false; + if (!existsSync(pkgPath)) return new Set(); try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { dependencies?: Record; devDependencies?: Record; }; - return Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next); + return new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ]); } catch { - return false; + return new Set(); } } diff --git a/src/steps/summary.ts b/src/steps/summary.ts index 1adc0f8..5cacf86 100644 --- a/src/steps/summary.ts +++ b/src/steps/summary.ts @@ -27,6 +27,10 @@ export async function runSummary( '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)', @@ -45,7 +49,9 @@ export async function runSummary( ? [ '', ' VENDOR SKILLS (recorded)', - ...config.vendorSkills.map((v) => row(v, 'fetch: coming soon')), + ...config.vendorSkills.map((v) => + row(v.name, v.source ? 'fetch: auto' : 'install by hand'), + ), ] : []), '', diff --git a/src/steps/vendor-consent.ts b/src/steps/vendor-consent.ts index 12e33f7..fda623f 100644 --- a/src/steps/vendor-consent.ts +++ b/src/steps/vendor-consent.ts @@ -1,37 +1,51 @@ import { isCancel, multiselect, note } from '@clack/prompts'; import { cancelAndExit } from '../lib/confirm.js'; +import type { VendorSkill } from '../types.js'; /** * Vendor official skills (e.g. supabase) are published by the vendor and live - * outside pharn-oss. External fetching is not built yet — this step records the - * user's consent (default-checked, explicit confirm) so it can drive automatic - * fetching once that ships. Returns the vendor skill names consented to. + * outside pharn-oss. This step records the user's consent (default-checked, + * explicit confirm); on install, skills with a declared `source` are fetched + * automatically from the vendor's registry, and any without a known source are + * recorded for manual install. Returns the consented vendor skills. */ export async function runVendorConsent( - candidates: string[], - initial?: string[], -): Promise { - const unique = [...new Set(candidates)]; + candidates: VendorSkill[], + initial?: VendorSkill[], +): Promise { + // Dedupe by name, keeping the first occurrence (and its source). + const seen = new Set(); + const unique = candidates.filter((c) => + seen.has(c.name) ? false : (seen.add(c.name), true), + ); if (unique.length === 0) return []; note( [ 'These are official skills published by each vendor, fetched from the', - "vendor's own registry — not from pharn-oss. Automatic fetching is", - 'coming soon; for now your choice is recorded and you install them by hand.', + "vendor's own registry — not from pharn-oss. Skills with a known source", + 'are fetched automatically on install; any marked (manual install) have', + 'no known source yet, so your choice is recorded and you install them by hand.', ].join('\n'), 'Vendor skills', ); + const initialNames = new Set(initial?.map((v) => v.name)); const result = await multiselect({ message: 'Record consent for which vendor skills?', - options: unique.map((v) => ({ value: v, label: v })), + options: unique.map((v) => ({ + value: v.name, + label: v.source ? v.name : `${v.name} (manual install)`, + })), // On loop-back, restore the prior consent (intersected with what's still on // offer); on the first pass everything is default-checked as documented. - initialValues: initial?.filter((v) => unique.includes(v)) ?? unique, + initialValues: initial + ? unique.filter((v) => initialNames.has(v.name)).map((v) => v.name) + : unique.map((v) => v.name), required: false, }); if (isCancel(result)) cancelAndExit(); - return result as string[]; + const chosen = new Set(result as string[]); + return unique.filter((v) => chosen.has(v.name)); } diff --git a/src/types.ts b/src/types.ts index beb205c..6d62379 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,14 @@ // 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; @@ -12,6 +20,11 @@ export interface ManifestModule { // 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 { @@ -42,8 +55,14 @@ export interface WizardOption { install: string | null; // Vendor official-skill name for the consent step, or null/absent. vendorSkill?: string | null; + // degit-compatible source for the vendor's official skill (oss-6), or + // null/absent when no official-skill location is known (manual install only). + source?: 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 = @@ -101,11 +120,23 @@ export interface WizardConfig { // 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"), the skill subfolders to copy, and the vendor skills consented to. stackAnswers?: Record; installedSkills?: InstalledSkill[]; - vendorSkills?: string[]; + vendorSkills?: VendorSkill[]; +} + +// A consented vendor official skill carried through the init flow. `source` is +// the degit location to auto-fetch from, or null (manual install only). The +// persisted pharn.config.json keeps only the names (see PharnConfig). +export interface VendorSkill { + name: string; + source: string | null; } // --------------------------------------------------------------------------- @@ -131,6 +162,10 @@ export interface PharnConfig { repo: string; commit: string | null; constitution: Constitution; + // Whether the project is a multi-tenant SaaS. Written on every fresh install; + // absent on legacy installs predating this flag (read as true → P2 kept). + // When false, Principle 2 was stripped from CONSTITUTION.md at install. + isMultiTenant?: boolean; modules: InstalledModule[]; installedAt: string; // schemaVersion 2 additions (absent on legacy installs): diff --git a/tests/add.test.ts b/tests/add.test.ts index 17b9870..c2dcef3 100644 --- a/tests/add.test.ts +++ b/tests/add.test.ts @@ -45,9 +45,21 @@ const stackPacks: ManifestModule[] = [ }, ]; 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(); diff --git a/tests/constitution.test.ts b/tests/constitution.test.ts new file mode 100644 index 0000000..1b9ae56 --- /dev/null +++ b/tests/constitution.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + MULTI_TENANT_PRINCIPLE, + stripPrinciple, +} from '../src/lib/constitution.js'; + +// Mirrors the shape of pharn-core/templates/constitution/CONSTITUTION.*.md: YAML +// frontmatter with `principles_included`, an H1 + intro, one `## Principle N:` +// section per principle, then a trailing `## How this file is enforced`. +const NAMES: Record = { + 1: 'Privacy by Default', + 2: 'Multi-Tenant Isolation', + 3: 'Layer Integrity', + 4: 'No Secrets in Code', + 5: 'Accessibility is Not Optional', + 6: 'Data Lifecycle Completeness', +}; + +function constitution(principles: number[]): string { + const sections = principles + .map( + (n) => + `## Principle ${n}: ${NAMES[n]}\n\n- Rule for principle ${n}\n- VIOLATION: Stop pipeline.`, + ) + .join('\n\n'); + return [ + '---', + 'file: "CONSTITUTION.md"', + `principles_included: [${principles.join(', ')}]`, + 'kind: "pharn-owned"', + '---', + '', + '# Constitution — Non-Negotiable Principles', + '', + 'These principles override ALL rules.', + '', + sections, + '', + '## How this file is enforced', + '', + 'The orchestrator injects this file before every agent prompt.', + '', + ].join('\n'); +} + +describe('stripPrinciple', () => { + it('exposes Principle 2 as the multi-tenant principle', () => { + expect(MULTI_TENANT_PRINCIPLE).toBe(2); + }); + + it('drops a middle principle (standard: [1,2,3,4] -> [1,3,4])', () => { + const out = stripPrinciple(constitution([1, 2, 3, 4]), 2); + 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'); + expect(out).toContain('## Principle 4: No Secrets in Code'); + expect(out).toContain('## How this file is enforced'); + // Exactly one blank line where P2 used to be — no doubled blank lines. + expect(out).not.toMatch(/\n\n\n/); + }); + + it('drops the first-listed principle (minimal: [2,3,4] -> [3,4])', () => { + const out = stripPrinciple(constitution([2, 3, 4]), 2); + expect(out).toContain('principles_included: [3, 4]'); + expect(out).not.toMatch(/## Principle 2:/); + // The intro before the first principle is untouched. + expect(out).toContain('These principles override ALL rules.'); + expect(out).toContain('## Principle 3: Layer Integrity'); + expect(out).not.toMatch(/\n\n\n/); + }); + + it('leaves later principles untouched (gdpr-strict: [1..6] -> [1,3,4,5,6])', () => { + const out = stripPrinciple(constitution([1, 2, 3, 4, 5, 6]), 2); + expect(out).toContain('principles_included: [1, 3, 4, 5, 6]'); + expect(out).not.toMatch(/## Principle 2:/); + expect(out).toContain('## Principle 5: Accessibility is Not Optional'); + expect(out).toContain('## Principle 6: Data Lifecycle Completeness'); + }); + + it('removes the whole section even when the body contains a "## " line', () => { + const md = [ + '---', + 'principles_included: [1, 2, 3]', + '---', + '', + '# Constitution', + '', + 'Intro.', + '', + '## Principle 1: A', + '', + '- one', + '', + '## Principle 2: Multi-Tenant Isolation', + '', + '- rule', + '## inline-hash-in-body', + '- more', + '', + '## Principle 3: C', + '', + '- three', + '', + '## How this file is enforced', + '', + 'x', + '', + ].join('\n'); + + const out = stripPrinciple(md, 2); + + expect(out).toContain('principles_included: [1, 3]'); + expect(out).not.toMatch(/## Principle 2:/); + expect(out).not.toContain('- rule'); + expect(out).not.toContain('inline-hash-in-body'); + expect(out).not.toContain('- more'); + expect(out).toContain('## Principle 3: C'); + expect(out).toContain('- three'); + expect(out).not.toMatch(/\n\n\n/); + }); + + it('is a no-op when the principle is absent', () => { + const src = constitution([1, 3, 4]); // already without P2 + expect(stripPrinciple(src, 2)).toBe(src); + }); + + it('is a no-op on a string without the expected structure', () => { + expect(stripPrinciple('STANDARD', 2)).toBe('STANDARD'); + }); + + it('is idempotent', () => { + const once = stripPrinciple(constitution([1, 2, 3, 4]), 2); + expect(stripPrinciple(once, 2)).toBe(once); + }); +}); diff --git a/tests/detect.test.ts b/tests/detect.test.ts new file mode 100644 index 0000000..f8d2a62 --- /dev/null +++ b/tests/detect.test.ts @@ -0,0 +1,75 @@ +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/fresh-check.test.ts b/tests/fresh-check.test.ts index 3ad7eb2..589ed53 100644 --- a/tests/fresh-check.test.ts +++ b/tests/fresh-check.test.ts @@ -1,5 +1,5 @@ import { execSync } from 'node:child_process'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { ProcessExit, stubProcessExit, useTmpDir } from './helpers.js'; @@ -12,23 +12,39 @@ vi.mock('@clack/prompts', () => ({ })); const { - countCustomFiles, gitCommitCount, + gitTrackedFileCount, runFreshCheck, - CUSTOM_FILE_THRESHOLD, + TRACKED_FILE_THRESHOLD, } = await import('../src/steps/fresh-check.js'); const prompts = await import('@clack/prompts'); +function git(dir: string, cmd: string): void { + execSync( + `git -c user.email=test@example.com -c user.name=Test -c init.defaultBranch=main ${cmd}`, + { cwd: dir, stdio: 'ignore' }, + ); +} + function initGit(dir: string, commits: number): void { - const git = (cmd: string) => - execSync( - `git -c user.email=test@example.com -c user.name=Test -c init.defaultBranch=main ${cmd}`, - { cwd: dir, stdio: 'ignore' }, - ); - git('init'); + git(dir, 'init'); for (let i = 0; i < commits; i++) { - git(`commit --allow-empty -m "c${i}"`); + git(dir, `commit --allow-empty -m "c${i}"`); + } +} + +// Init a repo, write `fileCount` files, stage them, and optionally commit. +function initGitWithFiles( + dir: string, + fileCount: number, + commit: boolean, +): void { + git(dir, 'init'); + for (let i = 0; i < fileCount; i++) { + writeFileSync(join(dir, `file-${i}.txt`), ''); } + git(dir, 'add -A'); + if (commit) git(dir, 'commit -m "snapshot"'); } describe('gitCommitCount', () => { @@ -44,57 +60,21 @@ describe('gitCommitCount', () => { }); }); -describe('countCustomFiles', () => { +describe('gitTrackedFileCount', () => { const tmp = useTmpDir(); - it('ignores known top-level files and dotfiles', () => { - writeFileSync(join(tmp.path(), 'package.json'), '{}'); - writeFileSync(join(tmp.path(), 'tsconfig.json'), '{}'); - writeFileSync(join(tmp.path(), '.gitignore'), ''); - writeFileSync(join(tmp.path(), '.env.local'), ''); - expect(countCustomFiles(tmp.path())).toBe(0); - }); - - it('ignores files matching known prefixes (incl. tailwind/prettier)', () => { - writeFileSync(join(tmp.path(), 'next.config.ts'), ''); - writeFileSync(join(tmp.path(), 'eslint.config.mjs'), ''); - writeFileSync(join(tmp.path(), 'postcss.config.js'), ''); - writeFileSync(join(tmp.path(), 'tailwind.config.ts'), ''); - writeFileSync(join(tmp.path(), 'prettier.config.mjs'), ''); - expect(countCustomFiles(tmp.path())).toBe(0); - }); - - it('counts unknown top-level files and directories', () => { - writeFileSync(join(tmp.path(), 'random.md'), ''); - mkdirSync(join(tmp.path(), 'features')); - expect(countCustomFiles(tmp.path())).toBe(2); - }); - - it('counts unknown files inside app/ but ignores known ones', () => { - mkdirSync(join(tmp.path(), 'app')); - writeFileSync(join(tmp.path(), 'app', 'page.tsx'), ''); - writeFileSync(join(tmp.path(), 'app', 'layout.tsx'), ''); - writeFileSync(join(tmp.path(), 'app', 'globals.css'), ''); - writeFileSync(join(tmp.path(), 'app', 'extra.tsx'), ''); - expect(countCustomFiles(tmp.path())).toBe(1); + it('returns 0 outside a git repo', () => { + expect(gitTrackedFileCount(tmp.path())).toBe(0); }); - it('counts 0 for a fresh --src-dir + Biome scaffold', () => { - writeFileSync(join(tmp.path(), 'package.json'), '{}'); - writeFileSync(join(tmp.path(), 'biome.json'), '{}'); - mkdirSync(join(tmp.path(), 'src', 'app'), { recursive: true }); - writeFileSync(join(tmp.path(), 'src', 'app', 'page.tsx'), ''); - writeFileSync(join(tmp.path(), 'src', 'app', 'layout.tsx'), ''); - writeFileSync(join(tmp.path(), 'src', 'app', 'globals.css'), ''); - writeFileSync(join(tmp.path(), 'src', 'app', 'favicon.ico'), ''); - expect(countCustomFiles(tmp.path())).toBe(0); + it('counts committed tracked files', () => { + initGitWithFiles(tmp.path(), 5, true); + expect(gitTrackedFileCount(tmp.path())).toBe(5); }); - it('counts unknown files inside src/app when root app/ is absent', () => { - mkdirSync(join(tmp.path(), 'src', 'app'), { recursive: true }); - writeFileSync(join(tmp.path(), 'src', 'app', 'page.tsx'), ''); - writeFileSync(join(tmp.path(), 'src', 'app', 'extra.tsx'), ''); - expect(countCustomFiles(tmp.path())).toBe(1); + it('counts staged-but-uncommitted files', () => { + initGitWithFiles(tmp.path(), 3, false); + expect(gitTrackedFileCount(tmp.path())).toBe(3); }); }); @@ -111,10 +91,19 @@ describe('runFreshCheck', () => { cwd.mockRestore(); }); + it('is a no-op for a fresh scaffold (1 commit, few tracked files)', async () => { + initGitWithFiles(tmp.path(), 5, true); + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); + vi.mocked(prompts.confirm).mockReset().mockResolvedValue(true); + await expect(runFreshCheck()).resolves.toBeUndefined(); + expect(prompts.confirm).not.toHaveBeenCalled(); + cwd.mockRestore(); + }); + it('prompts when commit count >= 2', async () => { initGit(tmp.path(), 3); const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); - vi.mocked(prompts.confirm).mockResolvedValue(true); + vi.mocked(prompts.confirm).mockReset().mockResolvedValue(true); await runFreshCheck(); expect(prompts.confirm).toHaveBeenCalledTimes(1); cwd.mockRestore(); @@ -123,15 +112,13 @@ describe('runFreshCheck', () => { it('exits when the user declines on a stale repo', async () => { initGit(tmp.path(), 7); const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); - vi.mocked(prompts.confirm).mockResolvedValue(false); + vi.mocked(prompts.confirm).mockReset().mockResolvedValue(false); await expect(runFreshCheck()).rejects.toMatchObject(new ProcessExit(0)); cwd.mockRestore(); }); - it(`prompts when 0 commits and custom files > ${CUSTOM_FILE_THRESHOLD}`, async () => { - for (let i = 0; i <= CUSTOM_FILE_THRESHOLD + 1; i++) { - writeFileSync(join(tmp.path(), `custom-${i}.txt`), ''); - } + it(`prompts at 1 commit when tracked files > ${TRACKED_FILE_THRESHOLD}`, async () => { + initGitWithFiles(tmp.path(), TRACKED_FILE_THRESHOLD + 1, true); const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); vi.mocked(prompts.confirm).mockReset().mockResolvedValue(true); await runFreshCheck(); @@ -139,11 +126,8 @@ describe('runFreshCheck', () => { cwd.mockRestore(); }); - it('prompts at 1 commit when the project is already customized', async () => { - initGit(tmp.path(), 1); - for (let i = 0; i <= CUSTOM_FILE_THRESHOLD + 1; i++) { - writeFileSync(join(tmp.path(), `custom-${i}.txt`), ''); - } + it(`prompts at 0 commits when staged files > ${TRACKED_FILE_THRESHOLD}`, async () => { + initGitWithFiles(tmp.path(), TRACKED_FILE_THRESHOLD + 1, false); const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); vi.mocked(prompts.confirm).mockReset().mockResolvedValue(true); await runFreshCheck(); diff --git a/tests/init-v2.test.ts b/tests/init-v2.test.ts index 638b7fd..9ee4128 100644 --- a/tests/init-v2.test.ts +++ b/tests/init-v2.test.ts @@ -1,7 +1,9 @@ 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 { VendorSkill, 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(), @@ -24,27 +26,43 @@ vi.mock('../src/lib/manifest.js', () => ({ })); // Real wizard.js (applyDefaults/collectInstalls/collectVendorSkills) runs. -const runPrereqs = vi.fn(); +const runGitPrereq = vi.fn(); +const assertPrerequisites = vi.fn(); const runFreshCheck = vi.fn(async () => undefined); const runModeSelect = vi.fn(); -const runWizardQuestions = 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(async () => null); +const runStackPackSelect = vi.fn< + (packs: unknown, initial?: string | null) => Promise +>(async () => null); const runConstitutionSelect = vi.fn(async () => 'standard' as const); -const runVendorConsent = vi.fn(async (c: string[], _initial?: string[]) => c); +const runMultiTenantSelect = vi.fn(async () => true); +const runVendorConsent = vi.fn( + async (c: VendorSkill[], _initial?: VendorSkill[]) => c, +); const runSummary = vi.fn(); const runInstall = vi.fn<(config: WizardConfig) => Promise>( async () => undefined, ); -vi.mock('../src/steps/prereqs.js', () => ({ runPrereqs })); +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/vendor-consent.js', () => ({ runVendorConsent })); vi.mock('../src/steps/summary.js', () => ({ runSummary })); vi.mock('../src/steps/install.js', () => ({ runInstall })); @@ -63,6 +81,7 @@ describe('runInit (schemaVersion 2)', () => { 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({ @@ -73,7 +92,12 @@ describe('runInit (schemaVersion 2)', () => { payments: 'stripe', }); // supabase carries a vendorSkill; drizzle/better-auth/resend/stripe install. - expect(config.vendorSkills).toEqual(['supabase']); + expect(config.vendorSkills).toEqual([ + { + name: 'supabase', + source: 'github:supabase/supabase-skills/database', + }, + ]); expect(config.installedSkills?.map((s) => s.skill)).toEqual([ 'drizzle', 'better-auth', @@ -114,7 +138,12 @@ describe('runInit (schemaVersion 2)', () => { expect(runVendorConsent).toHaveBeenCalledTimes(2); expect(runVendorConsent.mock.calls[0]![1]).toBeUndefined(); - expect(runVendorConsent.mock.calls[1]![1]).toEqual(['supabase']); + expect(runVendorConsent.mock.calls[1]![1]).toEqual([ + { + name: 'supabase', + source: 'github:supabase/supabase-skills/database', + }, + ]); }); it('cancels from the summary', async () => { @@ -124,4 +153,84 @@ describe('runInit (schemaVersion 2)', () => { 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 049602f..a5f0ee6 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -21,20 +21,26 @@ vi.mock('../src/lib/manifest.js', () => ({ resolveModules, })); -const runPrereqs = vi.fn(); +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', () => ({ runPrereqs })); +vi.mock('../src/steps/prereqs.js', () => ({ + runGitPrereq, + assertPrerequisites, +})); 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, })); +vi.mock('../src/steps/multitenant-select.js', () => ({ runMultiTenantSelect })); vi.mock('../src/steps/summary.js', () => ({ runSummary })); vi.mock('../src/steps/install.js', () => ({ runInstall })); @@ -52,7 +58,9 @@ describe('runInit', () => { await runInit(); - expect(runPrereqs).toHaveBeenCalled(); + expect(runGitPrereq).toHaveBeenCalled(); + // Package prerequisites are checked on the install path, not up front. + expect(assertPrerequisites).toHaveBeenCalledTimes(1); expect(runInstall).toHaveBeenCalledTimes(1); }); @@ -71,6 +79,7 @@ describe('runInit', () => { runSummary.mockResolvedValue('cancel'); await expect(runInit()).rejects.toMatchObject(new ProcessExit(0)); + expect(assertPrerequisites).not.toHaveBeenCalled(); expect(runInstall).not.toHaveBeenCalled(); }); diff --git a/tests/install-modules.test.ts b/tests/install-modules.test.ts index 1b98af4..44fe727 100644 --- a/tests/install-modules.test.ts +++ b/tests/install-modules.test.ts @@ -189,6 +189,65 @@ describe('materializeCore', () => { ); }); + 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'); diff --git a/tests/install.test.ts b/tests/install.test.ts index 04637c9..2c315ba 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -19,6 +19,9 @@ vi.mock('@clack/prompts', () => ({ const fetchAndInstall = vi.fn(); vi.mock('../src/lib/installer.js', () => ({ fetchAndInstall })); +const fetchVendorSkills = vi.fn(); +vi.mock('../src/lib/vendor-fetch.js', () => ({ fetchVendorSkills })); + const writePharnConfig = vi.fn(); const readPharnConfig = vi.fn(); vi.mock('../src/lib/pharn-config.js', () => ({ @@ -36,6 +39,7 @@ const config: WizardConfig = { modules: ['pharn-pipeline'], stackPack: 'pharn-stack-nextjs', constitution: 'standard', + isMultiTenant: true, }; const okResult = { @@ -52,6 +56,12 @@ describe('runInstall', () => { beforeEach(() => { vi.spyOn(process, 'cwd').mockReturnValue('/proj'); fetchAndInstall.mockReset(); + fetchVendorSkills.mockReset(); + fetchVendorSkills.mockResolvedValue({ + fetched: [], + manual: [], + failed: [], + }); writePharnConfig.mockReset(); readPharnConfig.mockReset(); existsSync.mockReset(); @@ -68,6 +78,7 @@ describe('runInstall', () => { claudeDir: '/proj/.claude', selected: ['pharn-pipeline', 'pharn-stack-nextjs'], constitution: 'standard', + isMultiTenant: true, }); const [, written] = writePharnConfig.mock.calls[0]!; expect(written).toMatchObject({ @@ -75,6 +86,7 @@ describe('runInstall', () => { 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' }, @@ -94,12 +106,16 @@ describe('runInstall', () => { 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' }, ], - vendorSkills: ['supabase'], + vendorSkills: [ + { name: 'supabase', source: 'github:acme/supabase' }, + { name: 'stripe', source: null }, + ], }; await runInstall(v2Config); @@ -109,11 +125,52 @@ describe('runInstall', () => { selected: ['pharn-pipeline', 'pharn-stack-nextjs'], constitution: 'standard', wizardSkills: v2Config.installedSkills, + isMultiTenant: true, }); + // The consented vendor skills (name + source) are passed to the fetcher; + // only their names are persisted to pharn.config.json. + expect(fetchVendorSkills).toHaveBeenCalledWith( + '/proj/.claude', + v2Config.vendorSkills, + ); const [, written] = writePharnConfig.mock.calls[0]!; expect(written.stackAnswers).toEqual(v2Config.stackAnswers); expect(written.installedSkills).toEqual(v2Config.installedSkills); - expect(written.vendorSkills).toEqual(v2Config.vendorSkills); + expect(written.vendorSkills).toEqual(['supabase', 'stripe']); + }); + + it('still writes the config when a vendor fetch fails (non-fatal)', async () => { + existsSync.mockReturnValue(false); + fetchAndInstall.mockResolvedValue(okResult); + fetchVendorSkills.mockResolvedValue({ + fetched: [], + manual: [], + failed: [{ name: 'supabase', message: '404' }], + }); + const v2Config: WizardConfig = { + ...config, + vendorSkills: [{ name: 'supabase', source: 'github:acme/supabase' }], + }; + + await runInstall(v2Config); + + expect(prompts.log.warn).toHaveBeenCalled(); + const [, written] = writePharnConfig.mock.calls[0]!; + expect(written.vendorSkills).toEqual(['supabase']); + expect(prompts.outro).toHaveBeenCalled(); + }); + + 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 () => { diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 2a318c2..2ccb07e 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -55,6 +55,7 @@ describe('fetchAndInstall', () => { '/tmp/repo', '/proj/.claude', 'standard', + undefined, ); expect(cleanup).toHaveBeenCalledTimes(1); expect(result).toEqual({ @@ -70,6 +71,21 @@ describe('fetchAndInstall', () => { 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' }, diff --git a/tests/manifest-v2.test.ts b/tests/manifest-v2.test.ts index dbbaa39..e0def29 100644 --- a/tests/manifest-v2.test.ts +++ b/tests/manifest-v2.test.ts @@ -32,6 +32,20 @@ describe('parseManifest schemaVersion routing', () => { ); }); + 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('round-trips a vendor option source (oss-6)', () => { + const m = parseManifest(rawV2Manifest()); + const db = m.wizard?.sections[0]?.questions[0]; + const supabase = db?.options.find((o) => o.value === 'supabase'); + expect(supabase?.source).toBe('github:supabase/supabase-skills/database'); + }); + it('rejects unknown schema versions', () => { expect(() => parseManifest({ schemaVersion: 3, skillsVersion: '1.0.0', modules: [] }), @@ -212,6 +226,31 @@ describe('parseWizard validation', () => { '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']), + ], + [ + 'option source with a bad charset', + (w) => + (sectionsOf(w)[0]!.questions[0]!.options[0]!.source = + 'github:acme/repo branch'), + ], + [ + 'option source containing ..', + (w) => + (sectionsOf(w)[0]!.questions[0]!.options[0]!.source = + 'github:acme/../etc'), + ], ])('rejects: %s', (_label, mutate) => { const raw = rawV2Manifest(); mutate(raw.wizard as Record); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index f36270c..703d803 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -8,13 +8,14 @@ import { resolveModules, categorizeModules, isStackPack, + detectStackPack, ResolutionError, fetchRemoteManifest, readManifest, readModuleManifest, } from '../src/lib/manifest.js'; import { ManifestValidationError } from '../src/lib/validate.js'; -import type { Manifest } from '../src/types.js'; +import type { Manifest, ManifestModule } from '../src/types.js'; function manifest(): Manifest { return parseManifest({ @@ -161,6 +162,74 @@ describe('parseManifest', () => { }), ).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', () => { @@ -325,6 +394,44 @@ describe('isStackPack', () => { }); }); +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(); diff --git a/tests/multitenant-select.test.ts b/tests/multitenant-select.test.ts new file mode 100644 index 0000000..e436eb1 --- /dev/null +++ b/tests/multitenant-select.test.ts @@ -0,0 +1,44 @@ +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/prereqs.test.ts b/tests/prereqs.test.ts index 78e4efa..1f0f19b 100644 --- a/tests/prereqs.test.ts +++ b/tests/prereqs.test.ts @@ -1,80 +1,171 @@ import { writeFileSync, 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 { hasNextDependency, runPrereqs } = +const { readProjectPackages, runGitPrereq, assertPrerequisites } = await import('../src/steps/prereqs.js'); -describe('hasNextDependency', () => { - const tmp = useTmpDir(); +function mod( + name: string, + prerequisites?: ModulePrerequisite[], +): ManifestModule { + return { + name, + version: '1.0.0', + required: false, + dependsOn: [], + description: name, + ...(prerequisites ? { prerequisites } : {}), + }; +} - it('returns false when package.json is missing', () => { - expect(hasNextDependency(tmp.path())).toBe(false); - }); +const nextPre: ModulePrerequisite = { + package: 'next', + reason: 'pharn-stack-nextjs targets Next.js. Run: npx create-next-app@latest', +}; - it('returns false when package.json has no next', () => { - writeFileSync( - join(tmp.path(), 'package.json'), - JSON.stringify({ dependencies: { react: '19' } }), - ); - expect(hasNextDependency(tmp.path())).toBe(false); - }); +describe('readProjectPackages', () => { + const tmp = useTmpDir(); - it('returns true for next in dependencies', () => { - writeFileSync( - join(tmp.path(), 'package.json'), - JSON.stringify({ dependencies: { next: '16' } }), - ); - expect(hasNextDependency(tmp.path())).toBe(true); + it('returns an empty set when package.json is missing', () => { + expect(readProjectPackages(tmp.path()).size).toBe(0); }); - it('returns true for next in devDependencies', () => { + it('unions dependencies and devDependencies', () => { writeFileSync( join(tmp.path(), 'package.json'), - JSON.stringify({ devDependencies: { next: '16' } }), + JSON.stringify({ + dependencies: { next: '16' }, + devDependencies: { typescript: '5' }, + }), ); - expect(hasNextDependency(tmp.path())).toBe(true); + const pkgs = readProjectPackages(tmp.path()); + expect(pkgs.has('next')).toBe(true); + expect(pkgs.has('typescript')).toBe(true); }); - it('returns false when package.json is malformed', () => { + it('returns an empty set when package.json is malformed', () => { writeFileSync(join(tmp.path(), 'package.json'), '{ not json'); - expect(hasNextDependency(tmp.path())).toBe(false); + expect(readProjectPackages(tmp.path()).size).toBe(0); }); }); -describe('runPrereqs', () => { +describe('runGitPrereq', () => { const tmp = useTmpDir(); stubProcessExit(); - it('fails if next is missing', () => { + it('fails if .git is missing', () => { const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); - expect(() => runPrereqs()).toThrow(ProcessExit); + expect(() => runGitPrereq()).toThrow(ProcessExit); cwd.mockRestore(); }); - it('fails if .git is missing', () => { + it('passes when .git is present (no package.json required)', () => { + mkdirSync(join(tmp.path(), '.git')); + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); + expect(() => runGitPrereq()).not.toThrow(); + cwd.mockRestore(); + }); +}); + +describe('assertPrerequisites', () => { + const tmp = useTmpDir(); + stubProcessExit(); + + function withPackages(deps: Record): void { writeFileSync( join(tmp.path(), 'package.json'), - JSON.stringify({ dependencies: { next: '16' } }), + JSON.stringify({ dependencies: deps }), ); - const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); - expect(() => runPrereqs()).toThrow(ProcessExit); - cwd.mockRestore(); + } + + it('passes for an empty module set (the no-pack / ungate case)', () => { + expect(() => assertPrerequisites([], tmp.path())).not.toThrow(); }); - it('passes when both next and .git are present', () => { + 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({ dependencies: { next: '16' } }), + JSON.stringify({ devDependencies: { next: '16' } }), ); - mkdirSync(join(tmp.path(), '.git')); - const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmp.path()); - expect(() => runPrereqs()).not.toThrow(); - cwd.mockRestore(); + 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/summary.test.ts b/tests/summary.test.ts index fa2788f..6c22402 100644 --- a/tests/summary.test.ts +++ b/tests/summary.test.ts @@ -16,6 +16,7 @@ const config: WizardConfig = { modules: ['pharn-pipeline'], stackPack: 'pharn-stack-nextjs', constitution: 'standard', + isMultiTenant: true, }; const resolved: ManifestModule[] = [ { @@ -68,13 +69,33 @@ describe('runSummary', () => { installedSkills: [ { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, ], - vendorSkills: ['supabase'], + vendorSkills: [ + { name: 'supabase', source: 'github:acme/supabase' }, + { name: 'stripe', source: null }, + ], }; 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).toContain('VENDOR SKILLS (recorded)'); - expect(note).toContain('supabase'); + expect(note).toMatch(/supabase\s+fetch: auto/); + expect(note).toMatch(/stripe\s+install by hand/); + }); + + 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/vendor-consent.test.ts b/tests/vendor-consent.test.ts index 0290f58..a5715e1 100644 --- a/tests/vendor-consent.test.ts +++ b/tests/vendor-consent.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { CANCEL, ProcessExit, stubProcessExit } from './helpers.js'; +import type { VendorSkill } from '../src/types.js'; vi.mock('@clack/prompts', () => ({ isCancel: (v: unknown) => v === CANCEL, @@ -11,6 +12,11 @@ vi.mock('@clack/prompts', () => ({ const { runVendorConsent } = await import('../src/steps/vendor-consent.js'); const prompts = await import('@clack/prompts'); +const v = (name: string, source: string | null = null): VendorSkill => ({ + name, + source, +}); + describe('runVendorConsent', () => { stubProcessExit(); @@ -21,9 +27,9 @@ describe('runVendorConsent', () => { it('dedupes candidates and returns the consented set', async () => { vi.mocked(prompts.multiselect).mockResolvedValue(['supabase']); - await expect(runVendorConsent(['supabase', 'supabase'])).resolves.toEqual([ - 'supabase', - ]); + await expect( + runVendorConsent([v('supabase', 'github:acme/supabase'), v('supabase')]), + ).resolves.toEqual([v('supabase', 'github:acme/supabase')]); expect(prompts.note).toHaveBeenCalled(); const [[arg]] = vi.mocked(prompts.multiselect).mock.calls as unknown as [ [{ options: { value: string }[] }], @@ -31,9 +37,27 @@ describe('runVendorConsent', () => { expect(arg.options.map((o) => o.value)).toEqual(['supabase']); }); + it('labels unsourced candidates as manual install', async () => { + vi.mocked(prompts.multiselect).mockResolvedValue([]); + await runVendorConsent([ + v('supabase', 'github:acme/supabase'), + v('stripe'), + ]); + const arg = vi.mocked(prompts.multiselect).mock.calls.at(-1)![0] as { + options: { value: string; label: string }[]; + }; + expect(arg.options).toEqual([ + { value: 'supabase', label: 'supabase' }, + { value: 'stripe', label: 'stripe (manual install)' }, + ]); + }); + it('restores prior consent on loop-back, dropping no-longer-offered values', async () => { vi.mocked(prompts.multiselect).mockResolvedValue(['supabase']); - await runVendorConsent(['supabase', 'stripe'], ['supabase', 'gone']); + await runVendorConsent( + [v('supabase'), v('stripe')], + [v('supabase'), v('gone')], + ); const arg = vi.mocked(prompts.multiselect).mock.calls.at(-1)![0] as { initialValues: string[]; }; @@ -42,7 +66,7 @@ describe('runVendorConsent', () => { it('default-checks everything when no initial is given', async () => { vi.mocked(prompts.multiselect).mockResolvedValue([]); - await runVendorConsent(['supabase', 'stripe']); + await runVendorConsent([v('supabase'), v('stripe')]); const arg = vi.mocked(prompts.multiselect).mock.calls.at(-1)![0] as { initialValues: string[]; }; @@ -51,7 +75,7 @@ describe('runVendorConsent', () => { it('exits when cancelled', async () => { vi.mocked(prompts.multiselect).mockResolvedValue(CANCEL); - await expect(runVendorConsent(['supabase'])).rejects.toMatchObject( + await expect(runVendorConsent([v('supabase')])).rejects.toMatchObject( new ProcessExit(0), ); }); diff --git a/tests/vendor-fetch.test.ts b/tests/vendor-fetch.test.ts new file mode 100644 index 0000000..11f41dc --- /dev/null +++ b/tests/vendor-fetch.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { VendorSkill } from '../src/types.js'; + +const clone = vi.fn(); +const degit = vi.fn(() => ({ clone })); +vi.mock('degit', () => ({ default: degit })); + +const { fetchVendorSkills } = await import('../src/lib/vendor-fetch.js'); + +const CLAUDE = '/proj/.claude'; + +describe('fetchVendorSkills', () => { + afterEach(() => vi.clearAllMocks()); + + it('clones a sourced skill into .claude/skills//', async () => { + clone.mockResolvedValue(undefined); + const vendors: VendorSkill[] = [ + { name: 'supabase', source: 'github:acme/supabase' }, + ]; + + const res = await fetchVendorSkills(CLAUDE, vendors); + + expect(degit).toHaveBeenCalledWith('github:acme/supabase', { + force: true, + cache: false, + }); + expect(clone).toHaveBeenCalledWith(`${CLAUDE}/skills/supabase`); + expect(res).toEqual({ fetched: ['supabase'], manual: [], failed: [] }); + }); + + it('falls back to manual for an unsourced skill without calling degit', async () => { + const res = await fetchVendorSkills(CLAUDE, [ + { name: 'stripe', source: null }, + ]); + + expect(degit).not.toHaveBeenCalled(); + expect(res).toEqual({ fetched: [], manual: ['stripe'], failed: [] }); + }); + + it('keeps a clone rejection non-fatal and continues with other skills', async () => { + clone + .mockRejectedValueOnce(new Error('404')) + .mockResolvedValueOnce(undefined); + const vendors: VendorSkill[] = [ + { name: 'supabase', source: 'github:acme/supabase' }, + { name: 'clerk', source: 'github:acme/clerk' }, + ]; + + const res = await fetchVendorSkills(CLAUDE, vendors); + + expect(res.fetched).toEqual(['clerk']); + expect(res.manual).toEqual([]); + expect(res.failed).toEqual([{ name: 'supabase', message: '404' }]); + }); + + it('rejects an invalid source without calling degit', async () => { + const res = await fetchVendorSkills(CLAUDE, [ + { name: 'evil', source: 'github:acme/../../etc' }, + ]); + + expect(degit).not.toHaveBeenCalled(); + expect(res.fetched).toEqual([]); + expect(res.failed).toHaveLength(1); + expect(res.failed[0]!.name).toBe('evil'); + }); +}); diff --git a/tests/wizard-fixture.ts b/tests/wizard-fixture.ts index 396dfd4..ee574c3 100644 --- a/tests/wizard-fixture.ts +++ b/tests/wizard-fixture.ts @@ -19,17 +19,21 @@ export function wizardSpec(): WizardSpec { default: true, install: null, vendorSkill: 'supabase', + source: 'github:supabase/supabase-skills/database', + 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 }, ], @@ -43,11 +47,13 @@ export function wizardSpec(): WizardSpec { 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 }, ], diff --git a/tests/wizard.test.ts b/tests/wizard.test.ts index 7dd5770..f44003c 100644 --- a/tests/wizard.test.ts +++ b/tests/wizard.test.ts @@ -4,13 +4,15 @@ import { applyRulesToQuestion, collectInstalls, collectVendorSkills, + describeAnswers, + detectAnswers, findSkillOption, listSkillAddresses, matchCondition, pendingWarnings, } from '../src/lib/wizard.js'; import { wizardSpec } from './wizard-fixture.js'; -import type { WizardQuestion } from '../src/types.js'; +import type { WizardQuestion, WizardSpec } from '../src/types.js'; const wizard = wizardSpec(); @@ -127,7 +129,10 @@ describe('collectInstalls', () => { describe('collectVendorSkills', () => { it('collects vendorSkill of answered options', () => { expect(collectVendorSkills(wizard, { database: 'supabase' })).toEqual([ - 'supabase', + { + name: 'supabase', + source: 'github:supabase/supabase-skills/database', + }, ]); expect(collectVendorSkills(wizard, { database: 'neon' })).toEqual([]); }); @@ -143,6 +148,108 @@ describe('applyDefaults', () => { 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', () => {