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:
parent
36c21dbada
commit
0bf8e0edfb
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {} };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in New Issue