diff --git a/tools/cli/commands/uninstall.js b/tools/cli/commands/uninstall.js index 99734791e..177dae27d 100644 --- a/tools/cli/commands/uninstall.js +++ b/tools/cli/commands/uninstall.js @@ -63,8 +63,8 @@ module.exports = { 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 modules = existingInstall.moduleIds.join(', '); + const ides = existingInstall.ides.join(', '); const outputFolder = await installer.getOutputFolder(projectDir); diff --git a/tools/cli/installers/lib/core/detector.js b/tools/cli/installers/lib/core/detector.js deleted file mode 100644 index 9bb736589..000000000 --- a/tools/cli/installers/lib/core/detector.js +++ /dev/null @@ -1,223 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const yaml = require('yaml'); -const { Manifest } = require('./manifest'); - -class Detector { - /** - * Detect existing BMAD installation - * @param {string} bmadDir - Path to bmad directory - * @returns {Object} Installation status and details - */ - async detect(bmadDir) { - const result = { - installed: false, - path: bmadDir, - version: null, - hasCore: false, - modules: [], - ides: [], - customModules: [], - manifest: null, - }; - - // Check if bmad directory exists - if (!(await fs.pathExists(bmadDir))) { - return result; - } - - // Check for manifest using the Manifest class - const manifest = new Manifest(); - const manifestData = await manifest.read(bmadDir); - if (manifestData) { - result.manifest = manifestData; - result.version = manifestData.version; - result.installed = true; - // Copy custom modules if they exist - if (manifestData.customModules) { - result.customModules = manifestData.customModules; - } - } - - // Check for core - const corePath = path.join(bmadDir, 'core'); - if (await fs.pathExists(corePath)) { - result.hasCore = true; - - // Try to get core version from config - const coreConfigPath = path.join(corePath, 'config.yaml'); - if (await fs.pathExists(coreConfigPath)) { - try { - const configContent = await fs.readFile(coreConfigPath, 'utf8'); - const config = yaml.parse(configContent); - if (!result.version && config.version) { - result.version = config.version; - } - } catch { - // Ignore config read errors - } - } - } - - // Check for modules - // If manifest exists, use it as the source of truth for installed modules - // Otherwise fall back to directory scanning (legacy installations) - if (manifestData && manifestData.modules && manifestData.modules.length > 0) { - // Use manifest module list - these are officially installed modules - for (const moduleId of manifestData.modules) { - const modulePath = path.join(bmadDir, moduleId); - const moduleConfigPath = path.join(modulePath, 'config.yaml'); - - const moduleInfo = { - id: moduleId, - path: modulePath, - version: 'unknown', - }; - - if (await fs.pathExists(moduleConfigPath)) { - try { - const configContent = await fs.readFile(moduleConfigPath, 'utf8'); - const config = yaml.parse(configContent); - moduleInfo.version = config.version || 'unknown'; - moduleInfo.name = config.name || moduleId; - moduleInfo.description = config.description; - } catch { - // Ignore config read errors - } - } - - result.modules.push(moduleInfo); - } - } else { - // Fallback: scan directory for modules (legacy installations without manifest) - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config') { - const modulePath = path.join(bmadDir, entry.name); - const moduleConfigPath = path.join(modulePath, 'config.yaml'); - - // Only treat it as a module if it has a config.yaml - if (await fs.pathExists(moduleConfigPath)) { - const moduleInfo = { - id: entry.name, - path: modulePath, - version: 'unknown', - }; - - try { - const configContent = await fs.readFile(moduleConfigPath, 'utf8'); - const config = yaml.parse(configContent); - moduleInfo.version = config.version || 'unknown'; - moduleInfo.name = config.name || entry.name; - moduleInfo.description = config.description; - } catch { - // Ignore config read errors - } - - result.modules.push(moduleInfo); - } - } - } - } - - // Check for IDE configurations from manifest - if (result.manifest && result.manifest.ides) { - // Filter out any undefined/null values - result.ides = result.manifest.ides.filter((ide) => ide && typeof ide === 'string'); - } - - // Mark as installed if we found core or modules - if (result.hasCore || result.modules.length > 0) { - result.installed = true; - } - - return result; - } - - /** - * Detect legacy installation (_bmad-method, .bmm, .cis) - * @param {string} projectDir - Project directory to check - * @returns {Object} Legacy installation details - */ - async detectLegacy(projectDir) { - const result = { - hasLegacy: false, - legacyCore: false, - legacyModules: [], - paths: [], - }; - - // Check for legacy core (_bmad-method) - const legacyCorePath = path.join(projectDir, '_bmad-method'); - if (await fs.pathExists(legacyCorePath)) { - result.hasLegacy = true; - result.legacyCore = true; - result.paths.push(legacyCorePath); - } - - // Check for legacy modules (directories starting with .) - const entries = await fs.readdir(projectDir, { withFileTypes: true }); - for (const entry of entries) { - if ( - entry.isDirectory() && - entry.name.startsWith('.') && - entry.name !== '_bmad-method' && - !entry.name.startsWith('.git') && - !entry.name.startsWith('.vscode') && - !entry.name.startsWith('.idea') - ) { - const modulePath = path.join(projectDir, entry.name); - const moduleManifestPath = path.join(modulePath, 'install-manifest.yaml'); - - // Check if it's likely a BMAD module - if ((await fs.pathExists(moduleManifestPath)) || (await fs.pathExists(path.join(modulePath, 'config.yaml')))) { - result.hasLegacy = true; - result.legacyModules.push({ - name: entry.name.slice(1), // Remove leading dot - path: modulePath, - }); - result.paths.push(modulePath); - } - } - } - - return result; - } - - /** - * Check if migration from legacy is needed - * @param {string} projectDir - Project directory - * @returns {Object} Migration requirements - */ - async checkMigrationNeeded(projectDir) { - const bmadDir = path.join(projectDir, 'bmad'); - const current = await this.detect(bmadDir); - const legacy = await this.detectLegacy(projectDir); - - return { - needed: legacy.hasLegacy && !current.installed, - canMigrate: legacy.hasLegacy, - legacy: legacy, - current: current, - }; - } - - /** - * Detect legacy BMAD v4 .bmad-method folder - * @param {string} projectDir - Project directory to check - * @returns {{ hasLegacyV4: boolean, offenders: string[] }} - */ - async detectLegacyV4(projectDir) { - const offenders = []; - - // Check for .bmad-method folder - const bmadMethodPath = path.join(projectDir, '.bmad-method'); - if (await fs.pathExists(bmadMethodPath)) { - offenders.push(bmadMethodPath); - } - - return { hasLegacyV4: offenders.length > 0, offenders }; - } -} - -module.exports = { Detector }; diff --git a/tools/cli/installers/lib/core/existing-install.js b/tools/cli/installers/lib/core/existing-install.js new file mode 100644 index 000000000..8e86f4b03 --- /dev/null +++ b/tools/cli/installers/lib/core/existing-install.js @@ -0,0 +1,127 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const yaml = require('yaml'); +const { Manifest } = require('./manifest'); + +/** + * Immutable snapshot of an existing BMAD installation. + * Pure query object — no filesystem operations after construction. + */ +class ExistingInstall { + #version; + + constructor({ installed, version, hasCore, modules, ides, customModules }) { + this.installed = installed; + this.#version = version; + this.hasCore = hasCore; + this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m }))); + this.moduleIds = Object.freeze(this.modules.map((m) => m.id)); + this.ides = Object.freeze([...ides]); + this.customModules = Object.freeze([...customModules]); + Object.freeze(this); + } + + get version() { + if (!this.installed) { + throw new Error('version is not available when nothing is installed'); + } + return this.#version; + } + + static empty() { + return new ExistingInstall({ + installed: false, + version: null, + hasCore: false, + modules: [], + ides: [], + customModules: [], + }); + } + + /** + * Scan a bmad directory and return an immutable snapshot of what's installed. + * @param {string} bmadDir - Path to bmad directory + * @returns {Promise} + */ + static async detect(bmadDir) { + if (!(await fs.pathExists(bmadDir))) { + return ExistingInstall.empty(); + } + + let version = null; + let hasCore = false; + const modules = []; + let ides = []; + let customModules = []; + + const manifest = new Manifest(); + const manifestData = await manifest.read(bmadDir); + if (manifestData) { + version = manifestData.version; + if (manifestData.customModules) { + customModules = manifestData.customModules; + } + if (manifestData.ides) { + ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string'); + } + } + + const corePath = path.join(bmadDir, 'core'); + if (await fs.pathExists(corePath)) { + hasCore = true; + + if (!version) { + const coreConfigPath = path.join(corePath, 'config.yaml'); + if (await fs.pathExists(coreConfigPath)) { + try { + const configContent = await fs.readFile(coreConfigPath, 'utf8'); + const config = yaml.parse(configContent); + if (config.version) { + version = config.version; + } + } catch { + // Ignore config read errors + } + } + } + } + + if (manifestData && manifestData.modules && manifestData.modules.length > 0) { + for (const moduleId of manifestData.modules) { + const modulePath = path.join(bmadDir, moduleId); + const moduleConfigPath = path.join(modulePath, 'config.yaml'); + + const moduleInfo = { + id: moduleId, + path: modulePath, + version: 'unknown', + }; + + if (await fs.pathExists(moduleConfigPath)) { + try { + const configContent = await fs.readFile(moduleConfigPath, 'utf8'); + const config = yaml.parse(configContent); + moduleInfo.version = config.version || 'unknown'; + moduleInfo.name = config.name || moduleId; + moduleInfo.description = config.description; + } catch { + // Ignore config read errors + } + } + + modules.push(moduleInfo); + } + } + + const installed = hasCore || modules.length > 0 || !!manifestData; + + if (!installed) { + return ExistingInstall.empty(); + } + + return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules }); + } +} + +module.exports = { ExistingInstall }; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 2bf68cec2..58fcab38c 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -1,12 +1,11 @@ const path = require('node:path'); const fs = require('fs-extra'); -const { Detector } = require('./detector'); const { Manifest } = require('./manifest'); const { OfficialModules } = require('../modules/official-modules'); const { CustomModules } = require('../modules/custom-modules'); const { IdeManager } = require('../ide/manager'); const { FileOps } = require('../../../lib/file-ops'); -const { Config } = require('../../../lib/config'); +const { Config } = require('./config'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { ManifestGenerator } = require('./manifest-generator'); const { IdeConfigManager } = require('./ide-config-manager'); @@ -16,16 +15,15 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); const { InstallPaths } = require('./install-paths'); const { ExternalModuleManager } = require('../modules/external-manager'); +const { ExistingInstall } = require('./existing-install'); + class Installer { constructor() { this.externalModuleManager = new ExternalModuleManager(); - this.detector = new Detector(); this.manifest = new Manifest(); - this.officialModules = new OfficialModules(); this.customModules = new CustomModules(); this.ideManager = new IdeManager(); this.fileOps = new FileOps(); - this.config = new Config(); this.ideConfigManager = new IdeConfigManager(); this.installedFiles = new Set(); // Track all installed files this.bmadFolderName = BMAD_FOLDER_NAME; @@ -39,26 +37,22 @@ class Installer { * @param {string[]} config.ides - IDEs to configure */ async install(originalConfig) { - const config = this._buildConfig(originalConfig); - - // Everything else — custom modules, quick-update state, the whole mess const customConfig = { ...originalConfig }; - const paths = await InstallPaths.create(config); - - // Collect configurations for official modules - await this.officialModules.collectConfigs(config, paths); - - await this.customModules.discoverPaths(config, paths); - try { - const existingInstall = await this.detector.detect(paths.bmadDir); + const config = Config.build(originalConfig); + const paths = await InstallPaths.create(config); + const officialModules = await OfficialModules.build(config, paths); + + const existingInstall = await ExistingInstall.detect(paths.bmadDir); + + await this.customModules.discoverPaths(config, paths); if (existingInstall.installed && !config.force) { if (!config.isQuickUpdate()) { await this._removeDeselectedModules(existingInstall, config, paths); } - await this._prepareUpdateState(paths, config, customConfig, existingInstall); + await this._prepareUpdateState(paths, config, customConfig, existingInstall, officialModules); } const ideConfigurations = await this._loadIdeConfigurations(config, customConfig, paths); @@ -74,8 +68,8 @@ class Installer { await this._cacheCustomModules(paths, addResult); - const { officialModules, allModules } = await this._buildModuleLists(config, customConfig, paths); - await this._installAndConfigure(config, customConfig, paths, officialModules, allModules, addResult); + const { officialModuleIds, allModules } = await this._buildModuleLists(config, customConfig, paths); + await this._installAndConfigure(config, customConfig, paths, officialModuleIds, allModules, addResult, officialModules); await this._setupIdes(config, ideConfigurations, allModules, paths, addResult); @@ -123,7 +117,7 @@ class Installer { * No confirmation — the user's module selection is the decision. */ async _removeDeselectedModules(existingInstall, config, paths) { - const previouslyInstalled = new Set(existingInstall.modules.map((m) => m.id)); + const previouslyInstalled = new Set(existingInstall.moduleIds); const newlySelected = new Set(config.modules || []); const toRemove = [...previouslyInstalled].filter((m) => !newlySelected.has(m) && m !== 'core'); @@ -156,10 +150,8 @@ class Installer { const bmadDir = paths.bmadDir; const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); - const { Detector } = require('./detector'); - const detector = new Detector(); - const existingInstall = await detector.detect(bmadDir); - const previouslyConfigured = existingInstall.ides || []; + const existingInstall = await ExistingInstall.detect(bmadDir); + const previouslyConfigured = existingInstall.ides; for (const ide of config.ides || []) { if (previouslyConfigured.includes(ide) && savedIdeConfigs[ide]) { @@ -201,7 +193,7 @@ class Installer { * No confirmation — the user's IDE selection is the decision. */ async _removeDeselectedIdes(existingInstall, config, ideConfigurations, paths) { - const previouslyInstalled = new Set(existingInstall.ides || []); + const previouslyInstalled = new Set(existingInstall.ides); const newlySelected = new Set(config.ides || []); const toRemove = [...previouslyInstalled].filter((ide) => !newlySelected.has(ide)); @@ -243,7 +235,7 @@ class Installer { /** * Build the official and combined module lists from config and custom sources. - * @returns {{ officialModules: string[], allModules: string[] }} + * @returns {{ officialModuleIds: string[], allModules: string[] }} */ async _buildModuleLists(config, customConfig, paths) { const finalCustomContent = customConfig.customContent; @@ -274,25 +266,25 @@ class Installer { } } - const officialModules = (config.modules || []).filter((m) => !customModuleIds.has(m)); + const officialModuleIds = (config.modules || []).filter((m) => !customModuleIds.has(m)); - const allModules = [...officialModules]; + const allModules = [...officialModuleIds]; for (const id of customModuleIds) { if (!allModules.includes(id)) { allModules.push(id); } } - return { officialModules, allModules }; + return { officialModuleIds, allModules }; } /** * Install modules, create directories, generate configs and manifests. */ - async _installAndConfigure(config, customConfig, paths, officialModules, allModules, addResult) { + async _installAndConfigure(config, customConfig, paths, officialModuleIds, allModules, addResult, officialModules) { const isQuickUpdate = config.isQuickUpdate(); const finalCustomContent = customConfig.customContent; - const moduleConfigs = this.officialModules.moduleConfigs; + const moduleConfigs = officialModules.moduleConfigs; const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; @@ -304,12 +296,12 @@ class Installer { task: async (message) => { const installedModuleNames = new Set(); - await this._installOfficialModules(config, paths, officialModules, addResult, isQuickUpdate, { + await this._installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, { message, installedModuleNames, }); - await this._installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, { + await this._installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, officialModules, { message, installedModuleNames, }); @@ -332,10 +324,10 @@ class Installer { if (config.modules && config.modules.length > 0) { for (const moduleName of config.modules) { message(`Setting up ${moduleName}...`); - const result = await this.officialModules.createModuleDirectories(moduleName, paths.bmadDir, { + const result = await officialModules.createModuleDirectories(moduleName, paths.bmadDir, { installedIDEs: config.ides || [], moduleConfig: moduleConfigs[moduleName] || {}, - existingModuleConfig: this.officialModules.existingConfig?.[moduleName] || {}, + existingModuleConfig: officialModules.existingConfig?.[moduleName] || {}, coreConfig: moduleConfigs.core || {}, logger: moduleLogger, silent: true, @@ -526,31 +518,6 @@ class Installer { ]); } - _buildConfig(originalConfig) { - const modules = [...(originalConfig.modules || [])]; - if (originalConfig.installCore && !modules.includes('core')) { - modules.unshift('core'); - } - - return { - directory: originalConfig.directory, - modules, - ides: originalConfig.skipIde ? [] : [...(originalConfig.ides || [])], - skipPrompts: originalConfig.skipPrompts || false, - verbose: originalConfig.verbose || false, - force: originalConfig.force || false, - actionType: originalConfig.actionType, - coreConfig: originalConfig.coreConfig || {}, - moduleConfigs: originalConfig.moduleConfigs || null, - hasCoreConfig() { - return this.coreConfig && Object.keys(this.coreConfig).length > 0; - }, - isQuickUpdate() { - return originalConfig._quickUpdate || false; - }, - }; - } - /** * Scan the custom module cache directory and register any cached custom modules * that aren't already known from the manifest or external module list. @@ -600,7 +567,7 @@ class Installer { * @param {Object} customConfig - Full config bag (mutated with update state) * @param {Object} existingInstall - Detection result from detector.detect() */ - async _prepareUpdateState(paths, config, customConfig, existingInstall) { + async _prepareUpdateState(paths, config, customConfig, existingInstall, officialModules) { customConfig._isUpdate = true; customConfig._existingInstall = existingInstall; @@ -622,7 +589,7 @@ class Installer { config.coreConfig = existingCoreConfig; customConfig.coreConfig = existingCoreConfig; - this.officialModules.moduleConfigs.core = existingCoreConfig; + officialModules.moduleConfigs.core = existingCoreConfig; } catch (error) { await prompts.log.warn(`Warning: Could not read existing core config: ${error.message}`); } @@ -678,22 +645,22 @@ class Installer { * Install official (non-custom) modules. * @param {Object} config - Installation configuration * @param {Object} paths - InstallPaths instance - * @param {string[]} officialModules - Official module IDs to install + * @param {string[]} officialModuleIds - Official module IDs to install * @param {Function} addResult - Callback to record installation results * @param {boolean} isQuickUpdate - Whether this is a quick update * @param {Object} ctx - Shared context: { message, installedModuleNames } */ - async _installOfficialModules(config, paths, officialModules, addResult, isQuickUpdate, ctx) { + async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) { const { message, installedModuleNames } = ctx; - for (const moduleName of officialModules) { + for (const moduleName of officialModuleIds) { if (installedModuleNames.has(moduleName)) continue; installedModuleNames.add(moduleName); message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); - const moduleConfig = this.officialModules.moduleConfigs[moduleName] || {}; - await this.officialModules.install( + const moduleConfig = officialModules.moduleConfigs[moduleName] || {}; + await officialModules.install( moduleName, paths.bmadDir, (filePath) => { @@ -720,7 +687,7 @@ class Installer { * @param {boolean} isQuickUpdate - Whether this is a quick update * @param {Object} ctx - Shared context: { message, installedModuleNames } */ - async _installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, ctx) { + async _installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, officialModules, ctx) { const { message, installedModuleNames } = ctx; // Collect all custom module IDs with their info from all sources @@ -776,8 +743,8 @@ class Installer { this.customModules.paths.set(moduleName, customInfo.path); } - const collectedModuleConfig = this.officialModules.moduleConfigs[moduleName] || {}; - await this.officialModules.install( + const collectedModuleConfig = officialModules.moduleConfigs[moduleName] || {}; + await officialModules.install( moduleName, paths.bmadDir, (filePath) => { @@ -834,19 +801,16 @@ class Installer { } // Check for already configured IDEs - const { Detector } = require('./detector'); - const detector = new Detector(); const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); // During full reinstall, use the saved previous IDEs since bmad dir was deleted // Otherwise detect from existing installation let previouslyConfiguredIdes; if (isFullReinstall) { - // During reinstall, treat all IDEs as new (need configuration) previouslyConfiguredIdes = []; } else { - const existingInstall = await detector.detect(bmadDir); - previouslyConfiguredIdes = existingInstall.ides || []; + const existingInstall = await ExistingInstall.detect(bmadDir); + previouslyConfiguredIdes = existingInstall.ides; } // Load saved IDE configurations for already-configured IDEs @@ -1467,9 +1431,9 @@ class Installer { } // Detect existing installation - const existingInstall = await this.detector.detect(bmadDir); - const installedModules = existingInstall.modules.map((m) => m.id); - const configuredIdes = existingInstall.ides || []; + const existingInstall = await ExistingInstall.detect(bmadDir); + const installedModules = existingInstall.moduleIds; + const configuredIdes = existingInstall.ides; const projectRoot = path.dirname(bmadDir); // Get custom module sources: first from --custom-content (re-cache from source), then from cache @@ -1532,7 +1496,7 @@ class Installer { const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); // Get available modules (what we have source for) - const availableModulesData = await this.officialModules.listAvailable(); + const availableModulesData = await new OfficialModules().listAvailable(); const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; // Add external official modules to available modules @@ -1612,19 +1576,20 @@ class Installer { // Load existing configs and collect new fields (if any) await prompts.log.info('Checking for new configuration options...'); - await this.officialModules.loadExistingConfig(projectDir); + const quickModules = new OfficialModules(); + await quickModules.loadExistingConfig(projectDir); let promptedForNewFields = false; // Check core config for new fields - const corePrompted = await this.officialModules.collectModuleConfigQuick('core', projectDir, true); + const corePrompted = await quickModules.collectModuleConfigQuick('core', projectDir, true); if (corePrompted) { promptedForNewFields = true; } // Check each module we're updating for new fields (NOT skipped modules) for (const moduleName of modulesToUpdate) { - const modulePrompted = await this.officialModules.collectModuleConfigQuick(moduleName, projectDir, true); + const modulePrompted = await quickModules.collectModuleConfigQuick(moduleName, projectDir, true); if (modulePrompted) { promptedForNewFields = true; } @@ -1635,7 +1600,7 @@ class Installer { } // Add metadata - this.officialModules.collectedConfig._meta = { + quickModules.collectedConfig._meta = { version: require(path.join(getProjectRoot(), 'package.json')).version, installDate: new Date().toISOString(), lastModified: new Date().toISOString(), @@ -1646,7 +1611,8 @@ class Installer { directory: projectDir, modules: modulesToUpdate, // Only update modules we have source for (includes core) ides: configuredIdes, - coreConfig: this.officialModules.collectedConfig.core, + coreConfig: quickModules.collectedConfig.core, + moduleConfigs: quickModules.collectedConfig, // Pass collected configs so build() picks them up actionType: 'install', // Use regular install flow _quickUpdate: true, // Flag to skip certain prompts _preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them @@ -1679,7 +1645,7 @@ class Installer { try { const projectDir = path.resolve(config.directory); const { bmadDir } = await this.findBmadDir(projectDir); - const existingInstall = await this.detector.detect(bmadDir); + const existingInstall = await ExistingInstall.detect(bmadDir); if (!existingInstall.installed) { throw new Error(`No BMAD installation found at ${bmadDir}`); @@ -1692,11 +1658,8 @@ class Installer { // Check for custom modules with missing sources before update const customModuleSources = new Map(); - // Check manifest for backward compatibility - if (existingInstall.customModules) { - for (const customModule of existingInstall.customModules) { - customModuleSources.set(customModule.id, customModule); - } + for (const customModule of existingInstall.customModules) { + customModuleSources.set(customModule.id, customModule); } // Also check cache directory @@ -1745,7 +1708,7 @@ class Installer { bmadDir, projectRoot, 'update', - existingInstall.modules.map((m) => m.id), + existingInstall.moduleIds, config.skipPrompts || false, ); } @@ -1770,8 +1733,9 @@ class Installer { await this.updateCore(bmadDir, config.force); } + const updateModules = new OfficialModules(); for (const module of existingInstall.modules) { - await this.officialModules.update(module.id, bmadDir, config.force, { installer: this }); + await updateModules.update(module.id, bmadDir, config.force, { installer: this }); } // Update manifest @@ -1791,7 +1755,7 @@ class Installer { */ async updateCore(bmadDir, force = false) { if (force) { - await this.officialModules.install('core', bmadDir, (filePath) => this.installedFiles.add(filePath), { + await new OfficialModules().install('core', bmadDir, (filePath) => this.installedFiles.add(filePath), { skipModuleInstaller: true, silent: true, }); @@ -1821,7 +1785,7 @@ class Installer { } // 1. DETECT: Read state BEFORE deleting anything - const existingInstall = await this.detector.detect(bmadDir); + const existingInstall = await ExistingInstall.detect(bmadDir); const outputFolder = await this._readOutputFolder(bmadDir); const removed = { modules: false, ideConfigs: false, outputFolder: false }; @@ -1855,7 +1819,7 @@ class Installer { async uninstallIdeConfigs(projectDir, existingInstall, options = {}) { await this.ideManager.ensureInitialized(); const cleanupOptions = { isUninstall: true, silent: options.silent }; - const ideList = existingInstall.ides || []; + const ideList = existingInstall.ides; if (ideList.length > 0) { return this.ideManager.cleanupByList(projectDir, ideList, cleanupOptions); } @@ -1902,14 +1866,14 @@ class Installer { async getStatus(directory) { const projectDir = path.resolve(directory); const { bmadDir } = await this.findBmadDir(projectDir); - return await this.detector.detect(bmadDir); + return await ExistingInstall.detect(bmadDir); } /** * Get available modules */ async getAvailableModules() { - return await this.officialModules.listAvailable(); + return await new OfficialModules().listAvailable(); } /** @@ -1923,48 +1887,6 @@ class Installer { return this._readOutputFolder(bmadDir); } - /** - * Handle legacy BMAD v4 detection with simple warning - * @param {string} _projectDir - Project directory (unused in simplified version) - * @param {Object} _legacyV4 - Legacy V4 detection result (unused in simplified version) - */ - async handleLegacyV4Migration(_projectDir, _legacyV4) { - await prompts.note( - 'Found .bmad-method folder from BMAD v4 installation.\n\n' + - 'Before continuing with installation, we recommend:\n' + - ' 1. Remove the .bmad-method folder, OR\n' + - ' 2. Back it up by renaming it to another name (e.g., bmad-method-backup)\n\n' + - 'If your v4 installation set up rules or commands, you should remove those as well.', - 'Legacy BMAD v4 detected', - ); - - const proceed = await prompts.select({ - message: 'What would you like to do?', - choices: [ - { - name: 'Exit and clean up manually (recommended)', - value: 'exit', - hint: 'Exit installation', - }, - { - name: 'Continue with installation anyway', - value: 'continue', - hint: 'Continue', - }, - ], - default: 'exit', - }); - - if (proceed === 'exit') { - await prompts.log.info('Please remove the .bmad-method folder and any v4 rules/commands, then run the installer again.'); - // Allow event loop to flush pending I/O before exit - setImmediate(() => process.exit(0)); - return; - } - - await prompts.log.warn('Proceeding with installation despite legacy v4 folder'); - } - /** * Handle missing custom module sources interactively * @param {Map} customModuleSources - Map of custom module ID to info @@ -2201,29 +2123,12 @@ class Installer { /** * Find the bmad installation directory in a project * Always uses the standard _bmad folder name - * Also checks for legacy _cfg folder for migration * @param {string} projectDir - Project directory - * @returns {Promise} { bmadDir: string, hasLegacyCfg: boolean } + * @returns {Promise} { bmadDir: string } */ async findBmadDir(projectDir) { const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); - - // Check if project directory exists - if (!(await fs.pathExists(projectDir))) { - // Project doesn't exist yet, return default - return { bmadDir, hasLegacyCfg: false }; - } - - // Check for legacy _cfg folder if bmad directory exists - let hasLegacyCfg = false; - if (await fs.pathExists(bmadDir)) { - const legacyCfgPath = path.join(bmadDir, '_cfg'); - if (await fs.pathExists(legacyCfgPath)) { - hasLegacyCfg = true; - } - } - - return { bmadDir, hasLegacyCfg }; + return { bmadDir }; } async createDirectoryStructure(bmadDir) { diff --git a/tools/cli/installers/lib/modules/custom-modules.js b/tools/cli/installers/lib/modules/custom-modules.js index b824db710..8e9b2e876 100644 --- a/tools/cli/installers/lib/modules/custom-modules.js +++ b/tools/cli/installers/lib/modules/custom-modules.js @@ -37,7 +37,7 @@ class CustomModules { } // From manifest (regular updates) - if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) { + if (config._isUpdate && config._existingInstall) { for (const customModule of config._existingInstall.customModules) { let absoluteSourcePath = customModule.sourcePath; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 192d85b58..134078b3b 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -51,125 +51,11 @@ class UI { confirmedDirectory = await this.getConfirmedDirectory(); } - // Preflight: Check for legacy BMAD v4 footprints immediately after getting directory - const { Detector } = require('../installers/lib/core/detector'); const { Installer } = require('../installers/lib/core/installer'); - const detector = new Detector(); const installer = new Installer(); - const legacyV4 = await detector.detectLegacyV4(confirmedDirectory); - if (legacyV4.hasLegacyV4) { - await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4); - } + const { bmadDir } = await installer.findBmadDir(confirmedDirectory); - // Check for legacy folders and prompt for rename before showing any menus - let hasLegacyCfg = false; - let hasLegacyBmadFolder = false; - let bmadDir = null; - let legacyBmadPath = null; - - // First check for legacy .bmad folder (instead of _bmad) - // Only check if directory exists - if (await fs.pathExists(confirmedDirectory)) { - const entries = await fs.readdir(confirmedDirectory, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && (entry.name === '.bmad' || entry.name === 'bmad')) { - hasLegacyBmadFolder = true; - legacyBmadPath = path.join(confirmedDirectory, entry.name); - bmadDir = legacyBmadPath; - - // Check if it has _cfg folder - const cfgPath = path.join(legacyBmadPath, '_cfg'); - if (await fs.pathExists(cfgPath)) { - hasLegacyCfg = true; - } - break; - } - } - } - - // If no .bmad or bmad found, check for current installations _bmad - if (!hasLegacyBmadFolder) { - const bmadResult = await installer.findBmadDir(confirmedDirectory); - bmadDir = bmadResult.bmadDir; - hasLegacyCfg = bmadResult.hasLegacyCfg; - } - - // Handle legacy .bmad or _cfg folder - these are very old (v4 or alpha) - // Show version warning instead of offering conversion - if (hasLegacyBmadFolder || hasLegacyCfg) { - await prompts.log.warn('LEGACY INSTALLATION DETECTED'); - await prompts.note( - 'Found a ".bmad"/"bmad" folder, or a legacy "_cfg" folder under the bmad folder -\n' + - 'this is from an old BMAD version that is out of date for automatic upgrade,\n' + - 'manual intervention required.\n\n' + - 'You have a legacy version installed (v4 or alpha).\n' + - 'Legacy installations may have compatibility issues.\n\n' + - 'For the best experience, we strongly recommend:\n' + - ' 1. Delete your current BMAD installation folder (.bmad or bmad)\n' + - ' 2. Run a fresh installation\n\n' + - 'If you do not want to start fresh, you can attempt to proceed beyond this\n' + - 'point IF you have ensured the bmad folder is named _bmad, and under it there\n' + - 'is a _config folder. If you have a folder under your bmad folder named _cfg,\n' + - 'you would need to rename it _config, and then restart the installer.\n\n' + - 'Benefits of a fresh install:\n' + - ' \u2022 Cleaner configuration without legacy artifacts\n' + - ' \u2022 All new features properly configured\n' + - ' \u2022 Fewer potential conflicts\n\n' + - 'If you have already produced output from an earlier alpha version, you can\n' + - 'still retain those artifacts. After installation, ensure you configured during\n' + - 'install the proper file locations for artifacts depending on the module you\n' + - 'are using, or move the files to the proper locations.', - 'Legacy Installation Detected', - ); - - const proceed = await prompts.select({ - message: 'How would you like to proceed?', - choices: [ - { - name: 'Cancel and do a fresh install (recommended)', - value: 'cancel', - }, - { - name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)', - value: 'proceed', - }, - ], - default: 'cancel', - }); - - if (proceed === 'cancel') { - await prompts.note('1. Delete the existing bmad folder in your project\n' + "2. Run 'bmad install' again", 'To do a fresh install'); - process.exit(0); - return; - } - - const s = await prompts.spinner(); - s.start('Updating folder structure...'); - try { - // Handle .bmad folder - if (hasLegacyBmadFolder) { - const newBmadPath = path.join(confirmedDirectory, '_bmad'); - await fs.move(legacyBmadPath, newBmadPath); - bmadDir = newBmadPath; - s.stop(`Renamed "${path.basename(legacyBmadPath)}" to "_bmad"`); - } - - // Handle _cfg folder (either from .bmad or standalone) - const cfgPath = path.join(bmadDir, '_cfg'); - if (await fs.pathExists(cfgPath)) { - s.start('Renaming configuration folder...'); - const newCfgPath = path.join(bmadDir, '_config'); - await fs.move(cfgPath, newCfgPath); - s.stop('Renamed "_cfg" to "_config"'); - } - } catch (error) { - s.stop('Failed to update folder structure'); - await prompts.log.error(`Error: ${error.message}`); - process.exit(1); - } - } - - // Check if there's an existing BMAD installation (after any folder renames) + // Check if there's an existing BMAD installation const hasExistingInstall = await fs.pathExists(bmadDir); let customContentConfig = { hasCustomContent: false }; @@ -188,15 +74,6 @@ class UI { const currentVersion = require(packageJsonPath).version; const installedVersion = existingInstall.version || 'unknown'; - // Check if version is pre beta - const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options); - - // If user chose to cancel, exit the installer - if (!shouldProceed) { - process.exit(0); - return; - } - // Build menu choices dynamically const choices = []; @@ -575,15 +452,12 @@ class UI { * @returns {Object} Tool configuration */ async promptToolSelection(projectDir, options = {}) { - // Check for existing configured IDEs - use findBmadDir to detect custom folder names - const { Detector } = require('../installers/lib/core/detector'); + const { ExistingInstall } = require('../installers/lib/core/existing-install'); const { Installer } = require('../installers/lib/core/installer'); - const detector = new Detector(); const installer = new Installer(); - const bmadResult = await installer.findBmadDir(projectDir || process.cwd()); - const bmadDir = bmadResult.bmadDir; - const existingInstall = await detector.detect(bmadDir); - const configuredIdes = existingInstall.ides || []; + const { bmadDir } = await installer.findBmadDir(projectDir || process.cwd()); + const existingInstall = await ExistingInstall.detect(bmadDir); + const configuredIdes = existingInstall.ides; // Get IDE manager to fetch available IDEs dynamically const { IdeManager } = require('../installers/lib/ide/manager'); @@ -816,14 +690,12 @@ class UI { * @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir */ async getExistingInstallation(directory) { - const { Detector } = require('../installers/lib/core/detector'); + const { ExistingInstall } = require('../installers/lib/core/existing-install'); const { Installer } = require('../installers/lib/core/installer'); - const detector = new Detector(); const installer = new Installer(); - const bmadDirResult = await installer.findBmadDir(directory); - const bmadDir = bmadDirResult.bmadDir; - const existingInstall = await detector.detect(bmadDir); - const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id)); + const { bmadDir } = await installer.findBmadDir(directory); + const existingInstall = await ExistingInstall.detect(bmadDir); + const installedModuleIds = new Set(existingInstall.moduleIds); return { existingInstall, installedModuleIds, bmadDir }; } @@ -1393,13 +1265,12 @@ class UI { * @returns {Array} List of configured IDEs */ async getConfiguredIdes(directory) { - const { Detector } = require('../installers/lib/core/detector'); + const { ExistingInstall } = require('../installers/lib/core/existing-install'); const { Installer } = require('../installers/lib/core/installer'); - const detector = new Detector(); const installer = new Installer(); - const bmadResult = await installer.findBmadDir(directory); - const existingInstall = await detector.detect(bmadResult.bmadDir); - return existingInstall.ides || []; + const { bmadDir } = await installer.findBmadDir(directory); + const existingInstall = await ExistingInstall.detect(bmadDir); + return existingInstall.ides; } /** @@ -1678,82 +1549,6 @@ class UI { return result; } - /** - * Check if installed version is a legacy version that needs fresh install - * @param {string} installedVersion - The installed version - * @returns {boolean} True if legacy (v4 or any alpha) - */ - isLegacyVersion(installedVersion) { - if (!installedVersion || installedVersion === 'unknown') { - return true; // Treat unknown as legacy for safety - } - // Check if version string contains -alpha or -Alpha (any v6 alpha) - return /-alpha\./i.test(installedVersion); - } - - /** - * Show warning for legacy version (v4 or alpha) and ask if user wants to proceed - * @param {string} installedVersion - The installed version - * @param {string} currentVersion - The current version - * @param {string} bmadFolderName - Name of the BMAD folder - * @returns {Promise} True if user wants to proceed, false if they cancel - */ - async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) { - if (!this.isLegacyVersion(installedVersion)) { - return true; // Not legacy, proceed - } - - let warningContent; - if (installedVersion === 'unknown') { - warningContent = 'Unable to detect your installed BMAD version.\n' + 'This appears to be a legacy or unsupported installation.'; - } else { - warningContent = - `You are updating from ${installedVersion} to ${currentVersion}.\n` + 'You have a legacy version installed (v4 or alpha).'; - } - - warningContent += - '\n\nFor the best experience, we recommend:\n' + - ' 1. Delete your current BMAD installation folder\n' + - ` (the "${bmadFolderName}/" folder in your project)\n` + - ' 2. Run a fresh installation\n\n' + - 'Benefits of a fresh install:\n' + - ' \u2022 Cleaner configuration without legacy artifacts\n' + - ' \u2022 All new features properly configured\n' + - ' \u2022 Fewer potential conflicts'; - - await prompts.log.warn('VERSION WARNING'); - await prompts.note(warningContent, 'Version Warning'); - - if (options.yes) { - await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update'); - return true; - } - - const proceed = await prompts.select({ - message: 'How would you like to proceed?', - choices: [ - { - name: 'Proceed with update anyway (may have issues)', - value: 'proceed', - }, - { - name: 'Cancel (recommended - do a fresh install instead)', - value: 'cancel', - }, - ], - default: 'cancel', - }); - - if (proceed === 'cancel') { - await prompts.note( - `1. Delete the "${bmadFolderName}/" folder in your project\n` + "2. Run 'bmad install' again", - 'To do a fresh install', - ); - } - - return proceed === 'proceed'; - } - /** * Display module versions with update availability * @param {Array} modules - Array of module info objects with version info