From 3698c88e7503e63aa50ffffcd20d68cd367790f4 Mon Sep 17 00:00:00 2001 From: murat Date: Wed, 22 Apr 2026 08:46:19 -0500 Subject: [PATCH] fix: bmad tea instal version --- test/test-installation-components.js | 191 +++++++++++++ tools/installer/core/installer.js | 50 +--- tools/installer/core/manifest.js | 87 ++---- tools/installer/modules/version-resolver.js | 299 ++++++++++++++++++++ tools/installer/ui.js | 43 +-- 5 files changed, 527 insertions(+), 143 deletions(-) create mode 100644 tools/installer/modules/version-resolver.js diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 1e66e35bc..32d7c15eb 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -2355,6 +2355,197 @@ async function runTests() { 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-')); + + 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 { + await fs.remove(tempRepo39).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-')); + + 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 { + await fs.remove(tempRepo39).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; + } + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index d46b0df3e..faf0b262d 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -11,6 +11,7 @@ const prompts = require('../prompts'); const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); const { InstallPaths } = require('./install-paths'); const { ExternalModuleManager } = require('../modules/external-manager'); +const { resolveModuleVersion } = require('../modules/version-resolver'); const { ExistingInstall } = require('./existing-install'); @@ -24,44 +25,6 @@ class Installer { 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 * @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 moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null; 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 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 }); } } diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index 2dc94ae9f..ab673fb19 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -1,7 +1,7 @@ const path = require('node:path'); const fs = require('../fs-native'); const crypto = require('node:crypto'); -const { getProjectRoot } = require('../project-root'); +const { resolveModuleVersion } = require('../modules/version-resolver'); const prompts = require('../prompts'); class Manifest { @@ -258,13 +258,11 @@ class Manifest { * @returns {Object} Version info object with version, source, npmPackage, repoUrl */ async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) { - const yaml = require('yaml'); - // Resolve source type first, then read version with the correct path context if (['core', 'bmm'].includes(moduleName)) { - const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); + const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath }); return { - version, + version: versionInfo.version, source: 'built-in', npmPackage: null, repoUrl: null, @@ -277,10 +275,9 @@ class Manifest { const moduleInfo = await extMgr.getModuleByCode(moduleName); if (moduleInfo) { - // External module: use moduleSourcePath if provided, otherwise fall back to cache - const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); + const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath }); return { - version, + version: versionInfo.version, source: 'external', npmPackage: moduleInfo.npmPackage || null, repoUrl: moduleInfo.url || null, @@ -292,9 +289,12 @@ class Manifest { const communityMgr = new CommunityModuleManager(); const communityInfo = await communityMgr.getModuleByCode(moduleName); if (communityInfo) { - const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath); + const versionInfo = await resolveModuleVersion(moduleName, { + moduleSourcePath, + fallbackVersion: communityInfo.version, + }); return { - version: communityVersion || communityInfo.version, + version: versionInfo.version || communityInfo.version, source: 'community', npmPackage: communityInfo.npmPackage || null, repoUrl: communityInfo.url || null, @@ -307,9 +307,13 @@ class Manifest { const resolved = customMgr.getResolution(moduleName); const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir }); 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 { - version: customVersion, + version: versionInfo.version, source: 'custom', npmPackage: null, repoUrl: resolved?.repoUrl || null, @@ -318,64 +322,15 @@ class Manifest { } // Unknown module - const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); + const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath }); return { - version, + version: versionInfo.version, source: 'unknown', npmPackage: 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 * @param {string} packageName - npm package name @@ -424,6 +379,7 @@ class Manifest { * @returns {Array} Array of update info objects */ async checkForUpdates(bmadDir) { + const semver = require('semver'); const modules = await this.getAllModuleVersions(bmadDir); const updates = []; @@ -437,7 +393,10 @@ class Manifest { 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({ name: module.name, installedVersion: module.version, diff --git a/tools/installer/modules/version-resolver.js b/tools/installer/modules/version-resolver.js new file mode 100644 index 000000000..fe31af337 --- /dev/null +++ b/tools/installer/modules/version-resolver.js @@ -0,0 +1,299 @@ +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, 'package.json'); + 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, path.join('.claude-plugin', 'marketplace.json')); + 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(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, maxDepth = DEFAULT_PARENT_DEPTH) { + const normalizedStartDir = await normalizeExistingDirectory(startDir); + if (!normalizedStartDir) { + return null; + } + + let currentDir = normalizedStartDir; + for (let depth = 0; depth <= maxDepth; depth++) { + const candidate = path.join(currentDir, relativeFilePath); + if (await fs.pathExists(candidate)) { + return candidate; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return null; +} + +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, +}; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index d1c5189e9..26b3619c1 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -3,48 +3,17 @@ const os = require('node:os'); const fs = require('./fs-native'); const { CLIUtils } = require('./cli-utils'); const { ExternalModuleManager } = require('./modules/external-manager'); -const { getProjectRoot } = require('./project-root'); +const { resolveModuleVersion } = require('./modules/version-resolver'); 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') * @returns {string} Version string or empty string */ -async function getMarketplaceVersion(moduleCode) { - let marketplacePath; - if (moduleCode === 'core' || moduleCode === 'bmm') { - 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; +async function getModuleVersion(moduleCode) { + const versionInfo = await resolveModuleVersion(moduleCode); + return versionInfo.version || ''; } /** @@ -644,7 +613,7 @@ class UI { const buildModuleEntry = async (code, name, description, isDefault) => { const isInstalled = installedModuleIds.has(code); - const version = await getMarketplaceVersion(code); + const version = await getModuleVersion(code); const label = version ? `${name} (v${version})` : name; return { label,