Compare commits

..

No commits in common. "02a43f96d7692c4619a8660071675759326e32a1" and "7c498a9fbea44b1403a3fe5f948dbbed7f1c65ba" have entirely different histories.

12 changed files with 490 additions and 932 deletions

View File

@ -39,6 +39,7 @@ module.exports = {
if (config.actionType === 'cancel') { if (config.actionType === 'cancel') {
await prompts.log.warn('Installation cancelled.'); await prompts.log.warn('Installation cancelled.');
process.exit(0); process.exit(0);
return;
} }
// Handle quick update separately // Handle quick update separately
@ -46,14 +47,23 @@ module.exports = {
const result = await installer.quickUpdate(config); const result = await installer.quickUpdate(config);
await prompts.log.success('Quick update complete!'); await prompts.log.success('Quick update complete!');
await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`); await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`);
// Display version-specific end message
const { MessageLoader } = require('../installers/lib/message-loader');
const messageLoader = new MessageLoader();
await messageLoader.displayEndMessage();
process.exit(0); process.exit(0);
return;
} }
// Handle compile agents separately // Handle compile agents separately
if (config.actionType === 'compile-agents') { if (config.actionType === 'compile-agents') {
const result = await installer.compileAgents(config); const result = await installer.compileAgents(config);
await prompts.log.success('Agent recompilation complete!');
await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`); await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`);
process.exit(0); process.exit(0);
return;
} }
// Regular install/update flow // Regular install/update flow
@ -62,10 +72,16 @@ module.exports = {
// Check if installation was cancelled // Check if installation was cancelled
if (result && result.cancelled) { if (result && result.cancelled) {
process.exit(0); process.exit(0);
return;
} }
// Check if installation succeeded // Check if installation succeeded
if (result && result.success) { if (result && result.success) {
// Display version-specific end message from install-messages.yaml
const { MessageLoader } = require('../installers/lib/message-loader');
const messageLoader = new MessageLoader();
await messageLoader.displayEndMessage();
process.exit(0); process.exit(0);
} }
} catch (error) { } catch (error) {

View File

@ -14,11 +14,29 @@ startMessage: |
but anticipate no massive breaking changes but anticipate no massive breaking changes
- Groundwork in place for customization and community modules - Groundwork in place for customization and community modules
🌟 BMad is 100% free and open source. 📚 New Docs Site: http://docs.bmad-method.org/
- No gated Discord. No paywalls. No gated content. - High quality tutorials, guided walkthrough, and articles coming soon!
- We believe in empowering everyone, not just those who can pay. - Everything is free. No paywalls. No gated content.
- Knowledge should be shared, not sold. - Knowledge should be shared, not sold.
💡 Love BMad? Please star us on GitHub & subscribe on YouTube!
- GitHub: https://github.com/bmad-code-org/BMAD-METHOD/
- YouTube: https://www.youtube.com/@BMadCode
Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Display at the END of installation (after all setup completes)
endMessage: |
════════════════════════════════════════════════════════════════════════════════
✨ BMAD V6 BETA IS INSTALLED! Thank you for being part of this journey!
🌟 BMad is 100% free and open source.
- No gated Discord. No paywalls.
- We believe in empowering everyone, not just those who can pay.
🙏 SUPPORT BMAD DEVELOPMENT: 🙏 SUPPORT BMAD DEVELOPMENT:
- During the Beta, please give us feedback and raise issues on GitHub! - During the Beta, please give us feedback and raise issues on GitHub!
- Donate: https://buymeacoffee.com/bmad - Donate: https://buymeacoffee.com/bmad
@ -29,14 +47,13 @@ startMessage: |
- Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method - Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method
- For speaking inquiries or interviews, reach out to BMad on Discord! - For speaking inquiries or interviews, reach out to BMad on Discord!
⭐ HELP US GROW: 📚 RESOURCES:
- Docs: http://docs.bmad-method.org/ (bookmark it!)
- Changelog: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md
⭐⭐⭐ HELP US GROW:
- Star us on GitHub: https://github.com/bmad-code-org/BMAD-METHOD/ - Star us on GitHub: https://github.com/bmad-code-org/BMAD-METHOD/
- Subscribe on YouTube: https://www.youtube.com/@BMadCode - Subscribe on YouTube: https://www.youtube.com/@BMadCode
- Every star & sub helps us reach more developers! - Every star & sub helps us reach more developers!
Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md ════════════════════════════════════════════════════════════════════════════════
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# No end message - install summary and next steps are rendered by the installer
endMessage: ""

View File

@ -10,19 +10,6 @@ class ConfigCollector {
this.collectedConfig = {}; this.collectedConfig = {};
this.existingConfig = null; this.existingConfig = null;
this.currentProjectDir = null; this.currentProjectDir = null;
this._moduleManagerInstance = null;
}
/**
* Get or create a cached ModuleManager instance (lazy initialization)
* @returns {Object} ModuleManager instance
*/
_getModuleManager() {
if (!this._moduleManagerInstance) {
const { ModuleManager } = require('../modules/manager');
this._moduleManagerInstance = new ModuleManager();
}
return this._moduleManagerInstance;
} }
/** /**
@ -142,70 +129,6 @@ class ConfigCollector {
return foundAny; return foundAny;
} }
/**
* Pre-scan module schemas to gather metadata for the configuration gateway prompt.
* Returns info about which modules have configurable options.
* @param {Array} modules - List of non-core module names
* @returns {Promise<Array>} Array of {moduleName, displayName, questionCount, hasFieldsWithoutDefaults}
*/
async scanModuleSchemas(modules) {
const metadataFields = new Set(['code', 'name', 'header', 'subheader', 'default_selected']);
const results = [];
for (const moduleName of modules) {
// Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search
let moduleConfigPath = null;
const customPath = this.customModulePaths?.get(moduleName);
if (customPath) {
moduleConfigPath = path.join(customPath, 'module.yaml');
} else {
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
if (await fs.pathExists(standardPath)) {
moduleConfigPath = standardPath;
} else {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
}
}
}
if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) {
continue;
}
try {
const content = await fs.readFile(moduleConfigPath, 'utf8');
const moduleConfig = yaml.parse(content);
if (!moduleConfig) continue;
const displayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`;
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
const questionKeys = configKeys.filter((key) => {
if (metadataFields.has(key)) return false;
const item = moduleConfig[key];
return item && typeof item === 'object' && item.prompt;
});
const hasFieldsWithoutDefaults = questionKeys.some((key) => {
const item = moduleConfig[key];
return item.default === undefined || item.default === null || item.default === '';
});
results.push({
moduleName,
displayName,
questionCount: questionKeys.length,
hasFieldsWithoutDefaults,
});
} catch (error) {
await prompts.log.warn(`Could not read schema for module "${moduleName}": ${error.message}`);
}
}
return results;
}
/** /**
* Collect configuration for all modules * Collect configuration for all modules
* @param {Array} modules - List of modules to configure (including 'core') * @param {Array} modules - List of modules to configure (including 'core')
@ -218,7 +141,6 @@ class ConfigCollector {
// Store custom module paths for use in collectModuleConfig // Store custom module paths for use in collectModuleConfig
this.customModulePaths = options.customModulePaths || new Map(); this.customModulePaths = options.customModulePaths || new Map();
this.skipPrompts = options.skipPrompts || false; this.skipPrompts = options.skipPrompts || false;
this.modulesToCustomize = undefined;
await this.loadExistingConfig(projectDir); await this.loadExistingConfig(projectDir);
// Check if core was already collected (e.g., in early collection phase) // Check if core was already collected (e.g., in early collection phase)
@ -232,95 +154,10 @@ class ConfigCollector {
this.allAnswers = {}; this.allAnswers = {};
} }
// Split processing: core first, then gateway, then remaining modules for (const moduleName of allModules) {
const coreModules = allModules.filter((m) => m === 'core');
const nonCoreModules = allModules.filter((m) => m !== 'core');
// Collect core config first (always fully prompted)
for (const moduleName of coreModules) {
await this.collectModuleConfig(moduleName, projectDir); await this.collectModuleConfig(moduleName, projectDir);
} }
// Show batch configuration gateway for non-core modules
// Scan all non-core module schemas for display names and config metadata
let scannedModules = [];
if (!this.skipPrompts && nonCoreModules.length > 0) {
scannedModules = await this.scanModuleSchemas(nonCoreModules);
const customizableModules = scannedModules.filter((m) => m.questionCount > 0);
if (customizableModules.length > 0) {
const configMode = await prompts.select({
message: 'Module configuration',
choices: [
{ name: 'Express Setup', value: 'express', hint: 'accept all defaults (recommended)' },
{ name: 'Customize', value: 'customize', hint: 'choose modules to configure' },
],
default: 'express',
});
if (configMode === 'customize') {
const choices = customizableModules.map((m) => ({
name: `${m.displayName} (${m.questionCount} option${m.questionCount === 1 ? '' : 's'})`,
value: m.moduleName,
hint: m.hasFieldsWithoutDefaults ? 'has fields without defaults' : undefined,
checked: m.hasFieldsWithoutDefaults,
}));
const selected = await prompts.multiselect({
message: 'Select modules to customize:',
choices,
required: false,
});
this.modulesToCustomize = new Set(selected);
} else {
// Express mode: no modules to customize
this.modulesToCustomize = new Set();
}
} else {
// All non-core modules have zero config - no gateway needed
this.modulesToCustomize = new Set();
}
}
// Collect remaining non-core modules
if (this.modulesToCustomize === undefined) {
// No gateway was shown (skipPrompts, no non-core modules, or direct call) - process all normally
for (const moduleName of nonCoreModules) {
await this.collectModuleConfig(moduleName, projectDir);
}
} else {
// Split into default modules (tasks progress) and customized modules (interactive)
const defaultModules = nonCoreModules.filter((m) => !this.modulesToCustomize.has(m));
const customizeModules = nonCoreModules.filter((m) => this.modulesToCustomize.has(m));
// Run default modules with a single spinner
if (defaultModules.length > 0) {
// Build display name map from all scanned modules for pre-call spinner messages
const displayNameMap = new Map();
for (const m of scannedModules) {
displayNameMap.set(m.moduleName, m.displayName);
}
const configSpinner = await prompts.spinner();
configSpinner.start('Configuring modules...');
for (const moduleName of defaultModules) {
const displayName = displayNameMap.get(moduleName) || moduleName.toUpperCase();
configSpinner.message(`Configuring ${displayName}...`);
try {
this._silentConfig = true;
await this.collectModuleConfig(moduleName, projectDir);
} finally {
this._silentConfig = false;
}
}
configSpinner.stop('Module configuration complete');
}
// Run customized modules individually (may show interactive prompts)
for (const moduleName of customizeModules) {
await this.collectModuleConfig(moduleName, projectDir);
}
}
// Add metadata // Add metadata
this.collectedConfig._meta = { this.collectedConfig._meta = {
version: require(path.join(getProjectRoot(), 'package.json')).version, version: require(path.join(getProjectRoot(), 'package.json')).version,
@ -357,7 +194,10 @@ class ConfigCollector {
// If not found in src/modules, we need to find it by searching the project // If not found in src/modules, we need to find it by searching the project
if (!(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); // Use the module manager to find the module source
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
if (moduleSourcePath) { if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -371,7 +211,9 @@ class ConfigCollector {
configPath = moduleConfigPath; configPath = moduleConfigPath;
} else { } else {
// Check if this is a custom module with custom.yaml // Check if this is a custom module with custom.yaml
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
if (moduleSourcePath) { if (moduleSourcePath) {
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
@ -665,7 +507,10 @@ class ConfigCollector {
// If not found in src/modules or custom paths, search the project // If not found in src/modules or custom paths, search the project
if (!(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); // Use the module manager to find the module source
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
if (moduleSourcePath) { if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -734,12 +579,12 @@ class ConfigCollector {
} }
} }
} else { } else {
if (!this._silentConfig) await prompts.log.step(`Configuring ${moduleDisplayName}`); await prompts.log.step(moduleDisplayName);
let useDefaults = true; let customize = true;
if (moduleName === 'core') { if (moduleName === 'core') {
useDefaults = false; // Core: always show all questions // Core module: no confirm prompt, continues directly
} else if (this.modulesToCustomize === undefined) { } else {
// Fallback: original per-module confirm (backward compat for direct calls) // Non-core modules: show "Accept Defaults?" confirm prompt (clack adds spacing)
const customizeAnswer = await prompts.prompt([ const customizeAnswer = await prompts.prompt([
{ {
type: 'confirm', type: 'confirm',
@ -748,13 +593,10 @@ class ConfigCollector {
default: true, default: true,
}, },
]); ]);
useDefaults = customizeAnswer.customize; customize = customizeAnswer.customize;
} else {
// Batch mode: use defaults unless module was selected for customization
useDefaults = !this.modulesToCustomize.has(moduleName);
} }
if (useDefaults && moduleName !== 'core') { if (customize && moduleName !== 'core') {
// Accept defaults - only ask questions that have NO default value // Accept defaults - only ask questions that have NO default value
const questionsWithoutDefaults = questions.filter((q) => q.default === undefined || q.default === null || q.default === ''); const questionsWithoutDefaults = questions.filter((q) => q.default === undefined || q.default === null || q.default === '');
@ -884,7 +726,6 @@ class ConfigCollector {
const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key)); const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key));
const hasNoConfig = actualConfigKeys.length === 0; const hasNoConfig = actualConfigKeys.length === 0;
if (!this._silentConfig) {
if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) { if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) {
await prompts.log.step(moduleDisplayName); await prompts.log.step(moduleDisplayName);
if (moduleConfig.subheader) { if (moduleConfig.subheader) {
@ -897,7 +738,6 @@ class ConfigCollector {
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`); await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`);
} }
} }
}
// If we have no collected config for this module, but we have a module schema, // If we have no collected config for this module, but we have a module schema,
// ensure we have at least an empty object // ensure we have at least an empty object

View File

@ -7,7 +7,6 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('node:path'); const path = require('node:path');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const prompts = require('../../../lib/prompts');
class CustomModuleCache { class CustomModuleCache {
constructor(bmadDir) { constructor(bmadDir) {
@ -196,7 +195,7 @@ class CustomModuleCache {
// Verify cache integrity // Verify cache integrity
const currentCacheHash = await this.calculateHash(cacheDir); const currentCacheHash = await this.calculateHash(cacheDir);
if (currentCacheHash !== cached.cacheHash) { if (currentCacheHash !== cached.cacheHash) {
await prompts.log.warn(`Cache integrity check failed for ${moduleId}`); console.warn(`Warning: Cache integrity check failed for ${moduleId}`);
} }
return { return {

View File

@ -1,7 +1,6 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts');
/** /**
* Manages IDE configuration persistence * Manages IDE configuration persistence
@ -94,7 +93,7 @@ class IdeConfigManager {
const config = yaml.parse(content); const config = yaml.parse(content);
return config; return config;
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to load IDE config for ${ideName}: ${error.message}`); console.warn(`Warning: Failed to load IDE config for ${ideName}:`, error.message);
return null; return null;
} }
} }
@ -124,7 +123,7 @@ class IdeConfigManager {
} }
} }
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to load IDE configs: ${error.message}`); console.warn('Warning: Failed to load IDE configs:', error.message);
} }
return configs; return configs;

View File

@ -109,17 +109,9 @@ class Installer {
* @param {boolean} isFullReinstall - Whether this is a full reinstall * @param {boolean} isFullReinstall - Whether this is a full reinstall
* @param {Array} previousIdes - Previously configured IDEs (for reinstalls) * @param {Array} previousIdes - Previously configured IDEs (for reinstalls)
* @param {Array} preSelectedIdes - Pre-selected IDEs from early prompt (optional) * @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 * @returns {Object} Tool/IDE selection and configurations
*/ */
async collectToolConfigurations( async collectToolConfigurations(projectDir, selectedModules, isFullReinstall = false, previousIdes = [], preSelectedIdes = null) {
projectDir,
selectedModules,
isFullReinstall = false,
previousIdes = [],
preSelectedIdes = null,
skipPrompts = false,
) {
// Use pre-selected IDEs if provided, otherwise prompt // Use pre-selected IDEs if provided, otherwise prompt
let toolConfig; let toolConfig;
if (preSelectedIdes === null) { if (preSelectedIdes === null) {
@ -190,7 +182,6 @@ class Installer {
selectedModules: selectedModules || [], selectedModules: selectedModules || [],
projectDir, projectDir,
bmadDir, bmadDir,
skipPrompts,
}); });
} else { } else {
// Config-driven IDEs don't need configuration - mark as ready // Config-driven IDEs don't need configuration - mark as ready
@ -415,9 +406,6 @@ class Installer {
let action = null; let action = null;
if (config.actionType === 'update') { if (config.actionType === 'update') {
action = 'update'; action = 'update';
} else if (config.skipPrompts) {
// Non-interactive mode: default to update
action = 'update';
} else { } else {
// Fallback: Ask the user (backwards compatibility for other code paths) // Fallback: Ask the user (backwards compatibility for other code paths)
await prompts.log.warn('Existing BMAD installation detected'); await prompts.log.warn('Existing BMAD installation detected');
@ -443,16 +431,9 @@ class Installer {
// If there are modules to remove, ask for confirmation // If there are modules to remove, ask for confirmation
if (modulesToRemove.length > 0) { if (modulesToRemove.length > 0) {
if (config.skipPrompts) { const prompts = require('../../../lib/prompts');
// 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) { if (spinner.isSpinning) {
spinner.stop('Module changes reviewed'); spinner.stop('Reviewing module changes');
} }
await prompts.log.warn('Modules to be removed:'); await prompts.log.warn('Modules to be removed:');
@ -493,7 +474,6 @@ class Installer {
spinner.start('Preparing update...'); spinner.start('Preparing update...');
} }
}
// Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv) // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
const existingFilesManifest = await this.readFilesManifest(bmadDir); const existingFilesManifest = await this.readFilesManifest(bmadDir);
@ -704,7 +684,6 @@ class Installer {
config._isFullReinstall || false, config._isFullReinstall || false,
config._previouslyConfiguredIdes || [], config._previouslyConfiguredIdes || [],
preSelectedIdes, preSelectedIdes,
config.skipPrompts || false,
); );
} }
@ -713,80 +692,14 @@ class Installer {
config.skipIde = toolSelection.skipIde; config.skipIde = toolSelection.skipIde;
const ideConfigurations = toolSelection.configurations; const ideConfigurations = toolSelection.configurations;
// 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(projectDir);
}
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 // Results collector for consolidated summary
const results = []; const results = [];
const addResult = (step, status, detail = '') => results.push({ step, status, detail }); const addResult = (step, status, detail = '') => results.push({ step, status, detail });
if (spinner.isSpinning) { if (spinner.isSpinning) {
spinner.message('Preparing installation...'); spinner.message('Installing...');
} else { } else {
spinner.start('Preparing installation...'); spinner.start('Installing...');
} }
// Create bmad directory structure // Create bmad directory structure
@ -815,10 +728,20 @@ class Installer {
const projectRoot = getProjectRoot(); const projectRoot = getProjectRoot();
// Custom content is already handled in UI before module selection // Step 1: Install core module first (if requested)
const finalCustomContent = config.customContent; if (config.installCore) {
spinner.message('Installing BMAD core...');
await this.installCoreWithDependencies(bmadDir, { core: {} });
addResult('Core', 'ok', 'installed');
// Prepare modules list including cached custom modules // Generate core config file
await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} });
}
// Custom content is already handled in UI before module selection
let finalCustomContent = config.customContent;
// Step 3: Prepare modules list including cached custom modules
let allModules = [...(config.modules || [])]; let allModules = [...(config.modules || [])];
// During quick update, we might have custom module sources from the manifest // During quick update, we might have custom module sources from the manifest
@ -857,6 +780,8 @@ class Installer {
allModules = allModules.filter((m) => m !== 'core'); allModules = allModules.filter((m) => m !== 'core');
} }
const modulesToInstall = allModules;
// For dependency resolution, we only need regular modules (not custom modules) // 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 // Custom modules are already installed in _bmad and don't need dependency resolution from source
const regularModulesForResolution = allModules.filter((module) => { const regularModulesForResolution = allModules.filter((module) => {
@ -871,91 +796,70 @@ class Installer {
return !isCustom; return !isCustom;
}); });
// Stop spinner before tasks() takes over progress display // For dependency resolution, we need to pass the project root
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 // Create a temporary module manager that knows about custom content locations
const tempModuleManager = new ModuleManager({ const tempModuleManager = new ModuleManager({
bmadDir: bmadDir, bmadDir: bmadDir, // Pass bmadDir so we can check cache
}); });
taskResolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, { spinner.message('Resolving dependencies...');
const resolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, {
verbose: config.verbose, verbose: config.verbose,
moduleManager: tempModuleManager, moduleManager: tempModuleManager,
}); });
return 'Dependencies resolved';
},
});
// Module installation task // Install modules with their dependencies
if (allModules && allModules.length > 0) { 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(); const installedModuleNames = new Set();
for (const moduleName of allModules) { for (const moduleName of allModules) {
if (installedModuleNames.has(moduleName)) continue; // Skip if already installed
if (installedModuleNames.has(moduleName)) {
continue;
}
installedModuleNames.add(moduleName); installedModuleNames.add(moduleName);
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); // Show appropriate message based on whether this is a quick update
const isQuickUpdate = config._quickUpdate || false;
spinner.message(`${isQuickUpdate ? 'Updating' : 'Installing'} module: ${moduleName}...`);
// Check if this is a custom module // Check if this is a custom module
let isCustomModule = false; let isCustomModule = false;
let customInfo = null; let customInfo = null;
let useCache = false;
// First check if we have a cached version // First check if we have a cached version
if (finalCustomContent && finalCustomContent.cachedModules) { if (finalCustomContent && finalCustomContent.cachedModules) {
const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName); const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName);
if (cachedModule) { if (cachedModule) {
isCustomModule = true; isCustomModule = true;
customInfo = { id: moduleName, path: cachedModule.cachePath, config: {} }; customInfo = {
id: moduleName,
path: cachedModule.cachePath,
config: {},
};
useCache = true;
} }
} }
// Then check custom module sources from manifest (for quick update) // Then check if we have custom module sources from the manifest (for quick update)
if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) { if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) {
customInfo = config._customModuleSources.get(moduleName); customInfo = config._customModuleSources.get(moduleName);
isCustomModule = true; isCustomModule = true;
// Check if this is a cached module (source path starts with _config)
if ( if (
customInfo.sourcePath && customInfo.sourcePath &&
(customInfo.sourcePath.startsWith('_config') || customInfo.sourcePath.includes('_config/custom')) && (customInfo.sourcePath.startsWith('_config') || customInfo.sourcePath.includes('_config/custom'))
!customInfo.path ) {
) useCache = true;
// Make sure we have the right path structure
if (!customInfo.path) {
customInfo.path = customInfo.sourcePath; customInfo.path = customInfo.sourcePath;
} }
}
}
// Finally check regular custom content // Finally check regular custom content
if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
@ -971,12 +875,16 @@ class Installer {
} }
if (isCustomModule && customInfo) { if (isCustomModule && customInfo) {
// Custom modules are now installed via ModuleManager just like standard modules
// The custom module path should already be in customModulePaths from earlier setup
if (!customModulePaths.has(moduleName) && customInfo.path) { if (!customModulePaths.has(moduleName) && customInfo.path) {
customModulePaths.set(moduleName, customInfo.path); customModulePaths.set(moduleName, customInfo.path);
this.moduleManager.setCustomModulePaths(customModulePaths); this.moduleManager.setCustomModulePaths(customModulePaths);
} }
const collectedModuleConfig = moduleConfigs[moduleName] || {}; const collectedModuleConfig = moduleConfigs[moduleName] || {};
// Use ModuleManager to install the custom module
await this.moduleManager.install( await this.moduleManager.install(
moduleName, moduleName,
bmadDir, bmadDir,
@ -986,19 +894,19 @@ class Installer {
{ {
isCustom: true, isCustom: true,
moduleConfig: collectedModuleConfig, moduleConfig: collectedModuleConfig,
isQuickUpdate: isQuickUpdate, isQuickUpdate: config._quickUpdate || false,
installer: this, installer: this,
silent: true, silent: true,
}, },
); );
// Create module config (include collected config from module.yaml prompts)
await this.generateModuleConfigs(bmadDir, { await this.generateModuleConfigs(bmadDir, {
[moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig }, [moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig },
}); });
} else { } else {
if (!resolution || !resolution.byModule) { // Regular module installation
addResult(`Module: ${moduleName}`, 'warn', 'skipped (no resolution data)'); // Special case for core module
continue;
}
if (moduleName === 'core') { if (moduleName === 'core') {
await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]); await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]);
} else { } else {
@ -1010,9 +918,6 @@ class Installer {
} }
// Install partial modules (only dependencies) // 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)) { for (const [module, files] of Object.entries(resolution.byModule)) {
if (!allModules.includes(module) && module !== 'core') { if (!allModules.includes(module) && module !== 'core') {
const totalFiles = const totalFiles =
@ -1023,185 +928,107 @@ class Installer {
files.data.length + files.data.length +
files.other.length; files.other.length;
if (totalFiles > 0) { if (totalFiles > 0) {
message(`Installing ${module} dependencies...`); spinner.message(`Installing ${module} dependencies...`);
await this.installPartialModule(module, bmadDir, files); await this.installPartialModule(module, bmadDir, files);
} }
} }
} }
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
},
});
} }
// Module directory creation task // All content is now installed as modules - no separate custom content handling needed
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 // Generate clean config.yaml files for each installed module
spinner.message('Generating module configurations...');
await this.generateModuleConfigs(bmadDir, moduleConfigs); await this.generateModuleConfigs(bmadDir, moduleConfigs);
addResult('Configurations', 'ok', 'generated'); addResult('Configurations', 'ok', 'generated');
// Pre-register manifest files // Create agent configuration files
// Note: Legacy createAgentConfigs removed - using YAML customize system instead
// Customize templates are now created in processAgentFiles when building YAML agents
// Pre-register manifest files that will be created (except files-manifest.csv to avoid recursion)
const cfgDir = path.join(bmadDir, '_config'); const cfgDir = path.join(bmadDir, '_config');
this.installedFiles.add(path.join(cfgDir, 'manifest.yaml')); this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv')); this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv')); this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv')); this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv // This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
message('Generating manifests...'); spinner.message('Generating workflow and agent manifests...');
const manifestGen = new ManifestGenerator(); const manifestGen = new ManifestGenerator();
// For quick update, we need ALL installed modules in the manifest
// Not just the ones being updated
const allModulesForManifest = config._quickUpdate const allModulesForManifest = config._quickUpdate
? config._existingModules || allModules || [] ? config._existingModules || allModules || []
: config._preserveModules : config._preserveModules
? [...allModules, ...config._preserveModules] ? [...allModules, ...config._preserveModules]
: allModules || []; : allModules || [];
// For regular installs (including when called from quick update), use what we have
let modulesForCsvPreserve; let modulesForCsvPreserve;
if (config._quickUpdate) { if (config._quickUpdate) {
// Quick update - use existing modules or fall back to modules being updated
modulesForCsvPreserve = config._existingModules || allModules || []; modulesForCsvPreserve = config._existingModules || allModules || [];
} else { } else {
// Regular install - use the modules we're installing plus any preserved ones
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules; modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
} }
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], { const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [], ides: config.ides || [],
preservedModules: modulesForCsvPreserve, preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir
}); });
// Custom modules are now included in the main modules list - no separate tracking needed
addResult( addResult(
'Manifests', 'Manifests',
'ok', 'ok',
`${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`, `${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
); );
// Merge help catalogs // Merge all module-help.csv files into bmad-help.csv
message('Generating help catalog...'); // This must happen AFTER generateManifests because it depends on agent-manifest.csv
spinner.message('Generating workflow help catalog...');
await this.mergeModuleHelpCatalogs(bmadDir); await this.mergeModuleHelpCatalogs(bmadDir);
addResult('Help catalog', 'ok'); addResult('Help catalog', 'ok');
return 'Configurations generated'; // Configure IDEs and copy documentation
},
};
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) { if (!config.skipIde && config.ides && config.ides.length > 0) {
// Ensure IDE manager is initialized (handlers may not be loaded in quick update flow)
await this.ideManager.ensureInitialized(); await this.ideManager.ensureInitialized();
// Filter out any undefined/null values from the IDE list
const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string'); const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string');
if (validIdes.length === 0) { if (validIdes.length === 0) {
addResult('IDE configuration', 'warn', 'no valid IDEs selected'); addResult('IDE configuration', 'warn', 'no valid IDEs selected');
} else { } else {
// Check if any IDE might need prompting (no pre-collected config)
const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]); const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]);
const ideSpinner = await prompts.spinner();
ideSpinner.start('Configuring tools...'); // Temporarily suppress console output if not verbose
const originalLog = console.log;
if (!config.verbose) {
console.log = () => {};
}
try { try {
for (const ide of validIdes) { for (const ide of validIdes) {
if (!needsPrompting || ideConfigurations[ide]) { if (!needsPrompting || ideConfigurations[ide]) {
ideSpinner.message(`Configuring ${ide}...`); // All IDEs pre-configured, or this specific IDE has config: keep spinner running
spinner.message(`Configuring ${ide}...`);
} else { } else {
if (ideSpinner.isSpinning) { // This IDE needs prompting: stop spinner to allow user interaction
ideSpinner.stop('Ready for IDE configuration'); if (spinner.isSpinning) {
spinner.stop('Ready for IDE configuration');
} }
} }
// Suppress stray console output for pre-configured IDEs (no user interaction) // Silent when this IDE has pre-collected config (no prompts for THIS IDE)
const ideHasConfig = Boolean(ideConfigurations[ide]); const ideHasConfig = Boolean(ideConfigurations[ide]);
const originalLog = console.log;
if (!config.verbose && ideHasConfig) {
console.log = () => {};
}
try {
const setupResult = await this.ideManager.setup(ide, projectDir, bmadDir, { const setupResult = await this.ideManager.setup(ide, projectDir, bmadDir, {
selectedModules: allModules || [], selectedModules: allModules || [],
preCollectedConfig: ideConfigurations[ide] || null, preCollectedConfig: ideConfigurations[ide] || null,
@ -1209,49 +1036,80 @@ class Installer {
silent: ideHasConfig, silent: ideHasConfig,
}); });
// Save IDE configuration for future updates
if (ideConfigurations[ide] && !ideConfigurations[ide]._alreadyConfigured) { if (ideConfigurations[ide] && !ideConfigurations[ide]._alreadyConfigured) {
await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]); await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]);
} }
// Collect result for summary
if (setupResult.success) { if (setupResult.success) {
addResult(ide, 'ok', setupResult.detail || ''); addResult(ide, 'ok', setupResult.detail || '');
} else { } else {
addResult(ide, 'error', setupResult.error || 'failed'); addResult(ide, 'error', setupResult.error || 'failed');
} }
// Restart spinner if we stopped it for prompting
if (needsPrompting && !spinner.isSpinning) {
spinner.start('Configuring IDEs...');
}
}
} finally { } finally {
console.log = originalLog; console.log = originalLog;
} }
if (needsPrompting && !ideSpinner.isSpinning) {
ideSpinner.start('Configuring tools...');
}
}
} finally {
if (ideSpinner.isSpinning) {
ideSpinner.stop('Tool configuration complete');
}
}
} }
} }
// ───────────────────────────────────────────────────────────────────────── // Run module-specific installers after IDE setup
// SECOND TASKS BLOCK: Post-IDE operations (non-interactive) spinner.message('Running module-specific installers...');
// ─────────────────────────────────────────────────────────────────────────
const postIdeTasks = [];
// File restoration task (only for updates) // Create a conditional logger based on verbose mode
if ( const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose;
config._isUpdate && const moduleLogger = {
((config._customFiles && config._customFiles.length > 0) || (config._modifiedFiles && config._modifiedFiles.length > 0)) log: (msg) => (verboseMode ? console.log(msg) : {}), // Only log in verbose mode
) { error: (msg) => console.error(msg), // Always show errors
postIdeTasks.push({ warn: (msg) => console.warn(msg), // Always show warnings
title: 'Finalizing installation', };
task: async (message) => {
// Create directories for core module if core was installed
if (config.installCore || resolution.byModule.core) {
spinner.message('Creating core module directories...');
await this.moduleManager.createModuleDirectories('core', bmadDir, {
installedIDEs: config.ides || [],
moduleConfig: moduleConfigs.core || {},
coreConfig: moduleConfigs.core || {},
logger: moduleLogger,
silent: true,
});
}
// Create directories for user-selected modules
if (config.modules && config.modules.length > 0) {
for (const moduleName of config.modules) {
spinner.message(`Creating ${moduleName} module directories...`);
// Pass installed IDEs and module config to directory creator
await this.moduleManager.createModuleDirectories(moduleName, bmadDir, {
installedIDEs: config.ides || [],
moduleConfig: moduleConfigs[moduleName] || {},
coreConfig: moduleConfigs.core || {},
logger: moduleLogger,
silent: true,
});
}
}
addResult('Module installers', 'ok');
// Note: Manifest files are already created by ManifestGenerator above
// No need to create legacy manifest.csv anymore
// If this was an update, restore custom files
let customFiles = []; let customFiles = [];
let modifiedFiles = []; let modifiedFiles = [];
if (config._isUpdate) {
if (config._customFiles && config._customFiles.length > 0) { if (config._customFiles && config._customFiles.length > 0) {
message(`Restoring ${config._customFiles.length} custom files...`); spinner.message(`Restoring ${config._customFiles.length} custom files...`);
for (const originalPath of config._customFiles) { for (const originalPath of config._customFiles) {
const relativePath = path.relative(bmadDir, originalPath); const relativePath = path.relative(bmadDir, originalPath);
@ -1263,6 +1121,7 @@ class Installer {
} }
} }
// Clean up temp backup
if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) { if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) {
await fs.remove(config._tempBackupDir); await fs.remove(config._tempBackupDir);
} }
@ -1273,8 +1132,9 @@ class Installer {
if (config._modifiedFiles && config._modifiedFiles.length > 0) { if (config._modifiedFiles && config._modifiedFiles.length > 0) {
modifiedFiles = config._modifiedFiles; modifiedFiles = config._modifiedFiles;
// Restore modified files as .bak files
if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) { if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) {
message(`Restoring ${modifiedFiles.length} modified files as .bak...`); spinner.message(`Restoring ${modifiedFiles.length} modified files as .bak...`);
for (const modifiedFile of modifiedFiles) { for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path); const relativePath = path.relative(bmadDir, modifiedFile.path);
@ -1287,24 +1147,14 @@ class Installer {
} }
} }
// Clean up temp backup
await fs.remove(config._tempModifiedBackupDir); await fs.remove(config._tempModifiedBackupDir);
} }
} }
// Store for summary access
config._restoredCustomFiles = customFiles;
config._restoredModifiedFiles = modifiedFiles;
return 'Installation finalized';
},
});
} }
await prompts.tasks(postIdeTasks); // Stop the single installation spinner
spinner.stop('Installation complete');
// Retrieve restored file info for summary
const customFiles = config._restoredCustomFiles || [];
const modifiedFiles = config._restoredModifiedFiles || [];
// Render consolidated summary // Render consolidated summary
await this.renderInstallSummary(results, { await this.renderInstallSummary(results, {
@ -1323,15 +1173,7 @@ class Installer {
projectDir: projectDir, projectDir: projectDir,
}; };
} catch (error) { } catch (error) {
try {
if (spinner.isSpinning) {
spinner.error('Installation failed'); spinner.error('Installation failed');
} else {
await prompts.log.error('Installation failed');
}
} catch {
// Ensure the original error is never swallowed by a logging failure
}
throw error; throw error;
} }
} }
@ -1371,14 +1213,6 @@ class Installer {
lines.push(` ${color.yellow(`Modified files backed up (.bak): ${context.modifiedFiles.length}`)}`); lines.push(` ${color.yellow(`Modified files backed up (.bak): ${context.modifiedFiles.length}`)}`);
} }
// Next steps
lines.push(
'',
' Next steps:',
` Docs: ${color.dim('https://docs.bmad-method.org/')}`,
` Run ${color.cyan('/bmad-help')} in your IDE to get started`,
);
await prompts.note(lines.join('\n'), 'BMAD is ready to use!'); await prompts.note(lines.join('\n'), 'BMAD is ready to use!');
} }
@ -1463,7 +1297,6 @@ class Installer {
projectRoot, projectRoot,
'update', 'update',
existingInstall.modules.map((m) => m.id), existingInstall.modules.map((m) => m.id),
config.skipPrompts || false,
); );
spinner.start('Preparing update...'); spinner.start('Preparing update...');
@ -2327,7 +2160,6 @@ class Installer {
projectRoot, projectRoot,
'update', 'update',
installedModules, installedModules,
config.skipPrompts || false,
); );
const { validCustomModules, keptModulesWithoutSources } = customModuleResult; const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
@ -2585,9 +2417,7 @@ class Installer {
if (proceed === 'exit') { if (proceed === 'exit') {
await prompts.log.info('Please remove the .bmad-method folder and any v4 rules/commands, then run the installer again.'); 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 process.exit(0);
setImmediate(() => process.exit(0));
return;
} }
await prompts.log.warn('Proceeding with installation despite legacy v4 folder'); await prompts.log.warn('Proceeding with installation despite legacy v4 folder');
@ -2771,10 +2601,9 @@ class Installer {
* @param {string} projectRoot - Project root directory * @param {string} projectRoot - Project root directory
* @param {string} operation - Current operation ('update', 'compile', etc.) * @param {string} operation - Current operation ('update', 'compile', etc.)
* @param {Array} installedModules - Array of installed module IDs (will be modified) * @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 * @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array
*/ */
async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) { async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules) {
const validCustomModules = []; const validCustomModules = [];
const keptModulesWithoutSources = []; // Track modules kept without sources const keptModulesWithoutSources = []; // Track modules kept without sources
const customModulesWithMissingSources = []; const customModulesWithMissingSources = [];
@ -2817,14 +2646,6 @@ class Installer {
}; };
} }
// 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:`); await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`);
let keptCount = 0; let keptCount = 0;
@ -2889,13 +2710,6 @@ class Installer {
}, },
}); });
// 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 // Update the source in manifest
const resolvedPath = path.resolve(newSourcePath.trim()); const resolvedPath = path.resolve(newSourcePath.trim());
missing.info.sourcePath = resolvedPath; missing.info.sourcePath = resolvedPath;

