fix: installer live version for external modules (#2307)

* resolved merge conflict

* fix: addressed PR comments

* fix: use git tags for installer module versions
This commit is contained in:
Murat K Ozcan 2026-04-24 13:13:56 -05:00 committed by GitHub
parent 3d824d4c0f
commit 0533976753
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 421 additions and 44 deletions

View File

@ -2622,6 +2622,229 @@ async function runTests() {
} }
} }
// --- Official module picker uses git tags for external module labels ---
{
const { UI } = require('../tools/installer/ui');
const prompts = require('../tools/installer/prompts');
const channelResolver = require('../tools/installer/modules/channel-resolver');
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
const ui = new UI();
const originalOfficialListAvailable39 = OfficialModules.prototype.listAvailable;
const originalExternalListAvailable39 = ExternalModuleManager.prototype.listAvailable;
const originalAutocomplete39 = prompts.autocompleteMultiselect;
const originalSpinner39 = prompts.spinner;
const originalWarn39 = prompts.log.warn;
const originalMessage39 = prompts.log.message;
const originalResolveChannel39 = channelResolver.resolveChannel;
const seenLabels39 = [];
const spinnerStarts39 = [];
const spinnerStops39 = [];
const warnings39 = [];
OfficialModules.prototype.listAvailable = async function () {
return {
modules: [
{
id: 'core',
name: 'BMad Core Module',
description: 'always installed',
defaultSelected: true,
},
],
};
};
ExternalModuleManager.prototype.listAvailable = async function () {
return [
{
code: 'bmb',
name: 'BMad Builder',
description: 'Builder module',
defaultSelected: false,
builtIn: false,
url: 'https://github.com/bmad-code-org/bmad-builder',
defaultChannel: 'stable',
},
{
code: 'tea',
name: 'Test Architect',
description: 'Test architecture module',
defaultSelected: false,
builtIn: false,
url: 'https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise',
defaultChannel: 'stable',
},
];
};
channelResolver.resolveChannel = async function ({ repoUrl, channel }) {
if (channel !== 'stable') {
return { channel, version: channel === 'next' ? 'main' : 'unknown' };
}
if (repoUrl.includes('bmad-builder')) {
return { channel: 'stable', version: 'v1.7.0', ref: 'v1.7.0', resolvedFallback: false };
}
if (repoUrl.includes('bmad-method-test-architecture-enterprise')) {
return { channel: 'stable', version: 'v1.15.0', ref: 'v1.15.0', resolvedFallback: false };
}
throw new Error(`unexpected repo ${repoUrl}`);
};
prompts.autocompleteMultiselect = async (options) => {
seenLabels39.push(...options.options.map((opt) => opt.label));
return ['core'];
};
prompts.spinner = async () => ({
start(message) {
spinnerStarts39.push(message);
},
stop(message) {
spinnerStops39.push(message);
},
error(message) {
spinnerStops39.push(`error:${message}`);
},
});
prompts.log.warn = async (message) => {
warnings39.push(message);
};
prompts.log.message = async () => {};
try {
await ui._selectOfficialModules(
new Set(['bmb']),
new Map([
['bmb', '1.1.0'],
['core', '6.2.0'],
]),
{ global: null, nextSet: new Set(), pins: new Map(), warnings: [] },
);
assert(
seenLabels39.includes('BMad Builder (v1.1.0 → v1.7.0)'),
'official module picker shows installed-to-latest arrow from git tags',
);
assert(seenLabels39.includes('Test Architect (v1.15.0)'), 'official module picker shows latest git-tag version for fresh installs');
assert(
spinnerStarts39.includes('Checking latest module versions...'),
'official module picker wraps external lookups in a single spinner',
);
assert(spinnerStops39.includes('Checked latest module versions.'), 'official module picker stops the version-check spinner');
assert(warnings39.length === 0, 'official module picker does not warn when tag lookups succeed');
} finally {
OfficialModules.prototype.listAvailable = originalOfficialListAvailable39;
ExternalModuleManager.prototype.listAvailable = originalExternalListAvailable39;
prompts.autocompleteMultiselect = originalAutocomplete39;
prompts.spinner = originalSpinner39;
prompts.log.warn = originalWarn39;
prompts.log.message = originalMessage39;
channelResolver.resolveChannel = originalResolveChannel39;
}
}
// --- Official module picker warns and falls back to cached versions when tag lookups fail ---
{
const { UI } = require('../tools/installer/ui');
const prompts = require('../tools/installer/prompts');
const channelResolver = require('../tools/installer/modules/channel-resolver');
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
const ui = new UI();
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-picker-cache-'));
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
const originalOfficialListAvailable39 = OfficialModules.prototype.listAvailable;
const originalExternalListAvailable39 = ExternalModuleManager.prototype.listAvailable;
const originalAutocomplete39 = prompts.autocompleteMultiselect;
const originalSpinner39 = prompts.spinner;
const originalWarn39 = prompts.log.warn;
const originalMessage39 = prompts.log.message;
const originalResolveChannel39 = channelResolver.resolveChannel;
const seenLabels39 = [];
const warnings39 = [];
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
await fs.ensureDir(path.join(tempCacheDir39, 'bmb'));
await fs.writeFile(
path.join(tempCacheDir39, 'bmb', 'package.json'),
JSON.stringify({ name: 'bmad-builder', version: '1.7.0' }, null, 2) + '\n',
);
OfficialModules.prototype.listAvailable = async function () {
return {
modules: [
{
id: 'core',
name: 'BMad Core Module',
description: 'always installed',
defaultSelected: true,
},
],
};
};
ExternalModuleManager.prototype.listAvailable = async function () {
return [
{
code: 'bmb',
name: 'BMad Builder',
description: 'Builder module',
defaultSelected: false,
builtIn: false,
url: 'https://github.com/bmad-code-org/bmad-builder',
defaultChannel: 'stable',
},
];
};
channelResolver.resolveChannel = async function () {
throw new Error('tag lookup unavailable');
};
prompts.autocompleteMultiselect = async (options) => {
seenLabels39.push(...options.options.map((opt) => opt.label));
return ['core'];
};
prompts.spinner = async () => ({
start() {},
stop() {},
error() {},
});
prompts.log.warn = async (message) => {
warnings39.push(message);
};
prompts.log.message = async () => {};
try {
await ui._selectOfficialModules(new Set(), new Map(), { global: null, nextSet: new Set(), pins: new Map(), warnings: [] });
assert(
seenLabels39.includes('BMad Builder (v1.7.0)'),
'official module picker falls back to cached/local versions when tag lookup fails',
);
assert(
warnings39.includes('Could not check latest module versions; showing cached/local versions.'),
'official module picker warns once when all latest-version lookups fail',
);
} finally {
OfficialModules.prototype.listAvailable = originalOfficialListAvailable39;
ExternalModuleManager.prototype.listAvailable = originalExternalListAvailable39;
prompts.autocompleteMultiselect = originalAutocomplete39;
prompts.spinner = originalSpinner39;
prompts.log.warn = originalWarn39;
prompts.log.message = originalMessage39;
channelResolver.resolveChannel = originalResolveChannel39;
if (priorCacheEnv39 === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
} else {
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
}
await fs.remove(tempCacheDir39).catch(() => {});
}
}
console.log(''); console.log('');
// ============================================================ // ============================================================

