fix(installer): seed channelOptions before module picker, not gate

CodeRabbit caught a label/install mismatch in the previous approach: the
module picker resolves version labels via decideChannelForModule, which runs
before _interactiveChannelGate. With channelOptions.global still null at
picker time, labels rendered from stable tags — then the gate flipped global
to 'next' and externals installed from main HEAD. Net effect on @next launches:
"tea (v1.6.0)" in the picker, but install pulled HEAD.

Move the launch detection up into promptInstall, immediately after
parseChannelOptions. Seeding channelOptions.global = 'next' before the picker
makes labels resolve from main HEAD (matching the install) and lets the
existing gate's haveFlagIntent check skip cleanly — the @next user already
declared their intent by typing it. Per-module customization remains available
via --pin / --next / --channel flags, same as for any pre-set global.
This commit is contained in:
Brian Madison 2026-04-26 10:53:42 -05:00
parent 30d94a878d
commit 43b29015de
1 changed files with 30 additions and 26 deletions

View File

@ -129,6 +129,24 @@ class UI {
await prompts.log.warn(warning); await prompts.log.warn(warning);
} }
// When the user launched the installer from a prerelease (npx bmad-method@next),
// mirror that intent for external modules: seed the global channel to 'next' so
// the module picker's version labels resolve from main HEAD (matching what
// actually gets installed) and the interactive channel gate skips — the user
// already declared "next" intent by typing @next. Explicit channel flags
// override this seed.
if (
semver.prerelease(installerPackageJson.version) !== null &&
!channelOptions.global &&
channelOptions.nextSet.size === 0 &&
channelOptions.pins.size === 0
) {
channelOptions.global = 'next';
await prompts.log.info(
'Launched from a prerelease — installing all external modules from main HEAD (next channel). Pass --all-stable or --pin to override.',
);
}
// Get directory from options or prompt // Get directory from options or prompt
let confirmedDirectory; let confirmedDirectory;
if (options.directory) { if (options.directory) {
@ -331,10 +349,10 @@ class UI {
selectedModules.unshift('core'); selectedModules.unshift('core');
} }
// Interactive channel gate: "Ready to install (all stable)? [Y/n]" — or // Interactive channel gate: "Ready to install (all stable)? [Y/n]"
// "(all next)" when the installer was launched from a prerelease // Only shown for fresh installs with no channel flags and an external module
// (npx bmad-method@next). Only shown for fresh installs with no channel // selected. Skipped for prerelease launches because channelOptions.global
// flags and an external module selected. Non-interactive installs skip this // was already seeded to 'next' upstream. Non-interactive installs skip this
// and fall through to the registry default (stable) or whatever flags were // and fall through to the registry default (stable) or whatever flags were
// supplied. // supplied.
await this._interactiveChannelGate({ options, channelOptions, selectedModules }); await this._interactiveChannelGate({ options, channelOptions, selectedModules });
@ -1782,17 +1800,16 @@ class UI {
} }
/** /**
* Fast-path channel gate: confirm "all stable" / "all next" or open the * Fast-path channel gate: confirm "all stable" or open the per-module picker.
* per-module picker. The default channel mirrors the installer's launch tag
* a prerelease bmad-method (npx bmad-method@next) defaults the gate to "all
* next"; a stable launch keeps the original "all stable" default.
* *
* Skipped when: * Skipped when:
* - running non-interactively (--yes) * - running non-interactively (--yes)
* - the user already passed channel flags (--channel / --pin / --next) * - the user already passed channel flags (--channel / --pin / --next), OR
* the installer was launched from a prerelease (which seeds
* channelOptions.global = 'next' upstream in promptInstall)
* - no externals/community modules are selected * - no externals/community modules are selected
* *
* Mutates channelOptions.global / .pins / .nextSet to reflect gate + picker choices. * Mutates channelOptions.pins and channelOptions.nextSet to reflect picker choices.
*/ */
async _interactiveChannelGate({ options, channelOptions, selectedModules }) { async _interactiveChannelGate({ options, channelOptions, selectedModules }) {
if (options.yes) return; if (options.yes) return;
@ -1818,24 +1835,11 @@ class UI {
}); });
if (channelSelectable.length === 0) return; if (channelSelectable.length === 0) return;
// When the user launched the installer from a prerelease (npx bmad-method@next),
// mirror that intent for external modules: default the gate to "all next" so the
// bleeding-edge launch flows end-to-end. Stable launches keep the original
// "all stable" default.
const launchedFromPrerelease = semver.prerelease(installerPackageJson.version) !== null;
const fastPathChannel = launchedFromPrerelease ? 'next' : 'stable';
const fastPath = await prompts.confirm({ const fastPath = await prompts.confirm({
message: `Ready to install (all ${fastPathChannel})? Pick "n" to customize channels or pin versions.`, message: `Ready to install (all stable)? Pick "n" to customize channels or pin versions.`,
default: true, default: true,
}); });
if (fastPath) { if (fastPath) return; // stable for all, registry default applies
if (fastPathChannel === 'next') {
channelOptions.global = 'next';
}
// stable: leave channelOptions untouched; registry default applies.
return;
}
// Customize path: per-module picker. // Customize path: per-module picker.
const { fetchStableTags, parseGitHubRepo } = require('./modules/channel-resolver'); const { fetchStableTags, parseGitHubRepo } = require('./modules/channel-resolver');
@ -1865,7 +1869,7 @@ class UI {
{ name: 'next (main HEAD \u2014 current development)', value: 'next' }, { name: 'next (main HEAD \u2014 current development)', value: 'next' },
{ name: 'pin (specific version)', value: 'pin' }, { name: 'pin (specific version)', value: 'pin' },
], ],
default: fastPathChannel, default: 'stable',
}); });
if (choice === 'next') { if (choice === 'next') {