From 3f9ad4868cc5dc44e5932b26b690c9130ff39672 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 22 Jan 2026 23:43:05 -0600 Subject: [PATCH] versioned module downloads and manifest --- tools/cli/commands/status.js | 65 +++ tools/cli/external-official-modules.yaml | 3 + .../installers/lib/core/manifest-generator.js | 57 +- tools/cli/installers/lib/core/manifest.js | 504 +++++++++++++++++- .../lib/modules/external-manager.js | 2 + tools/cli/installers/lib/modules/manager.js | 19 +- tools/cli/lib/ui.js | 125 +++++ 7 files changed, 741 insertions(+), 34 deletions(-) create mode 100644 tools/cli/commands/status.js diff --git a/tools/cli/commands/status.js b/tools/cli/commands/status.js new file mode 100644 index 00000000..5df2cfac --- /dev/null +++ b/tools/cli/commands/status.js @@ -0,0 +1,65 @@ +const chalk = require('chalk'); +const path = require('node:path'); +const { Installer } = require('../installers/lib/core/installer'); +const { Manifest } = require('../installers/lib/core/manifest'); +const { UI } = require('../lib/ui'); + +const installer = new Installer(); +const manifest = new Manifest(); +const ui = new UI(); + +module.exports = { + command: 'status', + description: 'Display BMAD installation status and module versions', + options: [], + action: async (options) => { + try { + // Find the bmad directory + const projectDir = process.cwd(); + const { bmadDir } = await installer.findBmadDir(projectDir); + + // Check if bmad directory exists + const fs = require('fs-extra'); + if (!(await fs.pathExists(bmadDir))) { + console.log(chalk.yellow('No BMAD installation found in the current directory.')); + console.log(chalk.dim(`Expected location: ${bmadDir}`)); + console.log(chalk.dim('\nRun "bmad install" to set up a new installation.')); + process.exit(0); + return; + } + + // Read manifest + const manifestData = await manifest._readRaw(bmadDir); + + if (!manifestData) { + console.log(chalk.yellow('No BMAD installation manifest found.')); + console.log(chalk.dim('\nRun "bmad install" to set up a new installation.')); + process.exit(0); + return; + } + + // Get installation info + const installation = manifestData.installation || {}; + const modules = manifestData.modules || []; + + // Check for available updates (only for external modules) + const availableUpdates = await manifest.checkForUpdates(bmadDir); + + // Display status + ui.displayStatus({ + installation, + modules, + availableUpdates, + bmadDir, + }); + + process.exit(0); + } catch (error) { + console.error(chalk.red('Status check failed:'), error.message); + if (process.env.BMAD_DEBUG) { + console.error(chalk.dim(error.stack)); + } + process.exit(1); + } + }, +}; diff --git a/tools/cli/external-official-modules.yaml b/tools/cli/external-official-modules.yaml index b476cb5f..c48f7175 100644 --- a/tools/cli/external-official-modules.yaml +++ b/tools/cli/external-official-modules.yaml @@ -10,6 +10,7 @@ modules: description: "Agent, Workflow and Module Builder" defaultSelected: false type: bmad-org + npmPackage: bmad-builder bmad-creative-intelligence-suite: url: https://github.com/bmad-code-org/bmad-module-creative-intelligence-suite @@ -19,6 +20,7 @@ modules: description: "Creative tools for writing, brainstorming, and more" defaultSelected: false type: bmad-org + npmPackage: bmad-creative-intelligence-suite bmad-game-dev-studio: url: https://github.com/bmad-code-org/bmad-module-game-dev-studio.git @@ -28,6 +30,7 @@ modules: description: "Game development agents and workflows" defaultSelected: false type: bmad-org + npmPackage: bmad-game-dev-studio # TODO: Enable once fixes applied: diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index 5aff792a..100164d5 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -534,18 +534,71 @@ class ManifestGenerator { /** * Write main manifest as YAML with installation info only + * Fetches fresh version info for all modules * @returns {string} Path to the manifest file */ async writeMainManifest(cfgDir) { const manifestPath = path.join(cfgDir, 'manifest.yaml'); + // Read existing manifest to preserve install date + let existingInstallDate = null; + const existingModulesMap = new Map(); + + if (await fs.pathExists(manifestPath)) { + try { + const existingContent = await fs.readFile(manifestPath, 'utf8'); + const existingManifest = yaml.parse(existingContent); + + // Preserve original install date + if (existingManifest.installation?.installDate) { + existingInstallDate = existingManifest.installation.installDate; + } + + // Build map of existing modules for quick lookup + if (existingManifest.modules && Array.isArray(existingManifest.modules)) { + for (const m of existingManifest.modules) { + if (typeof m === 'object' && m.name) { + existingModulesMap.set(m.name, m); + } else if (typeof m === 'string') { + existingModulesMap.set(m, { installDate: existingInstallDate }); + } + } + } + } catch { + // If we can't read existing manifest, continue with defaults + } + } + + // Fetch fresh version info for all modules + const { Manifest } = require('./manifest'); + const manifestObj = new Manifest(); + const updatedModules = []; + + for (const moduleName of this.modules) { + // Get fresh version info from source + const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, this.bmadDir); + + // Get existing install date if available + const existing = existingModulesMap.get(moduleName); + + updatedModules.push({ + name: moduleName, + version: versionInfo.version, + installDate: existing?.installDate || new Date().toISOString(), + lastUpdated: new Date().toISOString(), + source: versionInfo.source, + npmPackage: versionInfo.npmPackage, + repoUrl: versionInfo.repoUrl, + }); + } + const manifest = { installation: { version: packageJson.version, - installDate: new Date().toISOString(), + installDate: existingInstallDate || new Date().toISOString(), lastUpdated: new Date().toISOString(), }, - modules: this.modules, // Include ALL modules (standard and custom) + modules: updatedModules, ides: this.selectedIdes, }; diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/cli/installers/lib/core/manifest.js index 643a945c..209399d1 100644 --- a/tools/cli/installers/lib/core/manifest.js +++ b/tools/cli/installers/lib/core/manifest.js @@ -1,6 +1,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const crypto = require('node:crypto'); +const { getProjectRoot } = require('../../../lib/project-root'); class Manifest { /** @@ -16,14 +17,35 @@ class Manifest { // Ensure _config directory exists await fs.ensureDir(path.dirname(manifestPath)); + // Get the BMad version from package.json + const bmadVersion = data.version || require(path.join(process.cwd(), 'package.json')).version; + + // Convert module list to new detailed format + const moduleDetails = []; + if (data.modules && Array.isArray(data.modules)) { + for (const moduleName of data.modules) { + // Core and BMM modules use the BMad version + const moduleVersion = moduleName === 'core' || moduleName === 'bmm' ? bmadVersion : null; + const now = data.installDate || new Date().toISOString(); + + moduleDetails.push({ + name: moduleName, + version: moduleVersion, + installDate: now, + lastUpdated: now, + source: moduleName === 'core' || moduleName === 'bmm' ? 'built-in' : 'unknown', + }); + } + } + // Structure the manifest data const manifestData = { installation: { - version: data.version || require(path.join(process.cwd(), 'package.json')).version, + version: bmadVersion, installDate: data.installDate || new Date().toISOString(), lastUpdated: data.lastUpdated || new Date().toISOString(), }, - modules: data.modules || [], + modules: moduleDetails, ides: data.ides || [], }; @@ -57,12 +79,23 @@ class Manifest { const content = await fs.readFile(yamlPath, 'utf8'); const manifestData = yaml.parse(content); + // Handle new detailed module format + const modules = manifestData.modules || []; + + // For backward compatibility: if modules is an array of strings (old format), + // the calling code may need the array of names + const moduleNames = modules.map((m) => (typeof m === 'string' ? m : m.name)); + + // Check if we have the new detailed format + const hasDetailedModules = modules.length > 0 && typeof modules[0] === 'object'; + // Flatten the structure for compatibility with existing code return { version: manifestData.installation?.version, installDate: manifestData.installation?.installDate, lastUpdated: manifestData.installation?.lastUpdated, - modules: manifestData.modules || [], // All modules (standard and custom) + modules: moduleNames, // Simple array of module names for backward compatibility + modulesDetailed: hasDetailedModules ? modules : null, // New detailed format customModules: manifestData.customModules || [], // Keep for backward compatibility ides: manifestData.ides || [], }; @@ -82,28 +115,92 @@ class Manifest { */ async update(bmadDir, updates, installedFiles = null) { const yaml = require('yaml'); - const manifest = (await this.read(bmadDir)) || {}; - - // Merge updates - Object.assign(manifest, updates); - manifest.lastUpdated = new Date().toISOString(); - - // Convert back to structured format for YAML - const manifestData = { - installation: { - version: manifest.version, - installDate: manifest.installDate, - lastUpdated: manifest.lastUpdated, - }, - modules: manifest.modules || [], // All modules (standard and custom) - ides: manifest.ides || [], + const manifest = (await this._readRaw(bmadDir)) || { + installation: {}, + modules: [], + ides: [], }; + // Handle module updates + if (updates.modules) { + // If modules is being updated, we need to preserve detailed module info + const existingDetailed = manifest.modules || []; + const incomingNames = updates.modules; + + // Build updated modules array + const updatedModules = []; + for (const name of incomingNames) { + const existing = existingDetailed.find((m) => m.name === name); + if (existing) { + // Preserve existing details, update lastUpdated if this module is being updated + updatedModules.push({ + ...existing, + lastUpdated: new Date().toISOString(), + }); + } else { + // New module - add with minimal details + updatedModules.push({ + name, + version: null, + installDate: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + source: 'unknown', + }); + } + } + + manifest.modules = updatedModules; + } + + // Merge other updates + if (updates.version) { + manifest.installation.version = updates.version; + } + if (updates.installDate) { + manifest.installation.installDate = updates.installDate; + } + manifest.installation.lastUpdated = new Date().toISOString(); + + if (updates.ides) { + manifest.ides = updates.ides; + } + + // Handle per-module version updates + if (updates.moduleVersions) { + for (const [moduleName, versionInfo] of Object.entries(updates.moduleVersions)) { + const moduleIndex = manifest.modules.findIndex((m) => m.name === moduleName); + if (moduleIndex !== -1) { + manifest.modules[moduleIndex] = { + ...manifest.modules[moduleIndex], + ...versionInfo, + lastUpdated: new Date().toISOString(), + }; + } + } + } + + // Handle adding a new module with version info + if (updates.addModule) { + const { name, version, source, npmPackage, repoUrl } = updates.addModule; + const existing = manifest.modules.find((m) => m.name === name); + if (!existing) { + manifest.modules.push({ + name, + version: version || null, + installDate: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + source: source || 'external', + npmPackage: npmPackage || null, + repoUrl: repoUrl || null, + }); + } + } + const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml'); await fs.ensureDir(path.dirname(manifestPath)); // Clean the manifest data to remove any non-serializable values - const cleanManifestData = structuredClone(manifestData); + const cleanManifestData = structuredClone(manifest); const yamlContent = yaml.stringify(cleanManifestData, { indent: 2, @@ -115,16 +212,61 @@ class Manifest { const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n'; await fs.writeFile(manifestPath, content, 'utf8'); - return manifest; + // Return the flattened format for compatibility + return this._flattenManifest(manifest); } /** - * Add a module to the manifest + * Read raw manifest data without flattening + * @param {string} bmadDir - Path to bmad directory + * @returns {Object|null} Raw manifest data or null if not found + */ + async _readRaw(bmadDir) { + const yamlPath = path.join(bmadDir, '_config', 'manifest.yaml'); + const yaml = require('yaml'); + + if (await fs.pathExists(yamlPath)) { + try { + const content = await fs.readFile(yamlPath, 'utf8'); + return yaml.parse(content); + } catch (error) { + console.error('Failed to read YAML manifest:', error.message); + } + } + + return null; + } + + /** + * Flatten manifest for backward compatibility + * @param {Object} manifest - Raw manifest data + * @returns {Object} Flattened manifest + */ + _flattenManifest(manifest) { + const modules = manifest.modules || []; + const moduleNames = modules.map((m) => (typeof m === 'string' ? m : m.name)); + const hasDetailedModules = modules.length > 0 && typeof modules[0] === 'object'; + + return { + version: manifest.installation?.version, + installDate: manifest.installation?.installDate, + lastUpdated: manifest.installation?.lastUpdated, + modules: moduleNames, + modulesDetailed: hasDetailedModules ? modules : null, + customModules: manifest.customModules || [], + ides: manifest.ides || [], + }; + } + + /** + * Add a module to the manifest with optional version info + * If module already exists, update its version info * @param {string} bmadDir - Path to bmad directory * @param {string} moduleName - Module name to add + * @param {Object} options - Optional version info */ - async addModule(bmadDir, moduleName) { - const manifest = await this.read(bmadDir); + async addModule(bmadDir, moduleName, options = {}) { + const manifest = await this._readRaw(bmadDir); if (!manifest) { throw new Error('No manifest found'); } @@ -133,10 +275,33 @@ class Manifest { manifest.modules = []; } - if (!manifest.modules.includes(moduleName)) { - manifest.modules.push(moduleName); - await this.update(bmadDir, { modules: manifest.modules }); + const existingIndex = manifest.modules.findIndex((m) => m.name === moduleName); + + if (existingIndex === -1) { + // Module doesn't exist, add it + manifest.modules.push({ + name: moduleName, + version: options.version || null, + installDate: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + source: options.source || 'unknown', + npmPackage: options.npmPackage || null, + repoUrl: options.repoUrl || null, + }); + } else { + // Module exists, update its version info + const existing = manifest.modules[existingIndex]; + manifest.modules[existingIndex] = { + ...existing, + version: options.version === undefined ? existing.version : options.version, + source: options.source || existing.source, + npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage, + repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl, + lastUpdated: new Date().toISOString(), + }; } + + await this._writeRaw(bmadDir, manifest); } /** @@ -145,18 +310,93 @@ class Manifest { * @param {string} moduleName - Module name to remove */ async removeModule(bmadDir, moduleName) { - const manifest = await this.read(bmadDir); + const manifest = await this._readRaw(bmadDir); if (!manifest || !manifest.modules) { return; } - const index = manifest.modules.indexOf(moduleName); + const index = manifest.modules.findIndex((m) => m.name === moduleName); if (index !== -1) { manifest.modules.splice(index, 1); - await this.update(bmadDir, { modules: manifest.modules }); + await this._writeRaw(bmadDir, manifest); } } + /** + * Update a single module's version info + * @param {string} bmadDir - Path to bmad directory + * @param {string} moduleName - Module name + * @param {Object} versionInfo - Version info to update + */ + async updateModuleVersion(bmadDir, moduleName, versionInfo) { + const manifest = await this._readRaw(bmadDir); + if (!manifest || !manifest.modules) { + return; + } + + const index = manifest.modules.findIndex((m) => m.name === moduleName); + if (index !== -1) { + manifest.modules[index] = { + ...manifest.modules[index], + ...versionInfo, + lastUpdated: new Date().toISOString(), + }; + await this._writeRaw(bmadDir, manifest); + } + } + + /** + * Get version info for a specific module + * @param {string} bmadDir - Path to bmad directory + * @param {string} moduleName - Module name + * @returns {Object|null} Module version info or null + */ + async getModuleVersion(bmadDir, moduleName) { + const manifest = await this._readRaw(bmadDir); + if (!manifest || !manifest.modules) { + return null; + } + + return manifest.modules.find((m) => m.name === moduleName) || null; + } + + /** + * Get all modules with their version info + * @param {string} bmadDir - Path to bmad directory + * @returns {Array} Array of module info objects + */ + async getAllModuleVersions(bmadDir) { + const manifest = await this._readRaw(bmadDir); + if (!manifest || !manifest.modules) { + return []; + } + + return manifest.modules; + } + + /** + * Write raw manifest data to file + * @param {string} bmadDir - Path to bmad directory + * @param {Object} manifestData - Raw manifest data to write + */ + async _writeRaw(bmadDir, manifestData) { + const yaml = require('yaml'); + const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml'); + + await fs.ensureDir(path.dirname(manifestPath)); + + const cleanManifestData = structuredClone(manifestData); + + const yamlContent = yaml.stringify(cleanManifestData, { + indent: 2, + lineWidth: 0, + sortKeys: false, + }); + + const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n'; + await fs.writeFile(manifestPath, content, 'utf8'); + } + /** * Add an IDE configuration to the manifest * @param {string} bmadDir - Path to bmad directory @@ -585,6 +825,212 @@ class Manifest { await this.update(bmadDir, { customModules: manifest.customModules }); } } + + /** + * Get module version info from source + * @param {string} moduleName - Module name/code + * @param {string} bmadDir - Path to bmad directory + * @param {string} moduleSourcePath - Optional source path for custom modules + * @returns {Object} Version info object with version, source, npmPackage, repoUrl + */ + async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) { + const os = require('node:os'); + + // Built-in modules use BMad version (only core and bmm are in BMAD-METHOD repo) + if (['core', 'bmm'].includes(moduleName)) { + const bmadVersion = require(path.join(getProjectRoot(), 'package.json')).version; + return { + version: bmadVersion, + source: 'built-in', + npmPackage: null, + repoUrl: null, + }; + } + + // Check if this is an external official module + const { ExternalModuleManager } = require('../modules/external-manager'); + const extMgr = new ExternalModuleManager(); + const moduleInfo = await extMgr.getModuleByCode(moduleName); + + if (moduleInfo) { + // External module - try to get version from npm registry first, then fall back to cache + let version = null; + + if (moduleInfo.npmPackage) { + // Fetch version from npm registry + try { + version = await this.fetchNpmVersion(moduleInfo.npmPackage); + } catch { + // npm fetch failed, try cache as fallback + } + } + + // If npm didn't work, try reading from cached repo's package.json + if (!version) { + const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleName); + const packageJsonPath = path.join(cacheDir, 'package.json'); + + if (await fs.pathExists(packageJsonPath)) { + try { + const pkg = require(packageJsonPath); + version = pkg.version; + } catch (error) { + console.warn(`Failed to read package.json for ${moduleName}: ${error.message}`); + } + } + } + + return { + version: version, + source: 'external', + npmPackage: moduleInfo.npmPackage || null, + repoUrl: moduleInfo.url || null, + }; + } + + // Custom module - check cache directory + const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName); + const moduleYamlPath = path.join(cacheDir, 'module.yaml'); + + if (await fs.pathExists(moduleYamlPath)) { + try { + const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); + const moduleConfig = yaml.parse(yamlContent); + return { + version: moduleConfig.version || null, + source: 'custom', + npmPackage: moduleConfig.npmPackage || null, + repoUrl: moduleConfig.repoUrl || null, + }; + } catch (error) { + console.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`); + } + } + + // Unknown module + return { + version: null, + source: 'unknown', + npmPackage: null, + repoUrl: null, + }; + } + + /** + * Fetch latest version from npm for a package + * @param {string} packageName - npm package name + * @returns {string|null} Latest version or null + */ + async fetchNpmVersion(packageName) { + try { + const https = require('node:https'); + const { execSync } = require('node:child_process'); + + // Try using npm view first (more reliable) + try { + const result = execSync(`npm view ${packageName} version`, { + encoding: 'utf8', + stdio: 'pipe', + timeout: 10_000, + }); + return result.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)); + }); + } + } catch { + return null; + } + } + + /** + * Check for available updates for installed modules + * @param {string} bmadDir - Path to bmad directory + * @returns {Array} Array of update info objects + */ + async checkForUpdates(bmadDir) { + const modules = await this.getAllModuleVersions(bmadDir); + const updates = []; + + for (const module of modules) { + if (!module.npmPackage) { + continue; // Skip modules without npm package (built-in) + } + + const latestVersion = await this.fetchNpmVersion(module.npmPackage); + if (!latestVersion) { + continue; + } + + if (module.version !== latestVersion) { + updates.push({ + name: module.name, + installedVersion: module.version, + latestVersion: latestVersion, + npmPackage: module.npmPackage, + updateAvailable: true, + }); + } + } + + return updates; + } + + /** + * Compare two semantic versions + * @param {string} v1 - First version + * @param {string} v2 - Second version + * @returns {number} -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 + */ + compareVersions(v1, v2) { + if (!v1 || !v2) return 0; + + const normalize = (v) => { + // Remove leading 'v' if present + v = v.replace(/^v/, ''); + // Handle prerelease tags + const parts = v.split('-'); + const main = parts[0].split('.'); + const prerelease = parts[1]; + return { main, prerelease }; + }; + + const n1 = normalize(v1); + const n2 = normalize(v2); + + // Compare main version parts + for (let i = 0; i < 3; i++) { + const num1 = parseInt(n1.main[i] || '0', 10); + const num2 = parseInt(n2.main[i] || '0', 10); + if (num1 !== num2) { + return num1 < num2 ? -1 : 1; + } + } + + // If main versions are equal, compare prerelease + if (n1.prerelease && n2.prerelease) { + return n1.prerelease < n2.prerelease ? -1 : n1.prerelease > n2.prerelease ? 1 : 0; + } + if (n1.prerelease) return -1; // Prerelease is older than stable + if (n2.prerelease) return 1; // Stable is newer than prerelease + + return 0; + } } module.exports = { Manifest }; diff --git a/tools/cli/installers/lib/modules/external-manager.js b/tools/cli/installers/lib/modules/external-manager.js index 8843df38..a68a2ba1 100644 --- a/tools/cli/installers/lib/modules/external-manager.js +++ b/tools/cli/installers/lib/modules/external-manager.js @@ -54,6 +54,7 @@ class ExternalModuleManager { description: moduleConfig.description || '', defaultSelected: moduleConfig.defaultSelected === true, type: moduleConfig.type || 'community', // bmad-org or community + npmPackage: moduleConfig.npmPackage || null, // Include npm package name isExternal: true, }); } @@ -95,6 +96,7 @@ class ExternalModuleManager { description: moduleConfig.description || '', defaultSelected: moduleConfig.defaultSelected === true, type: moduleConfig.type || 'community', // bmad-org or community + npmPackage: moduleConfig.npmPackage || null, // Include npm package name isExternal: true, }; } diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 453fa81c..efbddcf0 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -371,9 +371,9 @@ class ModuleManager { const fetchSpinner = ora(`Fetching ${moduleInfo.name}...`).start(); try { const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); - execSync('git fetch --depth 1', { cwd: moduleCacheDir, stdio: 'pipe' }); - execSync('git checkout -f', { cwd: moduleCacheDir, stdio: 'pipe' }); - execSync('git pull --ff-only', { cwd: moduleCacheDir, stdio: 'pipe' }); + // Fetch and reset to remote - works better with shallow clones than pull + execSync('git fetch origin --depth 1', { cwd: moduleCacheDir, stdio: 'pipe' }); + execSync('git reset --hard origin/HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }); const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); fetchSpinner.succeed(`Fetched ${moduleInfo.name}`); @@ -555,10 +555,23 @@ class ModuleManager { await this.runModuleInstaller(moduleName, bmadDir, options); } + // Capture version info for manifest + const { Manifest } = require('../core/manifest'); + const manifestObj = new Manifest(); + const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath); + + await manifestObj.addModule(bmadDir, moduleName, { + version: versionInfo.version, + source: versionInfo.source, + npmPackage: versionInfo.npmPackage, + repoUrl: versionInfo.repoUrl, + }); + return { success: true, module: moduleName, path: targetPath, + versionInfo, }; } diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index a5b53a77..614e5016 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -1586,6 +1586,131 @@ class UI { return proceed === 'proceed'; } + + /** + * Display module versions with update availability + * @param {Array} modules - Array of module info objects with version info + * @param {Array} availableUpdates - Array of available updates + */ + displayModuleVersions(modules, availableUpdates = []) { + console.log(''); + console.log(chalk.cyan.bold('šŸ“¦ Module Versions')); + console.log(chalk.gray('─'.repeat(80))); + + // Group modules by source + const builtIn = modules.filter((m) => m.source === 'built-in'); + const external = modules.filter((m) => m.source === 'external'); + const custom = modules.filter((m) => m.source === 'custom'); + const unknown = modules.filter((m) => m.source === 'unknown'); + + const displayGroup = (group, title) => { + if (group.length === 0) return; + + console.log(chalk.yellow(`\n${title}`)); + for (const module of group) { + const updateInfo = availableUpdates.find((u) => u.name === module.name); + const versionDisplay = module.version || chalk.gray('unknown'); + + if (updateInfo) { + console.log( + ` ${chalk.cyan(module.name.padEnd(20))} ${versionDisplay} → ${chalk.green(updateInfo.latestVersion)} ${chalk.green('↑')}`, + ); + } else { + console.log(` ${chalk.cyan(module.name.padEnd(20))} ${versionDisplay} ${chalk.gray('āœ“')}`); + } + } + }; + + displayGroup(builtIn, 'Built-in Modules'); + displayGroup(external, 'External Modules (Official)'); + displayGroup(custom, 'Custom Modules'); + displayGroup(unknown, 'Other Modules'); + + console.log(''); + } + + /** + * Prompt user to select which modules to update + * @param {Array} availableUpdates - Array of available updates + * @returns {Array} Selected module names to update + */ + async promptUpdateSelection(availableUpdates) { + if (availableUpdates.length === 0) { + return []; + } + + console.log(''); + console.log(chalk.cyan.bold('šŸ”„ Available Updates')); + console.log(chalk.gray('─'.repeat(80))); + + const choices = availableUpdates.map((update) => ({ + name: `${update.name} ${chalk.dim(`(v${update.installedVersion} → v${update.latestVersion})`)}`, + value: update.name, + checked: true, // Default to selecting all updates + })); + + // Add "Update All" and "Cancel" options + const action = await prompts.select({ + message: 'How would you like to proceed?', + choices: [ + { name: 'Update all available modules', value: 'all' }, + { name: 'Select specific modules to update', value: 'select' }, + { name: 'Skip updates for now', value: 'skip' }, + ], + default: 'all', + }); + + if (action === 'all') { + return availableUpdates.map((u) => u.name); + } + + if (action === 'skip') { + return []; + } + + // Allow specific selection + const selected = await prompts.multiselect({ + message: `Select modules to update ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`, + choices: choices, + required: true, + }); + + return selected || []; + } + + /** + * Display status of all installed modules + * @param {Object} statusData - Status data with modules, installation info, and available updates + */ + displayStatus(statusData) { + const { installation, modules, availableUpdates, bmadDir } = statusData; + + console.log(''); + console.log(chalk.cyan.bold('šŸ“‹ BMAD Status')); + console.log(chalk.gray('─'.repeat(80))); + + // Installation info + console.log(chalk.yellow('\nInstallation')); + console.log(` ${chalk.gray('Version:'.padEnd(20))} ${installation.version || chalk.gray('unknown')}`); + console.log(` ${chalk.gray('Location:'.padEnd(20))} ${bmadDir}`); + console.log(` ${chalk.gray('Installed:'.padEnd(20))} ${new Date(installation.installDate).toLocaleDateString()}`); + console.log( + ` ${chalk.gray('Last Updated:'.padEnd(20))} ${installation.lastUpdated ? new Date(installation.lastUpdated).toLocaleDateString() : chalk.gray('unknown')}`, + ); + + // Module versions + this.displayModuleVersions(modules, availableUpdates); + + // Update summary + if (availableUpdates.length > 0) { + console.log(chalk.yellow.bold(`\nāš ļø ${availableUpdates.length} update(s) available`)); + console.log(chalk.dim(` Run 'bmad install' and select "Quick Update" to update`)); + } else { + console.log(chalk.green.bold('\nāœ“ All modules are up to date')); + } + + console.log(''); + } } module.exports = { UI };