fix: bmad tea instal version (#2298)

* fix: bmad tea instal version

* fix: addressed review comments
This commit is contained in:
Murat K Ozcan 2026-04-22 11:03:20 -05:00 committed by GitHub
parent 914c4edd6b
commit 2395b0e2ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 642 additions and 143 deletions

View File

@ -2355,6 +2355,275 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 39: Module Version Resolution
// ============================================================
console.log(`${colors.yellow}Test Suite 39: Module Version Resolution${colors.reset}\n`);
// --- package.json beats module.yaml and marketplace.json for cached external modules ---
{
const { resolveModuleVersion } = require('../tools/installer/modules/version-resolver');
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-cache-'));
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
try {
const moduleRoot = path.join(tempCacheDir39, 'tea');
const moduleSrc = path.join(moduleRoot, 'src');
await fs.ensureDir(path.join(moduleRoot, '.claude-plugin'));
await fs.ensureDir(moduleSrc);
await fs.writeFile(
path.join(moduleRoot, 'package.json'),
JSON.stringify({ name: 'bmad-method-test-architecture-enterprise', version: '1.12.3' }, null, 2) + '\n',
);
await fs.writeFile(
path.join(moduleSrc, 'module.yaml'),
['code: tea', 'name: Test Architect', 'module_version: 1.11.0', ''].join('\n'),
);
await fs.writeFile(
path.join(moduleRoot, '.claude-plugin', 'marketplace.json'),
JSON.stringify({ plugins: [{ name: 'tea', version: '1.7.2' }] }, null, 2) + '\n',
);
const versionInfo = await resolveModuleVersion('tea');
assert(versionInfo.version === '1.12.3', 'resolver prefers cached package.json over stale marketplace metadata for external modules');
assert(versionInfo.source === 'package.json', 'resolver reports package.json as the winning metadata source');
} finally {
if (priorCacheEnv39 === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
} else {
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
}
await fs.remove(tempCacheDir39).catch(() => {});
}
}
// --- module.yaml is used when package.json is absent ---
{
const { resolveModuleVersion } = require('../tools/installer/modules/version-resolver');
const tempRepo39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-module-yaml-'));
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-module-yaml-cache-'));
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
try {
const moduleDir = path.join(tempRepo39, 'src');
await fs.ensureDir(path.join(tempRepo39, '.claude-plugin'));
await fs.ensureDir(moduleDir);
await fs.writeFile(path.join(moduleDir, 'module.yaml'), ['code: sample-mod', 'module_version: 2.4.0', ''].join('\n'));
await fs.writeFile(
path.join(tempRepo39, '.claude-plugin', 'marketplace.json'),
JSON.stringify({ plugins: [{ name: 'sample-mod', version: '1.7.2' }] }, null, 2) + '\n',
);
const versionInfo = await resolveModuleVersion('sample-mod', { moduleSourcePath: moduleDir });
assert(versionInfo.version === '2.4.0', 'resolver falls back to module.yaml when package.json is missing');
assert(versionInfo.source === 'module.yaml', 'resolver reports module.yaml when it provides the selected version');
} finally {
if (priorCacheEnv39 === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
} else {
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
}
await fs.remove(tempRepo39).catch(() => {});
await fs.remove(tempCacheDir39).catch(() => {});
}
}
// --- marketplace fallback uses semver-aware comparison ---
{
const { resolveModuleVersion } = require('../tools/installer/modules/version-resolver');
const tempRepo39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-marketplace-'));
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-marketplace-cache-'));
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
try {
const moduleDir = path.join(tempRepo39, 'src');
await fs.ensureDir(path.join(tempRepo39, '.claude-plugin'));
await fs.ensureDir(moduleDir);
await fs.writeFile(
path.join(tempRepo39, '.claude-plugin', 'marketplace.json'),
JSON.stringify(
{
plugins: [
{ name: 'older-plugin', version: '1.7.2' },
{ name: 'newer-plugin', version: '1.12.3' },
],
},
null,
2,
) + '\n',
);
const versionInfo = await resolveModuleVersion('missing-plugin', { moduleSourcePath: moduleDir });
assert(
versionInfo.version === '1.12.3',
'resolver picks the highest marketplace fallback version using semver instead of string comparison',
);
assert(versionInfo.source === 'marketplace.json', 'resolver reports marketplace.json when it is the only usable metadata source');
} finally {
if (priorCacheEnv39 === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
} else {
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
}
await fs.remove(tempRepo39).catch(() => {});
await fs.remove(tempCacheDir39).catch(() => {});
}
}
// --- package.json lookup must not escape the module repo boundary ---
{
const { resolveModuleVersion } = require('../tools/installer/modules/version-resolver');
const tempHost39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-boundary-host-'));
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-boundary-cache-'));
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
try {
const moduleRoot = path.join(tempHost39, 'nested-module');
const moduleDir = path.join(moduleRoot, 'src');
await fs.ensureDir(path.join(moduleRoot, '.claude-plugin'));
await fs.ensureDir(moduleDir);
await fs.writeFile(path.join(tempHost39, 'package.json'), JSON.stringify({ name: 'host-project', version: '9.9.9' }, null, 2) + '\n');
await fs.writeFile(path.join(moduleDir, 'module.yaml'), ['code: sample-mod', 'module_version: 2.4.0', ''].join('\n'));
await fs.writeFile(
path.join(moduleRoot, '.claude-plugin', 'marketplace.json'),
JSON.stringify({ plugins: [{ name: 'sample-mod', version: '1.7.2' }] }, null, 2) + '\n',
);
const versionInfo = await resolveModuleVersion('sample-mod', { moduleSourcePath: moduleDir });
assert(versionInfo.version === '2.4.0', 'resolver does not read a host project package.json outside the module repo boundary');
assert(versionInfo.source === 'module.yaml', 'resolver stops at the module repo boundary before climbing into host project metadata');
} finally {
if (priorCacheEnv39 === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
} else {
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
}
await fs.remove(tempHost39).catch(() => {});
await fs.remove(tempCacheDir39).catch(() => {});
}
}
// --- Manifest uses the shared resolver for external modules ---
{
const { Manifest } = require('../tools/installer/core/manifest');
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-manifest-version-cache-'));
const tempBmadDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-manifest-version-install-'));
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
const originalLoadConfig39 = ExternalModuleManager.prototype.loadExternalModulesConfig;
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
ExternalModuleManager.prototype.loadExternalModulesConfig = async function () {
return {
modules: [
{
code: 'tea',
name: 'Test Architect',
repository: 'https://example.com/tea.git',
module_definition: 'src/module.yaml',
npm_package: 'bmad-method-test-architecture-enterprise',
},
],
};
};
try {
const moduleRoot = path.join(tempCacheDir39, 'tea');
const moduleSrc = path.join(moduleRoot, 'src');
await fs.ensureDir(path.join(moduleRoot, '.claude-plugin'));
await fs.ensureDir(moduleSrc);
await fs.writeFile(
path.join(moduleRoot, 'package.json'),
JSON.stringify({ name: 'bmad-method-test-architecture-enterprise', version: '1.12.3' }, null, 2) + '\n',
);
await fs.writeFile(path.join(moduleSrc, 'module.yaml'), ['code: tea', 'module_version: 1.11.0', ''].join('\n'));
await fs.writeFile(
path.join(moduleRoot, '.claude-plugin', 'marketplace.json'),
JSON.stringify({ plugins: [{ name: 'tea', version: '1.7.2' }] }, null, 2) + '\n',
);
const manifest39 = new Manifest();
const versionInfo = await manifest39.getModuleVersionInfo('tea', tempBmadDir39, moduleSrc);
assert(versionInfo.version === '1.12.3', 'manifest version info prefers external package.json over stale marketplace metadata');
assert(versionInfo.source === 'external', 'manifest preserves external source classification while using the shared resolver');
assert(
versionInfo.npmPackage === 'bmad-method-test-architecture-enterprise',
'manifest preserves npm package metadata for external modules',
);
} finally {
ExternalModuleManager.prototype.loadExternalModulesConfig = originalLoadConfig39;
if (priorCacheEnv39 === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
} else {
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
}
await fs.remove(tempCacheDir39).catch(() => {});
await fs.remove(tempBmadDir39).catch(() => {});
}
}
// --- Update checks should not advertise npm downgrades when source installs are newer ---
{
const { Manifest } = require('../tools/installer/core/manifest');
const manifest39 = new Manifest();
const originalGetAllModuleVersions39 = manifest39.getAllModuleVersions.bind(manifest39);
const originalFetchNpmVersion39 = manifest39.fetchNpmVersion.bind(manifest39);
manifest39.getAllModuleVersions = async () => [
{
name: 'tea',
version: '1.12.3',
npmPackage: 'bmad-method-test-architecture-enterprise',
},
];
manifest39.fetchNpmVersion = async () => '1.7.2';
try {
const updates = await manifest39.checkForUpdates('/unused');
assert(updates.length === 0, 'update check ignores older npm versions when installed source metadata is newer');
} finally {
manifest39.getAllModuleVersions = originalGetAllModuleVersions39;
manifest39.fetchNpmVersion = originalFetchNpmVersion39;
}
}
// --- Update checks ignore non-semver version strings instead of flagging false positives ---
{
const { Manifest } = require('../tools/installer/core/manifest');
const manifest39 = new Manifest();
const originalGetAllModuleVersions39 = manifest39.getAllModuleVersions.bind(manifest39);
const originalFetchNpmVersion39 = manifest39.fetchNpmVersion.bind(manifest39);
manifest39.getAllModuleVersions = async () => [
{
name: 'tea',
version: 'workspace-build',
npmPackage: 'bmad-method-test-architecture-enterprise',
},
];
manifest39.fetchNpmVersion = async () => 'latest-build';
try {
const updates = await manifest39.checkForUpdates('/unused');
assert(updates.length === 0, 'update check ignores non-semver version strings instead of reporting misleading updates');
} finally {
manifest39.getAllModuleVersions = originalGetAllModuleVersions39;
manifest39.fetchNpmVersion = originalFetchNpmVersion39;
}
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -11,6 +11,7 @@ const prompts = require('../prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const { InstallPaths } = require('./install-paths'); const { InstallPaths } = require('./install-paths');
const { ExternalModuleManager } = require('../modules/external-manager'); const { ExternalModuleManager } = require('../modules/external-manager');
const { resolveModuleVersion } = require('../modules/version-resolver');
const { ExistingInstall } = require('./existing-install'); const { ExistingInstall } = require('./existing-install');
@ -24,44 +25,6 @@ class Installer {
this.bmadFolderName = BMAD_FOLDER_NAME; this.bmadFolderName = BMAD_FOLDER_NAME;
} }
/**
* Read the module version from .claude-plugin/marketplace.json
* Walks up from sourcePath looking for .claude-plugin/marketplace.json
* @param {string} sourcePath - Module source directory
* @returns {string} Version string or empty string
*/
async _getMarketplaceVersion(sourcePath) {
let dir = sourcePath;
for (let i = 0; i < 5; i++) {
const marketplacePath = path.join(dir, '.claude-plugin', 'marketplace.json');
if (await fs.pathExists(marketplacePath)) {
try {
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
return this._extractMarketplaceVersion(data);
} catch {
return '';
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return '';
}
/**
* Extract the highest version from marketplace.json plugins array
*/
_extractMarketplaceVersion(data) {
const plugins = data?.plugins;
if (!Array.isArray(plugins) || plugins.length === 0) return '';
let best = '';
for (const p of plugins) {
if (p.version && (!best || p.version > best)) best = p.version;
}
return best;
}
/** /**
* Main installation method * Main installation method
* @param {Object} config - Installation configuration * @param {Object} config - Installation configuration
@ -641,15 +604,18 @@ class Installer {
}, },
); );
// Get display name from source module.yaml; version from resolution cache or marketplace.json // 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 });
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null; const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
const displayName = moduleInfo?.name || moduleName; const displayName = moduleInfo?.name || moduleName;
// Prefer version from resolution cache (accurate for custom/local modules),
// fall back to marketplace.json walk-up for official modules
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName); const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
const version = cachedResolution?.version || (sourcePath ? await this._getMarketplaceVersion(sourcePath) : ''); const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath: sourcePath,
fallbackVersion: cachedResolution?.version,
marketplacePluginNames: cachedResolution?.pluginName ? [cachedResolution.pluginName] : [],
});
const version = versionInfo.version || '';
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version }); addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
} }
} }

View File

@ -1,7 +1,7 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('../fs-native'); const fs = require('../fs-native');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { getProjectRoot } = require('../project-root'); const { resolveModuleVersion } = require('../modules/version-resolver');
const prompts = require('../prompts'); const prompts = require('../prompts');
class Manifest { class Manifest {
@ -258,13 +258,11 @@ class Manifest {
* @returns {Object} Version info object with version, source, npmPackage, repoUrl * @returns {Object} Version info object with version, source, npmPackage, repoUrl
*/ */
async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) { async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) {
const yaml = require('yaml');
// Resolve source type first, then read version with the correct path context // Resolve source type first, then read version with the correct path context
if (['core', 'bmm'].includes(moduleName)) { if (['core', 'bmm'].includes(moduleName)) {
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
return { return {
version, version: versionInfo.version,
source: 'built-in', source: 'built-in',
npmPackage: null, npmPackage: null,
repoUrl: null, repoUrl: null,
@ -277,10 +275,9 @@ class Manifest {
const moduleInfo = await extMgr.getModuleByCode(moduleName); const moduleInfo = await extMgr.getModuleByCode(moduleName);
if (moduleInfo) { if (moduleInfo) {
// External module: use moduleSourcePath if provided, otherwise fall back to cache const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
return { return {
version, version: versionInfo.version,
source: 'external', source: 'external',
npmPackage: moduleInfo.npmPackage || null, npmPackage: moduleInfo.npmPackage || null,
repoUrl: moduleInfo.url || null, repoUrl: moduleInfo.url || null,
@ -292,9 +289,12 @@ class Manifest {
const communityMgr = new CommunityModuleManager(); const communityMgr = new CommunityModuleManager();
const communityInfo = await communityMgr.getModuleByCode(moduleName); const communityInfo = await communityMgr.getModuleByCode(moduleName);
if (communityInfo) { if (communityInfo) {
const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath); const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath,
fallbackVersion: communityInfo.version,
});
return { return {
version: communityVersion || communityInfo.version, version: versionInfo.version || communityInfo.version,
source: 'community', source: 'community',
npmPackage: communityInfo.npmPackage || null, npmPackage: communityInfo.npmPackage || null,
repoUrl: communityInfo.url || null, repoUrl: communityInfo.url || null,
@ -307,9 +307,13 @@ class Manifest {
const resolved = customMgr.getResolution(moduleName); const resolved = customMgr.getResolution(moduleName);
const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir }); const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir });
if (customSource || resolved) { if (customSource || resolved) {
const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath)); const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath: moduleSourcePath || customSource,
fallbackVersion: resolved?.version,
marketplacePluginNames: resolved?.pluginName ? [resolved.pluginName] : [],
});
return { return {
version: customVersion, version: versionInfo.version,
source: 'custom', source: 'custom',
npmPackage: null, npmPackage: null,
repoUrl: resolved?.repoUrl || null, repoUrl: resolved?.repoUrl || null,
@ -318,64 +322,15 @@ class Manifest {
} }
// Unknown module // Unknown module
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
return { return {
version, version: versionInfo.version,
source: 'unknown', source: 'unknown',
npmPackage: null, npmPackage: null,
repoUrl: null, repoUrl: null,
}; };
} }
/**
* Read version from .claude-plugin/marketplace.json for a module
* @param {string} moduleName - Module code
* @returns {string|null} Version or null
*/
async _readMarketplaceVersion(moduleName, moduleSourcePath = null) {
const os = require('node:os');
let marketplacePath;
if (['core', 'bmm'].includes(moduleName)) {
marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json');
} else if (moduleSourcePath) {
// Walk up from source path to find marketplace.json
let dir = moduleSourcePath;
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, '.claude-plugin', 'marketplace.json');
if (await fs.pathExists(candidate)) {
marketplacePath = candidate;
break;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
// Fallback to external module cache
if (!marketplacePath) {
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleName);
marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json');
}
try {
if (await fs.pathExists(marketplacePath)) {
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
const plugins = data?.plugins;
if (!Array.isArray(plugins) || plugins.length === 0) return null;
let best = null;
for (const p of plugins) {
if (p.version && (!best || p.version > best)) best = p.version;
}
return best;
}
} catch {
// ignore
}
return null;
}
/** /**
* Fetch latest version from npm for a package * Fetch latest version from npm for a package
* @param {string} packageName - npm package name * @param {string} packageName - npm package name
@ -424,6 +379,7 @@ class Manifest {
* @returns {Array} Array of update info objects * @returns {Array} Array of update info objects
*/ */
async checkForUpdates(bmadDir) { async checkForUpdates(bmadDir) {
const semver = require('semver');
const modules = await this.getAllModuleVersions(bmadDir); const modules = await this.getAllModuleVersions(bmadDir);
const updates = []; const updates = [];
@ -437,7 +393,10 @@ class Manifest {
continue; continue;
} }
if (module.version !== latestVersion) { const installedVersion = semver.valid(module.version) || semver.valid(semver.coerce(module.version || ''));
const availableVersion = semver.valid(latestVersion) || semver.valid(semver.coerce(latestVersion));
if (installedVersion && availableVersion && semver.gt(availableVersion, installedVersion)) {
updates.push({ updates.push({
name: module.name, name: module.name,
installedVersion: module.version, installedVersion: module.version,

View File

@ -0,0 +1,336 @@
const path = require('node:path');
const semver = require('semver');
const yaml = require('yaml');
const fs = require('../fs-native');
const { getExternalModuleCachePath, getModulePath, resolveInstalledModuleYaml } = require('../project-root');
const DEFAULT_PARENT_DEPTH = 8;
/**
* Resolve a module version from authoritative on-disk metadata.
* Preference order:
* 1. package.json nearest the module source/cache root
* 2. module.yaml in the module source directory
* 3. .claude-plugin/marketplace.json
* 4. caller-provided fallback version
*
* @param {string} moduleName - Module code/name
* @param {Object} [options]
* @param {string} [options.moduleSourcePath] - Directory containing module.yaml
* @param {string} [options.fallbackVersion] - Final fallback when no metadata is found
* @param {string[]} [options.marketplacePluginNames] - Preferred marketplace plugin names
* @returns {Promise<{version: string|null, source: string|null, path: string|null}>}
*/
async function resolveModuleVersion(moduleName, options = {}) {
const moduleSourcePath = await normalizeDirectoryPath(options.moduleSourcePath);
const packageJsonPath = await findPackageJsonPath(moduleName, moduleSourcePath);
if (packageJsonPath) {
const packageVersion = await readPackageJsonVersion(packageJsonPath);
if (packageVersion) {
return {
version: packageVersion,
source: 'package.json',
path: packageJsonPath,
};
}
}
const moduleYamlPath = await findModuleYamlPath(moduleName, moduleSourcePath);
if (moduleYamlPath) {
const moduleVersion = await readModuleYamlVersion(moduleYamlPath);
if (moduleVersion) {
return {
version: moduleVersion,
source: 'module.yaml',
path: moduleYamlPath,
};
}
}
const marketplaceVersion = await findMarketplaceVersion(moduleName, moduleSourcePath, options.marketplacePluginNames || []);
if (marketplaceVersion) {
return marketplaceVersion;
}
const fallbackVersion = normalizeVersion(options.fallbackVersion);
if (fallbackVersion) {
return {
version: fallbackVersion,
source: 'fallback',
path: null,
};
}
return {
version: null,
source: null,
path: null,
};
}
async function findPackageJsonPath(moduleName, moduleSourcePath) {
const roots = await buildSearchRoots(moduleName, moduleSourcePath);
for (const root of roots) {
const packageJsonPath = await findNearestUpwardFile(root.searchDir, 'package.json', { boundaryDir: root.boundaryDir });
if (packageJsonPath) {
return packageJsonPath;
}
}
return null;
}
async function findModuleYamlPath(moduleName, moduleSourcePath) {
if (moduleSourcePath) {
const directModuleYamlPath = path.join(moduleSourcePath, 'module.yaml');
if (await fs.pathExists(directModuleYamlPath)) {
return directModuleYamlPath;
}
}
return resolveInstalledModuleYaml(moduleName);
}
async function findMarketplaceVersion(moduleName, moduleSourcePath, marketplacePluginNames) {
const roots = await buildSearchRoots(moduleName, moduleSourcePath);
for (const root of roots) {
const marketplacePath = await findNearestUpwardFile(root.searchDir, path.join('.claude-plugin', 'marketplace.json'), {
boundaryDir: root.boundaryDir,
});
if (!marketplacePath) {
continue;
}
const data = await readJsonFile(marketplacePath);
if (!data) {
continue;
}
const version = extractMarketplaceVersion(data, moduleName, marketplacePluginNames);
if (version) {
return {
version,
source: 'marketplace.json',
path: marketplacePath,
};
}
}
return null;
}
async function buildSearchRoots(moduleName, moduleSourcePath) {
const roots = [];
const seen = new Set();
const addRoot = async (candidate) => {
const normalized = await normalizeExistingDirectory(candidate);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
roots.push({
searchDir: normalized,
boundaryDir: await findSearchBoundary(normalized),
});
};
await addRoot(moduleSourcePath);
if (moduleName === 'core' || moduleName === 'bmm') {
await addRoot(getModulePath(moduleName));
} else {
await addRoot(getExternalModuleCachePath(moduleName));
}
return roots;
}
async function findNearestUpwardFile(startDir, relativeFilePath, options = {}) {
const normalizedStartDir = await normalizeExistingDirectory(startDir);
if (!normalizedStartDir) {
return null;
}
const maxDepth = options.maxDepth ?? DEFAULT_PARENT_DEPTH;
const normalizedBoundaryDir = await normalizeDirectoryPath(options.boundaryDir);
let currentDir = normalizedStartDir;
for (let depth = 0; depth <= maxDepth; depth++) {
const candidate = path.join(currentDir, relativeFilePath);
if (await fs.pathExists(candidate)) {
return candidate;
}
if (normalizedBoundaryDir && currentDir === normalizedBoundaryDir) {
break;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
return null;
}
async function findSearchBoundary(startDir) {
const normalizedStartDir = await normalizeExistingDirectory(startDir);
if (!normalizedStartDir) {
return null;
}
let currentDir = normalizedStartDir;
for (let depth = 0; depth <= DEFAULT_PARENT_DEPTH; depth++) {
if (
(await fs.pathExists(path.join(currentDir, 'package.json'))) ||
(await fs.pathExists(path.join(currentDir, '.claude-plugin', 'marketplace.json'))) ||
(await fs.pathExists(path.join(currentDir, '.git')))
) {
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
return normalizedStartDir;
}
async function normalizeDirectoryPath(candidate) {
if (!candidate) {
return null;
}
const resolvedPath = path.resolve(candidate);
try {
const stats = await fs.stat(resolvedPath);
return stats.isDirectory() ? resolvedPath : path.dirname(resolvedPath);
} catch {
return resolvedPath;
}
}
async function normalizeExistingDirectory(candidate) {
const normalized = await normalizeDirectoryPath(candidate);
if (!normalized) {
return null;
}
if (!(await fs.pathExists(normalized))) {
return null;
}
return normalized;
}
async function readPackageJsonVersion(packageJsonPath) {
const data = await readJsonFile(packageJsonPath);
return normalizeVersion(data?.version);
}
async function readModuleYamlVersion(moduleYamlPath) {
try {
const content = await fs.readFile(moduleYamlPath, 'utf8');
const data = yaml.parse(content);
return normalizeVersion(data?.version || data?.module_version || data?.moduleVersion);
} catch {
return null;
}
}
async function readJsonFile(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
function extractMarketplaceVersion(data, moduleName, marketplacePluginNames = []) {
const plugins = Array.isArray(data?.plugins) ? data.plugins : [];
if (plugins.length === 0) {
return null;
}
const preferredNames = new Set(
[moduleName, ...marketplacePluginNames]
.filter((value) => typeof value === 'string')
.map((value) => value.trim())
.filter(Boolean),
);
const exactMatches = [];
const fallbackVersions = [];
for (const plugin of plugins) {
const version = normalizeVersion(plugin?.version);
if (!version) {
continue;
}
fallbackVersions.push(version);
const pluginNames = [plugin?.name, plugin?.code].filter((value) => typeof value === 'string').map((value) => value.trim());
if (pluginNames.some((name) => preferredNames.has(name))) {
exactMatches.push(version);
}
}
return pickBestVersion(exactMatches.length > 0 ? exactMatches : fallbackVersions);
}
function pickBestVersion(versions) {
const candidates = versions.map(normalizeVersion).filter(Boolean);
if (candidates.length === 0) {
return null;
}
candidates.sort(compareVersionsDescending);
return candidates[0];
}
function compareVersionsDescending(left, right) {
const leftSemver = normalizeSemver(left);
const rightSemver = normalizeSemver(right);
if (leftSemver && rightSemver) {
return semver.rcompare(leftSemver, rightSemver);
}
if (leftSemver) {
return -1;
}
if (rightSemver) {
return 1;
}
return right.localeCompare(left, undefined, { numeric: true, sensitivity: 'base' });
}
function normalizeSemver(version) {
return semver.valid(version) || semver.valid(semver.coerce(version));
}
function normalizeVersion(version) {
if (typeof version !== 'string') {
return null;
}
const trimmed = version.trim();
return trimmed || null;
}
module.exports = {
resolveModuleVersion,
};

View File

@ -3,48 +3,17 @@ const os = require('node:os');
const fs = require('./fs-native'); const fs = require('./fs-native');
const { CLIUtils } = require('./cli-utils'); const { CLIUtils } = require('./cli-utils');
const { ExternalModuleManager } = require('./modules/external-manager'); const { ExternalModuleManager } = require('./modules/external-manager');
const { getProjectRoot } = require('./project-root'); const { resolveModuleVersion } = require('./modules/version-resolver');
const prompts = require('./prompts'); const prompts = require('./prompts');
/** /**
* Read module version from .claude-plugin/marketplace.json * Read a module version from the freshest local metadata available.
* @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis') * @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
* @returns {string} Version string or empty string * @returns {string} Version string or empty string
*/ */
async function getMarketplaceVersion(moduleCode) { async function getModuleVersion(moduleCode) {
let marketplacePath; const versionInfo = await resolveModuleVersion(moduleCode);
if (moduleCode === 'core' || moduleCode === 'bmm') { return versionInfo.version || '';
marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json');
} else {
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleCode);
marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json');
}
try {
if (await fs.pathExists(marketplacePath)) {
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
return _extractMarketplaceVersion(data);
}
} catch {
// ignore
}
return '';
}
/**
* Extract the highest version from marketplace.json plugins array.
* Handles multiple plugins per file safely.
* @param {Object} data - Parsed marketplace.json
* @returns {string} Version string or empty string
*/
function _extractMarketplaceVersion(data) {
const plugins = data?.plugins;
if (!Array.isArray(plugins) || plugins.length === 0) return '';
// Use the highest version across all plugins in the file
let best = '';
for (const p of plugins) {
if (p.version && (!best || p.version > best)) best = p.version;
}
return best;
} }
/** /**
@ -644,7 +613,7 @@ class UI {
const buildModuleEntry = async (code, name, description, isDefault) => { const buildModuleEntry = async (code, name, description, isDefault) => {
const isInstalled = installedModuleIds.has(code); const isInstalled = installedModuleIds.has(code);
const version = await getMarketplaceVersion(code); const version = await getModuleVersion(code);
const label = version ? `${name} (v${version})` : name; const label = version ? `${name} (v${version})` : name;
return { return {
label, label,