View File

@ -1,9 +1,20 @@
const path = require('node:path'); const path = require('node:path');
const https = require('node:https');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const fs = require('../fs-native'); const fs = require('../fs-native');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { resolveModuleVersion } = require('../modules/version-resolver'); const { resolveModuleVersion } = require('../modules/version-resolver');
const prompts = require('../prompts'); const prompts = require('../prompts');
const execFileAsync = promisify(execFile);
const NPM_LOOKUP_TIMEOUT_MS = 10_000;
const NPM_PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
function isValidNpmPackageName(packageName) {
return typeof packageName === 'string' && NPM_PACKAGE_NAME_PATTERN.test(packageName);
}
class Manifest { class Manifest {
/** /**
* Create a new manifest * Create a new manifest
@ -362,35 +373,40 @@ class Manifest {
* @returns {string|null} Latest version or null * @returns {string|null} Latest version or null
*/ */
async fetchNpmVersion(packageName) { async fetchNpmVersion(packageName) {
try { if (!isValidNpmPackageName(packageName)) {
const https = require('node:https'); return null;
const { execSync } = require('node:child_process'); }
try {
// Try using npm view first (more reliable) // Try using npm view first (more reliable)
try { try {
const result = execSync(`npm view ${packageName} version`, { const { stdout } = await execFileAsync('npm', ['view', packageName, 'version'], {
encoding: 'utf8', encoding: 'utf8',
stdio: 'pipe', timeout: NPM_LOOKUP_TIMEOUT_MS,
timeout: 10_000,
}); });
return result.trim(); return stdout.trim();
} catch { } catch {
// Fallback to npm registry API // Fallback to npm registry API
return new Promise((resolve, reject) => { return new Promise((resolve) => {
https const request = https.get(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, (res) => {
.get(`https://registry.npmjs.org/${packageName}`, (res) => { let data = '';
let data = ''; res.on('data', (chunk) => (data += chunk));
res.on('data', (chunk) => (data += chunk)); res.on('end', () => {
res.on('end', () => { try {
try { const pkg = JSON.parse(data);
const pkg = JSON.parse(data); resolve(pkg['dist-tags']?.latest || pkg.version || null);
resolve(pkg['dist-tags']?.latest || pkg.version || null); } catch {
} catch { resolve(null);
resolve(null); }
} });
}); });
})
.on('error', () => resolve(null)); request.setTimeout(NPM_LOOKUP_TIMEOUT_MS, () => {
request.destroy();
resolve(null);
});
request.on('error', () => resolve(null));
}); });
} }
} catch { } catch {

View File

@ -1,20 +1,107 @@
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const semver = require('semver');
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 { resolveModuleVersion } = require('./modules/version-resolver'); const { resolveModuleVersion } = require('./modules/version-resolver');
const { parseChannelOptions, buildPlan, orphanPinWarnings, bundledTargetWarnings } = require('./modules/channel-plan'); const { Manifest } = require('./core/manifest');
const {
parseChannelOptions,
buildPlan,
decideChannelForModule,
orphanPinWarnings,
bundledTargetWarnings,
} = require('./modules/channel-plan');
const channelResolver = require('./modules/channel-resolver');
const prompts = require('./prompts'); const prompts = require('./prompts');
const manifest = new Manifest();
/** /**
* Read a module version from the freshest local metadata available. * Format a resolved version for display in installer labels.
* @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis') * Semver-like values are normalized to a single leading "v".
* @returns {string} Version string or empty string * @param {string|null|undefined} version
* @returns {string}
*/ */
async function getModuleVersion(moduleCode) { function formatDisplayVersion(version) {
const trimmed = typeof version === 'string' ? version.trim() : '';
if (!trimmed) return '';
const normalized = semver.valid(semver.coerce(trimmed));
if (normalized) {
return `v${normalized}`;
}
return trimmed;
}
/**
* Build the display label for a module, showing an upgrade arrow when an
* installed semver differs from the latest resolvable semver.
* @param {string} name
* @param {string} latestVersion
* @param {string} installedVersion
* @returns {string}
*/
function buildModuleLabel(name, latestVersion, installedVersion = '') {
const latestDisplay = formatDisplayVersion(latestVersion);
if (!latestDisplay) return name;
const installedDisplay = formatDisplayVersion(installedVersion);
const latestSemver = semver.valid(semver.coerce(latestVersion || ''));
const installedSemver = semver.valid(semver.coerce(installedVersion || ''));
if (installedDisplay && latestSemver && installedSemver && semver.neq(installedSemver, latestSemver)) {
return `${name} (${installedDisplay}${latestDisplay})`;
}
return `${name} (${latestDisplay})`;
}
/**
* Resolve the version to show for a module picker entry. External modules use
* the same channel/tag resolver as installs; bundled modules fall back to local
* source metadata.
* @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
* @param {Object} options
* @param {string|null} [options.repoUrl] - Module repository URL for tag resolution
* @param {string|null} [options.registryDefault] - Registry default channel
* @param {Object|null} [options.channelOptions] - Parsed installer channel options
* @returns {Promise<{version: string, lookupAttempted: boolean, lookupSucceeded: boolean}>}
*/
async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault = null, channelOptions = null } = {}) {
if (repoUrl) {
const plan = decideChannelForModule({
code: moduleCode,
channelOptions,
registryDefault,
});
try {
const resolved = await channelResolver.resolveChannel({
channel: plan.channel,
pin: plan.pin,
repoUrl,
});
if (resolved?.version) {
return {
version: resolved.version,
lookupAttempted: plan.channel === 'stable',
lookupSucceeded: true,
};
}
} catch {
// Fall back to local metadata when tag resolution is unavailable.
}
}
const versionInfo = await resolveModuleVersion(moduleCode); const versionInfo = await resolveModuleVersion(moduleCode);
return versionInfo.version || ''; return {
version: versionInfo.version || '',
lookupAttempted: !!repoUrl,
lookupSucceeded: false,
};
} }
/** /**
@ -122,7 +209,7 @@ class UI {
// Return early with modify configuration // Return early with modify configuration
if (actionType === 'update') { if (actionType === 'update') {
// Get existing installation info // Get existing installation info
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); const { installedModuleIds, installedModuleVersions } = await this.getExistingInstallation(confirmedDirectory);
await prompts.log.message(`Found existing modules: ${[...installedModuleIds].join(', ')}`); await prompts.log.message(`Found existing modules: ${[...installedModuleIds].join(', ')}`);
@ -144,7 +231,7 @@ class UI {
`Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`, `Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`,
); );
} else { } else {
selectedModules = await this.selectAllModules(installedModuleIds); selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
} }
// Resolve custom sources from --custom-source flag // Resolve custom sources from --custom-source flag
@ -208,7 +295,7 @@ class UI {
} }
// This section is only for new installations (update returns early above) // This section is only for new installations (update returns early above)
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); const { installedModuleIds, installedModuleVersions } = await this.getExistingInstallation(confirmedDirectory);
// Unified module selection - all modules in one grouped multiselect // Unified module selection - all modules in one grouped multiselect
let selectedModules; let selectedModules;
@ -227,7 +314,7 @@ class UI {
selectedModules = await this.getDefaultModules(installedModuleIds); selectedModules = await this.getDefaultModules(installedModuleIds);
await prompts.log.info(`Using default modules (--yes flag): ${selectedModules.join(', ')}`); await prompts.log.info(`Using default modules (--yes flag): ${selectedModules.join(', ')}`);
} else { } else {
selectedModules = await this.selectAllModules(installedModuleIds); selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
} }
// Resolve custom sources from --custom-source flag // Resolve custom sources from --custom-source flag
@ -526,7 +613,7 @@ class UI {
/** /**
* Get existing installation info and installed modules * Get existing installation info and installed modules
* @param {string} directory - Installation directory * @param {string} directory - Installation directory
* @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir * @returns {Object} Object with existingInstall, installedModuleIds, installedModuleVersions, and bmadDir
*/ */
async getExistingInstallation(directory) { async getExistingInstallation(directory) {
const { ExistingInstall } = require('./core/existing-install'); const { ExistingInstall } = require('./core/existing-install');
@ -535,8 +622,26 @@ class UI {
const { bmadDir } = await installer.findBmadDir(directory); const { bmadDir } = await installer.findBmadDir(directory);
const existingInstall = await ExistingInstall.detect(bmadDir); const existingInstall = await ExistingInstall.detect(bmadDir);
const installedModuleIds = new Set(existingInstall.moduleIds); const installedModuleIds = new Set(existingInstall.moduleIds);
const installedModuleVersions = new Map();
const manifestModules = await manifest.getAllModuleVersions(bmadDir);
return { existingInstall, installedModuleIds, bmadDir }; for (const module of manifestModules) {
if (module?.name && module.version) {
installedModuleVersions.set(module.name, module.version);
}
}
for (const module of existingInstall.modules) {
if (module?.id && module.version && module.version !== 'unknown' && !installedModuleVersions.has(module.id)) {
installedModuleVersions.set(module.id, module.version);
}
}
if (existingInstall.hasCore && existingInstall.version && !installedModuleVersions.has('core')) {
installedModuleVersions.set('core', existingInstall.version);
}
return { existingInstall, installedModuleIds, installedModuleVersions, bmadDir };
} }
/** /**
@ -617,11 +722,13 @@ class UI {
/** /**
* Select all modules across three tiers: official, community, and custom URL. * Select all modules across three tiers: official, community, and custom URL.
* @param {Set} installedModuleIds - Currently installed module IDs * @param {Set} installedModuleIds - Currently installed module IDs
* @param {Map<string, string>} installedModuleVersions - Installed module versions from the local manifest
* @param {Object|null} channelOptions - Parsed installer channel options
* @returns {Array} Selected module codes (excluding core) * @returns {Array} Selected module codes (excluding core)
*/ */
async selectAllModules(installedModuleIds = new Set()) { async selectAllModules(installedModuleIds = new Set(), installedModuleVersions = new Map(), channelOptions = null) {
// Phase 1: Official modules // Phase 1: Official modules
const officialSelected = await this._selectOfficialModules(installedModuleIds); const officialSelected = await this._selectOfficialModules(installedModuleIds, installedModuleVersions, channelOptions);
// Determine which installed modules are NOT official (community or custom). // Determine which installed modules are NOT official (community or custom).
// These must be preserved even if the user declines to browse community/custom. // These must be preserved even if the user declines to browse community/custom.
@ -657,9 +764,11 @@ class UI {
* Select official modules using autocompleteMultiselect. * Select official modules using autocompleteMultiselect.
* Extracted from the original selectAllModules - unchanged behavior. * Extracted from the original selectAllModules - unchanged behavior.
* @param {Set} installedModuleIds - Currently installed module IDs * @param {Set} installedModuleIds - Currently installed module IDs
* @param {Map<string, string>} installedModuleVersions - Installed module versions from the local manifest
* @param {Object|null} channelOptions - Parsed installer channel options
* @returns {Array} Selected official module codes * @returns {Array} Selected official module codes
*/ */
async _selectOfficialModules(installedModuleIds = new Set()) { async _selectOfficialModules(installedModuleIds = new Set(), installedModuleVersions = new Map(), channelOptions = null) {
// Built-in modules (core, bmm) come from local source, not the registry // Built-in modules (core, bmm) come from local source, not the registry
const { OfficialModules } = require('./modules/official-modules'); const { OfficialModules } = require('./modules/official-modules');
const builtInModules = (await new OfficialModules().listAvailable()).modules || []; const builtInModules = (await new OfficialModules().listAvailable()).modules || [];
@ -672,15 +781,18 @@ class UI {
const initialValues = []; const initialValues = [];
const lockedValues = ['core']; const lockedValues = ['core'];
const buildModuleEntry = async (code, name, description, isDefault) => { const buildModuleEntry = async (code, name, description, isDefault, repoUrl = null, registryDefault = null) => {
const isInstalled = installedModuleIds.has(code); const isInstalled = installedModuleIds.has(code);
const version = await getModuleVersion(code); const installedVersion = installedModuleVersions.get(code) || '';
const label = version ? `${name} (v${version})` : name; const versionState = await getModuleVersion(code, { repoUrl, registryDefault, channelOptions });
const label = buildModuleLabel(name, versionState.version, installedVersion);
return { return {
label, label,
value: code, value: code,
hint: description, hint: description,
selected: isInstalled || isDefault, selected: isInstalled || isDefault,
lookupAttempted: versionState.lookupAttempted,
lookupSucceeded: versionState.lookupSucceeded,
}; };
}; };
@ -697,12 +809,38 @@ class UI {
} }
// Add external registry modules (skip built-in duplicates) // Add external registry modules (skip built-in duplicates)
for (const mod of registryModules) { const externalRegistryModules = registryModules.filter((mod) => !mod.builtIn && !builtInCodes.has(mod.code));
if (mod.builtIn || builtInCodes.has(mod.code)) continue; let externalRegistryEntries = [];
const entry = await buildModuleEntry(mod.code, mod.name, mod.description, mod.defaultSelected); if (externalRegistryModules.length > 0) {
const spinner = await prompts.spinner();
spinner.start('Checking latest module versions...');
externalRegistryEntries = await Promise.all(
externalRegistryModules.map(async (mod) => ({
code: mod.code,
entry: await buildModuleEntry(
mod.code,
mod.name,
mod.description,
mod.defaultSelected,
mod.url || null,
mod.defaultChannel || null,
),
})),
);
spinner.stop('Checked latest module versions.');
const attemptedLookups = externalRegistryEntries.filter(({ entry }) => entry.lookupAttempted).length;
const successfulLookups = externalRegistryEntries.filter(({ entry }) => entry.lookupSucceeded).length;
if (attemptedLookups > 0 && successfulLookups === 0) {
await prompts.log.warn('Could not check latest module versions; showing cached/local versions.');
}
}
for (const { code, entry } of externalRegistryEntries) {
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint }); allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
if (entry.selected) { if (entry.selected) {
initialValues.push(mod.code); initialValues.push(code);
} }
} }