diff --git a/package.json b/package.json index 9cd7e90ad..6fc2e1024 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "scripts": { "bmad:install": "node tools/cli/bmad-cli.js install", + "bmad:uninstall": "node tools/cli/bmad-cli.js uninstall", "docs:build": "node tools/build-docs.mjs", "docs:dev": "astro dev --root website", "docs:fix-links": "node tools/fix-doc-links.js", diff --git a/tools/cli/commands/uninstall.js b/tools/cli/commands/uninstall.js new file mode 100644 index 000000000..99734791e --- /dev/null +++ b/tools/cli/commands/uninstall.js @@ -0,0 +1,167 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const prompts = require('../lib/prompts'); +const { Installer } = require('../installers/lib/core/installer'); + +const installer = new Installer(); + +module.exports = { + command: 'uninstall', + description: 'Remove BMAD installation from the current project', + options: [ + ['-y, --yes', 'Remove all BMAD components without prompting (preserves user artifacts)'], + ['--directory ', 'Project directory (default: current directory)'], + ], + action: async (options) => { + try { + let projectDir; + + if (options.directory) { + // Explicit --directory flag takes precedence + projectDir = path.resolve(options.directory); + } else if (options.yes) { + // Non-interactive mode: use current directory + projectDir = process.cwd(); + } else { + // Interactive: ask user which directory to uninstall from + // select() handles cancellation internally (exits process) + const dirChoice = await prompts.select({ + message: 'Where do you want to uninstall BMAD from?', + choices: [ + { value: 'cwd', name: `Current directory (${process.cwd()})` }, + { value: 'other', name: 'Another directory...' }, + ], + }); + + if (dirChoice === 'other') { + // text() handles cancellation internally (exits process) + const customDir = await prompts.text({ + message: 'Enter the project directory path:', + placeholder: process.cwd(), + validate: (value) => { + if (!value || value.trim().length === 0) return 'Directory path is required'; + }, + }); + + projectDir = path.resolve(customDir.trim()); + } else { + projectDir = process.cwd(); + } + } + + if (!(await fs.pathExists(projectDir))) { + await prompts.log.error(`Directory does not exist: ${projectDir}`); + process.exit(1); + } + + const { bmadDir } = await installer.findBmadDir(projectDir); + + if (!(await fs.pathExists(bmadDir))) { + await prompts.log.warn('No BMAD installation found.'); + process.exit(0); + } + + const existingInstall = await installer.getStatus(projectDir); + const version = existingInstall.version || 'unknown'; + const modules = (existingInstall.modules || []).map((m) => m.id || m.name).join(', '); + const ides = (existingInstall.ides || []).join(', '); + + const outputFolder = await installer.getOutputFolder(projectDir); + + await prompts.intro('BMAD Uninstall'); + await prompts.note(`Version: ${version}\nModules: ${modules}\nIDE integrations: ${ides}`, 'Current Installation'); + + let removeModules = true; + let removeIdeConfigs = true; + let removeOutputFolder = false; + + if (!options.yes) { + // multiselect() handles cancellation internally (exits process) + const selected = await prompts.multiselect({ + message: 'Select components to remove:', + options: [ + { + value: 'modules', + label: `BMAD Modules & data (${installer.bmadFolderName}/)`, + hint: 'Core installation, agents, workflows, config', + }, + { value: 'ide', label: 'IDE integrations', hint: ides || 'No IDEs configured' }, + { value: 'output', label: `User artifacts (${outputFolder}/)`, hint: 'WARNING: Contains your work products' }, + ], + initialValues: ['modules', 'ide'], + required: true, + }); + + removeModules = selected.includes('modules'); + removeIdeConfigs = selected.includes('ide'); + removeOutputFolder = selected.includes('output'); + + const red = (s) => `\u001B[31m${s}\u001B[0m`; + await prompts.note( + red('šŸ’€ This action is IRREVERSIBLE! Removed files cannot be recovered!') + + '\n' + + red('šŸ’€ IDE configurations and modules will need to be reinstalled.') + + '\n' + + red('šŸ’€ User artifacts are preserved unless explicitly selected.'), + '!! DESTRUCTIVE ACTION !!', + ); + + const confirmed = await prompts.confirm({ + message: 'Proceed with uninstall?', + default: false, + }); + + if (!confirmed) { + await prompts.outro('Uninstall cancelled.'); + process.exit(0); + } + } + + // Phase 1: IDE integrations + if (removeIdeConfigs) { + const s = await prompts.spinner(); + s.start('Removing IDE integrations...'); + await installer.uninstallIdeConfigs(projectDir, existingInstall, { silent: true }); + s.stop(`Removed IDE integrations (${ides || 'none'})`); + } + + // Phase 2: User artifacts + if (removeOutputFolder) { + const s = await prompts.spinner(); + s.start(`Removing user artifacts (${outputFolder}/)...`); + await installer.uninstallOutputFolder(projectDir, outputFolder); + s.stop('User artifacts removed'); + } + + // Phase 3: BMAD modules & data (last — other phases may need _bmad/) + if (removeModules) { + const s = await prompts.spinner(); + s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`); + await installer.uninstallModules(projectDir); + s.stop('Modules & data removed'); + } + + const summary = []; + if (removeIdeConfigs) summary.push('IDE integrations cleaned'); + if (removeModules) summary.push('Modules & data removed'); + if (removeOutputFolder) summary.push('User artifacts removed'); + if (!removeOutputFolder) summary.push(`User artifacts preserved in ${outputFolder}/`); + + await prompts.note(summary.join('\n'), 'Summary'); + await prompts.outro('To reinstall, run: npx bmad-method install'); + + process.exit(0); + } catch (error) { + try { + const errorMessage = error instanceof Error ? error.message : String(error); + await prompts.log.error(`Uninstall failed: ${errorMessage}`); + if (error instanceof Error && error.stack) { + await prompts.log.message(error.stack); + } + } catch { + console.error(error instanceof Error ? error.message : error); + } + process.exit(1); + } + }, +}; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 3acb36465..b7197d44d 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -1528,20 +1528,157 @@ class Installer { } /** - * Uninstall BMAD + * Uninstall BMAD with selective removal options + * @param {string} directory - Project directory + * @param {Object} options - Uninstall options + * @param {boolean} [options.removeModules=true] - Remove _bmad/ directory + * @param {boolean} [options.removeIdeConfigs=true] - Remove IDE configurations + * @param {boolean} [options.removeOutputFolder=false] - Remove user artifacts output folder + * @returns {Object} Result with success status and removed components */ - async uninstall(directory) { + async uninstall(directory, options = {}) { const projectDir = path.resolve(directory); const { bmadDir } = await this.findBmadDir(projectDir); - if (await fs.pathExists(bmadDir)) { - await fs.remove(bmadDir); + if (!(await fs.pathExists(bmadDir))) { + return { success: false, reason: 'not-installed' }; } - // Clean up IDE configurations - await this.ideManager.cleanup(projectDir); + // 1. DETECT: Read state BEFORE deleting anything + const existingInstall = await this.detector.detect(bmadDir); + const outputFolder = await this._readOutputFolder(bmadDir); - return { success: true }; + const removed = { modules: false, ideConfigs: false, outputFolder: false }; + + // 2. IDE CLEANUP (before _bmad/ deletion so configs are accessible) + if (options.removeIdeConfigs !== false) { + await this.uninstallIdeConfigs(projectDir, existingInstall, { silent: options.silent }); + removed.ideConfigs = true; + } + + // 3. OUTPUT FOLDER (only if explicitly requested) + if (options.removeOutputFolder === true && outputFolder) { + removed.outputFolder = await this.uninstallOutputFolder(projectDir, outputFolder); + } + + // 4. BMAD DIRECTORY (last, after everything that needs it) + if (options.removeModules !== false) { + removed.modules = await this.uninstallModules(projectDir); + } + + return { success: true, removed, version: existingInstall.version }; + } + + /** + * Uninstall IDE configurations only + * @param {string} projectDir - Project directory + * @param {Object} existingInstall - Detection result from detector.detect() + * @param {Object} [options] - Options (e.g. { silent: true }) + * @returns {Promise} Results from IDE cleanup + */ + async uninstallIdeConfigs(projectDir, existingInstall, options = {}) { + await this.ideManager.ensureInitialized(); + const cleanupOptions = { isUninstall: true, silent: options.silent }; + const ideList = existingInstall.ides || []; + if (ideList.length > 0) { + return this.ideManager.cleanupByList(projectDir, ideList, cleanupOptions); + } + return this.ideManager.cleanup(projectDir, cleanupOptions); + } + + /** + * Remove user artifacts output folder + * @param {string} projectDir - Project directory + * @param {string} outputFolder - Output folder name (relative) + * @returns {Promise} Whether the folder was removed + */ + async uninstallOutputFolder(projectDir, outputFolder) { + if (!outputFolder) return false; + const resolvedProject = path.resolve(projectDir); + const outputPath = path.resolve(resolvedProject, outputFolder); + if (!outputPath.startsWith(resolvedProject + path.sep)) { + return false; + } + if (await fs.pathExists(outputPath)) { + await fs.remove(outputPath); + return true; + } + return false; + } + + /** + * Remove the _bmad/ directory + * @param {string} projectDir - Project directory + * @returns {Promise} Whether the directory was removed + */ + async uninstallModules(projectDir) { + const { bmadDir } = await this.findBmadDir(projectDir); + if (await fs.pathExists(bmadDir)) { + await fs.remove(bmadDir); + return true; + } + return false; + } + + /** + * Get the configured output folder name for a project + * Resolves bmadDir internally from projectDir + * @param {string} projectDir - Project directory + * @returns {string} Output folder name (relative, default: '_bmad-output') + */ + async getOutputFolder(projectDir) { + const { bmadDir } = await this.findBmadDir(projectDir); + return this._readOutputFolder(bmadDir); + } + + /** + * Read the output_folder setting from module config files + * Checks bmm/config.yaml first, then other module configs + * @param {string} bmadDir - BMAD installation directory + * @returns {string} Output folder path or default + */ + async _readOutputFolder(bmadDir) { + const yaml = require('yaml'); + + // Check bmm/config.yaml first (most common) + const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml'); + if (await fs.pathExists(bmmConfigPath)) { + try { + const content = await fs.readFile(bmmConfigPath, 'utf8'); + const config = yaml.parse(content); + if (config && config.output_folder) { + // Strip {project-root}/ prefix if present + return config.output_folder.replace(/^\{project-root\}[/\\]/, ''); + } + } catch { + // Fall through to other modules + } + } + + // Scan other module config.yaml files + try { + const entries = await fs.readdir(bmadDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'bmm' || entry.name.startsWith('_')) continue; + const configPath = path.join(bmadDir, entry.name, 'config.yaml'); + if (await fs.pathExists(configPath)) { + try { + const content = await fs.readFile(configPath, 'utf8'); + const config = yaml.parse(content); + if (config && config.output_folder) { + return config.output_folder.replace(/^\{project-root\}[/\\]/, ''); + } + } catch { + // Continue scanning + } + } + } + } catch { + // Directory scan failed + } + + // Default fallback + return '_bmad-output'; } /** diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 7eb2533ed..9541c75ed 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -456,8 +456,18 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} async cleanup(projectDir, options = {}) { // Clean all target directories if (this.installerConfig?.targets) { + const parentDirs = new Set(); for (const target of this.installerConfig.targets) { await this.cleanupTarget(projectDir, target.target_dir, options); + // Track parent directories for empty-dir cleanup + const parentDir = path.dirname(target.target_dir); + if (parentDir && parentDir !== '.') { + parentDirs.add(parentDir); + } + } + // After all targets cleaned, remove empty parent directories (recursive up to projectDir) + for (const parentDir of parentDirs) { + await this.removeEmptyParents(projectDir, parentDir); } } else if (this.installerConfig?.target_dir) { await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options); @@ -509,6 +519,41 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} if (removedCount > 0 && !options.silent) { await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`); } + + // Remove empty directory after cleanup + if (removedCount > 0) { + try { + const remaining = await fs.readdir(targetPath); + if (remaining.length === 0) { + await fs.remove(targetPath); + } + } catch { + // Directory may already be gone or in use — skip + } + } + } + /** + * Recursively remove empty directories walking up from dir toward projectDir + * Stops at projectDir boundary — never removes projectDir itself + * @param {string} projectDir - Project root (boundary) + * @param {string} relativeDir - Relative directory to start from + */ + async removeEmptyParents(projectDir, relativeDir) { + let current = relativeDir; + let last = null; + while (current && current !== '.' && current !== last) { + last = current; + const fullPath = path.join(projectDir, current); + try { + if (!(await fs.pathExists(fullPath))) break; + const remaining = await fs.readdir(fullPath); + if (remaining.length > 0) break; + await fs.rmdir(fullPath); + } catch { + break; + } + current = path.dirname(current); + } } } diff --git a/tools/cli/installers/lib/ide/github-copilot.js b/tools/cli/installers/lib/ide/github-copilot.js index 4d852fcb0..033e8d627 100644 --- a/tools/cli/installers/lib/ide/github-copilot.js +++ b/tools/cli/installers/lib/ide/github-copilot.js @@ -1,6 +1,6 @@ const path = require('node:path'); const { BaseIdeSetup } = require('./_base-ide'); -const chalk = require('chalk'); +const prompts = require('../../../lib/prompts'); const { AgentCommandGenerator } = require('./shared/agent-command-generator'); const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils'); const fs = require('fs-extra'); @@ -31,7 +31,7 @@ class GitHubCopilotSetup extends BaseIdeSetup { * @param {Object} options - Setup options */ async setup(projectDir, bmadDir, options = {}) { - console.log(chalk.cyan(`Setting up ${this.name}...`)); + if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`); // Create .github/agents and .github/prompts directories const githubDir = path.join(projectDir, this.githubDir); @@ -66,21 +66,15 @@ class GitHubCopilotSetup extends BaseIdeSetup { const targetPath = path.join(agentsDir, fileName); await this.writeFile(targetPath, agentContent); agentCount++; - - console.log(chalk.green(` āœ“ Created agent: ${fileName}`)); } // Generate prompt files from bmad-help.csv const promptCount = await this.generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest); // Generate copilot-instructions.md - await this.generateCopilotInstructions(projectDir, bmadDir, agentManifest); + await this.generateCopilotInstructions(projectDir, bmadDir, agentManifest, options); - console.log(chalk.green(`\nāœ“ ${this.name} configured:`)); - console.log(chalk.dim(` - ${agentCount} agents created in .github/agents/`)); - console.log(chalk.dim(` - ${promptCount} prompts created in .github/prompts/`)); - console.log(chalk.dim(` - copilot-instructions.md generated`)); - console.log(chalk.dim(` - Destination: .github/`)); + if (!options.silent) await prompts.log.success(`${this.name} configured: ${agentCount} agents, ${promptCount} prompts → .github/`); return { success: true, @@ -406,7 +400,7 @@ tools: ${toolsStr} * @param {string} bmadDir - BMAD installation directory * @param {Map} agentManifest - Agent manifest data */ - async generateCopilotInstructions(projectDir, bmadDir, agentManifest) { + async generateCopilotInstructions(projectDir, bmadDir, agentManifest, options = {}) { const configVars = await this.loadModuleConfig(bmadDir); // Build the agents table from the manifest @@ -495,19 +489,16 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac const after = existing.slice(endIdx + markerEnd.length); const merged = `${before}${markedContent}${after}`; await this.writeFile(instructionsPath, merged); - console.log(chalk.green(' āœ“ Updated BMAD section in copilot-instructions.md')); } else { // Existing file without markers — back it up before overwriting const backupPath = `${instructionsPath}.bak`; await fs.copy(instructionsPath, backupPath); - console.log(chalk.yellow(` ⚠ Backed up existing copilot-instructions.md → copilot-instructions.md.bak`)); + if (!options.silent) await prompts.log.warn(` Backed up copilot-instructions.md → .bak`); await this.writeFile(instructionsPath, `${markedContent}\n`); - console.log(chalk.green(' āœ“ Generated copilot-instructions.md (with BMAD markers)')); } } else { // No existing file — create fresh with markers await this.writeFile(instructionsPath, `${markedContent}\n`); - console.log(chalk.green(' āœ“ Generated copilot-instructions.md')); } } @@ -607,7 +598,7 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac /** * Cleanup GitHub Copilot configuration - surgically remove only BMAD files */ - async cleanup(projectDir) { + async cleanup(projectDir, options = {}) { // Clean up agents directory const agentsDir = path.join(projectDir, this.githubDir, this.agentsDir); if (await fs.pathExists(agentsDir)) { @@ -621,8 +612,8 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac } } - if (removed > 0) { - console.log(chalk.dim(` Cleaned up ${removed} existing BMAD agents`)); + if (removed > 0 && !options.silent) { + await prompts.log.message(` Cleaned up ${removed} existing BMAD agents`); } } @@ -639,16 +630,70 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac } } - if (removed > 0) { - console.log(chalk.dim(` Cleaned up ${removed} existing BMAD prompts`)); + if (removed > 0 && !options.silent) { + await prompts.log.message(` Cleaned up ${removed} existing BMAD prompts`); } } - // Note: copilot-instructions.md is NOT cleaned up here. - // generateCopilotInstructions() handles marker-based replacement in a single - // read-modify-write pass, which correctly preserves user content outside the markers. - // Stripping markers here would cause generation to treat the file as legacy (no markers) - // and overwrite user content. + // During uninstall, also strip BMAD markers from copilot-instructions.md. + // During reinstall (default), this is skipped because generateCopilotInstructions() + // handles marker-based replacement in a single read-modify-write pass, + // which correctly preserves user content outside the markers. + if (options.isUninstall) { + await this.cleanupCopilotInstructions(projectDir, options); + } + } + + /** + * Strip BMAD marker section from copilot-instructions.md + * If file becomes empty after stripping, delete it. + * If a .bak backup exists and the main file was deleted, restore the backup. + * @param {string} projectDir - Project directory + * @param {Object} [options] - Options (e.g. { silent: true }) + */ + async cleanupCopilotInstructions(projectDir, options = {}) { + const instructionsPath = path.join(projectDir, this.githubDir, 'copilot-instructions.md'); + const backupPath = `${instructionsPath}.bak`; + + if (!(await fs.pathExists(instructionsPath))) { + return; + } + + const content = await fs.readFile(instructionsPath, 'utf8'); + const markerStart = ''; + const markerEnd = ''; + const startIdx = content.indexOf(markerStart); + const endIdx = content.indexOf(markerEnd); + + if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) { + return; // No valid markers found + } + + // Strip the marker section (including markers) + const before = content.slice(0, startIdx); + const after = content.slice(endIdx + markerEnd.length); + const cleaned = before + after; + + if (cleaned.trim().length === 0) { + // File is empty after stripping — delete it + await fs.remove(instructionsPath); + + // If backup exists, restore it + if (await fs.pathExists(backupPath)) { + await fs.rename(backupPath, instructionsPath); + if (!options.silent) { + await prompts.log.message(' Restored copilot-instructions.md from backup'); + } + } + } else { + // Write cleaned content back (preserve original whitespace) + await fs.writeFile(instructionsPath, cleaned, 'utf8'); + + // If backup exists, it's stale now — remove it + if (await fs.pathExists(backupPath)) { + await fs.remove(backupPath); + } + } } } diff --git a/tools/cli/installers/lib/ide/manager.js b/tools/cli/installers/lib/ide/manager.js index cb9774307..f83db4592 100644 --- a/tools/cli/installers/lib/ide/manager.js +++ b/tools/cli/installers/lib/ide/manager.js @@ -216,13 +216,14 @@ class IdeManager { /** * Cleanup IDE configurations * @param {string} projectDir - Project directory + * @param {Object} [options] - Cleanup options passed through to handlers */ - async cleanup(projectDir) { + async cleanup(projectDir, options = {}) { const results = []; for (const [name, handler] of this.handlers) { try { - await handler.cleanup(projectDir); + await handler.cleanup(projectDir, options); results.push({ ide: name, success: true }); } catch (error) { results.push({ ide: name, success: false, error: error.message }); @@ -232,6 +233,40 @@ class IdeManager { return results; } + /** + * Cleanup only the IDEs in the provided list + * Falls back to cleanup() (all handlers) if ideList is empty or undefined + * @param {string} projectDir - Project directory + * @param {Array} ideList - List of IDE names to clean up + * @param {Object} [options] - Cleanup options passed through to handlers + * @returns {Array} Results array + */ + async cleanupByList(projectDir, ideList, options = {}) { + if (!ideList || ideList.length === 0) { + return this.cleanup(projectDir, options); + } + + await this.ensureInitialized(); + const results = []; + + // Build lowercase lookup for case-insensitive matching + const lowercaseHandlers = new Map([...this.handlers.entries()].map(([k, v]) => [k.toLowerCase(), v])); + + for (const ideName of ideList) { + const handler = lowercaseHandlers.get(ideName.toLowerCase()); + if (!handler) continue; + + try { + await handler.cleanup(projectDir, options); + results.push({ ide: ideName, success: true }); + } catch (error) { + results.push({ ide: ideName, success: false, error: error.message }); + } + } + + return results; + } + /** * Get list of supported IDEs * @returns {Array} List of supported IDE names