fix: installer fixes and non-interactive mode improvements (#1612)

* fix: remove duplicate 'recompilation complete' message in compile-agents output

The spinner.stop() in compileAgents() already displays this message,
so the additional log.success() call produced a redundant line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove deselected IDE configurations during installer update

When updating an existing installation, IDEs that were previously
configured but unchecked in the new selection are now detected and
cleaned up after user confirmation, mirroring the existing module
removal flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove unreachable return statements after process.exit calls

Remove 4 dead `return;` statements that immediately follow `process.exit(0)`
calls in install.js. Since process.exit() terminates the process, the
subsequent return statements are unreachable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: batch directory creation log messages for cleaner installer output

Collect all created directory names during module directory setup and
emit them as a single log message instead of one per directory. This
eliminates the excessive blank-line spacing that @clack/prompts adds
between individual log.message() calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: consolidate directory creation output across all modules

Move directory creation logging from ModuleManager.createModuleDirectories
into the installer caller. The method now returns created directory info
instead of logging directly, allowing the installer to batch all module
directories into a single log message under one spinner. Also adds spacing
before the final "Installation complete" status line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: propagate skipPrompts flag to IDE collectConfiguration calls

The --yes flag (skipPrompts) was not being passed through to IDE handler
collectConfiguration() calls, causing the Codex installer to hang on its
interactive prompt in non-interactive mode (CI/CD, --yes flag).

- Add skipPrompts parameter to collectToolConfigurations() and forward it
  to handler.collectConfiguration() options
- Add early return in CodexSetup.collectConfiguration() when skipPrompts
  is true, defaulting to global install location
- Guard IDE removal confirmation prompt with skipPrompts check, defaulting
  to preserve existing configs (consistent with prompt default: false)

Fixes #1610

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings from PR #1612

- Restore saved IDE configurations when removal is cancelled or skipped
  in non-interactive mode, preventing silent config downgrade (e.g.,
  Codex 'project' install reverting to 'global')
- Short-circuit IDE removal block when skipPrompts is true to eliminate
  unnecessary warning/cancelled log output in --yes mode
- Add null guard on config.ides before pushing re-added IDEs
- Return empty result object from createModuleDirectories early exits
  instead of undefined for a uniform return contract

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: guard remaining unguarded prompts with skipPrompts for non-interactive mode

Add skipPrompts guards to 4 remaining unguarded interactive prompts in
installer.js that caused hangs in non-interactive mode (--yes flag):
- Module removal confirmation: preserves modules by default
- Update action selection: defaults to 'update'
- Custom module missing sources: keeps all modules
- Custom module delete confirmation: unreachable via early return

Additional robustness fixes:
- Defensive type-check before .trim() on prompt result (symbol guard)
- Console.log suppression scoped per-IDE instead of global try/finally
- process.exit flush via setImmediate for legacy v4 exit path
- JSDoc updated for new skipPrompts parameter

Follows established pattern from commit f967fdde (IDE skipPrompts guards).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: migrate installer console.warn/error calls to prompts.log API

Replace all user-facing console.warn(), console.error(), and console.log()
calls with their prompts.log.* equivalents for consistent @clack/prompts
styled output across the installer codebase.

- Migrate 13 console.warn/error calls across 5 files to prompts.log.*
- Convert moduleLogger callbacks to async prompts.log.* in installer.js
- Replace blank-line console.log() with prompts.log.message('')
- Add prompts import to 5 files that lacked it
- Remove redundant "Warning:" prefixes (prompts.log.warn adds styling)
- Remove redundant local prompts require in installer.js L454
- Add missing await on logger.log call in manager.js
- Debug-gated console.log calls in manifest-generator.js left as-is

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: replace installer spinner with tasks() progress and consolidate summary

Replace the serial spinner during non-interactive install phases with
@clack/prompts tasks() component for clearer progress visibility. The
install flow now uses two tasks() blocks (pre-IDE and post-IDE) with
the IDE setup retaining its own spinner since it may prompt.

- Refactor install phases into tasks() callbacks with message() updates
- Merge next-steps content into the "BMAD is ready to use!" summary note
- Fix spinner.stop() tense: "Reviewing..." → past tense ("reviewed")
- Move directory creation output after tasks() to avoid breaking progress
- Remove dead showInstallSummary() from ui.js
- Harden error handling: try/finally on IDE spinner, safe catch block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: consolidate install messages into single start banner

Combine start and end marketing messages into one banner shown before
installation begins. Remove the post-install end message and its
displayEndMessage() calls — the install summary note now serves as
the final output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply prettier formatting to install command files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: rename "Module installers" label to "Module directories" in summary

The old script-based module installer pattern was replaced with
declarative directory creation, but the task title and summary label
were never updated to reflect that change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add batch module configuration gateway with express/customize modes

Replace N individual "Accept Defaults?" confirm prompts with a single
select gateway ("Express Setup" vs "Customize"). When customizing, a
multiselect shows only modules with configurable options. All others
silently receive defaults via spinner progress.

- Add scanModuleSchemas() to pre-scan module metadata and display names
- Add select/multiselect gateway in collectAllConfigurations()
- Replace per-module confirm with modulesToCustomize Set check
- Suppress UI output during silent default config via _silentConfig flag
- Reorder installer tasks: module dirs before config generation
- Add resolution null guards for edge-case safety
- Cache ModuleManager instance via _getModuleManager() for reuse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move module directories instead of creating new ones on path change

When users modify a module's directory path during installer update/modify,
the old directory is now moved to the new location instead of creating an
empty directory while leaving the old one (with its documents) behind.

Includes: cross-device fs.move, error handling with fallback, path
normalization, parent/child path guard, and warning when both dirs exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add --yes flag guards to all unguarded prompts in update/modify path

Guard 5 interactive prompts in ui.js that caused the installer to hang
when --yes flag was used with existing installations. Add skipPrompts
field to 3 return objects that were missing it, ensuring installer.js
downstream guards work correctly for all update paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Davor Racic 2026-02-10 02:32:38 +01:00 committed by GitHub
parent 36c21dbada
commit 0bf8e0edfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 920 additions and 478 deletions

View File

@ -39,7 +39,6 @@ module.exports = {
if (config.actionType === 'cancel') {
await prompts.log.warn('Installation cancelled.');
process.exit(0);
return;
}
// Handle quick update separately
@ -47,23 +46,14 @@ module.exports = {
const result = await installer.quickUpdate(config);
await prompts.log.success('Quick update complete!');
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);
return;
}
// Handle compile agents separately
if (config.actionType === 'compile-agents') {
const result = await installer.compileAgents(config);
await prompts.log.success('Agent recompilation complete!');
await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`);
process.exit(0);
return;
}
// Regular install/update flow
@ -72,16 +62,10 @@ module.exports = {
// Check if installation was cancelled
if (result && result.cancelled) {
process.exit(0);
return;
}
// Check if installation succeeded
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);
}
} catch (error) {

View File

@ -14,28 +14,10 @@ startMessage: |
but anticipate no massive breaking changes
- Groundwork in place for customization and community modules
📚 New Docs Site: http://docs.bmad-method.org/
- High quality tutorials, guided walkthrough, and articles coming soon!
- Everything is free. No paywalls. No gated content.
- 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.
- No gated Discord. No paywalls. No gated content.
- We believe in empowering everyone, not just those who can pay.
- Knowledge should be shared, not sold.
🙏 SUPPORT BMAD DEVELOPMENT:
- During the Beta, please give us feedback and raise issues on GitHub!
@ -47,13 +29,14 @@ endMessage: |
- Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method
- For speaking inquiries or interviews, reach out to BMad on Discord!
📚 RESOURCES:
- Docs: http://docs.bmad-method.org/ (bookmark it!)
- Changelog: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md
⭐⭐⭐ HELP US GROW:
⭐ HELP US GROW:
- Star us on GitHub: https://github.com/bmad-code-org/BMAD-METHOD/
- Subscribe on YouTube: https://www.youtube.com/@BMadCode
- 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,6 +10,19 @@ class ConfigCollector {
this.collectedConfig = {};
this.existingConfig = 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;
}
/**
@ -129,6 +142,70 @@ class ConfigCollector {
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
* @param {Array} modules - List of modules to configure (including 'core')
@ -141,6 +218,7 @@ class ConfigCollector {
// Store custom module paths for use in collectModuleConfig
this.customModulePaths = options.customModulePaths || new Map();
this.skipPrompts = options.skipPrompts || false;
this.modulesToCustomize = undefined;
await this.loadExistingConfig(projectDir);
// Check if core was already collected (e.g., in early collection phase)
@ -154,10 +232,95 @@ class ConfigCollector {
this.allAnswers = {};
}
for (const moduleName of allModules) {
// Split processing: core first, then gateway, then remaining modules
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);
}
// 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
this.collectedConfig._meta = {
version: require(path.join(getProjectRoot(), 'package.json')).version,
@ -194,10 +357,7 @@ class ConfigCollector {
// If not found in src/modules, we need to find it by searching the project
if (!(await fs.pathExists(moduleConfigPath))) {
// Use the module manager to find the module source
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -211,9 +371,7 @@ class ConfigCollector {
configPath = moduleConfigPath;
} else {
// Check if this is a custom module with custom.yaml
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) {
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
@ -507,10 +665,7 @@ class ConfigCollector {
// If not found in src/modules or custom paths, search the project
if (!(await fs.pathExists(moduleConfigPath))) {
// Use the module manager to find the module source
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -579,12 +734,12 @@ class ConfigCollector {
}
}
} else {
await prompts.log.step(moduleDisplayName);
let customize = true;
if (!this._silentConfig) await prompts.log.step(`Configuring ${moduleDisplayName}`);
let useDefaults = true;
if (moduleName === 'core') {
// Core module: no confirm prompt, continues directly
} else {
// Non-core modules: show "Accept Defaults?" confirm prompt (clack adds spacing)
useDefaults = false; // Core: always show all questions
} else if (this.modulesToCustomize === undefined) {
// Fallback: original per-module confirm (backward compat for direct calls)
const customizeAnswer = await prompts.prompt([
{
type: 'confirm',
@ -593,10 +748,13 @@ class ConfigCollector {
default: true,
},
]);
customize = customizeAnswer.customize;
useDefaults = customizeAnswer.customize;
} else {
// Batch mode: use defaults unless module was selected for customization
useDefaults = !this.modulesToCustomize.has(moduleName);
}
if (customize && moduleName !== 'core') {
if (useDefaults && moduleName !== 'core') {
// Accept defaults - only ask questions that have NO default value
const questionsWithoutDefaults = questions.filter((q) => q.default === undefined || q.default === null || q.default === '');
@ -726,6 +884,7 @@ class ConfigCollector {
const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key));
const hasNoConfig = actualConfigKeys.length === 0;
if (!this._silentConfig) {
if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) {
await prompts.log.step(moduleDisplayName);
if (moduleConfig.subheader) {
@ -738,6 +897,7 @@ class ConfigCollector {
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`);
}
}
}
// If we have no collected config for this module, but we have a module schema,
// ensure we have at least an empty object

