feat(installer): channel-based version resolution for external modules

Adds stable/next/pinned channel resolution so external/community modules
install at released git tags by default instead of tracking main HEAD.
Manifest now records channel, resolved version, and SHA per module for
reproducible installs.

CLI flags: --channel, --all-stable, --all-next, --next=CODE (repeatable),
--pin CODE=TAG (repeatable). Precedence: pin > next > channel > registry
default > stable. --yes accepts patch/minor upgrades but refuses majors.

Interactive "Ready to install (all stable)?" gate with a per-module
picker (stable/next/pin) when declined. Re-install prompts classify tag
diffs as patch/minor/major with semver-class-dependent defaults.
Legacy version:null manifests get a one-time migration prompt.

Custom modules gain an optional @<ref> URL suffix for pinning (https,
ssh, /tree/<ref>/subdir forms supported; local paths rejected).
Community modules honor --next/--pin overrides with a curator-bypass
warning; default path still enforces the approved SHA.

Quick-update now reads the manifest's recorded channel per module so
pinned installs don't silently roll forward.
This commit is contained in:
Brian Madison 2026-04-23 19:22:46 -05:00
parent 2395b0e2ed
commit 65bba449a7
14 changed files with 1309 additions and 124 deletions

42
package-lock.json generated
View File

@ -15,7 +15,6 @@
"chalk": "^4.1.2",
"commander": "^14.0.0",
"csv-parse": "^6.1.0",
"fs-extra": "^11.3.0",
"glob": "^11.0.3",
"ignore": "^7.0.5",
"js-yaml": "^4.1.0",
@ -25,8 +24,8 @@
"yaml": "^2.7.0"
},
"bin": {
"bmad": "tools/bmad-npx-wrapper.js",
"bmad-method": "tools/bmad-npx-wrapper.js"
"bmad": "tools/installer/bmad-cli.js",
"bmad-method": "tools/installer/bmad-cli.js"
},
"devDependencies": {
"@astrojs/sitemap": "^3.6.0",
@ -46,6 +45,7 @@
"prettier": "^3.7.4",
"prettier-plugin-packagejson": "^2.5.19",
"sharp": "^0.33.5",
"unist-util-visit": "^5.1.0",
"yaml-eslint-parser": "^1.2.3",
"yaml-lint": "^1.7.0"
},
@ -6975,20 +6975,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fs-extra": {
"version": "11.3.3",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
"integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -7227,6 +7213,7 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/h3": {
@ -9066,18 +9053,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/katex": {
"version": "0.16.28",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz",
@ -13607,15 +13582,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",

View File

@ -24,6 +24,19 @@ module.exports = {
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
['--custom-source <sources>', 'Comma-separated Git URLs or local paths to install custom modules from'],
['-y, --yes', 'Accept all defaults and skip prompts where possible'],
[
'--channel <channel>',
'Apply channel (stable|next) to all external modules being installed. --all-stable and --all-next are aliases.',
],
['--all-stable', 'Alias for --channel=stable. Resolves externals to the highest stable release tag.'],
['--all-next', 'Alias for --channel=next. Resolves externals to main HEAD.'],
['--next <code>', 'Install module <code> from main HEAD (next channel). Repeatable.', (value, prev) => [...(prev || []), value], []],
[
'--pin <spec>',
'Pin module to a specific tag: --pin CODE=TAG (e.g. --pin bmb=v1.7.0). Repeatable.',
(value, prev) => [...(prev || []), value],
[],
],
],
action: async (options) => {
try {

View File

@ -3,7 +3,7 @@
* User input comes from either UI answers or headless CLI flags.
*/
class Config {
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate }) {
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate, channelOptions }) {
this.directory = directory;
this.modules = Object.freeze([...modules]);
this.ides = Object.freeze([...ides]);
@ -13,6 +13,8 @@ class Config {
this.coreConfig = coreConfig;
this.moduleConfigs = moduleConfigs;
this._quickUpdate = quickUpdate;
// channelOptions carry a Map + Set; don't deep-freeze.
this.channelOptions = channelOptions || null;
Object.freeze(this);
}
@ -37,6 +39,7 @@ class Config {
coreConfig: userInput.coreConfig || {},
moduleConfigs: userInput.moduleConfigs || null,
quickUpdate: userInput._quickUpdate || false,
channelOptions: userInput.channelOptions || null,
});
}

View File

@ -601,21 +601,28 @@ class Installer {
moduleConfig: moduleConfig,
installer: this,
silent: true,
channelOptions: config.channelOptions,
},
);
// Get display name from source module.yaml and resolve the freshest version metadata we can find locally.
const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true });
const sourcePath = await officialModules.findModuleSource(moduleName, {
silent: true,
channelOptions: config.channelOptions,
});
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
const displayName = moduleInfo?.name || moduleName;
const externalResolution = officialModules.externalModuleManager.getResolution(moduleName);
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath: sourcePath,
fallbackVersion: cachedResolution?.version,
fallbackVersion: externalResolution?.version || cachedResolution?.version,
marketplacePluginNames: cachedResolution?.pluginName ? [cachedResolution.pluginName] : [],
});
const version = versionInfo.version || '';
// 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 });
}
}
@ -1091,12 +1098,17 @@ 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);
if (oldVersion && oldVersion === r.newVersion) {
detail = ` (v${r.newVersion}, no change)`;
detail = ` (${newV}, no change)`;
} else if (oldVersion) {
detail = ` (v${oldVersion} → v${r.newVersion})`;
detail = ` (${fmt(oldVersion)}${newV})`;
} else {
detail = ` (v${r.newVersion}, installed)`;
detail = ` (${newV}, installed)`;
}
} else if (r.detail) {
detail = ` (${r.detail})`;
@ -1246,6 +1258,26 @@ 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,
@ -1257,6 +1289,7 @@ class Installer {
_quickUpdate: true,
_preserveModules: skippedModules,
_existingModules: installedModules,
channelOptions,
};
await this.install(installConfig);

View File

@ -349,7 +349,22 @@ class ManifestGenerator {
npmPackage: versionInfo.npmPackage,
repoUrl: versionInfo.repoUrl,
};
if (versionInfo.localPath) moduleEntry.localPath = versionInfo.localPath;
// Preserve channel/sha from the resolution (external/community/custom)
// or from the existing entry if this is a no-change rewrite.
const channel = versionInfo.channel ?? existing?.channel;
const sha = versionInfo.sha ?? existing?.sha;
if (channel) moduleEntry.channel = channel;
if (sha) moduleEntry.sha = sha;
if (versionInfo.localPath || existing?.localPath) {
moduleEntry.localPath = versionInfo.localPath || existing.localPath;
}
if (versionInfo.rawSource || existing?.rawSource) {
moduleEntry.rawSource = versionInfo.rawSource || existing.rawSource;
}
const regTag = versionInfo.registryApprovedTag ?? existing?.registryApprovedTag;
const regSha = versionInfo.registryApprovedSha ?? existing?.registryApprovedSha;
if (regTag) moduleEntry.registryApprovedTag = regTag;
if (regSha) moduleEntry.registryApprovedSha = regSha;
updatedModules.push(moduleEntry);
}

View File

