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>
This commit is contained in:
Davor Racić 2026-02-09 10:43:10 +01:00
parent ef0c911722
commit 17017881fd
1 changed files with 96 additions and 64 deletions

View File

@ -415,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');
@ -440,48 +443,57 @@ 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) {
if (spinner.isSpinning) { // Non-interactive mode: preserve modules (matches prompt default: false)
spinner.stop('Reviewing module changes');
}
await prompts.log.warn('Modules to be removed:');
for (const moduleId of modulesToRemove) {
const moduleInfo = existingInstall.modules.find((m) => m.id === moduleId);
const displayName = moduleInfo?.name || moduleId;
const modulePath = path.join(bmadDir, moduleId);
await prompts.log.error(` - ${displayName} (${modulePath})`);
}
const confirmRemoval = await prompts.confirm({
message: `Remove ${modulesToRemove.length} module(s) from BMAD installation?`,
default: false,
});
if (confirmRemoval) {
// Remove module folders
for (const moduleId of modulesToRemove) {
const modulePath = path.join(bmadDir, moduleId);
try {
if (await fs.pathExists(modulePath)) {
await fs.remove(modulePath);
await prompts.log.message(` Removed: ${moduleId}`);
}
} catch (error) {
await prompts.log.warn(` Warning: Failed to remove ${moduleId}: ${error.message}`);
}
}
await prompts.log.success(` Removed ${modulesToRemove.length} module(s)`);
} else {
await prompts.log.message(' Module removal cancelled');
// Add the modules back to the selection since user cancelled removal
for (const moduleId of modulesToRemove) { for (const moduleId of modulesToRemove) {
if (!config.modules) config.modules = []; if (!config.modules) config.modules = [];
config.modules.push(moduleId); config.modules.push(moduleId);
} }
} spinner.start('Preparing update...');
} else {
const prompts = require('../../../lib/prompts');
if (spinner.isSpinning) {
spinner.stop('Reviewing module changes');
}
spinner.start('Preparing update...'); await prompts.log.warn('Modules to be removed:');
for (const moduleId of modulesToRemove) {
const moduleInfo = existingInstall.modules.find((m) => m.id === moduleId);
const displayName = moduleInfo?.name || moduleId;
const modulePath = path.join(bmadDir, moduleId);
await prompts.log.error(` - ${displayName} (${modulePath})`);
}
const confirmRemoval = await prompts.confirm({
message: `Remove ${modulesToRemove.length} module(s) from BMAD installation?`,
default: false,
});
if (confirmRemoval) {
// Remove module folders
for (const moduleId of modulesToRemove) {
const modulePath = path.join(bmadDir, moduleId);
try {
if (await fs.pathExists(modulePath)) {
await fs.remove(modulePath);
await prompts.log.message(` Removed: ${moduleId}`);
}
} catch (error) {
await prompts.log.warn(` Warning: Failed to remove ${moduleId}: ${error.message}`);
}
}
await prompts.log.success(` Removed ${modulesToRemove.length} module(s)`);
} else {
await prompts.log.message(' Module removal cancelled');
// Add the modules back to the selection since user cancelled removal
for (const moduleId of modulesToRemove) {
if (!config.modules) config.modules = [];
config.modules.push(moduleId);
}
}
spinner.start('Preparing update...');
}
} }
// Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv) // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
@ -1085,26 +1097,26 @@ class Installer {
// Check if any IDE might need prompting (no pre-collected config) // 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]);
// Temporarily suppress console output if not verbose for (const ide of validIdes) {
const originalLog = console.log; if (!needsPrompting || ideConfigurations[ide]) {
if (!config.verbose) { // All IDEs pre-configured, or this specific IDE has config: keep spinner running
console.log = () => {}; spinner.message(`Configuring ${ide}...`);
} } else {
// This IDE needs prompting: stop spinner to allow user interaction
try { if (spinner.isSpinning) {
for (const ide of validIdes) { spinner.stop('Ready for IDE configuration');
if (!needsPrompting || ideConfigurations[ide]) {
// All IDEs pre-configured, or this specific IDE has config: keep spinner running
spinner.message(`Configuring ${ide}...`);
} else {
// This IDE needs prompting: stop spinner to allow user interaction
if (spinner.isSpinning) {
spinner.stop('Ready for IDE configuration');
}
} }
}
// Silent when this IDE has pre-collected config (no prompts for THIS IDE) // Silent when this IDE has pre-collected config (no prompts for THIS IDE)
const ideHasConfig = Boolean(ideConfigurations[ide]); const ideHasConfig = Boolean(ideConfigurations[ide]);
// Suppress stray console output for pre-configured IDEs (no user interaction)
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,
@ -1123,14 +1135,14 @@ class Installer {
} else { } else {
addResult(ide, 'error', setupResult.error || 'failed'); addResult(ide, 'error', setupResult.error || 'failed');
} }
} finally {
// Restart spinner if we stopped it for prompting console.log = originalLog;
if (needsPrompting && !spinner.isSpinning) { }
spinner.start('Configuring IDEs...');
} // Restart spinner if we stopped it for prompting
if (needsPrompting && !spinner.isSpinning) {
spinner.start('Configuring IDEs...');
} }
} finally {
console.log = originalLog;
} }
} }
} }
@ -1396,6 +1408,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...');
@ -2259,6 +2272,7 @@ class Installer {
projectRoot, projectRoot,
'update', 'update',
installedModules, installedModules,
config.skipPrompts || false,
); );
const { validCustomModules, keptModulesWithoutSources } = customModuleResult; const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
@ -2516,7 +2530,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');
@ -2700,9 +2716,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 = [];
@ -2745,6 +2762,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;
@ -2809,6 +2834,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;