View File

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

View File

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

View File

@ -109,9 +109,17 @@ class Installer {
* @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) {
async collectToolConfigurations(
projectDir,
selectedModules,
isFullReinstall = false,
previousIdes = [],
preSelectedIdes = null,
skipPrompts = false,
) {
// Use pre-selected IDEs if provided, otherwise prompt
let toolConfig;
if (preSelectedIdes === null) {
@ -182,6 +190,7 @@ class Installer {
selectedModules: selectedModules || [],
projectDir,
bmadDir,
skipPrompts,
});
} else {
// Config-driven IDEs don't need configuration - mark as ready
@ -406,6 +415,9 @@ class Installer {
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');
@ -431,9 +443,16 @@ class Installer {
// If there are modules to remove, ask for confirmation
if (modulesToRemove.length > 0) {
const prompts = require('../../../lib/prompts');
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('Reviewing module changes');
spinner.stop('Module changes reviewed');
}
await prompts.log.warn('Modules to be removed:');
@ -474,6 +493,7 @@ class Installer {
spinner.start('Preparing update...');
}
}
// Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
const existingFilesManifest = await this.readFilesManifest(bmadDir);
@ -684,6 +704,7 @@ class Installer {
config._isFullReinstall || false,
config._previouslyConfiguredIdes || [],
preSelectedIdes,
config.skipPrompts || false,
);
}
@ -692,14 +713,80 @@ class Installer {
config.skipIde = toolSelection.skipIde;
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
const results = [];
const addResult = (step, status, detail = '') => results.push({ step, status, detail });
if (spinner.isSpinning) {
spinner.message('Installing...');
spinner.message('Preparing installation...');
} else {
spinner.start('Installing...');
spinner.start('Preparing installation...');
}
// Create bmad directory structure
@ -728,20 +815,10 @@ class Installer {
const projectRoot = getProjectRoot();
// Step 1: Install core module first (if requested)
if (config.installCore) {
spinner.message('Installing BMAD core...');
await this.installCoreWithDependencies(bmadDir, { core: {} });
addResult('Core', 'ok', 'installed');
// 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;
const finalCustomContent = config.customContent;
// Step 3: Prepare modules list including cached custom modules
// Prepare modules list including cached custom modules
let allModules = [...(config.modules || [])];
// During quick update, we might have custom module sources from the manifest
@ -780,8 +857,6 @@ class Installer {
allModules = allModules.filter((m) => m !== 'core');
}
const modulesToInstall = allModules;
// 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) => {
@ -796,70 +871,91 @@ class Installer {
return !isCustom;
});
// For dependency resolution, we need to pass the project root
// 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, // Pass bmadDir so we can check cache
bmadDir: bmadDir,
});
spinner.message('Resolving dependencies...');
const resolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, {
taskResolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, {
verbose: config.verbose,
moduleManager: tempModuleManager,
});
return 'Dependencies resolved';
},
});
// Install modules with their dependencies
// 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) {
// Skip if already installed
if (installedModuleNames.has(moduleName)) {
continue;
}
if (installedModuleNames.has(moduleName)) continue;
installedModuleNames.add(moduleName);
// Show appropriate message based on whether this is a quick update
const isQuickUpdate = config._quickUpdate || false;
spinner.message(`${isQuickUpdate ? 'Updating' : 'Installing'} module: ${moduleName}...`);
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`);
// Check if this is a custom module
let isCustomModule = false;
let customInfo = null;
let useCache = false;
// 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: {},
};
useCache = true;
customInfo = { id: moduleName, path: cachedModule.cachePath, config: {} };
}
}
// Then check if we have custom module sources from the manifest (for quick update)
// 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;
// Check if this is a cached module (source path starts with _config)
if (
customInfo.sourcePath &&
(customInfo.sourcePath.startsWith('_config') || customInfo.sourcePath.includes('_config/custom'))
) {
useCache = true;
// Make sure we have the right path structure
if (!customInfo.path) {
(customInfo.sourcePath.startsWith('_config') || customInfo.sourcePath.includes('_config/custom')) &&
!customInfo.path
)
customInfo.path = customInfo.sourcePath;
}
}
}
// Finally check regular custom content
if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
@ -875,16 +971,12 @@ class Installer {
}
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) {
customModulePaths.set(moduleName, customInfo.path);
this.moduleManager.setCustomModulePaths(customModulePaths);
}
const collectedModuleConfig = moduleConfigs[moduleName] || {};
// Use ModuleManager to install the custom module
await this.moduleManager.install(
moduleName,
bmadDir,
@ -894,19 +986,19 @@ class Installer {
{
isCustom: true,
moduleConfig: collectedModuleConfig,
isQuickUpdate: config._quickUpdate || false,
isQuickUpdate: isQuickUpdate,
installer: this,
silent: true,
},
);
// Create module config (include collected config from module.yaml prompts)
await this.generateModuleConfigs(bmadDir, {
[moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig },
});
} else {
// Regular module installation
// Special case for core module
if (!resolution || !resolution.byModule) {
addResult(`Module: ${moduleName}`, 'warn', 'skipped (no resolution data)');
continue;
}
if (moduleName === 'core') {
await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]);
} else {
@ -918,6 +1010,9 @@ class Installer {
}
// 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 =
@ -928,107 +1023,185 @@ class Installer {
files.data.length +
files.other.length;
if (totalFiles > 0) {
spinner.message(`Installing ${module} dependencies...`);
message(`Installing ${module} dependencies...`);
await this.installPartialModule(module, bmadDir, files);
}
}
}
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
},
});
}
// All content is now installed as modules - no separate custom content handling needed
// 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
spinner.message('Generating module configurations...');
await this.generateModuleConfigs(bmadDir, moduleConfigs);
addResult('Configurations', 'ok', 'generated');
// 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)
// Pre-register manifest files
const cfgDir = path.join(bmadDir, '_config');
this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
spinner.message('Generating workflow and agent manifests...');
message('Generating manifests...');
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
? config._existingModules || allModules || []
: config._preserveModules
? [...allModules, ...config._preserveModules]
: allModules || [];
// For regular installs (including when called from quick update), use what we have
let modulesForCsvPreserve;
if (config._quickUpdate) {
// Quick update - use existing modules or fall back to modules being updated
modulesForCsvPreserve = config._existingModules || allModules || [];
} else {
// Regular install - use the modules we're installing plus any preserved ones
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
}
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir
preservedModules: modulesForCsvPreserve,
});
// Custom modules are now included in the main modules list - no separate tracking needed
addResult(
'Manifests',
'ok',
`${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
);
// Merge all module-help.csv files into bmad-help.csv
// This must happen AFTER generateManifests because it depends on agent-manifest.csv
spinner.message('Generating workflow help catalog...');
// Merge help catalogs
message('Generating help catalog...');
await this.mergeModuleHelpCatalogs(bmadDir);
addResult('Help catalog', 'ok');
// Configure IDEs and copy documentation
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();
return 'Configurations generated';
},
};
installTasks.push(configTask);
// Filter out any undefined/null values from the IDE list
// 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 {
// Check if any IDE might need prompting (no pre-collected config)
const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]);
// Temporarily suppress console output if not verbose
const originalLog = console.log;
if (!config.verbose) {
console.log = () => {};
}
const ideSpinner = await prompts.spinner();
ideSpinner.start('Configuring tools...');
try {
for (const ide of validIdes) {
if (!needsPrompting || ideConfigurations[ide]) {
// All IDEs pre-configured, or this specific IDE has config: keep spinner running
spinner.message(`Configuring ${ide}...`);
ideSpinner.message(`Configuring ${ide}...`);
} else {
// This IDE needs prompting: stop spinner to allow user interaction
if (spinner.isSpinning) {
spinner.stop('Ready for IDE configuration');
if (ideSpinner.isSpinning) {
ideSpinner.stop('Ready for IDE configuration');
}
}
// Silent when this IDE has pre-collected config (no prompts for THIS IDE)
// 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, projectDir, bmadDir, {
selectedModules: allModules || [],
preCollectedConfig: ideConfigurations[ide] || null,
@ -1036,80 +1209,49 @@ class Installer {
silent: ideHasConfig,
});
// Save IDE configuration for future updates
if (ideConfigurations[ide] && !ideConfigurations[ide]._alreadyConfigured) {
await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]);
}
// Collect result for summary
if (setupResult.success) {
addResult(ide, 'ok', setupResult.detail || '');
} else {
addResult(ide, 'error', setupResult.error || 'failed');
}
// Restart spinner if we stopped it for prompting
if (needsPrompting && !spinner.isSpinning) {
spinner.start('Configuring IDEs...');
}
}
} finally {
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
spinner.message('Running module-specific installers...');
// ─────────────────────────────────────────────────────────────────────────
// SECOND TASKS BLOCK: Post-IDE operations (non-interactive)
// ─────────────────────────────────────────────────────────────────────────
const postIdeTasks = [];
// Create a conditional logger based on verbose mode
const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose;
const moduleLogger = {
log: (msg) => (verboseMode ? console.log(msg) : {}), // Only log in verbose mode
error: (msg) => console.error(msg), // Always show errors
warn: (msg) => console.warn(msg), // Always show warnings
};
// 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
// 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._isUpdate) {
if (config._customFiles && config._customFiles.length > 0) {
spinner.message(`Restoring ${config._customFiles.length} custom files...`);
message(`Restoring ${config._customFiles.length} custom files...`);
for (const originalPath of config._customFiles) {
const relativePath = path.relative(bmadDir, originalPath);
@ -1121,7 +1263,6 @@ class Installer {
}
}
// Clean up temp backup
if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) {
await fs.remove(config._tempBackupDir);
}
@ -1132,9 +1273,8 @@ class Installer {
if (config._modifiedFiles && config._modifiedFiles.length > 0) {
modifiedFiles = config._modifiedFiles;
// Restore modified files as .bak files
if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) {
spinner.message(`Restoring ${modifiedFiles.length} modified files as .bak...`);
message(`Restoring ${modifiedFiles.length} modified files as .bak...`);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path);
@ -1147,14 +1287,24 @@ class Installer {
}
}
// Clean up temp backup
await fs.remove(config._tempModifiedBackupDir);
}
}
// Store for summary access
config._restoredCustomFiles = customFiles;
config._restoredModifiedFiles = modifiedFiles;
return 'Installation finalized';
},
});
}
// Stop the single installation spinner
spinner.stop('Installation complete');
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, {
@ -1173,7 +1323,15 @@ class Installer {
projectDir: projectDir,
};
} 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
}
throw error;
}
}
@ -1213,6 +1371,14 @@ class Installer {
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!');
}
@ -1297,6 +1463,7 @@ class Installer {
projectRoot,
'update',
existingInstall.modules.map((m) => m.id),
config.skipPrompts || false,
);
spinner.start('Preparing update...');
@ -2160,6 +2327,7 @@ class Installer {
projectRoot,
'update',
installedModules,
config.skipPrompts || false,
);
const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
@ -2417,7 +2585,9 @@ class Installer {
if (proceed === 'exit') {
await prompts.log.info('Please remove the .bmad-method folder and any v4 rules/commands, then run the installer again.');
process.exit(0);
// 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');
@ -2601,9 +2771,10 @@ class Installer {
* @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) {
async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) {
const validCustomModules = [];
const keptModulesWithoutSources = []; // Track modules kept without sources
const customModulesWithMissingSources = [];
@ -2646,6 +2817,14 @@ 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:`);
let keptCount = 0;
@ -2710,6 +2889,13 @@ 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
const resolvedPath = path.resolve(newSourcePath.trim());
missing.info.sourcePath = resolvedPath;

View File

@ -4,6 +4,7 @@ const yaml = require('yaml');
const crypto = require('node:crypto');
const csv = require('csv-parse/sync');
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
const prompts = require('../../../lib/prompts');
// Load package.json for version info
const packageJson = require('../../../../../package.json');
@ -241,7 +242,7 @@ class ManifestGenerator {
}
}
} catch (error) {
console.warn(`Warning: Failed to parse workflow at ${fullPath}: ${error.message}`);
await prompts.log.warn(`Failed to parse workflow at ${fullPath}: ${error.message}`);
}
}
}
@ -693,7 +694,7 @@ class ManifestGenerator {
return preservedRows;
} catch (error) {
console.warn(`Warning: Failed to read existing CSV ${csvPath}:`, error.message);
await prompts.log.warn(`Failed to read existing CSV ${csvPath}: ${error.message}`);
return [];
}
}
@ -1072,7 +1073,7 @@ class ManifestGenerator {
}
}
} catch (error) {
console.warn(`Warning: Could not scan for installed modules: ${error.message}`);
await prompts.log.warn(`Could not scan for installed modules: ${error.message}`);
}
return modules;

View File

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

View File

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

View File

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

View File

@ -452,7 +452,7 @@ class ModuleManager {
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` Warning: ${error.message}`);
if (!silent) await prompts.log.warn(` ${error.message}`);
}
} else {
// Check if package.json is newer than node_modules
@ -478,7 +478,7 @@ class ModuleManager {
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` Warning: ${error.message}`);
if (!silent) await prompts.log.warn(` ${error.message}`);
}
}
}
@ -541,7 +541,7 @@ class ModuleManager {
const customContent = await fs.readFile(rootCustomConfigPath, 'utf8');
customConfig = yaml.parse(customContent);
} catch (error) {
await prompts.log.warn(`Warning: Failed to read custom.yaml for ${moduleName}: ${error.message}`);
await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`);
}
}
@ -549,7 +549,7 @@ class ModuleManager {
if (customConfig) {
options.moduleConfig = { ...options.moduleConfig, ...customConfig };
if (options.logger) {
options.logger.log(` Merged custom configuration for ${moduleName}`);
await options.logger.log(` Merged custom configuration for ${moduleName}`);
}
}
@ -857,7 +857,7 @@ class ModuleManager {
await fs.writeFile(targetFile, strippedYaml, 'utf8');
} catch {
// If anything fails, just copy the file as-is
await prompts.log.warn(` Warning: Could not process ${path.basename(sourceFile)}, copying as-is`);
await prompts.log.warn(` Could not process ${path.basename(sourceFile)}, copying as-is`);
await fs.copy(sourceFile, targetFile, { overwrite: true });
}
}
@ -1012,7 +1012,7 @@ class ModuleManager {
await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`);
}
} else if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.warn(` Warning: Agent marked as having sidecar but ${sidecarDirName} directory not found`);
await prompts.log.warn(` Agent marked as having sidecar but ${sidecarDirName} directory not found`);
}
}
@ -1247,15 +1247,20 @@ class ModuleManager {
/**
* Create directories declared in module.yaml's `directories` key
* 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} bmadDir - Target bmad directory
* @param {Object} options - Installation options
* @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
* @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
*/
async createModuleDirectories(moduleName, bmadDir, options = {}) {
const moduleConfig = options.moduleConfig || {};
const existingModuleConfig = options.existingModuleConfig || {};
const projectRoot = path.dirname(bmadDir);
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
// Special handling for core module - it's in src/core not src/modules
let sourcePath;
@ -1264,14 +1269,14 @@ class ModuleManager {
} else {
sourcePath = await this.findModuleSource(moduleName, { silent: true });
if (!sourcePath) {
return; // No source found, skip
return emptyResult; // No source found, skip
}
}
// Read module.yaml to find the `directories` key
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
if (!(await fs.pathExists(moduleYamlPath))) {
return; // No module.yaml, skip
return emptyResult; // No module.yaml, skip
}
let moduleYaml;
@ -1279,17 +1284,18 @@ class ModuleManager {
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
moduleYaml = yaml.parse(yamlContent);
} catch {
return; // Invalid YAML, skip
return emptyResult; // Invalid YAML, skip
}
if (!moduleYaml || !moduleYaml.directories) {
return; // No directories declared, skip
return emptyResult; // No directories declared, skip
}
// Get color utility for styled output
const color = await prompts.getColor();
const directories = moduleYaml.directories;
const wdsFolders = moduleYaml.wds_folders || [];
const createdDirs = [];
const movedDirs = [];
const createdWdsFolders = [];
for (const dirRef of directories) {
// Parse variable reference like "{design_artifacts}"
@ -1318,29 +1324,96 @@ class ModuleManager {
const normalizedPath = path.normalize(fullPath);
const normalizedRoot = path.normalize(projectRoot);
if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) {
await prompts.log.warn(color.yellow(`Warning: ${configKey} path escapes project root, skipping: ${dirPath}`));
const color = await prompts.getColor();
await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`));
continue;
}
// Create directory if it doesn't exist
if (!(await fs.pathExists(fullPath))) {
// Check if directory path changed from previous config (update/modify scenario)
const oldDirValue = existingModuleConfig[configKey];
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('_', ' ');
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);
}
// Create WDS subfolders if this is the design_artifacts directory
if (configKey === 'design_artifacts' && wdsFolders.length > 0) {
await prompts.log.message(color.cyan('Creating WDS folder structure...'));
for (const subfolder of wdsFolders) {
const subPath = path.join(fullPath, subfolder);
if (!(await fs.pathExists(subPath))) {
await fs.ensureDir(subPath);
await prompts.log.message(color.dim(`${subfolder}/`));
createdWdsFolders.push(subfolder);
}
}
}
}
return { createdDirs, movedDirs, createdWdsFolders };
}
/**

View File

@ -189,7 +189,7 @@ class UI {
const installedVersion = existingInstall.version || 'unknown';
// Check if version is pre beta
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir));
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options);
// If user chose to cancel, exit the installer
if (!shouldProceed) {
@ -227,6 +227,14 @@ class UI {
}
actionType = options.action;
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 {
actionType = await prompts.select({
message: 'How would you like to proceed?',
@ -242,6 +250,7 @@ class UI {
actionType: 'quick-update',
directory: confirmedDirectory,
customContent: { hasCustomContent: false },
skipPrompts: options.yes || false,
};
}
@ -252,6 +261,7 @@ class UI {
actionType: 'compile-agents',
directory: confirmedDirectory,
customContent: { hasCustomContent: false },
skipPrompts: options.yes || false,
};
}
@ -272,6 +282,11 @@ class UI {
.map((m) => m.trim())
.filter(Boolean);
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 {
selectedModules = await this.selectAllModules(installedModuleIds);
}
@ -330,6 +345,22 @@ 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 {
const changeCustomModules = await prompts.confirm({
message: 'Modify custom modules, agents, or workflows?',
@ -378,6 +409,7 @@ class UI {
skipIde: toolSelection.skipIde,
coreConfig: coreConfig,
customContent: customModuleResult.customContentConfig,
skipPrompts: options.yes || false,
};
}
}
@ -529,6 +561,27 @@ class UI {
if (configuredIdes.length > 0) {
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
const sortedTools = [
...allTools.filter((ide) => configuredIdes.includes(ide.value)),
@ -691,18 +744,6 @@ 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
* @returns {string} Confirmed directory path
@ -1642,7 +1683,7 @@ class UI {
* @param {string} bmadFolderName - Name of the BMAD folder
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
*/
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName) {
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) {
if (!this.isLegacyVersion(installedVersion)) {
return true; // Not legacy, proceed
}
@ -1668,6 +1709,11 @@ class UI {
await prompts.log.warn('VERSION WARNING');
await prompts.note(warningContent, 'Version Warning');
if (options.yes) {
await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update');
return true;
}
const proceed = await prompts.select({
message: 'How would you like to proceed?',
choices: [