@ -180,7 +180,12 @@ class Manifest {
npmPackage: options.npmPackage || null,
repoUrl: options.repoUrl || null,
};
if (options.channel) entry.channel = options.channel;
if (options.sha) entry.sha = options.sha;
if (options.localPath) entry.localPath = options.localPath;
if (options.rawSource) entry.rawSource = options.rawSource;
if (options.registryApprovedTag) entry.registryApprovedTag = options.registryApprovedTag;
if (options.registryApprovedSha) entry.registryApprovedSha = options.registryApprovedSha;
manifest.modules.push(entry);
} else {
// Module exists, update its version info
@ -192,6 +197,11 @@ class Manifest {
npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
localPath: options.localPath === undefined ? existing.localPath : options.localPath,
channel: options.channel === undefined ? existing.channel : options.channel,
sha: options.sha === undefined ? existing.sha : options.sha,
rawSource: options.rawSource === undefined ? existing.rawSource : options.rawSource,
registryApprovedTag: options.registryApprovedTag === undefined ? existing.registryApprovedTag : options.registryApprovedTag,
registryApprovedSha: options.registryApprovedSha === undefined ? existing.registryApprovedSha : options.registryApprovedSha,
lastUpdated: new Date().toISOString(),
};
}
@ -275,12 +285,17 @@ class Manifest {
const moduleInfo = await extMgr.getModuleByCode(moduleName);
if (moduleInfo) {
const externalResolution = extMgr.getResolution(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
return {
version: versionInfo.version,
// Git tag recorded during install trumps the on-disk package.json
// version, so the manifest carries "v1.7.0" instead of "1.7.0".
version: externalResolution?.version || versionInfo.version,
source: 'external',
npmPackage: moduleInfo.npmPackage || null,
repoUrl: moduleInfo.url || null,
channel: externalResolution?.channel || null,
sha: externalResolution?.sha || null,
};
}
@ -289,15 +304,20 @@ class Manifest {
const communityMgr = new CommunityModuleManager();
const communityInfo = await communityMgr.getModuleByCode(moduleName);
if (communityInfo) {
const communityResolution = communityMgr.getResolution(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath,
fallbackVersion: communityInfo.version,
});
return {
version: versionInfo.version || communityInfo.version,
version: communityResolution?.version || versionInfo.version || communityInfo.version,
source: 'community',
npmPackage: communityInfo.npmPackage || null,
repoUrl: communityInfo.url || null,
channel: communityResolution?.channel || null,
sha: communityResolution?.sha || null,
registryApprovedTag: communityResolution?.registryApprovedTag || null,
registryApprovedSha: communityResolution?.registryApprovedSha || null,
};
}
@ -312,12 +332,17 @@ class Manifest {
fallbackVersion: resolved?.version,
marketplacePluginNames: resolved?.pluginName ? [resolved.pluginName] : [],
});
const hasGitClone = !!resolved?.repoUrl;
return {
version: versionInfo.version,
// Prefer the git ref we actually cloned over the package.json version.
version: resolved?.cloneRef || (hasGitClone ? 'main' : versionInfo.version),
source: 'custom',
npmPackage: null,
repoUrl: resolved?.repoUrl || null,
localPath: resolved?.localPath || null,
channel: hasGitClone ? (resolved?.cloneRef ? 'pinned' : 'next') : null,
sha: resolved?.cloneSha || null,
rawSource: resolved?.rawInput || null,
};
}

View File

@ -0,0 +1,175 @@
/**
* Channel plan: the per-module resolution decision applied at install time.
*
* A "plan entry" for a module is:
* { channel: 'stable'|'next'|'pinned', pin?: string }
*
* We build the plan from:
* 1. CLI flags (--channel / --all-* / --next=CODE / --pin CODE=TAG)
* 2. Interactive answers (the "all stable?" gate + per-module picker)
* 3. Registry defaults (default_channel from registry-fallback.yaml / official.yaml)
* 4. Hardcoded fallback 'stable'
*
* Precedence: --pin > --next=CODE > --channel (global) > registry default > 'stable'.
*
* This module is pure. No prompts, no git, no filesystem.
*/
const VALID_CHANNELS = new Set(['stable', 'next']);
/**
* Parse raw commander options into a structured channel options object.
*
* @param {Object} options - raw command-line options
* @returns {{
* global: 'stable'|'next'|null,
* nextSet: Set<string>,
* pins: Map<string, string>,
* warnings: string[]
* }}
*/
function parseChannelOptions(options = {}) {
const warnings = [];
// Global channel from --channel / --all-stable / --all-next.
let global = null;
const aliases = [];
if (options.channel) aliases.push({ flag: '--channel', value: normalizeChannel(options.channel, warnings, '--channel') });
if (options.allStable) aliases.push({ flag: '--all-stable', value: 'stable' });
if (options.allNext) aliases.push({ flag: '--all-next', value: 'next' });
const distinct = new Set(aliases.map((a) => a.value).filter(Boolean));
if (distinct.size > 1) {
warnings.push(
`Conflicting channel flags: ${aliases
.filter((a) => a.value)
.map((a) => a.flag + '=' + a.value)
.join(', ')}. Using first: ${aliases.find((a) => a.value).flag}.`,
);
}
const firstValid = aliases.find((a) => a.value);
if (firstValid) global = firstValid.value;
// --next=CODE (repeatable)
const nextSet = new Set();
for (const code of options.next || []) {
const trimmed = String(code).trim();
if (!trimmed) continue;
nextSet.add(trimmed);
}
// --pin CODE=TAG (repeatable)
const pins = new Map();
for (const spec of options.pin || []) {
const parsed = parsePinSpec(spec);
if (!parsed) {
warnings.push(`Ignoring malformed --pin value '${spec}'. Expected CODE=TAG.`);
continue;
}
if (pins.has(parsed.code)) {
warnings.push(`--pin specified multiple times for '${parsed.code}'. Using last: ${parsed.tag}.`);
}
pins.set(parsed.code, parsed.tag);
}
return { global, nextSet, pins, warnings };
}
function normalizeChannel(raw, warnings, flagName) {
if (typeof raw !== 'string') return null;
const lower = raw.trim().toLowerCase();
if (VALID_CHANNELS.has(lower)) return lower;
warnings.push(`Ignoring invalid ${flagName} value '${raw}'. Expected one of: stable, next.`);
return null;
}
function parsePinSpec(spec) {
if (typeof spec !== 'string') return null;
const idx = spec.indexOf('=');
if (idx <= 0 || idx === spec.length - 1) return null;
const code = spec.slice(0, idx).trim();
const tag = spec.slice(idx + 1).trim();
if (!code || !tag) return null;
return { code, tag };
}
/**
* Build a per-module plan entry, applying precedence.
*
* @param {Object} args
* @param {string} args.code
* @param {Object} args.channelOptions - from parseChannelOptions
* @param {string} [args.registryDefault] - module's default_channel, if any
* @returns {{channel: 'stable'|'next'|'pinned', pin?: string, source: string}}
* source describes where the decision came from, for logging / debugging.
*/
function decideChannelForModule({ code, channelOptions, registryDefault }) {
const { global, nextSet, pins } = channelOptions || { nextSet: new Set(), pins: new Map() };
if (pins && pins.has(code)) {
return { channel: 'pinned', pin: pins.get(code), source: 'flag:--pin' };
}
if (nextSet && nextSet.has(code)) {
return { channel: 'next', source: 'flag:--next' };
}
if (global) {
return { channel: global, source: 'flag:--channel' };
}
if (registryDefault && VALID_CHANNELS.has(registryDefault)) {
return { channel: registryDefault, source: 'registry' };
}
return { channel: 'stable', source: 'default' };
}
/**
* Build a full channel plan map for a set of modules.
*
* @param {Object} args
* @param {Array<{code: string, defaultChannel?: string, builtIn?: boolean}>} args.modules
* Only the modules that need a channel entry; callers should filter out
* bundled modules (core/bmm) before calling.
* @param {Object} args.channelOptions - from parseChannelOptions
* @returns {Map<string, {channel: string, pin?: string, source: string}>}
*/
function buildPlan({ modules, channelOptions }) {
const plan = new Map();
for (const mod of modules || []) {
plan.set(
mod.code,
decideChannelForModule({
code: mod.code,
channelOptions,
registryDefault: mod.defaultChannel,
}),
);
}
return plan;
}
/**
* Report any --pin CODE=TAG entries that don't correspond to a selected module.
* These get warned about but don't abort the install.
*/
function orphanPinWarnings(channelOptions, selectedCodes) {
const warnings = [];
const selected = new Set(selectedCodes || []);
for (const code of channelOptions?.pins?.keys() || []) {
if (!selected.has(code)) {
warnings.push(`--pin for '${code}' has no effect (module not selected).`);
}
}
for (const code of channelOptions?.nextSet || []) {
if (!selected.has(code)) {
warnings.push(`--next for '${code}' has no effect (module not selected).`);
}
}
return warnings;
}
module.exports = {
parseChannelOptions,
decideChannelForModule,
buildPlan,
orphanPinWarnings,
parsePinSpec,
};

