diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3dca425 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check + - run: npm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5934d2a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,23 @@ +name: Publish + +on: + push: + tags: + - 'v*' + +permissions: + id-token: write + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm test + - run: npm publish --provenance --access public --tag next diff --git a/README.md b/README.md index 9ab7851..60fa751 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,121 @@ -# bmad-installer +# bmad-method -Installs and updates BMad Method modules. A thin wrapper around `npx skills`. +## What it does -Published to npm as `bmad-method`. Run it with `npx bmad-method`. +`bmad-method` installs BMad modules into a project as agent skills. It asks which modules you want, runs the Vercel skills CLI (the `skills` npm package, shipped as a dependency) to install them, then reads the install with the `bmad` skill's setup script and prints a report. It copies no files itself, keeps no state of its own, and never removes anything. + +## Install commands + +Needs Node 22 or newer and [uv](https://docs.astral.sh/uv/getting-started/installation/) on your PATH. + +``` +npx bmad-method install into the current directory +npx bmad-method install the same thing +npx bmad-method update update the BMad skills already installed +npx bmad-method status show what is installed and what is still missing +``` + +## Example + +Interactive, in a terminal: + +``` +npx bmad-method +``` + +It asks where to install, which modules and which parts of each module you want. The first skills install then hands over to the skills CLI, which shows its own agent picker if it finds none or several coding agents in the project. Later installs in the same run reuse that pick. The closing message names the directory and tells you to run `bmad setup` in your coding agent. + +Headless, for scripts and CI: + +``` +npx bmad-method install -d ./my-app -m method:planning+build,cis -t claude-code,codex -y +``` + +## Where it can run + +An interactive run needs a real terminal. It refuses to start inside a coding agent such as Claude Code or Codex, because the prompts cannot be answered there. Add `--yes` to run without prompts anywhere. `update` and `status` never prompt. + +## Choosing coding agents + +The installer keeps no list of coding agents. Agent ids and detection come from the skills CLI. + +- Interactive without `--tools`: the skills CLI detects agents and shows its picker when it needs a choice. The pick is reused for the rest of the run. +- `--tools `: every install goes to those agents and the picker never shows. The [skills CLI README](https://github.com/vercel-labs/skills#supported-agents) lists the valid ids. +- `--yes` without `--tools`: the skills CLI chooses from the agents it detects. + +## Where skills land + +The skills CLI decides. With one agent it copies the skills into that agent's folder, for example `.claude/skills`. With several agents it writes them once to `.agents/skills` and links each agent's folder to them. `npx bmad-method status` reads the real locations with `npx skills list`. + +## Flags + +| Flag | What it does | +| --- | --- | +| `-d, --directory ` | Project directory, default the current one | +| `-m, --modules ` | Modules to install, e.g. `method:planning+build,cis` | +| `-t, --tools ` | Comma-separated skills CLI agent ids to install to | +| `-y, --yes` | Take the defaults and ask nothing | +| `--action ` | `install`, `update` or `quick-update`. Kept so scripts written for the 6.12 installer still work | +| `--no-telemetry` | Turn off skills CLI telemetry and the skills.sh install counts | +| `--copy` | Copy skills instead of linking, if symlinks fail on your system | +| `--debug` | Print every child command, its cwd and its output to stderr | +| `-h, --help` | Show the help | +| `-v, --version` | Show the version | + +`--modules` takes module codes. `method` (alias `bmm`) has the bundles `planning`, `build`, `agents` and `extras`; `cis` has none. A module named without `:` takes its default bundles. The core tools module installs on every run. + +A 6.12 flag this installer dropped prints one line about it, then exits without installing. + +## What the closing message tells you + +The closing message names the directory BMad went into and asks you to open your coding agent there and tell it to run `bmad setup`. Setup asks the configuration questions and finishes the install. Configuration and customization belong to the `bmad` skill from then on; this installer never asks those questions. + +## Updating + +Three routes: ask the `bmad` skill, run `npx bmad-method update`, or run `npx skills update`. + +`npx bmad-method update` runs the skills CLI update over the project's skills, then prints the status report and any migrations for the version jump. + +After adding or updating any skill by any route, ask the `bmad` skill to update. Only it reconciles what is installed. + +## Removing + +`npx skills remove` removes skills. This installer has no uninstall command and never removes anything. + +## Installing with the skills CLI directly + +If you install with the skills CLI directly, a module is its `bmod-` record skill plus the skills that record lists. A record without its skills, or skills without their record, is a partial install: the `bmad` skill cannot see or run it. `npx bmad-method status` lists missing records and unmet requirements. + +## Troubleshooting + +- `status` and `update` take 5 to 10 seconds. The `bmad` skill's setup script reads each module's record from GitHub on every call. +- `npx skills update` currently skips the `bmad` skill because BMAD-METHOD ships a second `bmad` SKILL.md in a test fixture. +- `npx skills update` re-adds skills without the agent list, which can change where they land. `npx bmad-method update` has the same limit. +- Symlink errors on Windows: add `--copy`. +- `--debug` prints every child command with its output. + +## Telemetry + +The installer sends nothing. The skills CLI it runs sends two GET requests to `add-skill.vercel.sh`: an audit call to `https://add-skill.vercel.sh/audit` before an install, carrying the `owner/repo` and the skill names, and an install event to `https://add-skill.vercel.sh/t` after it, carrying the source, the skill names, the agent ids and the CLI version. The install event goes out only for a public GitHub repo. The audit call is not gated on repo visibility: it goes out for a private GitHub repo too. A local path sends neither. No file contents leave the machine. The install event carries the metadata this installer attaches: `{"installer":"bmad-method","version":""}`. + +`--no-telemetry` sets `DO_NOT_TRACK=1` in every skills CLI process the installer starts. That also removes those installs from the install counts on skills.sh. + +`DO_NOT_TRACK` or `DISABLE_TELEMETRY` set in your own environment turns telemetry off for every skills command, including the ones you run yourself. + +## Reproducible installs + +The skills CLI writes `skills-lock.json` in the project. Commit it. `npx skills experimental_install` restores the skills recorded there. + +## Testing + +`npm test` runs the unit tests with `node --test`. `npm run check` runs `tsc` over `bin`, `src` and `test`. + +`npm run e2e` runs the end-to-end install. It skips itself unless `BMAD_INSTALLER_E2E_SOURCE` points at a local BMAD-METHOD checkout. The test passes that path to the child run as `BMAD_INSTALLER_SOURCE_OVERRIDE`, which makes module loading replace every module's source with it. Set `BMAD_INSTALLER_E2E_DEBUG` to log every child command. `BMAD_INSTALLER_SOURCE_OVERRIDE` exists for this test; do not set it for a real install. + +## Releasing + +See [tools/release.md](tools/release.md). + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/bin/bmad-method.js b/bin/bmad-method.js new file mode 100644 index 0000000..a9ad59c --- /dev/null +++ b/bin/bmad-method.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { main } from '../src/cli.js' + +process.exitCode = await main(process.argv.slice(2)) diff --git a/messages.yaml b/messages.yaml new file mode 100644 index 0000000..c823e32 --- /dev/null +++ b/messages.yaml @@ -0,0 +1,174 @@ +intro: |- + BMad Installer installs BMad modules as agent skills. + It is free and open source. + Installs run through the skills CLI (npx skills). You need uv installed. + + Docs https://docs.bmad-method.org + GitHub https://github.com/bmad-code-org/BMAD-METHOD + Discord https://discord.gg/gk8jAdXWmj + YouTube https://www.youtube.com/@BMadCode + X https://x.com/BMadCode + + Support: star the repo, https://buymeacoffee.com/bmad + Contact: contact@bmadcode.com + +updateAvailable: |- + You are using version {current} but {latest} is available. + To update, exit and first run: + npm cache clean --force && npx bmad-method@{tag} install + +nodeTooOld: |- + Node {version} is too old. This installer needs Node 22 or newer. + Install Node 22, then run this again. + +nodeBelowSkillsFloor: |- + Node {version} is below the 22.20.0 the skills CLI asks for. + Installs usually still work. Upgrade Node if you hit errors. + +uvMissing: |- + uv is not installed. The BMad scripts need it. + Install it with: + {installCommand} + More ways: https://docs.astral.sh/uv/getting-started/installation/ + +uvInstall: + darwin: "curl -LsSf https://astral.sh/uv/install.sh | sh" + linux: "curl -LsSf https://astral.sh/uv/install.sh | sh" + win32: 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex"' + +wslWindowsNode: |- + This is Windows Node.js started from a WSL shell. + Install Node inside WSL and run this again from the WSL terminal. + +needsTerminal: |- + This installer needs an interactive terminal. + Run it in a terminal, or add --yes to install without prompts. + +insideAgent: |- + This is running inside {agent}. + Run the installer in a terminal, or add --yes to install without prompts. + +directoryPrompt: "Where should BMad be installed?" + +directoryCreateConfirm: "{directory} does not exist. Create it?" + +directoryConfirm: "Install into {directory}?" + +legacyManifest: |- + Found an older BMad install at _bmad/_config/manifest.yaml. + This installer leaves it alone. + +existingInstallPrompt: "BMad is already installed here. What do you want to do?" + +existingInstallModify: "Add or change modules" + +existingInstallUpdate: "Update what is installed" + +foundModules: "Found these modules:" + +installedSuffix: "(installed {version})" + +installedSuffixNoVersion: "(installed)" + +modulesPrompt: "Which modules do you want?" + +bundlesPrompt: "Which parts of {module} do you want?" + +deprecatedModule: "{module} is deprecated. {reason}" + +beforeSkillsPicker: |- + The skills CLI takes over for the first install. + Pick the coding agents to install to. + If it asks for a scope, keep Project. + If it asks for a method, keep Symlink. + Confirm, and this installer picks up again when it is done. + +firstInstallNotInProject: |- + The skills CLI did not install into {directory}. + Nothing was added to this project. + If you picked Global scope, the skills went to the global skills dir. + Run the installer again and keep the Project scope. + +bundleSkillUnknown: "Skipped skills that {module} no longer lists: {skills}" + +installingSpinner: "Installing {what}" + +statusSpinner: "Reading the BMad install" + +nothingToUpdate: "No BMad install here to update. Run npx bmad-method install first." + +nothingInstalled: "There is no BMad install here. Run npx bmad-method install first." + +updateSpinnerDone: "Update finished" + +migrationsAvailable: "Migrations are available for this upgrade:" + +migrationLine: "{title} ({module}, {from} to {to})" + +reportTitle: "BMad install" + +reportCurrent: "Everything the modules ask for is in place." + +reportNotCurrent: "Not finished yet. Next: {next}" + +reportModule: "{name} {version}, {count} skills installed" + +reportAlsoAvailable: " Also in {code}: {skills}" + +reportMissingRecord: " Missing module record. Install it with: {install}" + +reportUnmet: " {skill} needs {requires} {minimum} ({state}). Install it with: {install}" + +reportPendingQuestions: "{count} setup questions are still unanswered." + +reportProblem: "Problem: {message}" + +reportLegacy: "Left over from an older install: {path}" + +reportFailure: "Failed to install {skill}: {error}" + +closing: |- + BMad is installed in: + {directory} + Open your coding agent in this directory. + + Ask it to run: bmad setup + Setup asks the configuration questions and finishes the install. + Configuration and customization are its job from now on. + + Three ways to change this install later: + ask the bmad skill + npx bmad-method update + npx skills update + + npx skills remove removes skills. This installer never removes anything. + + Going direct with the skills CLI: a module is its bmod- record + skill plus the skills it requires. + A partial install is one the bmad skill cannot see or run. + + After adding or updating any skill by any route, + ask the bmad skill to update. Only it reconciles what is installed. + +cancelled: "Cancelled. Anything already installed is still there." + +droppedFlags: + custom-source: "--custom-source is gone. Install those with npx skills add ." + set: "--set is gone. The bmad setup skill asks these questions." + list-options: "--list-options is gone. The bmad setup skill asks these questions." + user-name: "--user-name is gone. The bmad setup skill asks these questions." + communication-language: "--communication-language is gone. The bmad setup skill asks these questions." + document-output-language: "--document-output-language is gone. The bmad setup skill asks these questions." + output-folder: "--output-folder is gone. The bmad setup skill asks these questions." + channel: "--channel is gone. Modules install from each repo's main branch." + all-stable: "--all-stable is gone. Modules install from each repo's main branch." + all-next: "--all-next is gone. Modules install from each repo's main branch." + next: "--next is gone. Modules install from each repo's main branch." + pin: "--pin is gone. Modules install from each repo's main branch." + shims: "--shims is gone. Shims are no longer shipped." + no-shims: "--no-shims is gone. Shims are no longer shipped." + list-tools: "--list-tools is gone. Tool ids are the skills CLI --agent ids; a wrong one lists the valid ones." + +droppedUninstall: |- + This installer does not remove anything. + Remove skills with npx skills remove. diff --git a/modules.yaml b/modules.yaml new file mode 100644 index 0000000..48e11ff --- /dev/null +++ b/modules.yaml @@ -0,0 +1,45 @@ +modules: + - code: core-tools + name: BMad Core Tools + description: Brainstorming, research, review, party mode, customization + source: bmad-code-org/BMAD-METHOD + record: bmod-core-tools + always: true + + - code: method + aliases: [bmm] + name: BMad Method + description: Plan, spec and build software with agile AI agents + source: bmad-code-org/BMAD-METHOD + record: bmod-method + default: true + message: | + BMad Method is installed. + Once setup is done, ask the `bmad` skill what to do next. + bundles: + - code: planning + name: Planning + description: Brief, PRD, UX, architecture, spec + default: true + skills: [bmad-product-brief, bmad-prfaq, bmad-prd, bmad-ux, bmad-architecture, bmad-spec, bmad-project-context] + - code: build + name: Build + description: Epics and stories, sprint planning, build, review, course correction, retrospective + default: true + skills: [bmad-create-epics-and-stories, bmad-sprint-planning, bmad-build, bmad-build-auto, bmad-code-review, bmad-correct-course, bmad-retrospective, bmad-qa-generate-e2e-tests] + - code: agents + name: Agent personas + description: Analyst, architect, developer, PM and UX designer agents + default: true + skills: [bmad-agent-analyst, bmad-agent-architect, bmad-agent-dev, bmad-agent-pm, bmad-agent-ux-designer] + - code: extras + name: Extras + description: Preview ticketing and the walkthrough + default: false + skills: [bmad-preview-ticketing, bmad-walkthrough] + + - code: cis + name: BMad Creative Intelligence Suite + description: Innovation, brainstorming, problem-solving + source: bmad-code-org/bmad-module-creative-intelligence-suite + record: bmod-cis diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2dbaf9d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,303 @@ +{ + "name": "bmad-method", + "version": "6.13.0-next.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bmad-method", + "version": "6.13.0-next.0", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.8.1", + "@vercel/detect-agent": "^1.2.5", + "cross-spawn": "^7.0.6", + "skills": "^1.7.0", + "yaml": "^2.9.1" + }, + "bin": { + "bmad-method": "bin/bmad-method.js" + }, + "devDependencies": { + "@types/cross-spawn": "^6.0.6", + "@types/node": "^22.20.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/detect-agent": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@vercel/detect-agent/-/detect-agent-1.2.5.tgz", + "integrity": "sha512-0krENrjuitlW8s6TJu0MlqCevyCU7K7JK63jZAf7xZ6n17tx+vUEwzHT3sTxawtwZxaW21hu+oFUpoOrm49FsQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/skills": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/skills/-/skills-1.7.0.tgz", + "integrity": "sha512-OfePnDft+Xt9/tCoHdCUe5fkM8i+Q3QOSQO53hm7mKtsXyvc+CKOAAliVWZ484HS3cWx+6r+ob0AArixs3jYXw==", + "license": "MIT", + "dependencies": { + "tar": "^7.5.20", + "yaml": "^2.8.3" + }, + "bin": { + "add-skill": "bin/cli.mjs", + "skills": "bin/cli.mjs" + }, + "engines": { + "node": ">=22.20.0" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6be3998 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "bmad-method", + "version": "6.13.0-next.0", + "description": "Installs and updates BMad Method modules. A thin wrapper around the skills CLI.", + "license": "MIT", + "author": "Brian (BMad) Madison", + "repository": { + "type": "git", + "url": "git+https://github.com/bmad-code-org/bmad-installer.git" + }, + "type": "module", + "bin": { + "bmad-method": "bin/bmad-method.js" + }, + "files": [ + "bin", + "src", + "modules.yaml", + "messages.yaml", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "test": "node --test \"test/**/*.test.js\"", + "check": "tsc", + "e2e": "node --test \"test/e2e/*.test.js\"" + }, + "dependencies": { + "@clack/prompts": "^1.8.1", + "cross-spawn": "^7.0.6", + "yaml": "^2.9.1", + "@vercel/detect-agent": "^1.2.5", + "skills": "^1.7.0" + }, + "devDependencies": { + "typescript": "^5.9.0", + "@types/node": "^22.20.0", + "@types/cross-spawn": "^6.0.6" + } +} diff --git a/src/bmad-scripts.js b/src/bmad-scripts.js new file mode 100644 index 0000000..b876d97 --- /dev/null +++ b/src/bmad-scripts.js @@ -0,0 +1,165 @@ +import { dirname, join } from 'node:path' + +/** @typedef {import('./run.js').Runner} Runner */ + +/** @typedef {{ state: 'current' | 'newer-available' | 'ahead' | 'differing-unordered', source: string, source_version: string }} ModuleUpdateChecked */ +/** @typedef {{ state: 'could-not-check', source: string, reason: string }} ModuleUpdateUnchecked */ +/** @typedef {{ state: 'plugin-managed', plugin: string, instruction: string }} ModuleUpdatePluginManaged */ +/** @typedef {ModuleUpdateChecked | ModuleUpdateUnchecked | ModuleUpdatePluginManaged} ModuleUpdate */ + +/** @typedef {{ module: string, folder: string, version: string, update_source: string, skills: string[], absent_skills: string[], scripts: string, update: ModuleUpdate }} ModuleStatus */ +/** @typedef {{ skill: string, bmod: string, source: string, channel: string, install: string | null }} MissingRecord */ +/** @typedef {{ skill: string, module: string | null, requires: string, minimum: string, installed: string, state: 'missing' | 'outdated' | 'unknown-version' | 'unorderable', source: string, channel: string, install: string | null }} UnmetEntry */ +/** @typedef {{ module: string, key: string, prompt: string, default: unknown, scope: string }} PendingQuestion */ +/** @typedef {{ kind: string, message: string } & Record} Problem */ +/** @typedef {{ module: string, path: string, file: string, from: string, to: string, title: string }} Migration */ + +/** + * @typedef {{ + * mode: string, + * module: string | null, + * bmad_exists: boolean, + * bmad: { skill: string, version: string | null, module: string | null }, + * shared_scripts: string, + * custom_gitignore: string, + * modules: ModuleStatus[], + * missing_module_records: MissingRecord[], + * pending_questions: PendingQuestion[], + * unmet_requirements: UnmetEntry[], + * unmet_recommendations: UnmetEntry[], + * problems: Problem[], + * legacy_leftovers: string[], + * current: boolean, + * next: string | null + * }} StatusReport + */ + +export class BmadScriptsError extends Error { + /** + * @param {string} message + * @param {string} [stderr] + */ + constructor(message, stderr = '') { + super(message) + this.name = 'BmadScriptsError' + this.stderr = stderr + } +} + +/** + * @param {string} bmadSkillDir + * @param {string} projectRoot + * @returns {string[]} + */ +export function setupStatusArgv(bmadSkillDir, projectRoot) { + return [ + 'uv', + 'run', + '--no-cache', + join(bmadSkillDir, 'scripts', 'setup.py'), + '--project-root', + projectRoot, + '--skill', + bmadSkillDir, + '--status', + ] +} + +/** + * @param {string} bmadSkillDir + * @returns {string[]} + */ +export function knowledgeArgv(bmadSkillDir) { + return [ + 'uv', + 'run', + '--no-cache', + join(bmadSkillDir, 'scripts', 'knowledge.py'), + '--root', + dirname(bmadSkillDir), + ] +} + +/** + * @param {string} stdout + * @returns {StatusReport} + */ +export function parseStatus(stdout) { + const parsed = parseJson(stdout, 'setup.py --status') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new BmadScriptsError('setup.py --status did not return an object') + } + return /** @type {StatusReport} */ (parsed) +} + +/** + * @param {string} stdout + * @returns {Migration[]} + */ +export function parseMigrations(stdout) { + const parsed = parseJson(stdout, 'knowledge.py') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new BmadScriptsError('knowledge.py did not return an object') + } + const migrations = /** @type {{ migrations?: unknown }} */ (parsed).migrations + return Array.isArray(migrations) ? /** @type {Migration[]} */ (migrations) : [] +} + +/** + * @param {{ runner: Runner }} deps + */ +export function createBmadScripts(deps) { + const { runner } = deps + + return { + /** + * @param {string} bmadSkillDir + * @param {string} projectRoot + * @returns {Promise} + */ + async status(bmadSkillDir, projectRoot) { + const result = await runner({ argv: setupStatusArgv(bmadSkillDir, projectRoot) }) + const stderr = result.error ? `${result.stderr}${result.error}` : result.stderr + if (result.code !== 0) { + throw new BmadScriptsError(`setup.py --status exited with ${result.code}`, stderr) + } + try { + return parseStatus(result.stdout) + } catch (err) { + throw new BmadScriptsError(errorMessage(err), stderr) + } + }, + + /** + * @param {string} bmadSkillDir + * @returns {Promise} + */ + async migrations(bmadSkillDir) { + const result = await runner({ argv: knowledgeArgv(bmadSkillDir) }) + if (result.code !== 0) return [] + try { + return parseMigrations(result.stdout) + } catch { + return [] + } + }, + } +} + +/** + * @param {string} stdout + * @param {string} what + * @returns {unknown} + */ +function parseJson(stdout, what) { + try { + return JSON.parse(stdout) + } catch { + throw new BmadScriptsError(`${what} did not return JSON`) + } +} + +/** @param {unknown} err */ +function errorMessage(err) { + return err instanceof Error ? err.message : String(err) +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..c605557 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,233 @@ +import { parseArgs } from 'node:util' +import { createBmadScripts } from './bmad-scripts.js' +import { install } from './commands/install.js' +import { status } from './commands/status.js' +import { update } from './commands/update.js' +import { MessagesError, loadMessages, message } from './messages.js' +import { ModulesError, loadModules } from './modules.js' +import { PreflightError } from './preflight.js' +import * as prompts from './prompts.js' +import { createRunner } from './run.js' +import { createSkillsCli } from './skills-cli.js' +import { version } from './version.js' + +/** @typedef {{ command: 'install' | 'update' | 'status', directory?: string, modules?: string, tools?: string, yes: boolean, telemetry: boolean, copy: boolean, debug: boolean }} CliOptions */ +/** @typedef {{ stdout: NodeJS.WriteStream, stderr: NodeJS.WriteStream, env: NodeJS.ProcessEnv, cwd: string }} Io */ + +const OPTIONS = /** @type {const} */ ({ + directory: { type: 'string', short: 'd' }, + modules: { type: 'string', short: 'm' }, + tools: { type: 'string', short: 't' }, + yes: { type: 'boolean', short: 'y' }, + action: { type: 'string' }, + copy: { type: 'boolean' }, + debug: { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, +}) + +const DROPPED_FLAGS = new Set([ + 'custom-source', 'set', 'list-options', 'user-name', 'communication-language', + 'document-output-language', 'output-folder', 'channel', 'all-stable', 'all-next', + 'next', 'pin', 'shims', 'no-shims', 'list-tools', +]) +const KNOWN_FLAGS = new Set([...Object.keys(OPTIONS), 'no-telemetry']) +const COMMANDS = new Set(['install', 'update', 'status']) +/** @type {Record} */ +const ACTIONS = { install: 'install', update: 'update', 'quick-update': 'update' } +const DROPPED_COMMAND = 'uninstall' +const INSTALLER_NAME = 'bmad-method' +const HELP_HINT = 'Run bmad-method --help for the list of flags.' + +const HELP = `bmad-method [install|update|status] [flags] + +Commands + install Install BMad modules into a project (the default) + update Update the BMad skills already installed + status Show what is installed and what is still missing + +Flags + -d, --directory Project directory, default the current one + -m, --modules Modules to install, e.g. method:planning+build,cis + -t, --tools Comma-separated skills CLI agent ids to install to + -y, --yes Take the defaults and ask nothing + --action install, update or quick-update, for 6.12 scripts + --no-telemetry Turn off skills CLI telemetry and the skills.sh install counts + --copy Copy skills instead of linking, if symlinks fail on your system + --debug Print every child command, its cwd and its output to stderr + -h, --help Show this help + -v, --version Show the version` + +export class UsageError extends Error { + name = 'UsageError' +} + +/** @returns {string} */ +export function helpText() { + return HELP +} + +/** + * @param {Record} values + * @param {string} name + * @returns {string | undefined} + */ +function textValue(values, name) { + const value = values[name] + if (value === undefined) return undefined + if (typeof value !== 'string') throw new UsageError(`--${name} needs a value`) + return value +} + +/** + * Non-strict parsing stores `--copy=true` as the string "true", so presence among the tokens, + * not the parsed value, decides a boolean flag. + * @param {{ name: string }[]} flags + * @param {string} name + * @returns {boolean} + */ +function flagGiven(flags, name) { + return flags.some((flag) => flag.name === name) +} + +/** + * @param {Record} values + * @param {string[]} positionals + * @returns {'install' | 'update' | 'status'} + */ +function readCommand(values, positionals) { + const [first, ...rest] = positionals + if (rest.length > 0) throw new UsageError(`unexpected argument ${rest[0]}`) + if (first !== undefined && !COMMANDS.has(first)) throw new UsageError(`unknown command ${first}`) + const action = textValue(values, 'action') + if (action === undefined) return /** @type {'install' | 'update' | 'status'} */ (first ?? 'install') + const mapped = ACTIONS[action] + if (!mapped) throw new UsageError(`unknown --action ${action}`) + return mapped +} + +/** + * Dropped flags are read from the tokens before anything else, so a 6.12 command line + * explains itself instead of failing on a stray positional. + * @param {string[]} argv + * @param {Record} messages + * @returns {{ kind: 'run', options: CliOptions } | { kind: 'help' } | { kind: 'version' } | { kind: 'dropped', text: string }} + */ +export function parseCli(argv, messages) { + const parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: false, tokens: true }) + const values = /** @type {Record} */ (parsed.values) + const flags = (parsed.tokens ?? []).flatMap((token) => (token.kind === 'option' ? [token] : [])) + + const dropped = flags.filter((flag) => DROPPED_FLAGS.has(flag.name)) + if (dropped.length > 0) { + return { kind: 'dropped', text: dropped.map((flag) => message(messages, `droppedFlags.${flag.name}`)).join('\n') } + } + if (parsed.positionals.includes(DROPPED_COMMAND)) { + return { kind: 'dropped', text: message(messages, 'droppedUninstall') } + } + + const unknown = flags.find((flag) => !KNOWN_FLAGS.has(flag.name)) + if (unknown) throw new UsageError(`unknown flag ${unknown.rawName}`) + + if (flagGiven(flags, 'help')) return { kind: 'help' } + if (flagGiven(flags, 'version')) return { kind: 'version' } + + return { + kind: 'run', + options: { + command: readCommand(values, parsed.positionals), + directory: textValue(values, 'directory'), + modules: textValue(values, 'modules'), + tools: textValue(values, 'tools'), + yes: flagGiven(flags, 'yes'), + telemetry: !flagGiven(flags, 'no-telemetry'), + copy: flagGiven(flags, 'copy'), + debug: flagGiven(flags, 'debug'), + }, + } +} + +/** + * @param {unknown} err + * @param {Io} io + * @param {{ debug: boolean, messages: Record | null }} context + * @returns {number} + */ +function reportError(err, io, context) { + if (err instanceof prompts.Cancelled && context.messages) { + prompts.fail(message(context.messages, 'cancelled')) + return 1 + } + if (err instanceof PreflightError) { + prompts.fail(err.message) + return 1 + } + if (err instanceof UsageError) { + io.stderr.write(`${err.message}\n${HELP_HINT}\n`) + return 2 + } + if (err instanceof MessagesError || err instanceof ModulesError) { + io.stderr.write(`${err.message}\n`) + return 1 + } + const detail = err instanceof Error ? ((context.debug && err.stack) || err.message) : String(err) + io.stderr.write(`${detail}\n`) + return 1 +} + +/** + * @param {string[]} argv + * @param {Io} [io] + * @returns {Promise} + */ +export async function main(argv, io) { + const out = io ?? { stdout: process.stdout, stderr: process.stderr, env: process.env, cwd: process.cwd() } + /** @type {Record | null} */ + let messages = null + let debug = false + + try { + messages = await loadMessages() + const parsed = parseCli(argv, messages) + if (parsed.kind === 'help') { + out.stdout.write(`${helpText()}\n`) + return 0 + } + if (parsed.kind === 'version') { + out.stdout.write(`${version}\n`) + return 0 + } + if (parsed.kind === 'dropped') { + out.stderr.write(`${parsed.text}\n`) + return 2 + } + + const options = parsed.options + debug = options.debug + const modules = await loadModules() + const runner = createRunner({ debug, log: (line) => out.stderr.write(`${line}\n`) }) + const deps = { + runner, + skillsCli: createSkillsCli({ + runner, + env: out.env, + telemetry: options.telemetry, + copy: options.copy, + metadata: { installer: INSTALLER_NAME, version }, + }), + bmadScripts: createBmadScripts({ runner }), + messages, + modules, + prompts, + env: out.env, + cwd: out.cwd, + version, + } + + if (options.command === 'update') return await update(options, deps) + if (options.command === 'status') return await status(options, deps) + return await install(options, deps) + } catch (err) { + return reportError(err, out, { debug, messages }) + } +} diff --git a/src/commands/install-plan.js b/src/commands/install-plan.js new file mode 100644 index 0000000..3a5801b --- /dev/null +++ b/src/commands/install-plan.js @@ -0,0 +1,135 @@ +import { bundleCodes, findModule, groupBySource, skillsToInstall } from '../modules.js' +import { versionMajor } from '../report.js' + +/** @typedef {import('../bmad-scripts.js').Migration} Migration */ +/** @typedef {import('../bmad-scripts.js').StatusReport} StatusReport */ +/** @typedef {import('../modules.js').ModuleChoice} ModuleChoice */ +/** @typedef {import('../modules.js').ModuleDefinition} ModuleDefinition */ +/** @typedef {{ source: string, skills: string[], label: string }} InstallCall */ +/** @typedef {{ module: string, skills: string[] }} UnknownSkills */ + +/** + * @param {string} source + * @param {ModuleDefinition[]} modules + * @returns {string} + */ +function callLabel(source, modules) { + return modules.length === 1 ? modules[0].name : source +} + +/** + * Puts the picked modules in modules.yaml order and adds the `always` ones, which neither + * the flag nor the prompt ever contributes. + * @param {ModuleChoice[]} choices + * @param {ModuleDefinition[]} modules + * @returns {ModuleChoice[]} + */ +export function orderedChoices(choices, modules) { + const picked = new Map(choices.map((choice) => [choice.code, choice])) + /** @type {ModuleChoice[]} */ + const ordered = [] + for (const module of modules) { + const choice = picked.get(module.code) + if (choice) ordered.push(choice) + else if (module.always) { + ordered.push({ + code: module.code, + bundles: module.bundles ? bundleCodes(module, (bundle) => bundle.default === true) : null, + }) + } + } + return ordered +} + +/** + * @param {ModuleChoice[]} choices + * @param {ModuleDefinition[]} modules + * @returns {ModuleDefinition[]} + */ +export function chosenModules(choices, modules) { + return choices.flatMap((choice) => { + const module = findModule(modules, choice.code) + return module ? [module] : [] + }) +} + +/** + * Records already in the project are left alone; `update` is what refreshes them. + * @param {ModuleDefinition[]} chosen + * @param {string} firstSkill + * @param {string[]} installed + * @returns {InstallCall[]} + */ +export function recordCalls(chosen, firstSkill, installed = []) { + /** @type {InstallCall[]} */ + const calls = [] + let first = true + for (const [source, modules] of groupBySource(chosen)) { + const wanted = modules.map((module) => module.record) + if (first) wanted.unshift(firstSkill) + first = false + const skills = wanted.filter((skill) => !installed.includes(skill)) + if (skills.length > 0) calls.push({ source, skills, label: callLabel(source, modules) }) + } + return calls +} + +/** + * @param {ModuleChoice[]} choices + * @param {ModuleDefinition[]} modules + * @param {StatusReport} report + * @returns {{ calls: InstallCall[], unknown: UnknownSkills[] }} + */ +export function memberCalls(choices, modules, report) { + const entries = new Map(report.modules.map((entry) => [entry.module, entry])) + /** @type {Map} */ + const bySource = new Map() + /** @type {UnknownSkills[]} */ + const unknown = [] + + for (const choice of choices) { + const module = findModule(modules, choice.code) + const entry = module && entries.get(module.code) + if (!module || !entry) continue + const wanted = skillsToInstall(module, choice.bundles, [...entry.skills, ...entry.absent_skills]) + if (wanted.unknown.length > 0) unknown.push({ module: module.name, skills: wanted.unknown }) + const missing = wanted.install.filter((skill) => entry.absent_skills.includes(skill)) + if (missing.length === 0) continue + const group = bySource.get(module.source) ?? { skills: [], modules: [] } + for (const skill of missing) if (!group.skills.includes(skill)) group.skills.push(skill) + group.modules.push(module) + bySource.set(module.source, group) + } + + const calls = [...bySource].map(([source, group]) => ({ + source, + skills: group.skills, + label: callLabel(source, group.modules), + })) + return { calls, unknown } +} + +/** + * @param {StatusReport | null} report + * @returns {Record} + */ +export function versionsByCode(report) { + /** @type {Record} */ + const versions = {} + for (const entry of report?.modules ?? []) versions[entry.module] = entry.version + return versions +} + +/** + * @param {Migration[]} migrations + * @param {{ codes: string[], before: Record, after: Record }} options + * @returns {Migration[]} + */ +export function selectMigrations(migrations, options) { + return migrations.filter((migration) => { + if (!options.codes.includes(migration.module)) return false + const from = versionMajor(options.before[migration.module] ?? '') + const to = versionMajor(options.after[migration.module] ?? '') + return from !== null && from === migration.from && to !== null && to === migration.to + }) +} diff --git a/src/commands/install-steps.js b/src/commands/install-steps.js new file mode 100644 index 0000000..8998049 --- /dev/null +++ b/src/commands/install-steps.js @@ -0,0 +1,201 @@ +import { mkdir, stat } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pickedAgents, skillsLockPath, snapshotLock } from '../harness.js' +import { hasSkills, inspectInstall } from '../installed.js' +import { message } from '../messages.js' +import { defaultChoices, findModule, parseModulesFlag } from '../modules.js' +import { Cancelled } from '../prompts.js' +import { distTag, newerVersion } from '../self-update.js' +import { versionsByCode } from './install-plan.js' +import { statusWithSpinner } from './status.js' + +/** @typedef {import('../bmad-scripts.js').StatusReport} StatusReport */ +/** @typedef {import('../cli.js').CliOptions} CliOptions */ +/** @typedef {import('../installed.js').InstalledState} InstalledState */ +/** @typedef {import('../modules.js').ModuleChoice} ModuleChoice */ +/** @typedef {import('../skills-cli.js').AddResult} AddResult */ +/** @typedef {import('./install.js').CommandDeps} CommandDeps */ +/** @typedef {import('./install-plan.js').InstallCall} InstallCall */ +/** @typedef {{ state: InstalledState, report: StatusReport | null, codes: string[], versions: Record }} Existing */ + +/** @param {string} path */ +async function isDirectory(path) { + try { + return (await stat(path)).isDirectory() + } catch { + return false + } +} + +/** + * @param {string | undefined} value + * @returns {string[] | null} + */ +export function parseTools(value) { + if (!value) return null + const ids = value.split(',').map((id) => id.trim()).filter(Boolean) + return ids.length > 0 ? ids : null +} + +/** + * @param {CommandDeps} deps + * @returns {Promise} + */ +export async function announceUpdate(deps) { + const latest = await newerVersion({ runner: deps.runner, version: deps.version }) + if (!latest) return + deps.prompts.showNote( + message(deps.messages, 'updateAvailable', { current: deps.version, latest, tag: distTag(deps.version) }), + ) +} + +/** + * @param {CliOptions} options + * @param {CommandDeps} deps + * @param {boolean} interactive + * @returns {Promise} + */ +export async function resolveDirectory(options, deps, interactive) { + const { cwd, messages, prompts } = deps + const asked = options.directory ?? (interactive ? await prompts.askDirectory(messages, cwd) : cwd) + const directory = resolve(cwd, asked) + + if (await isDirectory(directory)) { + if (interactive && !(await prompts.confirm(message(messages, 'directoryConfirm', { directory })))) { + throw new Cancelled() + } + return directory + } + if (interactive && !(await prompts.confirm(message(messages, 'directoryCreateConfirm', { directory })))) { + throw new Cancelled() + } + await mkdir(directory, { recursive: true }) + return directory +} + +/** + * @param {CommandDeps} deps + * @param {string[]} codes + * @param {Record} versions + * @returns {string} + */ +function foundModulesText(deps, codes, versions) { + const { messages, modules } = deps + const lines = [message(messages, 'foundModules')] + for (const code of codes) { + const version = versions[code] + const suffix = version + ? message(messages, 'installedSuffix', { version }) + : message(messages, 'installedSuffixNoVersion') + lines.push(`${findModule(modules, code)?.name ?? code} ${suffix}`) + } + return lines.join('\n') +} + +/** + * @param {string} directory + * @param {CommandDeps} deps + * @returns {Promise} + */ +export async function readExisting(directory, deps) { + const { messages, prompts, skillsCli } = deps + const state = await inspectInstall(directory, skillsCli) + /** @type {StatusReport | null} */ + let report = null + if (state.bmadSkillDir) { + try { + report = await statusWithSpinner(deps, state.bmadSkillDir, directory) + } catch { + report = null + } + } + const codes = report ? report.modules.map((entry) => entry.module) : state.moduleCodes + const versions = versionsByCode(report) + + if (state.legacyManifest) prompts.say(message(messages, 'legacyManifest')) + if (codes.length > 0) prompts.say(foundModulesText(deps, codes, versions)) + return { state, report, codes, versions } +} + +/** + * @param {CliOptions} options + * @param {CommandDeps} deps + * @param {Existing} existing + * @returns {Promise} + */ +export async function pickChoices(options, deps, existing) { + const { messages, modules, prompts } = deps + if (options.modules) return parseModulesFlag(options.modules, modules) + if (options.yes) return defaultChoices(modules) + + const installedSkills = existing.state.skills.map((skill) => skill.name) + const codes = await prompts.chooseModules(messages, modules, { + installedCodes: existing.codes, + installedVersions: existing.versions, + }) + /** @type {ModuleChoice[]} */ + const choices = [] + for (const code of codes) { + const module = findModule(modules, code) + if (!module) continue + const bundles = module.bundles ? await prompts.chooseBundles(messages, module, { installedSkills }) : null + choices.push({ code: module.code, bundles }) + } + return choices +} + +/** + * @param {InstallCall} call + * @param {string[] | null} agents + * @param {string} directory + * @param {CommandDeps} deps + * @returns {Promise} + */ +export async function headlessAdd(call, agents, directory, deps) { + const spinner = deps.prompts.startSpinner(message(deps.messages, 'installingSpinner', { what: call.label })) + try { + const results = await deps.skillsCli.add({ source: call.source, skills: call.skills, agents, cwd: directory }) + return results.filter((entry) => entry.status !== 'installed') + } finally { + spinner.stop() + } +} + +/** + * Runs the add calls of one install. When `pick` is set, the first call hands the terminal to the + * skills CLI so its own agent picker runs; no spinner runs across it, and the picked agent ids come + * back through the CLI's lock file for every call after it. + * @param {{ directory: string, deps: CommandDeps, agents: string[] | null, pick: boolean }} context + */ +export function createAdds(context) { + const { directory, deps } = context + const { env, messages, prompts, skillsCli } = deps + let agents = context.agents + let pickPending = context.pick + + /** @param {InstallCall} call @returns {Promise} */ + async function addInteractively(call) { + prompts.showNote(message(messages, 'beforeSkillsPicker')) + const lockPath = skillsLockPath(env) + const before = await snapshotLock(lockPath) + await skillsCli.addInteractive({ source: call.source, skills: call.skills, cwd: directory }) + agents = await pickedAgents(lockPath, before) + const landed = hasSkills(await inspectInstall(directory, skillsCli), call.skills) + if (!landed) prompts.fail(message(messages, 'firstInstallNotInProject', { directory })) + return landed + } + + return { + /** @returns {string[] | null} */ + agents: () => agents, + /** + * @param {InstallCall} call + * @returns {Promise<{ landed: boolean, failures: AddResult[] }>} + */ + async run(call) { + if (!pickPending) return { landed: true, failures: await headlessAdd(call, agents, directory, deps) } + pickPending = false + return { landed: await addInteractively(call), failures: [] } + }, + } +} diff --git a/src/commands/install.js b/src/commands/install.js new file mode 100644 index 0000000..8aac2dc --- /dev/null +++ b/src/commands/install.js @@ -0,0 +1,128 @@ +import { BMAD_SKILL, inspectInstall } from '../installed.js' +import { message } from '../messages.js' +import { runPreflight } from '../preflight.js' +import { renderClosing, renderFailures, renderMigrations, renderModuleMessages, renderStatus } from '../report.js' +import { + chosenModules, + memberCalls, + orderedChoices, + recordCalls, + selectMigrations, + versionsByCode, +} from './install-plan.js' +import { + announceUpdate, + createAdds, + parseTools, + pickChoices, + readExisting, + resolveDirectory, +} from './install-steps.js' +import { statusWithSpinner } from './status.js' +import { update } from './update.js' + +/** @typedef {import('../cli.js').CliOptions} CliOptions */ +/** @typedef {import('../modules.js').ModuleDefinition} ModuleDefinition */ +/** @typedef {import('../run.js').Runner} Runner */ +/** @typedef {import('../skills-cli.js').AddResult} AddResult */ + +/** + * `terminal` and `agent` stand in for the live TTY and coding-agent checks in tests. + * @typedef {{ + * runner: Runner, + * skillsCli: ReturnType, + * bmadScripts: ReturnType, + * messages: Record, + * modules: ModuleDefinition[], + * prompts: typeof import('../prompts.js'), + * env: NodeJS.ProcessEnv, + * cwd: string, + * version: string, + * terminal?: () => boolean, + * agent?: () => Promise + * }} CommandDeps + */ + +const TITLE = 'BMad Installer' +const SETUP_NEXT = 'bmad setup' + +/** + * @param {CliOptions} options + * @param {CommandDeps} deps + * @returns {Promise} + */ +export async function install(options, deps) { + const { bmadScripts, messages, modules, prompts, runner } = deps + const interactive = !options.yes + + prompts.showIntro(messages, TITLE) + await announceUpdate(deps) + await runPreflight({ + interactive, + runner, + messages, + warn: prompts.warn, + terminal: deps.terminal, + agent: deps.agent, + }) + + const directory = await resolveDirectory(options, deps, interactive) + const existing = await readExisting(directory, deps) + if (existing.codes.length > 0 && interactive && (await prompts.chooseExistingAction(messages)) === 'update') { + return update({ ...options, directory }, deps) + } + + const choices = orderedChoices(await pickChoices(options, deps, existing), modules) + const chosen = chosenModules(choices, modules) + for (const module of chosen) { + if (module.deprecated) { + prompts.warn(message(messages, 'deprecatedModule', { module: module.name, reason: module.deprecated })) + } + } + + /** @type {AddResult[]} */ + const failures = [] + const adds = createAdds({ directory, deps, agents: parseTools(options.tools), pick: interactive && !options.tools }) + const installedSkills = existing.state.skills.map((skill) => skill.name) + for (const call of recordCalls(chosen, BMAD_SKILL, installedSkills)) { + const result = await adds.run(call) + if (!result.landed) return 1 + failures.push(...result.failures) + } + + const { bmadSkillDir } = await inspectInstall(directory, deps.skillsCli) + if (!bmadSkillDir) { + prompts.fail(failures.length > 0 + ? renderFailures(failures, messages) + : message(messages, 'firstInstallNotInProject', { directory })) + return 1 + } + + const afterRecords = await statusWithSpinner(deps, bmadSkillDir, directory) + const members = memberCalls(choices, modules, afterRecords) + for (const entry of members.unknown) { + prompts.warn(message(messages, 'bundleSkillUnknown', { module: entry.module, skills: entry.skills.join(', ') })) + } + for (const call of members.calls) { + const result = await adds.run(call) + if (!result.landed) return 1 + failures.push(...result.failures) + } + + const report = await statusWithSpinner(deps, bmadSkillDir, directory) + const migrations = selectMigrations(await bmadScripts.migrations(bmadSkillDir), { + codes: chosen.map((module) => module.code), + before: existing.versions, + after: versionsByCode(report), + }) + const migrationText = renderMigrations(migrations, messages) + if (migrationText) prompts.showNote(migrationText) + + prompts.showNote(renderStatus(report, { failures, messages, modules })) + const moduleText = renderModuleMessages(chosen) + if (moduleText) prompts.showNote(moduleText) + prompts.showNote(renderClosing(messages, { directory })) + prompts.finish('') + + return failures.length === 0 && (report.current || report.next === SETUP_NEXT) ? 0 : 1 +} diff --git a/src/commands/status.js b/src/commands/status.js new file mode 100644 index 0000000..13e9988 --- /dev/null +++ b/src/commands/status.js @@ -0,0 +1,55 @@ +import { resolve } from 'node:path' +import { inspectInstall } from '../installed.js' +import { message } from '../messages.js' +import { PreflightError, uvInstallCommand, uvVersion } from '../preflight.js' +import { renderStatus } from '../report.js' + +/** @typedef {import('../bmad-scripts.js').StatusReport} StatusReport */ +/** @typedef {import('./install.js').CommandDeps} CommandDeps */ +/** @typedef {import('../cli.js').CliOptions} CliOptions */ + +/** + * @param {CommandDeps} deps + * @param {string} bmadSkillDir + * @param {string} projectRoot + * @returns {Promise} + */ +export async function statusWithSpinner(deps, bmadSkillDir, projectRoot) { + const spinner = deps.prompts.startSpinner(message(deps.messages, 'statusSpinner')) + try { + return await deps.bmadScripts.status(bmadSkillDir, projectRoot) + } finally { + spinner.stop() + } +} + +/** + * @param {CommandDeps} deps + * @returns {Promise} + */ +async function requireUv(deps) { + if (await uvVersion(deps.runner) !== null) return + const installCommand = uvInstallCommand(deps.messages) + throw new PreflightError(message(deps.messages, 'uvMissing', { installCommand })) +} + +/** + * @param {CliOptions} options + * @param {CommandDeps} deps + * @returns {Promise} + */ +export async function status(options, deps) { + const { messages, modules, prompts, skillsCli, cwd } = deps + await requireUv(deps) + + const directory = resolve(cwd, options.directory ?? cwd) + const state = await inspectInstall(directory, skillsCli) + if (!state.bmadSkillDir) { + prompts.fail(message(messages, 'nothingInstalled')) + return 1 + } + + const report = await statusWithSpinner(deps, state.bmadSkillDir, directory) + prompts.showNote(renderStatus(report, { failures: [], messages, modules })) + return report.current ? 0 : 1 +} diff --git a/src/commands/update.js b/src/commands/update.js new file mode 100644 index 0000000..dcf06cc --- /dev/null +++ b/src/commands/update.js @@ -0,0 +1,67 @@ +import { resolve } from 'node:path' +import { inspectInstall } from '../installed.js' +import { message } from '../messages.js' +import { runPreflight } from '../preflight.js' +import { renderClosing, renderMigrations, renderModuleMessages, renderStatus } from '../report.js' +import { selectMigrations, versionsByCode } from './install-plan.js' +import { statusWithSpinner } from './status.js' + +/** @typedef {import('../bmad-scripts.js').StatusReport} StatusReport */ +/** @typedef {import('../cli.js').CliOptions} CliOptions */ +/** @typedef {import('./install.js').CommandDeps} CommandDeps */ + +/** + * @param {CommandDeps} deps + * @param {string} bmadSkillDir + * @param {string} projectRoot + * @returns {Promise} + */ +async function optionalStatus(deps, bmadSkillDir, projectRoot) { + try { + return await statusWithSpinner(deps, bmadSkillDir, projectRoot) + } catch { + return null + } +} + +/** + * @param {CliOptions} options + * @param {CommandDeps} deps + * @returns {Promise} + */ +export async function update(options, deps) { + const { bmadScripts, messages, modules, prompts, runner, skillsCli, cwd } = deps + await runPreflight({ interactive: false, runner, messages, warn: prompts.warn }) + + const directory = resolve(cwd, options.directory ?? cwd) + const before = await inspectInstall(directory, skillsCli) + if (!before.bmadSkillDir) { + prompts.fail(message(messages, 'nothingToUpdate')) + return 1 + } + + const wasInstalled = await optionalStatus(deps, before.bmadSkillDir, directory) + const result = await skillsCli.update({ cwd: directory }) + prompts.say(message(messages, 'updateSpinnerDone')) + + const after = await inspectInstall(directory, skillsCli) + const bmadSkillDir = after.bmadSkillDir ?? before.bmadSkillDir + const report = await statusWithSpinner(deps, bmadSkillDir, directory) + const codes = report.modules.map((entry) => entry.module) + + const migrations = selectMigrations(await bmadScripts.migrations(bmadSkillDir), { + codes, + before: versionsByCode(wasInstalled), + after: versionsByCode(report), + }) + const migrationText = renderMigrations(migrations, messages) + if (migrationText) prompts.showNote(migrationText) + + prompts.showNote(renderStatus(report, { failures: [], messages, modules })) + const moduleText = renderModuleMessages(modules.filter((module) => codes.includes(module.code))) + if (moduleText) prompts.showNote(moduleText) + prompts.showNote(renderClosing(messages, { directory })) + prompts.finish('') + + return result.code === 0 ? 0 : 1 +} diff --git a/src/harness.js b/src/harness.js new file mode 100644 index 0000000..9898f25 --- /dev/null +++ b/src/harness.js @@ -0,0 +1,63 @@ +import { readFile, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** @typedef {{ exists: boolean, mtimeMs: number | null }} LockSnapshot */ + +const LOCK_FILE = '.skill-lock.json' + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {string} [homeDir] + * @returns {string} + */ +export function skillsLockPath(env = process.env, homeDir = homedir()) { + const stateHome = env.XDG_STATE_HOME + if (stateHome) return join(stateHome, 'skills', LOCK_FILE) + return join(homeDir, '.agents', LOCK_FILE) +} + +/** + * @param {string} lockPath + * @returns {Promise} + */ +async function readSelectedAgents(lockPath) { + try { + const lock = JSON.parse(await readFile(lockPath, 'utf8')) + const agents = lock?.lastSelectedAgents + if (!Array.isArray(agents) || agents.length === 0) return null + if (!agents.every((id) => typeof id === 'string')) return null + return agents + } catch { + return null + } +} + +/** + * @param {string} lockPath + * @returns {Promise} + */ +export async function snapshotLock(lockPath) { + try { + const info = await stat(lockPath) + return { exists: true, mtimeMs: info.mtimeMs } + } catch { + return { exists: false, mtimeMs: null } + } +} + +/** + * A lock rewritten during the interactive call means the CLI's agent picker ran, and a pick that + * matches the previous one is still a pick. The CLI's one-time find-skills prompt can also rewrite + * the lock; the selection it carries forward is then the user's own last pick, which beats leaving + * the later calls to the CLI's auto-detection. + * @param {string} lockPath + * @param {LockSnapshot} before + * @returns {Promise} + */ +export async function pickedAgents(lockPath, before) { + const after = await snapshotLock(lockPath) + if (!after.exists) return null + if (before.exists && (after.mtimeMs ?? 0) <= (before.mtimeMs ?? 0)) return null + return readSelectedAgents(lockPath) +} diff --git a/src/installed.js b/src/installed.js new file mode 100644 index 0000000..efd6538 --- /dev/null +++ b/src/installed.js @@ -0,0 +1,60 @@ +import { access } from 'node:fs/promises' +import { join } from 'node:path' + +/** @typedef {import('./skills-cli.js').ListedSkill} ListedSkill */ +/** @typedef {{ skills: ListedSkill[], bmadSkillDir: string | null, moduleCodes: string[], legacyManifest: boolean }} InstalledState */ + +export const BMAD_SKILL = 'bmad' +export const RECORD_PREFIX = 'bmod-' + +const LEGACY_MANIFEST = ['_bmad', '_config', 'manifest.yaml'] + +/** + * @param {string} dir + * @param {{ list(options: { cwd: string }): Promise }} skillsCli + * @returns {Promise} + */ +export async function inspectInstall(dir, skillsCli) { + const skills = await skillsCli.list({ cwd: dir }) + const bmad = skills.find((skill) => skill.name === BMAD_SKILL) + const moduleCodes = skills.flatMap((skill) => { + const code = moduleCodeFromRecord(skill.name) + return code ? [code] : [] + }) + return { + skills, + bmadSkillDir: bmad ? bmad.path : null, + moduleCodes, + legacyManifest: await exists(join(dir, ...LEGACY_MANIFEST)), + } +} + +/** + * @param {string} name + * @returns {string | null} + */ +export function moduleCodeFromRecord(name) { + if (!name.startsWith(RECORD_PREFIX)) return null + const code = name.slice(RECORD_PREFIX.length) + return code.length > 0 ? code : null +} + +/** + * @param {InstalledState} state + * @param {string[]} names + * @returns {boolean} + */ +export function hasSkills(state, names) { + const installed = new Set(state.skills.map((skill) => skill.name)) + return names.every((name) => installed.has(name)) +} + +/** @param {string} path */ +async function exists(path) { + try { + await access(path) + return true + } catch { + return false + } +} diff --git a/src/messages.js b/src/messages.js new file mode 100644 index 0000000..971c7fd --- /dev/null +++ b/src/messages.js @@ -0,0 +1,84 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { parse } from 'yaml' +import { packageRoot } from './version.js' + +const PLACEHOLDER = /\{(\w+)\}/g + +export class MessagesError extends Error {} + +/** + * @param {string} [filePath] + * @returns {Promise>} + */ +export async function loadMessages(filePath = join(packageRoot, 'messages.yaml')) { + let text + try { + text = await readFile(filePath, 'utf8') + } catch (err) { + throw new MessagesError(`cannot read ${filePath}: ${errorText(err)}`) + } + + let data + try { + data = parse(text) + } catch (err) { + throw new MessagesError(`invalid YAML in ${filePath}: ${errorText(err)}`) + } + + if (!isRecord(data)) throw new MessagesError(`${filePath} must hold a mapping of keys`) + return data +} + +/** + * @param {string} template + * @param {Record} [values] + * @returns {string} + */ +export function render(template, values = {}) { + return template.replace(PLACEHOLDER, (match, key) => { + const value = values[key] + if (value === undefined || value === null) throw new MessagesError(`no value for ${match}`) + return String(value) + }) +} + +/** + * @param {Record} messages + * @param {string} key + * @param {Record} [values] + * @returns {string} + */ +export function message(messages, key, values) { + const found = lookup(messages, key) + if (typeof found !== 'string') throw new MessagesError(`unknown message key: ${key}`) + return render(found, values) +} + +/** + * @param {Record} messages + * @param {string} key + * @returns {unknown} + */ +function lookup(messages, key) { + /** @type {unknown} */ + let current = messages + for (const part of key.split('.')) { + if (!isRecord(current)) return undefined + current = current[part] + } + return current +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** @param {unknown} err */ +function errorText(err) { + return err instanceof Error ? err.message : String(err) +} diff --git a/src/modules.js b/src/modules.js new file mode 100644 index 0000000..040b166 --- /dev/null +++ b/src/modules.js @@ -0,0 +1,224 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { parse } from 'yaml' +import { packageRoot } from './version.js' + +/** @typedef {{ code: string, name: string, description: string, default?: boolean, skills: string[] }} Bundle */ +/** @typedef {{ code: string, name: string, description: string, source: string, record: string, aliases?: string[], always?: boolean, default?: boolean, deprecated?: string, message?: string, bundles?: Bundle[] }} ModuleDefinition */ +/** @typedef {{ code: string, bundles: string[] | null }} ModuleChoice */ + +const SOURCE_PATTERN = /^[\w.-]+\/[\w.-]+$/ +const SOURCE_OVERRIDE = 'BMAD_INSTALLER_SOURCE_OVERRIDE' + +export class ModulesError extends Error { + /** @param {string} message */ + constructor(message) { + super(message) + this.name = 'ModulesError' + } +} + +/** + * @param {unknown} value + * @param {string} where + * @param {string} field + * @returns {string} + */ +function requireText(value, where, field) { + if (typeof value !== 'string' || value.trim() === '') { + throw new ModulesError(`${where}: ${field} must be a non-empty string`) + } + return value +} + +/** + * @param {any} entry + * @param {string} where + * @returns {Bundle} + */ +function validateBundle(entry, where) { + const code = requireText(entry?.code, where, 'code') + const at = `${where} bundle "${code}"` + const skills = entry.skills + if (!Array.isArray(skills) || skills.length === 0) { + throw new ModulesError(`${at}: skills must be a non-empty array`) + } + /** @type {Bundle} */ + const bundle = { + code, + name: requireText(entry.name, at, 'name'), + description: requireText(entry.description, at, 'description'), + skills: skills.map((skill, index) => requireText(skill, at, `skills[${index}]`)), + } + if (entry.default === true) bundle.default = true + return bundle +} + +/** + * @param {any} entry + * @param {number} index + * @param {Set} taken + * @returns {ModuleDefinition} + */ +function validateModule(entry, index, taken) { + const code = requireText(entry?.code, `modules[${index}]`, 'code') + const where = `module "${code}"` + const source = requireText(entry.source, where, 'source') + if (!SOURCE_PATTERN.test(source)) { + throw new ModulesError(`${where}: source must look like owner/repo`) + } + /** @type {ModuleDefinition} */ + const module = { + code, + name: requireText(entry.name, where, 'name'), + description: requireText(entry.description, where, 'description'), + source, + record: requireText(entry.record, where, 'record'), + } + for (const claimed of [code, ...(entry.aliases ?? [])]) { + const text = requireText(claimed, where, 'aliases') + if (taken.has(text)) throw new ModulesError(`${where}: "${text}" is already used by another module`) + taken.add(text) + } + if (entry.aliases) module.aliases = [...entry.aliases] + if (entry.always === true) module.always = true + if (entry.default === true) module.default = true + if (entry.deprecated !== undefined) module.deprecated = requireText(entry.deprecated, where, 'deprecated') + if (entry.message !== undefined) module.message = requireText(entry.message, where, 'message') + if (entry.bundles !== undefined) { + if (!Array.isArray(entry.bundles)) throw new ModulesError(`${where}: bundles must be an array`) + const bundles = entry.bundles.map((/** @type {any} */ bundle) => validateBundle(bundle, where)) + if (new Set(bundles.map((/** @type {Bundle} */ bundle) => bundle.code)).size !== bundles.length) { + throw new ModulesError(`${where}: bundle codes must be unique`) + } + module.bundles = bundles + } + return module +} + +/** + * @param {string} [filePath] + * @returns {Promise} + */ +export async function loadModules(filePath = join(packageRoot, 'modules.yaml')) { + const document = parse(await readFile(filePath, 'utf8')) + const entries = document?.modules + if (!Array.isArray(entries) || entries.length === 0) { + throw new ModulesError('modules must be a non-empty array') + } + /** @type {Set} */ + const taken = new Set() + const modules = entries.map((entry, index) => validateModule(entry, index, taken)) + const override = process.env[SOURCE_OVERRIDE] + return override ? modules.map((module) => ({ ...module, source: override })) : modules +} + +/** + * @param {ModuleDefinition[]} modules + * @param {string} codeOrAlias + * @returns {ModuleDefinition | undefined} + */ +export function findModule(modules, codeOrAlias) { + return modules.find((module) => module.code === codeOrAlias || (module.aliases ?? []).includes(codeOrAlias)) +} + +/** + * @param {ModuleDefinition} module + * @param {(bundle: Bundle) => boolean} filter + * @returns {string[]} + */ +export function bundleCodes(module, filter) { + return (module.bundles ?? []).filter(filter).map((bundle) => bundle.code) +} + +/** + * @param {ModuleDefinition} module + * @param {string} list + * @returns {string[]} + */ +function parseBundleList(module, list) { + return list.split('+').map((raw) => { + const code = raw.trim() + if (!(module.bundles ?? []).some((bundle) => bundle.code === code)) { + throw new ModulesError(`unknown bundle "${code}" for module "${module.code}"`) + } + return code + }) +} + +/** + * @param {string} value + * @param {ModuleDefinition[]} modules + * @returns {ModuleChoice[]} + */ +export function parseModulesFlag(value, modules) { + /** @type {ModuleChoice[]} */ + const choices = [] + for (const token of value.split(',')) { + const trimmed = token.trim() + if (trimmed === '') continue + const separator = trimmed.indexOf(':') + const codeOrAlias = (separator === -1 ? trimmed : trimmed.slice(0, separator)).trim() + const module = findModule(modules, codeOrAlias) + if (!module) throw new ModulesError(`unknown module "${codeOrAlias}"`) + const listed = separator === -1 + ? bundleCodes(module, (bundle) => bundle.default === true) + : parseBundleList(module, trimmed.slice(separator + 1)) + const existing = choices.find((choice) => choice.code === module.code) + if (!existing) choices.push({ code: module.code, bundles: module.bundles ? [...new Set(listed)] : null }) + else if (existing.bundles) existing.bundles = [...new Set([...existing.bundles, ...listed])] + } + return choices +} + +/** + * @param {ModuleDefinition[]} modules + * @returns {ModuleChoice[]} + */ +export function defaultChoices(modules) { + return modules + .filter((module) => module.default === true) + .map((module) => ({ + code: module.code, + bundles: module.bundles ? bundleCodes(module, (bundle) => bundle.default === true) : null, + })) +} + +/** + * @param {ModuleDefinition} module + * @param {string[] | null} chosenBundles + * @param {string[]} recordSkills + * @returns {{ install: string[], unknown: string[] }} + */ +export function skillsToInstall(module, chosenBundles, recordSkills) { + if (!chosenBundles || !module.bundles) return { install: [...recordSkills], unknown: [] } + const chosen = new Set(chosenBundles) + /** @type {Set} */ + const wanted = new Set() + for (const bundle of module.bundles) { + if (!chosen.has(bundle.code)) continue + for (const skill of bundle.skills) wanted.add(skill) + } + const available = new Set(recordSkills) + /** @type {string[]} */ + const install = [] + /** @type {string[]} */ + const unknown = [] + for (const skill of wanted) (available.has(skill) ? install : unknown).push(skill) + return { install, unknown } +} + +/** + * @param {ModuleDefinition[]} modules + * @returns {Map} + */ +export function groupBySource(modules) { + /** @type {Map} */ + const groups = new Map() + for (const module of modules) { + const existing = groups.get(module.source) + if (existing) existing.push(module) + else groups.set(module.source, [module]) + } + return groups +} diff --git a/src/preflight.js b/src/preflight.js new file mode 100644 index 0000000..e640ba7 --- /dev/null +++ b/src/preflight.js @@ -0,0 +1,116 @@ +import { determineAgent } from '@vercel/detect-agent' +import { message } from './messages.js' + +/** @typedef {import('./run.js').Runner} Runner */ + +const UV_TIMEOUT_MS = 5000 +const NODE_MAJOR_FLOOR = 22 +const SKILLS_CLI_MINOR_FLOOR = 20 +const WSL_PATH_PATTERN = /^\\\\wsl(\.localhost|\$)?\\/ +const WSL_EXEC_PATHS = ['\\wsl$\\', '\\wsl.localhost\\'] +const UV_VERSION_PATTERN = /uv\s+(\d+\.\d+(?:\.\d+)?)/ + +export class PreflightError extends Error { + name = 'PreflightError' +} + +/** + * @param {string} [version] + * @returns {'ok' | 'warn' | 'too-old'} + */ +export function nodeVersionState(version = process.versions.node) { + const [major, minor] = version.replace(/^v/, '').split('.').map((part) => Number.parseInt(part, 10)) + if (!Number.isFinite(major) || major < NODE_MAJOR_FLOOR) return 'too-old' + if (major === NODE_MAJOR_FLOOR && (!Number.isFinite(minor) || minor < SKILLS_CLI_MINOR_FLOOR)) return 'warn' + return 'ok' +} + +/** + * @param {{ platform: string, env: NodeJS.ProcessEnv, cwd: string, execPath: string }} [input] + * @returns {boolean} + */ +export function wslRunningWindowsNode(input) { + const { platform, env, cwd, execPath } = input ?? { + platform: process.platform, + env: process.env, + cwd: process.cwd(), + execPath: process.execPath, + } + if (platform !== 'win32') return false + if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true + if (env.PWD?.startsWith('/')) return true + if (WSL_PATH_PATTERN.test(cwd)) return true + return WSL_EXEC_PATHS.some((fragment) => execPath.includes(fragment)) +} + +/** + * @param {Runner} runner + * @returns {Promise} + */ +export async function uvVersion(runner) { + const result = await runner({ argv: ['uv', '--version'], timeoutMs: UV_TIMEOUT_MS }) + if (result.code !== 0) return null + const match = UV_VERSION_PATTERN.exec(result.stdout) + return match ? match[1] : null +} + +/** @returns {Promise} */ +export async function insideAgent() { + const result = await determineAgent() + return result.isAgent ? result.agent.name : null +} + +/** + * @param {NodeJS.ReadStream} [stdin] + * @param {NodeJS.WriteStream} [stdout] + * @returns {boolean} + */ +export function hasTerminal(stdin = process.stdin, stdout = process.stdout) { + return Boolean(stdin?.isTTY && stdout?.isTTY) +} + +/** + * @param {Record} messages + * @param {string} [platform] + * @returns {string} + */ +export function uvInstallCommand(messages, platform = process.platform) { + const commands = messages.uvInstall ?? {} + return String(commands[platform] ?? commands.linux ?? '') +} + +/** + * `terminal` and `agent` are injectable so the command tests can drive the interactive path. + * @param {{ interactive: boolean, runner: Runner, messages: Record, warn: (text: string) => void, platform?: string, terminal?: () => boolean, agent?: () => Promise }} options + * @returns {Promise} + */ +export async function runPreflight(options) { + const { + interactive, + runner, + messages, + warn, + platform = process.platform, + terminal = hasTerminal, + agent: readAgent = insideAgent, + } = options + const version = process.versions.node + const state = nodeVersionState(version) + if (state === 'too-old') throw new PreflightError(message(messages, 'nodeTooOld', { version })) + if (state === 'warn') warn(message(messages, 'nodeBelowSkillsFloor', { version })) + + const wsl = wslRunningWindowsNode({ platform, env: process.env, cwd: process.cwd(), execPath: process.execPath }) + if (wsl) throw new PreflightError(message(messages, 'wslWindowsNode')) + + const uv = await uvVersion(runner) + if (uv === null) { + throw new PreflightError(message(messages, 'uvMissing', { installCommand: uvInstallCommand(messages, platform) })) + } + + if (!interactive) return + + if (!terminal()) throw new PreflightError(message(messages, 'needsTerminal')) + + const agent = await readAgent() + if (agent) throw new PreflightError(message(messages, 'insideAgent', { agent })) +} diff --git a/src/prompts.js b/src/prompts.js new file mode 100644 index 0000000..678c580 --- /dev/null +++ b/src/prompts.js @@ -0,0 +1,201 @@ +import { + cancel, + confirm as confirmPrompt, + intro, + isCancel, + log, + multiselect, + note, + outro, + select, + spinner, + text, +} from '@clack/prompts' +import { message } from './messages.js' + +/** @typedef {import('./modules.js').ModuleDefinition} ModuleDefinition */ +/** @typedef {{ options: { value: string, label: string, hint: string }[], initialValues: string[] }} Picker */ + +export class Cancelled extends Error { + name = 'Cancelled' +} + +/** + * @template T + * @param {T} value + * @returns {Exclude} + */ +function ensure(value) { + if (isCancel(value)) throw new Cancelled() + return /** @type {Exclude} */ (/** @type {unknown} */ (value)) +} + +/** + * @param {Record} messages + * @param {string} title + */ +export function showIntro(messages, title) { + intro(title) + note(message(messages, 'intro')) +} + +/** + * @param {string} text + * @param {string} [title] + */ +export function showNote(text, title) { + note(text, title) +} + +/** @param {string} text */ +export function say(text) { + log.info(text) +} + +/** @param {string} text */ +export function warn(text) { + log.warn(text) +} + +/** + * `cancel` puts the guide prefix in front of the first line only, and most stops here are + * several lines, so the lines go through `log.error` and `cancel` just closes the guide. + * @param {string} text + */ +export function fail(text) { + log.error(text) + cancel() +} + +/** + * @param {string} text + * @returns {{ stop(text?: string): void }} + */ +export function startSpinner(text) { + const active = spinner() + active.start(text) + return { stop: (done) => active.stop(done) } +} + +/** + * @param {Record} messages + * @param {string} defaultDir + * @returns {Promise} + */ +export async function askDirectory(messages, defaultDir) { + const answer = ensure(await text({ + message: message(messages, 'directoryPrompt'), + placeholder: defaultDir, + defaultValue: defaultDir, + })) + return answer.trim() === '' ? defaultDir : answer.trim() +} + +/** + * @param {string} question + * @param {boolean} [initial] + * @returns {Promise} + */ +export async function confirm(question, initial = true) { + return ensure(await confirmPrompt({ message: question, initialValue: initial })) +} + +/** + * @param {Record} messages + * @param {ModuleDefinition} module + * @param {string[]} installedCodes + * @param {Record} installedVersions + * @returns {string} + */ +function moduleLabel(messages, module, installedCodes, installedVersions) { + if (!installedCodes.includes(module.code)) return module.name + const version = installedVersions[module.code] + const suffix = version + ? message(messages, 'installedSuffix', { version }) + : message(messages, 'installedSuffixNoVersion') + return `${module.name} ${suffix}` +} + +/** + * @param {Record} messages + * @param {ModuleDefinition[]} modules + * @param {{ installedCodes: string[], installedVersions: Record }} options + * @returns {Picker} + */ +export function modulePicker(messages, modules, options) { + const { installedCodes, installedVersions } = options + const fresh = installedCodes.length === 0 + const shown = modules.filter((module) => !module.always) + return { + options: shown.map((module) => ({ + value: module.code, + label: moduleLabel(messages, module, installedCodes, installedVersions), + hint: module.description, + })), + initialValues: shown + .filter((module) => installedCodes.includes(module.code) || (fresh && module.default)) + .map((module) => module.code), + } +} + +/** + * @param {ModuleDefinition} module + * @param {{ installedSkills: string[] }} options + * @returns {Picker} + */ +export function bundlePicker(module, options) { + const bundles = module.bundles ?? [] + const installed = new Set(options.installedSkills) + return { + options: bundles.map((bundle) => ({ value: bundle.code, label: bundle.name, hint: bundle.description })), + initialValues: bundles + .filter((bundle) => bundle.default || bundle.skills.every((skill) => installed.has(skill))) + .map((bundle) => bundle.code), + } +} + +/** + * @param {Record} messages + * @param {ModuleDefinition[]} modules + * @param {{ installedCodes: string[], installedVersions: Record }} options + * @returns {Promise} + */ +export async function chooseModules(messages, modules, options) { + return ensure(await multiselect({ + message: message(messages, 'modulesPrompt'), + ...modulePicker(messages, modules, options), + required: false, + })) +} + +/** + * @param {Record} messages + * @param {ModuleDefinition} module + * @param {{ installedSkills: string[] }} options + * @returns {Promise} + */ +export async function chooseBundles(messages, module, options) { + return ensure(await multiselect({ + message: message(messages, 'bundlesPrompt', { module: module.name }), + ...bundlePicker(module, options), + required: false, + })) +} + +/** + * @param {Record} messages + * @returns {Promise<'modify' | 'update'>} + */ +export async function chooseExistingAction(messages) { + /** @type {Array<{ value: 'modify' | 'update', label: string }>} */ + const options = [ + { value: 'modify', label: message(messages, 'existingInstallModify') }, + { value: 'update', label: message(messages, 'existingInstallUpdate') }, + ] + return ensure(await select({ message: message(messages, 'existingInstallPrompt'), options })) +} + +/** @param {string} text */ +export function finish(text) { + outro(text) +} diff --git a/src/report.js b/src/report.js new file mode 100644 index 0000000..8a7fdda --- /dev/null +++ b/src/report.js @@ -0,0 +1,143 @@ +import { message } from './messages.js' + +/** @typedef {import('./bmad-scripts.js').StatusReport} StatusReport */ +/** @typedef {import('./bmad-scripts.js').Migration} Migration */ +/** @typedef {import('./skills-cli.js').AddResult} AddResult */ +/** @typedef {import('./modules.js').ModuleDefinition} ModuleDefinition */ +/** @typedef {Record} Messages */ + +/** + * @param {StatusReport} report + * @param {{ failures: AddResult[], messages: Messages, modules?: ModuleDefinition[] }} options + * @returns {string} + */ +export function renderStatus(report, options) { + const { failures, messages, modules = [] } = options + const lines = [message(messages, 'reportTitle')] + + lines.push( + report.current + ? message(messages, 'reportCurrent') + : message(messages, 'reportNotCurrent', { next: report.next ?? '' }), + ) + + for (const module of report.modules) { + lines.push( + message(messages, 'reportModule', { + name: moduleName(modules, module.module), + version: module.version, + count: module.skills.length, + }), + ) + if (module.absent_skills.length > 0) { + lines.push( + message(messages, 'reportAlsoAvailable', { + skills: module.absent_skills.join(', '), + code: module.module, + }), + ) + } + } + + for (const record of report.missing_module_records) { + lines.push(message(messages, 'reportMissingRecord', { install: record.install ?? '' })) + } + + for (const unmet of [...report.unmet_requirements, ...report.unmet_recommendations]) { + lines.push(message(messages, 'reportUnmet', { + skill: unmet.skill, + requires: unmet.requires, + minimum: unmet.minimum ?? '', + state: unmet.state, + install: unmet.install ?? '', + })) + } + + if (report.pending_questions.length > 0) { + lines.push(message(messages, 'reportPendingQuestions', { count: report.pending_questions.length })) + } + + for (const problem of report.problems) { + lines.push(message(messages, 'reportProblem', { message: problem.message })) + } + + for (const leftover of report.legacy_leftovers) { + lines.push(message(messages, 'reportLegacy', { path: leftover })) + } + + if (failures.length > 0) lines.push(renderFailures(failures, messages)) + + return lines.join('\n') +} + +/** + * @param {AddResult[]} failures + * @param {Messages} messages + * @returns {string} + */ +export function renderFailures(failures, messages) { + return failures + .map((failure) => message(messages, 'reportFailure', { + skill: failure.name ?? '', + error: failure.error ?? failure.reason ?? '', + })) + .join('\n') +} + +/** + * @param {Migration[]} migrations + * @param {Messages} messages + * @returns {string} + */ +export function renderMigrations(migrations, messages) { + if (migrations.length === 0) return '' + const lines = [message(messages, 'migrationsAvailable')] + for (const migration of migrations) { + lines.push( + message(messages, 'migrationLine', { + title: migration.title, + module: migration.module, + from: migration.from, + to: migration.to, + }), + ) + } + return lines.join('\n') +} + +/** + * @param {ModuleDefinition[]} modules + * @returns {string} + */ +export function renderModuleMessages(modules) { + return modules + .flatMap((module) => (module.message ? [module.message.trim()] : [])) + .join('\n\n') +} + +/** + * @param {Messages} messages + * @param {{ directory: string }} values + * @returns {string} + */ +export function renderClosing(messages, values) { + return message(messages, 'closing', { directory: values.directory }) +} + +/** + * @param {string} version + * @returns {string | null} + */ +export function versionMajor(version) { + const match = /^v?(\d+)(?=[.\-+]|$)/.exec(version.trim()) + return match ? match[1] : null +} + +/** + * @param {ModuleDefinition[]} modules + * @param {string} code + * @returns {string} + */ +function moduleName(modules, code) { + return modules.find((module) => module.code === code)?.name ?? code +} diff --git a/src/run.js b/src/run.js new file mode 100644 index 0000000..539759f --- /dev/null +++ b/src/run.js @@ -0,0 +1,131 @@ +import { randomUUID } from 'node:crypto' +import { open, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import spawn from 'cross-spawn' + +/** @typedef {{ argv: string[], cwd?: string, env?: NodeJS.ProcessEnv, stdio?: 'inherit' | 'pipe', timeoutMs?: number }} RunRequest */ +/** @typedef {{ code: number | null, stdout: string, stderr: string, timedOut: boolean, error?: string }} RunResult */ +/** @typedef {(request: RunRequest) => Promise} Runner */ +/** @typedef {{ fd: number, read(): Promise, dispose(): Promise }} Capture */ + +/** + * A spawned child's stdout pipe is a socket with an 8 KB buffer, and a child that ends with + * `process.exit()` drops whatever has not drained. Writes to a regular file always land, so + * captured stdout goes through a temp file instead. + * @returns {Promise} + */ +async function openCapture() { + const path = join(tmpdir(), `bmad-installer-${randomUUID()}`) + const handle = await open(path, 'w+') + return { + fd: handle.fd, + async read() { + try { + return await readFile(path, 'utf8') + } catch { + return '' + } + }, + async dispose() { + await handle.close().catch(() => {}) + await rm(path, { force: true }).catch(() => {}) + }, + } +} + +/** + * @param {{ argv: string[], cwd?: string, env?: NodeJS.ProcessEnv, stdio: import('node:child_process').StdioOptions, capture: Capture | null, timeoutMs?: number }} request + * @returns {Promise} + */ +function spawnAndWait(request) { + const { capture, timeoutMs } = request + const [command, ...args] = request.argv + + return new Promise((resolve) => { + let child + try { + child = spawn(command, args, { + cwd: request.cwd, + env: request.env, + stdio: request.stdio, + windowsHide: true, + }) + } catch (err) { + resolve({ code: null, stdout: '', stderr: '', timedOut: false, error: String(err) }) + return + } + + let stderr = '' + let timedOut = false + /** @type {NodeJS.Timeout | undefined} */ + let timer + + child.stderr?.setEncoding('utf8') + child.stderr?.on('data', (chunk) => { stderr += chunk }) + + if (timeoutMs) { + timer = setTimeout(() => { + timedOut = true + child.kill() + }, timeoutMs) + } + + /** + * @param {number | null} code + * @param {string} [error] + */ + const settle = async (code, error) => { + if (timer) clearTimeout(timer) + const stdout = capture ? await capture.read() : '' + resolve(error === undefined + ? { code, stdout, stderr, timedOut } + : { code, stdout, stderr, timedOut, error }) + } + + child.on('error', (err) => { void settle(null, String(err.message ?? err)) }) + child.on('close', (code) => { void settle(code) }) + }) +} + +/** + * @param {{ debug?: boolean, log?: (line: string) => void }} [options] + * @returns {Runner} + */ +export function createRunner(options = {}) { + const debug = options.debug ?? false + const log = options.log ?? ((line) => process.stderr.write(`${line}\n`)) + + /** @type {Runner} */ + return async function run(request) { + const { argv, cwd, env, stdio = 'pipe', timeoutMs } = request + const inherit = stdio === 'inherit' + + if (debug) log(`$ ${cwd ?? process.cwd()}: ${argv.join(' ')}`) + + /** @type {Capture | null} */ + let capture = null + /** @type {import('node:child_process').StdioOptions} */ + let childStdio = 'inherit' + if (!inherit) { + try { + capture = await openCapture() + } catch (err) { + return { code: null, stdout: '', stderr: '', timedOut: false, error: String(err) } + } + childStdio = ['ignore', capture.fd, 'pipe'] + } + + try { + const result = await spawnAndWait({ argv, cwd, env, stdio: childStdio, capture, timeoutMs }) + if (debug) { + log(`exit ${result.code}`) + if (result.stdout) log(result.stdout) + if (result.stderr) log(result.stderr) + } + return result + } finally { + await capture?.dispose() + } + } +} diff --git a/src/self-update.js b/src/self-update.js new file mode 100644 index 0000000..10716d5 --- /dev/null +++ b/src/self-update.js @@ -0,0 +1,87 @@ +/** @typedef {import('./run.js').Runner} Runner */ +/** @typedef {{ major: number, minor: number, patch: number, prerelease: string | null }} ParsedVersion */ + +const PACKAGE_NAME = 'bmad-method' +const NPM_VIEW_TIMEOUT_MS = 5000 +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/ + +/** + * @param {string} version + * @returns {'next' | 'latest'} + */ +export function distTag(version) { + return VERSION_PATTERN.exec(version)?.[4] ? 'next' : 'latest' +} + +/** + * @param {string} value + * @returns {ParsedVersion | null} + */ +function parseVersion(value) { + const match = VERSION_PATTERN.exec(value) + if (!match) return null + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + } +} + +/** + * @param {string} a + * @param {string} b + * @returns {number} + */ +function comparePrerelease(a, b) { + const left = a.split('.') + const right = b.split('.') + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const one = left[index] + const other = right[index] + if (one === undefined) return -1 + if (other === undefined) return 1 + if (one === other) continue + const numeric = /^\d+$/.test(one) && /^\d+$/.test(other) + return numeric ? Number(one) - Number(other) : one < other ? -1 : 1 + } + return 0 +} + +/** + * @param {ParsedVersion} a + * @param {ParsedVersion} b + * @returns {number} + */ +function compareVersions(a, b) { + if (a.major !== b.major) return a.major - b.major + if (a.minor !== b.minor) return a.minor - b.minor + if (a.patch !== b.patch) return a.patch - b.patch + if (a.prerelease === b.prerelease) return 0 + if (a.prerelease === null) return 1 + if (b.prerelease === null) return -1 + return comparePrerelease(a.prerelease, b.prerelease) +} + +/** + * @param {{ runner: Runner, version: string, timeoutMs?: number }} deps + * @returns {Promise} + */ +export async function newerVersion(deps) { + const { runner, version, timeoutMs = NPM_VIEW_TIMEOUT_MS } = deps + const ours = parseVersion(version) + if (!ours) return null + try { + const result = await runner({ + argv: ['npm', 'view', `${PACKAGE_NAME}@${distTag(version)}`, 'version'], + timeoutMs, + }) + if (result.timedOut || result.code !== 0) return null + const latest = result.stdout.trim() + const parsed = parseVersion(latest) + if (!parsed) return null + return compareVersions(parsed, ours) > 0 ? latest : null + } catch { + return null + } +} diff --git a/src/skills-cli.js b/src/skills-cli.js new file mode 100644 index 0000000..23ac4f2 --- /dev/null +++ b/src/skills-cli.js @@ -0,0 +1,153 @@ +import { createRequire } from 'node:module' + +/** @typedef {import('./run.js').RunResult} RunResult */ +/** @typedef {import('./run.js').Runner} Runner */ +/** @typedef {{ name?: string, status: 'installed' | 'failed' | 'skipped', error?: string, reason?: string, path?: string, agents?: string[], mode?: string }} AddResult */ +/** @typedef {{ name: string, path: string, scope: 'project' | 'global', agents: string[], source: string | null }} ListedSkill */ +/** @typedef {{ source: string, skills: string[], agents?: string[] | null }} AddRequest */ + +export class SkillsCliError extends Error { + /** + * @param {string} message + * @param {{ code?: number | null, stderr?: string }} [details] + */ + constructor(message, details = {}) { + super(message) + this.name = 'SkillsCliError' + this.code = details.code ?? null + this.stderr = details.stderr ?? '' + } +} + +/** @returns {string} */ +export function skillsCliBin() { + return createRequire(import.meta.url).resolve('skills/bin/cli.mjs') +} + +/** + * @param {NodeJS.ProcessEnv} base + * @param {{ telemetry: boolean }} options + * @returns {NodeJS.ProcessEnv} + */ +export function childEnv(base, options) { + const env = { ...base } + if (!options.telemetry) env.DO_NOT_TRACK = '1' + return env +} + +/** + * `-s` and `-a` swallow every following non-dash token, so `--metadata` comes after them. + * @param {AddRequest} request + * @param {{ interactive: boolean, copy: boolean, metadata: object }} options + * @returns {string[]} + */ +export function addArgv(request, options) { + const argv = [process.execPath, skillsCliBin(), 'add', request.source, '-s', ...request.skills] + if (Array.isArray(request.agents) && request.agents.length > 0) argv.push('-a', ...request.agents) + argv.push('--metadata', JSON.stringify(options.metadata)) + if (options.copy) argv.push('--copy') + if (!options.interactive) argv.push('-y', '--json') + return argv +} + +/** @returns {string[]} */ +export function updateArgv() { + return [process.execPath, skillsCliBin(), 'update', '-p', '-y'] +} + +/** @returns {string[]} */ +export function listArgv() { + return [process.execPath, skillsCliBin(), 'list', '--json'] +} + +/** + * @param {string} stdout + * @returns {AddResult[]} + */ +export function parseAddResults(stdout) { + return parseJsonArray(stdout) +} + +/** + * @param {string} stdout + * @returns {ListedSkill[]} + */ +export function parseListResults(stdout) { + return parseJsonArray(stdout) +} + +/** + * @param {string} stdout + * @returns {any[]} + */ +function parseJsonArray(stdout) { + let parsed + try { + parsed = JSON.parse(stdout) + } catch { + throw new SkillsCliError('skills CLI stdout is not JSON') + } + if (!Array.isArray(parsed)) throw new SkillsCliError('skills CLI stdout is not a JSON array') + return parsed +} + +/** + * @param {RunResult} result + * @returns {string} + */ +function failureReason(result) { + const lines = result.stderr.split('\n').map((line) => line.trim()).filter(Boolean) + return lines.at(-1) ?? `skills CLI exited with code ${result.code}` +} + +/** + * @param {{ runner: Runner, env: NodeJS.ProcessEnv, telemetry: boolean, copy: boolean, metadata: object }} deps + */ +export function createSkillsCli(deps) { + const env = childEnv(deps.env, { telemetry: deps.telemetry }) + const { copy, metadata } = deps + + return { + /** @param {AddRequest & { cwd: string }} request */ + async addInteractive(request) { + const result = await deps.runner({ + argv: addArgv(request, { interactive: true, copy, metadata }), + cwd: request.cwd, + env, + stdio: 'inherit', + }) + return { code: result.code } + }, + /** + * @param {AddRequest & { cwd: string }} request + * @returns {Promise} + */ + async add(request) { + const result = await deps.runner({ + argv: addArgv(request, { interactive: false, copy, metadata }), + cwd: request.cwd, + env, + stdio: 'pipe', + }) + try { + return parseAddResults(result.stdout) + } catch { + return [{ status: 'failed', error: failureReason(result) }] + } + }, + /** @param {{ cwd: string }} options */ + async update(options) { + const result = await deps.runner({ argv: updateArgv(), cwd: options.cwd, env, stdio: 'inherit' }) + return { code: result.code } + }, + /** + * @param {{ cwd: string }} options + * @returns {Promise} + */ + async list(options) { + const result = await deps.runner({ argv: listArgv(), cwd: options.cwd, env, stdio: 'pipe' }) + if (result.code !== 0 && result.stdout.trim() === '') return [] + return parseListResults(result.stdout) + }, + } +} diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..48a7f85 --- /dev/null +++ b/src/version.js @@ -0,0 +1,8 @@ +import { createRequire } from 'node:module' +import { dirname } from 'node:path' + +const require = createRequire(import.meta.url) +const packageJsonPath = require.resolve('../package.json') + +export const packageRoot = dirname(packageJsonPath) +export const version = require('../package.json').version diff --git a/test/bmad-scripts.test.js b/test/bmad-scripts.test.js new file mode 100644 index 0000000..b212620 --- /dev/null +++ b/test/bmad-scripts.test.js @@ -0,0 +1,168 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + BmadScriptsError, + createBmadScripts, + knowledgeArgv, + parseMigrations, + parseStatus, + setupStatusArgv, +} from '../src/bmad-scripts.js' + +/** @typedef {import('../src/run.js').RunRequest} RunRequest */ +/** @typedef {import('../src/run.js').RunResult} RunResult */ + +const SKILLS_DIR = join('/project', '.agents', 'skills') +const BMAD_DIR = join(SKILLS_DIR, 'bmad') +const PROJECT = '/project' + +/** @param {string} name */ +function fixture(name) { + return readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8') +} + +/** + * @param {Partial[]} results + */ +function fakeRunner(results) { + /** @type {RunRequest[]} */ + const calls = [] + let index = 0 + /** @type {import('../src/run.js').Runner} */ + const runner = async (request) => { + calls.push(request) + const result = results[Math.min(index, results.length - 1)] + index += 1 + return { code: 0, stdout: '', stderr: '', timedOut: false, ...result } + } + return { runner, calls } +} + +test('setupStatusArgv builds the uv command', () => { + assert.deepEqual(setupStatusArgv(BMAD_DIR, PROJECT), [ + 'uv', + 'run', + '--no-cache', + join(BMAD_DIR, 'scripts', 'setup.py'), + '--project-root', + PROJECT, + '--skill', + BMAD_DIR, + '--status', + ]) +}) + +test('knowledgeArgv points --root at the skills directory', () => { + assert.deepEqual(knowledgeArgv(BMAD_DIR), [ + 'uv', + 'run', + '--no-cache', + join(BMAD_DIR, 'scripts', 'knowledge.py'), + '--root', + SKILLS_DIR, + ]) +}) + +test('parseStatus reads the status fixture', () => { + const report = parseStatus(fixture('status-fresh.json')) + assert.equal(report.current, false) + assert.equal(report.next, 'bmad setup') + assert.equal(report.modules.length, 2) + assert.deepEqual(report.modules[0].skills, ['bmad']) + assert.equal(report.modules[1].module, 'method') + assert.equal(report.modules[1].absent_skills.length, 22) +}) + +test('parseStatus sees the moved skill in the after-prd fixture', () => { + const report = parseStatus(fixture('status-after-prd.json')) + assert.deepEqual(report.modules[1].skills, ['bmad-prd']) + assert.equal(report.modules[1].absent_skills.includes('bmad-prd'), false) + assert.equal(report.modules[1].absent_skills.length, 21) +}) + +test('parseStatus throws BmadScriptsError on unparsable stdout', () => { + assert.throws(() => parseStatus('not json'), BmadScriptsError) + assert.throws(() => parseStatus('[]'), BmadScriptsError) +}) + +test('parseMigrations returns the migrations entries', () => { + const migrations = parseMigrations(fixture('knowledge-migrations.json')) + assert.equal(migrations.length, 1) + assert.equal(migrations[0].module, 'method') + assert.equal(migrations[0].from, '6') + assert.equal(migrations[0].to, '7') +}) + +test('parseMigrations returns [] when the key is missing', () => { + assert.deepEqual(parseMigrations(fixture('knowledge-no-migrations.json')), []) +}) + +test('status runs setup.py and parses the report', async () => { + const { runner, calls } = fakeRunner([{ stdout: fixture('status-fresh.json') }]) + const scripts = createBmadScripts({ runner }) + const report = await scripts.status(BMAD_DIR, PROJECT) + assert.equal(report.mode, 'status') + assert.deepEqual(calls[0].argv, setupStatusArgv(BMAD_DIR, PROJECT)) +}) + +test('status throws with the stderr on a non-zero exit', async () => { + const { runner } = fakeRunner([{ code: 1, stderr: 'error: boom' }]) + const scripts = createBmadScripts({ runner }) + await assert.rejects( + () => scripts.status(BMAD_DIR, PROJECT), + (err) => { + assert.ok(err instanceof BmadScriptsError) + assert.equal(err.stderr, 'error: boom') + return true + }, + ) +}) + +test('status throws with the stderr on unparsable stdout', async () => { + const { runner } = fakeRunner([{ code: 0, stdout: 'huh', stderr: 'warning' }]) + const scripts = createBmadScripts({ runner }) + await assert.rejects( + () => scripts.status(BMAD_DIR, PROJECT), + (err) => { + assert.ok(err instanceof BmadScriptsError) + assert.equal(err.stderr, 'warning') + return true + }, + ) +}) + +test('status reports a spawn error in the stderr it carries', async () => { + const { runner } = fakeRunner([{ code: null, error: 'spawn uv ENOENT' }]) + const scripts = createBmadScripts({ runner }) + await assert.rejects( + () => scripts.status(BMAD_DIR, PROJECT), + (err) => { + assert.ok(err instanceof BmadScriptsError) + assert.match(err.stderr, /ENOENT/) + return true + }, + ) +}) + +test('migrations runs knowledge.py and returns the entries', async () => { + const { runner, calls } = fakeRunner([{ stdout: fixture('knowledge-migrations.json') }]) + const scripts = createBmadScripts({ runner }) + const migrations = await scripts.migrations(BMAD_DIR) + assert.equal(migrations.length, 1) + assert.deepEqual(calls[0].argv, knowledgeArgv(BMAD_DIR)) +}) + +test('migrations returns [] on a failed run or unparsable output', async () => { + const failed = createBmadScripts({ runner: fakeRunner([{ code: 1, stderr: 'boom' }]).runner }) + assert.deepEqual(await failed.migrations(BMAD_DIR), []) + + const garbage = createBmadScripts({ runner: fakeRunner([{ code: 0, stdout: 'nope' }]).runner }) + assert.deepEqual(await garbage.migrations(BMAD_DIR), []) + + const missing = createBmadScripts({ + runner: fakeRunner([{ stdout: fixture('knowledge-no-migrations.json') }]).runner, + }) + assert.deepEqual(await missing.migrations(BMAD_DIR), []) +}) diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..7ad224e --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,177 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { UsageError, helpText, main, parseCli } from '../src/cli.js' +import { loadMessages } from '../src/messages.js' +import { version } from '../src/version.js' + +const messages = await loadMessages() + +/** @param {string[]} argv */ +function run(argv) { + const parsed = parseCli(argv, messages) + assert.equal(parsed.kind, 'run') + return parsed.kind === 'run' ? parsed.options : assert.fail('not a run') +} + +/** @param {string[]} argv */ +function dropped(argv) { + const parsed = parseCli(argv, messages) + assert.equal(parsed.kind, 'dropped') + return parsed.kind === 'dropped' ? parsed.text : assert.fail('not dropped') +} + +function fakeIo() { + /** @type {string[]} */ + const out = [] + /** @type {string[]} */ + const err = [] + const io = { + stdout: /** @type {NodeJS.WriteStream} */ (/** @type {any} */ ({ write: (/** @type {string} */ t) => out.push(t) })), + stderr: /** @type {NodeJS.WriteStream} */ (/** @type {any} */ ({ write: (/** @type {string} */ t) => err.push(t) })), + env: /** @type {NodeJS.ProcessEnv} */ ({}), + cwd: process.cwd(), + } + return { io, out, err } +} + +test('no arguments installs into the current directory with telemetry on', () => { + assert.deepEqual(run([]), { + command: 'install', + directory: undefined, + modules: undefined, + tools: undefined, + yes: false, + telemetry: true, + copy: false, + debug: false, + }) +}) + +test('every flag is read, long and short', () => { + const long = run([ + 'install', + '--directory', '/tmp/p', + '--modules', 'method:planning+build,cis', + '--tools', 'claude-code,codex', + '--yes', + '--no-telemetry', + '--copy', + '--debug', + ]) + assert.deepEqual(long, { + command: 'install', + directory: '/tmp/p', + modules: 'method:planning+build,cis', + tools: 'claude-code,codex', + yes: true, + telemetry: false, + copy: true, + debug: true, + }) + + const short = run(['-d', '/tmp/p', '-m', 'cis', '-t', 'codex', '-y']) + assert.equal(short.directory, '/tmp/p') + assert.equal(short.modules, 'cis') + assert.equal(short.tools, 'codex') + assert.equal(short.yes, true) +}) + +test('a boolean flag written with = still counts as given', () => { + const equals = run(['--no-telemetry=1', '--copy=true', '--debug=true', '--yes=true']) + assert.equal(equals.telemetry, false) + assert.equal(equals.copy, true) + assert.equal(equals.debug, true) + assert.equal(equals.yes, true) + + assert.equal(run(['--no-telemetry=']).telemetry, false) + assert.deepEqual(parseCli(['--help=1'], messages), { kind: 'help' }) + assert.deepEqual(parseCli(['--version=1'], messages), { kind: 'version' }) +}) + +test('update and status are commands', () => { + assert.equal(run(['update']).command, 'update') + assert.equal(run(['status']).command, 'status') +}) + +test('--action maps the 6.12 values onto the commands', () => { + assert.equal(run(['--action', 'install']).command, 'install') + assert.equal(run(['--action', 'update']).command, 'update') + assert.equal(run(['--action', 'quick-update']).command, 'update') + assert.throws(() => parseCli(['--action', 'remove'], messages), UsageError) +}) + +test('unknown flags, commands and stray arguments are usage errors', () => { + assert.throws(() => parseCli(['--bogus'], messages), UsageError) + assert.throws(() => parseCli(['-q'], messages), UsageError) + assert.throws(() => parseCli(['reinstall'], messages), UsageError) + assert.throws(() => parseCli(['install', 'extra'], messages), UsageError) + assert.throws(() => parseCli(['--directory'], messages), UsageError) +}) + +test('every dropped 6.12 flag explains itself', () => { + const flags = [ + 'custom-source', 'set', 'list-options', 'user-name', 'communication-language', + 'document-output-language', 'output-folder', 'channel', 'all-stable', 'all-next', + 'next', 'pin', 'shims', 'no-shims', 'list-tools', + ] + for (const flag of flags) { + const text = dropped([`--${flag}`]) + assert.equal(text, messages.droppedFlags[flag]) + assert.ok(text.startsWith(`--${flag} `)) + } +}) + +test('dropped flags are read before the arguments they swallowed', () => { + assert.equal(dropped(['--custom-source', 'owner/repo']), messages.droppedFlags['custom-source']) + assert.equal(dropped(['install', '--next', '--bogus']), messages.droppedFlags.next) + const both = dropped(['--pin', '6.12.0', '--shims']) + assert.deepEqual(both.split('\n'), [messages.droppedFlags.pin, messages.droppedFlags.shims]) +}) + +test('uninstall is dropped too', () => { + assert.equal(dropped(['uninstall']), messages.droppedUninstall) +}) + +test('help and version win over a command', () => { + assert.deepEqual(parseCli(['--help'], messages), { kind: 'help' }) + assert.deepEqual(parseCli(['-h'], messages), { kind: 'help' }) + assert.deepEqual(parseCli(['install', '--version'], messages), { kind: 'version' }) + assert.deepEqual(parseCli(['-v'], messages), { kind: 'version' }) +}) + +test('helpText lists every command and flag on one line each', () => { + const text = helpText() + for (const name of ['install', 'update', 'status', '--directory', '--modules', '--tools', '--yes', + '--action', '--no-telemetry', '--copy', '--debug', '--help', '--version']) { + assert.ok(text.includes(name), `missing ${name}`) + } + assert.ok(/--no-telemetry .*skills\.sh install counts/.test(text)) + assert.ok(/--copy .*symlinks fail on your system/.test(text)) + assert.ok(text.split('\n').every((line) => line.length <= 100)) +}) + +test('main prints the help and exits 0', async () => { + const { io, out } = fakeIo() + assert.equal(await main(['--help'], io), 0) + assert.equal(out.join(''), `${helpText()}\n`) +}) + +test('main prints the version and exits 0', async () => { + const { io, out } = fakeIo() + assert.equal(await main(['--version'], io), 0) + assert.equal(out.join(''), `${version}\n`) +}) + +test('main writes dropped flags to stderr and exits 2', async () => { + const { io, out, err } = fakeIo() + assert.equal(await main(['--channel', 'next'], io), 2) + assert.equal(err.join(''), `${messages.droppedFlags.channel}\n`) + assert.equal(out.length, 0) +}) + +test('main writes a usage error with a help hint and exits 2', async () => { + const { io, err } = fakeIo() + assert.equal(await main(['--bogus'], io), 2) + assert.ok(err.join('').includes('unknown flag --bogus')) + assert.ok(err.join('').includes('--help')) +}) diff --git a/test/commands/install-agents.test.js b/test/commands/install-agents.test.js new file mode 100644 index 0000000..abc965b --- /dev/null +++ b/test/commands/install-agents.test.js @@ -0,0 +1,151 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { install } from '../../src/commands/install.js' +import { skillsLockPath } from '../../src/harness.js' +import { + RECORDS, + fakeBmadScripts, + fakePrompts, + fakeSkillsCli, + listed, + makeDeps, + messages, + only, + options, + statusOf, + tempDir, +} from '../helpers/command-fakes.js' + +test('a fresh interactive run picks agents through the CLI and reuses them', async (t) => { + const dir = await tempDir(t) + const state = join(dir, 'state') + const lockPath = skillsLockPath({ XDG_STATE_HOME: state }) + const { prompts, said, asked } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ + lists: [[], RECORDS.map(listed), RECORDS.map(listed)], + async onInteractive() { + await mkdir(dirname(lockPath), { recursive: true }) + await writeFile(lockPath, JSON.stringify({ version: 3, lastSelectedAgents: ['claude-code', 'codex'] })) + }, + }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await install( + options({ directory: dir }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir, env: { XDG_STATE_HOME: state } }), + ) + + assert.equal(code, 0) + const [first] = only(requests, 'interactive') + assert.deepEqual(first.skills, RECORDS) + assert.equal(first.agents, undefined) + assert.ok(said.notes.some((note) => note === messages.beforeSkillsPicker)) + + const members = only(requests) + assert.equal(members.length, 1) + assert.deepEqual(members[0].agents, ['claude-code', 'codex']) + assert.equal(members[0].source, 'bmad-code-org/BMAD-METHOD') + assert.ok(members[0].skills.includes('bmad-prd')) + assert.ok(members[0].skills.includes('bmad-brainstorming')) + assert.equal(members[0].skills.includes('bmad-build'), false) + + assert.deepEqual(asked, ['intro', 'confirm', 'modules', 'bundles']) + assert.ok(said.notes.at(-1)?.includes('bmad setup')) + assert.ok(said.notes.some((note) => note.includes('ask the `bmad` skill what to do next'))) + assert.deepEqual(said.finish, ['']) +}) + +test('an interactive run whose picker never ran sends no agent ids', async (t) => { + const dir = await tempDir(t) + const { prompts } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ lists: [[], RECORDS.map(listed), RECORDS.map(listed)] }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await install( + options({ directory: dir }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir, env: { XDG_STATE_HOME: join(dir, 'state') } }), + ) + + assert.equal(code, 0) + assert.equal(only(requests)[0].agents, null) +}) + +test('a pick that matches the previous selection still carries the ids', async (t) => { + const dir = await tempDir(t) + const state = join(dir, 'state') + const lockPath = skillsLockPath({ XDG_STATE_HOME: state }) + await mkdir(dirname(lockPath), { recursive: true }) + await writeFile(lockPath, JSON.stringify({ version: 3, lastSelectedAgents: ['cursor'] })) + const { prompts } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ + lists: [[], RECORDS.map(listed), RECORDS.map(listed)], + async onInteractive() { + await writeFile(lockPath, JSON.stringify({ + version: 3, + lastSelectedAgents: ['cursor'], + dismissed: { findSkillsPrompt: true }, + })) + }, + }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await install( + options({ directory: dir }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir, env: { XDG_STATE_HOME: state } }), + ) + + assert.equal(code, 0) + assert.deepEqual(only(requests)[0].agents, ['cursor']) +}) + +test('on a re-run the interactive call is the first add that is still needed', async (t) => { + const dir = await tempDir(t) + const state = join(dir, 'state') + const lockPath = skillsLockPath({ XDG_STATE_HOME: state }) + const installed = [...RECORDS, 'bmad-prd', 'bmad-product-brief', 'bmad-prfaq', 'bmad-ux', 'bmad-architecture', 'bmad-spec', 'bmad-project-context'] + const withPlanning = statusOf() + withPlanning.modules[1].skills = installed.slice(3) + withPlanning.modules[1].absent_skills = withPlanning.modules[1].absent_skills.filter((name) => !installed.includes(name)) + const { prompts } = fakePrompts({ modules: ['method'], bundles: ['planning', 'build'] }) + const afterBuild = [...installed, ...withPlanning.modules.flatMap((entry) => entry.absent_skills)] + const { cli, requests } = fakeSkillsCli({ + lists: [installed.map(listed), afterBuild.map(listed)], + async onInteractive() { + await mkdir(dirname(lockPath), { recursive: true }) + await writeFile(lockPath, JSON.stringify({ version: 3, lastSelectedAgents: ['claude-code'] })) + }, + }) + const { scripts } = fakeBmadScripts({ statuses: [withPlanning, withPlanning, withPlanning] }) + + const code = await install( + options({ directory: dir }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir, env: { XDG_STATE_HOME: state } }), + ) + + assert.equal(code, 0) + assert.equal(only(requests).length, 0) + const [first] = only(requests, 'interactive') + assert.ok(first.skills.includes('bmad-build')) + for (const name of installed) assert.equal(first.skills.includes(name), false, `${name} was re-added`) +}) + +test('--tools makes every call headless and carries the ids', async (t) => { + const dir = await tempDir(t) + const { prompts, asked } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ lists: [[], RECORDS.map(listed)] }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await install( + options({ directory: dir, tools: 'claude-code, codex' }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir }), + ) + + assert.equal(code, 0) + assert.equal(only(requests, 'interactive').length, 0) + for (const request of only(requests)) assert.deepEqual(request.agents, ['claude-code', 'codex']) + assert.deepEqual(only(requests)[0].skills, RECORDS) + assert.ok(asked.includes('modules')) +}) + diff --git a/test/commands/install-plan.test.js b/test/commands/install-plan.test.js new file mode 100644 index 0000000..e47299f --- /dev/null +++ b/test/commands/install-plan.test.js @@ -0,0 +1,142 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { + chosenModules, + memberCalls, + orderedChoices, + recordCalls, + selectMigrations, + versionsByCode, +} from '../../src/commands/install-plan.js' +import { loadModules } from '../../src/modules.js' + +/** @typedef {import('../../src/bmad-scripts.js').Migration} Migration */ +/** @typedef {import('../../src/bmad-scripts.js').StatusReport} StatusReport */ + +const modules = await loadModules() +const fixture = JSON.parse(await readFile(new URL('../fixtures/status-fresh.json', import.meta.url), 'utf8')) + +/** @returns {StatusReport} */ +function statusOf() { + return structuredClone(fixture) +} + +test('orderedChoices adds the always modules and follows modules.yaml order', () => { + assert.deepEqual(orderedChoices([{ code: 'cis', bundles: null }, { code: 'method', bundles: ['build'] }], modules), [ + { code: 'core-tools', bundles: null }, + { code: 'method', bundles: ['build'] }, + { code: 'cis', bundles: null }, + ]) + assert.deepEqual(orderedChoices([], modules), [{ code: 'core-tools', bundles: null }]) +}) + +test('chosenModules resolves the definitions and drops unknown codes', () => { + const chosen = chosenModules([{ code: 'method', bundles: null }, { code: 'gone', bundles: null }], modules) + assert.deepEqual(chosen.map((module) => module.code), ['method']) +}) + +test('recordCalls puts bmad in the first source call and one call per source', () => { + const chosen = chosenModules(orderedChoices([{ code: 'method', bundles: [] }, { code: 'cis', bundles: null }], modules), modules) + const calls = recordCalls(chosen, 'bmad') + + assert.equal(calls.length, 2) + assert.deepEqual(calls[0], { + source: 'bmad-code-org/BMAD-METHOD', + skills: ['bmad', 'bmod-core-tools', 'bmod-method'], + label: 'bmad-code-org/BMAD-METHOD', + }) + assert.deepEqual(calls[1], { + source: 'bmad-code-org/bmad-module-creative-intelligence-suite', + skills: ['bmod-cis'], + label: 'BMad Creative Intelligence Suite', + }) +}) + +test('recordCalls leaves out records already in the project and drops empty calls', () => { + const chosen = chosenModules(orderedChoices([{ code: 'method', bundles: [] }, { code: 'cis', bundles: null }], modules), modules) + + const partial = recordCalls(chosen, 'bmad', ['bmad', 'bmod-core-tools']) + assert.deepEqual(partial.map((call) => call.skills), [['bmod-method'], ['bmod-cis']]) + + const onlyCis = recordCalls(chosen, 'bmad', ['bmad', 'bmod-core-tools', 'bmod-method']) + assert.deepEqual(onlyCis.map((call) => call.skills), [['bmod-cis']]) + + assert.deepEqual(recordCalls(chosen, 'bmad', ['bmad', 'bmod-core-tools', 'bmod-method', 'bmod-cis']), []) +}) + +test('memberCalls leaves out bundle skills that are already installed without calling them unknown', () => { + const report = statusOf() + report.modules[1].skills = ['bmad-prd'] + report.modules[1].absent_skills = report.modules[1].absent_skills.filter((name) => name !== 'bmad-prd') + const { calls, unknown } = memberCalls([{ code: 'method', bundles: ['planning'] }], modules, report) + + assert.deepEqual(unknown, []) + assert.equal(calls[0].skills.includes('bmad-prd'), false) + assert.ok(calls[0].skills.includes('bmad-product-brief')) +}) + +test('memberCalls takes the absent skills of a module without bundles', () => { + const { calls, unknown } = memberCalls([{ code: 'core-tools', bundles: null }], modules, statusOf()) + + assert.deepEqual(unknown, []) + assert.equal(calls.length, 1) + assert.equal(calls[0].label, 'BMad Core Tools') + assert.deepEqual(calls[0].skills, statusOf().modules[0].absent_skills) +}) + +test('memberCalls unions the chosen bundles and names what the record dropped', () => { + const report = statusOf() + report.modules[1].absent_skills = report.modules[1].absent_skills.filter((name) => name !== 'bmad-ux') + const { calls, unknown } = memberCalls([{ code: 'method', bundles: ['planning', 'agents'] }], modules, report) + + assert.deepEqual(unknown, [{ module: 'BMad Method', skills: ['bmad-ux'] }]) + assert.ok(calls[0].skills.includes('bmad-prd')) + assert.ok(calls[0].skills.includes('bmad-agent-dev')) + assert.equal(calls[0].skills.includes('bmad-build'), false) + assert.equal(calls[0].skills.includes('bmad-ux'), false) +}) + +test('memberCalls merges modules that share a source and skips empty ones', () => { + const report = statusOf() + report.modules[0].absent_skills = [] + const { calls } = memberCalls( + [{ code: 'core-tools', bundles: null }, { code: 'method', bundles: ['extras'] }], + modules, + report, + ) + + assert.equal(calls.length, 1) + assert.deepEqual(calls[0].skills, ['bmad-preview-ticketing', 'bmad-walkthrough']) + assert.equal(calls[0].label, 'BMad Method') +}) + +test('memberCalls ignores a chosen module the status does not know', () => { + assert.deepEqual(memberCalls([{ code: 'cis', bundles: null }], modules, statusOf()), { calls: [], unknown: [] }) +}) + +test('versionsByCode maps the status entries and tolerates no status', () => { + assert.deepEqual(versionsByCode(statusOf()), { 'core-tools': '6.13.0-next', method: '6.13.0-next' }) + assert.deepEqual(versionsByCode(null), {}) +}) + +test('selectMigrations keeps only chosen modules whose major actually moved', () => { + /** @param {Partial} patch @returns {Migration} */ + const migration = (patch) => ({ + module: 'method', path: 'm.toml', file: '/p/m.toml', from: '6', to: '7', title: 'Move', ...patch, + }) + const all = [ + migration({}), + migration({ from: '5' }), + migration({ module: 'cis' }), + migration({ module: 'core-tools' }), + ] + const kept = selectMigrations(all, { + codes: ['core-tools', 'method'], + before: { method: '6.13.0-next', 'core-tools': '6.13.0-next' }, + after: { method: '7.0.0', 'core-tools': '6.14.0' }, + }) + + assert.deepEqual(kept, [migration({})]) + assert.deepEqual(selectMigrations(all, { codes: ['method'], before: {}, after: { method: '7.0.0' } }), []) +}) diff --git a/test/commands/install.test.js b/test/commands/install.test.js new file mode 100644 index 0000000..da009c4 --- /dev/null +++ b/test/commands/install.test.js @@ -0,0 +1,169 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { install } from '../../src/commands/install.js' +import { + RECORDS, + fakeBmadScripts, + fakePrompts, + fakeSkillsCli, + listed, + makeDeps, + messages, + only, + options, + statusOf, + tempDir, +} from '../helpers/command-fakes.js' + +/** @typedef {import('../../src/bmad-scripts.js').Migration} Migration */ + +test('--yes takes the default modules and asks nothing', async (t) => { + const dir = await tempDir(t) + const { prompts, asked } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ lists: [[], RECORDS.map(listed)] }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await install( + options({ directory: dir, yes: true }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir }), + ) + + assert.equal(code, 0) + assert.deepEqual(asked, ['intro']) + const members = only(requests)[1] + assert.equal(members.agents, null) + for (const skill of ['bmad-prd', 'bmad-build', 'bmad-agent-dev', 'bmad-brainstorming']) { + assert.ok(members.skills.includes(skill), `missing ${skill}`) + } + assert.equal(members.skills.includes('bmad-walkthrough'), false) +}) + +test('a skipped skill is reported and turns the run into an exit 1', async (t) => { + const dir = await tempDir(t) + const { prompts, said } = fakePrompts() + const { cli } = fakeSkillsCli({ + lists: [[], RECORDS.map(listed)], + add: (request) => request.skills.map((/** @type {string} */ name) => ( + name === 'bmad-prd' + ? { name, status: 'skipped', reason: 'No matching skill found in source' } + : { name, status: 'installed' } + )), + }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await install( + options({ directory: dir, yes: true }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir }), + ) + + assert.equal(code, 1) + assert.ok(said.notes.some((note) => note.includes('Failed to install bmad-prd: No matching skill found in source'))) +}) + +test('the run stops when the first interactive install landed somewhere else', async (t) => { + const dir = await tempDir(t) + const { prompts, said } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ lists: [[], []] }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf()] }) + + const code = await install( + options({ directory: dir }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir, env: { XDG_STATE_HOME: join(dir, 'state') } }), + ) + + assert.equal(code, 1) + assert.deepEqual(said.fails, [messages.firstInstallNotInProject.replace('{directory}', dir)]) + assert.equal(only(requests).length, 0) +}) + +test('a record call that failed is explained instead of blamed on the scope', async (t) => { + const dir = await tempDir(t) + const { prompts, said } = fakePrompts() + const { cli } = fakeSkillsCli({ + lists: [[], []], + add: () => [{ status: 'failed', error: 'Failed to clone repository' }], + }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf()] }) + + const code = await install( + options({ directory: dir, yes: true }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir }), + ) + + assert.equal(code, 1) + assert.equal(said.fails.length, 1) + assert.ok(said.fails[0].includes('Failed to clone repository')) + assert.equal(said.fails[0].includes('Project scope'), false) +}) + +test('a bundle skill the record no longer lists is named and skipped', async (t) => { + const dir = await tempDir(t) + const thinned = statusOf() + thinned.modules[1].absent_skills = thinned.modules[1].absent_skills.filter((name) => name !== 'bmad-prd') + const { prompts, said } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ lists: [[], RECORDS.map(listed)] }) + const { scripts } = fakeBmadScripts({ statuses: [thinned, thinned] }) + + const code = await install( + options({ directory: dir, tools: 'claude-code' }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir }), + ) + + assert.equal(code, 0) + assert.ok(said.warns.some((warning) => warning === 'Skipped skills that BMad Method no longer lists: bmad-prd')) + assert.equal(only(requests)[1].skills.includes('bmad-prd'), false) +}) + +test('migrations are listed only for the major jump this run made', async (t) => { + const dir = await tempDir(t) + const before = statusOf() + const after = statusOf() + after.modules[1].version = '7.0.0' + /** @param {Partial} patch @returns {Migration} */ + const migration = (patch) => ({ + module: 'method', path: 'm.toml', file: '/p/m.toml', from: '6', to: '7', title: 'Move v6 artifacts', ...patch, + }) + const { prompts, said } = fakePrompts() + const { cli } = fakeSkillsCli({ lists: [[listed('bmad'), listed('bmod-method')], RECORDS.map(listed)] }) + const { scripts } = fakeBmadScripts({ + statuses: [before, after, after], + migrations: [ + migration({}), + migration({ from: '5', to: '6', title: 'Older jump' }), + migration({ module: 'cis', title: 'Not chosen' }), + ], + }) + + const code = await install( + options({ directory: dir, tools: 'claude-code' }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir }), + ) + + assert.equal(code, 0) + const listedMigrations = said.notes.find((note) => note.startsWith(messages.migrationsAvailable)) + assert.ok(listedMigrations) + assert.deepEqual(listedMigrations.split('\n'), [ + messages.migrationsAvailable, + 'Move v6 artifacts (method, 6 to 7)', + ]) + assert.ok(said.says.some((line) => line.startsWith(messages.foundModules))) +}) + +test('choosing quick update on an existing install runs the update flow', async (t) => { + const dir = await tempDir(t) + const { prompts, said, asked } = fakePrompts({ existing: 'update' }) + const { cli, requests } = fakeSkillsCli({ lists: [[listed('bmad'), listed('bmod-method')]] }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await install( + options({ directory: dir }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, cwd: dir }), + ) + + assert.equal(code, 0) + assert.ok(asked.includes('existing')) + assert.equal(asked.includes('modules'), false) + assert.equal(only(requests, 'update').length, 1) + assert.deepEqual(said.says.at(-1), messages.updateSpinnerDone) +}) diff --git a/test/commands/status.test.js b/test/commands/status.test.js new file mode 100644 index 0000000..0737104 --- /dev/null +++ b/test/commands/status.test.js @@ -0,0 +1,66 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { status } from '../../src/commands/status.js' +import { PreflightError } from '../../src/preflight.js' +import { + fakeBmadScripts, + fakePrompts, + fakeRunner, + fakeSkillsCli, + listed, + makeDeps, + messages, + options, + statusOf, +} from '../helpers/command-fakes.js' + +/** @typedef {import('../../src/bmad-scripts.js').StatusReport} StatusReport */ +/** @typedef {import('../../src/skills-cli.js').ListedSkill} ListedSkill */ + +/** @param {{ uv?: boolean, lists: ListedSkill[][], report?: StatusReport }} script */ +function statusDeps(script) { + const { prompts, said } = fakePrompts() + const { runner, calls } = fakeRunner({ uv: script.uv }) + const { cli } = fakeSkillsCli({ lists: script.lists }) + const { scripts } = fakeBmadScripts({ statuses: [script.report ?? statusOf()] }) + return { deps: makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts, runner }), said, calls } +} + +/** @param {Partial} [patch] */ +function statusOptions(patch = {}) { + return options({ command: 'status', yes: true, ...patch }) +} + +test('a missing uv stops the status before anything is read', async () => { + const { deps } = statusDeps({ uv: false, lists: [[listed('bmad')]] }) + await assert.rejects(() => status(statusOptions(), deps), PreflightError) +}) + +test('nothing installed without the bmad skill', async () => { + const { deps, said } = statusDeps({ lists: [[listed('bmad-prd')]] }) + + assert.equal(await status(statusOptions(), deps), 1) + assert.deepEqual(said.fails, [messages.nothingInstalled]) + assert.deepEqual(said.notes, []) +}) + +test('an unfinished install prints the report and exits 1', async () => { + const { deps, said, calls } = statusDeps({ lists: [[listed('bmad'), listed('bmod-method')]] }) + + assert.equal(await status(statusOptions(), deps), 1) + assert.equal(said.notes.length, 1) + const lines = said.notes[0].split('\n') + assert.equal(lines[0], messages.reportTitle) + assert.equal(lines[1], 'Not finished yet. Next: bmad setup') + assert.ok(lines.some((line) => line.startsWith('BMad Method 6.13.0-next'))) + assert.deepEqual(calls, [['uv', '--version']]) + assert.deepEqual(said.finish, []) +}) + +test('a finished install exits 0', async () => { + const report = statusOf({ current: true, next: null }) + const { deps, said } = statusDeps({ lists: [[listed('bmad')]], report }) + + assert.equal(await status(statusOptions(), deps), 0) + assert.ok(said.notes[0].includes(messages.reportCurrent)) +}) diff --git a/test/commands/update.test.js b/test/commands/update.test.js new file mode 100644 index 0000000..adf9a4d --- /dev/null +++ b/test/commands/update.test.js @@ -0,0 +1,84 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { resolve } from 'node:path' +import { update } from '../../src/commands/update.js' +import { + fakeBmadScripts, + fakePrompts, + fakeSkillsCli, + listed, + makeDeps, + messages, + only, + options, + statusOf, +} from '../helpers/command-fakes.js' + +/** @param {Partial} [patch] */ +function updateOptions(patch = {}) { + return options({ command: 'update', yes: true, ...patch }) +} + +test('nothing to update without the bmad skill', async () => { + const { prompts, said } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ lists: [[listed('bmad-prd')]] }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf()] }) + + const code = await update(updateOptions(), makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts })) + + assert.equal(code, 1) + assert.deepEqual(said.fails, [messages.nothingToUpdate]) + assert.equal(only(requests, 'update').length, 0) +}) + +test('the update runs the CLI, then reports and closes', async () => { + const { prompts, said } = fakePrompts() + const { cli, requests } = fakeSkillsCli({ lists: [[listed('bmad'), listed('bmod-method')]] }) + const { scripts, seen } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await update( + updateOptions({ directory: '/project' }), + makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts }), + ) + + assert.equal(code, 0) + assert.deepEqual(only(requests, 'update'), [{ kind: 'update', cwd: resolve('/project') }]) + assert.deepEqual(seen, ['/project/.agents/skills/bmad', '/project/.agents/skills/bmad']) + assert.deepEqual(said.says, [messages.updateSpinnerDone]) + assert.ok(said.notes.some((note) => note.startsWith(messages.reportTitle))) + assert.ok(said.notes.some((note) => note.includes('ask the `bmad` skill what to do next'))) + assert.ok(said.notes.at(-1)?.includes('bmad setup')) + assert.deepEqual(said.finish, ['']) +}) + +test('a failing skills CLI still reports and exits 1', async () => { + const { prompts, said } = fakePrompts() + const { cli } = fakeSkillsCli({ lists: [[listed('bmad')]], updateCode: 1 }) + const { scripts } = fakeBmadScripts({ statuses: [statusOf(), statusOf()] }) + + const code = await update(updateOptions(), makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts })) + + assert.equal(code, 1) + assert.ok(said.notes.some((note) => note.startsWith(messages.reportTitle))) +}) + +test('migrations compare the versions before and after the update', async () => { + const before = statusOf() + const after = statusOf() + after.modules[1].version = '7.0.0' + const { prompts, said } = fakePrompts() + const { cli } = fakeSkillsCli({ lists: [[listed('bmad'), listed('bmod-method')]] }) + const { scripts } = fakeBmadScripts({ + statuses: [before, after], + migrations: [ + { module: 'method', path: 'm.toml', file: '/p/m.toml', from: '6', to: '7', title: 'Move v6 artifacts' }, + { module: 'core-tools', path: 'c.toml', file: '/p/c.toml', from: '6', to: '7', title: 'No jump here' }, + ], + }) + + const code = await update(updateOptions(), makeDeps({ prompts, skillsCli: cli, bmadScripts: scripts })) + + assert.equal(code, 0) + const listedMigrations = said.notes.find((note) => note.startsWith(messages.migrationsAvailable)) + assert.deepEqual(listedMigrations?.split('\n'), [messages.migrationsAvailable, 'Move v6 artifacts (method, 6 to 7)']) +}) diff --git a/test/e2e/install.test.js b/test/e2e/install.test.js new file mode 100644 index 0000000..3df06b0 --- /dev/null +++ b/test/e2e/install.test.js @@ -0,0 +1,45 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRunner } from '../../src/run.js' +import { listArgv, parseListResults } from '../../src/skills-cli.js' + +const source = process.env.BMAD_INSTALLER_E2E_SOURCE +const bin = fileURLToPath(new URL('../../bin/bmad-method.js', import.meta.url)) +const TIMEOUT_MS = 600_000 +const EXPECTED = ['bmad', 'bmod-core-tools', 'bmod-method', 'bmad-prd'] + +test('a real headless install lands the records, the members and the closing message', { + skip: source ? false : 'set BMAD_INSTALLER_E2E_SOURCE to a local BMAD-METHOD checkout', + timeout: TIMEOUT_MS, +}, async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'bmad-e2e-')) + t.after(() => rm(directory, { recursive: true, force: true })) + + const runner = createRunner({ debug: Boolean(process.env.BMAD_INSTALLER_E2E_DEBUG) }) + const env = { ...process.env, BMAD_INSTALLER_SOURCE_OVERRIDE: source } + + const install = await runner({ + argv: [ + process.execPath, bin, 'install', + '--yes', + '--tools', 'claude-code', + '--modules', 'method:planning', + '--directory', directory, + '--no-telemetry', + ], + cwd: directory, + env, + timeoutMs: TIMEOUT_MS, + }) + + assert.equal(install.code, 0, `${install.stdout}\n${install.stderr}`) + assert.ok(install.stdout.includes('bmad setup'), install.stdout) + + const list = await runner({ argv: listArgv(), cwd: directory, env, timeoutMs: TIMEOUT_MS }) + const names = parseListResults(list.stdout).map((skill) => skill.name) + for (const name of EXPECTED) assert.ok(names.includes(name), `${name} is missing from ${names.join(', ')}`) +}) diff --git a/test/fixtures/add-json-mixed.json b/test/fixtures/add-json-mixed.json new file mode 100644 index 0000000..6ef8b6e --- /dev/null +++ b/test/fixtures/add-json-mixed.json @@ -0,0 +1,19 @@ +[ + { + "name": "bmad", + "status": "installed", + "source": "bmad-code-org/BMAD-METHOD", + "ref": null, + "hash": "4f1c9a2b7d3e5601", + "path": "/project/.agents/skills/bmad", + "scope": "project", + "agents": ["Claude Code", "Codex"], + "mode": "symlink", + "security": null + }, + { + "name": "bmod-nope", + "status": "skipped", + "reason": "No matching skill found in source" + } +] diff --git a/test/fixtures/knowledge-migrations.json b/test/fixtures/knowledge-migrations.json new file mode 100644 index 0000000..4888946 --- /dev/null +++ b/test/fixtures/knowledge-migrations.json @@ -0,0 +1,20 @@ +{ + "roots": ["/project/.agents/skills"], + "skills": [ + { "name": "bmad", "path": "/project/.agents/skills/bmad", "module": "core-tools" }, + { "name": "bmad-prd", "path": "/project/.agents/skills/bmad-prd", "module": "method" } + ], + "documents": [], + "topics": [], + "problems": [], + "migrations": [ + { + "module": "method", + "path": "v6-v7-migration.toml", + "file": "/project/.agents/skills/bmod-method/v6-v7-migration.toml", + "from": "6", + "to": "7", + "title": "Move v6 planning and implementation artifacts into the v7 initiative layout" + } + ] +} diff --git a/test/fixtures/knowledge-no-migrations.json b/test/fixtures/knowledge-no-migrations.json new file mode 100644 index 0000000..c73c6cf --- /dev/null +++ b/test/fixtures/knowledge-no-migrations.json @@ -0,0 +1,9 @@ +{ + "roots": ["/project/.agents/skills"], + "skills": [ + { "name": "bmad", "path": "/project/.agents/skills/bmad", "module": "core-tools" } + ], + "documents": [], + "topics": [], + "problems": [] +} diff --git a/test/fixtures/status-after-prd.json b/test/fixtures/status-after-prd.json new file mode 100644 index 0000000..ab9b484 --- /dev/null +++ b/test/fixtures/status-after-prd.json @@ -0,0 +1,76 @@ +{ + "mode": "status", + "module": null, + "bmad_exists": false, + "bmad": { "skill": "bmad", "version": "6.13.0-next", "module": "core-tools" }, + "shared_scripts": "missing", + "custom_gitignore": "missing", + "modules": [ + { + "module": "core-tools", + "folder": "bmod-core-tools", + "version": "6.13.0-next", + "update_source": "github:bmad-code-org/BMAD-METHOD/skills", + "skills": ["bmad"], + "absent_skills": [ + "bmad-advanced-elicitation", + "bmad-brainstorming", + "bmad-customize", + "bmad-deep-recon", + "bmad-forge-idea", + "bmad-party-mode", + "bmad-review" + ], + "scripts": "missing", + "update": { + "state": "current", + "source": "https://raw.githubusercontent.com/bmad-code-org/BMAD-METHOD/main/skills/bmod-core-tools/bmod.toml", + "source_version": "6.13.0-next" + } + }, + { + "module": "method", + "folder": "bmod-method", + "version": "6.13.0-next", + "update_source": "github:bmad-code-org/BMAD-METHOD/skills", + "skills": ["bmad-prd"], + "absent_skills": [ + "bmad-agent-analyst", + "bmad-agent-architect", + "bmad-agent-dev", + "bmad-agent-pm", + "bmad-agent-ux-designer", + "bmad-architecture", + "bmad-build", + "bmad-build-auto", + "bmad-code-review", + "bmad-correct-course", + "bmad-create-epics-and-stories", + "bmad-preview-ticketing", + "bmad-prfaq", + "bmad-product-brief", + "bmad-project-context", + "bmad-qa-generate-e2e-tests", + "bmad-retrospective", + "bmad-spec", + "bmad-sprint-planning", + "bmad-ux", + "bmad-walkthrough" + ], + "scripts": "missing", + "update": { + "state": "current", + "source": "https://raw.githubusercontent.com/bmad-code-org/BMAD-METHOD/main/skills/bmod-method/bmod.toml", + "source_version": "6.13.0-next" + } + } + ], + "missing_module_records": [], + "pending_questions": [], + "unmet_requirements": [], + "unmet_recommendations": [], + "problems": [], + "legacy_leftovers": [], + "current": false, + "next": "bmad setup" +} diff --git a/test/fixtures/status-fresh.json b/test/fixtures/status-fresh.json new file mode 100644 index 0000000..8b8004c --- /dev/null +++ b/test/fixtures/status-fresh.json @@ -0,0 +1,77 @@ +{ + "mode": "status", + "module": null, + "bmad_exists": false, + "bmad": { "skill": "bmad", "version": "6.13.0-next", "module": "core-tools" }, + "shared_scripts": "missing", + "custom_gitignore": "missing", + "modules": [ + { + "module": "core-tools", + "folder": "bmod-core-tools", + "version": "6.13.0-next", + "update_source": "github:bmad-code-org/BMAD-METHOD/skills", + "skills": ["bmad"], + "absent_skills": [ + "bmad-advanced-elicitation", + "bmad-brainstorming", + "bmad-customize", + "bmad-deep-recon", + "bmad-forge-idea", + "bmad-party-mode", + "bmad-review" + ], + "scripts": "missing", + "update": { + "state": "current", + "source": "https://raw.githubusercontent.com/bmad-code-org/BMAD-METHOD/main/skills/bmod-core-tools/bmod.toml", + "source_version": "6.13.0-next" + } + }, + { + "module": "method", + "folder": "bmod-method", + "version": "6.13.0-next", + "update_source": "github:bmad-code-org/BMAD-METHOD/skills", + "skills": [], + "absent_skills": [ + "bmad-agent-analyst", + "bmad-agent-architect", + "bmad-agent-dev", + "bmad-agent-pm", + "bmad-agent-ux-designer", + "bmad-architecture", + "bmad-build", + "bmad-build-auto", + "bmad-code-review", + "bmad-correct-course", + "bmad-create-epics-and-stories", + "bmad-prd", + "bmad-preview-ticketing", + "bmad-prfaq", + "bmad-product-brief", + "bmad-project-context", + "bmad-qa-generate-e2e-tests", + "bmad-retrospective", + "bmad-spec", + "bmad-sprint-planning", + "bmad-ux", + "bmad-walkthrough" + ], + "scripts": "missing", + "update": { + "state": "current", + "source": "https://raw.githubusercontent.com/bmad-code-org/BMAD-METHOD/main/skills/bmod-method/bmod.toml", + "source_version": "6.13.0-next" + } + } + ], + "missing_module_records": [], + "pending_questions": [], + "unmet_requirements": [], + "unmet_recommendations": [], + "problems": [], + "legacy_leftovers": [], + "current": false, + "next": "bmad setup" +} diff --git a/test/harness.test.js b/test/harness.test.js new file mode 100644 index 0000000..bde5d7c --- /dev/null +++ b/test/harness.test.js @@ -0,0 +1,144 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pickedAgents, skillsLockPath, snapshotLock } from '../src/harness.js' + +const OLD = new Date(1_000_000) +const NEW = new Date(2_000_000) + +/** @returns {Promise<{ dir: string, lockPath: string }>} */ +async function tempLock() { + const dir = await mkdtemp(join(tmpdir(), 'bmad-harness-')) + return { dir, lockPath: join(dir, '.skill-lock.json') } +} + +/** + * @param {string} lockPath + * @param {unknown} contents + * @param {Date} mtime + */ +async function writeLock(lockPath, contents, mtime) { + await writeFile(lockPath, typeof contents === 'string' ? contents : JSON.stringify(contents)) + await utimes(lockPath, mtime, mtime) +} + +test('skillsLockPath honours XDG_STATE_HOME', () => { + assert.equal( + skillsLockPath({ XDG_STATE_HOME: '/state' }, '/home/user'), + join('/state', 'skills', '.skill-lock.json'), + ) +}) + +test('skillsLockPath falls back to the home dir without XDG_STATE_HOME', () => { + assert.equal(skillsLockPath({}, '/home/user'), join('/home/user', '.agents', '.skill-lock.json')) + assert.equal( + skillsLockPath({ XDG_STATE_HOME: '' }, '/home/user'), + join('/home/user', '.agents', '.skill-lock.json'), + ) +}) + +test('snapshotLock reports a missing file and an existing one', async () => { + const { dir, lockPath } = await tempLock() + try { + assert.deepEqual(await snapshotLock(lockPath), { exists: false, mtimeMs: null }) + + await writeLock(lockPath, { version: 3, lastSelectedAgents: ['codex'] }, OLD) + assert.deepEqual(await snapshotLock(lockPath), { exists: true, mtimeMs: OLD.getTime() }) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('pickedAgents returns ids when the picker created the lock', async () => { + const { dir, lockPath } = await tempLock() + try { + const before = await snapshotLock(lockPath) + await writeLock(lockPath, { version: 3, lastSelectedAgents: ['claude-code', 'codex'] }, NEW) + assert.deepEqual(await pickedAgents(lockPath, before), ['claude-code', 'codex']) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('pickedAgents returns ids when the selection changed', async () => { + const { dir, lockPath } = await tempLock() + try { + await writeLock(lockPath, { version: 3, lastSelectedAgents: ['opencode'] }, OLD) + const before = await snapshotLock(lockPath) + await writeLock(lockPath, { version: 3, lastSelectedAgents: ['opencode', 'cursor'] }, NEW) + assert.deepEqual(await pickedAgents(lockPath, before), ['opencode', 'cursor']) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('pickedAgents returns ids when the user picked the same agents as last time', async () => { + const { dir, lockPath } = await tempLock() + try { + await writeLock(lockPath, { version: 3, lastSelectedAgents: ['cursor'] }, OLD) + const before = await snapshotLock(lockPath) + await writeLock( + lockPath, + { version: 3, lastSelectedAgents: ['cursor'], dismissed: { findSkillsPrompt: true } }, + NEW, + ) + assert.deepEqual(await pickedAgents(lockPath, before), ['cursor']) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('pickedAgents returns null when the lock was untouched', async () => { + const { dir, lockPath } = await tempLock() + try { + await writeLock(lockPath, { version: 3, lastSelectedAgents: ['claude-code'] }, OLD) + const before = await snapshotLock(lockPath) + assert.equal(await pickedAgents(lockPath, before), null) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('pickedAgents returns null when the lock still does not exist', async () => { + const { dir, lockPath } = await tempLock() + try { + const before = await snapshotLock(lockPath) + assert.equal(await pickedAgents(lockPath, before), null) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('pickedAgents returns null for an empty, missing or non-string selection', async () => { + const { dir, lockPath } = await tempLock() + try { + const before = await snapshotLock(lockPath) + + await writeLock(lockPath, { version: 3, lastSelectedAgents: [] }, NEW) + assert.equal(await pickedAgents(lockPath, before), null) + + await writeLock(lockPath, { version: 3, skills: {} }, NEW) + assert.equal(await pickedAgents(lockPath, before), null) + + await writeLock(lockPath, { version: 3, lastSelectedAgents: ['claude-code', 7] }, NEW) + assert.equal(await pickedAgents(lockPath, before), null) + + await writeLock(lockPath, { version: 3, lastSelectedAgents: 'claude-code' }, NEW) + assert.equal(await pickedAgents(lockPath, before), null) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('pickedAgents returns null when the lock is not JSON', async () => { + const { dir, lockPath } = await tempLock() + try { + const before = await snapshotLock(lockPath) + await writeLock(lockPath, 'not json', NEW) + assert.equal(await pickedAgents(lockPath, before), null) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/test/helpers/command-fakes.js b/test/helpers/command-fakes.js new file mode 100644 index 0000000..7e75a96 --- /dev/null +++ b/test/helpers/command-fakes.js @@ -0,0 +1,158 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadMessages } from '../../src/messages.js' +import { loadModules } from '../../src/modules.js' + +/** @typedef {import('../../src/bmad-scripts.js').Migration} Migration */ +/** @typedef {import('../../src/bmad-scripts.js').StatusReport} StatusReport */ +/** @typedef {import('../../src/cli.js').CliOptions} CliOptions */ +/** @typedef {import('../../src/commands/install.js').CommandDeps} CommandDeps */ +/** @typedef {import('../../src/run.js').Runner} Runner */ +/** @typedef {import('../../src/skills-cli.js').AddResult} AddResult */ +/** @typedef {import('../../src/skills-cli.js').ListedSkill} ListedSkill */ + +export const messages = await loadMessages() +export const modules = await loadModules() +export const RECORDS = ['bmad', 'bmod-core-tools', 'bmod-method'] + +const fresh = JSON.parse(await readFile(new URL('../fixtures/status-fresh.json', import.meta.url), 'utf8')) + +/** @param {Partial} [patch] @returns {StatusReport} */ +export function statusOf(patch = {}) { + return { ...structuredClone(fresh), ...patch } +} + +/** @param {string} name @returns {ListedSkill} */ +export function listed(name) { + return { name, path: `/project/.agents/skills/${name}`, scope: 'project', agents: ['Claude Code'], source: 'a/b' } +} + +/** @param {Record} [answers] */ +export function fakePrompts(answers = {}) { + /** @type {Record} */ + const said = { notes: [], says: [], warns: [], fails: [], finish: [] } + /** @type {string[]} */ + const asked = [] + const prompts = /** @type {any} */ ({ + showIntro: () => asked.push('intro'), + showNote: (/** @type {string} */ t) => said.notes.push(t), + say: (/** @type {string} */ t) => said.says.push(t), + warn: (/** @type {string} */ t) => said.warns.push(t), + fail: (/** @type {string} */ t) => said.fails.push(t), + finish: (/** @type {string} */ t) => said.finish.push(t), + startSpinner: () => ({ stop: () => {} }), + askDirectory: async (/** @type {any} */ _m, /** @type {string} */ d) => (asked.push('directory'), d), + confirm: async () => (asked.push('confirm'), answers.confirm ?? true), + chooseModules: async () => (asked.push('modules'), answers.modules ?? ['method']), + chooseBundles: async () => (asked.push('bundles'), answers.bundles ?? ['planning']), + chooseExistingAction: async () => (asked.push('existing'), answers.existing ?? 'modify'), + }) + return { prompts, said, asked } +} + +/** + * @param {{ + * lists: ListedSkill[][], + * add?: (request: any) => AddResult[], + * onInteractive?: () => Promise, + * updateCode?: number + * }} script + */ +export function fakeSkillsCli(script) { + /** @type {any[]} */ + const requests = [] + let listIndex = 0 + const cli = /** @type {any} */ ({ + async addInteractive(/** @type {any} */ request) { + requests.push({ kind: 'interactive', ...request }) + await script.onInteractive?.() + return { code: 0 } + }, + async add(/** @type {any} */ request) { + requests.push({ kind: 'add', ...request }) + if (script.add) return script.add(request) + return request.skills.map((/** @type {string} */ name) => ({ name, status: 'installed' })) + }, + async update(/** @type {any} */ options) { + requests.push({ kind: 'update', ...options }) + return { code: script.updateCode ?? 0 } + }, + async list(/** @type {any} */ options) { + requests.push({ kind: 'list', ...options }) + const result = script.lists[Math.min(listIndex, script.lists.length - 1)] + listIndex += 1 + return result + }, + }) + return { cli, requests } +} + +/** @param {{ statuses: StatusReport[], migrations?: Migration[] }} script */ +export function fakeBmadScripts(script) { + let index = 0 + /** @type {string[]} */ + const seen = [] + const scripts = /** @type {any} */ ({ + async status(/** @type {string} */ bmadSkillDir) { + seen.push(bmadSkillDir) + const report = script.statuses[Math.min(index, script.statuses.length - 1)] + index += 1 + return report + }, + async migrations() { + return script.migrations ?? [] + }, + }) + return { scripts, seen } +} + +/** @param {{ uv?: boolean }} [script] */ +export function fakeRunner(script = {}) { + /** @type {string[][]} */ + const calls = [] + /** @type {Runner} */ + const runner = async (request) => { + calls.push(request.argv) + const ok = request.argv[0] === 'uv' && script.uv !== false + return { code: ok ? 0 : 1, stdout: ok ? 'uv 0.5.31\n' : '', stderr: '', timedOut: false } + } + return { runner, calls } +} + +/** + * @param {{ prompts: any, skillsCli: any, bmadScripts: any, cwd?: string, env?: NodeJS.ProcessEnv, runner?: Runner }} parts + * @returns {CommandDeps} + */ +export function makeDeps(parts) { + return /** @type {CommandDeps} */ ({ + runner: parts.runner ?? fakeRunner().runner, + skillsCli: parts.skillsCli, + bmadScripts: parts.bmadScripts, + messages, + modules, + prompts: parts.prompts, + env: parts.env ?? {}, + cwd: parts.cwd ?? '/project', + version: '6.13.0-next.0', + terminal: () => true, + agent: async () => null, + }) +} + +/** @param {Partial} [patch] @returns {CliOptions} */ +export function options(patch = {}) { + return { command: 'install', yes: false, telemetry: true, copy: false, debug: false, ...patch } +} + +/** @param {any[]} requests @param {string} [kind] */ +export function only(requests, kind = 'add') { + return requests.filter((request) => request.kind === kind) +} + +/** @param {import('node:test').TestContext} t */ +export async function tempDir(t) { + const dir = await mkdtemp(join(tmpdir(), 'bmad-install-')) + t.after(() => rm(dir, { recursive: true, force: true })) + return dir +} diff --git a/test/installed.test.js b/test/installed.test.js new file mode 100644 index 0000000..3c0b295 --- /dev/null +++ b/test/installed.test.js @@ -0,0 +1,108 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + BMAD_SKILL, + RECORD_PREFIX, + hasSkills, + inspectInstall, + moduleCodeFromRecord, +} from '../src/installed.js' + +/** @typedef {import('../src/skills-cli.js').ListedSkill} ListedSkill */ + +/** + * @param {string} name + * @param {string} path + * @returns {ListedSkill} + */ +function listed(name, path) { + return { name, path, scope: 'project', agents: ['Claude Code'], source: 'bmad-code-org/BMAD-METHOD' } +} + +/** @param {ListedSkill[]} skills */ +function fakeSkillsCli(skills) { + /** @type {{ cwd: string }[]} */ + const calls = [] + return { + calls, + /** @param {{ cwd: string }} options */ + async list(options) { + calls.push(options) + return skills + }, + } +} + +async function tempDir() { + return await mkdtemp(join(tmpdir(), 'bmad-installed-')) +} + +test('inspectInstall finds the bmad skill directory and module codes', async () => { + const dir = await tempDir() + const skillsCli = fakeSkillsCli([ + listed(BMAD_SKILL, join(dir, '.agents', 'skills', 'bmad')), + listed('bmod-core-tools', join(dir, '.agents', 'skills', 'bmod-core-tools')), + listed('bmod-method', join(dir, '.agents', 'skills', 'bmod-method')), + listed('bmad-prd', join(dir, '.agents', 'skills', 'bmad-prd')), + ]) + + const state = await inspectInstall(dir, skillsCli) + + assert.equal(state.bmadSkillDir, join(dir, '.agents', 'skills', 'bmad')) + assert.deepEqual(state.moduleCodes, ['core-tools', 'method']) + assert.equal(state.skills.length, 4) + assert.equal(state.legacyManifest, false) + assert.deepEqual(skillsCli.calls, [{ cwd: dir }]) + + await rm(dir, { recursive: true, force: true }) +}) + +test('inspectInstall reports no bmad skill when it is absent', async () => { + const dir = await tempDir() + const state = await inspectInstall(dir, fakeSkillsCli([listed('bmod-cis', join(dir, 'bmod-cis'))])) + + assert.equal(state.bmadSkillDir, null) + assert.deepEqual(state.moduleCodes, ['cis']) + + await rm(dir, { recursive: true, force: true }) +}) + +test('inspectInstall detects a legacy manifest', async () => { + const dir = await tempDir() + await mkdir(join(dir, '_bmad', '_config'), { recursive: true }) + await writeFile(join(dir, '_bmad', '_config', 'manifest.yaml'), 'modules: []\n') + + const state = await inspectInstall(dir, fakeSkillsCli([])) + + assert.equal(state.legacyManifest, true) + assert.deepEqual(state.skills, []) + assert.deepEqual(state.moduleCodes, []) + + await rm(dir, { recursive: true, force: true }) +}) + +test('moduleCodeFromRecord only accepts prefixed names with a code', () => { + assert.equal(RECORD_PREFIX, 'bmod-') + assert.equal(moduleCodeFromRecord('bmod-method'), 'method') + assert.equal(moduleCodeFromRecord('bmod-core-tools'), 'core-tools') + assert.equal(moduleCodeFromRecord('bmod-'), null) + assert.equal(moduleCodeFromRecord('bmad'), null) + assert.equal(moduleCodeFromRecord('bmad-prd'), null) +}) + +test('hasSkills checks every requested name', () => { + /** @type {import('../src/installed.js').InstalledState} */ + const state = { + skills: [listed('bmad', '/s/bmad'), listed('bmod-method', '/s/bmod-method')], + bmadSkillDir: '/s/bmad', + moduleCodes: ['method'], + legacyManifest: false, + } + + assert.equal(hasSkills(state, ['bmad', 'bmod-method']), true) + assert.equal(hasSkills(state, []), true) + assert.equal(hasSkills(state, ['bmad', 'bmad-prd']), false) +}) diff --git a/test/messages.test.js b/test/messages.test.js new file mode 100644 index 0000000..c022c2d --- /dev/null +++ b/test/messages.test.js @@ -0,0 +1,185 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadMessages, message, render, MessagesError } from '../src/messages.js' + +const messagesPath = fileURLToPath(new URL('../messages.yaml', import.meta.url)) + +// clack's note() rewraps at the terminal width minus the box chrome: 74 columns at the default 80. +const NOTE_WIDTH = 74 + +/** @type {Record>} */ +const keysWithValues = { + intro: {}, + updateAvailable: { current: '6.13.0-next.0', latest: '6.13.0-next.1', tag: 'next' }, + nodeTooOld: { version: '20.11.0' }, + nodeBelowSkillsFloor: { version: '22.13.0' }, + uvMissing: { installCommand: 'curl -LsSf https://astral.sh/uv/install.sh | sh' }, + wslWindowsNode: {}, + needsTerminal: {}, + insideAgent: { agent: 'Claude Code' }, + directoryPrompt: {}, + directoryCreateConfirm: { directory: '/tmp/project' }, + directoryConfirm: { directory: '/tmp/project' }, + legacyManifest: {}, + existingInstallPrompt: {}, + existingInstallModify: {}, + existingInstallUpdate: {}, + foundModules: {}, + installedSuffix: { version: '6.13.0-next' }, + installedSuffixNoVersion: {}, + modulesPrompt: {}, + bundlesPrompt: { module: 'BMad Method' }, + deprecatedModule: { module: 'BMad Method', reason: 'Replaced by another module.' }, + beforeSkillsPicker: {}, + firstInstallNotInProject: { directory: '/tmp/project' }, + bundleSkillUnknown: { module: 'BMad Method', skills: 'bmad-old, bmad-older' }, + installingSpinner: { what: 'BMad Method' }, + statusSpinner: {}, + nothingToUpdate: {}, + nothingInstalled: {}, + updateSpinnerDone: {}, + migrationsAvailable: {}, + migrationLine: { title: 'Move v6 artifacts', module: 'method', from: '6', to: '7' }, + reportTitle: {}, + reportCurrent: {}, + reportNotCurrent: { next: 'bmad setup' }, + reportModule: { name: 'BMad Method', version: '6.13.0-next', count: 22 }, + reportAlsoAvailable: { skills: 'bmad-ux, bmad-spec', code: 'method' }, + reportMissingRecord: { install: 'npx skills add bmad-code-org/BMAD-METHOD -s bmod-method' }, + reportUnmet: { + skill: 'bmad-prd', + requires: 'bmad', + minimum: '6.13.0', + state: 'outdated', + install: 'npx skills add bmad-code-org/BMAD-METHOD -s bmad', + }, + reportPendingQuestions: { count: 3 }, + reportProblem: { message: 'Two records claim the same module.' }, + reportLegacy: { path: '_bmad/_config/manifest.yaml' }, + reportFailure: { skill: 'bmad-prd', error: 'No matching skill found in source' }, + closing: { directory: '/tmp/project' }, + cancelled: {}, + droppedUninstall: {}, +} + +const droppedFlagNames = [ + 'custom-source', + 'set', + 'list-options', + 'user-name', + 'communication-language', + 'document-output-language', + 'output-folder', + 'channel', + 'all-stable', + 'all-next', + 'next', + 'pin', + 'shims', + 'no-shims', + 'list-tools', +] + +const uvPlatforms = ['darwin', 'linux', 'win32'] + +const loaded = await loadMessages(messagesPath) + +test('every documented key renders with no placeholder left', () => { + for (const [key, values] of Object.entries(keysWithValues)) { + const text = message(loaded, key, values) + assert.ok(text.length > 0, `${key} is empty`) + assert.ok(!/\{\w+\}/.test(text), `${key} left a placeholder: ${text}`) + } +}) + +test('uvInstall has a command for every platform', () => { + for (const platform of uvPlatforms) { + const command = message(loaded, `uvInstall.${platform}`) + assert.ok(command.includes('astral.sh/uv'), `${platform} command is wrong: ${command}`) + } +}) + +test('droppedFlags has one line for every dropped flag', () => { + const dropped = loaded.droppedFlags + assert.deepEqual(Object.keys(dropped).sort(), [...droppedFlagNames].sort()) + for (const flag of droppedFlagNames) { + const line = message(loaded, `droppedFlags.${flag}`) + assert.ok(!line.includes('\n'), `${flag} is more than one line`) + assert.ok(!/\{\w+\}/.test(line), `${flag} left a placeholder`) + assert.ok(line.includes(`--${flag}`), `${flag} does not name the flag`) + } +}) + +test('closing names every route the user needs', () => { + const text = message(loaded, 'closing', { directory: '/tmp/project' }) + for (const needle of ['bmad setup', 'npx bmad-method update', 'npx skills update', 'npx skills remove']) { + assert.ok(text.includes(needle), `closing is missing: ${needle}`) + } + assert.ok(text.includes('/tmp/project')) + const index = text.indexOf('bmad setup') + assert.ok(index < text.indexOf('npx bmad-method update')) + assert.ok(text.indexOf('npx skills update') < text.indexOf('npx skills remove')) +}) + +test('the prose blocks fit the clack note box without rewrapping', () => { + const blocks = [ + 'intro', 'updateAvailable', 'nodeTooOld', 'nodeBelowSkillsFloor', 'uvMissing', 'wslWindowsNode', + 'needsTerminal', 'insideAgent', 'legacyManifest', 'beforeSkillsPicker', 'firstInstallNotInProject', + 'nothingInstalled', 'nothingToUpdate', 'cancelled', 'closing', 'droppedUninstall', + ] + for (const key of blocks) { + for (const line of message(loaded, key, keysWithValues[key]).split('\n')) { + assert.ok(line.length <= NOTE_WIDTH, `${key} line is ${line.length} columns: ${line}`) + } + } +}) + +test('closing reads in a terminal', () => { + const lines = message(loaded, 'closing', { directory: '/tmp/project' }).split('\n') + assert.ok(lines.some((line) => line === ''), 'closing has no blank line between groups') +}) + +test('intro is short and carries the links', () => { + const text = message(loaded, 'intro') + assert.ok(text.split('\n').length < 20) + for (const link of [ + 'https://docs.bmad-method.org', + 'https://github.com/bmad-code-org/BMAD-METHOD', + 'https://discord.gg/gk8jAdXWmj', + 'https://www.youtube.com/@BMadCode', + 'https://x.com/BMadCode', + 'https://buymeacoffee.com/bmad', + 'contact@bmadcode.com', + ]) { + assert.ok(text.includes(link), `intro is missing: ${link}`) + } +}) + +test('render fills placeholders and throws when a value is missing', () => { + assert.equal(render('hello {name}', { name: 'BMad' }), 'hello BMad') + assert.equal(render('count {n}', { n: 4 }), 'count 4') + assert.equal(render('no placeholders'), 'no placeholders') + assert.throws(() => render('hello {name}'), MessagesError) + assert.throws(() => render('hello {name}', { other: 'x' }), MessagesError) +}) + +test('message throws on an unknown key', () => { + assert.throws(() => message(loaded, 'notAKey'), MessagesError) + assert.throws(() => message(loaded, 'uvInstall'), MessagesError) + assert.throws(() => message(loaded, 'droppedFlags.not-a-flag'), MessagesError) +}) + +test('loadMessages rejects a missing file and a file that is not a mapping', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bmad-messages-')) + const sequence = join(dir, 'sequence.yaml') + await writeFile(sequence, '- one\n- two\n') + + await assert.rejects(() => loadMessages(join(dir, 'missing.yaml')), MessagesError) + await assert.rejects(() => loadMessages(sequence), MessagesError) + + await rm(dir, { recursive: true, force: true }) +}) diff --git a/test/modules-validation.test.js b/test/modules-validation.test.js new file mode 100644 index 0000000..126ee14 --- /dev/null +++ b/test/modules-validation.test.js @@ -0,0 +1,115 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ModulesError, findModule, groupBySource, loadModules } from '../src/modules.js' + +const SOURCE_OVERRIDE = 'BMAD_INSTALLER_SOURCE_OVERRIDE' + +/** @type {string[]} */ +const tempDirs = [] + +/** + * @param {string} yaml + * @returns {Promise} + */ +async function writeModulesFile(yaml) { + const dir = await mkdtemp(join(tmpdir(), 'bmad-modules-')) + tempDirs.push(dir) + const filePath = join(dir, 'modules.yaml') + await writeFile(filePath, yaml, 'utf8') + return filePath +} + +/** + * @param {string} yaml + * @param {string} reason + */ +async function assertInvalid(yaml, reason) { + const filePath = await writeModulesFile(yaml) + await assert.rejects( + loadModules(filePath), + (error) => error instanceof ModulesError && error.message.includes(reason), + ) +} + +after(async () => { + for (const dir of tempDirs) await rm(dir, { recursive: true, force: true }) +}) + +const VALID = ` +modules: + - code: one + name: One + description: First + source: owner/repo + record: bmod-one + always: true + - code: two + aliases: [dos] + name: Two + description: Second + source: owner/other-repo + record: bmod-two + default: true + deprecated: use one instead + bundles: + - code: a + name: A + description: Bundle A + default: true + skills: [s1, s2] + - code: b + name: B + description: Bundle B + skills: [s3] +` + +test('findModule matches codes and aliases', async () => { + const modules = await loadModules(await writeModulesFile(VALID)) + assert.equal(findModule(modules, 'two')?.code, 'two') + assert.equal(findModule(modules, 'dos')?.code, 'two') + assert.equal(findModule(modules, 'nope'), undefined) +}) + +test('validation rejects malformed files', async () => { + await assertInvalid('modules: []', 'non-empty array') + await assertInvalid('other: 1', 'non-empty array') + await assertInvalid('modules:\n - name: One\n', 'code must be a non-empty string') + await assertInvalid( + 'modules:\n - code: one\n name: One\n description: d\n source: not-a-repo\n record: r\n', + 'owner/repo', + ) + await assertInvalid( + 'modules:\n - code: one\n name: One\n description: d\n source: o/r\n', + 'record must be a non-empty string', + ) + await assertInvalid(`${VALID}\n - code: one\n name: Dup\n description: d\n source: o/r\n record: r\n`, 'already used') + await assertInvalid(`${VALID}\n - code: dos\n name: Dup\n description: d\n source: o/r\n record: r\n`, 'already used') + await assertInvalid( + 'modules:\n - code: one\n name: One\n description: d\n source: o/r\n record: r\n bundles:\n - code: a\n name: A\n description: d\n skills: []\n', + 'skills must be a non-empty array', + ) + await assertInvalid( + 'modules:\n - code: one\n name: One\n description: d\n source: o/r\n record: r\n bundles:\n - code: a\n name: A\n description: d\n skills: [s]\n - code: a\n name: A2\n description: d\n skills: [t]\n', + 'bundle codes must be unique', + ) +}) + +test('the source override replaces every source after validation', async () => { + const previous = process.env[SOURCE_OVERRIDE] + process.env[SOURCE_OVERRIDE] = '/tmp/local-checkout' + try { + const modules = await loadModules() + assert.deepEqual(new Set(modules.map((module) => module.source)), new Set(['/tmp/local-checkout'])) + assert.equal(groupBySource(modules).size, 1) + await assertInvalid( + 'modules:\n - code: one\n name: One\n description: d\n source: nope\n record: r\n', + 'owner/repo', + ) + } finally { + if (previous === undefined) delete process.env[SOURCE_OVERRIDE] + else process.env[SOURCE_OVERRIDE] = previous + } +}) diff --git a/test/modules.test.js b/test/modules.test.js new file mode 100644 index 0000000..68e2862 --- /dev/null +++ b/test/modules.test.js @@ -0,0 +1,181 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + ModulesError, + loadModules, + findModule, + parseModulesFlag, + defaultChoices, + bundleCodes, + skillsToInstall, + groupBySource, +} from '../src/modules.js' + +const METHOD_RECORD_SKILLS = [ + 'bmad-agent-analyst', 'bmad-agent-architect', 'bmad-agent-dev', 'bmad-agent-pm', + 'bmad-agent-ux-designer', 'bmad-architecture', 'bmad-build', 'bmad-build-auto', + 'bmad-code-review', 'bmad-correct-course', 'bmad-create-epics-and-stories', 'bmad-prd', + 'bmad-preview-ticketing', 'bmad-prfaq', 'bmad-product-brief', 'bmad-project-context', + 'bmad-qa-generate-e2e-tests', 'bmad-retrospective', 'bmad-spec', 'bmad-sprint-planning', + 'bmad-ux', 'bmad-walkthrough', +] + +/** @type {string[]} */ +const tempDirs = [] + +/** + * @param {string} yaml + * @returns {Promise} + */ +async function writeModulesFile(yaml) { + const dir = await mkdtemp(join(tmpdir(), 'bmad-modules-')) + tempDirs.push(dir) + const filePath = join(dir, 'modules.yaml') + await writeFile(filePath, yaml, 'utf8') + return filePath +} + +/** + * @param {import('../src/modules.js').ModuleDefinition[]} modules + * @param {string} code + */ +function must(modules, code) { + const module = findModule(modules, code) + assert.ok(module, `expected module ${code}`) + return module +} + +after(async () => { + for (const dir of tempDirs) await rm(dir, { recursive: true, force: true }) +}) + +test('loads and validates the real modules.yaml', async () => { + const modules = await loadModules() + assert.deepEqual(modules.map((module) => module.code), ['core-tools', 'method', 'cis']) + assert.equal(must(modules, 'core-tools').always, true) + assert.equal(must(modules, 'core-tools').bundles, undefined) + const method = must(modules, 'method') + assert.deepEqual(method.aliases, ['bmm']) + assert.equal(method.default, true) + assert.equal(method.record, 'bmod-method') + assert.equal(method.source, 'bmad-code-org/BMAD-METHOD') + assert.ok(method.message && method.message.length > 0) + assert.deepEqual(bundleCodes(method, () => true), ['planning', 'build', 'agents', 'extras']) + assert.equal(must(modules, 'cis').bundles, undefined) +}) + +test('the method bundles cover the record skill list exactly', async () => { + const method = must(await loadModules(), 'method') + const all = bundleCodes(method, () => true) + const { install, unknown } = skillsToInstall(method, all, METHOD_RECORD_SKILLS) + assert.deepEqual(unknown, []) + assert.deepEqual([...install].sort(), [...METHOD_RECORD_SKILLS].sort()) + assert.equal(install.length, METHOD_RECORD_SKILLS.length) +}) + +test('parseModulesFlag reads the code:bundle+bundle,code grammar', async () => { + const modules = await loadModules() + assert.deepEqual(parseModulesFlag('method:planning+build,cis', modules), [ + { code: 'method', bundles: ['planning', 'build'] }, + { code: 'cis', bundles: null }, + ]) + assert.deepEqual(parseModulesFlag(' method : planning + extras , cis ', modules), [ + { code: 'method', bundles: ['planning', 'extras'] }, + { code: 'cis', bundles: null }, + ]) + assert.deepEqual(parseModulesFlag('bmm', modules), [ + { code: 'method', bundles: ['planning', 'build', 'agents'] }, + ]) + assert.deepEqual(parseModulesFlag('cis,', modules), [{ code: 'cis', bundles: null }]) + assert.deepEqual(parseModulesFlag('method:planning,method:build+planning', modules), [ + { code: 'method', bundles: ['planning', 'build'] }, + ]) + assert.deepEqual(parseModulesFlag('', modules), []) +}) + +test('parseModulesFlag names what it cannot resolve', async () => { + const modules = await loadModules() + assert.throws( + () => parseModulesFlag('method,nope', modules), + (error) => error instanceof ModulesError && error.message.includes('"nope"'), + ) + assert.throws( + () => parseModulesFlag('method:planning+nope', modules), + (error) => error instanceof ModulesError && error.message.includes('"nope"') && error.message.includes('"method"'), + ) + assert.throws( + () => parseModulesFlag('cis:planning', modules), + (error) => error instanceof ModulesError && error.message.includes('"planning"'), + ) +}) + +const ONE_DEFAULT_MODULE = ` +modules: + - code: two + name: Two + description: Second + source: owner/repo + record: bmod-two + default: true + bundles: + - code: a + name: A + description: Bundle A + default: true + skills: [s1] + - code: b + name: B + description: Bundle B + skills: [s2] +` + +test('defaultChoices takes default modules with their default bundles', async () => { + assert.deepEqual(defaultChoices(await loadModules()), [ + { code: 'method', bundles: ['planning', 'build', 'agents'] }, + ]) + const other = await loadModules(await writeModulesFile(ONE_DEFAULT_MODULE)) + assert.deepEqual(defaultChoices(other), [{ code: 'two', bundles: ['a'] }]) +}) + +test('bundleCodes filters', async () => { + const method = must(await loadModules(), 'method') + assert.deepEqual(bundleCodes(method, (bundle) => bundle.default === true), ['planning', 'build', 'agents']) + assert.deepEqual(bundleCodes(method, (bundle) => bundle.default !== true), ['extras']) +}) + +test('skillsToInstall without bundles takes the whole record list', async () => { + const modules = await loadModules() + const cis = must(modules, 'cis') + const record = ['bmad-cis-storytelling', 'bmad-cis-design-thinking'] + assert.deepEqual(skillsToInstall(cis, null, record), { install: record, unknown: [] }) + assert.deepEqual(skillsToInstall(must(modules, 'method'), null, record), { install: record, unknown: [] }) +}) + +test('skillsToInstall keeps module order and reports unknown skills', async () => { + const method = must(await loadModules(), 'method') + const { install, unknown } = skillsToInstall(method, ['build', 'planning'], METHOD_RECORD_SKILLS) + assert.deepEqual(install.slice(0, 7), [ + 'bmad-product-brief', 'bmad-prfaq', 'bmad-prd', 'bmad-ux', + 'bmad-architecture', 'bmad-spec', 'bmad-project-context', + ]) + assert.equal(install[7], 'bmad-create-epics-and-stories') + assert.deepEqual(unknown, []) + + const partial = skillsToInstall(method, ['extras'], ['bmad-walkthrough']) + assert.deepEqual(partial.install, ['bmad-walkthrough']) + assert.deepEqual(partial.unknown, ['bmad-preview-ticketing']) +}) + +test('groupBySource keeps modules.yaml order', async () => { + const modules = await loadModules() + const groups = groupBySource(modules) + assert.deepEqual([...groups.keys()], [ + 'bmad-code-org/BMAD-METHOD', + 'bmad-code-org/bmad-module-creative-intelligence-suite', + ]) + assert.deepEqual(groups.get('bmad-code-org/BMAD-METHOD')?.map((module) => module.code), ['core-tools', 'method']) + assert.deepEqual(groups.get('bmad-code-org/bmad-module-creative-intelligence-suite')?.map((module) => module.code), ['cis']) +}) diff --git a/test/preflight.test.js b/test/preflight.test.js new file mode 100644 index 0000000..40b315a --- /dev/null +++ b/test/preflight.test.js @@ -0,0 +1,178 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + PreflightError, + hasTerminal, + nodeVersionState, + runPreflight, + uvVersion, + wslRunningWindowsNode, +} from '../src/preflight.js' + +/** @typedef {import('../src/run.js').RunRequest} RunRequest */ +/** @typedef {import('../src/run.js').RunResult} RunResult */ +/** @typedef {import('../src/run.js').Runner} Runner */ + +const messages = { + nodeTooOld: 'Node {version} is too old.', + nodeBelowSkillsFloor: 'Node {version} is below the floor.', + wslWindowsNode: 'Windows Node was launched from a WSL shell.', + uvMissing: 'uv is missing. Install it with {installCommand}', + uvInstall: { darwin: 'brew install uv', linux: 'curl uv', win32: 'irm uv' }, + needsTerminal: 'This needs a terminal.', + insideAgent: 'Running inside {agent}.', +} + +/** + * @param {Partial} result + * @param {RunRequest[]} [calls] + * @returns {Runner} + */ +function fakeRunner(result, calls = []) { + return async (request) => { + calls.push(request) + return { code: 0, stdout: '', stderr: '', timedOut: false, ...result } + } +} + +/** @returns {Runner} */ +function uvOk() { + return fakeRunner({ code: 0, stdout: 'uv 0.5.31\n' }) +} + +test('nodeVersionState maps versions to states', () => { + /** @type {Array<[string, 'ok' | 'warn' | 'too-old']>} */ + const table = [ + ['18.20.8', 'too-old'], + ['20.11.1', 'too-old'], + ['21.9.0', 'too-old'], + ['22.0.0', 'warn'], + ['22.13.0', 'warn'], + ['22.19.99', 'warn'], + ['22.20.0', 'ok'], + ['22.21.1', 'ok'], + ['23.1.0', 'ok'], + ['v24.0.0', 'ok'], + ] + for (const [version, expected] of table) { + assert.equal(nodeVersionState(version), expected, version) + } +}) + +test('nodeVersionState defaults to the running node', () => { + assert.equal(nodeVersionState(), nodeVersionState(process.versions.node)) +}) + +test('wslRunningWindowsNode follows the WSL rules', () => { + const base = { platform: 'win32', env: {}, cwd: 'C:\\project', execPath: 'C:\\Program Files\\nodejs\\node.exe' } + /** @type {Array<[string, { platform: string, env: NodeJS.ProcessEnv, cwd: string, execPath: string }, boolean]>} */ + const table = [ + ['plain windows', base, false], + ['distro name', { ...base, env: { WSL_DISTRO_NAME: 'Ubuntu' } }, true], + ['interop socket', { ...base, env: { WSL_INTEROP: '/run/WSL/12_interop' } }, true], + ['linux PWD', { ...base, env: { PWD: '/home/me/project' } }, true], + ['windows PWD', { ...base, env: { PWD: 'C:\\project' } }, false], + ['unc wsl$ cwd', { ...base, cwd: '\\\\wsl$\\Ubuntu\\home\\me\\project' }, true], + ['unc wsl.localhost cwd', { ...base, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me' }, true], + ['unc wsl cwd', { ...base, cwd: '\\\\wsl\\Ubuntu\\home\\me' }, true], + ['other unc cwd', { ...base, cwd: '\\\\server\\share\\project' }, false], + ['wsl$ execPath', { ...base, execPath: '\\\\wsl$\\Ubuntu\\usr\\bin\\node' }, true], + ['wsl.localhost execPath', { ...base, execPath: '\\\\wsl.localhost\\Ubuntu\\usr\\bin\\node' }, true], + ['darwin with wsl env', { ...base, platform: 'darwin', env: { WSL_DISTRO_NAME: 'Ubuntu' } }, false], + ['linux with wsl env', { ...base, platform: 'linux', env: { WSL_INTEROP: '/run/WSL/12_interop' } }, false], + ] + for (const [name, input, expected] of table) { + assert.equal(wslRunningWindowsNode(input), expected, name) + } +}) + +test('uvVersion parses the version and asks uv once', async () => { + /** @type {RunRequest[]} */ + const calls = [] + const runner = fakeRunner({ code: 0, stdout: 'uv 0.5.31 (abc1234 2026-01-01)\n' }, calls) + assert.equal(await uvVersion(runner), '0.5.31') + assert.equal(calls.length, 1) + assert.deepEqual(calls[0].argv, ['uv', '--version']) + assert.equal(calls[0].timeoutMs, 5000) +}) + +test('uvVersion accepts a two part version', async () => { + assert.equal(await uvVersion(fakeRunner({ code: 0, stdout: 'uv 0.9\n' })), '0.9') +}) + +test('uvVersion returns null when uv is missing, fails or is unreadable', async () => { + assert.equal(await uvVersion(fakeRunner({ code: null, error: 'spawn uv ENOENT' })), null) + assert.equal(await uvVersion(fakeRunner({ code: 1, stderr: 'boom' })), null) + assert.equal(await uvVersion(fakeRunner({ code: 0, stdout: 'not a version\n' })), null) + assert.equal(await uvVersion(fakeRunner({ code: null, timedOut: true })), null) +}) + +test('hasTerminal needs both streams to be a TTY', () => { + const tty = /** @type {any} */ ({ isTTY: true }) + const pipe = /** @type {any} */ ({ isTTY: false }) + const unknown = /** @type {any} */ ({}) + assert.equal(hasTerminal(tty, tty), true) + assert.equal(hasTerminal(pipe, tty), false) + assert.equal(hasTerminal(tty, pipe), false) + assert.equal(hasTerminal(unknown, tty), false) +}) + +test('runPreflight passes when uv answers', async () => { + /** @type {string[]} */ + const warnings = [] + await runPreflight({ + interactive: false, + runner: uvOk(), + messages, + warn: (text) => warnings.push(text), + platform: 'darwin', + }) + assert.equal(warnings.length, nodeVersionState() === 'warn' ? 1 : 0) + if (warnings.length === 1) assert.ok(warnings[0].includes(process.versions.node)) +}) + +test('runPreflight stops with the platform install command when uv is missing', async () => { + await assert.rejects( + () => runPreflight({ + interactive: false, + runner: fakeRunner({ code: null, error: 'spawn uv ENOENT' }), + messages, + warn: () => {}, + platform: 'darwin', + }), + (error) => error instanceof PreflightError && error.message === 'uv is missing. Install it with brew install uv', + ) +}) + +test('runPreflight falls back to the linux install command', async () => { + await assert.rejects( + () => runPreflight({ + interactive: false, + runner: fakeRunner({ code: 1 }), + messages, + warn: () => {}, + platform: 'freebsd', + }), + (error) => error instanceof PreflightError && error.message === 'uv is missing. Install it with curl uv', + ) +}) + +test('runPreflight stops a Windows node launched from a WSL shell', async () => { + process.env.WSL_DISTRO_NAME = 'Ubuntu' + try { + await assert.rejects( + () => runPreflight({ interactive: false, runner: uvOk(), messages, warn: () => {}, platform: 'win32' }), + (error) => error instanceof PreflightError && error.message === messages.wslWindowsNode, + ) + } finally { + delete process.env.WSL_DISTRO_NAME + } +}) + +test('runPreflight requires a terminal for an interactive run', async (t) => { + if (hasTerminal()) return t.skip('attached to a terminal') + await assert.rejects( + () => runPreflight({ interactive: true, runner: uvOk(), messages, warn: () => {}, platform: 'darwin' }), + (error) => error instanceof PreflightError && error.message === messages.needsTerminal, + ) +}) diff --git a/test/prompts.test.js b/test/prompts.test.js new file mode 100644 index 0000000..70cb25e --- /dev/null +++ b/test/prompts.test.js @@ -0,0 +1,70 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { loadMessages } from '../src/messages.js' +import { loadModules } from '../src/modules.js' +import { bundlePicker, modulePicker } from '../src/prompts.js' + +/** @typedef {import('../src/modules.js').ModuleDefinition} ModuleDefinition */ + +const messages = await loadMessages() +const modules = await loadModules() + +/** @param {string} code @returns {ModuleDefinition} */ +function must(code) { + const module = modules.find((entry) => entry.code === code) + assert.ok(module, `expected module ${code}`) + return module +} + +const method = must('method') +const fresh = { installedCodes: /** @type {string[]} */ ([]), installedVersions: {} } + +test('the module picker hides the always modules', () => { + const picker = modulePicker(messages, modules, fresh) + assert.deepEqual(picker.options.map((option) => option.value), ['method', 'cis']) + assert.equal(picker.options.some((option) => option.value === 'core-tools'), false) +}) + +test('a fresh install arrives with the default modules ticked', () => { + const picker = modulePicker(messages, modules, fresh) + assert.deepEqual(picker.initialValues, ['method']) + assert.equal(picker.options[0].label, 'BMad Method') + assert.equal(picker.options[0].hint, method.description) +}) + +test('an existing install ticks what is installed and labels its version', () => { + const picker = modulePicker(messages, modules, { + installedCodes: ['core-tools', 'cis'], + installedVersions: { cis: '6.13.0-next' }, + }) + + assert.deepEqual(picker.initialValues, ['cis']) + assert.equal(picker.options[0].label, 'BMad Method') + assert.equal(picker.options[1].label, `${must('cis').name} ${messages.installedSuffix.replace('{version}', '6.13.0-next')}`) +}) + +test('an installed module with no known version gets the plain suffix', () => { + const picker = modulePicker(messages, modules, { installedCodes: ['method'], installedVersions: {} }) + assert.equal(picker.options[0].label, `BMad Method ${messages.installedSuffixNoVersion}`) + assert.deepEqual(picker.initialValues, ['method']) +}) + +test('the bundle picker offers every bundle and ticks the default ones', () => { + const picker = bundlePicker(method, { installedSkills: [] }) + assert.deepEqual(picker.options.map((option) => option.value), ['planning', 'build', 'agents', 'extras']) + assert.deepEqual(picker.initialValues, ['planning', 'build', 'agents']) +}) + +test('a bundle whose skills are all installed is ticked even when it is not a default', () => { + const extras = method.bundles?.find((bundle) => bundle.code === 'extras') + assert.ok(extras) + const picker = bundlePicker(method, { installedSkills: extras.skills }) + assert.deepEqual(picker.initialValues, ['planning', 'build', 'agents', 'extras']) + + const partial = bundlePicker(method, { installedSkills: extras.skills.slice(0, 1) }) + assert.equal(partial.initialValues.includes('extras'), false) +}) + +test('a module without bundles offers nothing', () => { + assert.deepEqual(bundlePicker(must('cis'), { installedSkills: [] }), { options: [], initialValues: [] }) +}) diff --git a/test/report.test.js b/test/report.test.js new file mode 100644 index 0000000..1d6b5e5 --- /dev/null +++ b/test/report.test.js @@ -0,0 +1,215 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { + renderClosing, + renderFailures, + renderMigrations, + renderModuleMessages, + renderStatus, + versionMajor, +} from '../src/report.js' + +/** @typedef {import('../src/bmad-scripts.js').StatusReport} StatusReport */ +/** @typedef {import('../src/modules.js').ModuleDefinition} ModuleDefinition */ + +const messages = { + reportTitle: 'BMad status', + reportCurrent: 'Everything is set up.', + reportNotCurrent: 'Next step: {next}', + reportModule: '{name} {version} - {count} skills installed', + reportAlsoAvailable: 'Also available for {code}: {skills}', + reportMissingRecord: 'Missing module record. Install it with: {install}', + reportUnmet: '{skill} needs {requires} {minimum} ({state}): {install}', + reportPendingQuestions: 'Pending questions: {count}', + reportProblem: 'Problem: {message}', + reportLegacy: 'Left over: {path}', + reportFailure: 'Failed: {skill} ({error})', + migrationsAvailable: 'Migrations are available.', + migrationLine: '{title} - {module} {from} to {to}', + closing: 'Installed in {directory}. Ask your agent for bmad setup.', +} + +/** @param {string} name @returns {StatusReport} */ +function fixture(name) { + return JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')) +} + +test('renderStatus lists the title, the next step and every module', () => { + const text = renderStatus(fixture('status-fresh.json'), { failures: [], messages }) + const lines = text.split('\n') + + assert.equal(lines[0], 'BMad status') + assert.equal(lines[1], 'Next step: bmad setup') + assert.equal(lines[2], 'core-tools 6.13.0-next - 1 skills installed') + assert.ok(lines[3].startsWith('Also available for core-tools: bmad-advanced-elicitation,')) + assert.equal(lines[4], 'method 6.13.0-next - 0 skills installed') + assert.ok(lines[5].includes('bmad-prd')) + assert.equal(lines.length, 6) +}) + +test('renderStatus prefers the module name from modules.yaml', () => { + /** @type {ModuleDefinition[]} */ + const modules = [ + { + code: 'method', + name: 'BMad Method', + description: 'Plan, spec and build', + source: 'bmad-code-org/BMAD-METHOD', + record: 'bmod-method', + }, + ] + const text = renderStatus(fixture('status-after-prd.json'), { failures: [], messages, modules }) + + assert.ok(text.includes('BMad Method 6.13.0-next - 1 skills installed')) + assert.ok(text.includes('core-tools 6.13.0-next - 1 skills installed')) +}) + +test('renderStatus omits the also-available line when nothing is absent', () => { + const report = fixture('status-fresh.json') + report.modules[0].absent_skills = [] + report.modules[1].absent_skills = [] + + const text = renderStatus(report, { failures: [], messages }) + + assert.equal(text.includes('Also available'), false) +}) + +test('renderStatus reports the current install without a next step', () => { + const report = fixture('status-fresh.json') + report.current = true + report.next = null + + const text = renderStatus(report, { failures: [], messages }) + + assert.ok(text.includes('Everything is set up.')) + assert.equal(text.includes('Next step:'), false) +}) + +test('renderStatus lists problems, unmet needs, questions, leftovers and failures', () => { + const report = fixture('status-fresh.json') + report.missing_module_records = [ + { skill: 'bmad-ux', bmod: 'bmod-method', source: 'github', channel: 'main', install: 'npx skills add x' }, + ] + report.unmet_requirements = [ + { + skill: 'bmad-prd', + module: 'method', + requires: 'bmad', + minimum: '6.13.0', + installed: '6.12.0', + state: 'outdated', + source: 'github', + channel: 'main', + install: 'npx skills add bmad', + }, + ] + report.unmet_recommendations = [ + { + skill: 'bmad-build', + module: 'method', + requires: 'bmad-review', + minimum: '6.13.0', + installed: '', + state: 'missing', + source: 'github', + channel: 'main', + install: null, + }, + ] + report.pending_questions = [ + { module: 'method', key: 'user_name', prompt: 'Your name?', default: null, scope: 'project' }, + ] + report.problems = [{ kind: 'duplicate', message: 'two copies of bmad' }] + report.legacy_leftovers = ['_bmad/_config/manifest.yaml'] + + const text = renderStatus(report, { + messages, + failures: [ + { name: 'bmad-spec', status: 'failed', error: 'network' }, + { name: 'bmod-nope', status: 'skipped', reason: 'No matching skill found in source' }, + { status: 'failed', error: 'whole run failed' }, + ], + }) + + assert.ok(text.includes('Missing module record. Install it with: npx skills add x')) + assert.ok(text.includes('bmad-prd needs bmad 6.13.0 (outdated): npx skills add bmad')) + assert.ok(text.includes('bmad-build needs bmad-review 6.13.0 (missing): ')) + assert.ok(text.includes('Pending questions: 1')) + assert.ok(text.includes('Problem: two copies of bmad')) + assert.ok(text.includes('Left over: _bmad/_config/manifest.yaml')) + assert.ok(text.includes('Failed: bmad-spec (network)')) + assert.ok(text.includes('Failed: bmod-nope (No matching skill found in source)')) + assert.ok(text.includes('Failed: (whole run failed)')) +}) + +test('renderFailures names each failed skill and its reason', () => { + assert.equal(renderFailures([], messages), '') + assert.deepEqual( + renderFailures( + [ + { name: 'bmad-spec', status: 'failed', error: 'Failed to clone repository' }, + { status: 'failed', error: 'Invalid agents: nope' }, + ], + messages, + ).split('\n'), + ['Failed: bmad-spec (Failed to clone repository)', 'Failed: (Invalid agents: nope)'], + ) +}) + +test('renderMigrations is empty without migrations and lists them otherwise', () => { + assert.equal(renderMigrations([], messages), '') + + const text = renderMigrations( + [ + { + module: 'method', + path: 'v6-v7-migration.toml', + file: '/project/.agents/skills/bmod-method/v6-v7-migration.toml', + from: '6', + to: '7', + title: 'Move v6 artifacts', + }, + ], + messages, + ) + + assert.deepEqual(text.split('\n'), [ + 'Migrations are available.', + 'Move v6 artifacts - method 6 to 7', + ]) +}) + +test('renderModuleMessages joins only the modules that carry a message', () => { + /** @type {ModuleDefinition[]} */ + const modules = [ + { code: 'core-tools', name: 'Core', description: 'c', source: 'a/b', record: 'bmod-core-tools' }, + { + code: 'method', + name: 'Method', + description: 'm', + source: 'a/b', + record: 'bmod-method', + message: 'Ask the bmad skill what to do next.\n', + }, + ] + + assert.equal(renderModuleMessages(modules), 'Ask the bmad skill what to do next.') + assert.equal(renderModuleMessages([]), '') +}) + +test('renderClosing fills in the directory', () => { + assert.equal( + renderClosing(messages, { directory: '/tmp/demo' }), + 'Installed in /tmp/demo. Ask your agent for bmad setup.', + ) +}) + +test('versionMajor reads the leading major version', () => { + assert.equal(versionMajor('6.13.0-next'), '6') + assert.equal(versionMajor('7.0.0'), '7') + assert.equal(versionMajor('6'), '6') + assert.equal(versionMajor('v6.1.0'), '6') + assert.equal(versionMajor(''), null) + assert.equal(versionMajor('unknown'), null) +}) diff --git a/test/run.test.js b/test/run.test.js new file mode 100644 index 0000000..2c50df5 --- /dev/null +++ b/test/run.test.js @@ -0,0 +1,73 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createRunner } from '../src/run.js' + +test('captures stdout, stderr and exit code', async () => { + const run = createRunner() + const result = await run({ + argv: [process.execPath, '-e', "process.stdout.write('out'); process.stderr.write('err'); process.exit(3)"], + }) + assert.equal(result.code, 3) + assert.equal(result.stdout, 'out') + assert.equal(result.stderr, 'err') + assert.equal(result.timedOut, false) + assert.equal(result.error, undefined) +}) + +test('captures output far past the stdout pipe buffer when the child calls process.exit', async () => { + const run = createRunner() + const result = await run({ + argv: [process.execPath, '-e', "process.stdout.write('x'.repeat(200000)); process.exit(0)"], + }) + assert.equal(result.code, 0) + assert.equal(result.stdout.length, 200000) +}) + +test('decodes multi-byte output across chunk boundaries', async () => { + const run = createRunner() + const script = "const s = 'café–'.repeat(20000); process.stdout.write(s); process.stderr.write(s)" + const result = await run({ argv: [process.execPath, '-e', script] }) + assert.equal(result.stdout.length, 100000) + assert.equal(result.stderr.length, 100000) + assert.equal(result.stdout.includes('�'), false) + assert.equal(result.stderr.includes('�'), false) +}) + +test('kills the child and reports timedOut on timeout', async () => { + const run = createRunner() + const result = await run({ + argv: [process.execPath, '-e', 'setTimeout(() => {}, 60000)'], + timeoutMs: 200, + }) + assert.equal(result.timedOut, true) + assert.notEqual(result.code, 0) +}) + +test('resolves with code null and an error for a missing executable', async () => { + const run = createRunner() + const result = await run({ argv: ['bmad-installer-does-not-exist-xyz'] }) + assert.equal(result.code, null) + assert.equal(result.timedOut, false) + assert.ok(result.error) +}) + +test('inherit stdio returns empty stdout and stderr', async () => { + const run = createRunner() + const result = await run({ + argv: [process.execPath, '-e', "process.stdout.write('out'); process.exit(0)"], + stdio: 'inherit', + }) + assert.equal(result.code, 0) + assert.equal(result.stdout, '') + assert.equal(result.stderr, '') +}) + +test('debug option logs the command line and the exit', async () => { + /** @type {string[]} */ + const lines = [] + const run = createRunner({ debug: true, log: (line) => lines.push(line) }) + await run({ argv: [process.execPath, '-e', "process.stdout.write('hi')"], cwd: process.cwd() }) + assert.ok(lines.some((line) => line.startsWith(`$ ${process.cwd()}:`))) + assert.ok(lines.some((line) => line === 'exit 0')) + assert.ok(lines.some((line) => line === 'hi')) +}) diff --git a/test/self-update.test.js b/test/self-update.test.js new file mode 100644 index 0000000..72f3b89 --- /dev/null +++ b/test/self-update.test.js @@ -0,0 +1,78 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { distTag, newerVersion } from '../src/self-update.js' + +/** @typedef {import('../src/run.js').RunRequest} RunRequest */ +/** @typedef {import('../src/run.js').RunResult} RunResult */ +/** @typedef {import('../src/run.js').Runner} Runner */ + +/** + * @param {Partial} result + * @param {RunRequest[]} [calls] + * @returns {Runner} + */ +function fakeRunner(result, calls = []) { + return async (request) => { + calls.push(request) + return { code: 0, stdout: '', stderr: '', timedOut: false, ...result } + } +} + +test('distTag picks next for a prerelease and latest otherwise', () => { + assert.equal(distTag('6.13.0-next.0'), 'next') + assert.equal(distTag('6.13.0-beta'), 'next') + assert.equal(distTag('6.13.0'), 'latest') + assert.equal(distTag('6.12.10'), 'latest') +}) + +test('newerVersion asks npm for the dist tag with a timeout', async () => { + /** @type {RunRequest[]} */ + const calls = [] + const runner = fakeRunner({ stdout: '6.13.0\n' }, calls) + assert.equal(await newerVersion({ runner, version: '6.12.0', timeoutMs: 5000 }), '6.13.0') + assert.equal(calls.length, 1) + assert.deepEqual(calls[0].argv, ['npm', 'view', 'bmad-method@latest', 'version']) + assert.equal(calls[0].timeoutMs, 5000) +}) + +test('newerVersion uses the next tag for a prerelease', async () => { + /** @type {RunRequest[]} */ + const calls = [] + const runner = fakeRunner({ stdout: '6.13.0-next.1\n' }, calls) + assert.equal(await newerVersion({ runner, version: '6.13.0-next.0' }), '6.13.0-next.1') + assert.deepEqual(calls[0].argv, ['npm', 'view', 'bmad-method@next', 'version']) +}) + +test('newerVersion returns null for the same or an older version', async () => { + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '6.13.0\n' }), version: '6.13.0' }), null) + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '6.11.9\n' }), version: '6.12.0' }), null) + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '6.12.0\n' }), version: '6.12.1' }), null) + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '5.9.9\n' }), version: '6.0.0' }), null) +}) + +test('newerVersion orders prereleases below their release', async () => { + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '6.13.0\n' }), version: '6.13.0-next.0' }), '6.13.0') + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '6.13.0-next.9\n' }), version: '6.13.0' }), null) + assert.equal( + await newerVersion({ runner: fakeRunner({ stdout: '6.13.0-next.10\n' }), version: '6.13.0-next.9' }), + '6.13.0-next.10', + ) + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '6.13.0-next.1\n' }), version: '6.13.0-next.1' }), null) +}) + +test('newerVersion returns null on timeout, failure or garbage', async () => { + assert.equal(await newerVersion({ runner: fakeRunner({ code: null, timedOut: true }), version: '6.12.0' }), null) + assert.equal(await newerVersion({ runner: fakeRunner({ code: 1, stderr: 'E404' }), version: '6.12.0' }), null) + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: 'npm ERR! code E404\n' }), version: '6.12.0' }), null) + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '' }), version: '6.12.0' }), null) + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '7\n' }), version: '6.12.0' }), null) + assert.equal(await newerVersion({ runner: fakeRunner({ stdout: '7.0.0\n' }), version: 'not-a-version' }), null) +}) + +test('newerVersion never throws when the runner rejects', async () => { + /** @type {Runner} */ + const runner = async () => { + throw new Error('spawn failed') + } + assert.equal(await newerVersion({ runner, version: '6.12.0' }), null) +}) diff --git a/test/skills-cli.test.js b/test/skills-cli.test.js new file mode 100644 index 0000000..38c036d --- /dev/null +++ b/test/skills-cli.test.js @@ -0,0 +1,235 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { + SkillsCliError, + addArgv, + childEnv, + createSkillsCli, + listArgv, + parseAddResults, + parseListResults, + skillsCliBin, + updateArgv, +} from '../src/skills-cli.js' + +/** @typedef {import('../src/run.js').RunRequest} RunRequest */ +/** @typedef {import('../src/run.js').RunResult} RunResult */ + +const bin = skillsCliBin() +const metadata = { installer: 'bmad-method', version: '6.13.0-next.0' } + +/** + * @param {Partial[]} canned + */ +function fakeRunner(canned) { + /** @type {RunRequest[]} */ + const calls = [] + /** @type {import('../src/run.js').Runner} */ + const runner = async (request) => { + calls.push(request) + const result = canned[calls.length - 1] ?? canned[canned.length - 1] ?? {} + return { code: 0, stdout: '', stderr: '', timedOut: false, ...result } + } + return { runner, calls } +} + +/** + * @param {Partial[]} canned + * @param {{ telemetry?: boolean, copy?: boolean }} [options] + */ +function fakeCli(canned, options = {}) { + const { runner, calls } = fakeRunner(canned) + const cli = createSkillsCli({ + runner, + env: { PATH: '/usr/bin' }, + telemetry: options.telemetry ?? true, + copy: options.copy ?? false, + metadata, + }) + return { cli, calls } +} + +test('skillsCliBin resolves the shipped skills CLI entry point', () => { + assert.ok(bin.endsWith(join('skills', 'bin', 'cli.mjs'))) +}) + +test('addArgv builds a headless call with metadata after the swallowing flags', () => { + const argv = addArgv( + { source: 'owner/repo', skills: ['bmad', 'bmod-method'] }, + { interactive: false, copy: false, metadata }, + ) + assert.deepEqual(argv, [ + process.execPath, + bin, + 'add', + 'owner/repo', + '-s', + 'bmad', + 'bmod-method', + '--metadata', + JSON.stringify(metadata), + '-y', + '--json', + ]) +}) + +test('addArgv adds -a before --metadata when agents are known', () => { + const argv = addArgv( + { source: 'owner/repo', skills: ['bmad'], agents: ['claude-code', 'codex'] }, + { interactive: false, copy: true, metadata }, + ) + assert.deepEqual(argv, [ + process.execPath, + bin, + 'add', + 'owner/repo', + '-s', + 'bmad', + '-a', + 'claude-code', + 'codex', + '--metadata', + JSON.stringify(metadata), + '--copy', + '-y', + '--json', + ]) + assert.ok(argv.indexOf('--metadata') > argv.indexOf('codex')) +}) + +test('addArgv omits -a for null and empty agent lists', () => { + const base = { interactive: false, copy: false, metadata } + assert.ok(!addArgv({ source: 'owner/repo', skills: ['bmad'], agents: null }, base).includes('-a')) + assert.ok(!addArgv({ source: 'owner/repo', skills: ['bmad'], agents: [] }, base).includes('-a')) +}) + +test('addArgv interactive omits -y, --json and -a', () => { + const argv = addArgv( + { source: 'owner/repo', skills: ['bmad'], agents: ['claude-code'] }, + { interactive: true, copy: false, metadata }, + ) + assert.deepEqual(argv, [ + process.execPath, + bin, + 'add', + 'owner/repo', + '-s', + 'bmad', + '-a', + 'claude-code', + '--metadata', + JSON.stringify(metadata), + ]) +}) + +test('updateArgv and listArgv are fixed', () => { + assert.deepEqual(updateArgv(), [process.execPath, bin, 'update', '-p', '-y']) + assert.deepEqual(listArgv(), [process.execPath, bin, 'list', '--json']) +}) + +test('childEnv sets DO_NOT_TRACK only when telemetry is off and deletes nothing', () => { + const base = { PATH: '/usr/bin', DO_NOT_TRACK: '1' } + assert.equal(childEnv({ PATH: '/usr/bin' }, { telemetry: false }).DO_NOT_TRACK, '1') + assert.equal(childEnv({ PATH: '/usr/bin' }, { telemetry: true }).DO_NOT_TRACK, undefined) + assert.equal(childEnv(base, { telemetry: true }).DO_NOT_TRACK, '1') + assert.equal(childEnv(base, { telemetry: true }).PATH, '/usr/bin') +}) + +test('parseAddResults reads installed, failed and skipped entries', () => { + const stdout = JSON.stringify([ + { name: 'no-such-skill', status: 'skipped', reason: 'No matching skill found in source' }, + { name: 'bmad', status: 'installed', path: '/p/.claude/skills/bmad', scope: 'project', agents: ['Claude Code'], mode: 'copy' }, + { name: 'bmad-prd', status: 'failed', error: 'Installation failed' }, + ]) + const results = parseAddResults(stdout) + assert.equal(results.length, 3) + assert.equal(results[0].reason, 'No matching skill found in source') + assert.equal(results[1].mode, 'copy') + assert.equal(results[2].error, 'Installation failed') +}) + +test('parseAddResults throws SkillsCliError on malformed or non-array stdout', () => { + assert.throws(() => parseAddResults('not json'), SkillsCliError) + assert.throws(() => parseAddResults('{"status":"failed"}'), SkillsCliError) + assert.throws(() => parseAddResults(''), SkillsCliError) +}) + +test('parseListResults reads listed skills and an empty list', () => { + const stdout = JSON.stringify([ + { name: 'bmad', path: '/p/.claude/skills/bmad', scope: 'project', agents: ['Claude Code'], source: 'owner/repo' }, + ]) + const listed = parseListResults(stdout) + assert.equal(listed.length, 1) + assert.equal(listed[0].name, 'bmad') + assert.equal(listed[0].path, '/p/.claude/skills/bmad') + assert.deepEqual(parseListResults('[]'), []) +}) + +test('add runs piped and returns the parsed results', async () => { + const stdout = JSON.stringify([{ name: 'bmad', status: 'installed' }]) + const { cli, calls } = fakeCli([{ stdout }]) + const results = await cli.add({ source: 'owner/repo', skills: ['bmad'], cwd: '/proj' }) + + assert.deepEqual(results, [{ name: 'bmad', status: 'installed' }]) + assert.equal(calls[0].cwd, '/proj') + assert.equal(calls[0].stdio, 'pipe') + assert.ok(calls[0].argv.includes('--json')) +}) + +test('add turns unparsable stdout into one failure with the last stderr line', async () => { + const { cli } = fakeCli([{ code: 1, stdout: '', stderr: 'Invalid agents: nope\nValid agents: claude-code\n' }]) + const results = await cli.add({ source: 'owner/repo', skills: ['bmad'], cwd: '/proj' }) + assert.deepEqual(results, [{ status: 'failed', error: 'Valid agents: claude-code' }]) +}) + +test('add falls back to the exit code when stderr is empty', async () => { + const { cli } = fakeCli([{ code: 7, stdout: 'garbage', stderr: ' \n\n' }]) + const results = await cli.add({ source: 'owner/repo', skills: ['bmad'], cwd: '/proj' }) + assert.deepEqual(results, [{ status: 'failed', error: 'skills CLI exited with code 7' }]) +}) + +test('add passes DO_NOT_TRACK and --copy through from the factory', async () => { + const { cli, calls } = fakeCli([{ stdout: '[]' }], { telemetry: false, copy: true }) + await cli.add({ source: 'owner/repo', skills: ['bmad'], cwd: '/proj' }) + assert.equal(calls[0].env?.DO_NOT_TRACK, '1') + assert.ok(calls[0].argv.includes('--copy')) +}) + +test('addInteractive inherits stdio and returns the exit code', async () => { + const { cli, calls } = fakeCli([{ code: 0 }]) + const result = await cli.addInteractive({ source: 'owner/repo', skills: ['bmad'], cwd: '/proj' }) + assert.deepEqual(result, { code: 0 }) + assert.equal(calls[0].stdio, 'inherit') + assert.ok(!calls[0].argv.includes('--json')) +}) + +test('update inherits stdio and returns the exit code', async () => { + const { cli, calls } = fakeCli([{ code: 1 }]) + const result = await cli.update({ cwd: '/proj' }) + assert.deepEqual(result, { code: 1 }) + assert.equal(calls[0].stdio, 'inherit') + assert.deepEqual(calls[0].argv, updateArgv()) +}) + +test('list returns [] when the CLI fails with empty stdout', async () => { + const { cli } = fakeCli([{ code: 1, stdout: '', stderr: 'boom' }]) + assert.deepEqual(await cli.list({ cwd: '/proj' }), []) +}) + +test('list throws rather than claim an empty project when stdout cannot be read', async () => { + const truncated = JSON.stringify([{ name: 'bmad', path: '/p/bmad' }]).slice(0, 20) + const { cli } = fakeCli([{ code: 0, stdout: truncated, stderr: '' }]) + await assert.rejects(() => cli.list({ cwd: '/proj' }), SkillsCliError) + + const { cli: failed } = fakeCli([{ code: 1, stdout: 'not json', stderr: 'boom' }]) + await assert.rejects(() => failed.list({ cwd: '/proj' }), SkillsCliError) +}) + +test('list parses the listed skills', async () => { + const stdout = JSON.stringify([{ name: 'bmad', path: '/p/bmad', scope: 'project', agents: [], source: null }]) + const { cli, calls } = fakeCli([{ stdout }]) + const listed = await cli.list({ cwd: '/proj' }) + assert.equal(listed[0].name, 'bmad') + assert.equal(calls[0].stdio, 'pipe') +}) diff --git a/tools/release.md b/tools/release.md new file mode 100644 index 0000000..84187a8 --- /dev/null +++ b/tools/release.md @@ -0,0 +1,9 @@ +# Releasing + +Bump the version in `package.json`: `npm version prerelease --preid next` for a `-next` build, or `npm version minor` (or `major`/`patch`) for a stable release. This creates a commit and a tag `vX.Y.Z`. + +Push the tag: `git push origin vX.Y.Z`. The `publish.yml` workflow runs on that push and publishes to npm under the `next` dist-tag with provenance. + +Once you've verified the published version works, move the `latest` dist-tag by hand: `npm dist-tag add bmad-method@X.Y.Z latest`. + +The npm trusted publisher for this package must point at `bmad-code-org/bmad-installer`; if it points anywhere else, `npm publish --provenance` in the workflow fails. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42aebc9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "es2022", + "types": ["node"], + "skipLibCheck": true + }, + "include": ["bin", "src", "test"] +}