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') { 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
@ -47,23 +46,14 @@ 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
@ -72,16 +62,10 @@ 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,28 +14,10 @@ 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
📚 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. 🌟 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. - We believe in empowering everyone, not just those who can pay.
- Knowledge should be shared, not sold.
🙏 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!
@ -47,13 +29,14 @@ endMessage: |
- 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!
📚 RESOURCES: ⭐ HELP US GROW:
- 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,6 +10,19 @@ 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;
} }
/** /**
@ -129,6 +142,70 @@ 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')
@ -141,6 +218,7 @@ 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)
@ -154,10 +232,95 @@ class ConfigCollector {
this.allAnswers = {}; 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); 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,
@ -194,10 +357,7 @@ 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))) {
// Use the module manager to find the module source 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) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -211,9 +371,7 @@ 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 { ModuleManager } = require('../modules/manager'); const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
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');
@ -507,10 +665,7 @@ 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))) {
// Use the module manager to find the module source 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) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -579,12 +734,12 @@ class ConfigCollector {
} }
} }
} else { } else {
await prompts.log.step(moduleDisplayName); if (!this._silentConfig) await prompts.log.step(`Configuring ${moduleDisplayName}`);
let customize = true; let useDefaults = true;
if (moduleName === 'core') { if (moduleName === 'core') {
// Core module: no confirm prompt, continues directly useDefaults = false; // Core: always show all questions
} else { } else if (this.modulesToCustomize === undefined) {
// Non-core modules: show "Accept Defaults?" confirm prompt (clack adds spacing) // Fallback: original per-module confirm (backward compat for direct calls)
const customizeAnswer = await prompts.prompt([ const customizeAnswer = await prompts.prompt([
{ {
type: 'confirm', type: 'confirm',
@ -593,10 +748,13 @@ class ConfigCollector {
default: true, 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 // 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 === '');
@ -726,6 +884,7 @@ 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) {
@ -738,6 +897,7 @@ 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,6 +7,7 @@
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) {
@ -195,7 +196,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) {
console.warn(`Warning: Cache integrity check failed for ${moduleId}`); await prompts.log.warn(`Cache integrity check failed for ${moduleId}`);
} }
return { return {

View File

@ -1,6 +1,7 @@
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
@ -93,7 +94,7 @@ class IdeConfigManager {
const config = yaml.parse(content); const config = yaml.parse(content);
return config; return config;
} catch (error) { } 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; return null;
} }
} }
@ -123,7 +124,7 @@ class IdeConfigManager {
} }
} }
} catch (error) { } 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; return configs;

View File

@ -109,9 +109,17 @@ 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(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 // Use pre-selected IDEs if provided, otherwise prompt
let toolConfig; let toolConfig;
if (preSelectedIdes === null) { if (preSelectedIdes === null) {
@ -182,6 +190,7 @@ 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
@ -406,6 +415,9 @@ 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');
@ -431,9 +443,16 @@ 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) {
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) { if (spinner.isSpinning) {
spinner.stop('Reviewing module changes'); spinner.stop('Module changes reviewed');
} }
await prompts.log.warn('Modules to be removed:'); await prompts.log.warn('Modules to be removed:');
@ -474,6 +493,7 @@ 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);
@ -684,6 +704,7 @@ class Installer {
config._isFullReinstall || false, config._isFullReinstall || false,
config._previouslyConfiguredIdes || [], config._previouslyConfiguredIdes || [],
preSelectedIdes, preSelectedIdes,
config.skipPrompts || false,
); );
} }
@ -692,14 +713,80 @@ 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('Installing...'); spinner.message('Preparing installation...');
} else { } else {
spinner.start('Installing...'); spinner.start('Preparing installation...');
} }
// Create bmad directory structure // Create bmad directory structure
@ -728,20 +815,10 @@ class Installer {
const projectRoot = getProjectRoot(); 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 // 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 || [])]; 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
@ -780,8 +857,6 @@ 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) => {
@ -796,70 +871,91 @@ class Installer {
return !isCustom; 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 // Create a temporary module manager that knows about custom content locations
const tempModuleManager = new ModuleManager({ const tempModuleManager = new ModuleManager({
bmadDir: bmadDir, // Pass bmadDir so we can check cache bmadDir: bmadDir,
}); });
spinner.message('Resolving dependencies...'); taskResolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, {
const resolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, {
verbose: config.verbose, verbose: config.verbose,
moduleManager: tempModuleManager, moduleManager: tempModuleManager,
}); });
return 'Dependencies resolved';
},
});
// Install modules with their dependencies // Module installation task
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) {
// Skip if already installed if (installedModuleNames.has(moduleName)) continue;
if (installedModuleNames.has(moduleName)) {
continue;
}
installedModuleNames.add(moduleName); installedModuleNames.add(moduleName);
// Show appropriate message based on whether this is a quick update message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`);
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 = { customInfo = { id: moduleName, path: cachedModule.cachePath, config: {} };
id: moduleName,
path: cachedModule.cachePath,
config: {},
};
useCache = true;
} }
} }
// 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)) { 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) {
@ -875,16 +971,12 @@ 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,
@ -894,19 +986,19 @@ class Installer {
{ {
isCustom: true, isCustom: true,
moduleConfig: collectedModuleConfig, moduleConfig: collectedModuleConfig,
isQuickUpdate: config._quickUpdate || false, isQuickUpdate: isQuickUpdate,
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 {
// Regular module installation if (!resolution || !resolution.byModule) {
// Special case for core module addResult(`Module: ${moduleName}`, 'warn', 'skipped (no resolution data)');
continue;
}
if (moduleName === 'core') { if (moduleName === 'core') {
await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]); await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]);
} else { } else {
@ -918,6 +1010,9 @@ 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 =
@ -928,107 +1023,185 @@ class Installer {
files.data.length + files.data.length +
files.other.length; files.other.length;
if (totalFiles > 0) { if (totalFiles > 0) {
spinner.message(`Installing ${module} dependencies...`); message(`Installing ${module} dependencies...`);
await this.installPartialModule(module, bmadDir, files); 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 // 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');
// Create agent configuration files // Pre-register manifest 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 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 // 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(); 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, // Scan these from installed bmad/ dir preservedModules: modulesForCsvPreserve,
}); });
// 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 all module-help.csv files into bmad-help.csv // Merge help catalogs
// This must happen AFTER generateManifests because it depends on agent-manifest.csv message('Generating help catalog...');
spinner.message('Generating workflow help catalog...');
await this.mergeModuleHelpCatalogs(bmadDir); await this.mergeModuleHelpCatalogs(bmadDir);
addResult('Help catalog', 'ok'); addResult('Help catalog', 'ok');
// Configure IDEs and copy documentation return 'Configurations generated';
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(); 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'); 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();
// Temporarily suppress console output if not verbose ideSpinner.start('Configuring tools...');
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]) {
// All IDEs pre-configured, or this specific IDE has config: keep spinner running ideSpinner.message(`Configuring ${ide}...`);
spinner.message(`Configuring ${ide}...`);
} else { } else {
// This IDE needs prompting: stop spinner to allow user interaction if (ideSpinner.isSpinning) {
if (spinner.isSpinning) { ideSpinner.stop('Ready for IDE configuration');
spinner.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 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,
@ -1036,80 +1209,49 @@ 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 // ─────────────────────────────────────────────────────────────────────────
spinner.message('Running module-specific installers...'); // SECOND TASKS BLOCK: Post-IDE operations (non-interactive)
// ─────────────────────────────────────────────────────────────────────────
const postIdeTasks = [];
// Create a conditional logger based on verbose mode // File restoration task (only for updates)
const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose; if (
const moduleLogger = { config._isUpdate &&
log: (msg) => (verboseMode ? console.log(msg) : {}), // Only log in verbose mode ((config._customFiles && config._customFiles.length > 0) || (config._modifiedFiles && config._modifiedFiles.length > 0))
error: (msg) => console.error(msg), // Always show errors ) {
warn: (msg) => console.warn(msg), // Always show warnings postIdeTasks.push({
}; 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) {
spinner.message(`Restoring ${config._customFiles.length} custom files...`); 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);
@ -1121,7 +1263,6 @@ 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);
} }
@ -1132,9 +1273,8 @@ 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))) {
spinner.message(`Restoring ${modifiedFiles.length} modified files as .bak...`); 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);
@ -1147,14 +1287,24 @@ 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';
},
});
} }
// Stop the single installation spinner await prompts.tasks(postIdeTasks);
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, {
@ -1173,7 +1323,15 @@ 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;
} }
} }
@ -1213,6 +1371,14 @@ 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!');
} }
@ -1297,6 +1463,7 @@ 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...');
@ -2160,6 +2327,7 @@ class Installer {
projectRoot, projectRoot,
'update', 'update',
installedModules, installedModules,
config.skipPrompts || false,
); );
const { validCustomModules, keptModulesWithoutSources } = customModuleResult; const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
@ -2417,7 +2585,9 @@ 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.');
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'); 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} 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) { async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) {
const validCustomModules = []; const validCustomModules = [];
const keptModulesWithoutSources = []; // Track modules kept without sources const keptModulesWithoutSources = []; // Track modules kept without sources
const customModulesWithMissingSources = []; 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:`); await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`);
let keptCount = 0; 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 // 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

@ -4,6 +4,7 @@ const yaml = require('yaml');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
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');
@ -241,7 +242,7 @@ class ManifestGenerator {
} }
} }
} catch (error) { } 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; return preservedRows;
} catch (error) { } 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 []; return [];
} }
} }
@ -1072,7 +1073,7 @@ class ManifestGenerator {
} }
} }
} catch (error) { } 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; return modules;

View File

@ -2,6 +2,7 @@ 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 {
/** /**
@ -100,7 +101,7 @@ class Manifest {
ides: manifestData.ides || [], ides: manifestData.ides || [],
}; };
} catch (error) { } 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'); const content = await fs.readFile(yamlPath, 'utf8');
return yaml.parse(content); return yaml.parse(content);
} catch (error) { } 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) { } 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.) // Handle other file types (CSV, JSON, YAML, etc.)
@ -774,7 +775,7 @@ class Manifest {
configs[moduleName] = yaml.parse(content); configs[moduleName] = yaml.parse(content);
} }
} catch (error) { } 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); const pkg = require(packageJsonPath);
version = pkg.version; version = pkg.version;
} catch (error) { } 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, repoUrl: moduleConfig.repoUrl || null,
}; };
} catch (error) { } 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 * @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,6 +1,7 @@
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
@ -29,7 +30,7 @@ class ExternalModuleManager {
this.cachedModules = config; this.cachedModules = config;
return config; return config;
} catch (error) { } 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: {} }; 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(` Warning: ${error.message}`); if (!silent) await prompts.log.warn(` ${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(` Warning: ${error.message}`); if (!silent) await prompts.log.warn(` ${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(`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) { if (customConfig) {
options.moduleConfig = { ...options.moduleConfig, ...customConfig }; options.moduleConfig = { ...options.moduleConfig, ...customConfig };
if (options.logger) { 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'); await fs.writeFile(targetFile, strippedYaml, 'utf8');
} catch { } catch {
// If anything fails, just copy the file as-is // 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 }); await fs.copy(sourceFile, targetFile, { overwrite: true });
} }
} }
@ -1012,7 +1012,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(` 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 * 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;
@ -1264,14 +1269,14 @@ class ModuleManager {
} else { } else {
sourcePath = await this.findModuleSource(moduleName, { silent: true }); sourcePath = await this.findModuleSource(moduleName, { silent: true });
if (!sourcePath) { if (!sourcePath) {
return; // No source found, skip return emptyResult; // 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; // No module.yaml, skip return emptyResult; // No module.yaml, skip
} }
let moduleYaml; let moduleYaml;
@ -1279,17 +1284,18 @@ 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; // Invalid YAML, skip return emptyResult; // Invalid YAML, skip
} }
if (!moduleYaml || !moduleYaml.directories) { 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 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}"
@ -1318,29 +1324,96 @@ 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) {
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; continue;
} }
// Create directory if it doesn't exist // Check if directory path changed from previous config (update/modify scenario)
if (!(await fs.pathExists(fullPath))) { 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('_', ' '); 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);
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'; 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)); const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options);
// If user chose to cancel, exit the installer // If user chose to cancel, exit the installer
if (!shouldProceed) { if (!shouldProceed) {
@ -227,6 +227,14 @@ 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?',
@ -242,6 +250,7 @@ class UI {
actionType: 'quick-update', actionType: 'quick-update',
directory: confirmedDirectory, directory: confirmedDirectory,
customContent: { hasCustomContent: false }, customContent: { hasCustomContent: false },
skipPrompts: options.yes || false,
}; };
} }
@ -252,6 +261,7 @@ class UI {
actionType: 'compile-agents', actionType: 'compile-agents',
directory: confirmedDirectory, directory: confirmedDirectory,
customContent: { hasCustomContent: false }, customContent: { hasCustomContent: false },
skipPrompts: options.yes || false,
}; };
} }
@ -272,6 +282,11 @@ 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);
} }
@ -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 { } 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?',
@ -378,6 +409,7 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: coreConfig, coreConfig: coreConfig,
customContent: customModuleResult.customContentConfig, customContent: customModuleResult.customContentConfig,
skipPrompts: options.yes || false,
}; };
} }
} }
@ -529,6 +561,27 @@ 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)),
@ -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 * Get confirmed directory from user
* @returns {string} Confirmed directory path * @returns {string} Confirmed directory path
@ -1642,7 +1683,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) { async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) {
if (!this.isLegacyVersion(installedVersion)) { if (!this.isLegacyVersion(installedVersion)) {
return true; // Not legacy, proceed return true; // Not legacy, proceed
} }
@ -1668,6 +1709,11 @@ 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: [