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:
parent
2395b0e2ed
commit
65bba449a7
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 || '' },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue