Compare commits

..

3 Commits

Author SHA1 Message Date
Curtis Ide be85e5b4a0
fix(installer): support local custom-source modules in resolveInstalledModuleYaml and TOML key (#2316)
- resolveInstalledModuleYaml: fall back to CustomModuleManager._resolutionCache for local
  custom-source modules (external cache path doesn't exist for these); refactor candidate-path
  search into shared searchRoot() helper; add *-setup/assets/module.yaml BMB standard path
- manifest-generator: use module code field (not display name) as TOML section key [modules.X]

Co-authored-by: cidemaxio <cidemaxio@users.noreply.github.com>
2026-04-26 12:55:56 -05:00
Brian 04cfde1454
fix(installer): mirror launch channel as default for external modules (#2321)
* fix(installer): mirror launch channel as default for external modules

When the user runs `npx bmad-method@next install`, the installer itself
runs from a prerelease, but the interactive channel gate previously hardcoded
"(all stable)" — defaulting tea/community modules to stable while bmad-method
itself was on next. The bleeding-edge launch did not flow through.

Detect the installer's own version via semver.prerelease() and default the
gate (and per-module picker) to match — "all next" for prerelease launches,
"all stable" for stable. Users keep full control: hit "n" to customize per
module, or pass explicit --channel / --pin / --next flags to override.

* 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.
2026-04-26 10:54:38 -05:00
Brian 7baa30c567
fix(publish): advance @next dist-tag after stable release (#2320)
* fix(publish): advance @next dist-tag after stable release

When a stable release publishes via workflow_dispatch, @latest can leapfrog
the existing @next prerelease (e.g. latest=6.5.0 while next=6.4.1-next.0),
turning `npx bmad-method@next install` into a silent downgrade until the
next qualifying push to main republishes a fresh -next.0.

- publish.yaml: after stable publish, repoint @next at the just-published
  stable version. The existing derive-prerelease step picks max(latest, next)
  as its base, so subsequent push-driven prereleases bump from there.
- bmad-cli.js: checkForUpdate was querying the @beta dist-tag (which this
  package does not use). Replace string-matching with semver.prerelease()
  and query @next for prerelease users.

* fix(publish): harden next-tag advance step and broaden path filter

- continue-on-error on the dist-tag advance: failure leaves @next stale
  until the next push-driven prerelease, which is recoverable; failing the
  job after a successful publish + git tag + GH release is not.
- Status echo so release-log triage can confirm the advance ran.
- Add removals.txt to the push-trigger path filter. Installer-affecting
  changes outside src/** (like the post-6.5.0 removals.txt fix) should
  still trigger a fresh -next.0 publish.
2026-04-26 10:30:41 -05:00
5 changed files with 103 additions and 27 deletions

View File

@ -7,6 +7,7 @@ on:
- "src/**" - "src/**"
- "tools/installer/**" - "tools/installer/**"
- "package.json" - "package.json"
- "removals.txt"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
channel: channel:
@ -135,6 +136,22 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Advance @next dist-tag to stable
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'latest'
# Failure here leaves @next stale until the next push-driven prerelease
# republishes — annoying but not release-breaking. Don't fail the job
# after a successful stable publish + tag + GH release.
continue-on-error: true
run: |
# Without this, @latest can leapfrog @next (e.g. latest=6.5.0 while
# next=6.4.1-next.0) and `npx bmad-method@next install` silently
# downgrades users. Point @next at the just-published stable so
# @next >= @latest always holds; the next push-driven prerelease will
# bump from this base via the existing derive step above.
VERSION=$(node -p 'require("./package.json").version')
npm dist-tag add "bmad-method@${VERSION}" next
echo "Advanced @next dist-tag to ${VERSION}"
- name: Notify Discord - name: Notify Discord
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'latest' if: github.event_name == 'workflow_dispatch' && inputs.channel == 'latest'
continue-on-error: true continue-on-error: true

View File

@ -23,13 +23,10 @@ checkForUpdate().catch(() => {
async function checkForUpdate() { async function checkForUpdate() {
try { try {
// For beta versions, check the beta tag; otherwise check latest // Prereleases (e.g. 6.5.1-next.0) live on the `next` dist-tag; stable
const isBeta = // releases live on `latest`. semver.prerelease() returns null for stable,
packageJson.version.includes('Beta') || // so this correctly routes pre-1.0-next/rc/etc. without string matching.
packageJson.version.includes('beta') || const tag = semver.prerelease(packageJson.version) ? 'next' : 'latest';
packageJson.version.includes('alpha') ||
packageJson.version.includes('rc');
const tag = isBeta ? 'beta' : 'latest';
const result = execSync(`npm view ${packageName}@${tag} version`, { const result = execSync(`npm view ${packageName}@${tag} version`, {
encoding: 'utf8', encoding: 'utf8',

View File

@ -435,6 +435,9 @@ class ManifestGenerator {
// this means user-scoped keys (e.g. user_name) could mis-file into the // this means user-scoped keys (e.g. user_name) could mis-file into the
// team config, so the operator should notice. // team config, so the operator should notice.
const scopeByModuleKey = {}; const scopeByModuleKey = {};
// Maps installer moduleName (may be full display name) → module code field
// from module.yaml, so TOML sections use [modules.<code>] not [modules.<name>].
const codeByModuleName = {};
for (const moduleName of this.updatedModules) { for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName); const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) { if (!moduleYamlPath) {
@ -447,6 +450,7 @@ class ManifestGenerator {
try { try {
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8')); const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
if (!parsed || typeof parsed !== 'object') continue; if (!parsed || typeof parsed !== 'object') continue;
if (parsed.code) codeByModuleName[moduleName] = parsed.code;
scopeByModuleKey[moduleName] = {}; scopeByModuleKey[moduleName] = {};
for (const [key, value] of Object.entries(parsed)) { for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value) { if (value && typeof value === 'object' && 'prompt' in value) {
@ -545,6 +549,9 @@ class ManifestGenerator {
if (moduleName === 'core') continue; if (moduleName === 'core') continue;
const cfg = moduleConfigs[moduleName]; const cfg = moduleConfigs[moduleName];
if (!cfg || Object.keys(cfg).length === 0) continue; if (!cfg || Object.keys(cfg).length === 0) continue;
// Use the module's code field from module.yaml as the TOML key so the
// section is [modules.mdo] not [modules.MDO: Maxio DevOps Operations].
const sectionKey = codeByModuleName[moduleName] || moduleName;
// Only filter out spread-from-core pollution when we actually know // Only filter out spread-from-core pollution when we actually know
// this module's prompt schema. For external/marketplace modules whose // this module's prompt schema. For external/marketplace modules whose
// module.yaml isn't in the src tree, fall through as all-team so we // module.yaml isn't in the src tree, fall through as all-team so we
@ -552,14 +559,14 @@ class ManifestGenerator {
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0; const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema); const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema);
if (Object.keys(modTeam).length > 0) { if (Object.keys(modTeam).length > 0) {
teamLines.push(`[modules.${moduleName}]`); teamLines.push(`[modules.${sectionKey}]`);
for (const [key, value] of Object.entries(modTeam)) { for (const [key, value] of Object.entries(modTeam)) {
teamLines.push(`${key} = ${formatTomlValue(value)}`); teamLines.push(`${key} = ${formatTomlValue(value)}`);
} }
teamLines.push(''); teamLines.push('');
} }
if (Object.keys(modUser).length > 0) { if (Object.keys(modUser).length > 0) {
userLines.push(`[modules.${moduleName}]`); userLines.push(`[modules.${sectionKey}]`);
for (const [key, value] of Object.entries(modUser)) { for (const [key, value] of Object.entries(modUser)) {
userLines.push(`${key} = ${formatTomlValue(value)}`); userLines.push(`${key} = ${formatTomlValue(value)}`);
} }

View File

@ -86,6 +86,8 @@ function getExternalModuleCachePath(moduleName, ...segments) {
* Built-in modules (core, bmm) live under <src>. External official modules are * Built-in modules (core, bmm) live under <src>. External official modules are
* cloned into ~/.bmad/cache/external-modules/<name>/ with varying internal * cloned into ~/.bmad/cache/external-modules/<name>/ with varying internal
* layouts (some at src/module.yaml, some at skills/module.yaml, some nested). * layouts (some at src/module.yaml, some at skills/module.yaml, some nested).
* Local custom-source modules are not cached; their path is read from the
* CustomModuleManager resolution cache set during the same install run.
* This mirrors the candidate-path search in * This mirrors the candidate-path search in
* ExternalModuleManager.findExternalModuleSource but performs no git/network * ExternalModuleManager.findExternalModuleSource but performs no git/network
* work, which keeps it safe to call during manifest writing. * work, which keeps it safe to call during manifest writing.
@ -97,14 +99,13 @@ async function resolveInstalledModuleYaml(moduleName) {
const builtIn = path.join(getModulePath(moduleName), 'module.yaml'); const builtIn = path.join(getModulePath(moduleName), 'module.yaml');
if (await fs.pathExists(builtIn)) return builtIn; if (await fs.pathExists(builtIn)) return builtIn;
const cacheRoot = getExternalModuleCachePath(moduleName); // Search a resolved root directory using the same candidate-path pattern.
if (!(await fs.pathExists(cacheRoot))) return null; async function searchRoot(root) {
for (const dir of ['skills', 'src']) { for (const dir of ['skills', 'src']) {
const direct = path.join(cacheRoot, dir, 'module.yaml'); const direct = path.join(root, dir, 'module.yaml');
if (await fs.pathExists(direct)) return direct; if (await fs.pathExists(direct)) return direct;
const dirPath = path.join(cacheRoot, dir); const dirPath = path.join(root, dir);
if (await fs.pathExists(dirPath)) { if (await fs.pathExists(dirPath)) {
const entries = await fs.readdir(dirPath, { withFileTypes: true }); const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
@ -115,8 +116,39 @@ async function resolveInstalledModuleYaml(moduleName) {
} }
} }
const atRoot = path.join(cacheRoot, 'module.yaml'); // BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory)
const rootEntries = await fs.readdir(root, { withFileTypes: true });
for (const entry of rootEntries) {
if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml');
if (await fs.pathExists(setupAssets)) return setupAssets;
}
const atRoot = path.join(root, 'module.yaml');
if (await fs.pathExists(atRoot)) return atRoot; if (await fs.pathExists(atRoot)) return atRoot;
return null;
}
const cacheRoot = getExternalModuleCachePath(moduleName);
if (await fs.pathExists(cacheRoot)) {
const found = await searchRoot(cacheRoot);
if (found) return found;
}
// Fallback: local custom-source modules store their source path in the
// CustomModuleManager resolution cache populated during the same install run.
// Match by code OR name since callers may use either form.
try {
const { CustomModuleManager } = require('./modules/custom-module-manager');
for (const [, mod] of CustomModuleManager._resolutionCache) {
if ((mod.code === moduleName || mod.name === moduleName) && mod.localPath) {
const found = await searchRoot(mod.localPath);
if (found) return found;
}
}
} catch {
// Resolution cache unavailable — continue
}
return null; return null;
} }

View File

@ -2,6 +2,7 @@ const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const semver = require('semver'); const semver = require('semver');
const fs = require('./fs-native'); const fs = require('./fs-native');
const installerPackageJson = require('../../package.json');
const { CLIUtils } = require('./cli-utils'); const { CLIUtils } = require('./cli-utils');
const { ExternalModuleManager } = require('./modules/external-manager'); const { ExternalModuleManager } = require('./modules/external-manager');
const { resolveModuleVersion } = require('./modules/version-resolver'); const { resolveModuleVersion } = require('./modules/version-resolver');
@ -128,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) {
@ -332,8 +351,10 @@ class UI {
// Interactive channel gate: "Ready to install (all stable)? [Y/n]" // Interactive channel gate: "Ready to install (all stable)? [Y/n]"
// Only shown for fresh installs with no channel flags and an external module // Only shown for fresh installs with no channel flags and an external module
// selected. Non-interactive installs skip this and fall through to the // selected. Skipped for prerelease launches because channelOptions.global
// registry default (stable) or whatever flags were supplied. // was already seeded to 'next' upstream. Non-interactive installs skip this
// and fall through to the registry default (stable) or whatever flags were
// supplied.
await this._interactiveChannelGate({ options, channelOptions, selectedModules }); await this._interactiveChannelGate({ options, channelOptions, selectedModules });
let toolSelection = await this.promptToolSelection(confirmedDirectory, options); let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
@ -1783,7 +1804,9 @@ class UI {
* *
* 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.pins and channelOptions.nextSet to reflect picker choices. * Mutates channelOptions.pins and channelOptions.nextSet to reflect picker choices.