View File

@ -0,0 +1,241 @@
const https = require('node:https');
const semver = require('semver');
/**
* Channel resolver for external and community modules.
*
* A "channel" is the resolution strategy that decides which ref of a module
* to clone when no explicit version is supplied:
* - stable: highest pure-semver git tag (excludes -alpha/-beta/-rc)
* - next: main branch HEAD
* - pinned: an explicit user-supplied tag
*
* This module is pure (no prompts, no git, no filesystem). It only talks to
* the GitHub tags API and performs semver math. Clone logic lives in the
* module managers that call resolveChannel().
*/
const GITHUB_API_BASE = 'https://api.github.com';
const DEFAULT_TIMEOUT_MS = 10_000;
const USER_AGENT = 'bmad-method-installer';
// Per-process cache: { 'owner/repo' => string[] sorted desc } of pure-semver tags.
const tagCache = new Map();
/**
* Parse a GitHub repo URL into { owner, repo }. Returns null if the URL is
* not a GitHub URL the resolver can handle.
*/
function parseGitHubRepo(url) {
if (!url || typeof url !== 'string') return null;
const trimmed = url
.trim()
.replace(/\.git$/, '')
.replace(/\/$/, '');
// https://github.com/owner/repo
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/.*)?$/i);
if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] };
// git@github.com:owner/repo
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/]+)$/i);
if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
return null;
}
function fetchJson(url, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
const headers = {
'User-Agent': USER_AGENT,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
return new Promise((resolve, reject) => {
const req = https.get(url, { headers, timeout }, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
const err = new Error(`GitHub API ${res.statusCode} for ${url}: ${body.slice(0, 200)}`);
err.statusCode = res.statusCode;
return reject(err);
}
try {
resolve(JSON.parse(body));
} catch (error) {
reject(new Error(`Failed to parse GitHub response: ${error.message}`));
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error(`GitHub API request timed out: ${url}`));
});
});
}
/**
* Strip a leading 'v' and return a valid semver string, or null if the tag
* is not valid semver or is a prerelease (contains -alpha/-beta/-rc/etc.).
*/
function normalizeStableTag(tagName) {
if (typeof tagName !== 'string') return null;
const stripped = tagName.startsWith('v') ? tagName.slice(1) : tagName;
const valid = semver.valid(stripped);
if (!valid) return null;
// Exclude prereleases. semver.prerelease returns null for pure releases.
if (semver.prerelease(valid)) return null;
return valid;
}
/**
* Fetch pure-semver tags (highest first) from a GitHub repo.
* Cached per-process per owner/repo.
*
* @returns {Promise<Array<{tag: string, version: string}>>}
* tag is the original ref name (e.g. "v1.7.0"), version is the cleaned
* semver (e.g. "1.7.0").
*/
async function fetchStableTags(owner, repo, { timeout } = {}) {
const cacheKey = `${owner}/${repo}`;
if (tagCache.has(cacheKey)) return tagCache.get(cacheKey);
// GitHub returns up to 100 tags per page; one page is plenty for our modules.
const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/tags?per_page=100`;
const raw = await fetchJson(url, { timeout });
if (!Array.isArray(raw)) {
throw new TypeError(`Unexpected response from ${url}`);
}
const stable = [];
for (const entry of raw) {
const version = normalizeStableTag(entry?.name);
if (version) stable.push({ tag: entry.name, version });
}
stable.sort((a, b) => semver.rcompare(a.version, b.version));
tagCache.set(cacheKey, stable);
return stable;
}
/**
* Resolve a channel plan for a single module into a git-clonable ref.
*
* @param {Object} args
* @param {'stable'|'next'|'pinned'} args.channel
* @param {string} [args.pin] - Required when channel === 'pinned'
* @param {string} args.repoUrl - Module's git URL (for tag lookup)
* @returns {Promise<{channel, ref, version}>} where
* ref: the git ref to pass to `git clone --branch`, or null for HEAD (next)
* version: the resolved version string (tag name for stable/pinned, 'main' for next)
*
* Throws on:
* - pinned without a pin value
* - stable with no GitHub repo parseable from the URL (pass through to caller to fall back)
*
* Falls back to next-channel semantics and sets resolvedFallback=true when
* stable resolution turns up no tags.
*/
async function resolveChannel({ channel, pin, repoUrl, timeout }) {
if (channel === 'pinned') {
if (!pin) throw new Error('resolveChannel: pinned channel requires a pin value');
return { channel: 'pinned', ref: pin, version: pin, resolvedFallback: false };
}
if (channel === 'next') {
return { channel: 'next', ref: null, version: 'main', resolvedFallback: false };
}
if (channel === 'stable') {
const parsed = parseGitHubRepo(repoUrl);
if (!parsed) {
// No GitHub URL — caller must handle by falling back to next.
return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'not-a-github-url' };
}
try {
const tags = await fetchStableTags(parsed.owner, parsed.repo, { timeout });
if (tags.length === 0) {
return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'no-stable-tags' };
}
const top = tags[0];
return { channel: 'stable', ref: top.tag, version: top.tag, resolvedFallback: false };
} catch (error) {
// Propagate the error; callers decide whether to fall back or abort.
error.message = `Failed to resolve stable channel for ${parsed.owner}/${parsed.repo}: ${error.message}`;
throw error;
}
}
throw new Error(`resolveChannel: unknown channel '${channel}'`);
}
/**
* Verify that a specific tag exists in a GitHub repo. Used to validate
* --pin values before the user sits through a long clone that then fails.
*/
async function tagExists(owner, repo, tagName, { timeout } = {}) {
const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(tagName)}`;
try {
await fetchJson(url, { timeout });
return true;
} catch (error) {
if (error.statusCode === 404) return false;
throw error;
}
}
/**
* Classify the semver delta between two versions.
* - 'none' same version (or downgrade; treated same)
* - 'patch' same major.minor, higher patch
* - 'minor' same major, higher minor
* - 'major' different major
* - 'unknown' either version is not valid semver; caller should treat as major
*/
function classifyUpgrade(currentVersion, newVersion) {
const current = semver.valid(semver.coerce(currentVersion));
const next = semver.valid(semver.coerce(newVersion));
if (!current || !next) return 'unknown';
if (semver.lte(next, current)) return 'none';
const diff = semver.diff(current, next);
if (diff === 'patch') return 'patch';
if (diff === 'minor' || diff === 'preminor') return 'minor';
if (diff === 'major' || diff === 'premajor') return 'major';
// prepatch, prerelease — treat conservatively as minor (prereleases shouldn't
// normally surface here since stable channel filters them out).
return 'minor';
}
/**
* Build the GitHub release notes URL for a resolved tag.
* Returns null if the repo URL isn't a GitHub URL.
*/
function releaseNotesUrl(repoUrl, tag) {
const parsed = parseGitHubRepo(repoUrl);
if (!parsed || !tag) return null;
return `https://github.com/${parsed.owner}/${parsed.repo}/releases/tag/${encodeURIComponent(tag)}`;
}
/**
* Test-only: clear the per-process tag cache.
*/
function _clearTagCache() {
tagCache.clear();
}
module.exports = {
parseGitHubRepo,
fetchStableTags,
resolveChannel,
tagExists,
classifyUpgrade,
releaseNotesUrl,
normalizeStableTag,
_clearTagCache,
};

