Compare commits

..

2 Commits

Author SHA1 Message Date
Brian Madison b768e0b369 fix(installer): address CodeRabbit review findings
- ui.js: skip stable-channel upgrade classification when the user has
  already declared intent via --pin/--next=/--channel or the review
  gate. Prevents the decline / major-refused / fetch-error branches
  from silently overwriting an explicit pin with prev.version.
- external-manager.js: short-circuit cloneExternalModule when the
  requested plan matches an existing in-process resolution and the
  cache is valid. Avoids redundant resolveChannel() + git fetch on
  every same-plan lookup in a single install.
- installer.js: fall back to CommunityModuleManager.getResolution()
  when no external resolution exists, so community module result
  rows carry newChannel/newSha instead of null under --next/--pin.
- installer.js: don't label a module as "no change" when its version
  string is 'main'/'HEAD' — the SHA may have moved and preVersions
  doesn't track the prior SHA. Show "(refreshed)" instead.
- official-modules.js: match versionInfo.version to the manifest's
  cloneRef || (hasGitClone ? 'main' : version) expression so summary
  lines report the cloned ref for git-backed custom installs.
- install-bmad.md: clarify that sha is only written for git-backed
  modules and that rerunning the same --modules on another machine
  does not reproduce stable-channel installs — convert recorded tags
  into explicit --pin flags for cross-machine reproducibility.
2026-04-23 22:57:11 -05:00
Brian Madison b292cb9bfe fix(installer): review fixes + unit tests for channel resolution
- ui.js: import parseGitHubRepo; fixes ReferenceError in the
  interactive channel picker's stable-tag pre-resolve path.
- community-manager: pinned modules now fetch+checkout the pin tag
  on cache refresh instead of resetting to origin/HEAD (was silently
  drifting to main on re-install).
- channel-plan: parseChannelOptions returns acceptBypass so --yes
  auto-confirms the curator-bypass prompt; headless --next/--pin
  installs of community modules no longer hang.
- community-manager: simplify recordedVersion (dead ternary branch).
- custom-module-manager: drop "or sha" from the @<ref> comment
  (git clone --branch rejects raw SHAs); update-path fetches
  origin <ref> so /tree/<branch>/ URLs work too.
- install-bmad.md: rename "Headless / CI installs" to "Headless CI
  installs" so the stub's #headless-ci-installs anchor resolves.
- test/test-installer-channels.js: 83 unit tests for channel-plan
  and channel-resolver pure modules; wired into npm test as
  test:channels.
2026-04-23 21:38:48 -05:00
10 changed files with 472 additions and 61 deletions

View File

