BMAD-METHOD/tools/cli/installers/lib/core/installer.js

2968 lines
115 KiB
JavaScript

const path = require('node:path');
const fs = require('fs-extra');
const { Detector } = require('./detector');
const { Manifest } = require('./manifest');
const { ModuleManager } = require('../modules/manager');
const { IdeManager } = require('../ide/manager');
const { FileOps } = require('../../../lib/file-ops');
const { Config } = require('../../../lib/config');
const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { CLIUtils } = require('../../../lib/cli-utils');
const { ManifestGenerator } = require('./manifest-generator');
const { IdeConfigManager } = require('./ide-config-manager');
const { CustomHandler } = require('../custom/handler');
const prompts = require('../../../lib/prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const { InstallPaths } = require('./install-paths');
class Installer {
constructor() {
this.detector = new Detector();
this.manifest = new Manifest();
this.moduleManager = new ModuleManager();
this.ideManager = new IdeManager();
this.fileOps = new FileOps();
this.config = new Config();
this.dependencyResolver = new DependencyResolver();
this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager();
this.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME;
}
/**
* 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<Object>} { bmadDir: string, hasLegacyCfg: boolean }
*/
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 };
}
/**
* @function copyFileWithPlaceholderReplacement
* @intent Copy files from BMAD source to installation directory with dynamic content transformation
* @why Enables installation-time customization: _bmad replacement
* @param {string} sourcePath - Absolute path to source file in BMAD repository
* @param {string} targetPath - Absolute path to destination file in user's project
* @param {string} bmadFolderName - User's chosen bmad folder name (default: 'bmad')
* @returns {Promise<void>} Resolves when file copy and transformation complete
* @sideeffects Writes transformed file to targetPath, creates parent directories if needed
* @edgecases Binary files bypass transformation, falls back to raw copy if UTF-8 read fails
* @calledby installCore(), installModule(), IDE installers during file vendoring
* @calls fs.readFile(), fs.writeFile(), fs.copy()
*
*
* 3. Document marker in instructions.md (if applicable)
*/
async copyFileWithPlaceholderReplacement(sourcePath, targetPath) {
// List of text file extensions that should have placeholder replacement
const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv', '.xml'];
const ext = path.extname(sourcePath).toLowerCase();
// Check if this is a text file that might contain placeholders
if (textExtensions.includes(ext)) {
try {
// Read the file content
let content = await fs.readFile(sourcePath, 'utf8');
// Write to target with replaced content
await fs.ensureDir(path.dirname(targetPath));
await fs.writeFile(targetPath, content, 'utf8');
} catch {
// If reading as text fails (might be binary despite extension), fall back to regular copy
await fs.copy(sourcePath, targetPath, { overwrite: true });
}
} else {
// Binary file or other file type - just copy directly
await fs.copy(sourcePath, targetPath, { overwrite: true });
}
}
/**
* Collect Tool/IDE configurations after module configuration
* @param {string} projectDir - Project directory
* @param {Array} selectedModules - Selected modules from configuration
* @param {boolean} isFullReinstall - Whether this is a full reinstall
* @param {Array} previousIdes - Previously configured IDEs (for reinstalls)
* @param {Array} preSelectedIdes - Pre-selected IDEs from early prompt (optional)
* @param {boolean} skipPrompts - Skip prompts and use defaults (for --yes flag)
* @returns {Object} Tool/IDE selection and configurations
*/
async collectToolConfigurations(
projectDir,
selectedModules,
isFullReinstall = false,
previousIdes = [],
preSelectedIdes = null,
skipPrompts = false,
) {
// Use pre-selected IDEs if provided, otherwise prompt
let toolConfig;
if (preSelectedIdes === null) {
// Fallback: prompt for tool selection (backwards compatibility)
const { UI } = require('../../../lib/ui');
const ui = new UI();
toolConfig = await ui.promptToolSelection(projectDir);
} else {
// IDEs were already selected during initial prompts
toolConfig = {
ides: preSelectedIdes,
skipIde: !preSelectedIdes || preSelectedIdes.length === 0,
};
}
// 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 || [];
}
// Load saved IDE configurations for already-configured IDEs
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
// Collect IDE-specific configurations if any were selected
const ideConfigurations = {};
// First, add saved configs for already-configured IDEs
for (const ide of toolConfig.ides || []) {
if (previouslyConfiguredIdes.includes(ide) && savedIdeConfigs[ide]) {
ideConfigurations[ide] = savedIdeConfigs[ide];
}
}
if (!toolConfig.skipIde && toolConfig.ides && toolConfig.ides.length > 0) {
// Ensure IDE manager is initialized
await this.ideManager.ensureInitialized();
// Determine which IDEs are newly selected (not previously configured)
const newlySelectedIdes = toolConfig.ides.filter((ide) => !previouslyConfiguredIdes.includes(ide));
if (newlySelectedIdes.length > 0) {
// Collect configuration for IDEs that support it
for (const ide of newlySelectedIdes) {
try {
const handler = this.ideManager.handlers.get(ide);
if (!handler) {
await prompts.log.warn(`Warning: IDE '${ide}' handler not found`);
continue;
}
// Check if this IDE handler has a collectConfiguration method
// (custom installers like Codex, Kilo may have this)
if (typeof handler.collectConfiguration === 'function') {
await prompts.log.info(`Configuring ${ide}...`);
ideConfigurations[ide] = await handler.collectConfiguration({
selectedModules: selectedModules || [],
projectDir,
bmadDir,
skipPrompts,
});
} else {
// Config-driven IDEs don't need configuration - mark as ready
ideConfigurations[ide] = { _noConfigNeeded: true };
}
} catch (error) {
// IDE doesn't support configuration or has an error
await prompts.log.warn(`Warning: Could not load configuration for ${ide}: ${error.message}`);
}
}
}
// Log which IDEs are already configured and being kept
const keptIdes = toolConfig.ides.filter((ide) => previouslyConfiguredIdes.includes(ide));
if (keptIdes.length > 0) {
await prompts.log.message(`Keeping existing configuration for: ${keptIdes.join(', ')}`);
}
}
return {
ides: toolConfig.ides,
skipIde: toolConfig.skipIde,
configurations: ideConfigurations,
};
}
/**
* Main installation method
* @param {Object} config - Installation configuration
* @param {string} config.directory - Target directory
* @param {boolean} config.installCore - Whether to install core
* @param {string[]} config.modules - Modules to install
* @param {string[]} config.ides - IDEs to configure
* @param {boolean} config.skipIde - Skip IDE configuration
*/
async install(originalConfig) {
// Clone config to avoid mutating the caller's object
const config = { ...originalConfig };
// if core config isn't collected, we haven't run the UI -> display logo/version
const hasCoreConfig = config.coreConfig && Object.keys(config.coreConfig).length > 0;
if (!hasCoreConfig) {
await CLIUtils.displayLogo();
}
const paths = await InstallPaths.create(config);
const { projectRoot, bmadDir, srcDir } = paths;
// If core config was pre-collected (from interactive mode), use it
if (config.coreConfig && Object.keys(config.coreConfig).length > 0) {
this.configCollector.collectedConfig.core = config.coreConfig;
// Also store in allAnswers for cross-referencing
this.configCollector.allAnswers = {};
for (const [key, value] of Object.entries(config.coreConfig)) {
this.configCollector.allAnswers[`core_${key}`] = value;
}
}
// Collect configurations for modules (skip if quick update already collected them)
let moduleConfigs;
let customModulePaths = new Map();
if (config._quickUpdate) {
// Quick update already collected all configs, use them directly
moduleConfigs = this.configCollector.collectedConfig;
// For quick update, populate customModulePaths from _customModuleSources
if (config._customModuleSources) {
for (const [moduleId, customInfo] of config._customModuleSources) {
customModulePaths.set(moduleId, customInfo.sourcePath);
}
}
} else {
// For regular updates (modify flow), check manifest for custom module sources
if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) {
for (const customModule of config._existingInstall.customModules) {
// Ensure we have an absolute sourcePath
let absoluteSourcePath = customModule.sourcePath;
// Check if sourcePath is a cache-relative path (starts with _config)
if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) {
// Convert cache-relative path to absolute path
absoluteSourcePath = path.join(bmadDir, absoluteSourcePath);
}
// If no sourcePath but we have relativePath, convert it
else if (!absoluteSourcePath && customModule.relativePath) {
// relativePath is relative to the project root (parent of bmad dir)
absoluteSourcePath = path.resolve(projectRoot, customModule.relativePath);
}
// Ensure sourcePath is absolute for anything else
else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
absoluteSourcePath = path.resolve(absoluteSourcePath);
}
if (absoluteSourcePath) {
customModulePaths.set(customModule.id, absoluteSourcePath);
}
}
}
// Build custom module paths map from customContent
// Handle selectedFiles (from existing install path or manual directory input)
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
const customHandler = new CustomHandler();
for (const customFile of config.customContent.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile, projectRoot);
if (customInfo && customInfo.id) {
customModulePaths.set(customInfo.id, customInfo.path);
}
}
}
// Handle new custom content sources from UI
if (config.customContent && config.customContent.sources) {
for (const source of config.customContent.sources) {
customModulePaths.set(source.id, source.path);
}
}
// Handle cachedModules (from new install path where modules are cached)
// Only include modules that were actually selected for installation
if (config.customContent && config.customContent.cachedModules) {
// Get selected cached module IDs (if available)
const selectedCachedIds = config.customContent.selectedCachedModules || [];
// If no selection info, include all cached modules (for backward compatibility)
const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected;
for (const cachedModule of config.customContent.cachedModules) {
// For cached modules, the path is the cachePath which contains the module.yaml
if (
cachedModule.id &&
cachedModule.cachePath && // Include if selected or if we should include all
(shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))
) {
customModulePaths.set(cachedModule.id, cachedModule.cachePath);
}
}
}
// Get list of all modules including custom modules
// Order: core first, then official modules, then custom modules
const allModulesForConfig = ['core'];
// Add official modules (excluding core and any custom modules)
const officialModules = (config.modules || []).filter((m) => m !== 'core' && !customModulePaths.has(m));
allModulesForConfig.push(...officialModules);
// Add custom modules at the end
for (const [moduleId] of customModulePaths) {
if (!allModulesForConfig.includes(moduleId)) {
allModulesForConfig.push(moduleId);
}
}
// Check if core was already collected in UI
if (config.coreConfig && Object.keys(config.coreConfig).length > 0) {
// Core already collected, skip it in config collection
const modulesWithoutCore = allModulesForConfig.filter((m) => m !== 'core');
moduleConfigs = await this.configCollector.collectAllConfigurations(modulesWithoutCore, projectRoot, {
customModulePaths,
skipPrompts: config.skipPrompts,
});
} else {
// Core not collected yet, include it
moduleConfigs = await this.configCollector.collectAllConfigurations(allModulesForConfig, projectRoot, {
customModulePaths,
skipPrompts: config.skipPrompts,
});
}
}
// Set bmad folder name on module manager and IDE manager for placeholder replacement
this.moduleManager.setBmadFolderName(BMAD_FOLDER_NAME);
this.moduleManager.setCoreConfig(moduleConfigs.core || {});
this.moduleManager.setCustomModulePaths(customModulePaths);
this.ideManager.setBmadFolderName(BMAD_FOLDER_NAME);
// Tool selection will be collected after we determine if it's a reinstall/update/new install
const spinner = await prompts.spinner();
spinner.start('Preparing installation...');
try {
// Check existing installation
spinner.message('Checking for existing installation...');
const existingInstall = await this.detector.detect(bmadDir);
if (existingInstall.installed && !config.force && !config._quickUpdate) {
spinner.stop('Existing installation detected');
// Check if user already decided what to do (from early menu in ui.js)
let action = null;
if (config.actionType === 'update') {
action = 'update';
} else if (config.skipPrompts) {
// Non-interactive mode: default to update
action = 'update';
} else {
// Fallback: Ask the user (backwards compatibility for other code paths)
await prompts.log.warn('Existing BMAD installation detected');
await prompts.log.message(` Location: ${bmadDir}`);
await prompts.log.message(` Version: ${existingInstall.version}`);
const promptResult = await this.promptUpdateAction();
action = promptResult.action;
}
if (action === 'update') {
// Store that we're updating for later processing
config._isUpdate = true;
config._existingInstall = existingInstall;
// Detect modules that were previously installed but are NOT in the new selection (to be removed)
const previouslyInstalledModules = new Set(existingInstall.modules.map((m) => m.id));
const newlySelectedModules = new Set(config.modules || []);
// Find modules to remove (installed but not in new selection)
// Exclude 'core' from being removable
const modulesToRemove = [...previouslyInstalledModules].filter((m) => !newlySelectedModules.has(m) && m !== 'core');
// If there are modules to remove, ask for confirmation
if (modulesToRemove.length > 0) {
if (config.skipPrompts) {
// Non-interactive mode: preserve modules (matches prompt default: false)
for (const moduleId of modulesToRemove) {
if (!config.modules) config.modules = [];
config.modules.push(moduleId);
}
spinner.start('Preparing update...');
} else {
if (spinner.isSpinning) {
spinner.stop('Module changes reviewed');
}
await prompts.log.warn('Modules to be removed:');
for (const moduleId of modulesToRemove) {
const moduleInfo = existingInstall.modules.find((m) => m.id === moduleId);
const displayName = moduleInfo?.name || moduleId;
const modulePath = paths.moduleDir(moduleId);
await prompts.log.error(` - ${displayName} (${modulePath})`);
}
const confirmRemoval = await prompts.confirm({
message: `Remove ${modulesToRemove.length} module(s) from BMAD installation?`,
default: false,
});
if (confirmRemoval) {
// Remove module folders
for (const moduleId of modulesToRemove) {
const modulePath = paths.moduleDir(moduleId);
try {
if (await fs.pathExists(modulePath)) {
await fs.remove(modulePath);
await prompts.log.message(` Removed: ${moduleId}`);
}
} catch (error) {
await prompts.log.warn(` Warning: Failed to remove ${moduleId}: ${error.message}`);
}
}
await prompts.log.success(` Removed ${modulesToRemove.length} module(s)`);
} else {
await prompts.log.message(' Module removal cancelled');
// Add the modules back to the selection since user cancelled removal
for (const moduleId of modulesToRemove) {
if (!config.modules) config.modules = [];
config.modules.push(moduleId);
}
}
spinner.start('Preparing update...');
}
}
// Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
const existingFilesManifest = await this.readFilesManifest(bmadDir);
const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest);
config._customFiles = customFiles;
config._modifiedFiles = modifiedFiles;
// Preserve existing core configuration during updates
// Read the current core config.yaml to maintain user's settings
const coreConfigPath = paths.moduleConfig('core');
if ((await fs.pathExists(coreConfigPath)) && (!config.coreConfig || Object.keys(config.coreConfig).length === 0)) {
try {
const yaml = require('yaml');
const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8');
const existingCoreConfig = yaml.parse(coreConfigContent);
// Store in config.coreConfig so it's preserved through the installation
config.coreConfig = existingCoreConfig;
// Also store in configCollector for use during config collection
this.configCollector.collectedConfig.core = existingCoreConfig;
} catch (error) {
await prompts.log.warn(`Warning: Could not read existing core config: ${error.message}`);
}
}
// Also check cache directory for custom modules (like quick update does)
const cacheDir = paths.customCacheDir;
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
const moduleId = cachedModule.name;
const cachedPath = path.join(cacheDir, moduleId);
// Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
if (!(await fs.pathExists(cachedPath)) || !cachedModule.isDirectory()) {
continue;
}
// Skip if we already have this module from manifest
if (customModulePaths.has(moduleId)) {
continue;
}
// Check if this is an external official module - skip cache for those
const isExternal = await this.moduleManager.isExternalModule(moduleId);
if (isExternal) {
// External modules are handled via cloneExternalModule, not from cache
continue;
}
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModulePaths.set(moduleId, cachedPath);
}
}
// Update module manager with the new custom module paths from cache
this.moduleManager.setCustomModulePaths(customModulePaths);
}
// If there are custom files, back them up temporarily
if (customFiles.length > 0) {
const tempBackupDir = path.join(projectRoot, '_bmad-custom-backup-temp');
await fs.ensureDir(tempBackupDir);
spinner.start(`Backing up ${customFiles.length} custom files...`);
for (const customFile of customFiles) {
const relativePath = path.relative(bmadDir, customFile);
const backupPath = path.join(tempBackupDir, relativePath);
await fs.ensureDir(path.dirname(backupPath));
await fs.copy(customFile, backupPath);
}
spinner.stop(`Backed up ${customFiles.length} custom files`);
config._tempBackupDir = tempBackupDir;
}
// For modified files, back them up to temp directory (will be restored as .bak files after install)
if (modifiedFiles.length > 0) {
const tempModifiedBackupDir = path.join(projectRoot, '_bmad-modified-backup-temp');
await fs.ensureDir(tempModifiedBackupDir);
spinner.start(`Backing up ${modifiedFiles.length} modified files...`);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
await fs.ensureDir(path.dirname(tempBackupPath));
await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true });
}
spinner.stop(`Backed up ${modifiedFiles.length} modified files`);
config._tempModifiedBackupDir = tempModifiedBackupDir;
}
}
} else if (existingInstall.installed && config._quickUpdate) {
// Quick update mode - automatically treat as update without prompting
spinner.message('Preparing quick update...');
config._isUpdate = true;
config._existingInstall = existingInstall;
// Detect custom and modified files BEFORE updating
const existingFilesManifest = await this.readFilesManifest(bmadDir);
const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest);
config._customFiles = customFiles;
config._modifiedFiles = modifiedFiles;
// Also check cache directory for custom modules (like quick update does)
const cacheDir = paths.customCacheDir;
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
const moduleId = cachedModule.name;
const cachedPath = path.join(cacheDir, moduleId);
// Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
if (!(await fs.pathExists(cachedPath)) || !cachedModule.isDirectory()) {
continue;
}
// Skip if we already have this module from manifest
if (customModulePaths.has(moduleId)) {
continue;
}
// Check if this is an external official module - skip cache for those
const isExternal = await this.moduleManager.isExternalModule(moduleId);
if (isExternal) {
// External modules are handled via cloneExternalModule, not from cache
continue;
}
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModulePaths.set(moduleId, cachedPath);
}
}
// Update module manager with the new custom module paths from cache
this.moduleManager.setCustomModulePaths(customModulePaths);
}
// Back up custom files
if (customFiles.length > 0) {
const tempBackupDir = path.join(projectRoot, '_bmad-custom-backup-temp');
await fs.ensureDir(tempBackupDir);
spinner.start(`Backing up ${customFiles.length} custom files...`);
for (const customFile of customFiles) {
const relativePath = path.relative(bmadDir, customFile);
const backupPath = path.join(tempBackupDir, relativePath);
await fs.ensureDir(path.dirname(backupPath));
await fs.copy(customFile, backupPath);
}
spinner.stop(`Backed up ${customFiles.length} custom files`);
config._tempBackupDir = tempBackupDir;
}
// Back up modified files
if (modifiedFiles.length > 0) {
const tempModifiedBackupDir = path.join(projectRoot, '_bmad-modified-backup-temp');
await fs.ensureDir(tempModifiedBackupDir);
spinner.start(`Backing up ${modifiedFiles.length} modified files...`);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
await fs.ensureDir(path.dirname(tempBackupPath));
await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true });
}
spinner.stop(`Backed up ${modifiedFiles.length} modified files`);
config._tempModifiedBackupDir = tempModifiedBackupDir;
}
}
// Now collect tool configurations after we know if it's a reinstall
// Skip for quick update since we already have the IDE list
spinner.stop('Pre-checks complete');
let toolSelection;
if (config._quickUpdate) {
// Quick update already has IDEs configured, use saved configurations
const preConfiguredIdes = {};
const savedIdeConfigs = config._savedIdeConfigs || {};
for (const ide of config.ides || []) {
// Use saved config if available, otherwise mark as already configured (legacy)
if (savedIdeConfigs[ide]) {
preConfiguredIdes[ide] = savedIdeConfigs[ide];
} else {
preConfiguredIdes[ide] = { _alreadyConfigured: true };
}
}
toolSelection = {
ides: config.ides || [],
skipIde: !config.ides || config.ides.length === 0,
configurations: preConfiguredIdes,
};
} else {
// Pass pre-selected IDEs from early prompt (if available)
// This allows IDE selection to happen before file copying, improving UX
// Use config.ides if it's an array (even if empty), null means prompt
const preSelectedIdes = Array.isArray(config.ides) ? config.ides : null;
toolSelection = await this.collectToolConfigurations(
projectRoot,
config.modules,
config._isFullReinstall || false,
config._previouslyConfiguredIdes || [],
preSelectedIdes,
config.skipPrompts || false,
);
}
// Merge tool selection into config (for both quick update and regular flow)
// Normalize IDE keys to lowercase so they match handler map keys consistently
config.ides = (toolSelection.ides || []).map((ide) => ide.toLowerCase());
config.skipIde = toolSelection.skipIde;
const ideConfigurations = toolSelection.configurations;
// Early check: fail fast if ALL selected IDEs are suspended
if (config.ides && config.ides.length > 0) {
await this.ideManager.ensureInitialized();
const suspendedIdes = config.ides.filter((ide) => {
const handler = this.ideManager.handlers.get(ide);
return handler?.platformConfig?.suspended;
});
if (suspendedIdes.length > 0 && suspendedIdes.length === config.ides.length) {
for (const ide of suspendedIdes) {
const handler = this.ideManager.handlers.get(ide);
await prompts.log.error(`${handler.displayName || ide}: ${handler.platformConfig.suspended}`);
}
throw new Error(
`All selected tool(s) are suspended: ${suspendedIdes.join(', ')}. Installation aborted to prevent upgrading _bmad/ without a working IDE configuration.`,
);
}
}
// Detect IDEs that were previously installed but are NOT in the new selection (to be removed)
if (config._isUpdate && config._existingInstall) {
const previouslyInstalledIdes = new Set(config._existingInstall.ides || []);
const newlySelectedIdes = new Set(config.ides || []);
const idesToRemove = [...previouslyInstalledIdes].filter((ide) => !newlySelectedIdes.has(ide));
if (idesToRemove.length > 0) {
if (config.skipPrompts) {
// Non-interactive mode: silently preserve existing IDE configs
if (!config.ides) config.ides = [];
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
for (const ide of idesToRemove) {
config.ides.push(ide);
if (savedIdeConfigs[ide] && !ideConfigurations[ide]) {
ideConfigurations[ide] = savedIdeConfigs[ide];
}
}
} else {
if (spinner.isSpinning) {
spinner.stop('IDE changes reviewed');
}
await prompts.log.warn('IDEs to be removed:');
for (const ide of idesToRemove) {
await prompts.log.error(` - ${ide}`);
}
const confirmRemoval = await prompts.confirm({
message: `Remove BMAD configuration for ${idesToRemove.length} IDE(s)?`,
default: false,
});
if (confirmRemoval) {
await this.ideManager.ensureInitialized();
for (const ide of idesToRemove) {
try {
const handler = this.ideManager.handlers.get(ide);
if (handler) {
await handler.cleanup(projectRoot);
}
await this.ideConfigManager.deleteIdeConfig(bmadDir, ide);
await prompts.log.message(` Removed: ${ide}`);
} catch (error) {
await prompts.log.warn(` Warning: Failed to remove ${ide}: ${error.message}`);
}
}
await prompts.log.success(` Removed ${idesToRemove.length} IDE(s)`);
} else {
await prompts.log.message(' IDE removal cancelled');
// Add IDEs back to selection and restore their saved configurations
if (!config.ides) config.ides = [];
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
for (const ide of idesToRemove) {
config.ides.push(ide);
if (savedIdeConfigs[ide] && !ideConfigurations[ide]) {
ideConfigurations[ide] = savedIdeConfigs[ide];
}
}
}
spinner.start('Preparing installation...');
}
}
}
// Results collector for consolidated summary
const results = [];
const addResult = (step, status, detail = '') => results.push({ step, status, detail });
if (spinner.isSpinning) {
spinner.message('Preparing installation...');
} else {
spinner.start('Preparing installation...');
}
// Cache custom modules if any
if (customModulePaths && customModulePaths.size > 0) {
spinner.message('Caching custom modules...');
const { CustomModuleCache } = require('./custom-module-cache');
const customCache = new CustomModuleCache(bmadDir);
for (const [moduleId, sourcePath] of customModulePaths) {
const cachedInfo = await customCache.cacheModule(moduleId, sourcePath, {
sourcePath: sourcePath, // Store original path for updates
});
// Update the customModulePaths to use the cached location
customModulePaths.set(moduleId, cachedInfo.cachePath);
}
// Update module manager with the cached paths
this.moduleManager.setCustomModulePaths(customModulePaths);
addResult('Custom modules cached', 'ok');
}
// Custom content is already handled in UI before module selection
const finalCustomContent = config.customContent;
// Prepare modules list including cached custom modules
let allModules = [...(config.modules || [])];
// During quick update, we might have custom module sources from the manifest
if (config._customModuleSources) {
// Add custom modules from stored sources
for (const [moduleId, customInfo] of config._customModuleSources) {
if (!allModules.includes(moduleId) && (await fs.pathExists(customInfo.sourcePath))) {
allModules.push(moduleId);
}
}
}
// Add cached custom modules
if (finalCustomContent && finalCustomContent.cachedModules) {
for (const cachedModule of finalCustomContent.cachedModules) {
if (!allModules.includes(cachedModule.id)) {
allModules.push(cachedModule.id);
}
}
}
// Regular custom content from user input (non-cached)
if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
// Add custom modules to the installation list
const customHandler = new CustomHandler();
for (const customFile of finalCustomContent.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile, projectRoot);
if (customInfo && customInfo.id) {
allModules.push(customInfo.id);
}
}
}
// Don't include core again if already installed
if (config.installCore) {
allModules = allModules.filter((m) => m !== 'core');
}
// For dependency resolution, we only need regular modules (not custom modules)
// Custom modules are already installed in _bmad and don't need dependency resolution from source
const regularModulesForResolution = allModules.filter((module) => {
// Check if this is a custom module
const isCustom =
customModulePaths.has(module) ||
(finalCustomContent && finalCustomContent.cachedModules && finalCustomContent.cachedModules.some((cm) => cm.id === module)) ||
(finalCustomContent &&
finalCustomContent.selected &&
finalCustomContent.selectedFiles &&
finalCustomContent.selectedFiles.some((f) => f.includes(module)));
return !isCustom;
});
// Stop spinner before tasks() takes over progress display
spinner.stop('Preparation complete');
// ─────────────────────────────────────────────────────────────────────────
// FIRST TASKS BLOCK: Core installation through manifests (non-interactive)
// ─────────────────────────────────────────────────────────────────────────
const isQuickUpdate = config._quickUpdate || false;
// Shared resolution result across task callbacks (closure-scoped, not on `this`)
let taskResolution;
// Collect directory creation results for output after tasks() completes
const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
// Build task list conditionally
const installTasks = [];
// Core installation task
if (config.installCore) {
installTasks.push({
title: isQuickUpdate ? 'Updating BMAD core' : 'Installing BMAD core',
task: async (message) => {
await this.installCoreWithDependencies(bmadDir, { core: {} });
addResult('Core', 'ok', isQuickUpdate ? 'updated' : 'installed');
await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} });
return isQuickUpdate ? 'Core updated' : 'Core installed';
},
});
}
// Dependency resolution task
installTasks.push({
title: 'Resolving dependencies',
task: async (message) => {
// Create a temporary module manager that knows about custom content locations
const tempModuleManager = new ModuleManager({
bmadDir: bmadDir,
});
taskResolution = await this.dependencyResolver.resolve(srcDir, regularModulesForResolution, {
verbose: config.verbose,
moduleManager: tempModuleManager,
});
return 'Dependencies resolved';
},
});
// Module installation task
if (allModules && allModules.length > 0) {
installTasks.push({
title: isQuickUpdate ? `Updating ${allModules.length} module(s)` : `Installing ${allModules.length} module(s)`,
task: async (message) => {
const resolution = taskResolution;
const installedModuleNames = new Set();
for (const moduleName of allModules) {
if (installedModuleNames.has(moduleName)) continue;
installedModuleNames.add(moduleName);
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`);
// Check if this is a custom module
let isCustomModule = false;
let customInfo = null;
// First check if we have a cached version
if (finalCustomContent && finalCustomContent.cachedModules) {
const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName);
if (cachedModule) {
isCustomModule = true;
customInfo = { id: moduleName, path: cachedModule.cachePath, config: {} };
}
}
// Then check custom module sources from manifest (for quick update)
if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) {
customInfo = config._customModuleSources.get(moduleName);
isCustomModule = true;
if (customInfo.sourcePath && !customInfo.path) {
customInfo.path = path.isAbsolute(customInfo.sourcePath)
? customInfo.sourcePath
: path.join(bmadDir, customInfo.sourcePath);
}
}
// Finally check regular custom content
if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
const customHandler = new CustomHandler();
for (const customFile of finalCustomContent.selectedFiles) {
const info = await customHandler.getCustomInfo(customFile, projectRoot);
if (info && info.id === moduleName) {
isCustomModule = true;
customInfo = info;
break;
}
}
}
if (isCustomModule && customInfo) {
if (!customModulePaths.has(moduleName) && customInfo.path) {
customModulePaths.set(moduleName, customInfo.path);
this.moduleManager.setCustomModulePaths(customModulePaths);
}
const collectedModuleConfig = moduleConfigs[moduleName] || {};
await this.moduleManager.install(
moduleName,
bmadDir,
(filePath) => {
this.installedFiles.add(filePath);
},
{
isCustom: true,
moduleConfig: collectedModuleConfig,
isQuickUpdate: isQuickUpdate,
installer: this,
silent: true,
},
);
await this.generateModuleConfigs(bmadDir, {
[moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig },
});
} else {
if (!resolution || !resolution.byModule) {
addResult(`Module: ${moduleName}`, 'warn', 'skipped (no resolution data)');
continue;
}
if (moduleName === 'core') {
await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]);
} else {
await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]);
}
}
addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed');
}
// Install partial modules (only dependencies)
if (!resolution || !resolution.byModule) {
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
}
for (const [module, files] of Object.entries(resolution.byModule)) {
if (!allModules.includes(module) && module !== 'core') {
const totalFiles =
files.agents.length +
files.tasks.length +
files.tools.length +
files.templates.length +
files.data.length +
files.other.length;
if (totalFiles > 0) {
message(`Installing ${module} dependencies...`);
await this.installPartialModule(module, bmadDir, files);
}
}
}
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
},
});
}
// Module directory creation task
installTasks.push({
title: 'Creating module directories',
task: async (message) => {
const resolution = taskResolution;
if (!resolution || !resolution.byModule) {
addResult('Module directories', 'warn', 'no resolution data');
return 'Module directories skipped (no resolution data)';
}
const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose;
const moduleLogger = {
log: async (msg) => (verboseMode ? await prompts.log.message(msg) : undefined),
error: async (msg) => await prompts.log.error(msg),
warn: async (msg) => await prompts.log.warn(msg),
};
// Core module directories
if (config.installCore || resolution.byModule.core) {
const result = await this.moduleManager.createModuleDirectories('core', bmadDir, {
installedIDEs: config.ides || [],
moduleConfig: moduleConfigs.core || {},
existingModuleConfig: this.configCollector.existingConfig?.core || {},
coreConfig: moduleConfigs.core || {},
logger: moduleLogger,
silent: true,
});
if (result) {
dirResults.createdDirs.push(...result.createdDirs);
dirResults.movedDirs.push(...(result.movedDirs || []));
dirResults.createdWdsFolders.push(...result.createdWdsFolders);
}
}
// User-selected module directories
if (config.modules && config.modules.length > 0) {
for (const moduleName of config.modules) {
message(`Setting up ${moduleName}...`);
const result = await this.moduleManager.createModuleDirectories(moduleName, bmadDir, {
installedIDEs: config.ides || [],
moduleConfig: moduleConfigs[moduleName] || {},
existingModuleConfig: this.configCollector.existingConfig?.[moduleName] || {},
coreConfig: moduleConfigs.core || {},
logger: moduleLogger,
silent: true,
});
if (result) {
dirResults.createdDirs.push(...result.createdDirs);
dirResults.movedDirs.push(...(result.movedDirs || []));
dirResults.createdWdsFolders.push(...result.createdWdsFolders);
}
}
}
addResult('Module directories', 'ok');
return 'Module directories created';
},
});
// Configuration generation task (stored as named reference for deferred execution)
const configTask = {
title: 'Generating configurations',
task: async (message) => {
// Generate clean config.yaml files for each installed module
await this.generateModuleConfigs(bmadDir, moduleConfigs);
addResult('Configurations', 'ok', 'generated');
// Pre-register manifest files
this.installedFiles.add(paths.manifestFile());
this.installedFiles.add(paths.agentManifest());
// Generate CSV manifests for agents, skills AND ALL FILES with hashes
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
message('Generating manifests...');
const manifestGen = new ManifestGenerator();
const allModulesForManifest = config._quickUpdate
? config._existingModules || allModules || []
: config._preserveModules
? [...allModules, ...config._preserveModules]
: allModules || [];
let modulesForCsvPreserve;
if (config._quickUpdate) {
modulesForCsvPreserve = config._existingModules || allModules || [];
} else {
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
}
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve,
});
// Merge help catalogs
message('Generating help catalog...');
await this.mergeModuleHelpCatalogs(bmadDir);
addResult('Help catalog', 'ok');
return 'Configurations generated';
},
};
installTasks.push(configTask);
// Run all tasks except config (which runs after directory output)
const mainTasks = installTasks.filter((t) => t !== configTask);
await prompts.tasks(mainTasks);
// Render directory creation output right after directory task
const color = await prompts.getColor();
if (dirResults.movedDirs.length > 0) {
const lines = dirResults.movedDirs.map((d) => ` ${d}`).join('\n');
await prompts.log.message(color.cyan(`Moved directories:\n${lines}`));
}
if (dirResults.createdDirs.length > 0) {
const lines = dirResults.createdDirs.map((d) => ` ${d}`).join('\n');
await prompts.log.message(color.yellow(`Created directories:\n${lines}`));
}
if (dirResults.createdWdsFolders.length > 0) {
const lines = dirResults.createdWdsFolders.map((f) => color.dim(` \u2713 ${f}/`)).join('\n');
await prompts.log.message(color.cyan(`Created WDS folder structure:\n${lines}`));
}
// Now run configuration generation
await prompts.tasks([configTask]);
// Resolution is now available via closure-scoped taskResolution
const resolution = taskResolution;
// ─────────────────────────────────────────────────────────────────────────
// IDE SETUP: Keep as spinner since it may prompt for user input
// ─────────────────────────────────────────────────────────────────────────
if (!config.skipIde && config.ides && config.ides.length > 0) {
await this.ideManager.ensureInitialized();
const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string');
if (validIdes.length === 0) {
addResult('IDE configuration', 'warn', 'no valid IDEs selected');
} else {
const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]);
const ideSpinner = await prompts.spinner();
ideSpinner.start('Configuring tools...');
try {
for (const ide of validIdes) {
if (!needsPrompting || ideConfigurations[ide]) {
ideSpinner.message(`Configuring ${ide}...`);
} else {
if (ideSpinner.isSpinning) {
ideSpinner.stop('Ready for IDE configuration');
}
}
// Suppress stray console output for pre-configured IDEs (no user interaction)
const ideHasConfig = Boolean(ideConfigurations[ide]);
const originalLog = console.log;
if (!config.verbose && ideHasConfig) {
console.log = () => {};
}
try {
const setupResult = await this.ideManager.setup(ide, projectRoot, bmadDir, {
selectedModules: allModules || [],
preCollectedConfig: ideConfigurations[ide] || null,
verbose: config.verbose,
silent: ideHasConfig,
});
if (ideConfigurations[ide] && !ideConfigurations[ide]._alreadyConfigured) {
await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]);
}
if (setupResult.success) {
addResult(ide, 'ok', setupResult.detail || '');
} else {
addResult(ide, 'error', setupResult.error || 'failed');
}
} finally {
console.log = originalLog;
}
if (needsPrompting && !ideSpinner.isSpinning) {
ideSpinner.start('Configuring tools...');
}
}
} finally {
if (ideSpinner.isSpinning) {
ideSpinner.stop('Tool configuration complete');
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// SECOND TASKS BLOCK: Post-IDE operations (non-interactive)
// ─────────────────────────────────────────────────────────────────────────
const postIdeTasks = [];
// File restoration task (only for updates)
if (
config._isUpdate &&
((config._customFiles && config._customFiles.length > 0) || (config._modifiedFiles && config._modifiedFiles.length > 0))
) {
postIdeTasks.push({
title: 'Finalizing installation',
task: async (message) => {
let customFiles = [];
let modifiedFiles = [];
if (config._customFiles && config._customFiles.length > 0) {
message(`Restoring ${config._customFiles.length} custom files...`);
for (const originalPath of config._customFiles) {
const relativePath = path.relative(bmadDir, originalPath);
const backupPath = path.join(config._tempBackupDir, relativePath);
if (await fs.pathExists(backupPath)) {
await fs.ensureDir(path.dirname(originalPath));
await fs.copy(backupPath, originalPath, { overwrite: true });
}
}
if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) {
await fs.remove(config._tempBackupDir);
}
customFiles = config._customFiles;
}
if (config._modifiedFiles && config._modifiedFiles.length > 0) {
modifiedFiles = config._modifiedFiles;
if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) {
message(`Restoring ${modifiedFiles.length} modified files as .bak...`);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(config._tempModifiedBackupDir, relativePath);
const bakPath = modifiedFile.path + '.bak';
if (await fs.pathExists(tempBackupPath)) {
await fs.ensureDir(path.dirname(bakPath));
await fs.copy(tempBackupPath, bakPath, { overwrite: true });
}
}
await fs.remove(config._tempModifiedBackupDir);
}
}
// Store for summary access
config._restoredCustomFiles = customFiles;
config._restoredModifiedFiles = modifiedFiles;
return 'Installation finalized';
},
});
}
await prompts.tasks(postIdeTasks);
// Retrieve restored file info for summary
const customFiles = config._restoredCustomFiles || [];
const modifiedFiles = config._restoredModifiedFiles || [];
// Render consolidated summary
await this.renderInstallSummary(results, {
bmadDir,
modules: config.modules,
ides: config.ides,
customFiles: customFiles.length > 0 ? customFiles : undefined,
modifiedFiles: modifiedFiles.length > 0 ? modifiedFiles : undefined,
});
return {
success: true,
path: bmadDir,
modules: config.modules,
ides: config.ides,
projectDir: projectRoot,
};
} catch (error) {
try {
if (spinner.isSpinning) {
spinner.error('Installation failed');
} else {
await prompts.log.error('Installation failed');
}
} catch {
// Ensure the original error is never swallowed by a logging failure
}
// Clean up any temp backup directories that were created before the failure
try {
if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) {
await fs.remove(config._tempBackupDir);
}
if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) {
await fs.remove(config._tempModifiedBackupDir);
}
} catch {
// Best-effort cleanup — don't mask the original error
}
throw error;
}
}
/**
* Render a consolidated install summary using prompts.note()
* @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail}
* @param {Object} context - {bmadDir, modules, ides, customFiles, modifiedFiles}
*/
async renderInstallSummary(results, context = {}) {
const color = await prompts.getColor();
const selectedIdes = new Set((context.ides || []).map((ide) => String(ide).toLowerCase()));
// Build step lines with status indicators
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;
}
let icon;
if (r.status === 'ok') {
icon = color.green('\u2713');
} else if (r.status === 'warn') {
icon = color.yellow('!');
} else {
icon = color.red('\u2717');
}
const detail = r.detail ? color.dim(` (${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)')}`);
}
// Context and warnings
lines.push('');
if (context.bmadDir) {
lines.push(` Installed to: ${color.dim(context.bmadDir)}`);
}
if (context.customFiles && context.customFiles.length > 0) {
lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`);
}
if (context.modifiedFiles && context.modifiedFiles.length > 0) {
lines.push(` ${color.yellow(`Modified files backed up (.bak): ${context.modifiedFiles.length}`)}`);
}
// 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')}`,
);
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!');
}
/**
* Update existing installation
*/
async update(config) {
const spinner = await prompts.spinner();
spinner.start('Checking installation...');
try {
const projectDir = path.resolve(config.directory);
const { bmadDir } = await this.findBmadDir(projectDir);
const existingInstall = await this.detector.detect(bmadDir);
if (!existingInstall.installed) {
spinner.stop('No BMAD installation found');
throw new Error(`No BMAD installation found at ${bmadDir}`);
}
spinner.message('Analyzing update requirements...');
// Compare versions and determine what needs updating
const currentVersion = existingInstall.version;
const newVersion = require(path.join(getProjectRoot(), 'package.json')).version;
// 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);
}
}
// Also check cache directory
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
if (cachedModule.isDirectory()) {
const moduleId = cachedModule.name;
// Skip if we already have this module
if (customModuleSources.has(moduleId)) {
continue;
}
// Check if this is an external official module - skip cache for those
const isExternal = await this.moduleManager.isExternalModule(moduleId);
if (isExternal) {
// External modules are handled via cloneExternalModule, not from cache
continue;
}
const cachedPath = path.join(cacheDir, moduleId);
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModuleSources.set(moduleId, {
id: moduleId,
name: moduleId,
sourcePath: path.join('_config', 'custom', moduleId), // Relative path
cached: true,
});
}
}
}
}
if (customModuleSources.size > 0) {
spinner.stop('Update analysis complete');
await prompts.log.warn('Checking custom module sources before update...');
const projectRoot = getProjectRoot();
await this.handleMissingCustomSources(
customModuleSources,
bmadDir,
projectRoot,
'update',
existingInstall.modules.map((m) => m.id),
config.skipPrompts || false,
);
spinner.start('Preparing update...');
}
if (config.dryRun) {
spinner.stop('Dry run analysis complete');
let dryRunContent = `Current version: ${currentVersion}\n`;
dryRunContent += `New version: ${newVersion}\n`;
dryRunContent += `Core: ${existingInstall.hasCore ? 'Will be updated' : 'Not installed'}`;
if (existingInstall.modules.length > 0) {
dryRunContent += '\n\nModules to update:';
for (const mod of existingInstall.modules) {
dryRunContent += `\n - ${mod.id}`;
}
}
await prompts.note(dryRunContent, 'Update Preview (Dry Run)');
return;
}
// Perform actual update
if (existingInstall.hasCore) {
spinner.message('Updating core...');
await this.updateCore(bmadDir, config.force);
}
for (const module of existingInstall.modules) {
spinner.message(`Updating module: ${module.id}...`);
await this.moduleManager.update(module.id, bmadDir, config.force, { installer: this });
}
// Update manifest
spinner.message('Updating manifest...');
await this.manifest.update(bmadDir, {
version: newVersion,
updateDate: new Date().toISOString(),
});
spinner.stop('Update complete');
return { success: true };
} catch (error) {
spinner.error('Update failed');
throw error;
}
}
/**
* Get installation status
*/
async getStatus(directory) {
const projectDir = path.resolve(directory);
const { bmadDir } = await this.findBmadDir(projectDir);
return await this.detector.detect(bmadDir);
}
/**
* Get available modules
*/
async getAvailableModules() {
return await this.moduleManager.listAvailable();
}
/**
* 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, options = {}) {
const projectDir = path.resolve(directory);
const { bmadDir } = await this.findBmadDir(projectDir);
if (!(await fs.pathExists(bmadDir))) {
return { success: false, reason: 'not-installed' };
}
// 1. DETECT: Read state BEFORE deleting anything
const existingInstall = await this.detector.detect(bmadDir);
const outputFolder = await this._readOutputFolder(bmadDir);
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<Object>} 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<boolean>} 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<boolean>} 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';
}
/**
* Private: Create directory structure
*/
/**
* Merge all module-help.csv files into a single bmad-help.csv
* Scans all installed modules for module-help.csv and merges them
* Enriches agent info from agent-manifest.csv
* Output is written to _bmad/_config/bmad-help.csv
* @param {string} bmadDir - BMAD installation directory
*/
async mergeModuleHelpCatalogs(bmadDir) {
const allRows = [];
const headerRow =
'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs';
// Load agent manifest for agent info lookup
const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv');
const agentInfo = new Map(); // agent-name -> {command, displayName, title+icon}
if (await fs.pathExists(agentManifestPath)) {
const manifestContent = await fs.readFile(agentManifestPath, 'utf8');
const lines = manifestContent.split('\n').filter((line) => line.trim());
for (const line of lines) {
if (line.startsWith('name,')) continue; // Skip header
const cols = line.split(',');
if (cols.length >= 4) {
const agentName = cols[0].replaceAll('"', '').trim();
const displayName = cols[1].replaceAll('"', '').trim();
const title = cols[2].replaceAll('"', '').trim();
const icon = cols[3].replaceAll('"', '').trim();
const module = cols[10] ? cols[10].replaceAll('"', '').trim() : '';
// Build agent command: bmad:module:agent:name
const agentCommand = module ? `bmad:${module}:agent:${agentName}` : `bmad:agent:${agentName}`;
agentInfo.set(agentName, {
command: agentCommand,
displayName: displayName || agentName,
title: icon && title ? `${icon} ${title}` : title || agentName,
});
}
}
}
// Get all installed module directories
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
const installedModules = entries
.filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs' && entry.name !== '_memory')
.map((entry) => entry.name);
// Add core module to scan (it's installed at root level as _config, but we check src/core-skills)
const coreModulePath = getSourcePath('core-skills');
const modulePaths = new Map();
// Map all module source paths
if (await fs.pathExists(coreModulePath)) {
modulePaths.set('core', coreModulePath);
}
// Map installed module paths
for (const moduleName of installedModules) {
const modulePath = path.join(bmadDir, moduleName);
modulePaths.set(moduleName, modulePath);
}
// Scan each module for module-help.csv
for (const [moduleName, modulePath] of modulePaths) {
const helpFilePath = path.join(modulePath, 'module-help.csv');
if (await fs.pathExists(helpFilePath)) {
try {
const content = await fs.readFile(helpFilePath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#'));
for (const line of lines) {
// Skip header row
if (line.startsWith('module,')) {
continue;
}
// Parse the line - handle quoted fields with commas
const columns = this.parseCSVLine(line);
if (columns.length >= 12) {
// Map old schema to new schema
// Old: module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs
// New: module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs
const [
module,
phase,
name,
code,
sequence,
workflowFile,
command,
required,
agentName,
options,
description,
outputLocation,
outputs,
] = columns;
// If module column is empty, set it to this module's name (except for core which stays empty for universal tools)
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
// Lookup agent info
const cleanAgentName = agentName ? agentName.trim() : '';
const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' };
// Build new row with agent info
const newRow = [
finalModule,
phase || '',
name || '',
code || '',
sequence || '',
workflowFile || '',
command || '',
required || 'false',
cleanAgentName,
agentData.command,
agentData.displayName,
agentData.title,
options || '',
description || '',
outputLocation || '',
outputs || '',
];
allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(','));
}
}
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Merged module-help from: ${moduleName}`);
}
} catch (error) {
await prompts.log.warn(` Warning: Failed to read module-help.csv from ${moduleName}: ${error.message}`);
}
}
}
// Sort by module, then phase, then sequence
allRows.sort((a, b) => {
const colsA = this.parseCSVLine(a);
const colsB = this.parseCSVLine(b);
// Module comparison (empty module/universal tools come first)
const moduleA = (colsA[0] || '').toLowerCase();
const moduleB = (colsB[0] || '').toLowerCase();
if (moduleA !== moduleB) {
return moduleA.localeCompare(moduleB);
}
// Phase comparison
const phaseA = colsA[1] || '';
const phaseB = colsB[1] || '';
if (phaseA !== phaseB) {
return phaseA.localeCompare(phaseB);
}
// Sequence comparison
const seqA = parseInt(colsA[4] || '0', 10);
const seqB = parseInt(colsB[4] || '0', 10);
return seqA - seqB;
});
// Write merged catalog
const outputDir = path.join(bmadDir, '_config');
await fs.ensureDir(outputDir);
const outputPath = path.join(outputDir, 'bmad-help.csv');
const mergedContent = [headerRow, ...allRows].join('\n');
await fs.writeFile(outputPath, mergedContent, 'utf8');
// Track the installed file
this.installedFiles.add(outputPath);
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`);
}
}
/**
* Parse a CSV line, handling quoted fields
* @param {string} line - CSV line to parse
* @returns {Array} Array of field values
*/
parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote
current += '"';
i++; // Skip next quote
} else {
// Toggle quote mode
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}
/**
* Escape a CSV field if it contains special characters
* @param {string} field - Field value to escape
* @returns {string} Escaped field
*/
escapeCSVField(field) {
if (field === null || field === undefined) {
return '';
}
const str = String(field);
// If field contains comma, quote, or newline, wrap in quotes and escape inner quotes
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replaceAll('"', '""')}"`;
}
return str;
}
async createDirectoryStructure(bmadDir) {
await fs.ensureDir(bmadDir);
await fs.ensureDir(path.join(bmadDir, '_config'));
await fs.ensureDir(path.join(bmadDir, '_config', 'agents'));
await fs.ensureDir(path.join(bmadDir, '_config', 'custom'));
}
/**
* Generate clean config.yaml files for each installed module
* @param {string} bmadDir - BMAD installation directory
* @param {Object} moduleConfigs - Collected configuration values
*/
async generateModuleConfigs(bmadDir, moduleConfigs) {
const yaml = require('yaml');
// Extract core config values to share with other modules
const coreConfig = moduleConfigs.core || {};
// Get all installed module directories
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
const installedModules = entries
.filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs')
.map((entry) => entry.name);
// Generate config.yaml for each installed module
for (const moduleName of installedModules) {
const modulePath = path.join(bmadDir, moduleName);
// Get module-specific config or use empty object if none
const config = moduleConfigs[moduleName] || {};
if (await fs.pathExists(modulePath)) {
const configPath = path.join(modulePath, 'config.yaml');
// Create header
const packageJson = require(path.join(getProjectRoot(), 'package.json'));
const header = `# ${moduleName.toUpperCase()} Module Configuration
# Generated by BMAD installer
# Version: ${packageJson.version}
# Date: ${new Date().toISOString()}
`;
// For non-core modules, add core config values directly
let finalConfig = { ...config };
let coreSection = '';
if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) {
// Add core values directly to the module config
// These will be available for reference in the module
finalConfig = {
...config,
...coreConfig, // Spread core config values directly into the module config
};
// Create a comment section to identify core values
coreSection = '\n# Core Configuration Values\n';
}
// Clean the config to remove any non-serializable values (like functions)
const cleanConfig = structuredClone(finalConfig);
// Convert config to YAML
let yamlContent = yaml.stringify(cleanConfig, {
indent: 2,
lineWidth: 0,
minContentWidth: 0,
});
// If we have core values, reorganize the YAML to group them with their comment
if (coreSection && moduleName !== 'core') {
// Split the YAML into lines
const lines = yamlContent.split('\n');
const moduleConfigLines = [];
const coreConfigLines = [];
// Separate module-specific and core config lines
for (const line of lines) {
const key = line.split(':')[0].trim();
if (Object.prototype.hasOwnProperty.call(coreConfig, key)) {
coreConfigLines.push(line);
} else {
moduleConfigLines.push(line);
}
}
// Rebuild YAML with module config first, then core config with comment
yamlContent = moduleConfigLines.join('\n');
if (coreConfigLines.length > 0) {
yamlContent += coreSection + coreConfigLines.join('\n');
}
}
// Write the clean config file with POSIX-compliant final newline
const content = header + yamlContent;
await fs.writeFile(configPath, content.endsWith('\n') ? content : content + '\n', 'utf8');
// Track the config file in installedFiles
this.installedFiles.add(configPath);
}
}
}
/**
* Install core with resolved dependencies
* @param {string} bmadDir - BMAD installation directory
* @param {Object} coreFiles - Core files to install
*/
async installCoreWithDependencies(bmadDir, coreFiles) {
const sourcePath = getModulePath('core');
const targetPath = path.join(bmadDir, 'core');
await this.installCore(bmadDir);
}
/**
* Install module with resolved dependencies
* @param {string} moduleName - Module name
* @param {string} bmadDir - BMAD installation directory
* @param {Object} moduleFiles - Module files to install
*/
async installModuleWithDependencies(moduleName, bmadDir, moduleFiles) {
// Get module configuration for conditional installation
const moduleConfig = this.configCollector.collectedConfig[moduleName] || {};
// Use existing module manager for full installation with file tracking
// Note: Module-specific installers are called separately after IDE setup
await this.moduleManager.install(
moduleName,
bmadDir,
(filePath) => {
this.installedFiles.add(filePath);
},
{
skipModuleInstaller: true, // We'll run it later after IDE setup
moduleConfig: moduleConfig, // Pass module config for conditional filtering
installer: this,
silent: true,
},
);
// Dependencies are already included in full module install
}
/**
* Install partial module (only dependencies needed by other modules)
*/
async installPartialModule(moduleName, bmadDir, files) {
const sourceBase = getModulePath(moduleName);
const targetBase = path.join(bmadDir, moduleName);
// Create module directory
await fs.ensureDir(targetBase);
// Copy only the required dependency files
if (files.agents && files.agents.length > 0) {
const agentsDir = path.join(targetBase, 'agents');
await fs.ensureDir(agentsDir);
for (const agentPath of files.agents) {
const fileName = path.basename(agentPath);
const sourcePath = path.join(sourceBase, 'agents', fileName);
const targetPath = path.join(agentsDir, fileName);
if (await fs.pathExists(sourcePath)) {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath);
this.installedFiles.add(targetPath);
}
}
}
if (files.tasks && files.tasks.length > 0) {
const tasksDir = path.join(targetBase, 'tasks');
await fs.ensureDir(tasksDir);
for (const taskPath of files.tasks) {
const fileName = path.basename(taskPath);
const sourcePath = path.join(sourceBase, 'tasks', fileName);
const targetPath = path.join(tasksDir, fileName);
if (await fs.pathExists(sourcePath)) {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath);
this.installedFiles.add(targetPath);
}
}
}
if (files.tools && files.tools.length > 0) {
const toolsDir = path.join(targetBase, 'tools');
await fs.ensureDir(toolsDir);
for (const toolPath of files.tools) {
const fileName = path.basename(toolPath);
const sourcePath = path.join(sourceBase, 'tools', fileName);
const targetPath = path.join(toolsDir, fileName);
if (await fs.pathExists(sourcePath)) {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath);
this.installedFiles.add(targetPath);
}
}
}
if (files.templates && files.templates.length > 0) {
const templatesDir = path.join(targetBase, 'templates');
await fs.ensureDir(templatesDir);
for (const templatePath of files.templates) {
const fileName = path.basename(templatePath);
const sourcePath = path.join(sourceBase, 'templates', fileName);
const targetPath = path.join(templatesDir, fileName);
if (await fs.pathExists(sourcePath)) {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath);
this.installedFiles.add(targetPath);
}
}
}
if (files.data && files.data.length > 0) {
for (const dataPath of files.data) {
// Preserve directory structure for data files
const relative = path.relative(sourceBase, dataPath);
const targetPath = path.join(targetBase, relative);
await fs.ensureDir(path.dirname(targetPath));
if (await fs.pathExists(dataPath)) {
await this.copyFileWithPlaceholderReplacement(dataPath, targetPath);
this.installedFiles.add(targetPath);
}
}
}
// Create a marker file to indicate this is a partial installation
const markerPath = path.join(targetBase, '.partial');
await fs.writeFile(
markerPath,
`This module contains only dependencies required by other modules.\nInstalled: ${new Date().toISOString()}\n`,
);
}
/**
* Private: Install core
* @param {string} bmadDir - BMAD installation directory
*/
async installCore(bmadDir) {
const sourcePath = getModulePath('core');
const targetPath = path.join(bmadDir, 'core');
// Copy core files
await this.copyCoreFiles(sourcePath, targetPath);
}
/**
* Copy core files (similar to copyModuleWithFiltering but for core)
* @param {string} sourcePath - Source path
* @param {string} targetPath - Target path
*/
async copyCoreFiles(sourcePath, targetPath) {
// Get all files in source
const files = await this.getFileList(sourcePath);
for (const file of files) {
// Skip sub-modules directory - these are IDE-specific and handled separately
if (file.startsWith('sub-modules/')) {
continue;
}
// Skip module.yaml at root - it's only needed at install time
if (file === 'module.yaml') {
continue;
}
// Skip config.yaml templates - we'll generate clean ones with actual values
if (file === 'config.yaml' || file.endsWith('/config.yaml') || file === 'custom.yaml' || file.endsWith('/custom.yaml')) {
continue;
}
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Copy the file with placeholder replacement
await fs.ensureDir(path.dirname(targetFile));
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
// Track the installed file
this.installedFiles.add(targetFile);
}
}
/**
* Get list of all files in a directory recursively
* @param {string} dir - Directory path
* @param {string} baseDir - Base directory for relative paths
* @returns {Array} List of relative file paths
*/
async getFileList(dir, baseDir = dir) {
const files = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const subFiles = await this.getFileList(fullPath, baseDir);
files.push(...subFiles);
} else {
files.push(path.relative(baseDir, fullPath));
}
}
return files;
}
/**
* Private: Update core
*/
async updateCore(bmadDir, force = false) {
const sourcePath = getModulePath('core');
const targetPath = path.join(bmadDir, 'core');
if (force) {
await fs.remove(targetPath);
await this.installCore(bmadDir);
} else {
// Selective update - preserve user modifications
await this.fileOps.syncDirectory(sourcePath, targetPath);
}
}
/**
* Quick update method - preserves all settings and only prompts for new config fields
* @param {Object} config - Configuration with directory
* @returns {Object} Update result
*/
async quickUpdate(config) {
const spinner = await prompts.spinner();
spinner.start('Starting quick update...');
try {
const projectDir = path.resolve(config.directory);
const { bmadDir } = await this.findBmadDir(projectDir);
// Check if bmad directory exists
if (!(await fs.pathExists(bmadDir))) {
spinner.stop('No BMAD installation found');
throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`);
}
spinner.message('Detecting installed modules and configuration...');
// Detect existing installation
const existingInstall = await this.detector.detect(bmadDir);
const installedModules = existingInstall.modules.map((m) => m.id);
const configuredIdes = existingInstall.ides || [];
const projectRoot = path.dirname(bmadDir);
// Get custom module sources: first from --custom-content (re-cache from source), then from cache
const customModuleSources = new Map();
if (config.customContent?.sources?.length > 0) {
for (const source of config.customContent.sources) {
if (source.id && source.path && (await fs.pathExists(source.path))) {
customModuleSources.set(source.id, {
id: source.id,
name: source.name || source.id,
sourcePath: source.path,
cached: false, // From CLI, will be re-cached
});
}
}
}
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
const moduleId = cachedModule.name;
const cachedPath = path.join(cacheDir, moduleId);
// Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
if (!(await fs.pathExists(cachedPath))) {
continue;
}
if (!cachedModule.isDirectory()) {
continue;
}
// Skip if we already have this module from manifest
if (customModuleSources.has(moduleId)) {
continue;
}
// Check if this is an external official module - skip cache for those
const isExternal = await this.moduleManager.isExternalModule(moduleId);
if (isExternal) {
// External modules are handled via cloneExternalModule, not from cache
continue;
}
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
// For quick update, we always rebuild from cache
customModuleSources.set(moduleId, {
id: moduleId,
name: moduleId, // We'll read the actual name if needed
sourcePath: cachedPath,
cached: true, // Flag to indicate this is from cache
});
}
}
}
// Load saved IDE configurations
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
// Get available modules (what we have source for)
const availableModulesData = await this.moduleManager.listAvailable();
const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules];
// Add external official modules to available modules
// These can always be obtained by cloning from their remote URLs
const { ExternalModuleManager } = require('../modules/external-manager');
const externalManager = new ExternalModuleManager();
const externalModules = await externalManager.listAvailable();
for (const externalModule of externalModules) {
// Only add if not already in the list and is installed
if (installedModules.includes(externalModule.code) && !availableModules.some((m) => m.id === externalModule.code)) {
availableModules.push({
id: externalModule.code,
name: externalModule.name,
isExternal: true,
fromExternal: true,
});
}
}
// Add custom modules from manifest if their sources exist
for (const [moduleId, customModule] of customModuleSources) {
// Use the absolute sourcePath
const sourcePath = customModule.sourcePath;
// Check if source exists at the recorded path
if (
sourcePath &&
(await fs.pathExists(sourcePath)) && // Add to available modules if not already there
!availableModules.some((m) => m.id === moduleId)
) {
availableModules.push({
id: moduleId,
name: customModule.name || moduleId,
path: sourcePath,
isCustom: true,
fromManifest: true,
});
}
}
// Handle missing custom module sources using shared method
const customModuleResult = await this.handleMissingCustomSources(
customModuleSources,
bmadDir,
projectRoot,
'update',
installedModules,
config.skipPrompts || false,
);
const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
const customModulesFromManifest = validCustomModules.map((m) => ({
...m,
isCustom: true,
hasUpdate: true,
}));
const allAvailableModules = [...availableModules, ...customModulesFromManifest];
const availableModuleIds = new Set(allAvailableModules.map((m) => m.id));
// Core module is special - never include it in update flow
const nonCoreInstalledModules = installedModules.filter((id) => id !== 'core');
// Only update modules that are BOTH installed AND available (we have source for)
const modulesToUpdate = nonCoreInstalledModules.filter((id) => availableModuleIds.has(id));
const skippedModules = nonCoreInstalledModules.filter((id) => !availableModuleIds.has(id));
// Add custom modules that were kept without sources to the skipped modules
// This ensures their agents are preserved in the manifest
for (const keptModule of keptModulesWithoutSources) {
if (!skippedModules.includes(keptModule)) {
skippedModules.push(keptModule);
}
}
spinner.stop(`Found ${modulesToUpdate.length} module(s) to update and ${configuredIdes.length} configured tool(s)`);
if (skippedModules.length > 0) {
await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`);
}
// Load existing configs and collect new fields (if any)
await prompts.log.info('Checking for new configuration options...');
await this.configCollector.loadExistingConfig(projectDir);
let promptedForNewFields = false;
// Check core config for new fields
const corePrompted = await this.configCollector.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.configCollector.collectModuleConfigQuick(moduleName, projectDir, true);
if (modulePrompted) {
promptedForNewFields = true;
}
}
if (!promptedForNewFields) {
await prompts.log.success('All configuration is up to date, no new options to configure');
}
// Add metadata
this.configCollector.collectedConfig._meta = {
version: require(path.join(getProjectRoot(), 'package.json')).version,
installDate: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
// Build the config object for the installer
const installConfig = {
directory: projectDir,
installCore: true,
modules: modulesToUpdate, // Only update modules we have source for
ides: configuredIdes,
skipIde: configuredIdes.length === 0,
coreConfig: this.configCollector.collectedConfig.core,
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
_savedIdeConfigs: savedIdeConfigs, // Pass saved IDE configs to installer
_customModuleSources: customModuleSources, // Pass custom module sources for updates
_existingModules: installedModules, // Pass all installed modules for manifest generation
customContent: config.customContent, // Pass through for re-caching from source
};
// Call the standard install method
const result = await this.install(installConfig);
// Only succeed the spinner if it's still spinning
// (install method might have stopped it if folder name changed)
if (spinner.isSpinning) {
spinner.stop('Quick update complete!');
}
return {
success: true,
moduleCount: modulesToUpdate.length + 1, // +1 for core
hadNewFields: promptedForNewFields,
modules: ['core', ...modulesToUpdate],
skippedModules: skippedModules,
ides: configuredIdes,
};
} catch (error) {
spinner.error('Quick update failed');
throw error;
}
}
/**
* Private: Prompt for update action
*/
async promptUpdateAction() {
const action = await prompts.select({
message: 'What would you like to do?',
choices: [{ name: 'Update existing installation', value: 'update' }],
});
return { action };
}
/**
* 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');
}
/**
* Read files-manifest.csv
* @param {string} bmadDir - BMAD installation directory
* @returns {Array} Array of file entries from files-manifest.csv
*/
async readFilesManifest(bmadDir) {
const filesManifestPath = path.join(bmadDir, '_config', 'files-manifest.csv');
if (!(await fs.pathExists(filesManifestPath))) {
return [];
}
try {
const content = await fs.readFile(filesManifestPath, 'utf8');
const lines = content.split('\n');
const files = [];
for (let i = 1; i < lines.length; i++) {
// Skip header
const line = lines[i].trim();
if (!line) continue;
// Parse CSV line properly handling quoted values
const parts = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
} else {
current += char;
}
}
parts.push(current); // Add last part
if (parts.length >= 4) {
files.push({
type: parts[0],
name: parts[1],
module: parts[2],
path: parts[3],
hash: parts[4] || null, // Hash may not exist in old manifests
});
}
}
return files;
} catch (error) {
await prompts.log.warn('Could not read files-manifest.csv: ' + error.message);
return [];
}
}
/**
* Detect custom and modified files
* @param {string} bmadDir - BMAD installation directory
* @param {Array} existingFilesManifest - Previous files from files-manifest.csv
* @returns {Object} Object with customFiles and modifiedFiles arrays
*/
async detectCustomFiles(bmadDir, existingFilesManifest) {
const customFiles = [];
const modifiedFiles = [];
// Memory is always in _bmad/_memory
const bmadMemoryPath = '_memory';
// Check if the manifest has hashes - if not, we can't detect modifications
let manifestHasHashes = false;
if (existingFilesManifest && existingFilesManifest.length > 0) {
manifestHasHashes = existingFilesManifest.some((f) => f.hash);
}
// Build map of previously installed files from files-manifest.csv with their hashes
const installedFilesMap = new Map();
for (const fileEntry of existingFilesManifest) {
if (fileEntry.path) {
const absolutePath = path.join(bmadDir, fileEntry.path);
installedFilesMap.set(path.normalize(absolutePath), {
hash: fileEntry.hash,
relativePath: fileEntry.path,
});
}
}
// Recursively scan bmadDir for all files
const scanDirectory = async (dir) => {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip certain directories
if (entry.name === 'node_modules' || entry.name === '.git') {
continue;
}
await scanDirectory(fullPath);
} else if (entry.isFile()) {
const normalizedPath = path.normalize(fullPath);
const fileInfo = installedFilesMap.get(normalizedPath);
// Skip certain system files that are auto-generated
const relativePath = path.relative(bmadDir, fullPath);
const fileName = path.basename(fullPath);
// Skip _config directory EXCEPT for modified agent customizations
if (relativePath.startsWith('_config/') || relativePath.startsWith('_config\\')) {
// Special handling for .customize.yaml files - only preserve if modified
if (relativePath.includes('/agents/') && fileName.endsWith('.customize.yaml')) {
// Check if the customization file has been modified from manifest
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
if (await fs.pathExists(manifestPath)) {
const crypto = require('node:crypto');
const currentContent = await fs.readFile(fullPath, 'utf8');
const currentHash = crypto.createHash('sha256').update(currentContent).digest('hex');
const yaml = require('yaml');
const manifestContent = await fs.readFile(manifestPath, 'utf8');
const manifestData = yaml.parse(manifestContent);
const originalHash = manifestData.agentCustomizations?.[relativePath];
// Only add to customFiles if hash differs (user modified)
if (originalHash && currentHash !== originalHash) {
customFiles.push(fullPath);
}
}
}
continue;
}
if (relativePath.startsWith(bmadMemoryPath + '/') && path.dirname(relativePath).includes('-sidecar')) {
continue;
}
// Skip config.yaml files - these are regenerated on each install/update
if (fileName === 'config.yaml') {
continue;
}
if (!fileInfo) {
// File not in manifest = custom file
// EXCEPT: Agent .md files in module folders are generated files, not custom
// Only treat .md files under _config/agents/ as custom
if (!(fileName.endsWith('.md') && relativePath.includes('/agents/') && !relativePath.startsWith('_config/'))) {
customFiles.push(fullPath);
}
} else if (manifestHasHashes && fileInfo.hash) {
// File in manifest with hash - check if it was modified
const currentHash = await this.manifest.calculateFileHash(fullPath);
if (currentHash && currentHash !== fileInfo.hash) {
// Hash changed = file was modified
modifiedFiles.push({
path: fullPath,
relativePath: fileInfo.relativePath,
});
}
}
}
}
} catch {
// Ignore errors scanning directories
}
};
await scanDirectory(bmadDir);
return { customFiles, modifiedFiles };
}
/**
* Handle missing custom module sources interactively
* @param {Map} customModuleSources - Map of custom module ID to info
* @param {string} bmadDir - BMAD directory
* @param {string} projectRoot - Project root directory
* @param {string} operation - Current operation ('update', 'compile', etc.)
* @param {Array} installedModules - Array of installed module IDs (will be modified)
* @param {boolean} [skipPrompts=false] - Skip interactive prompts and keep all modules with missing sources
* @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array
*/
async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) {
const validCustomModules = [];
const keptModulesWithoutSources = []; // Track modules kept without sources
const customModulesWithMissingSources = [];
// Check which sources exist
for (const [moduleId, customInfo] of customModuleSources) {
if (await fs.pathExists(customInfo.sourcePath)) {
validCustomModules.push({
id: moduleId,
name: customInfo.name,
path: customInfo.sourcePath,
info: customInfo,
});
} else {
// For cached modules that are missing, we just skip them without prompting
if (customInfo.cached) {
// Skip cached modules without prompting
keptModulesWithoutSources.push({
id: moduleId,
name: customInfo.name,
cached: true,
});
} else {
customModulesWithMissingSources.push({
id: moduleId,
name: customInfo.name,
sourcePath: customInfo.sourcePath,
relativePath: customInfo.relativePath,
info: customInfo,
});
}
}
}
// If no missing sources, return immediately
if (customModulesWithMissingSources.length === 0) {
return {
validCustomModules,
keptModulesWithoutSources: [],
};
}
// Non-interactive mode: keep all modules with missing sources
if (skipPrompts) {
for (const missing of customModulesWithMissingSources) {
keptModulesWithoutSources.push(missing.id);
}
return { validCustomModules, keptModulesWithoutSources };
}
await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`);
let keptCount = 0;
let updatedCount = 0;
let removedCount = 0;
for (const missing of customModulesWithMissingSources) {
await prompts.log.message(
`${missing.name} (${missing.id})\n Original source: ${missing.relativePath}\n Full path: ${missing.sourcePath}`,
);
const choices = [
{
name: 'Keep installed (will not be processed)',
value: 'keep',
hint: 'Keep',
},
{
name: 'Specify new source location',
value: 'update',
hint: 'Update',
},
];
// Only add remove option if not just compiling agents
if (operation !== 'compile-agents') {
choices.push({
name: '⚠️ REMOVE module completely (destructive!)',
value: 'remove',
hint: 'Remove',
});
}
const action = await prompts.select({
message: `How would you like to handle "${missing.name}"?`,
choices,
});
switch (action) {
case 'update': {
// Use sync validation because @clack/prompts doesn't support async validate
const newSourcePath = await prompts.text({
message: 'Enter the new path to the custom module:',
default: missing.sourcePath,
validate: (input) => {
if (!input || input.trim() === '') {
return 'Please enter a path';
}
const expandedPath = path.resolve(input.trim());
if (!fs.pathExistsSync(expandedPath)) {
return 'Path does not exist';
}
// Check if it looks like a valid module
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
const agentsPath = path.join(expandedPath, 'agents');
const workflowsPath = path.join(expandedPath, 'workflows');
if (!fs.pathExistsSync(moduleYamlPath) && !fs.pathExistsSync(agentsPath) && !fs.pathExistsSync(workflowsPath)) {
return 'Path does not appear to contain a valid custom module';
}
return; // clack expects undefined for valid input
},
});
// Defensive: handleCancel should have exited, but guard against symbol propagation
if (typeof newSourcePath !== 'string') {
keptCount++;
keptModulesWithoutSources.push(missing.id);
continue;
}
// Update the source in manifest
const resolvedPath = path.resolve(newSourcePath.trim());
missing.info.sourcePath = resolvedPath;
// Remove relativePath - we only store absolute sourcePath now
delete missing.info.relativePath;
await this.manifest.addCustomModule(bmadDir, missing.info);
validCustomModules.push({
id: missing.id,
name: missing.name,
path: resolvedPath,
info: missing.info,
});
updatedCount++;
await prompts.log.success('Updated source location');
break;
}
case 'remove': {
// Extra confirmation for destructive remove
await prompts.log.error(
`WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!\n Module location: ${path.join(bmadDir, missing.id)}`,
);
const confirmDelete = await prompts.confirm({
message: 'Are you absolutely sure you want to delete this module?',
default: false,
});
if (confirmDelete) {
const typedConfirm = await prompts.text({
message: 'Type "DELETE" to confirm permanent deletion:',
validate: (input) => {
if (input !== 'DELETE') {
return 'You must type "DELETE" exactly to proceed';
}
return; // clack expects undefined for valid input
},
});
if (typedConfirm === 'DELETE') {
// Remove the module from filesystem and manifest
const modulePath = path.join(bmadDir, missing.id);
if (await fs.pathExists(modulePath)) {
const fsExtra = require('fs-extra');
await fsExtra.remove(modulePath);
await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`);
}
await this.manifest.removeModule(bmadDir, missing.id);
await this.manifest.removeCustomModule(bmadDir, missing.id);
await prompts.log.warn('Removed from manifest');
// Also remove from installedModules list
if (installedModules && installedModules.includes(missing.id)) {
const index = installedModules.indexOf(missing.id);
if (index !== -1) {
installedModules.splice(index, 1);
}
}
removedCount++;
await prompts.log.error(`"${missing.name}" has been permanently removed`);
} else {
await prompts.log.message('Removal cancelled - module will be kept');
keptCount++;
}
} else {
await prompts.log.message('Removal cancelled - module will be kept');
keptCount++;
}
break;
}
case 'keep': {
keptCount++;
keptModulesWithoutSources.push(missing.id);
await prompts.log.message('Module will be kept as-is');
break;
}
// No default
}
}
// Show summary
if (keptCount > 0 || updatedCount > 0 || removedCount > 0) {
let summary = 'Summary for custom modules with missing sources:';
if (keptCount > 0) summary += `\n${keptCount} module(s) kept as-is`;
if (updatedCount > 0) summary += `\n${updatedCount} module(s) updated with new sources`;
if (removedCount > 0) summary += `\n${removedCount} module(s) permanently deleted`;
await prompts.log.message(summary);
}
return {
validCustomModules,
keptModulesWithoutSources,
};
}
}
module.exports = { Installer };