View File

@ -4,6 +4,8 @@ const path = require('node:path');
const { execSync } = require('node:child_process');
const prompts = require('../prompts');
const { RegistryClient } = require('./registry-client');
const { decideChannelForModule } = require('./channel-plan');
const { parseGitHubRepo, tagExists } = require('./channel-resolver');
const MARKETPLACE_OWNER = 'bmad-code-org';
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
@ -15,13 +17,29 @@ const MARKETPLACE_REF = 'main';
* Returns empty results when the registry is unreachable.
* Community modules are pinned to approved SHA when set; uses HEAD otherwise.
*/
function quoteShellRef(ref) {
if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
}
return `"${ref}"`;
}
class CommunityModuleManager {
// moduleCode → { channel, version, sha, registryApprovedTag, registryApprovedSha, repoUrl, bypassedCurator }
// Shared across all instances; the manifest writer often uses a fresh instance.
static _resolutions = new Map();
constructor() {
this._client = new RegistryClient();
this._cachedIndex = null;
this._cachedCategories = null;
}
/** Get the most recent channel resolution for a community module. */
getResolution(moduleCode) {
return CommunityModuleManager._resolutions.get(moduleCode) || null;
}
// ─── Data Loading ──────────────────────────────────────────────────────────
/**
@ -196,12 +214,47 @@ class CommunityModuleManager {
return await prompts.spinner();
};
const sha = moduleInfo.approvedSha;
// ─── Resolve channel plan ──────────────────────────────────────────────
// Default community behavior (stable channel) honors the curator's
// approved SHA. --next=CODE and --pin CODE=TAG override the curator; we
// warn the user before bypassing the approved version.
const planEntry = decideChannelForModule({
code: moduleCode,
channelOptions: options.channelOptions,
registryDefault: 'stable',
});
const approvedSha = moduleInfo.approvedSha;
const approvedTag = moduleInfo.approvedTag;
let bypassedCurator = false;
if (planEntry.channel !== 'stable') {
bypassedCurator = true;
if (!silent) {
const approvedLabel = approvedTag || approvedSha || 'curator-approved version';
await prompts.log.warn(
`WARNING: Installing '${moduleCode}' from ${
planEntry.channel === 'pinned' ? `tag ${planEntry.pin}` : 'main HEAD'
} bypasses the curator-approved ${approvedLabel}. Proceed only if you trust this source.`,
);
if (!options.channelOptions?.acceptBypass) {
const proceed = await prompts.confirm({
message: `Continue installing '${moduleCode}' with curator bypass?`,
default: false,
});
if (!proceed) {
throw new Error(`Install of community module '${moduleCode}' cancelled by user.`);
}
}
}
}
let needsDependencyInstall = false;
let wasNewClone = false;
if (await fs.pathExists(moduleCacheDir)) {
// Already cloned - update to latest HEAD
// Already cloned — refresh to current origin/HEAD before we decide the
// final ref. We may still check out a tag or approved SHA after this.
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
try {
@ -231,10 +284,17 @@ class CommunityModuleManager {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
try {
if (planEntry.channel === 'pinned') {
execSync(`git clone --depth 1 --branch ${quoteShellRef(planEntry.pin)} "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
} else {
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
}
fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
needsDependencyInstall = true;
} catch (error) {
@ -243,18 +303,19 @@ class CommunityModuleManager {
}
}
// If pinned to a specific SHA, check out that exact commit.
// Refuse to install if the approved SHA cannot be reached - security requirement.
if (sha) {
// ─── Check out the resolved ref per channel ──────────────────────────
if (planEntry.channel === 'stable' && approvedSha) {
// Default path: pin to the curator-approved SHA. Refuse install if the SHA
// is unreachable (tag may have been deleted or rewritten) — security requirement.
const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
if (headSha !== sha) {
if (headSha !== approvedSha) {
try {
execSync(`git fetch --depth 1 origin ${sha}`, {
execSync(`git fetch --depth 1 origin ${quoteShellRef(approvedSha)}`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync(`git checkout ${sha}`, {
execSync(`git checkout ${quoteShellRef(approvedSha)}`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
@ -262,12 +323,41 @@ class CommunityModuleManager {
} catch {
await fs.remove(moduleCacheDir);
throw new Error(
`Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
`Installation refused for security. The module registry entry may need updating.`,
`Community module '${moduleCode}' could not be pinned to its approved commit (${approvedSha}). ` +
`Installation refused for security. The module registry entry may need updating, ` +
`or use --next=${moduleCode} / --pin ${moduleCode}=<tag> to explicitly bypass.`,
);
}
}
} else if (planEntry.channel === 'stable' && !approvedSha) {
// Registry data gap: tag or SHA missing. Warn but proceed at HEAD (pre-existing behavior).
if (!silent) {
await prompts.log.warn(`Community module '${moduleCode}' has no curator-approved SHA in the registry; installing from main HEAD.`);
}
} else if (planEntry.channel === 'pinned') {
// We cloned the tag directly above (via --branch), but ensure HEAD matches.
// No additional checkout needed.
}
// else: 'next' channel — already at origin/HEAD from the fetch/reset above.
// 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 recordedVersion =
planEntry.channel === 'pinned'
? planEntry.pin
: planEntry.channel === 'next'
? 'main'
: approvedTag || (installedSha === approvedSha ? approvedTag : installedSha.slice(0, 7));
CommunityModuleManager._resolutions.set(moduleCode, {
channel: planEntry.channel,
version: recordedVersion,
sha: installedSha,
registryApprovedTag: approvedTag || null,
registryApprovedSha: approvedSha || null,
repoUrl: moduleInfo.url,
bypassedCurator,
planSource: planEntry.source,
});
// Install dependencies if needed
const packageJsonPath = path.join(moduleCacheDir, 'package.json');

View File

@ -4,6 +4,13 @@ const path = require('node:path');
const { execSync } = require('node:child_process');
const prompts = require('../prompts');
function quoteCustomRef(ref) {
if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
}
return `"${ref}"`;
}
/**
* Manages custom modules installed from user-provided sources.
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
@ -38,8 +45,8 @@ class CustomModuleManager {
};
}
const trimmed = input.trim();
if (!trimmed) {
const trimmedRaw = input.trim();
if (!trimmedRaw) {
return {
type: null,
cloneUrl: null,
@ -52,8 +59,51 @@ class CustomModuleManager {
};
}
// Extract optional @<tag-or-sha> suffix from the end of the input.
// Semver-valid characters: letters, digits, dot, hyphen, underscore, plus, slash
// 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.
let trimmed = trimmedRaw;
let versionSuffix = null;
const lastAt = trimmedRaw.lastIndexOf('@');
// Skip if @ is part of git@github.com:... (first char cannot be stripped as version)
// and skip if @ appears before the path rather than after a ref-shaped tail.
if (lastAt > 0) {
const candidate = trimmedRaw.slice(lastAt + 1);
const before = trimmedRaw.slice(0, lastAt);
// candidate must be ref-shaped and must not itself look like a URL / SSH host
if (/^[\w.\-+/]+$/.test(candidate) && !candidate.includes(':')) {
// Avoid consuming the @ in `git@host:owner/repo` — `before` wouldn't end with a path separator
// in that case. Require that the @ comes after the host/path, not inside the auth segment.
// Rule: the @ is a version suffix only if `before` looks like a complete URL or local path.
const beforeLooksLikeRepo =
before.startsWith('/') ||
before.startsWith('./') ||
before.startsWith('../') ||
before.startsWith('~') ||
/^https?:\/\//i.test(before) ||
/^git@[^:]+:.+/.test(before);
if (beforeLooksLikeRepo) {
versionSuffix = candidate;
trimmed = before;
}
}
}
// Local path detection: starts with /, ./, ../, or ~
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~')) {
if (versionSuffix) {
return {
type: 'local',
cloneUrl: null,
subdir: null,
localPath: null,
cacheKey: null,
displayName: null,
isValid: false,
error: 'Local paths do not support @version suffixes',
};
}
return this._parseLocalPath(trimmed);
}
@ -66,6 +116,8 @@ class CustomModuleManager {
cloneUrl: trimmed,
subdir: null,
localPath: null,
version: versionSuffix || null,
rawInput: trimmedRaw,
cacheKey: `${host}/${owner}/${repo}`,
displayName: `${owner}/${repo}`,
isValid: true,
@ -79,29 +131,47 @@ class CustomModuleManager {
const [, host, owner, repo, remainder] = httpsMatch;
const cloneUrl = `https://${host}/${owner}/${repo}`;
let subdir = null;
let urlRef = null; // branch/tag extracted from /tree/<ref>/subdir
if (remainder) {
// Extract subdir from deep path patterns used by various Git hosts
const deepPathPatterns = [
/^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path
/^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree)
/^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path
{ regex: /^\/(?:-\/)?tree\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // GitHub, GitLab
{ regex: /^\/(?:-\/)?blob\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 },
{ regex: /^\/src\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // Gitea/Forgejo
];
// Also match `/tree/<ref>` with no subdir
const refOnlyPatterns = [/^\/(?:-\/)?tree\/([^/]+?)\/?$/, /^\/(?:-\/)?blob\/([^/]+?)\/?$/, /^\/src\/([^/]+?)\/?$/];
for (const pattern of deepPathPatterns) {
const match = remainder.match(pattern);
for (const p of deepPathPatterns) {
const match = remainder.match(p.regex);
if (match) {
subdir = match[1].replace(/\/$/, ''); // strip trailing slash
urlRef = match[p.refIdx];
subdir = match[p.pathIdx].replace(/\/$/, '');
break;
}
}
if (!subdir) {
for (const r of refOnlyPatterns) {
const match = remainder.match(r);
if (match) {
urlRef = match[1];
break;
}
}
}
}
// Precedence: explicit @version suffix > URL /tree/<ref> path segment.
const version = versionSuffix || urlRef || null;
return {
type: 'url',
cloneUrl,
subdir,
localPath: null,
version,
rawInput: trimmedRaw,
cacheKey: `${host}/${owner}/${repo}`,
displayName: `${owner}/${repo}`,
isValid: true,
@ -255,6 +325,10 @@ class CustomModuleManager {
const silent = options.silent || false;
const displayName = parsed.displayName;
// Pin override: --pin CODE=TAG resolved at module-selection time overrides
// any @version suffix present in the URL.
const effectiveVersion = options.pinOverride || parsed.version || null;
await fs.ensureDir(path.dirname(repoCacheDir));
const createSpinner = async () => {
@ -264,8 +338,23 @@ class CustomModuleManager {
return await prompts.spinner();
};
// If an existing cache exists but was cloned at a different version, re-clone.
// Tracked via .bmad-source.json's recorded version.
if (await fs.pathExists(repoCacheDir)) {
// Update existing clone
let cachedVersion = null;
try {
const existing = await fs.readJson(path.join(repoCacheDir, '.bmad-source.json'));
cachedVersion = existing?.version || null;
} catch {
// no metadata; treat as mismatched to be safe if a version was requested
}
if ((effectiveVersion || null) !== (cachedVersion || null)) {
await fs.remove(repoCacheDir);
}
}
if (await fs.pathExists(repoCacheDir)) {
// Update existing clone (same version as before)
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Updating ${displayName}...`);
try {
@ -274,10 +363,22 @@ class CustomModuleManager {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
if (effectiveVersion) {
execSync(`git fetch --depth 1 origin tag ${quoteCustomRef(effectiveVersion)} --no-tags`, {
cwd: repoCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync(`git checkout --quiet FETCH_HEAD`, {
cwd: repoCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
} else {
execSync('git reset --hard origin/HEAD', {
cwd: repoCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
}
fetchSpinner.stop(`Updated ${displayName}`);
} catch {
fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
@ -287,25 +388,44 @@ class CustomModuleManager {
if (!(await fs.pathExists(repoCacheDir))) {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Cloning ${displayName}...`);
fetchSpinner.start(`Cloning ${displayName}${effectiveVersion ? ` @ ${effectiveVersion}` : ''}...`);
try {
if (effectiveVersion) {
execSync(`git clone --depth 1 --branch ${quoteCustomRef(effectiveVersion)} "${parsed.cloneUrl}" "${repoCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
} else {
execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
}
fetchSpinner.stop(`Cloned ${displayName}`);
} catch (error_) {
fetchSpinner.error(`Failed to clone ${displayName}`);
throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
const refSuffix = effectiveVersion ? `@${effectiveVersion}` : '';
throw new Error(`Failed to clone ${parsed.cloneUrl}${refSuffix}: ${error_.message}`);
}
}
// Record the resolved SHA for the manifest writer.
let resolvedSha = null;
try {
resolvedSha = execSync('git rev-parse HEAD', { cwd: repoCacheDir, stdio: 'pipe' }).toString().trim();
} catch {
// swallow — a non-git repo (local path) wouldn't reach here anyway
}
// Write source metadata for later URL reconstruction
const metadataPath = path.join(repoCacheDir, '.bmad-source.json');
await fs.writeJson(metadataPath, {
cloneUrl: parsed.cloneUrl,
cacheKey: parsed.cacheKey,
displayName: parsed.displayName,
version: effectiveVersion || null,
rawInput: parsed.rawInput || sourceInput,
sha: resolvedSha,
clonedAt: new Date().toISOString(),
});
@ -346,10 +466,26 @@ class CustomModuleManager {
const resolver = new PluginResolver();
const resolved = await resolver.resolve(repoPath, plugin);
// Read clone metadata (written by cloneRepo) so we can pick up the
// resolved git ref + SHA for manifest recording.
let cloneMetadata = null;
if (sourceUrl) {
try {
cloneMetadata = await fs.readJson(path.join(repoPath, '.bmad-source.json'));
} catch {
// no metadata — local-source or legacy cache
}
}
// Stamp source info onto each resolved module for manifest tracking
for (const mod of resolved) {
if (sourceUrl) mod.repoUrl = sourceUrl;
if (localPath) mod.localPath = localPath;
if (cloneMetadata) {
mod.cloneRef = cloneMetadata.version || null;
mod.cloneSha = cloneMetadata.sha || null;
mod.rawInput = cloneMetadata.rawInput || null;
}
CustomModuleManager._resolutionCache.set(mod.code, mod);
}

View File

@ -5,6 +5,46 @@ const { execSync } = require('node:child_process');
const yaml = require('yaml');
const prompts = require('../prompts');
const { RegistryClient } = require('./registry-client');
const { resolveChannel, tagExists, parseGitHubRepo } = require('./channel-resolver');
const { decideChannelForModule } = require('./channel-plan');
const VALID_CHANNELS = new Set(['stable', 'next', 'pinned']);
function normalizeChannelName(raw) {
if (typeof raw !== 'string') return null;
const lower = raw.trim().toLowerCase();
return VALID_CHANNELS.has(lower) ? lower : null;
}
/**
* Conservative quoting for tag names passed to git commands. Tags are
* user-typed (--pin) or come from the GitHub API. Only allow the semver
* character class we use to tag BMad releases; anything else throws.
*/
function quoteShell(ref) {
if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
}
return `"${ref}"`;
}
async function readChannelMarker(markerPath) {
try {
if (!(await fs.pathExists(markerPath))) return null;
const content = await fs.readFile(markerPath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
async function writeChannelMarker(markerPath, data) {
try {
await fs.writeFile(markerPath, JSON.stringify({ ...data, writtenAt: new Date().toISOString() }, null, 2));
} catch {
// Best-effort: marker is an optimization, not a correctness requirement.
}
}
const MARKETPLACE_OWNER = 'bmad-code-org';
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
@ -19,10 +59,25 @@ const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
* @class ExternalModuleManager
*/
class ExternalModuleManager {
// moduleCode → { channel, version, ref, sha, repoUrl, resolvedFallback }
// Populated when cloneExternalModule resolves a channel. Shared across all
// instances so the manifest writer (which often instantiates a fresh
// ExternalModuleManager) sees resolutions made during install.
static _resolutions = new Map();
constructor() {
this._client = new RegistryClient();
}
/**
* Get the most recent channel resolution for a module (if any).
* @param {string} moduleCode
* @returns {Object|null}
*/
getResolution(moduleCode) {
return ExternalModuleManager._resolutions.get(moduleCode) || null;
}
/**
* Load the official modules registry from GitHub, falling back to the
* bundled YAML file if the fetch fails.
@ -75,6 +130,7 @@ class ExternalModuleManager {
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
type: mod.type || 'bmad-org',
npmPackage: mod.npm_package || mod.npmPackage || null,
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
builtIn: mod.built_in === true,
isExternal: mod.built_in !== true,
};
@ -120,10 +176,15 @@ class ExternalModuleManager {
}
/**
* Clone an external module repository to cache
* Clone an external module repository to cache, resolving the requested
* channel (stable / next / pinned) to a concrete git ref.
*
* @param {string} moduleCode - Code of the external module
* @param {Object} options - Clone options
* @param {boolean} options.silent - Suppress spinner output
* @param {boolean} [options.silent] - Suppress spinner output
* @param {Object} [options.channelOptions] - Parsed channel flags. See
* modules/channel-plan.js. When absent, the module installs on its
* registry-declared default channel (typically 'stable').
* @returns {string} Path to the cloned repository
*/
async cloneExternalModule(moduleCode, options = {}) {
@ -161,18 +222,91 @@ class ExternalModuleManager {
return await prompts.spinner();
};
// Track if we need to install dependencies
// ─── Resolve channel plan ─────────────────────────────────────────────
// Post-install callers (config generation, directory setup, help catalog
// rebuild) invoke findModuleSource/cloneExternalModule without
// channelOptions just to locate the module's files. Those calls must not
// redecide the channel — the install step already chose one, cloned the
// right ref, and recorded a resolution. If we re-resolve without flags,
// we'd snap back to stable and overwrite a pinned install.
const hasExplicitChannelInput =
options.channelOptions &&
(options.channelOptions.global ||
(options.channelOptions.nextSet && options.channelOptions.nextSet.size > 0) ||
(options.channelOptions.pins && options.channelOptions.pins.size > 0));
const existingResolution = ExternalModuleManager._resolutions.get(moduleCode);
const haveUsableCache = await fs.pathExists(moduleCacheDir);
if (!hasExplicitChannelInput && existingResolution && haveUsableCache) {
// This is a look-up only; the module is already installed at its chosen
// ref. Skip cloning and return the cached path unchanged.
return moduleCacheDir;
}
const planEntry = decideChannelForModule({
code: moduleCode,
channelOptions: options.channelOptions,
registryDefault: moduleInfo.defaultChannel,
});
let resolved;
try {
resolved = await resolveChannel({
channel: planEntry.channel,
pin: planEntry.pin,
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' };
}
if (resolved.resolvedFallback && !silent) {
if (resolved.reason === 'no-stable-tags') {
await prompts.log.warn(`No stable releases found for ${moduleInfo.name}; installing from main.`);
} else if (resolved.reason === 'not-a-github-url') {
await prompts.log.warn(`Cannot determine stable tags for ${moduleInfo.name} (non-GitHub URL); installing from main.`);
}
}
// Validate pin before we burn time cloning. Best-effort: skip on non-GitHub URLs.
if (planEntry.channel === 'pinned') {
const parsed = parseGitHubRepo(moduleInfo.url);
if (parsed) {
try {
const exists = await tagExists(parsed.owner, parsed.repo, planEntry.pin);
if (!exists) {
throw new Error(`Tag '${planEntry.pin}' not found in ${parsed.owner}/${parsed.repo}.`);
}
} catch (error) {
if (error.message?.includes('not found')) throw error;
// Network hiccup on tag verification — let the clone attempt fail clearly.
}
}
}
// ─── Clone or update cache by resolved channel ────────────────────────
const markerPath = path.join(moduleCacheDir, '.bmad-channel.json');
const currentMarker = await readChannelMarker(markerPath);
const needsChannelReset = currentMarker && currentMarker.channel !== resolved.channel;
let needsDependencyInstall = false;
let wasNewClone = false;
// Check if already cloned
if (needsChannelReset && (await fs.pathExists(moduleCacheDir))) {
// Channel changed (e.g. user switched stable→next). Blow away and re-clone
// to avoid tangling shallow clones of different refs.
await fs.remove(moduleCacheDir);
}
if (await fs.pathExists(moduleCacheDir)) {
// Try to update if it's a git repo
// Cache exists on the right channel. Refresh the ref.
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try {
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
// Fetch and reset to remote - works better with shallow clones than pull
const currentSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
if (resolved.channel === 'next') {
execSync('git fetch origin --depth 1', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
@ -183,16 +317,24 @@ class ExternalModuleManager {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
// Force dependency install if we got new code
if (currentRef !== newRef) {
needsDependencyInstall = true;
} else {
// stable or pinned — fetch the specific tag and check it out.
execSync(`git fetch --depth 1 origin tag ${quoteShell(resolved.ref)} --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'],
});
}
const newSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
if (currentSha !== newSha) needsDependencyInstall = true;
} catch {
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
// If update fails, remove and re-clone
await fs.remove(moduleCacheDir);
wasNewClone = true;
}
@ -200,22 +342,41 @@ class ExternalModuleManager {
wasNewClone = true;
}
// Clone if not exists or was removed
if (wasNewClone) {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try {
if (resolved.channel === 'next') {
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
} else {
execSync(`git clone --depth 1 --branch ${quoteShell(resolved.ref)} "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
}
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
} catch (error) {
fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
throw new Error(`Failed to clone external module '${moduleCode}' at ${resolved.version}: ${error.message}`);
}
}
// Record resolution (channel + tag + SHA) for the manifest writer to pick up.
const sha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
ExternalModuleManager._resolutions.set(moduleCode, {
channel: resolved.channel,
version: resolved.version,
ref: resolved.ref,
sha,
repoUrl: moduleInfo.url,
resolvedFallback: !!resolved.resolvedFallback,
planSource: planEntry.source,
});
await writeChannelMarker(markerPath, { channel: resolved.channel, version: resolved.version, sha });
// Install dependencies if package.json exists
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');

View File

@ -15,6 +15,11 @@ class OfficialModules {
// Tracked during interactive config collection so {directory_name}
// placeholder defaults can be resolved in buildQuestion().
this.currentProjectDir = null;
// Install-time channel flag state. Set by Config.build once, then used as
// the default for every findModuleSource/cloneExternalModule call so that
// pre-install config collection and the install step agree on which ref
// to clone.
this.channelOptions = options.channelOptions || null;
}
/**
@ -38,7 +43,7 @@ class OfficialModules {
* @returns {OfficialModules}
*/
static async build(config, paths) {
const instance = new OfficialModules();
const instance = new OfficialModules({ channelOptions: config.channelOptions });
// Pre-collected by UI or quickUpdate — store and load existing for path-change detection
if (config.moduleConfigs) {
@ -196,6 +201,12 @@ class OfficialModules {
* @returns {string|null} Path to the module source or null if not found
*/
async findModuleSource(moduleCode, options = {}) {
// Inherit channelOptions from the install-scoped instance when the caller
// didn't pass one explicitly. Keeps pre-install config collection and the
// actual install step looking at the same git ref.
if (options.channelOptions === undefined && this.channelOptions) {
options = { ...options, channelOptions: this.channelOptions };
}
const projectRoot = getProjectRoot();
// Check for core module (directly under src/core-skills)
@ -214,13 +225,13 @@ class OfficialModules {
}
}
// Check external official modules
// Check external official modules (pass channelOptions so channel plan applies)
const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options);
if (externalSource) {
return externalSource;
}
// Check community modules
// Check community modules (pass channelOptions for --next/--pin overrides)
const { CommunityModuleManager } = require('./community-manager');
const communityMgr = new CommunityModuleManager();
const communitySource = await communityMgr.findModuleSource(moduleCode, options);
@ -258,7 +269,10 @@ class OfficialModules {
return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
}
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
const sourcePath = await this.findModuleSource(moduleName, {
silent: options.silent,
channelOptions: options.channelOptions,
});
const targetPath = path.join(bmadDir, moduleName);
if (!sourcePath) {
@ -281,11 +295,24 @@ class OfficialModules {
const manifestObj = new Manifest();
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
// Pick up channel resolution recorded by whichever manager did the clone.
const externalResolution = this.externalModuleManager.getResolution(moduleName);
let communityResolution = null;
if (!externalResolution) {
const { CommunityModuleManager } = require('./community-manager');
communityResolution = new CommunityModuleManager().getResolution(moduleName);
}
const resolution = externalResolution || communityResolution;
await manifestObj.addModule(bmadDir, moduleName, {
version: versionInfo.version,
version: resolution?.version || versionInfo.version,
source: versionInfo.source,
npmPackage: versionInfo.npmPackage,
repoUrl: versionInfo.repoUrl,
channel: resolution?.channel,
sha: resolution?.sha,
registryApprovedTag: communityResolution?.registryApprovedTag,
registryApprovedSha: communityResolution?.registryApprovedSha,
});
return { success: true, module: moduleName, path: targetPath, versionInfo };
@ -333,18 +360,34 @@ class OfficialModules {
await this.createModuleDirectories(resolved.code, bmadDir, options);
}
// Update manifest
// Update manifest. For custom modules, derive channel from the git ref:
// cloneRef present → pinned at that ref
// cloneRef absent → next (main HEAD)
// local path → no channel concept
const { Manifest } = require('../core/manifest');
const manifestObj = new Manifest();
await manifestObj.addModule(bmadDir, resolved.code, {
version: resolved.version || null,
const hasGitClone = !!resolved.repoUrl;
const manifestEntry = {
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
source: 'custom',
npmPackage: null,
repoUrl: resolved.repoUrl || null,
});
};
if (hasGitClone) {
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
}
if (resolved.localPath) manifestEntry.localPath = resolved.localPath;
await manifestObj.addModule(bmadDir, resolved.code, manifestEntry);
return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } };
return {
success: true,
module: resolved.code,
path: targetPath,
versionInfo: { version: resolved.cloneRef || resolved.version || '' },
};
}
/**

View File

@ -1,6 +1,10 @@
# Fallback module registry — used only when the BMad Marketplace repo
# (bmad-code-org/bmad-plugins-marketplace) is unreachable.
# The remote registry/official.yaml is the source of truth.
#
# default_channel (optional) — the install channel when the user does not
# override with --channel/--pin/--next. Valid values: stable | next.
# Omit to inherit the installer's hardcoded default (stable).
modules:
bmad-builder:
@ -12,6 +16,7 @@ modules:
defaultSelected: false
type: bmad-org
npmPackage: bmad-builder
default_channel: stable
bmad-creative-intelligence-suite:
url: https://github.com/bmad-code-org/bmad-module-creative-intelligence-suite
@ -22,6 +27,7 @@ modules:
defaultSelected: false
type: bmad-org
npmPackage: bmad-creative-intelligence-suite
default_channel: stable
bmad-game-dev-studio:
url: https://github.com/bmad-code-org/bmad-module-game-dev-studio.git
@ -32,6 +38,7 @@ modules:
defaultSelected: false
type: bmad-org
npmPackage: bmad-game-dev-studio
default_channel: stable
bmad-method-test-architecture-enterprise:
url: https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise
@ -42,3 +49,4 @@ modules:
defaultSelected: false
type: bmad-org
npmPackage: bmad-method-test-architecture-enterprise
default_channel: stable

View File

@ -4,6 +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 prompts = require('./prompts');
/**
@ -33,6 +34,13 @@ class UI {
const messageLoader = new MessageLoader();
await messageLoader.displayStartMessage();
// Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
// are surfaced immediately so the user sees them before any git ops run.
const channelOptions = parseChannelOptions(options);
for (const warning of channelOptions.warnings) {
await prompts.log.warn(warning);
}
// Get directory from options or prompt
let confirmedDirectory;
if (options.directory) {
@ -152,10 +160,30 @@ class UI {
selectedModules.unshift('core');
}
// For existing installs, resolve per-module update decisions BEFORE
// we clone anything. Reads the existing manifest's recorded channel
// per module and prompts the user on available upgrades (patch/minor
// default Y, major default N). Legacy entries with no channel are
// migrated here too. Mutates channelOptions.pins to lock rejections.
await this._resolveUpdateChannels({
bmadDir,
selectedModules,
channelOptions,
yes: options.yes || false,
});
// Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
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);
}
return {
actionType: 'update',
@ -166,6 +194,7 @@ class UI {
coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
skipPrompts: options.yes || false,
channelOptions,
};
}
}
@ -205,8 +234,23 @@ class UI {
if (!selectedModules.includes('core')) {
selectedModules.unshift('core');
}
// Interactive channel gate: "Ready to install (all stable)? [Y/n]"
// Only shown for fresh installs with no channel flags and an external module
// selected. Non-interactive installs skip this and fall through to the
// registry default (stable) or whatever flags were supplied.
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
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);
}
return {
actionType: 'install',
@ -217,6 +261,7 @@ class UI {
coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
skipPrompts: options.yes || false,
channelOptions,
};
}
@ -488,7 +533,7 @@ class UI {
*/
async collectModuleConfigs(directory, modules, options = {}) {
const { OfficialModules } = require('./modules/official-modules');
const configCollector = new OfficialModules();
const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
// Seed core config from CLI options if provided
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
@ -1563,6 +1608,237 @@ class UI {
});
await prompts.log.message('Selected tools:\n' + toolLines.join('\n'));
}
/**
* Fast-path channel gate: confirm "all stable" or open the per-module picker.
*
* Skipped when:
* - running non-interactively (--yes)
* - the user already passed channel flags (--channel / --pin / --next)
* - no externals/community modules are selected
*
* Mutates channelOptions.pins and channelOptions.nextSet to reflect picker choices.
*/
async _interactiveChannelGate({ options, channelOptions, selectedModules }) {
if (options.yes) return;
// If the user already declared their channel intent via flags, trust them
// and skip the gate.
const haveFlagIntent = channelOptions.global || channelOptions.nextSet.size > 0 || channelOptions.pins.size > 0;
if (haveFlagIntent) return;
// Figure out which selected modules actually get a channel (externals +
// community modules). Bundled core/bmm and custom modules skip the picker.
const externalManager = new ExternalModuleManager();
const externals = await externalManager.listAvailable();
const externalByCode = new Map(externals.map((m) => [m.code, m]));
const { CommunityModuleManager } = require('./modules/community-manager');
const communityMgr = new CommunityModuleManager();
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));
if (channelSelectable.length === 0) return;
const fastPath = await prompts.confirm({
message: `Ready to install (all stable)? Pick "n" to customize channels or pin versions.`,
default: true,
});
if (fastPath) return; // stable for all, registry default applies
// Customize path: per-module picker.
const { fetchStableTags } = require('./modules/channel-resolver');
for (const code of channelSelectable) {
const info = externalByCode.get(code) || communityByCode.get(code);
const repoUrl = info.url;
// Try to pre-resolve the top stable tag so we can surface it in the picker.
let stableLabel = 'stable (released version)';
try {
const parsed = repoUrl ? parseGitHubRepoFromUrl(repoUrl) : null;
if (parsed) {
const tags = await fetchStableTags(parsed.owner, parsed.repo);
if (tags.length > 0) {
stableLabel = `stable ${tags[0].tag} (released version)`;
}
}
} catch {
// fall through with the generic label
}
const choice = await prompts.select({
message: `${code}: choose a channel`,
choices: [
{ name: stableLabel, value: 'stable' },
{ name: 'next (main HEAD \u2014 current development)', value: 'next' },
{ name: 'pin (specific version)', value: 'pin' },
],
default: 'stable',
});
if (choice === 'next') {
channelOptions.nextSet.add(code);
} else 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());
}
// 'stable' is the default; nothing to record.
}
}
/**
* Resolve channel decisions for an update over an existing install.
*
* For each selected external/community module:
* - Read the recorded channel from the existing manifest.
* - On `stable`: query tags; if a newer stable exists, classify the diff
* and prompt. Patch/minor default Y; major defaults N. `--yes` accepts
* defaults (patches/minors) but NOT majors a major under --yes stays
* frozen unless the user also passes `--pin CODE=NEW_TAG`.
* - On `next`: no prompt (pull HEAD).
* - On `pinned`: no prompt (stays pinned).
* - No channel recorded and `version: null`: one-time migration prompt
* ("Switch to stable / Keep on next").
*
* Decisions that freeze the current version are applied by adding a pin to
* `channelOptions.pins` so downstream clone logic honors them.
*/
async _resolveUpdateChannels({ bmadDir, selectedModules, channelOptions, yes }) {
const { Manifest } = require('./core/manifest');
const manifestObj = new Manifest();
const manifest = await manifestObj.read(bmadDir);
const existingByName = new Map();
for (const m of manifest?.modulesDetailed || []) {
if (m?.name) existingByName.set(m.name, m);
}
if (existingByName.size === 0) return;
const externalManager = new ExternalModuleManager();
const externals = await externalManager.listAvailable();
const externalByCode = new Map(externals.map((m) => [m.code, m]));
const { CommunityModuleManager } = require('./modules/community-manager');
const communityMgr = new CommunityModuleManager();
const community = await communityMgr.listAll();
const communityByCode = new Map(community.map((m) => [m.code, m]));
const { fetchStableTags, classifyUpgrade, releaseNotesUrl } = require('./modules/channel-resolver');
const { parseGitHubRepo } = require('./modules/channel-resolver');
for (const code of selectedModules) {
const prev = existingByName.get(code);
if (!prev) continue;
const info = externalByCode.get(code) || communityByCode.get(code);
if (!info) continue;
const repoUrl = info.url;
const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
// Legacy migration: manifest carries no channel and a null/empty
// version. Offer the one-time pick between stable and next.
const recordedChannel = prev.channel || null;
const needsMigration = !recordedChannel && (prev.version == null || prev.version === '');
if (needsMigration) {
if (yes) {
// Conservative headless default: stable.
continue;
}
const chosen = await prompts.select({
message: `${code}: your existing install tracks the main branch. Switch to stable releases (recommended for production), or keep on main?`,
choices: [
{ name: 'Switch to stable', value: 'stable' },
{ name: 'Keep on main (next)', value: 'next' },
],
default: 'stable',
});
if (chosen === 'next') channelOptions.nextSet.add(code);
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') {
channelOptions.nextSet.add(code);
}
continue;
}
// Stable channel: check for a newer released tag.
if (!parsed) continue;
let tags;
try {
tags = await fetchStableTags(parsed.owner, parsed.repo);
} catch (error) {
await prompts.log.warn(`Could not check for updates on ${code} (${error.message}). Leaving at ${prev.version}.`);
if (prev.version) channelOptions.pins.set(code, prev.version);
continue;
}
if (!tags || tags.length === 0) continue;
const topTag = tags[0].tag; // e.g. "v1.7.0"
const currentTag = prev.version || '';
const diffClass = classifyUpgrade(currentTag, topTag);
if (diffClass === 'none') continue; // already at or above top tag
const notes = releaseNotesUrl(repoUrl, topTag);
let accept;
if (diffClass === 'major') {
if (yes) {
// Major under --yes is refused by design.
await prompts.log.warn(
`${code} ${currentTag}${topTag} is a new major release; staying on ${currentTag}. ` +
`To accept, rerun with --pin ${code}=${topTag}.`,
);
channelOptions.pins.set(code, currentTag);
continue;
}
accept = await prompts.confirm({
message:
`${code} ${topTag} available — new major release (may change behavior).` +
(notes ? ` Release notes: ${notes}.` : '') +
' Upgrade?',
default: false,
});
} else if (diffClass === 'minor') {
if (yes) {
accept = true;
} else {
accept = await prompts.confirm({
message: `${code} ${topTag} available (new features).` + (notes ? ` Release notes: ${notes}.` : '') + ' Upgrade?',
default: true,
});
}
} else {
// patch
if (yes) {
accept = true;
} else {
accept = await prompts.confirm({
message: `${code} ${topTag} available. Upgrade?`,
default: true,
});
}
}
if (!accept && currentTag) {
// Freeze the current version by pinning it for this run.
channelOptions.pins.set(code, currentTag);
}
}
}
}
module.exports = { UI };