@ -55,11 +55,11 @@ Two independent axes control what ends up on disk.
Every external module — bmb, cis, gds, tea, and any community module — installs on one of three 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 | | Channel | What gets installed | Who picks this |
| --- | --- | --- | | ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
| `stable` (default) | Highest released semver tag. Prereleases like `v2.0.0-alpha.1` are excluded. | Most users | | `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 | | `next` | Main branch HEAD at install time | Contributors, early adopters |
| `pinned` | A specific tag you name | Enterprise installs, CI reproducibility | | `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. Channels are per-module. You can run bmb on `next` while leaving cis on `stable` — the flags below let you mix freely.
@ -67,10 +67,10 @@ Channels are per-module. You can run bmb on `next` while leaving cis on `stable`
The `bmad-method` npm package itself has two dist-tags: The `bmad-method` npm package itself has two dist-tags:
| Command | What you get | | Command | What you get |
| --- | --- | | ------------------------------------- | ----------------------------------------------------------------- |
| `npx bmad-method install` (`@latest`) | Latest stable installer release | | `npx bmad-method install` (`@latest`) | Latest stable installer release |
| `npx bmad-method@next install` | Latest prerelease installer, auto-published on every push to main | | `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. **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.
@ -88,20 +88,20 @@ They're stapled to the installer binary you ran:
Running `npx bmad-method install` in a directory that already contains `_bmad/` gives you a menu: Running `npx bmad-method install` in a directory that already contains `_bmad/` gives you a menu:
| Choice | What it does | | 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. | | **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. | | **Modify Install** | Full interactive flow. Add or remove modules, reconfigure settings, optionally review and switch channels for existing modules. |
### Upgrade prompts ### Upgrade prompts
When Modify detects a newer stable tag for a module you've installed on `stable`, it classifies the diff and prompts accordingly: 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 | | Upgrade type | Example | Default |
| --- | --- | --- | | ------------ | --------------- | ------- |
| Patch | v1.7.0 → v1.7.1 | Y | | Patch | v1.7.0 → v1.7.1 | Y |
| Minor | v1.7.0 → v1.8.0 | Y | | Minor | v1.7.0 → v1.8.0 | Y |
| Major | v1.7.0 → v2.0.0 | **N** | | 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. 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.
@ -113,24 +113,24 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen
**Via flags:** the recipes in the next section cover the common cases. **Via flags:** the recipes in the next section cover the common cases.
## Headless / CI installs ## Headless CI installs
### Flag reference ### Flag reference
| Flag | Purpose | | Flag | Purpose |
| --- | --- | | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `--yes`, `-y` | Skip all prompts; accept flag values + defaults | | `--yes`, `-y` | Skip all prompts; accept flag values + defaults |
| `--directory <path>` | Install into this directory (default: current working dir) | | `--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. | | `--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. | | `--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. | | `--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 | | `--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`) | | `--channel <stable\|next>` | Apply to all externals (aliased as `--all-stable` / `--all-next`) |
| `--all-stable` | Alias for `--channel=stable` | | `--all-stable` | Alias for `--channel=stable` |
| `--all-next` | Alias for `--channel=next` | | `--all-next` | Alias for `--channel=next` |
| `--next=<code>` | Put one module on next. Repeatable. | | `--next=<code>` | Put one module on next. Repeatable. |
| `--pin <code>=<tag>` | Pin one module to a specific tag. 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 | | `--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`). Precedence when flags overlap: `--pin` beats `--next=` beats `--channel` / `--all-*` beats the registry default (`stable`).
@ -191,14 +191,21 @@ After any install, `_bmad/_config/manifest.yaml` records exactly what's on disk:
```yaml ```yaml
modules: modules:
- name: bmb - name: bmb
version: v1.7.0 # the tag, or "main" for next version: v1.7.0 # the tag, or "main" for next
channel: stable # stable | next | pinned channel: stable # stable | next | pinned
sha: 86033fc9aeae2ca6d52c7cdb675c1f4bf17fc1c1 sha: 86033fc9aeae2ca6d52c7cdb675c1f4bf17fc1c1
source: external source: external
repoUrl: https://github.com/bmad-code-org/bmad-builder repoUrl: https://github.com/bmad-code-org/bmad-builder
``` ```
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. The `sha` field is written for git-backed modules (external, community, and URL-based custom). Bundled modules (core, bmm) and local-path custom modules don't have one — their code travels with the installer binary or your filesystem, not a cloneable ref.
For cross-machine reproducibility, don't rely on rerunning the same `--modules` command. Stable-channel installs resolve to the highest released tag **at install time**, so a later rerun lands on whatever has been released since. Convert the recorded tags from `manifest.yaml` into explicit `--pin` flags on the target machine, e.g.:
```bash
npx bmad-method install --yes --modules bmb,cis \
--pin bmb=v1.7.0 --pin cis=v0.4.2 --tools none
```
## Troubleshooting ## Troubleshooting

View File

@ -41,7 +41,8 @@
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills", "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills",
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check", "test": "npm run test:refs && npm run test:install && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
"test:channels": "node test/test-installer-channels.js",
"test:install": "node test/test-installation-components.js", "test:install": "node test/test-installation-components.js",
"test:refs": "node test/test-file-refs-csv.js", "test:refs": "node test/test-file-refs-csv.js",
"validate:refs": "node tools/validate-file-refs.js --strict", "validate:refs": "node tools/validate-file-refs.js --strict",

View File

@ -0,0 +1,348 @@
/**
* Installer Channel Resolution Tests
*
* Unit tests for the pure planning/resolution modules:
* - tools/installer/modules/channel-plan.js
* - tools/installer/modules/channel-resolver.js
*
* Neither module does I/O outside of GitHub tag lookups (which we don't
* exercise here) and semver math. All tests are deterministic.
*
* Usage: node test/test-installer-channels.js
*/
const {
parseChannelOptions,
decideChannelForModule,
buildPlan,
orphanPinWarnings,
bundledTargetWarnings,
parsePinSpec,
} = require('../tools/installer/modules/channel-plan');
const { parseGitHubRepo, normalizeStableTag, classifyUpgrade, releaseNotesUrl } = require('../tools/installer/modules/channel-resolver');
const colors = {
reset: '',
green: '',
red: '',
yellow: '',
cyan: '',
dim: '',
};
let passed = 0;
let failed = 0;
function assert(condition, testName, errorMessage = '') {
if (condition) {
console.log(`${colors.green}${colors.reset} ${testName}`);
passed++;
} else {
console.log(`${colors.red}${colors.reset} ${testName}`);
if (errorMessage) {
console.log(` ${colors.dim}${errorMessage}${colors.reset}`);
}
failed++;
}
}
function assertEqual(actual, expected, testName) {
const ok = actual === expected;
assert(ok, testName, ok ? '' : `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function section(title) {
console.log(`\n${colors.cyan}── ${title} ──${colors.reset}`);
}
function runTests() {
// ─────────────────────────────────────────────────────────────────────────
// channel-plan.js :: parsePinSpec
// ─────────────────────────────────────────────────────────────────────────
section('channel-plan :: parsePinSpec');
{
const r = parsePinSpec('bmb=v1.2.3');
assert(r && r.code === 'bmb' && r.tag === 'v1.2.3', 'valid CODE=TAG');
}
{
const r = parsePinSpec(' cis = v0.1.0 ');
assert(r && r.code === 'cis' && r.tag === 'v0.1.0', 'trims whitespace around code and tag');
}
assert(parsePinSpec('') === null, 'empty string returns null');
assert(parsePinSpec('bmb') === null, 'missing = returns null');
assert(parsePinSpec('=v1.0.0') === null, 'leading = returns null');
assert(parsePinSpec('bmb=') === null, 'trailing = returns null');
assert(parsePinSpec(null) === null, 'null input returns null');
let undef;
assert(parsePinSpec(undef) === null, 'undefined input returns null');
assert(parsePinSpec(42) === null, 'non-string input returns null');
// ─────────────────────────────────────────────────────────────────────────
// channel-plan.js :: parseChannelOptions
// ─────────────────────────────────────────────────────────────────────────
section('channel-plan :: parseChannelOptions');
{
const r = parseChannelOptions({});
assert(r.global === null, 'empty: global is null');
assert(r.nextSet instanceof Set && r.nextSet.size === 0, 'empty: nextSet is empty Set');
assert(r.pins instanceof Map && r.pins.size === 0, 'empty: pins is empty Map');
assert(Array.isArray(r.warnings) && r.warnings.length === 0, 'empty: no warnings');
assert(r.acceptBypass === false, 'empty: acceptBypass false by default');
}
{
const r = parseChannelOptions({ channel: 'stable' });
assertEqual(r.global, 'stable', '--channel=stable sets global');
}
{
const r = parseChannelOptions({ channel: 'NEXT' });
assertEqual(r.global, 'next', '--channel is case-insensitive');
}
{
const r = parseChannelOptions({ allStable: true });
assertEqual(r.global, 'stable', '--all-stable sets global stable');
}
{
const r = parseChannelOptions({ allNext: true });
assertEqual(r.global, 'next', '--all-next sets global next');
}
{
const r = parseChannelOptions({ channel: 'bogus' });
assert(r.global === null, 'invalid --channel value is rejected (global stays null)');
assert(
r.warnings.some((w) => w.includes("Ignoring invalid --channel value 'bogus'")),
'invalid --channel produces a warning',
);
}
{
// --all-stable and --all-next conflict → warning, first-wins
const r = parseChannelOptions({ allStable: true, allNext: true });
assertEqual(r.global, 'stable', 'conflict: first flag (--all-stable) wins');
assert(
r.warnings.some((w) => w.includes('Conflicting channel flags')),
'conflict produces warning',
);
}
{
const r = parseChannelOptions({ next: ['bmb', 'cis', ' '] });
assert(r.nextSet.has('bmb') && r.nextSet.has('cis'), '--next=CODE adds to nextSet');
assert(!r.nextSet.has(''), 'blank --next entries are skipped');
}
{
const r = parseChannelOptions({ pin: ['bmb=v1.0.0', 'cis=v2.0.0'] });
assertEqual(r.pins.get('bmb'), 'v1.0.0', '--pin bmb=v1.0.0 recorded');
assertEqual(r.pins.get('cis'), 'v2.0.0', '--pin cis=v2.0.0 recorded');
}
{
const r = parseChannelOptions({ pin: ['bmb=v1.0.0', 'bmb=v1.1.0'] });
assertEqual(r.pins.get('bmb'), 'v1.1.0', 'duplicate --pin: last wins');
assert(
r.warnings.some((w) => w.includes('--pin specified multiple times')),
'duplicate --pin produces warning',
);
}
{
const r = parseChannelOptions({ pin: ['malformed-no-equals'] });
assert(r.pins.size === 0, 'malformed --pin is ignored');
assert(
r.warnings.some((w) => w.includes('malformed --pin')),
'malformed --pin warns',
);
}
{
const r = parseChannelOptions({ yes: true });
assertEqual(r.acceptBypass, true, '--yes sets acceptBypass so curator-bypass prompt is auto-confirmed');
}
{
const r = parseChannelOptions({ acceptBypass: true });
assertEqual(r.acceptBypass, true, 'explicit acceptBypass: true honored');
}
// ─────────────────────────────────────────────────────────────────────────
// channel-plan.js :: decideChannelForModule (precedence)
// ─────────────────────────────────────────────────────────────────────────
section('channel-plan :: decideChannelForModule (precedence)');
const emptyOpts = parseChannelOptions({});
{
const r = decideChannelForModule({ code: 'bmb', channelOptions: emptyOpts });
assertEqual(r.channel, 'stable', 'no signal → stable default');
assertEqual(r.source, 'default', 'source: default');
}
{
const r = decideChannelForModule({ code: 'bmb', channelOptions: emptyOpts, registryDefault: 'next' });
assertEqual(r.channel, 'next', 'registry default applied when no flags');
assertEqual(r.source, 'registry', 'source: registry');
}
{
const r = decideChannelForModule({ code: 'bmb', channelOptions: emptyOpts, registryDefault: 'bogus' });
assertEqual(r.channel, 'stable', 'invalid registry default ignored, falls to stable');
}
{
const opts = parseChannelOptions({ channel: 'next' });
const r = decideChannelForModule({ code: 'bmb', channelOptions: opts, registryDefault: 'stable' });
assertEqual(r.channel, 'next', 'global --channel beats registry default');
assertEqual(r.source, 'flag:--channel', 'source reflects --channel origin');
}
{
const opts = parseChannelOptions({ channel: 'stable', next: ['bmb'] });
const r = decideChannelForModule({ code: 'bmb', channelOptions: opts });
assertEqual(r.channel, 'next', '--next=bmb beats --channel=stable for bmb');
assertEqual(r.source, 'flag:--next', 'source: flag:--next');
}
{
const opts = parseChannelOptions({ channel: 'next', pin: ['bmb=v1.0.0'] });
const r = decideChannelForModule({ code: 'bmb', channelOptions: opts });
assertEqual(r.channel, 'pinned', '--pin beats --channel');
assertEqual(r.pin, 'v1.0.0', 'pin value carried through');
assertEqual(r.source, 'flag:--pin', 'source: flag:--pin');
}
{
const opts = parseChannelOptions({ next: ['bmb'], pin: ['bmb=v1.0.0'] });
const r = decideChannelForModule({ code: 'bmb', channelOptions: opts });
assertEqual(r.channel, 'pinned', '--pin beats --next for same code');
}
// ─────────────────────────────────────────────────────────────────────────
// channel-plan.js :: buildPlan, orphanPinWarnings, bundledTargetWarnings
// ─────────────────────────────────────────────────────────────────────────
section('channel-plan :: buildPlan / warnings');
{
const opts = parseChannelOptions({ allStable: true, pin: ['bmb=v1.0.0'] });
const plan = buildPlan({
modules: [
{ code: 'bmb', defaultChannel: 'stable' },
{ code: 'cis', defaultChannel: 'stable' },
],
channelOptions: opts,
});
assertEqual(plan.get('bmb').channel, 'pinned', 'buildPlan: bmb pinned');
assertEqual(plan.get('cis').channel, 'stable', 'buildPlan: cis stable via global');
}
{
const opts = parseChannelOptions({ pin: ['ghost=v1.0.0', 'bmb=v1.0.0'], next: ['gds'] });
const warnings = orphanPinWarnings(opts, ['bmb']);
assert(
warnings.some((w) => w.includes("--pin for 'ghost'")),
'orphanPinWarnings: flags pin for unselected module',
);
assert(
warnings.some((w) => w.includes("--next for 'gds'")),
'orphanPinWarnings: flags --next for unselected module',
);
assert(!warnings.some((w) => w.includes("'bmb'")), 'orphanPinWarnings: no warning for selected module');
}
{
const opts = parseChannelOptions({ pin: ['bmm=v1.0.0'], next: ['core'] });
const warnings = bundledTargetWarnings(opts, ['core', 'bmm']);
assert(
warnings.some((w) => w.includes('bundled module')),
'bundledTargetWarnings: warns bundled pin',
);
assert(warnings.length === 2, 'bundledTargetWarnings: both pin and next warned');
}
// ─────────────────────────────────────────────────────────────────────────
// channel-resolver.js :: parseGitHubRepo
// ─────────────────────────────────────────────────────────────────────────
section('channel-resolver :: parseGitHubRepo');
{
const r = parseGitHubRepo('https://github.com/bmad-code-org/BMAD-METHOD');
assert(r && r.owner === 'bmad-code-org' && r.repo === 'BMAD-METHOD', 'https URL basic');
}
{
const r = parseGitHubRepo('https://github.com/bmad-code-org/BMAD-METHOD.git');
assert(r && r.repo === 'BMAD-METHOD', '.git suffix stripped');
}
{
const r = parseGitHubRepo('https://github.com/bmad-code-org/BMAD-METHOD/');
assert(r && r.repo === 'BMAD-METHOD', 'trailing slash stripped');
}
{
const r = parseGitHubRepo('https://github.com/org/repo/tree/main/subdir');
assert(r && r.owner === 'org' && r.repo === 'repo', 'deep path yields owner/repo');
}
{
const r = parseGitHubRepo('git@github.com:org/repo.git');
assert(r && r.owner === 'org' && r.repo === 'repo', 'SSH URL parsed');
}
assert(parseGitHubRepo('https://gitlab.com/foo/bar') === null, 'non-github URL returns null');
assert(parseGitHubRepo('') === null, 'empty string returns null');
assert(parseGitHubRepo(null) === null, 'null input returns null');
assert(parseGitHubRepo(123) === null, 'non-string input returns null');
// ─────────────────────────────────────────────────────────────────────────
// channel-resolver.js :: normalizeStableTag
// ─────────────────────────────────────────────────────────────────────────
section('channel-resolver :: normalizeStableTag');
assertEqual(normalizeStableTag('v1.2.3'), '1.2.3', 'strips leading v');
assertEqual(normalizeStableTag('1.2.3'), '1.2.3', 'bare semver accepted');
assertEqual(normalizeStableTag('v1.2.3-alpha.1'), null, 'prerelease -alpha excluded');
assertEqual(normalizeStableTag('v1.2.3-beta'), null, 'prerelease -beta excluded');
assertEqual(normalizeStableTag('v1.2.3-rc.1'), null, 'prerelease -rc excluded');
assertEqual(normalizeStableTag('not-a-version'), null, 'invalid string returns null');
assertEqual(normalizeStableTag('v1.2'), null, 'incomplete semver returns null');
assertEqual(normalizeStableTag(null), null, 'null returns null');
assertEqual(normalizeStableTag(123), null, 'non-string returns null');
// ─────────────────────────────────────────────────────────────────────────
// channel-resolver.js :: classifyUpgrade
// ─────────────────────────────────────────────────────────────────────────
section('channel-resolver :: classifyUpgrade');
assertEqual(classifyUpgrade('v1.2.3', 'v1.2.3'), 'none', 'equal versions → none');
assertEqual(classifyUpgrade('v1.2.3', 'v1.2.2'), 'none', 'downgrade → none');
assertEqual(classifyUpgrade('v1.2.3', 'v1.2.4'), 'patch', 'patch bump');
assertEqual(classifyUpgrade('v1.2.3', 'v1.3.0'), 'minor', 'minor bump');
assertEqual(classifyUpgrade('v1.2.3', 'v2.0.0'), 'major', 'major bump');
assertEqual(classifyUpgrade('1.2.3', '1.2.4'), 'patch', 'unprefixed versions work');
assertEqual(classifyUpgrade('main', 'v1.2.3'), 'unknown', 'non-semver current → unknown');
assertEqual(classifyUpgrade('v1.2.3', 'main'), 'unknown', 'non-semver next → unknown');
assertEqual(classifyUpgrade('', ''), 'unknown', 'both empty → unknown');
// ─────────────────────────────────────────────────────────────────────────
// channel-resolver.js :: releaseNotesUrl
// ─────────────────────────────────────────────────────────────────────────
section('channel-resolver :: releaseNotesUrl');
assertEqual(
releaseNotesUrl('https://github.com/bmad-code-org/BMAD-METHOD', 'v1.2.3'),
'https://github.com/bmad-code-org/BMAD-METHOD/releases/tag/v1.2.3',
'builds standard release URL',
);
assertEqual(releaseNotesUrl('https://gitlab.com/foo/bar', 'v1.0.0'), null, 'non-github repo → null');
assertEqual(releaseNotesUrl('https://github.com/foo/bar', null), null, 'null tag → null');
assertEqual(releaseNotesUrl('', 'v1.0.0'), null, 'empty URL → null');
// ─────────────────────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────────────────────
console.log('');
console.log(`${colors.cyan}========================================`);
console.log('Test Results:');
console.log(` Passed: ${colors.green}${passed}${colors.reset}`);
console.log(` Failed: ${colors.red}${failed}${colors.reset}`);
console.log(`========================================${colors.reset}\n`);
if (failed === 0) {
console.log(`${colors.green}✨ All channel resolution tests passed!${colors.reset}\n`);
process.exit(0);
} else {
console.log(`${colors.red}❌ Some channel resolution tests failed${colors.reset}\n`);
process.exit(1);
}
}
try {
runTests();
} catch (error) {
console.error(`${colors.red}Test runner failed:${colors.reset}`, error.message);
console.error(error.stack);
process.exit(1);
}

