From 0533976753643750408e4d61ac357b2f6a219155 Mon Sep 17 00:00:00 2001 From: Murat K Ozcan <34237651+muratkeremozcan@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:13:56 -0500 Subject: [PATCH] fix: installer live version for external modules (#2307) * resolved merge conflict * fix: addressed PR comments * fix: use git tags for installer module versions --- test/test-installation-components.js | 223 +++++++++++++++++++++++++++ tools/installer/core/manifest.js | 60 ++++--- tools/installer/ui.js | 182 +++++++++++++++++++--- 3 files changed, 421 insertions(+), 44 deletions(-) diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 24cf782e5..58d6c7d8f 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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(''); // ============================================================ diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index ffe0de4ad..d604bf2fe 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -1,9 +1,20 @@ 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 crypto = require('node:crypto'); const { resolveModuleVersion } = require('../modules/version-resolver'); 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 { /** * Create a new manifest @@ -362,35 +373,40 @@ class Manifest { * @returns {string|null} Latest version or null */ async fetchNpmVersion(packageName) { - try { - const https = require('node:https'); - const { execSync } = require('node:child_process'); + if (!isValidNpmPackageName(packageName)) { + return null; + } + try { // Try using npm view first (more reliable) try { - const result = execSync(`npm view ${packageName} version`, { + const { stdout } = await execFileAsync('npm', ['view', packageName, 'version'], { encoding: 'utf8', - stdio: 'pipe', - timeout: 10_000, + timeout: NPM_LOOKUP_TIMEOUT_MS, }); - return result.trim(); + return stdout.trim(); } catch { // Fallback to npm registry API - return new Promise((resolve, reject) => { - https - .get(`https://registry.npmjs.org/${packageName}`, (res) => { - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => { - try { - const pkg = JSON.parse(data); - resolve(pkg['dist-tags']?.latest || pkg.version || null); - } catch { - resolve(null); - } - }); - }) - .on('error', () => resolve(null)); + return new Promise((resolve) => { + const request = https.get(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + const pkg = JSON.parse(data); + resolve(pkg['dist-tags']?.latest || pkg.version || null); + } catch { + resolve(null); + } + }); + }); + + request.setTimeout(NPM_LOOKUP_TIMEOUT_MS, () => { + request.destroy(); + resolve(null); + }); + + request.on('error', () => resolve(null)); }); } } catch { diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 030ef5a3b..f2f6e31c1 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -1,20 +1,107 @@ const path = require('node:path'); const os = require('node:os'); +const semver = require('semver'); const fs = require('./fs-native'); const { CLIUtils } = require('./cli-utils'); const { ExternalModuleManager } = require('./modules/external-manager'); const { resolveModuleVersion } = require('./modules/version-resolver'); -const { parseChannelOptions, buildPlan, orphanPinWarnings, 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 manifest = new Manifest(); + /** - * 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 + * Format a resolved version for display in installer labels. + * Semver-like values are normalized to a single leading "v". + * @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); - return versionInfo.version || ''; + return { + version: versionInfo.version || '', + lookupAttempted: !!repoUrl, + lookupSucceeded: false, + }; } /** @@ -122,7 +209,7 @@ class UI { // Return early with modify configuration if (actionType === 'update') { // 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(', ')}`); @@ -144,7 +231,7 @@ class UI { `Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`, ); } else { - selectedModules = await this.selectAllModules(installedModuleIds); + selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions); } // Resolve custom sources from --custom-source flag @@ -208,7 +295,7 @@ class UI { } // 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 let selectedModules; @@ -227,7 +314,7 @@ class UI { selectedModules = await this.getDefaultModules(installedModuleIds); await prompts.log.info(`Using default modules (--yes flag): ${selectedModules.join(', ')}`); } else { - selectedModules = await this.selectAllModules(installedModuleIds); + selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions); } // Resolve custom sources from --custom-source flag @@ -526,7 +613,7 @@ class UI { /** * Get existing installation info and installed modules * @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) { const { ExistingInstall } = require('./core/existing-install'); @@ -535,8 +622,26 @@ class UI { const { bmadDir } = await installer.findBmadDir(directory); const existingInstall = await ExistingInstall.detect(bmadDir); 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. * @param {Set} installedModuleIds - Currently installed module IDs + * @param {Map} installedModuleVersions - Installed module versions from the local manifest + * @param {Object|null} channelOptions - Parsed installer channel options * @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 - const officialSelected = await this._selectOfficialModules(installedModuleIds); + const officialSelected = await this._selectOfficialModules(installedModuleIds, installedModuleVersions, channelOptions); // Determine which installed modules are NOT official (community or 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. * Extracted from the original selectAllModules - unchanged behavior. * @param {Set} installedModuleIds - Currently installed module IDs + * @param {Map} installedModuleVersions - Installed module versions from the local manifest + * @param {Object|null} channelOptions - Parsed installer channel options * @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 const { OfficialModules } = require('./modules/official-modules'); const builtInModules = (await new OfficialModules().listAvailable()).modules || []; @@ -672,15 +781,18 @@ class UI { const initialValues = []; 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 version = await getModuleVersion(code); - const label = version ? `${name} (v${version})` : name; + const installedVersion = installedModuleVersions.get(code) || ''; + const versionState = await getModuleVersion(code, { repoUrl, registryDefault, channelOptions }); + const label = buildModuleLabel(name, versionState.version, installedVersion); return { label, value: code, hint: description, selected: isInstalled || isDefault, + lookupAttempted: versionState.lookupAttempted, + lookupSucceeded: versionState.lookupSucceeded, }; }; @@ -697,12 +809,38 @@ class UI { } // Add external registry modules (skip built-in duplicates) - for (const mod of registryModules) { - if (mod.builtIn || builtInCodes.has(mod.code)) continue; - const entry = await buildModuleEntry(mod.code, mod.name, mod.description, mod.defaultSelected); + const externalRegistryModules = registryModules.filter((mod) => !mod.builtIn && !builtInCodes.has(mod.code)); + let externalRegistryEntries = []; + 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 }); if (entry.selected) { - initialValues.push(mod.code); + initialValues.push(code); } }