From c46502f64049797ba1f04bcc18c5f6ca219a250a Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 7 Apr 2026 02:31:36 -0500 Subject: [PATCH] feat(installer): overhaul branding, versioning, and skill cleanup (#2223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(installer): overhaul branding, versioning, and skill cleanup Logo and branding: - Responsive logo: full "BMAD METHOD" at >=95 cols, "BMAD" for narrower terminals - Color scheme updated from yellow to blue (matching bmadcode.com brand) - Added copyright notice and tagline in white for contrast - Removed version number from logo (individual module versions shown in summary) - Added ™ to both wide and narrow logo variants Installer start message: - Replaced outdated V6 launch announcement with clean welcome - Consolidated redundant module/platform messaging into single intro - Tightened open source manifesto (same spirit, fewer words) - Merged speaking/media into support section with contact email - Added full social links: Website, Discord, YouTube, X, Facebook - Replaced docs.bmad-method.org and changelog links with bmadcode.com hub Install summary improvements: - Module names now show full display names from module.yaml (not abbreviations) - All module versions sourced from .claude-plugin/marketplace.json exclusively - Summary shows version transitions: "v6.2.2 -> v6.3.0", "v6.3.0, no change", or "v6.3.0, installed" for fresh installs - Switched summary from clack note() to box() for full-brightness text - Removed dim/gray styling that was hard to read on dark terminals - Links styled with color.blue instead of color.dim - Get started section leads with actionable steps (launch agent, run bmad-help) - Removed redundant social links (already shown in start message) Version source unification: - All module versions now come from .claude-plugin/marketplace.json only - Removed package.json as version source for core/bmm modules - Updated manifest.js getModuleVersionInfo() to use marketplace.json - Updated installer.js _getMarketplaceVersion() helper - Updated ui.js getMarketplaceVersion() for module selection display - Quick Update menu no longer shows misleading version (was using package.json) - Module selection list now shows versions next to each module name Skill cleanup overhaul: - Replaced blunt-force bmad-* prefix deletion with surgical removal system - Added removals.txt support: optional per-project file listing skills to remove - Created initial removals.txt with all skills removed since v6.2.0 - Install/update: captures previously installed skill IDs from skill-manifest.csv before manifest regeneration, then removes those + removals.txt entries - Uninstall: removes all installed skills via skill-manifest.csv + removals.txt - Deselecting modules now correctly removes their skills from IDE directories - User-created bmad-* skills in IDE directories are no longer destroyed - Legacy directory cleanup retains prefix matching (those dirs are abandoned) Bug fixes: - Fixed duplicate "CORE module already up to date" during quick update - Fixed version display showing package.json version instead of actual module version - Updated test fixture for bmad-os-* preservation test to use skill-manifest.csv * fix(installer): address Augment review findings - Fix plugins[0] fragility: extract highest version across all plugins in marketplace.json instead of assuming first entry (ui.js, installer.js, manifest.js) - Fix _readMarketplaceVersion ignoring moduleSourcePath: custom modules can now source their own marketplace.json by walking up from source path - Hard-exclude bmad-os-* utility skills in both surgical and legacy cleanup modes, preventing accidental deletion if tracked in manifests - Distinguish missing file vs parse error in skill-manifest.csv reading: warn on corrupt CSV instead of silently skipping cleanup * fix(installer): resolve module source before reading marketplace version Move _readMarketplaceVersion call after source type resolution so custom modules use their own source path instead of falling back to the external module cache, which could match a different module with the same code. --- removals.txt | 17 +++ test/test-installation-components.js | 8 ++ tools/installer/cli-utils.js | 25 +++-- tools/installer/core/installer.js | 145 ++++++++++++++++++------ tools/installer/core/manifest.js | 96 ++++++++++------ tools/installer/ide/_config-driven.js | 156 ++++++++++++++++++++++---- tools/installer/install-messages.yaml | 37 +++--- tools/installer/ui.js | 65 +++++++++-- 8 files changed, 420 insertions(+), 129 deletions(-) create mode 100644 removals.txt diff --git a/removals.txt b/removals.txt new file mode 100644 index 000000000..81a2b5dce --- /dev/null +++ b/removals.txt @@ -0,0 +1,17 @@ +# BMad Method - Skill Removal List +# Entries listed here will be removed from IDE skill directories during install/update. +# One entry per line. Lines starting with # are comments. +# Each entry is a skill directory name (canonicalId) that was removed or renamed. + +# Removed agents (v6.2.0 - v6.2.2) +bmad-agent-sm +bmad-agent-qa +bmad-agent-quick-flow-solo-dev + +# Removed skills (v6.2.0 - v6.2.2) +bmad-create-product-brief +bmad-product-brief-preview +bmad-quick-spec +bmad-quick-flow +bmad-quick-dev-new-preview +bmad-init diff --git a/test/test-installation-components.js b/test/test-installation-components.js index b548cbabe..1ac4b386d 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -1301,6 +1301,14 @@ async function runTests() { '---\nname: bmad-architect\ndescription: Architect\n---\nOld skill content\n', ); + // Add bmad-architect to the existing skill-manifest.csv so cleanup knows it was previously installed + const configDir27 = path.join(installedBmadDir27, '_config'); + const existingCsv27 = await fs.readFile(path.join(configDir27, 'skill-manifest.csv'), 'utf8'); + await fs.writeFile( + path.join(configDir27, 'skill-manifest.csv'), + existingCsv27.trimEnd() + '\n"bmad-architect","bmad-architect","Architect","bmm","_bmad/bmm/agents/bmad-architect/SKILL.md","true"\n', + ); + // Run Claude Code setup (which triggers cleanup then install) const ideManager27 = new IdeManager(); await ideManager27.ensureInitialized(); diff --git a/tools/installer/cli-utils.js b/tools/installer/cli-utils.js index 6ca615534..a0efdbe06 100644 --- a/tools/installer/cli-utils.js +++ b/tools/installer/cli-utils.js @@ -19,24 +19,33 @@ const CLIUtils = { * Display BMAD logo and version using @clack intro + box */ async displayLogo() { - const version = this.getVersion(); const color = await prompts.getColor(); + const termWidth = process.stdout.columns || 80; - // ASCII art logo - const logo = [ + // Full "BMad Method" logo for wide terminals, "BMad" only for narrow + const logoWide = [ + ' ██████╗ ███╗ ███╗ █████╗ ██████╗ ███╗ ███╗███████╗████████╗██╗ ██╗ ██████╗ ██████╗ ™', + '██╔══██╗████╗ ████║██╔══██╗██╔══██╗ ████╗ ████║██╔════╝╚══██╔══╝██║ ██║██╔═══██╗██╔══██╗', + '██████╔╝██╔████╔██║███████║██║ ██║ ██╔████╔██║█████╗ ██║ ███████║██║ ██║██║ ██║', + '██╔══██╗██║╚██╔╝██║██╔══██║██║ ██║ ██║╚██╔╝██║██╔══╝ ██║ ██╔══██║██║ ██║██║ ██║', + '██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝ ██║ ╚═╝ ██║███████╗ ██║ ██║ ██║╚██████╔╝██████╔╝', + '╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ', + ]; + + const logoNarrow = [ ' ██████╗ ███╗ ███╗ █████╗ ██████╗ ™', ' ██╔══██╗████╗ ████║██╔══██╗██╔══██╗', ' ██████╔╝██╔████╔██║███████║██║ ██║', ' ██╔══██╗██║╚██╔╝██║██╔══██║██║ ██║', ' ██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝', ' ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝', - ] - .map((line) => color.yellow(line)) - .join('\n'); + ]; - const tagline = ' Build More, Architect Dreams'; + const logoLines = termWidth >= 95 ? logoWide : logoNarrow; + const logo = logoLines.map((line) => color.blue(line)).join('\n'); + const tagline = color.white(' Build More, Architect Dreams\n © BMad Code'); - await prompts.box(`${logo}\n${tagline}`, `v${version}`, { + await prompts.box(`${logo}\n${tagline}`, '', { contentAlign: 'center', rounded: true, formatBorder: color.blue, diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index a0ea9a66e..d75355d72 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -26,6 +26,44 @@ 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 @@ -52,9 +90,36 @@ class Installer { await this._validateIdeSelection(config); + // Capture pre-install module versions for from→to display + const preInstallVersions = new Map(); + if (existingInstall.installed) { + const existingModules = await this.manifest.getAllModuleVersions(paths.bmadDir); + for (const mod of existingModules) { + if (mod.name && mod.version) { + preInstallVersions.set(mod.name, mod.version); + } + } + } + // Results collector for consolidated summary const results = []; - const addResult = (step, status, detail = '') => results.push({ step, status, detail }); + const addResult = (step, status, detail = '', meta = {}) => results.push({ step, status, detail, ...meta }); + + // Capture previously installed skill IDs before they get overwritten + const previousSkillIds = new Set(); + const prevCsvPath = path.join(paths.bmadDir, '_config', 'skill-manifest.csv'); + if (await fs.pathExists(prevCsvPath)) { + try { + const csvParse = require('csv-parse/sync'); + const content = await fs.readFile(prevCsvPath, 'utf8'); + const records = csvParse.parse(content, { columns: true, skip_empty_lines: true }); + for (const r of records) { + if (r.canonicalId) previousSkillIds.add(r.canonicalId); + } + } catch (error) { + await prompts.log.warn(`Failed to parse skill-manifest.csv: ${error.message}`); + } + } await this._cacheCustomModules(paths, addResult); @@ -65,7 +130,7 @@ class Installer { await this._installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules); - await this._setupIdes(config, allModules, paths, addResult); + await this._setupIdes(config, allModules, paths, addResult, previousSkillIds); const restoreResult = await this._restoreUserFiles(paths, updateState); @@ -76,6 +141,7 @@ class Installer { ides: config.ides, customFiles: restoreResult.customFiles.length > 0 ? restoreResult.customFiles : undefined, modifiedFiles: restoreResult.modifiedFiles.length > 0 ? restoreResult.modifiedFiles : undefined, + preInstallVersions, }); return { @@ -321,7 +387,7 @@ class Installer { /** * Set up IDE integrations for each selected IDE. */ - async _setupIdes(config, allModules, paths, addResult) { + async _setupIdes(config, allModules, paths, addResult, previousSkillIds = new Set()) { if (config.skipIde || !config.ides || config.ides.length === 0) return; await this.ideManager.ensureInitialized(); @@ -336,6 +402,7 @@ class Installer { const setupResult = await this.ideManager.setup(ide, paths.projectRoot, paths.bmadDir, { selectedModules: allModules || [], verbose: config.verbose, + previousSkillIds, }); if (setupResult.success) { @@ -556,7 +623,7 @@ class Installer { message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); const moduleConfig = officialModules.moduleConfigs[moduleName] || {}; - await officialModules.install( + const installResult = await officialModules.install( moduleName, paths.bmadDir, (filePath) => { @@ -570,7 +637,12 @@ class Installer { }, ); - addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); + // Get display name from source module.yaml; version from marketplace.json + const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true }); + const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null; + const displayName = moduleInfo?.name || moduleName; + const version = sourcePath ? await this._getMarketplaceVersion(sourcePath) : ''; + addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version }); } } @@ -598,7 +670,11 @@ class Installer { [moduleName]: { ...config.coreConfig, ...result.moduleConfig, ...collectedModuleConfig }, }); - addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); + // Get display name from source module.yaml; version from marketplace.json + const moduleInfo = await officialModules.getModuleInfo(sourcePath, moduleName, ''); + const displayName = moduleInfo?.name || moduleName; + const version = await this._getMarketplaceVersion(sourcePath); + addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version }); } } @@ -1062,23 +1138,10 @@ class Installer { const selectedIdes = new Set((context.ides || []).map((ide) => String(ide).toLowerCase())); // Build step lines with status indicators + const preVersions = context.preInstallVersions || new Map(); const lines = []; for (const r of results) { - let stepLabel = null; - - if (r.status !== 'ok') { - stepLabel = r.step; - } else if (r.step === 'Core') { - stepLabel = 'BMAD'; - } else if (r.step.startsWith('Module: ')) { - stepLabel = r.step; - } else if (selectedIdes.has(String(r.step).toLowerCase())) { - stepLabel = r.step; - } - - if (!stepLabel) { - continue; - } + const stepLabel = r.step; let icon; if (r.status === 'ok') { @@ -1088,18 +1151,32 @@ class Installer { } else { icon = color.red('\u2717'); } - const detail = r.detail ? color.dim(` (${r.detail})`) : ''; + + // Build version detail for module results + let detail = ''; + if (r.moduleCode && r.newVersion) { + const oldVersion = preVersions.get(r.moduleCode); + if (oldVersion && oldVersion === r.newVersion) { + detail = ` (v${r.newVersion}, no change)`; + } else if (oldVersion) { + detail = ` (v${oldVersion} → v${r.newVersion})`; + } else { + detail = ` (v${r.newVersion}, installed)`; + } + } else if (r.detail) { + detail = ` (${r.detail})`; + } lines.push(` ${icon} ${stepLabel}${detail}`); } if ((context.ides || []).length === 0) { - lines.push(` ${color.green('\u2713')} No IDE selected ${color.dim('(installed in _bmad only)')}`); + lines.push(` ${color.green('\u2713')} No IDE selected (installed in _bmad only)`); } // Context and warnings lines.push(''); if (context.bmadDir) { - lines.push(` Installed to: ${color.dim(context.bmadDir)}`); + lines.push(` Installed to: ${context.bmadDir}`); } if (context.customFiles && context.customFiles.length > 0) { lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`); @@ -1111,17 +1188,18 @@ class Installer { // Next steps lines.push( '', - ' Next steps:', - ` Read our new Docs Site: ${color.dim('https://docs.bmad-method.org/')}`, - ` Join our Discord: ${color.dim('https://discord.gg/gk8jAdXWmj')}`, - ` Star us on GitHub: ${color.dim('https://github.com/bmad-code-org/BMAD-METHOD/')}`, - ` Subscribe on YouTube: ${color.dim('https://www.youtube.com/@BMadCode')}`, + ' Get started:', + ` 1. Launch your AI agent from your project folder`, + ` 2. Not sure what to do? Invoke the ${color.cyan('bmad-help')} skill and ask it what to do!`, + '', + ` Blog, Docs and Guides: ${color.blue('https://bmadcode.com/')}`, + ` Community: ${color.blue('https://discord.gg/gk8jAdXWmj')}`, ); - if (context.ides && context.ides.length > 0) { - lines.push(` Invoke the ${color.cyan('bmad-help')} skill in your IDE Agent to get started`); - } - await prompts.note(lines.join('\n'), 'BMAD is ready to use!'); + await prompts.box(lines.join('\n'), 'BMAD is ready to use!', { + rounded: true, + formatBorder: color.green, + }); } /** @@ -1231,6 +1309,7 @@ class Installer { } for (const moduleName of modulesToUpdate) { + if (moduleName === 'core') continue; // Already collected above const modulePrompted = await quickModules.collectModuleConfigQuick(moduleName, projectDir, true); if (modulePrompted) { promptedForNewFields = true; diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index d6eade648..287b38918 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -837,14 +837,13 @@ class Manifest { * @returns {Object} Version info object with version, source, npmPackage, repoUrl */ async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) { - const os = require('node:os'); const yaml = require('yaml'); - // Built-in modules use BMad version (only core and bmm are in BMAD-METHOD repo) + // Resolve source type first, then read version with the correct path context if (['core', 'bmm'].includes(moduleName)) { - const bmadVersion = require(path.join(getProjectRoot(), 'package.json')).version; + const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); return { - version: bmadVersion, + version, source: 'built-in', npmPackage: null, repoUrl: null, @@ -857,42 +856,20 @@ class Manifest { 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) { - await prompts.log.warn(`Failed to read package.json for ${moduleName}: ${error.message}`); - } - } - } - + // External module: use moduleSourcePath if provided, otherwise fall back to cache + const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); return { - version: version, + version, source: 'external', npmPackage: moduleInfo.npmPackage || null, repoUrl: moduleInfo.url || null, }; } - // Custom module - check cache directory + // Custom module: resolve path from source or cache before reading version + const customSourcePath = moduleSourcePath || path.join(bmadDir, '_config', 'custom', moduleName); + const version = await this._readMarketplaceVersion(moduleName, customSourcePath); + const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName); const moduleYamlPath = path.join(cacheDir, 'module.yaml'); @@ -901,7 +878,7 @@ class Manifest { const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); const moduleConfig = yaml.parse(yamlContent); return { - version: moduleConfig.version || null, + version: version || moduleConfig.version || null, source: 'custom', npmPackage: moduleConfig.npmPackage || null, repoUrl: moduleConfig.repoUrl || null, @@ -913,13 +890,62 @@ class Manifest { // Unknown module return { - version: null, + 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 diff --git a/tools/installer/ide/_config-driven.js b/tools/installer/ide/_config-driven.js index 603ffc7a4..ec7dcaad6 100644 --- a/tools/installer/ide/_config-driven.js +++ b/tools/installer/ide/_config-driven.js @@ -86,7 +86,7 @@ class ConfigDrivenIdeSetup { if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`); // Clean up any old BMAD installation first - await this.cleanup(projectDir, options); + await this.cleanup(projectDir, options, bmadDir); if (!this.installerConfig) { return { success: false, reason: 'no-config' }; @@ -215,15 +215,34 @@ class ConfigDrivenIdeSetup { * Cleanup IDE configuration * @param {string} projectDir - Project directory */ - async cleanup(projectDir, options = {}) { + async cleanup(projectDir, options = {}, bmadDir = null) { + const resolvedBmadDir = bmadDir || (await this._findBmadDir(projectDir)); + + // Build removal set: previously installed skills + removals.txt entries + let removalSet; + if (options.previousSkillIds && options.previousSkillIds.size > 0) { + // Install/update flow: use pre-captured skill IDs (before manifest was overwritten) + removalSet = new Set(options.previousSkillIds); + if (resolvedBmadDir) { + const removals = await this.loadRemovalLists(resolvedBmadDir); + for (const entry of removals) removalSet.add(entry); + } + } else if (resolvedBmadDir) { + // Uninstall flow: read from current skill-manifest.csv + removals.txt + removalSet = await this._buildUninstallSet(resolvedBmadDir); + } else { + removalSet = new Set(); + } + // Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents) + // Legacy dirs are abandoned entirely, so use prefix matching (null removalSet) if (this.installerConfig?.legacy_targets) { if (!options.silent) await prompts.log.message(' Migrating legacy directories...'); for (const legacyDir of this.installerConfig.legacy_targets) { if (this.isGlobalPath(legacyDir)) { await this.warnGlobalLegacy(legacyDir, options); } else { - await this.cleanupTarget(projectDir, legacyDir, options); + await this.cleanupTarget(projectDir, legacyDir, options, null); await this.removeEmptyParents(projectDir, legacyDir); } } @@ -244,9 +263,9 @@ class ConfigDrivenIdeSetup { await this.cleanupRovoDevPrompts(projectDir, options); } - // Clean target directory + // Clean current target directory if (this.installerConfig?.target_dir) { - await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options); + await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet); } } @@ -286,23 +305,117 @@ class ConfigDrivenIdeSetup { } /** - * Cleanup a specific target directory + * Find the _bmad directory in a project + * @param {string} projectDir - Project directory + * @returns {string|null} Path to bmad dir or null + */ + async _findBmadDir(projectDir) { + const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); + return (await fs.pathExists(bmadDir)) ? bmadDir : null; + } + + /** + * Build the full set of entries to remove for uninstall. + * Reads skill-manifest.csv to know exactly what was installed, plus removal lists. + * @param {string} bmadDir - BMAD installation directory + * @returns {Set} Set of entries to remove + */ + async _buildUninstallSet(bmadDir) { + const removals = await this.loadRemovalLists(bmadDir); + + // Also add all currently installed skills from skill-manifest.csv + const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); + try { + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const records = csv.parse(content, { columns: true, skip_empty_lines: true }); + for (const record of records) { + if (record.canonicalId) { + removals.add(record.canonicalId); + } + } + } + } catch { + // If we can't read the manifest, we still have the removal lists + } + + return removals; + } + + /** + * Load removal lists from all module sources in the bmad directory. + * Each module can have an optional removals.txt listing entries to remove. + * @param {string} bmadDir - BMAD installation directory + * @returns {Set} Set of entries to remove + */ + async loadRemovalLists(bmadDir) { + const removals = new Set(); + const { getProjectRoot } = require('../project-root'); + + // Read project-level removals.txt (covers core and bmm) + const projectRemovalsPath = path.join(getProjectRoot(), 'removals.txt'); + await this._readRemovalFile(projectRemovalsPath, removals); + + // Read per-module removals.txt from installed module directories + try { + const entries = await fs.readdir(bmadDir); + for (const entry of entries) { + if (entry.startsWith('_')) continue; + const removalPath = path.join(bmadDir, entry, 'removals.txt'); + await this._readRemovalFile(removalPath, removals); + } + } catch { + // bmadDir may not exist yet on fresh install + } + + return removals; + } + + /** + * Read a removals.txt file and add entries to the set + * @param {string} filePath - Path to removals.txt + * @param {Set} removals - Set to add entries to + */ + async _readRemovalFile(filePath, removals) { + try { + if (await fs.pathExists(filePath)) { + const content = await fs.readFile(filePath, 'utf8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + removals.add(trimmed); + } + } + } + } catch { + // Optional file — ignore errors + } + } + + /** + * Cleanup a specific target directory. + * When removalSet is provided, only removes entries in that set. + * When removalSet is null (legacy dirs), removes all bmad-prefixed entries. * @param {string} projectDir - Project directory * @param {string} targetDir - Target directory to clean + * @param {Object} options - Cleanup options + * @param {Set|null} removalSet - Entries to remove, or null for legacy prefix matching */ - async cleanupTarget(projectDir, targetDir, options = {}) { + async cleanupTarget(projectDir, targetDir, options = {}, removalSet = new Set()) { const targetPath = path.join(projectDir, targetDir); if (!(await fs.pathExists(targetPath))) { return; } - // Remove all bmad* files + if (removalSet && removalSet.size === 0) { + return; + } + let entries; try { entries = await fs.readdir(targetPath); } catch { - // Directory exists but can't be read - skip cleanup return; } @@ -313,23 +426,26 @@ class ConfigDrivenIdeSetup { let removedCount = 0; for (const entry of entries) { - if (!entry || typeof entry !== 'string') { - continue; - } - if (entry.startsWith('bmad') && !entry.startsWith('bmad-os-')) { - const entryPath = path.join(targetPath, entry); + if (!entry || typeof entry !== 'string') continue; + + // Always preserve bmad-os-* utility skills regardless of cleanup mode + if (entry.startsWith('bmad-os-')) continue; + + // Surgical removal from set, or legacy prefix matching when set is null + const shouldRemove = removalSet ? removalSet.has(entry) : entry.startsWith('bmad'); + + if (shouldRemove) { try { - await fs.remove(entryPath); + await fs.remove(path.join(targetPath, entry)); removedCount++; } catch { - // Skip entries that can't be removed (broken symlinks, permission errors) + // Skip entries that can't be removed } } } - if (removedCount > 0 && !options.silent) { - await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`); - } + // Only log cleanup when it's not a routine reinstall (legacy dir cleanup or actual removals) + // Suppress for current target_dir since it's always cleaned before a fresh write // Remove empty directory after cleanup if (removedCount > 0) { @@ -339,7 +455,7 @@ class ConfigDrivenIdeSetup { await fs.remove(targetPath); } } catch { - // Directory may already be gone or in use — skip + // Directory may already be gone or in use } } } diff --git a/tools/installer/install-messages.yaml b/tools/installer/install-messages.yaml index 0fc32cc82..4aff87a95 100644 --- a/tools/installer/install-messages.yaml +++ b/tools/installer/install-messages.yaml @@ -6,32 +6,25 @@ startMessage: | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 🎉 V6 IS HERE! Welcome to BMad Method V6 - Official Stable Release! + Agile AI-Driven Development. Powered by BMad Core and a growing module ecosystem. + Install official and community modules during setup to customize your experience. - The BMad Method is now a Platform powered by the BMad Method Core and Module Ecosystem! - - Select and install modules during setup - customize your experience - - New BMad Method for Agile AI-Driven Development (the evolution of V4) - - Exciting new modules available during installation, with community modules coming soon - - Documentation: https://docs.bmad-method.org + 🌟 100% free. 100% open source. Always. + No paywalls. No gated content. Knowledge shared, not sold. - 🌟 BMad is 100% free and open source. - - No gated Discord. No paywalls. No gated content. - - We believe in empowering everyone, not just those who can pay. - - Knowledge should be shared, not sold. + 🌐 CONNECT: + Website: https://bmadcode.com/ + Discord: https://discord.gg/gk8jAdXWmj + YouTube: https://www.youtube.com/@BMadCode + X: https://x.com/BMadCode + Facebook: https://facebook.com/@BMadCode - 🎤 SPEAKING & MEDIA: - - Available for conferences, podcasts, and media appearances - - Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method - - For speaking inquiries or interviews, reach out to BMad on Discord! + ⭐ SUPPORT THE PROJECT: + Star us: https://github.com/bmad-code-org/BMAD-METHOD/ + Donate: https://buymeacoffee.com/bmad + Corporate sponsorship and speaking inquiries: contact@bmadcode.com - ⭐ HELP US GROW: - - Star us on GitHub: https://github.com/bmad-code-org/BMAD-METHOD/ - - Subscribe on YouTube: https://www.youtube.com/@BMadCode - - Free Community and Support: https://discord.gg/gk8jAdXWmj - - Donate: https://buymeacoffee.com/bmad - - Corporate Sponsorship available - - Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/blob/main/CHANGELOG.md + Docs, blog, and latest updates: https://bmadcode.com/ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 03d38e4da..cccf219cc 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -4,8 +4,50 @@ const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); const { CustomHandler } = require('./custom-handler'); const { ExternalModuleManager } = require('./modules/external-manager'); +const { getProjectRoot } = require('./project-root'); const prompts = require('./prompts'); +/** + * Read module version from .claude-plugin/marketplace.json + * @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; +} + // Separator class for visual grouping in select/multiselect prompts // Note: @clack/prompts doesn't support separators natively, they are filtered out class Separator { @@ -70,17 +112,14 @@ class UI { if (hasExistingInstall) { // Get version information const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory); - const packageJsonPath = path.join(__dirname, '../../package.json'); - const currentVersion = require(packageJsonPath).version; - const installedVersion = existingInstall.installed ? existingInstall.version || 'unknown' : 'unknown'; // Build menu choices dynamically const choices = []; // Always show Quick Update first (allows refreshing installation even on same version) - if (installedVersion !== 'unknown') { + if (existingInstall.installed) { choices.push({ - name: `Quick Update (v${installedVersion} → v${currentVersion})`, + name: 'Quick Update', value: 'quick-update', }); } @@ -880,14 +919,18 @@ class UI { const lockedValues = ['core']; // Core module is always installed — show it locked at the top - allOptions.push({ label: 'BMad Core Module', value: 'core', hint: 'Core configuration and shared resources' }); + const coreVersion = await getMarketplaceVersion('core'); + const coreLabel = coreVersion ? `BMad Core Module (v${coreVersion})` : 'BMad Core Module'; + allOptions.push({ label: coreLabel, value: 'core', hint: 'Core configuration and shared resources' }); initialValues.push('core'); // Helper to build module entry with proper sorting and selection - const buildModuleEntry = (mod, value, group) => { + const buildModuleEntry = async (mod, value, group) => { const isInstalled = installedModuleIds.has(value); + const version = await getMarketplaceVersion(value); + const label = version ? `${mod.name} (v${version})` : mod.name; return { - label: mod.name, + label, value, hint: mod.description || group, // Pre-select only if already installed (not on fresh install) @@ -899,7 +942,7 @@ class UI { const localEntries = []; for (const mod of localModules) { if (!mod.isCustom && mod.id !== 'core') { - const entry = buildModuleEntry(mod, mod.id, 'Local'); + const entry = await buildModuleEntry(mod, mod.id, 'Local'); localEntries.push(entry); if (entry.selected) { initialValues.push(mod.id); @@ -912,7 +955,7 @@ class UI { const officialModules = []; for (const mod of externalModules) { if (mod.type === 'bmad-org') { - const entry = buildModuleEntry(mod, mod.code, 'Official'); + const entry = await buildModuleEntry(mod, mod.code, 'Official'); officialModules.push(entry); if (entry.selected) { initialValues.push(mod.code); @@ -925,7 +968,7 @@ class UI { const communityModules = []; for (const mod of externalModules) { if (mod.type === 'community') { - const entry = buildModuleEntry(mod, mod.code, 'Community'); + const entry = await buildModuleEntry(mod, mod.code, 'Community'); communityModules.push(entry); if (entry.selected) { initialValues.push(mod.code);