From 331a67eeb3ddb9864e4fb17836a528e15b865b9d Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Wed, 26 Nov 2025 16:47:15 -0600 Subject: [PATCH] installer allows cleanup of unneeded files in upgrades --- tools/cli/commands/cleanup.js | 141 ++++++++ tools/cli/commands/install.js | 9 +- tools/cli/installers/lib/core/installer.js | 375 ++++++++++++++++++++- 3 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 tools/cli/commands/cleanup.js diff --git a/tools/cli/commands/cleanup.js b/tools/cli/commands/cleanup.js new file mode 100644 index 00000000..5dae8e5d --- /dev/null +++ b/tools/cli/commands/cleanup.js @@ -0,0 +1,141 @@ +const chalk = require('chalk'); +const nodePath = require('node:path'); +const { Installer } = require('../installers/lib/core/installer'); + +module.exports = { + command: 'cleanup', + description: 'Clean up obsolete files from BMAD installation', + options: [ + ['-d, --dry-run', 'Show what would be deleted without actually deleting'], + ['-a, --auto-delete', 'Automatically delete non-retained files without prompts'], + ['-l, --list-retained', 'List currently retained files'], + ['-c, --clear-retained', 'Clear retained files list'], + ], + action: async (options) => { + try { + // Create installer and let it find the BMAD directory + const installer = new Installer(); + const bmadDir = await installer.findBmadDir(process.cwd()); + + if (!bmadDir) { + console.error(chalk.red('❌ BMAD installation not found')); + process.exit(1); + } + + const retentionPath = nodePath.join(bmadDir, '_cfg', 'user-retained-files.yaml'); + + // Handle list-retained option + if (options.listRetained) { + const fs = require('fs-extra'); + const yaml = require('js-yaml'); + + if (await fs.pathExists(retentionPath)) { + const retentionContent = await fs.readFile(retentionPath, 'utf8'); + const retentionData = yaml.load(retentionContent) || { retainedFiles: [] }; + + if (retentionData.retainedFiles.length > 0) { + console.log(chalk.cyan('\n📋 Retained Files:\n')); + for (const file of retentionData.retainedFiles) { + console.log(chalk.dim(` - ${file}`)); + } + console.log(); + } else { + console.log(chalk.yellow('\n✨ No retained files found\n')); + } + } else { + console.log(chalk.yellow('\n✨ No retained files found\n')); + } + + return; + } + + // Handle clear-retained option + if (options.clearRetained) { + const fs = require('fs-extra'); + + if (await fs.pathExists(retentionPath)) { + await fs.remove(retentionPath); + console.log(chalk.green('\n✅ Cleared retained files list\n')); + } else { + console.log(chalk.yellow('\n✨ No retained files list to clear\n')); + } + + return; + } + + // Handle cleanup operations + if (options.dryRun) { + console.log(chalk.cyan('\n🔍 Legacy File Scan (Dry Run)\n')); + + const legacyFiles = await installer.scanForLegacyFiles(bmadDir); + const allLegacyFiles = [ + ...legacyFiles.backup, + ...legacyFiles.documentation, + ...legacyFiles.deprecated_task, + ...legacyFiles.unknown, + ]; + + if (allLegacyFiles.length === 0) { + console.log(chalk.green('✨ No legacy files found\n')); + return; + } + + // Group files by category + const categories = []; + if (legacyFiles.backup.length > 0) { + categories.push({ name: 'Backup Files (.bak)', files: legacyFiles.backup }); + } + if (legacyFiles.documentation.length > 0) { + categories.push({ name: 'Documentation', files: legacyFiles.documentation }); + } + if (legacyFiles.deprecated_task.length > 0) { + categories.push({ name: 'Deprecated Task Files', files: legacyFiles.deprecated_task }); + } + if (legacyFiles.unknown.length > 0) { + categories.push({ name: 'Unknown Files', files: legacyFiles.unknown }); + } + + for (const category of categories) { + console.log(chalk.yellow(`${category.name}:`)); + for (const file of category.files) { + const size = (file.size / 1024).toFixed(1); + const date = file.mtime.toLocaleDateString(); + let line = ` - ${file.relativePath} (${size}KB, ${date})`; + if (file.suggestedAlternative) { + line += chalk.dim(` → ${file.suggestedAlternative}`); + } + console.log(chalk.dim(line)); + } + console.log(); + } + + console.log(chalk.cyan(`Found ${allLegacyFiles.length} legacy file(s) that could be cleaned up.\n`)); + console.log(chalk.dim('Run "bmad cleanup" to actually delete these files.\n')); + + return; + } + + // Perform actual cleanup + console.log(chalk.cyan('\n🧹 Cleaning up legacy files...\n')); + + const result = await installer.performCleanup(bmadDir, options.autoDelete); + + if (result.message) { + console.log(chalk.dim(result.message)); + } else { + if (result.deleted > 0) { + console.log(chalk.green(`✅ Deleted ${result.deleted} legacy file(s)`)); + } + if (result.retained > 0) { + console.log(chalk.yellow(`⏭️ Retained ${result.retained} file(s)`)); + console.log(chalk.dim('Run "bmad cleanup --list-retained" to see retained files\n')); + } + } + + console.log(); + } catch (error) { + console.error(chalk.red(`❌ Error: ${error.message}`)); + process.exit(1); + } + }, +}; diff --git a/tools/cli/commands/install.js b/tools/cli/commands/install.js index a9d484d5..e7725338 100644 --- a/tools/cli/commands/install.js +++ b/tools/cli/commands/install.js @@ -9,8 +9,8 @@ const ui = new UI(); module.exports = { command: 'install', description: 'Install BMAD Core agents and tools', - options: [], - action: async () => { + options: [['--skip-cleanup', 'Skip automatic cleanup of legacy files']], + action: async (options) => { try { const config = await ui.promptInstall(); @@ -44,6 +44,11 @@ module.exports = { config._requestedReinstall = true; } + // Add skip cleanup flag if option provided + if (options && options.skipCleanup) { + config.skipCleanup = true; + } + // Regular install/update flow const result = await installer.install(config); diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 8539ebbe..6e6fbab9 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -1018,6 +1018,23 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: customFiles: customFiles.length > 0 ? customFiles : undefined, }); + // Offer cleanup for legacy files (only for updates, not fresh installs, and only if not skipped) + if (!config.skipCleanup && config._isUpdate) { + try { + const cleanupResult = await this.performCleanup(bmadDir, false); + if (cleanupResult.deleted > 0) { + console.log(chalk.green(`\n✓ Cleaned up ${cleanupResult.deleted} legacy file${cleanupResult.deleted > 1 ? 's' : ''}`)); + } + if (cleanupResult.retained > 0) { + console.log(chalk.dim(`Run 'bmad cleanup' anytime to manage retained files`)); + } + } catch (cleanupError) { + // Don't fail the installation for cleanup errors + console.log(chalk.yellow(`\n⚠️ Cleanup warning: ${cleanupError.message}`)); + console.log(chalk.dim('Run "bmad cleanup" to manually clean up legacy files')); + } + } + return { success: true, path: bmadDir, @@ -1939,7 +1956,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: if (existingBmadFolderName === newBmadFolderName) { // Normal quick update - start the spinner - spinner.start('Updating BMAD installation...'); + console.log(chalk.cyan('Updating BMAD installation...')); } else { // Folder name has changed - stop spinner and let install() handle it spinner.stop(); @@ -2578,6 +2595,362 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: } } } + + /** + * Scan for legacy/obsolete files in BMAD installation + * @param {string} bmadDir - BMAD installation directory + * @returns {Object} Categorized files for cleanup + */ + async scanForLegacyFiles(bmadDir) { + const legacyFiles = { + backup: [], + documentation: [], + deprecated_task: [], + unknown: [], + }; + + try { + // Load files manifest to understand what should exist + const manifestPath = path.join(bmadDir, 'files-manifest.csv'); + const manifestFiles = new Set(); + + if (await fs.pathExists(manifestPath)) { + const manifestContent = await fs.readFile(manifestPath, 'utf8'); + const lines = manifestContent.split('\n').slice(1); // Skip header + for (const line of lines) { + if (line.trim()) { + const relativePath = line.split(',')[0]; + if (relativePath) { + manifestFiles.add(relativePath); + } + } + } + } + + // Scan all files recursively + const allFiles = await this.getAllFiles(bmadDir); + + for (const filePath of allFiles) { + const relativePath = path.relative(bmadDir, filePath); + + // Skip expected files + if (this.isExpectedFile(relativePath, manifestFiles)) { + continue; + } + + // Categorize legacy files + if (relativePath.endsWith('.bak')) { + legacyFiles.backup.push({ + path: filePath, + relativePath: relativePath, + size: (await fs.stat(filePath)).size, + mtime: (await fs.stat(filePath)).mtime, + }); + } else if (this.isDocumentationFile(relativePath)) { + legacyFiles.documentation.push({ + path: filePath, + relativePath: relativePath, + size: (await fs.stat(filePath)).size, + mtime: (await fs.stat(filePath)).mtime, + }); + } else if (this.isDeprecatedTaskFile(relativePath)) { + const suggestedAlternative = this.suggestAlternative(relativePath); + legacyFiles.deprecated_task.push({ + path: filePath, + relativePath: relativePath, + size: (await fs.stat(filePath)).size, + mtime: (await fs.stat(filePath)).mtime, + suggestedAlternative, + }); + } else { + legacyFiles.unknown.push({ + path: filePath, + relativePath: relativePath, + size: (await fs.stat(filePath)).size, + mtime: (await fs.stat(filePath)).mtime, + }); + } + } + } catch (error) { + console.warn(`Warning: Could not scan for legacy files: ${error.message}`); + } + + return legacyFiles; + } + + /** + * Get all files in directory recursively + * @param {string} dir - Directory to scan + * @returns {Array} Array of file paths + */ + async getAllFiles(dir) { + const files = []; + + async function scan(currentDir) { + const entries = await fs.readdir(currentDir); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry); + const stat = await fs.stat(fullPath); + + if (stat.isDirectory()) { + // Skip certain directories + if (!['node_modules', '.git', 'dist', 'build'].includes(entry)) { + await scan(fullPath); + } + } else { + files.push(fullPath); + } + } + } + + await scan(dir); + return files; + } + + /** + * Check if file is expected in installation + * @param {string} relativePath - Relative path from BMAD dir + * @param {Set} manifestFiles - Files from manifest + * @returns {boolean} True if expected file + */ + isExpectedFile(relativePath, manifestFiles) { + // Core files in manifest + if (manifestFiles.has(relativePath)) { + return true; + } + + // Configuration files + if (relativePath.startsWith('_cfg/') || relativePath === 'config.yaml') { + return true; + } + + // Custom files + if (relativePath.startsWith('custom/') || relativePath === 'manifest.yaml') { + return true; + } + + // Generated files + if (relativePath === 'manifest.csv' || relativePath === 'files-manifest.csv') { + return true; + } + + // IDE-specific files + const ides = ['vscode', 'cursor', 'windsurf', 'claude-code', 'github-copilot', 'zsh', 'bash', 'fish']; + if (ides.some((ide) => relativePath.includes(ide))) { + return true; + } + + // BMAD MODULE STRUCTURES - recognize valid module content + const modulePrefixes = ['bmb/', 'bmm/', 'cis/', 'core/', 'bmgd/']; + const validExtensions = ['.yaml', '.yml', '.json', '.csv', '.md', '.xml', '.svg', '.png', '.jpg', '.gif', '.excalidraw', '.js']; + + // Check if this file is in a recognized module directory + for (const modulePrefix of modulePrefixes) { + if (relativePath.startsWith(modulePrefix)) { + // Check if it has a valid extension + const hasValidExtension = validExtensions.some((ext) => relativePath.endsWith(ext)); + if (hasValidExtension) { + return true; + } + } + } + + // Special case for core module resources + if (relativePath.startsWith('core/resources/')) { + return true; + } + + // Special case for docs directory + if (relativePath.startsWith('docs/')) { + return true; + } + + return false; + } + + /** + * Check if file is documentation + * @param {string} relativePath - Relative path + * @returns {boolean} True if documentation + */ + isDocumentationFile(relativePath) { + const docExtensions = ['.md', '.txt', '.pdf']; + const docPatterns = ['docs/', 'README', 'CHANGELOG', 'LICENSE']; + + return docExtensions.some((ext) => relativePath.endsWith(ext)) || docPatterns.some((pattern) => relativePath.includes(pattern)); + } + + /** + * Check if file is deprecated task file + * @param {string} relativePath - Relative path + * @returns {boolean} True if deprecated + */ + isDeprecatedTaskFile(relativePath) { + // Known deprecated files + const deprecatedFiles = ['adv-elicit-methods.csv', 'game-resources.json', 'ux-workflow.json']; + + return deprecatedFiles.some((dep) => relativePath.includes(dep)); + } + + /** + * Suggest alternative for deprecated file + * @param {string} relativePath - Deprecated file path + * @returns {string} Suggested alternative + */ + suggestAlternative(relativePath) { + const alternatives = { + 'adv-elicit-methods.csv': 'Use the new structured workflows in src/modules/', + 'game-resources.json': 'Resources are now integrated into modules', + 'ux-workflow.json': 'UX workflows are now in src/modules/bmm/workflows/', + }; + + for (const [deprecated, alternative] of Object.entries(alternatives)) { + if (relativePath.includes(deprecated)) { + return alternative; + } + } + + return 'Check src/modules/ for new alternatives'; + } + + /** + * Perform interactive cleanup of legacy files + * @param {string} bmadDir - BMAD installation directory + * @param {boolean} skipInteractive - Skip interactive prompts + * @returns {Object} Cleanup results + */ + async performCleanup(bmadDir, skipInteractive = false) { + const inquirer = require('inquirer'); + const yaml = require('js-yaml'); + + // Load user retention preferences + const retentionPath = path.join(bmadDir, '_cfg', 'user-retained-files.yaml'); + let retentionData = { retainedFiles: [], history: [] }; + + if (await fs.pathExists(retentionPath)) { + const retentionContent = await fs.readFile(retentionPath, 'utf8'); + retentionData = yaml.load(retentionContent) || retentionData; + } + + // Scan for legacy files + const legacyFiles = await this.scanForLegacyFiles(bmadDir); + const allLegacyFiles = [...legacyFiles.backup, ...legacyFiles.documentation, ...legacyFiles.deprecated_task, ...legacyFiles.unknown]; + + if (allLegacyFiles.length === 0) { + return { deleted: 0, retained: 0, message: 'No legacy files found' }; + } + + let deletedCount = 0; + let retainedCount = 0; + const filesToDelete = []; + + if (skipInteractive) { + // Auto-delete all non-retained files + for (const file of allLegacyFiles) { + if (!retentionData.retainedFiles.includes(file.relativePath)) { + filesToDelete.push(file); + } + } + } else { + // Interactive cleanup + console.log(chalk.cyan('\n🧹 Legacy File Cleanup\n')); + console.log(chalk.dim('The following obsolete files were found:\n')); + + // Group files by category + const categories = []; + if (legacyFiles.backup.length > 0) { + categories.push({ name: 'Backup Files (.bak)', files: legacyFiles.backup }); + } + if (legacyFiles.documentation.length > 0) { + categories.push({ name: 'Documentation', files: legacyFiles.documentation }); + } + if (legacyFiles.deprecated_task.length > 0) { + categories.push({ name: 'Deprecated Task Files', files: legacyFiles.deprecated_task }); + } + if (legacyFiles.unknown.length > 0) { + categories.push({ name: 'Unknown Files', files: legacyFiles.unknown }); + } + + for (const category of categories) { + console.log(chalk.yellow(`${category.name}:`)); + for (const file of category.files) { + const size = (file.size / 1024).toFixed(1); + const date = file.mtime.toLocaleDateString(); + let line = ` - ${file.relativePath} (${size}KB, ${date})`; + if (file.suggestedAlternative) { + line += chalk.dim(` → ${file.suggestedAlternative}`); + } + console.log(chalk.dim(line)); + } + console.log(); + } + + const prompt = await inquirer.prompt([ + { + type: 'confirm', + name: 'proceed', + message: 'Would you like to review these files for cleanup?', + default: true, + }, + ]); + + if (!prompt.proceed) { + return { deleted: 0, retained: allLegacyFiles.length, message: 'Cleanup cancelled by user' }; + } + + // Show selection interface + const selectionPrompt = await inquirer.prompt([ + { + type: 'checkbox', + name: 'filesToDelete', + message: 'Select files to delete (use SPACEBAR to select, ENTER to continue):', + choices: allLegacyFiles.map((file) => { + const isRetained = retentionData.retainedFiles.includes(file.relativePath); + const description = `${file.relativePath} (${(file.size / 1024).toFixed(1)}KB)`; + return { + name: description, + value: file, + checked: !isRetained && !file.relativePath.includes('.bak'), + }; + }), + pageSize: Math.min(allLegacyFiles.length, 15), + }, + ]); + + filesToDelete.push(...selectionPrompt.filesToDelete); + } + + // Delete selected files + for (const file of filesToDelete) { + try { + await fs.remove(file.path); + deletedCount++; + } catch (error) { + console.warn(`Warning: Could not delete ${file.relativePath}: ${error.message}`); + } + } + + // Count retained files + retainedCount = allLegacyFiles.length - deletedCount; + + // Update retention data + const newlyRetained = allLegacyFiles.filter((f) => !filesToDelete.includes(f)).map((f) => f.relativePath); + + retentionData.retainedFiles = [...new Set([...retentionData.retainedFiles, ...newlyRetained])]; + retentionData.history.push({ + date: new Date().toISOString(), + deleted: deletedCount, + retained: retainedCount, + files: filesToDelete.map((f) => f.relativePath), + }); + + // Save retention data + await fs.ensureDir(path.dirname(retentionPath)); + await fs.writeFile(retentionPath, yaml.dump(retentionData), 'utf8'); + + return { deleted: deletedCount, retained: retainedCount }; + } } module.exports = { Installer };