From 17017881fd3091823a36895ad6c7935229873aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Mon, 9 Feb 2026 10:43:10 +0100 Subject: [PATCH] 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 --- tools/cli/installers/lib/core/installer.js | 160 ++++++++++++--------- 1 file changed, 96 insertions(+), 64 deletions(-) diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index f3bb027af..7105b7a6f 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -415,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'); @@ -440,48 +443,57 @@ class Installer { // If there are modules to remove, ask for confirmation if (modulesToRemove.length > 0) { - const prompts = require('../../../lib/prompts'); - if (spinner.isSpinning) { - 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 + 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 { + 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) @@ -1085,26 +1097,26 @@ class Installer { // 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 = () => {}; - } - - 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}...`); - } else { - // This IDE needs prompting: stop spinner to allow user interaction - if (spinner.isSpinning) { - spinner.stop('Ready for IDE configuration'); - } + 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}...`); + } 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) - const ideHasConfig = Boolean(ideConfigurations[ide]); + // Silent when this IDE has pre-collected config (no prompts for THIS 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, { selectedModules: allModules || [], preCollectedConfig: ideConfigurations[ide] || null, @@ -1123,14 +1135,14 @@ class Installer { } 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; + } + + // 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, 'update', existingInstall.modules.map((m) => m.id), + config.skipPrompts || false, ); spinner.start('Preparing update...'); @@ -2259,6 +2272,7 @@ class Installer { projectRoot, 'update', installedModules, + config.skipPrompts || false, ); const { validCustomModules, keptModulesWithoutSources } = customModuleResult; @@ -2516,7 +2530,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'); @@ -2700,9 +2716,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 = []; @@ -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:`); 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 const resolvedPath = path.resolve(newSourcePath.trim()); missing.info.sourcePath = resolvedPath;