feat(installer): interactive channel switch, upgrade refusal, unified docs

Builds on the channel-resolution foundation. The installer now lets users
flip a module between stable, next, and pinned after install — either
interactively via a "Review channel assignments?" gate, or by flag. Quick
and modify re-installs classify stable upgrades; under non-interactive
flows, patches and minors apply automatically but majors are refused with
a pointer to --pin.

Fallback behavior for GitHub rate-limit / network failures is now cache-
aware: re-installs reuse the recorded ref silently; fresh installs abort
with actionable guidance (set GITHUB_TOKEN or use --next/--pin). Bundled
modules (core, bmm) warn when targeted by --pin or --next so users aren't
left wondering why the flag had no effect.

Install summary labels no longer mangle "main" into "vmain"; next-channel
entries render as "main @ <short-sha>" instead. Bundled modules are now
correctly skipped from all channel prompts and tag-API lookups.

Docs consolidated into a single how-to. install-bmad.md now covers the
interactive flow, the channel model (stable/next/pinned plus the npm
dist-tag axis for core/bmm), the re-install upgrade prompts, the full
flag reference, copy-paste recipes, and troubleshooting. The old
non-interactive-installation.md is reduced to a redirect stub.
This commit is contained in:
Brian Madison 2026-04-23 20:51:30 -05:00
parent 65bba449a7
commit 14f9b8a851
6 changed files with 442 additions and 315 deletions

View File