View File

@ -614,20 +614,26 @@ class Installer {
const displayName = moduleInfo?.name || moduleName; const displayName = moduleInfo?.name || moduleName;
const externalResolution = officialModules.externalModuleManager.getResolution(moduleName); const externalResolution = officialModules.externalModuleManager.getResolution(moduleName);
let communityResolution = null;
if (!externalResolution) {
const { CommunityModuleManager } = require('../modules/community-manager');
communityResolution = new CommunityModuleManager().getResolution(moduleName);
}
const resolution = externalResolution || communityResolution;
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName); const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, { const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath: sourcePath, moduleSourcePath: sourcePath,
fallbackVersion: externalResolution?.version || cachedResolution?.version, fallbackVersion: resolution?.version || cachedResolution?.version,
marketplacePluginNames: cachedResolution?.pluginName ? [cachedResolution.pluginName] : [], marketplacePluginNames: cachedResolution?.pluginName ? [cachedResolution.pluginName] : [],
}); });
// Prefer the git tag recorded by the external resolution (e.g. "v1.7.0") over // Prefer the git tag recorded by the resolution (e.g. "v1.7.0") over
// the on-disk package.json (which may be ahead of the released tag). // the on-disk package.json (which may be ahead of the released tag).
const version = externalResolution?.version || versionInfo.version || ''; const version = resolution?.version || versionInfo.version || '';
addResult(displayName, 'ok', '', { addResult(displayName, 'ok', '', {
moduleCode: moduleName, moduleCode: moduleName,
newVersion: version, newVersion: version,
newChannel: externalResolution?.channel || null, newChannel: resolution?.channel || null,
newSha: externalResolution?.sha || null, newSha: resolution?.sha || null,
}); });
} }
} }
@ -1114,8 +1120,15 @@ class Installer {
return v; return v;
}; };
const newV = fmt(r.newVersion, r.newSha); const newV = fmt(r.newVersion, r.newSha);
if (oldVersion && oldVersion === r.newVersion) { // 'main'/'HEAD' strings only identify the channel, not the commit, so
// we can't assert "no change" without comparing SHAs — and preVersions
// doesn't carry the old SHA. Render these as a refresh instead of a
// false-negative "no change".
const isMainLike = oldVersion === 'main' || oldVersion === 'HEAD';
if (oldVersion && oldVersion === r.newVersion && !isMainLike) {
detail = ` (${newV}, no change)`; detail = ` (${newV}, no change)`;
} else if (oldVersion && isMainLike) {
detail = ` (${newV}, refreshed)`;
} else if (oldVersion) { } else if (oldVersion) {
detail = ` (${fmt(oldVersion, r.newSha)}${newV})`; detail = ` (${fmt(oldVersion, r.newSha)}${newV})`;
} else { } else {

View File

@ -72,7 +72,11 @@ function parseChannelOptions(options = {}) {
pins.set(parsed.code, parsed.tag); pins.set(parsed.code, parsed.tag);
} }
return { global, nextSet, pins, warnings }; // --yes auto-confirms the community-module curator-bypass prompt so
// headless installs with --next=/--pin for a community module don't hang.
const acceptBypass = options.yes === true || options.acceptBypass === true;
return { global, nextSet, pins, warnings, acceptBypass };
} }
function normalizeChannel(raw, warnings, flagName) { function normalizeChannel(raw, warnings, flagName) {

View File

@ -253,8 +253,10 @@ class CommunityModuleManager {
let wasNewClone = false; let wasNewClone = false;
if (await fs.pathExists(moduleCacheDir)) { if (await fs.pathExists(moduleCacheDir)) {
// Already cloned — refresh to current origin/HEAD before we decide the // Already cloned — refresh to the correct ref for the resolved channel.
// final ref. We may still check out a tag or approved SHA after this. // A pinned install must not reset to origin/HEAD (it would silently drift
// to main on every re-install). Stable + approvedSha is handled below
// by the curator-SHA checkout logic.
const fetchSpinner = await createSpinner(); const fetchSpinner = await createSpinner();
fetchSpinner.start(`Checking ${moduleInfo.displayName}...`); fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
try { try {
@ -264,10 +266,24 @@ class CommunityModuleManager {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}); });
execSync('git reset --hard origin/HEAD', { if (planEntry.channel === 'pinned') {
cwd: moduleCacheDir, // Fetch the pin tag specifically and check it out.
stdio: ['ignore', 'pipe', 'pipe'], execSync(`git fetch --depth 1 origin ${quoteShellRef(planEntry.pin)} --no-tags`, {
}); cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync('git checkout --quiet FETCH_HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
} else {
// stable (approvedSha path re-checks out below) and next: track main.
execSync('git reset --hard origin/HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
}
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
if (currentRef !== newRef) needsDependencyInstall = true; if (currentRef !== newRef) needsDependencyInstall = true;
fetchSpinner.stop(`Verified ${moduleInfo.displayName}`); fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
@ -343,11 +359,7 @@ class CommunityModuleManager {
// Record the resolution so the manifest writer can pick up channel/version/sha. // Record the resolution so the manifest writer can pick up channel/version/sha.
const installedSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); const installedSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
const recordedVersion = const recordedVersion =
planEntry.channel === 'pinned' planEntry.channel === 'pinned' ? planEntry.pin : planEntry.channel === 'next' ? 'main' : approvedTag || installedSha.slice(0, 7);
? planEntry.pin
: planEntry.channel === 'next'
? 'main'
: approvedTag || (installedSha === approvedSha ? approvedTag : installedSha.slice(0, 7));
CommunityModuleManager._resolutions.set(moduleCode, { CommunityModuleManager._resolutions.set(moduleCode, {
channel: planEntry.channel, channel: planEntry.channel,
version: recordedVersion, version: recordedVersion,

View File

@ -59,8 +59,10 @@ class CustomModuleManager {
}; };
} }
// Extract optional @<tag-or-sha> suffix from the end of the input. // Extract optional @<tag-or-branch> suffix from the end of the input.
// Semver-valid characters: letters, digits, dot, hyphen, underscore, plus, slash // Semver-valid characters: letters, digits, dot, hyphen, underscore, plus, slash.
// Raw commit SHAs are NOT supported here — `git clone --branch` can't take
// them; use --pin at the module level or check out the SHA manually.
// Only strip when the tail looks like a ref, so we don't disturb // Only strip when the tail looks like a ref, so we don't disturb
// URLs without a version spec or the SSH protocol's `git@host:...` prefix. // URLs without a version spec or the SSH protocol's `git@host:...` prefix.
let trimmed = trimmedRaw; let trimmed = trimmedRaw;
@ -364,7 +366,10 @@ class CustomModuleManager {
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}); });
if (effectiveVersion) { if (effectiveVersion) {
execSync(`git fetch --depth 1 origin tag ${quoteCustomRef(effectiveVersion)} --no-tags`, { // Fetch the ref as either a tag or a branch — `origin <ref>` works
// for both, whereas `origin tag <ref>` fails for branch refs parsed
// out of /tree/<branch>/... URLs.
execSync(`git fetch --depth 1 origin ${quoteCustomRef(effectiveVersion)} --no-tags`, {
cwd: repoCacheDir, cwd: repoCacheDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },

View File

@ -249,6 +249,17 @@ class ExternalModuleManager {
registryDefault: moduleInfo.defaultChannel, registryDefault: moduleInfo.defaultChannel,
}); });
// Same-plan short-circuit: a single install calls cloneExternalModule
// several times (config collection, directory setup, help-catalog rebuild)
// with the same channelOptions. The first call resolves + clones; later
// calls with an identical plan and a valid cache should return immediately
// instead of re-running resolveChannel() and `git fetch` (slow; can fail
// on flaky networks even though the tagCache dedupes the GitHub API hit).
if (existingResolution && haveUsableCache && existingResolution.channel === planEntry.channel) {
const samePin = planEntry.channel !== 'pinned' || existingResolution.version === planEntry.pin;
if (samePin) return moduleCacheDir;
}
let resolved; let resolved;
try { try {
resolved = await resolveChannel({ resolved = await resolveChannel({

View File

@ -386,7 +386,10 @@ class OfficialModules {
success: true, success: true,
module: resolved.code, module: resolved.code,
path: targetPath, path: targetPath,
versionInfo: { version: resolved.cloneRef || resolved.version || '' }, // Match the manifestEntry.version expression above so downstream summary
// lines show the cloned ref (tag or 'main') instead of the on-disk
// package.json version for git-backed custom installs.
versionInfo: { version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || '') },
}; };
} }

View File

@ -1681,7 +1681,7 @@ class UI {
if (fastPath) return; // stable for all, registry default applies if (fastPath) return; // stable for all, registry default applies
// Customize path: per-module picker. // Customize path: per-module picker.
const { fetchStableTags } = require('./modules/channel-resolver'); const { fetchStableTags, parseGitHubRepo } = require('./modules/channel-resolver');
for (const code of channelSelectable) { for (const code of channelSelectable) {
const info = externalByCode.get(code) || communityByCode.get(code); const info = externalByCode.get(code) || communityByCode.get(code);
@ -1690,7 +1690,7 @@ class UI {
// Try to pre-resolve the top stable tag so we can surface it in the picker. // Try to pre-resolve the top stable tag so we can surface it in the picker.
let stableLabel = 'stable (released version)'; let stableLabel = 'stable (released version)';
try { try {
const parsed = repoUrl ? parseGitHubRepoFromUrl(repoUrl) : null; const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
if (parsed) { if (parsed) {
const tags = await fetchStableTags(parsed.owner, parsed.repo); const tags = await fetchStableTags(parsed.owner, parsed.repo);
if (tags.length > 0) { if (tags.length > 0) {
@ -1899,6 +1899,13 @@ class UI {
// Stable channel: check for a newer released tag. // Stable channel: check for a newer released tag.
if (!parsed) continue; if (!parsed) continue;
// Respect explicit CLI intent (--pin / --next=CODE / --all-*) and any
// choice the user already made in the earlier review gate. Without this
// guard the upgrade classifier below would unconditionally call
// `channelOptions.pins.set(code, prev.version)` on decline/major-refuse/
// fetch-error, silently clobbering the user's override.
const alreadyDecided = channelOptions.global || channelOptions.nextSet.has(code) || channelOptions.pins.has(code);
if (alreadyDecided) continue;
let tags; let tags;
try { try {
tags = await fetchStableTags(parsed.owner, parsed.repo); tags = await fetchStableTags(parsed.owner, parsed.repo);