Compare commits

...

3 Commits

Author SHA1 Message Date
Hisyam 26d7fed318
Merge 84a3aa57de into 2395b0e2ed 2026-04-22 10:04:42 -06:00
Murat K Ozcan 2395b0e2ed
fix: bmad tea instal version (#2298)
* fix: bmad tea instal version

* fix: addressed review comments
2026-04-22 11:03:20 -05:00
Hisyam 84a3aa57de
docs: add pencil usage guidelines to core-tools #2162
Addresses issue #2162 by adding recommendations for introducing Pencil early in the BMad workflow.
2026-03-30 12:14:20 +07:00
6 changed files with 653 additions and 143 deletions

View File

@ -291,3 +291,14 @@ Run both `bmad-review-adversarial-general` and `bmad-review-edge-case-hunter` to
**Input:** Target folder path
**Output:** `index.md` with organized file listings, relative links, and brief descriptions
## 3rd Party Tools Integration: Pencil
If you are using **Pencil** within BMAD workflows for creating mockups or UI designs, it is critical that the LLM is fully aware of its existence early in the process rather than treating it merely as a nice-to-have MCP (Model Context Protocol).
**Important Guidelines for Pencil:**
- **Introduce Early:** The sooner you bring Pencil into your planning process, the better. You must specify Pencil in your tooling **before** generating the `_bmad-output/planning-artifacts/architecture.md` document.
- **Enforce Context:** Explicitly enforce the inclusion of your `.pen` (Pencil) files in the `_bmad-output/project-context.md` file.
- **Why this matters:** If you bring Pencil in after the PRD, UX, and Architecture docs are already drafted without establishing these guardrails, the AI will not integrate it smoothly and will cause avoidable course corrections during sprints.

View File

@ -2355,6 +2355,275 @@ 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-'));
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
// ============================================================

View File

@ -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 });
}
}

View File

@ -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,

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 { 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,