@ -1,122 +1,219 @@
---
title: 'How to Install BMad'
description: Step-by-step guide to installing BMad in your project
description: Install, update, and pin BMad for local development, teams, and CI
sidebar:
order: 1
---
Use the `npx bmad-method install` command to set up BMad in your project with your choice of modules and AI tools.
If you want to use a non interactive installer and provide all install options on the command line, see [this guide](./non-interactive-installation.md).
Use `npx bmad-method install` to set up BMad in your project. One command handles first installs, upgrades, channel switching, and scripted CI runs. This page covers all of it.
## When to Use This
- Starting a new project with BMad
- Adding BMad to an existing codebase
- Update the existing BMad Installation
- Adding or removing modules on an existing install
- Switching a module to main-HEAD or pinning to a specific release
- Scripting installs for CI pipelines, Dockerfiles, or enterprise rollouts
:::note[Prerequisites]
- **Node.js** 20+ (required for the installer)
- **Git** (recommended)
- **AI tool** (Claude Code, Cursor, or similar)
:::
- **Node.js** 20+ (the installer requires it)
- **Git** (for cloning external modules)
- **An AI tool** such as Claude Code or Cursor — or install without one using `--tools none`
## Steps
:::
### 1. Run the Installer
## First-time install (the fast path)
```bash
npx bmad-method install
```
:::tip[Want the newest prerelease build?]
Use the `next` dist-tag:
The interactive flow asks you five things:
1. Installation directory (defaults to the current working directory)
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea)
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
5. Per-module config (name, language, output folder)
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
:::tip[Just want the newest prerelease?]
```bash
npx bmad-method@next install
```
This gets you newer changes earlier, with a higher chance of churn than the default install.
Runs the prerelease installer, which ships a newer snapshot of core and bmm. More churn, fewer delays between development and release.
:::
:::tip[Bleeding edge]
To install the latest from the main branch (may be unstable):
## Picking a specific version
Two independent axes control what ends up on disk.
### Axis 1: external module channels
Every external module — bmb, cis, gds, tea, and any community module — installs on one of three channels:
| Channel | What gets installed | Who picks this |
| --- | --- | --- |
| `stable` (default) | Highest released semver tag. Prereleases like `v2.0.0-alpha.1` are excluded. | Most users |
| `next` | Main branch HEAD at install time | Contributors, early adopters |
| `pinned` | A specific tag you name | Enterprise installs, CI reproducibility |
Channels are per-module. You can run bmb on `next` while leaving cis on `stable` — the flags below let you mix freely.
### Axis 2: installer binary version
The `bmad-method` npm package itself has two dist-tags:
| Command | What you get |
| --- | --- |
| `npx bmad-method install` (`@latest`) | Latest stable installer release |
| `npx bmad-method@next install` | Latest prerelease installer, auto-published on every push to main |
**The installer binary determines your core and bmm versions.** Those two modules ship bundled inside the installer package rather than being cloned from separate repos.
### Why core and bmm don't have their own channel
They're stapled to the installer binary you ran:
- `npx bmad-method install` → latest stable core and bmm
- `npx bmad-method@next install` → prerelease core and bmm
- `node /path/to/local-checkout/tools/installer/bmad-cli.js install` → whatever your local checkout has
`--pin bmm=v6.3.0` and `--next=bmm` are silently ineffective against bundled modules, and the installer warns you when you try. A future release extracts bmm from the installer package; once that ships, bmm gets a proper channel selector like bmb has today.
## Updating an existing install
Running `npx bmad-method install` in a directory that already contains `_bmad/` gives you a menu:
| Choice | What it does |
| --- | --- |
| **Quick Update** | Re-runs the install with your existing settings. Refreshes files, applies patches and minor stable upgrades, refuses major upgrades. Fast, non-interactive. |
| **Modify Install** | Full interactive flow. Add or remove modules, reconfigure settings, optionally review and switch channels for existing modules. |
### Upgrade prompts
When Modify detects a newer stable tag for a module you've installed on `stable`, it classifies the diff and prompts accordingly:
| Upgrade type | Example | Default |
| --- | --- | --- |
| Patch | v1.7.0 → v1.7.1 | Y |
| Minor | v1.7.0 → v1.8.0 | Y |
| Major | v1.7.0 → v2.0.0 | **N** |
Major defaults to N because breaking changes frequently surface as "instability" when they weren't expected. The prompt includes a GitHub release-notes URL so you can read what changed before accepting.
Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen — pass `--pin <code>=<new-tag>` to accept non-interactively.
### Switching a module's channel
**Interactively:** choose Modify → answer **Yes** to "Review channel assignments?" → each external module offers Keep, Switch to stable, Switch to next, or Pin to a tag.
**Via flags:** the recipes in the next section cover the common cases.
## Headless / CI installs
### Flag reference
| Flag | Purpose |
| --- | --- |
| `--yes`, `-y` | Skip all prompts; accept flag values + defaults |
| `--directory <path>` | Install into this directory (default: current working dir) |
| `--modules <a,b,c>` | Exact module set. Core is auto-added. Not a delta — list everything you want kept. |
| `--tools <a,b>` or `--tools none` | IDE/tool selection. `none` skips tool config entirely. |
| `--action <type>` | `install`, `update`, or `quick-update`. Defaults based on existing install state. |
| `--custom-source <urls>` | Install custom modules from Git URLs or local paths |
| `--channel <stable\|next>` | Apply to all externals (aliased as `--all-stable` / `--all-next`) |
| `--all-stable` | Alias for `--channel=stable` |
| `--all-next` | Alias for `--channel=next` |
| `--next=<code>` | Put one module on next. Repeatable. |
| `--pin <code>=<tag>` | Pin one module to a specific tag. Repeatable. |
| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Override per-user config defaults |
Precedence when flags overlap: `--pin` beats `--next=` beats `--channel` / `--all-*` beats the registry default (`stable`).
:::note[Example resolution]
`--all-next --pin cis=v0.2.0` puts bmb, gds, and tea on next while pinning cis to v0.2.0.
:::
### Recipes
**Default install — latest stable for everything:**
```bash
npx github:bmad-code-org/BMAD-METHOD install
npx bmad-method install --yes --modules bmm,bmb,cis --tools claude-code
```
**Enterprise pin — reproducible byte-for-byte:**
```bash
npx bmad-method install --yes \
--modules bmm,bmb,cis \
--pin bmb=v1.7.0 --pin cis=v0.2.0 \
--tools claude-code
```
**Bleeding edge — externals on main HEAD:**
```bash
npx bmad-method install --yes --modules bmm,bmb --all-next --tools claude-code
```
**Add a module to an existing install** (keep everything else):
```bash
npx bmad-method install --yes --action update \
--modules bmm,bmb,gds \
--tools none
```
**Mix channels — bmb on next, gds on stable:**
```bash
npx bmad-method install --yes --action update \
--modules bmm,bmb,cis,gds \
--next=bmb \
--tools none
```
:::caution[Rate limit on shared IPs]
Anonymous GitHub API calls are capped at 60/hour per IP. A single install hits the API once per external module to resolve the stable tag. Offices behind NAT, CI runner pools, and VPNs can collectively exhaust this.
Set `GITHUB_TOKEN=<personal access token>` in the environment to raise the limit to 5000/hour per account. Any public-repo-read PAT works; no scopes are required.
:::
### 2. Choose Installation Location
## What got installed
The installer will ask where to install BMad files:
After any install, `_bmad/_config/manifest.yaml` records exactly what's on disk:
- Current directory (recommended for new projects if you created the directory yourself and ran from within the directory)
- Custom path
### 3. Select Your AI Tools
Pick which AI tools you use:
- Claude Code
- Cursor
- Others
Each tool has its own way of integrating skills. The installer creates tiny prompt files to activate workflows and agents — it just puts them where your tool expects to find them.
:::note[Enabling Skills]
Some platforms require skills to be explicitly enabled in settings before they appear. If you install BMad and don't see the skills, check your platform's settings or ask your AI assistant how to enable skills.
:::
### 4. Choose Modules
The installer shows available modules. Select whichever ones you need — most users just want **BMad Method** (the software development module).
### 5. Follow the Prompts
The installer guides you through the rest — settings, tool integrations, etc.
## What You Get
```text
your-project/
├── _bmad/
│ ├── bmm/ # Your selected modules
│ │ └── config.yaml # Module settings (if you ever need to change them)
│ ├── core/ # Required core module
│ └── ...
├── _bmad-output/ # Generated artifacts
├── .claude/ # Claude Code skills (if using Claude Code)
│ └── skills/
│ ├── bmad-help/
│ ├── bmad-persona/
│ └── ...
└── .cursor/ # Cursor skills (if using Cursor)
└── skills/
└── ...
```yaml
modules:
- name: bmb
version: v1.7.0 # the tag, or "main" for next
channel: stable # stable | next | pinned
sha: 86033fc9aeae2ca6d52c7cdb675c1f4bf17fc1c1
source: external
repoUrl: https://github.com/bmad-code-org/bmad-builder
```
## Verify Installation
Run `bmad-help` to verify everything works and see what to do next.
**BMad-Help is your intelligent guide** that will:
- Confirm your installation is working
- Show what's available based on your installed modules
- Recommend your first step
You can also ask it questions:
```
bmad-help I just installed, what should I do first?
bmad-help What are my options for a SaaS project?
```
The `sha` field is always populated. For reproducible installs, pass the same `--modules` + `--pin` / `--next=` combination on a fresh machine and you'll land on the same commits.
## Troubleshooting
**Installer throws an error** — Copy-paste the output into your AI assistant and let it figure it out.
### "Could not resolve stable tag" or "API rate limit exceeded"
**Installer worked but something doesn't work later** — Your AI needs BMad context to help. See [How to Get Answers About BMad](./get-answers-about-bmad.md) for how to point your AI at the right sources.
You've hit GitHub's 60/hr anonymous limit. Set `GITHUB_TOKEN` and retry. If you already have a token set, it may be expired or rate-limited on its own budget — try a different token or wait for the hourly reset.
### "Tag 'vX.Y.Z' not found"
The tag you passed to `--pin` doesn't exist in the module's repo. Check the repo's releases page on GitHub for valid tags.
### A pinned install keeps upgrading
Pinned installs don't upgrade. Quick-update applies patches and minors on stable channel only; it won't touch `pinned` or `next`. If a pinned install changed, open `_bmad/_config/manifest.yaml``channel: pinned` plus a fixed `version` and `sha` should hold across runs unless you explicitly override via flags.
### `--pin bmm=X` didn't do anything
bmm is a bundled module — `--pin` and `--next=` don't apply. Use `npx bmad-method@next install` for a prerelease core/bmm, or check out the bmad-bmm repo and run the installer locally to get unreleased changes.

View File

@ -1,196 +1,10 @@
---
title: Non-Interactive Installation
description: Install BMad using command-line flags for CI/CD pipelines and automated deployments
description: Headless / CI install docs have moved
sidebar:
order: 2
---
Use command-line flags to install BMad non-interactively. This is useful for:
## When to Use This
- Automated deployments and CI/CD pipelines
- Scripted installations
- Batch installations across multiple projects
- Quick installations with known configurations
:::note[Prerequisites]
Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
:::
## Available Flags
### Installation Options
| Flag | Description | Example |
| --------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------- |
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
| `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` |
| `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` |
| `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
| `--custom-source <sources>` | Comma-separated Git URLs or local paths for custom modules | `--custom-source /path/to/module` |
### Core Configuration
| Flag | Description | Default |
| ----------------------------------- | ----------------------------------------------- | --------------- |
| `--user-name <name>` | Name for agents to use | System username |
| `--communication-language <lang>` | Agent communication language | English |
| `--document-output-language <lang>` | Document output language | English |
| `--output-folder <path>` | Output folder path (see resolution rules below) | `_bmad-output` |
#### Output Folder Path Resolution
The value passed to `--output-folder` (or entered interactively) is resolved according to these rules:
| Input type | Example | Resolved as |
| ---------------------------- | -------------------------- | ---------------------------------------------------------- |
| Relative path (default) | `_bmad-output` | `<project-root>/_bmad-output` |
| Relative path with traversal | `../../shared-outputs` | Normalized absolute path — e.g. `/Users/me/shared-outputs` |
| Absolute path | `/Users/me/shared-outputs` | Used as-is — project root is **not** prepended |
The resolved path is what agents and workflows use at runtime when writing output files. Using an absolute path or a traversal-based relative path lets you direct all generated artifacts to a directory outside your project tree — useful for shared or monorepo setups.
### Other Options
| Flag | Description |
| ------------- | ------------------------------------------- |
| `-y, --yes` | Accept all defaults and skip prompts |
| `-d, --debug` | Enable debug output for manifest generation |
## Module IDs
Available module IDs for the `--modules` flag:
- `bmm` — BMad Method Master
- `bmb` — BMad Builder
Check the [BMad registry](https://github.com/bmad-code-org) for available external modules.
## Tool/IDE IDs
Available tool IDs for the `--tools` flag:
**Preferred:** `claude-code`, `cursor`
Run `npx bmad-method install` interactively once to see the full current list of supported tools, or check the [platform codes configuration](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml).
## Installation Modes
| Mode | Description | Example |
| --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Fully non-interactive | Provide all flags to skip all prompts | `npx bmad-method install --directory . --modules bmm --tools claude-code --yes` |
| Semi-interactive | Provide some flags; BMad prompts for the rest | `npx bmad-method install --directory . --modules bmm` |
| Defaults only | Accept all defaults with `-y` | `npx bmad-method install --yes` |
| Custom source only | Install core + custom module(s) | `npx bmad-method install --directory . --custom-source /path/to/module --tools claude-code --yes` |
| Without tools | Skip tool/IDE configuration | `npx bmad-method install --modules bmm --tools none` |
## Examples
### CI/CD Pipeline Installation
```bash
#!/bin/bash
# install-bmad.sh
npx bmad-method install \
--directory "${GITHUB_WORKSPACE}" \
--modules bmm \
--tools claude-code \
--user-name "CI Bot" \
--communication-language English \
--document-output-language English \
--output-folder _bmad-output \
--yes
```
### Update Existing Installation
```bash
npx bmad-method install \
--directory ~/projects/myapp \
--action update \
--modules bmm,bmb,custom-module
```
### Quick Update (Preserve Settings)
```bash
npx bmad-method install \
--directory ~/projects/myapp \
--action quick-update
```
### Install from Custom Source
Install a module from a local path or any Git host:
```bash
npx bmad-method install \
--directory . \
--custom-source /path/to/my-module \
--tools claude-code \
--yes
```
Combine with official modules:
```bash
npx bmad-method install \
--directory . \
--modules bmm \
--custom-source https://gitlab.com/myorg/my-module \
--tools claude-code \
--yes
```
:::note[Custom source behavior]
When `--custom-source` is used without `--modules`, only core and the custom modules are installed. Add `--modules` to include official modules as well. See [Install Custom and Community Modules](./install-custom-modules.md) for details.
:::
## What You Get
- A fully configured `_bmad/` directory in your project
- Agents and workflows configured for your selected modules and tools
- A `_bmad-output/` folder for generated artifacts
## Validation and Error Handling
BMad validates all provided flags:
- **Directory** — Must be a valid path with write permissions
- **Modules** — Warns about invalid module IDs (but won't fail)
- **Tools** — Warns about invalid tool IDs (but won't fail)
- **Action** — Must be one of: `install`, `update`, `quick-update`
Invalid values will either:
1. Show an error and exit (for critical options like directory)
2. Show a warning and skip (for optional items)
3. Fall back to interactive prompts (for missing required values)
:::tip[Best Practices]
- Use absolute paths for `--directory` to avoid ambiguity
- Use an absolute path for `--output-folder` when you want artifacts written outside the project tree (e.g. a shared monorepo outputs directory)
- Test flags locally before using in CI/CD pipelines
- Combine with `-y` for truly unattended installations
- Use `--debug` if you encounter issues during installation
:::
## Troubleshooting
### Installation fails with "Invalid directory"
- The directory path must exist (or its parent must exist)
- You need write permissions
- The path must be absolute or correctly relative to the current directory
### Module not found
- Verify the module ID is correct
- External modules must be available in the registry
:::note[Still stuck?]
Run with `--debug` for detailed output, try interactive mode to isolate the issue, or report at <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
:::note[This page has moved]
Headless and CI install flags, channel selection, and pinning now live in the unified [How to Install BMad](./install-bmad.md) guide. Jump to the [Headless / CI installs](./install-bmad.md#headless-ci-installs) section for the flag reference and copy-paste recipes.
:::

View File

@ -623,7 +623,12 @@ class Installer {
// Prefer the git tag recorded by the external resolution (e.g. "v1.7.0") over
// the on-disk package.json (which may be ahead of the released tag).
const version = externalResolution?.version || versionInfo.version || '';
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
addResult(displayName, 'ok', '', {
moduleCode: moduleName,
newVersion: version,
newChannel: externalResolution?.channel || null,
newSha: externalResolution?.sha || null,
});
}
}
@ -1098,15 +1103,21 @@ class Installer {
let detail = '';
if (r.moduleCode && r.newVersion) {
const oldVersion = preVersions.get(r.moduleCode);
// External/community modules record the git tag (e.g. "v1.7.0") while
// core/bmm carry the package.json string ("6.3.0"). Prepend 'v' only
// when the value doesn't already start with 'v'.
const fmt = (v) => (typeof v === 'string' && v.startsWith('v') ? v : `v${v}`);
const newV = fmt(r.newVersion);
// Format a version label for display:
// "main" → "main @ <short-sha>" (next channel shows what SHA landed)
// "v1.7.0" or "1.7.0" → "v1.7.0" (prefix 'v' when missing)
// anything else (legacy strings) → as-is
const fmt = (v, sha) => {
if (typeof v !== 'string' || !v) return '';
if (v === 'main' || v === 'HEAD') return sha ? `main @ ${sha.slice(0, 7)}` : 'main';
if (/^v?\d+\.\d+\.\d+/.test(v)) return v.startsWith('v') ? v : `v${v}`;
return v;
};
const newV = fmt(r.newVersion, r.newSha);
if (oldVersion && oldVersion === r.newVersion) {
detail = ` (${newV}, no change)`;
} else if (oldVersion) {
detail = ` (${fmt(oldVersion)}${newV})`;
detail = ` (${fmt(oldVersion, r.newSha)}${newV})`;
} else {
detail = ` (${newV}, installed)`;
}
@ -1228,9 +1239,59 @@ class Installer {
await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`);
}
// Build channel options from the existing manifest FIRST so the config
// collector below (which triggers external-module clones via
// findModuleSource) knows each module's recorded channel and doesn't
// silently redecide it. Without this, modules previously on 'next' or
// 'pinned' would trigger a stable-channel tag lookup at config-collection
// time, burning GitHub API quota and potentially failing.
const manifestData = await this.manifest.read(bmadDir);
const channelOptions = { global: null, nextSet: new Set(), pins: new Map(), warnings: [] };
if (manifestData?.modulesDetailed) {
const { fetchStableTags, classifyUpgrade, parseGitHubRepo } = require('../modules/channel-resolver');
for (const entry of manifestData.modulesDetailed) {
if (!entry?.name || !entry?.channel) continue;
if (entry.channel === 'pinned' && entry.version) {
channelOptions.pins.set(entry.name, entry.version);
continue;
}
if (entry.channel === 'next') {
channelOptions.nextSet.add(entry.name);
continue;
}
// Stable: classify the available upgrade. Patches and minors fall
// through (stable default picks up the top tag). A major upgrade
// requires opt-in, so under quick-update's non-interactive semantics
// we pin to the current version to prevent a silent breaking jump.
if (entry.channel === 'stable' && entry.version && entry.repoUrl) {
const parsed = parseGitHubRepo(entry.repoUrl);
if (!parsed) continue;
try {
const tags = await fetchStableTags(parsed.owner, parsed.repo);
if (tags.length === 0) continue;
const topTag = tags[0].tag;
const cls = classifyUpgrade(entry.version, topTag);
if (cls === 'major') {
channelOptions.pins.set(entry.name, entry.version);
await prompts.log.warn(
`${entry.name} ${entry.version}${topTag} is a new major release; staying on ${entry.version}. ` +
`Run \`bmad install\` (Modify) with \`--pin ${entry.name}=${topTag}\` to accept.`,
);
}
} catch (error) {
// Tag lookup failed (offline, rate-limited). Stay on the current
// version rather than guessing — the existing cache is already
// at that ref, so re-using it keeps the install stable.
channelOptions.pins.set(entry.name, entry.version);
await prompts.log.warn(`Could not check ${entry.name} for updates (${error.message}); staying on ${entry.version}.`);
}
}
}
}
// Load existing configs and collect new fields (if any)
await prompts.log.info('Checking for new configuration options...');
const quickModules = new OfficialModules();
const quickModules = new OfficialModules({ channelOptions });
await quickModules.loadExistingConfig(projectDir);
let promptedForNewFields = false;
@ -1258,26 +1319,6 @@ class Installer {
lastModified: new Date().toISOString(),
};
// Build channel options from the existing manifest so the quick update
// re-clones each module at its recorded ref (pinned/next stays put;
// stable modules pick up new stable tags — same semver-class behavior
// the update flow uses, here with --yes semantics since quick-update is
// non-interactive by definition: patches/minors apply, majors stay frozen).
const manifestData = await this.manifest.read(bmadDir);
const channelOptions = { global: null, nextSet: new Set(), pins: new Map(), warnings: [] };
if (manifestData?.modulesDetailed) {
for (const entry of manifestData.modulesDetailed) {
if (!entry?.name || !entry?.channel) continue;
if (entry.channel === 'pinned' && entry.version) {
channelOptions.pins.set(entry.name, entry.version);
} else if (entry.channel === 'next') {
channelOptions.nextSet.add(entry.name);
}
// stable modules fall through — stable is the default, and the
// update-channel resolver will handle upgrade classification.
}
}
// Build config and delegate to install()
const installConfig = {
directory: projectDir,

View File

@ -166,10 +166,34 @@ function orphanPinWarnings(channelOptions, selectedCodes) {
return warnings;
}
/**
* Warn when --pin / --next targets a bundled module (core, bmm). Those are
* shipped inside the installer binary there's no git clone to override, so
* the flag has no effect. Users who actually want a prerelease core/bmm
* should use `npx bmad-method@next install`.
*/
function bundledTargetWarnings(channelOptions, bundledCodes) {
const warnings = [];
const bundled = new Set(bundledCodes || []);
const hint = '(bundled module; use `npx bmad-method@next install` for a prerelease)';
for (const code of channelOptions?.pins?.keys() || []) {
if (bundled.has(code)) {
warnings.push(`--pin for '${code}' has no effect ${hint}.`);
}
}
for (const code of channelOptions?.nextSet || []) {
if (bundled.has(code)) {
warnings.push(`--next for '${code}' has no effect ${hint}.`);
}
}
return warnings;
}
module.exports = {
parseChannelOptions,
decideChannelForModule,
buildPlan,
orphanPinWarnings,
bundledTargetWarnings,
parsePinSpec,
};

View File

@ -257,8 +257,38 @@ class ExternalModuleManager {
repoUrl: moduleInfo.url,
});
} catch (error) {
if (!silent) await prompts.log.warn(`${error.message} — falling back to main HEAD.`);
resolved = { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'api-failure' };
// Tag-API failure (rate limit, transient network). If we already have
// a usable cache at a recorded ref, treat this as "couldn't check for
// updates" and re-use the cached version silently — that's the right
// call for an update/quick-update, since the semantics don't change
// and the user isn't worse off than before they ran this command.
const cachedMarker = await readChannelMarker(path.join(moduleCacheDir, '.bmad-channel.json'));
if (cachedMarker?.channel && (await fs.pathExists(moduleCacheDir))) {
if (!silent) {
await prompts.log.warn(
`Could not check for updates to ${moduleInfo.name} (${error.message}); using cached ${cachedMarker.version || cachedMarker.channel}.`,
);
}
ExternalModuleManager._resolutions.set(moduleCode, {
channel: cachedMarker.channel,
version: cachedMarker.version || 'main',
ref: cachedMarker.version && cachedMarker.version !== 'main' ? cachedMarker.version : null,
sha: cachedMarker.sha,
repoUrl: moduleInfo.url,
resolvedFallback: false,
planSource: 'cached',
});
return moduleCacheDir;
}
// No cache to fall back on — this is effectively a fresh install with
// no offline safety net. Surface a clear error with actionable guidance.
const isRateLimited = /rate limit/i.test(error.message);
const hint = isRateLimited
? process.env.GITHUB_TOKEN
? 'Your GITHUB_TOKEN may have expired or been rate-limited on its own budget. Try a different token or wait for the reset.'
: 'Set a GITHUB_TOKEN env var (any personal access token with public-repo read) to raise the 60-req/hour anonymous limit.'
: `Check your network connection, or rerun with \`--next=${moduleCode}\` / \`--pin ${moduleCode}=<tag>\` to skip the tag lookup.`;
throw new Error(`Could not resolve stable tag for '${moduleCode}' (${error.message}). ${hint}`);
}
if (resolved.resolvedFallback && !silent) {

View File

@ -4,7 +4,7 @@ const fs = require('./fs-native');
const { CLIUtils } = require('./cli-utils');
const { ExternalModuleManager } = require('./modules/external-manager');
const { resolveModuleVersion } = require('./modules/version-resolver');
const { parseChannelOptions, buildPlan, orphanPinWarnings } = require('./modules/channel-plan');
const { parseChannelOptions, buildPlan, orphanPinWarnings, bundledTargetWarnings } = require('./modules/channel-plan');
const prompts = require('./prompts');
/**
@ -180,9 +180,17 @@ class UI {
channelOptions,
});
// Warn about --pin/--next flags that refer to modules the user didn't select.
for (const warning of orphanPinWarnings(channelOptions, selectedModules)) {
await prompts.log.warn(warning);
// Warn about --pin/--next flags that refer to modules the user didn't
// select, or that target bundled modules (core/bmm) where channel
// flags don't apply.
{
const bundledCodes = await this._bundledModuleCodes();
for (const warning of [
...orphanPinWarnings(channelOptions, selectedModules),
...bundledTargetWarnings(channelOptions, bundledCodes),
]) {
await prompts.log.warn(warning);
}
}
return {
@ -247,9 +255,17 @@ class UI {
channelOptions,
});
// Warn about --pin/--next flags that refer to modules the user didn't select.
for (const warning of orphanPinWarnings(channelOptions, selectedModules)) {
await prompts.log.warn(warning);
// Warn about --pin/--next flags that refer to modules the user didn't
// select, or that target bundled modules (core/bmm) where channel
// flags don't apply.
{
const bundledCodes = await this._bundledModuleCodes();
for (const warning of [
...orphanPinWarnings(channelOptions, selectedModules),
...bundledTargetWarnings(channelOptions, bundledCodes),
]) {
await prompts.log.warn(warning);
}
}
return {
@ -1609,6 +1625,21 @@ class UI {
await prompts.log.message('Selected tools:\n' + toolLines.join('\n'));
}
/**
* Return the set of module codes the registry marks as built-in (core, bmm).
* These ship with the installer binary and have no per-module channel.
*/
async _bundledModuleCodes() {
const externalManager = new ExternalModuleManager();
try {
const modules = await externalManager.listAvailable();
return modules.filter((m) => m.builtIn).map((m) => m.code);
} catch {
// Registry unreachable — fall back to the known bundled codes.
return ['core', 'bmm'];
}
}
/**
* Fast-path channel gate: confirm "all stable" or open the per-module picker.
*
@ -1637,7 +1668,10 @@ class UI {
const community = await communityMgr.listAll();
const communityByCode = new Map(community.map((m) => [m.code, m]));
const channelSelectable = selectedModules.filter((code) => externalByCode.has(code) || communityByCode.has(code));
const channelSelectable = selectedModules.filter((code) => {
const info = externalByCode.get(code) || communityByCode.get(code);
return info && !info.builtIn;
});
if (channelSelectable.length === 0) return;
const fastPath = await prompts.confirm({
@ -1733,12 +1767,35 @@ class UI {
const { fetchStableTags, classifyUpgrade, releaseNotesUrl } = require('./modules/channel-resolver');
const { parseGitHubRepo } = require('./modules/channel-resolver');
// Interactive-only: offer a one-time gate to review / switch channels for
// selected modules that are already installed. Default N so normal Modify
// flows (add/remove modules) aren't interrupted.
let reviewChannels = false;
if (!yes) {
const existingWithChannel = selectedModules.filter((code) => {
const prev = existingByName.get(code);
if (!prev) return false;
const info = externalByCode.get(code) || communityByCode.get(code);
return info && !info.builtIn;
});
if (existingWithChannel.length > 0) {
reviewChannels = await prompts.confirm({
message: 'Review channel assignments (stable / next / pin) for your existing modules?',
default: false,
});
}
}
for (const code of selectedModules) {
const prev = existingByName.get(code);
if (!prev) continue;
const info = externalByCode.get(code) || communityByCode.get(code);
if (!info) continue;
// Bundled modules (core/bmm) ship with the installer binary itself —
// their version is stapled to the CLI version, not a git tag. Skip
// tag-API lookups for them; the "upgrade" mechanism is `npx bmad@X install`.
if (info.builtIn) continue;
const repoUrl = info.url;
const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
@ -1764,14 +1821,78 @@ class UI {
continue;
}
if (recordedChannel === 'pinned' || recordedChannel === 'next') {
// Pinned: nothing to prompt — cloneExternalModule re-clones at the
// recorded ref. Next: always pulls HEAD.
if (recordedChannel === 'pinned' && prev.version) {
// Re-assert the pin so subsequent channel decisions honor it.
if (!channelOptions.pins.has(code)) channelOptions.pins.set(code, prev.version);
} else if (recordedChannel === 'next') {
// Optional channel-switch offer. Fires only when the user opted in via
// the gate above. 'keep' falls through to the existing per-channel
// logic (which runs upgrade classification for stable). Any switch
// records the new intent into channelOptions and skips upgrade prompts.
if (reviewChannels && recordedChannel) {
const switchChoices = [
{
name: `Keep on '${recordedChannel}'${prev.version ? ` @ ${prev.version}` : ''}`,
value: 'keep',
},
];
if (recordedChannel !== 'stable') {
switchChoices.push({ name: 'Switch to stable (released version)', value: 'stable' });
}
if (recordedChannel !== 'next') {
switchChoices.push({ name: 'Switch to next (main HEAD)', value: 'next' });
}
switchChoices.push({ name: 'Pin to a specific version tag', value: 'pin' });
const choice = await prompts.select({
message: `${code} channel:`,
choices: switchChoices,
default: 'keep',
});
if (choice === 'next') {
channelOptions.nextSet.add(code);
continue;
}
if (choice === 'pin') {
const pinValue = await prompts.text({
message: `Enter a version tag for '${code}' (e.g. v1.6.0):`,
validate: (value) => {
if (!value || !/^[\w.\-+/]+$/.test(String(value).trim())) {
return 'Must be a non-empty tag name (letters, digits, dots, hyphens).';
}
},
});
channelOptions.pins.set(code, String(pinValue).trim());
continue;
}
if (choice === 'stable') {
// Switch to stable: install at the top stable tag without an
// upgrade-classification prompt (the user explicitly opted in).
// Also warm the tag cache here so the actual clone step doesn't
// need a second GitHub API call (can hit rate limits).
if (parsed) {
try {
await fetchStableTags(parsed.owner, parsed.repo);
} catch {
// best effort; clone step will surface any failure
}
}
continue;
}
// 'keep' → fall through with recordedChannel below.
}
if (recordedChannel === 'pinned' || recordedChannel === 'next') {
// Respect any explicit channel intent the user already expressed via
// CLI flags (--channel / --all-* / --next=CODE / --pin CODE=TAG) or
// via the interactive review gate above. Only auto-re-assert the
// recorded channel when the user hasn't opted into anything else —
// otherwise --all-stable (or a review "switch to stable") would be
// silently clobbered by the prior channel.
const alreadyDecided = channelOptions.global || channelOptions.nextSet.has(code) || channelOptions.pins.has(code);
if (!alreadyDecided) {
if (recordedChannel === 'pinned' && prev.version) {
channelOptions.pins.set(code, prev.version);
} else if (recordedChannel === 'next') {
channelOptions.nextSet.add(code);
}
}
continue;
}