View File

@ -3,7 +3,6 @@ const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getSourcePath, getModulePath } = require('../../../lib/project-root');
const prompts = require('../../../lib/prompts');
// Load package.json for version info // Load package.json for version info
const packageJson = require('../../../../../package.json'); const packageJson = require('../../../../../package.json');
@ -219,7 +218,7 @@ class ManifestGenerator {
} }
} }
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to parse workflow at ${fullPath}: ${error.message}`); console.warn(`Warning: Failed to parse workflow at ${fullPath}: ${error.message}`);
} }
} }
} }
@ -690,7 +689,7 @@ class ManifestGenerator {
return preservedRows; return preservedRows;
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to read existing CSV ${csvPath}: ${error.message}`); console.warn(`Warning: Failed to read existing CSV ${csvPath}:`, error.message);
return []; return [];
} }
} }
@ -916,7 +915,7 @@ class ManifestGenerator {
} }
} }
} catch (error) { } catch (error) {
await prompts.log.warn(`Could not scan for installed modules: ${error.message}`); console.warn(`Warning: Could not scan for installed modules: ${error.message}`);
} }
return modules; return modules;

View File

@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { getProjectRoot } = require('../../../lib/project-root'); const { getProjectRoot } = require('../../../lib/project-root');
const prompts = require('../../../lib/prompts');
class Manifest { class Manifest {
/** /**
@ -101,7 +100,7 @@ class Manifest {
ides: manifestData.ides || [], ides: manifestData.ides || [],
}; };
} catch (error) { } catch (error) {
await prompts.log.error(`Failed to read YAML manifest: ${error.message}`); console.error('Failed to read YAML manifest:', error.message);
} }
} }
@ -231,7 +230,7 @@ class Manifest {
const content = await fs.readFile(yamlPath, 'utf8'); const content = await fs.readFile(yamlPath, 'utf8');
return yaml.parse(content); return yaml.parse(content);
} catch (error) { } catch (error) {
await prompts.log.error(`Failed to read YAML manifest: ${error.message}`); console.error('Failed to read YAML manifest:', error.message);
} }
} }
@ -473,7 +472,7 @@ class Manifest {
} }
} }
} catch (error) { } catch (error) {
await prompts.log.warn(`Could not parse ${filePath}: ${error.message}`); console.warn(`Warning: Could not parse ${filePath}:`, error.message);
} }
} }
// Handle other file types (CSV, JSON, YAML, etc.) // Handle other file types (CSV, JSON, YAML, etc.)
@ -775,7 +774,7 @@ class Manifest {
configs[moduleName] = yaml.parse(content); configs[moduleName] = yaml.parse(content);
} }
} catch (error) { } catch (error) {
await prompts.log.warn(`Could not load config for module ${moduleName}: ${error.message}`); console.warn(`Could not load config for module ${moduleName}:`, error.message);
} }
} }
@ -877,7 +876,7 @@ class Manifest {
const pkg = require(packageJsonPath); const pkg = require(packageJsonPath);
version = pkg.version; version = pkg.version;
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to read package.json for ${moduleName}: ${error.message}`); console.warn(`Failed to read package.json for ${moduleName}: ${error.message}`);
} }
} }
} }
@ -905,7 +904,7 @@ class Manifest {
repoUrl: moduleConfig.repoUrl || null, repoUrl: moduleConfig.repoUrl || null,
}; };
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`); console.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`);
} }
} }

View File

@ -23,11 +23,6 @@ class CodexSetup extends BaseIdeSetup {
* @returns {Object} Collected configuration * @returns {Object} Collected configuration
*/ */
async collectConfiguration(options = {}) { async collectConfiguration(options = {}) {
// Non-interactive mode: use default (global)
if (options.skipPrompts) {
return { installLocation: 'global' };
}
let confirmed = false; let confirmed = false;
let installLocation = 'global'; let installLocation = 'global';

View File

@ -1,7 +1,6 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('node:path'); const path = require('node:path');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts');
/** /**
* Manages external official modules defined in external-official-modules.yaml * Manages external official modules defined in external-official-modules.yaml
@ -30,7 +29,7 @@ class ExternalModuleManager {
this.cachedModules = config; this.cachedModules = config;
return config; return config;
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to load external modules config: ${error.message}`); console.warn(`Failed to load external modules config: ${error.message}`);
return { modules: {} }; return { modules: {} };
} }
} }

View File

@ -452,7 +452,7 @@ class ModuleManager {
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) { } catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` ${error.message}`); if (!silent) await prompts.log.warn(` Warning: ${error.message}`);
} }
} else { } else {
// Check if package.json is newer than node_modules // Check if package.json is newer than node_modules
@ -478,7 +478,7 @@ class ModuleManager {
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) { } catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` ${error.message}`); if (!silent) await prompts.log.warn(` Warning: ${error.message}`);
} }
} }
} }
@ -541,7 +541,7 @@ class ModuleManager {
const customContent = await fs.readFile(rootCustomConfigPath, 'utf8'); const customContent = await fs.readFile(rootCustomConfigPath, 'utf8');
customConfig = yaml.parse(customContent); customConfig = yaml.parse(customContent);
} catch (error) { } catch (error) {
await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`); await prompts.log.warn(`Warning: Failed to read custom.yaml for ${moduleName}: ${error.message}`);
} }
} }
@ -549,7 +549,7 @@ class ModuleManager {
if (customConfig) { if (customConfig) {
options.moduleConfig = { ...options.moduleConfig, ...customConfig }; options.moduleConfig = { ...options.moduleConfig, ...customConfig };
if (options.logger) { if (options.logger) {
await options.logger.log(` Merged custom configuration for ${moduleName}`); options.logger.log(` Merged custom configuration for ${moduleName}`);
} }
} }
@ -970,7 +970,7 @@ class ModuleManager {
await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`); await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`);
} }
} else if (process.env.BMAD_VERBOSE_INSTALL === 'true') { } else if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.warn(` Agent marked as having sidecar but ${sidecarDirName} directory not found`); await prompts.log.warn(` Warning: Agent marked as having sidecar but ${sidecarDirName} directory not found`);
} }
} }
@ -1208,20 +1208,15 @@ class ModuleManager {
/** /**
* Create directories declared in module.yaml's `directories` key * Create directories declared in module.yaml's `directories` key
* This replaces the security-risky module installer pattern with declarative config * This replaces the security-risky module installer pattern with declarative config
* During updates, if a directory path changed, moves the old directory to the new path
* @param {string} moduleName - Name of the module * @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory * @param {string} bmadDir - Target bmad directory
* @param {Object} options - Installation options * @param {Object} options - Installation options
* @param {Object} options.moduleConfig - Module configuration from config collector * @param {Object} options.moduleConfig - Module configuration from config collector
* @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates)
* @param {Object} options.coreConfig - Core configuration * @param {Object} options.coreConfig - Core configuration
* @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
*/ */
async createModuleDirectories(moduleName, bmadDir, options = {}) { async createModuleDirectories(moduleName, bmadDir, options = {}) {
const moduleConfig = options.moduleConfig || {}; const moduleConfig = options.moduleConfig || {};
const existingModuleConfig = options.existingModuleConfig || {};
const projectRoot = path.dirname(bmadDir); const projectRoot = path.dirname(bmadDir);
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
// Special handling for core module - it's in src/core not src/modules // Special handling for core module - it's in src/core not src/modules
let sourcePath; let sourcePath;
@ -1230,14 +1225,14 @@ class ModuleManager {
} else { } else {
sourcePath = await this.findModuleSource(moduleName, { silent: true }); sourcePath = await this.findModuleSource(moduleName, { silent: true });
if (!sourcePath) { if (!sourcePath) {
return emptyResult; // No source found, skip return; // No source found, skip
} }
} }
// Read module.yaml to find the `directories` key // Read module.yaml to find the `directories` key
const moduleYamlPath = path.join(sourcePath, 'module.yaml'); const moduleYamlPath = path.join(sourcePath, 'module.yaml');
if (!(await fs.pathExists(moduleYamlPath))) { if (!(await fs.pathExists(moduleYamlPath))) {
return emptyResult; // No module.yaml, skip return; // No module.yaml, skip
} }
let moduleYaml; let moduleYaml;
@ -1245,18 +1240,17 @@ class ModuleManager {
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
moduleYaml = yaml.parse(yamlContent); moduleYaml = yaml.parse(yamlContent);
} catch { } catch {
return emptyResult; // Invalid YAML, skip return; // Invalid YAML, skip
} }
if (!moduleYaml || !moduleYaml.directories) { if (!moduleYaml || !moduleYaml.directories) {
return emptyResult; // No directories declared, skip return; // No directories declared, skip
} }
// Get color utility for styled output
const color = await prompts.getColor();
const directories = moduleYaml.directories; const directories = moduleYaml.directories;
const wdsFolders = moduleYaml.wds_folders || []; const wdsFolders = moduleYaml.wds_folders || [];
const createdDirs = [];
const movedDirs = [];
const createdWdsFolders = [];
for (const dirRef of directories) { for (const dirRef of directories) {
// Parse variable reference like "{design_artifacts}" // Parse variable reference like "{design_artifacts}"
@ -1285,96 +1279,29 @@ class ModuleManager {
const normalizedPath = path.normalize(fullPath); const normalizedPath = path.normalize(fullPath);
const normalizedRoot = path.normalize(projectRoot); const normalizedRoot = path.normalize(projectRoot);
if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) { if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) {
const color = await prompts.getColor(); await prompts.log.warn(color.yellow(`Warning: ${configKey} path escapes project root, skipping: ${dirPath}`));
await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`));
continue; continue;
} }
// Check if directory path changed from previous config (update/modify scenario) // Create directory if it doesn't exist
const oldDirValue = existingModuleConfig[configKey]; if (!(await fs.pathExists(fullPath))) {
let oldFullPath = null;
let oldDirPath = null;
if (oldDirValue && typeof oldDirValue === 'string') {
// F3: Normalize both values before comparing to avoid false negatives
// from trailing slashes, separator differences, or prefix format variations
let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, '');
normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', ''));
const normalizedNew = path.normalize(dirPath);
if (normalizedOld !== normalizedNew) {
oldDirPath = normalizedOld;
oldFullPath = path.join(projectRoot, oldDirPath);
const normalizedOldAbsolute = path.normalize(oldFullPath);
if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) {
oldFullPath = null; // Old path escapes project root, ignore it
}
// F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2)
if (oldFullPath) {
const normalizedNewAbsolute = path.normalize(fullPath);
if (
normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) ||
normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep)
) {
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`,
),
);
oldFullPath = null;
}
}
}
}
const dirName = configKey.replaceAll('_', ' '); const dirName = configKey.replaceAll('_', ' ');
await prompts.log.message(color.yellow(`Creating ${dirName} directory: ${dirPath}`));
if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) {
// Path changed and old dir exists → move old to new location
// F1: Use fs.move() instead of fs.rename() for cross-device/volume support
// F2: Wrap in try/catch — fallback to creating new dir on failure
try {
await fs.ensureDir(path.dirname(fullPath));
await fs.move(oldFullPath, fullPath);
movedDirs.push(`${dirName}: ${oldDirPath}${dirPath}`);
} catch (moveError) {
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`Failed to move ${oldDirPath}${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`,
),
);
await fs.ensureDir(fullPath);
createdDirs.push(`${dirName}: ${dirPath}`);
}
} else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) {
// F5: Both old and new directories exist — warn user about potential orphaned documents
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`,
),
);
} else if (!(await fs.pathExists(fullPath))) {
// New directory doesn't exist yet → create it
createdDirs.push(`${dirName}: ${dirPath}`);
await fs.ensureDir(fullPath); await fs.ensureDir(fullPath);
} }
// Create WDS subfolders if this is the design_artifacts directory // Create WDS subfolders if this is the design_artifacts directory
if (configKey === 'design_artifacts' && wdsFolders.length > 0) { if (configKey === 'design_artifacts' && wdsFolders.length > 0) {
await prompts.log.message(color.cyan('Creating WDS folder structure...'));
for (const subfolder of wdsFolders) { for (const subfolder of wdsFolders) {
const subPath = path.join(fullPath, subfolder); const subPath = path.join(fullPath, subfolder);
if (!(await fs.pathExists(subPath))) { if (!(await fs.pathExists(subPath))) {
await fs.ensureDir(subPath); await fs.ensureDir(subPath);
createdWdsFolders.push(subfolder); await prompts.log.message(color.dim(`${subfolder}/`));
} }
} }
} }
} }
return { createdDirs, movedDirs, createdWdsFolders };
} }
/** /**

View File

@ -189,7 +189,7 @@ class UI {
const installedVersion = existingInstall.version || 'unknown'; const installedVersion = existingInstall.version || 'unknown';
// Check if version is pre beta // Check if version is pre beta
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options); const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir));
// If user chose to cancel, exit the installer // If user chose to cancel, exit the installer
if (!shouldProceed) { if (!shouldProceed) {
@ -227,14 +227,6 @@ class UI {
} }
actionType = options.action; actionType = options.action;
await prompts.log.info(`Using action from command-line: ${actionType}`); await prompts.log.info(`Using action from command-line: ${actionType}`);
} else if (options.yes) {
// Default to quick-update if available, otherwise first available choice
if (choices.length === 0) {
throw new Error('No valid actions available for this installation');
}
const hasQuickUpdate = choices.some((c) => c.value === 'quick-update');
actionType = hasQuickUpdate ? 'quick-update' : choices[0].value;
await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`);
} else { } else {
actionType = await prompts.select({ actionType = await prompts.select({
message: 'How would you like to proceed?', message: 'How would you like to proceed?',
@ -250,7 +242,6 @@ class UI {
actionType: 'quick-update', actionType: 'quick-update',
directory: confirmedDirectory, directory: confirmedDirectory,
customContent: { hasCustomContent: false }, customContent: { hasCustomContent: false },
skipPrompts: options.yes || false,
}; };
} }
@ -261,7 +252,6 @@ class UI {
actionType: 'compile-agents', actionType: 'compile-agents',
directory: confirmedDirectory, directory: confirmedDirectory,
customContent: { hasCustomContent: false }, customContent: { hasCustomContent: false },
skipPrompts: options.yes || false,
}; };
} }
@ -282,11 +272,6 @@ class UI {
.map((m) => m.trim()) .map((m) => m.trim())
.filter(Boolean); .filter(Boolean);
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`); await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
} else if (options.yes) {
selectedModules = await this.getDefaultModules(installedModuleIds);
await prompts.log.info(
`Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`,
);
} else { } else {
selectedModules = await this.selectAllModules(installedModuleIds); selectedModules = await this.selectAllModules(installedModuleIds);
} }
@ -345,22 +330,6 @@ class UI {
}, },
}; };
} }
} else if (options.yes) {
// Non-interactive mode: preserve existing custom modules (matches default: false)
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
customModuleResult.selectedCustomModules.push(entry.name);
}
}
await prompts.log.info(
`Non-interactive mode (--yes): preserving ${customModuleResult.selectedCustomModules.length} existing custom module(s)`,
);
} else {
await prompts.log.info('Non-interactive mode (--yes): no existing custom modules found');
}
} else { } else {
const changeCustomModules = await prompts.confirm({ const changeCustomModules = await prompts.confirm({
message: 'Modify custom modules, agents, or workflows?', message: 'Modify custom modules, agents, or workflows?',
@ -409,7 +378,6 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: coreConfig, coreConfig: coreConfig,
customContent: customModuleResult.customContentConfig, customContent: customModuleResult.customContentConfig,
skipPrompts: options.yes || false,
}; };
} }
} }
@ -561,27 +529,6 @@ class UI {
if (configuredIdes.length > 0) { if (configuredIdes.length > 0) {
const allTools = [...preferredIdes, ...otherIdes]; const allTools = [...preferredIdes, ...otherIdes];
// Non-interactive: handle --tools and --yes flags before interactive prompt
if (options.tools) {
if (options.tools.toLowerCase() === 'none') {
await prompts.log.info('Skipping tool configuration (--tools none)');
return { ides: [], skipIde: true };
}
const selectedIdes = options.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean);
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
return { ides: selectedIdes, skipIde: false };
}
if (options.yes) {
await prompts.log.info(`Non-interactive mode (--yes): keeping configured tools: ${configuredIdes.join(', ')}`);
await this.displaySelectedTools(configuredIdes, preferredIdes, allTools);
return { ides: configuredIdes, skipIde: false };
}
// Sort: configured tools first, then preferred, then others // Sort: configured tools first, then preferred, then others
const sortedTools = [ const sortedTools = [
...allTools.filter((ide) => configuredIdes.includes(ide.value)), ...allTools.filter((ide) => configuredIdes.includes(ide.value)),
@ -744,6 +691,18 @@ class UI {
}); });
} }
/**
* Display installation summary
* @param {Object} result - Installation result
*/
async showInstallSummary(result) {
let summary = `Installed to: ${result.path}`;
if (result.modules && result.modules.length > 0) {
summary += `\nModules: ${result.modules.join(', ')}`;
}
await prompts.note(summary, 'BMAD is ready to use!');
}
/** /**
* Get confirmed directory from user * Get confirmed directory from user
* @returns {string} Confirmed directory path * @returns {string} Confirmed directory path
@ -1683,7 +1642,7 @@ class UI {
* @param {string} bmadFolderName - Name of the BMAD folder * @param {string} bmadFolderName - Name of the BMAD folder
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel * @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
*/ */
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) { async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName) {
if (!this.isLegacyVersion(installedVersion)) { if (!this.isLegacyVersion(installedVersion)) {
return true; // Not legacy, proceed return true; // Not legacy, proceed
} }
@ -1709,11 +1668,6 @@ class UI {
await prompts.log.warn('VERSION WARNING'); await prompts.log.warn('VERSION WARNING');
await prompts.note(warningContent, 'Version Warning'); await prompts.note(warningContent, 'Version Warning');
if (options.yes) {
await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update');
return true;
}
const proceed = await prompts.select({ const proceed = await prompts.select({
message: 'How would you like to proceed?', message: 'How would you like to proceed?',
choices: [ choices: [