From 232fba8cc8b1502d2506b747aa7eaeb55daa8614 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sat, 21 Mar 2026 23:58:37 -0600 Subject: [PATCH] refactor(installer): merge ConfigCollector into OfficialModules Move all config collection state and methods from config-collector.js into OfficialModules. Move interactive config prompting from install() into ui.js so install() never prompts. Delete config-collector.js and eliminate the moduleConfigs parameter chain from _installAndConfigure, _installOfficialModules, and _installCustomModules. --- .../installers/lib/core/config-collector.js | 1285 ---------------- tools/cli/installers/lib/core/installer.js | 65 +- .../lib/modules/official-modules.js | 1315 +++++++++++++++++ tools/cli/lib/ui.js | 72 +- 4 files changed, 1353 insertions(+), 1384 deletions(-) delete mode 100644 tools/cli/installers/lib/core/config-collector.js diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js deleted file mode 100644 index de69873fd..000000000 --- a/tools/cli/installers/lib/core/config-collector.js +++ /dev/null @@ -1,1285 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const yaml = require('yaml'); -const { getProjectRoot, getModulePath } = require('../../../lib/project-root'); -const { CLIUtils } = require('../../../lib/cli-utils'); -const prompts = require('../../../lib/prompts'); - -class ConfigCollector { - constructor() { - this.collectedConfig = {}; - this.existingConfig = null; - this.currentProjectDir = null; - this._officialModulesInstance = null; - } - - /** - * Get or create a cached OfficialModules instance (lazy initialization) - * @returns {Object} OfficialModules instance - */ - _getOfficialModules() { - if (!this._officialModulesInstance) { - const { OfficialModules } = require('../modules/official-modules'); - this._officialModulesInstance = new OfficialModules(); - } - return this._officialModulesInstance; - } - - /** - * Find the bmad installation directory in a project - * V6+ installations can use ANY folder name but ALWAYS have _config/manifest.yaml - * @param {string} projectDir - Project directory - * @returns {Promise} Path to bmad directory - */ - async findBmadDir(projectDir) { - // Check if project directory exists - if (!(await fs.pathExists(projectDir))) { - // Project doesn't exist yet, return default - return path.join(projectDir, 'bmad'); - } - - // V6+ strategy: Look for ANY directory with _config/manifest.yaml - // This is the definitive marker of a V6+ installation - try { - const entries = await fs.readdir(projectDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - const manifestPath = path.join(projectDir, entry.name, '_config', 'manifest.yaml'); - if (await fs.pathExists(manifestPath)) { - // Found a V6+ installation - return path.join(projectDir, entry.name); - } - } - } - } catch { - // Ignore errors, fall through to default - } - - // No V6+ installation found, return default - // This will be used for new installations - return path.join(projectDir, 'bmad'); - } - - /** - * Detect the existing BMAD folder name in a project - * @param {string} projectDir - Project directory - * @returns {Promise} Folder name (just the name, not full path) or null if not found - */ - async detectExistingBmadFolder(projectDir) { - // Check if project directory exists - if (!(await fs.pathExists(projectDir))) { - return null; - } - - // Look for ANY directory with _config/manifest.yaml - try { - const entries = await fs.readdir(projectDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - const manifestPath = path.join(projectDir, entry.name, '_config', 'manifest.yaml'); - if (await fs.pathExists(manifestPath)) { - // Found a V6+ installation, return just the folder name - return entry.name; - } - } - } - } catch { - // Ignore errors - } - - return null; - } - - /** - * Load existing config if it exists from module config files - * @param {string} projectDir - Target project directory - */ - async loadExistingConfig(projectDir) { - this.existingConfig = {}; - - // Check if project directory exists first - if (!(await fs.pathExists(projectDir))) { - return false; - } - - // Find the actual bmad directory (handles custom folder names) - const bmadDir = await this.findBmadDir(projectDir); - - // Check if bmad directory exists - if (!(await fs.pathExists(bmadDir))) { - return false; - } - - // Dynamically discover all installed modules by scanning bmad directory - // A directory is a module ONLY if it contains a config.yaml file - let foundAny = false; - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - // Skip the _config directory - it's for system use - if (entry.name === '_config' || entry.name === '_memory') { - continue; - } - - const moduleConfigPath = path.join(bmadDir, entry.name, 'config.yaml'); - - if (await fs.pathExists(moduleConfigPath)) { - try { - const content = await fs.readFile(moduleConfigPath, 'utf8'); - const moduleConfig = yaml.parse(content); - if (moduleConfig) { - this.existingConfig[entry.name] = moduleConfig; - foundAny = true; - } - } catch { - // Ignore parse errors for individual modules - } - } - } - } - - 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 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 OfficialModules 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._getOfficialModules().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') - * @param {string} projectDir - Target project directory - * @param {Object} options - Additional options - * @param {Map} options.customModulePaths - Map of module ID to source path for custom modules - * @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag) - */ - async collectAllConfigurations(modules, projectDir, options = {}) { - // 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) - const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0; - - // If core wasn't already collected, include it - const allModules = coreAlreadyCollected ? modules.filter((m) => m !== 'core') : ['core', ...modules.filter((m) => m !== 'core')]; - - // Store all answers across modules for cross-referencing - if (!this.allAnswers) { - this.allAnswers = {}; - } - - // 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...'); - try { - 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; - } - } - } finally { - configSpinner.stop(customizeModules.length > 0 ? 'Module defaults applied' : 'Module configuration complete'); - } - } - - // Run customized modules individually (may show interactive prompts) - for (const moduleName of customizeModules) { - await this.collectModuleConfig(moduleName, projectDir); - } - - if (customizeModules.length > 0) { - await prompts.log.step('Module configuration complete'); - } - } - - // Add metadata - this.collectedConfig._meta = { - version: require(path.join(getProjectRoot(), 'package.json')).version, - installDate: new Date().toISOString(), - lastModified: new Date().toISOString(), - }; - - return this.collectedConfig; - } - - /** - * Collect configuration for a single module (Quick Update mode - only new fields) - * @param {string} moduleName - Module name - * @param {string} projectDir - Target project directory - * @param {boolean} silentMode - If true, only prompt for new/missing fields - * @returns {boolean} True if new fields were prompted, false if all fields existed - */ - async collectModuleConfigQuick(moduleName, projectDir, silentMode = true) { - this.currentProjectDir = projectDir; - - // Load existing config if not already loaded - if (!this.existingConfig) { - await this.loadExistingConfig(projectDir); - } - - // Initialize allAnswers if not already initialized - if (!this.allAnswers) { - this.allAnswers = {}; - } - - // Load module's config schema from module.yaml - // First, try the standard src/modules location - let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); - - // If not found in src/modules, we need to find it by searching the project - if (!(await fs.pathExists(moduleConfigPath))) { - const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true }); - - if (moduleSourcePath) { - moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); - } - } - - let configPath = null; - let isCustomModule = false; - - if (await fs.pathExists(moduleConfigPath)) { - configPath = moduleConfigPath; - } else { - // Check if this is a custom module with custom.yaml - const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true }); - - if (moduleSourcePath) { - const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); - - if (await fs.pathExists(rootCustomConfigPath)) { - isCustomModule = true; - // For custom modules, we don't have an install-config schema, so just use existing values - // The custom.yaml values will be loaded and merged during installation - } - } - - // No config schema for this module - use existing values - if (this.existingConfig && this.existingConfig[moduleName]) { - if (!this.collectedConfig[moduleName]) { - this.collectedConfig[moduleName] = {}; - } - this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; - } - return false; - } - - const configContent = await fs.readFile(configPath, 'utf8'); - const moduleConfig = yaml.parse(configContent); - - if (!moduleConfig) { - return false; - } - - // Compare schema with existing config to find new/missing fields - const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); - const existingKeys = this.existingConfig && this.existingConfig[moduleName] ? Object.keys(this.existingConfig[moduleName]) : []; - - // Check if this module has no configuration keys at all (like CIS) - // Filter out metadata fields and only count actual config objects - const metadataFields = new Set(['code', 'name', 'header', 'subheader', 'default_selected']); - const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key)); - const hasNoConfig = actualConfigKeys.length === 0; - - // If module has no config keys at all, handle it specially - if (hasNoConfig && moduleConfig.subheader) { - const moduleDisplayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`; - await prompts.log.step(moduleDisplayName); - await prompts.log.message(` \u2713 ${moduleConfig.subheader}`); - return false; // No new fields - } - - // Find new interactive fields (with prompt) - const newKeys = configKeys.filter((key) => { - const item = moduleConfig[key]; - // Check if it's a config item and doesn't exist in existing config - return item && typeof item === 'object' && item.prompt && !existingKeys.includes(key); - }); - - // Find new static fields (without prompt, just result) - const newStaticKeys = configKeys.filter((key) => { - const item = moduleConfig[key]; - return item && typeof item === 'object' && !item.prompt && item.result && !existingKeys.includes(key); - }); - - // If in silent mode and no new keys (neither interactive nor static), use existing config and skip prompts - if (silentMode && newKeys.length === 0 && newStaticKeys.length === 0) { - if (this.existingConfig && this.existingConfig[moduleName]) { - if (!this.collectedConfig[moduleName]) { - this.collectedConfig[moduleName] = {}; - } - this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; - - // Special handling for user_name: ensure it has a value - if ( - moduleName === 'core' && - (!this.collectedConfig[moduleName].user_name || this.collectedConfig[moduleName].user_name === '[USER_NAME]') - ) { - this.collectedConfig[moduleName].user_name = this.getDefaultUsername(); - } - - // Also populate allAnswers for cross-referencing - for (const [key, value] of Object.entries(this.existingConfig[moduleName])) { - // Ensure user_name is properly set in allAnswers too - let finalValue = value; - if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) { - finalValue = this.getDefaultUsername(); - } - this.allAnswers[`${moduleName}_${key}`] = finalValue; - } - } else if (moduleName === 'core') { - // No existing core config - ensure we at least have user_name - if (!this.collectedConfig[moduleName]) { - this.collectedConfig[moduleName] = {}; - } - if (!this.collectedConfig[moduleName].user_name) { - this.collectedConfig[moduleName].user_name = this.getDefaultUsername(); - this.allAnswers[`${moduleName}_user_name`] = this.getDefaultUsername(); - } - } - - // Show "no config" message for modules with no new questions (that have config keys) - await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module already up to date`); - return false; // No new fields - } - - // If we have new fields (interactive or static), process them - if (newKeys.length > 0 || newStaticKeys.length > 0) { - const questions = []; - const staticAnswers = {}; - - // Build questions for interactive fields - for (const key of newKeys) { - const item = moduleConfig[key]; - const question = await this.buildQuestion(moduleName, key, item, moduleConfig); - if (question) { - questions.push(question); - } - } - - // Prepare static answers (no prompt, just result) - for (const key of newStaticKeys) { - staticAnswers[`${moduleName}_${key}`] = undefined; - } - - // Collect all answers (static + prompted) - let allAnswers = { ...staticAnswers }; - - if (questions.length > 0) { - // Only show header if we actually have questions - await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader); - await prompts.log.message(''); - const promptedAnswers = await prompts.prompt(questions); - - // Merge prompted answers with static answers - Object.assign(allAnswers, promptedAnswers); - } else if (newStaticKeys.length > 0) { - // Only static fields, no questions - show no config message - await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configuration updated`); - } - - // Store all answers for cross-referencing - Object.assign(this.allAnswers, allAnswers); - - // Process all answers (both static and prompted) - // First, copy existing config to preserve values that aren't being updated - if (this.existingConfig && this.existingConfig[moduleName]) { - this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; - } else { - this.collectedConfig[moduleName] = {}; - } - - for (const key of Object.keys(allAnswers)) { - const originalKey = key.replace(`${moduleName}_`, ''); - const item = moduleConfig[originalKey]; - const value = allAnswers[key]; - - let result; - if (Array.isArray(value)) { - result = value; - } else if (item.result) { - result = this.processResultTemplate(item.result, value); - } else { - result = value; - } - - // Update the collected config with new/updated values - this.collectedConfig[moduleName][originalKey] = result; - } - } - - // Copy over existing values for fields that weren't prompted - if (this.existingConfig && this.existingConfig[moduleName]) { - if (!this.collectedConfig[moduleName]) { - this.collectedConfig[moduleName] = {}; - } - for (const [key, value] of Object.entries(this.existingConfig[moduleName])) { - if (!this.collectedConfig[moduleName][key]) { - this.collectedConfig[moduleName][key] = value; - this.allAnswers[`${moduleName}_${key}`] = value; - } - } - } - - await this.displayModulePostConfigNotes(moduleName, moduleConfig); - - return newKeys.length > 0 || newStaticKeys.length > 0; // Return true if we had any new fields (interactive or static) - } - - /** - * Process a result template with value substitution - * @param {*} resultTemplate - The result template - * @param {*} value - The value to substitute - * @returns {*} Processed result - */ - processResultTemplate(resultTemplate, value) { - let result = resultTemplate; - - if (typeof result === 'string' && value !== undefined) { - if (typeof value === 'string') { - result = result.replace('{value}', value); - } else if (typeof value === 'boolean' || typeof value === 'number') { - if (result === '{value}') { - result = value; - } else { - result = result.replace('{value}', value); - } - } else { - result = value; - } - - if (typeof result === 'string') { - result = result.replaceAll(/{([^}]+)}/g, (match, configKey) => { - if (configKey === 'project-root') { - return '{project-root}'; - } - if (configKey === 'value') { - return match; - } - - let configValue = this.allAnswers[configKey] || this.allAnswers[`${configKey}`]; - if (!configValue) { - for (const [answerKey, answerValue] of Object.entries(this.allAnswers)) { - if (answerKey.endsWith(`_${configKey}`)) { - configValue = answerValue; - break; - } - } - } - - if (!configValue) { - for (const mod of Object.keys(this.collectedConfig)) { - if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) { - configValue = this.collectedConfig[mod][configKey]; - if (typeof configValue === 'string' && configValue.includes('{project-root}/')) { - configValue = configValue.replace('{project-root}/', ''); - } - break; - } - } - } - - return configValue || match; - }); - } - } - - return result; - } - - /** - * Get the default username from the system - * @returns {string} Capitalized username\ - */ - getDefaultUsername() { - let result = 'BMad'; - try { - const os = require('node:os'); - const userInfo = os.userInfo(); - if (userInfo && userInfo.username) { - const username = userInfo.username; - result = username.charAt(0).toUpperCase() + username.slice(1); - } - } catch { - // Do nothing, just return 'BMad' - } - return result; - } - - /** - * Collect configuration for a single module - * @param {string} moduleName - Module name - * @param {string} projectDir - Target project directory - * @param {boolean} skipLoadExisting - Skip loading existing config (for early core collection) - * @param {boolean} skipCompletion - Skip showing completion message (for early core collection) - */ - async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) { - this.currentProjectDir = projectDir; - // Load existing config if needed and not already loaded - if (!skipLoadExisting && !this.existingConfig) { - await this.loadExistingConfig(projectDir); - } - - // Initialize allAnswers if not already initialized - if (!this.allAnswers) { - this.allAnswers = {}; - } - // Load module's config - // First, check if we have a custom module path for this module - let moduleConfigPath = null; - - if (this.customModulePaths && this.customModulePaths.has(moduleName)) { - const customPath = this.customModulePaths.get(moduleName); - moduleConfigPath = path.join(customPath, 'module.yaml'); - } else { - // Try the standard src/modules location - moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); - } - - // If not found in src/modules or custom paths, search the project - if (!(await fs.pathExists(moduleConfigPath))) { - const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true }); - - if (moduleSourcePath) { - moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); - } - } - - let configPath = null; - if (await fs.pathExists(moduleConfigPath)) { - configPath = moduleConfigPath; - } else { - // No config for this module - return; - } - - const configContent = await fs.readFile(configPath, 'utf8'); - const moduleConfig = yaml.parse(configContent); - - if (!moduleConfig) { - return; - } - - // Process each config item - const questions = []; - const staticAnswers = {}; - const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); - - for (const key of configKeys) { - const item = moduleConfig[key]; - - // Skip if not a config object - if (!item || typeof item !== 'object') { - continue; - } - - // Handle static values (no prompt, just result) - if (!item.prompt && item.result) { - // Add to static answers with a marker value - staticAnswers[`${moduleName}_${key}`] = undefined; - continue; - } - - // Handle interactive values (with prompt) - if (item.prompt) { - const question = await this.buildQuestion(moduleName, key, item, moduleConfig); - if (question) { - questions.push(question); - } - } - } - - // Collect all answers (static + prompted) - let allAnswers = { ...staticAnswers }; - - // If there are questions to ask, prompt for accepting defaults vs customizing - if (questions.length > 0) { - const moduleDisplayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`; - - // Skip prompts mode: use all defaults without asking - if (this.skipPrompts) { - await prompts.log.info(`Using default configuration for ${moduleDisplayName}`); - // Use defaults for all questions - for (const question of questions) { - const hasDefault = question.default !== undefined && question.default !== null && question.default !== ''; - if (hasDefault && typeof question.default !== 'function') { - allAnswers[question.name] = question.default; - } - } - } else { - if (!this._silentConfig) await prompts.log.step(`Configuring ${moduleDisplayName}`); - let useDefaults = true; - if (moduleName === 'core') { - 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', - name: 'customize', - message: 'Accept Defaults (no to customize)?', - default: true, - }, - ]); - useDefaults = customizeAnswer.customize; - } else { - // Batch mode: use defaults unless module was selected for customization - useDefaults = !this.modulesToCustomize.has(moduleName); - } - - 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 === ''); - - if (questionsWithoutDefaults.length > 0) { - await prompts.log.message(` Asking required questions for ${moduleName.toUpperCase()}...`); - const promptedAnswers = await prompts.prompt(questionsWithoutDefaults); - Object.assign(allAnswers, promptedAnswers); - } - - // For questions with defaults that weren't asked, we need to process them with their default values - const questionsWithDefaults = questions.filter((q) => q.default !== undefined && q.default !== null && q.default !== ''); - for (const question of questionsWithDefaults) { - // Skip function defaults - these are dynamic and will be evaluated later - if (typeof question.default === 'function') { - continue; - } - allAnswers[question.name] = question.default; - } - } else { - const promptedAnswers = await prompts.prompt(questions); - Object.assign(allAnswers, promptedAnswers); - } - } - } - - // Store all answers for cross-referencing - Object.assign(this.allAnswers, allAnswers); - - // Process all answers (both static and prompted) - // Always process if we have any answers or static answers - if (Object.keys(allAnswers).length > 0 || Object.keys(staticAnswers).length > 0) { - const answers = allAnswers; - - // Process answers and build result values - for (const key of Object.keys(answers)) { - const originalKey = key.replace(`${moduleName}_`, ''); - const item = moduleConfig[originalKey]; - const value = answers[key]; - - // Build the result using the template - let result; - - // For arrays (multi-select), handle differently - if (Array.isArray(value)) { - result = value; - } else if (item.result) { - result = item.result; - - // Replace placeholders only for strings - if (typeof result === 'string' && value !== undefined) { - // Replace {value} with the actual value - if (typeof value === 'string') { - result = result.replace('{value}', value); - } else if (typeof value === 'boolean' || typeof value === 'number') { - // For boolean and number values, if result is just "{value}", use the raw value - if (result === '{value}') { - result = value; - } else { - result = result.replace('{value}', value); - } - } else { - result = value; - } - - // Only do further replacements if result is still a string - if (typeof result === 'string') { - // Replace references to other config values - result = result.replaceAll(/{([^}]+)}/g, (match, configKey) => { - // Check if it's a special placeholder - if (configKey === 'project-root') { - return '{project-root}'; - } - - // Skip if it's the 'value' placeholder we already handled - if (configKey === 'value') { - return match; - } - - // Look for the config value across all modules - // First check if it's in the current module's answers - let configValue = answers[`${moduleName}_${configKey}`]; - - // Then check all answers (for cross-module references like outputFolder) - if (!configValue) { - // Try with various module prefixes - for (const [answerKey, answerValue] of Object.entries(this.allAnswers)) { - if (answerKey.endsWith(`_${configKey}`)) { - configValue = answerValue; - break; - } - } - } - - // Check in already collected config - if (!configValue) { - for (const mod of Object.keys(this.collectedConfig)) { - if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) { - configValue = this.collectedConfig[mod][configKey]; - break; - } - } - } - - return configValue || match; - }); - } - } - } else { - result = value; - } - - // Store only the result value (no prompts, defaults, examples, etc.) - if (!this.collectedConfig[moduleName]) { - this.collectedConfig[moduleName] = {}; - } - this.collectedConfig[moduleName][originalKey] = result; - } - - // No longer display completion boxes - keep output clean - } else { - // No questions for this module - show completion message with header if available - const moduleDisplayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`; - - // Check if this module has NO configuration keys at all (like CIS) - // Filter out metadata fields and only count actual config objects - const metadataFields = new Set(['code', 'name', 'header', 'subheader', 'default_selected']); - 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) { - await prompts.log.message(` \u2713 ${moduleConfig.subheader}`); - } else { - await prompts.log.message(` \u2713 No custom configuration required`); - } - } else { - // Module has config but just no questions to ask - 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 - if (!this.collectedConfig[moduleName]) { - this.collectedConfig[moduleName] = {}; - - // If we accepted defaults and have no answers, we still need to check - // if there are any static values in the schema that should be applied - if (moduleConfig) { - for (const key of Object.keys(moduleConfig)) { - if (key !== 'prompt' && moduleConfig[key] && typeof moduleConfig[key] === 'object') { - const item = moduleConfig[key]; - // For static items (no prompt, just result), apply the result - if (!item.prompt && item.result) { - // Apply any placeholder replacements to the result - let result = item.result; - if (typeof result === 'string') { - result = this.replacePlaceholders(result, moduleName, moduleConfig); - } - this.collectedConfig[moduleName][key] = result; - } - } - } - } - } - - await this.displayModulePostConfigNotes(moduleName, moduleConfig); - } - - /** - * Replace placeholders in a string with collected config values - * @param {string} str - String with placeholders - * @param {string} currentModule - Current module name (to look up defaults in same module) - * @param {Object} moduleConfig - Current module's config schema (to look up defaults) - * @returns {string} String with placeholders replaced - */ - replacePlaceholders(str, currentModule = null, moduleConfig = null) { - if (typeof str !== 'string') { - return str; - } - - return str.replaceAll(/{([^}]+)}/g, (match, configKey) => { - // Preserve special placeholders - if (configKey === 'project-root' || configKey === 'value' || configKey === 'directory_name') { - return match; - } - - // Look for the config value in allAnswers (already answered questions) - let configValue = this.allAnswers[configKey] || this.allAnswers[`core_${configKey}`]; - - // Check in already collected config - if (!configValue) { - for (const mod of Object.keys(this.collectedConfig)) { - if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) { - configValue = this.collectedConfig[mod][configKey]; - break; - } - } - } - - // If still not found and we're in the same module, use the default from the config schema - if (!configValue && currentModule && moduleConfig && moduleConfig[configKey]) { - const referencedItem = moduleConfig[configKey]; - if (referencedItem && referencedItem.default !== undefined) { - configValue = referencedItem.default; - } - } - - return configValue || match; - }); - } - - /** - * Build a prompt question from a config item - * @param {string} moduleName - Module name - * @param {string} key - Config key - * @param {Object} item - Config item definition - * @param {Object} moduleConfig - Full module config schema (for resolving defaults) - */ - async buildQuestion(moduleName, key, item, moduleConfig = null) { - const questionName = `${moduleName}_${key}`; - - // Check for existing value - let existingValue = null; - if (this.existingConfig && this.existingConfig[moduleName]) { - existingValue = this.existingConfig[moduleName][key]; - - // Clean up existing value - remove {project-root}/ prefix if present - // This prevents duplication when the result template adds it back - if (typeof existingValue === 'string' && existingValue.startsWith('{project-root}/')) { - existingValue = existingValue.replace('{project-root}/', ''); - } - } - - // Special handling for user_name: default to system user - if (moduleName === 'core' && key === 'user_name' && !existingValue) { - item.default = this.getDefaultUsername(); - } - - // Determine question type and default value - let questionType = 'input'; - let defaultValue = item.default; - let choices = null; - - // Check if default contains references to other fields in the same module - const hasSameModuleReference = typeof defaultValue === 'string' && defaultValue.match(/{([^}]+)}/); - let dynamicDefault = false; - - // Replace placeholders in default value with collected config values - if (typeof defaultValue === 'string') { - if (defaultValue.includes('{directory_name}') && this.currentProjectDir) { - const dirName = path.basename(this.currentProjectDir); - defaultValue = defaultValue.replaceAll('{directory_name}', dirName); - } - - // Check if this references another field in the same module (for dynamic defaults) - if (hasSameModuleReference && moduleConfig) { - const matches = defaultValue.match(/{([^}]+)}/g); - if (matches) { - for (const match of matches) { - const fieldName = match.slice(1, -1); // Remove { } - // Check if this field exists in the same module config - if (moduleConfig[fieldName]) { - dynamicDefault = true; - break; - } - } - } - } - - // If not dynamic, replace placeholders now - if (!dynamicDefault) { - defaultValue = this.replacePlaceholders(defaultValue, moduleName, moduleConfig); - } - - // Strip {project-root}/ from defaults since it will be added back by result template - // This makes the display cleaner and user input simpler - if (defaultValue.includes('{project-root}/')) { - defaultValue = defaultValue.replace('{project-root}/', ''); - } - } - - // Handle different question types - if (item['single-select']) { - questionType = 'list'; - choices = item['single-select'].map((choice) => { - // If choice is an object with label and value - if (typeof choice === 'object' && choice.label && choice.value !== undefined) { - return { - name: choice.label, - value: choice.value, - }; - } - // Otherwise it's a simple string choice - return { - name: choice, - value: choice, - }; - }); - if (existingValue) { - defaultValue = existingValue; - } - } else if (item['multi-select']) { - questionType = 'checkbox'; - choices = item['multi-select'].map((choice) => { - // If choice is an object with label and value - if (typeof choice === 'object' && choice.label && choice.value !== undefined) { - return { - name: choice.label, - value: choice.value, - checked: existingValue - ? existingValue.includes(choice.value) - : item.default && Array.isArray(item.default) - ? item.default.includes(choice.value) - : false, - }; - } - // Otherwise it's a simple string choice - return { - name: choice, - value: choice, - checked: existingValue - ? existingValue.includes(choice) - : item.default && Array.isArray(item.default) - ? item.default.includes(choice) - : false, - }; - }); - } else if (typeof defaultValue === 'boolean') { - questionType = 'confirm'; - } - - // Build the prompt message - let message = ''; - - // Handle array prompts for multi-line messages - if (Array.isArray(item.prompt)) { - message = item.prompt.join('\n'); - } else { - message = item.prompt; - } - - // Replace placeholders in prompt message with collected config values - if (typeof message === 'string') { - message = this.replacePlaceholders(message, moduleName, moduleConfig); - } - - // Add current value indicator for existing configs - const color = await prompts.getColor(); - if (existingValue !== null && existingValue !== undefined) { - if (typeof existingValue === 'boolean') { - message += color.dim(` (current: ${existingValue ? 'true' : 'false'})`); - } else if (Array.isArray(existingValue)) { - message += color.dim(` (current: ${existingValue.join(', ')})`); - } else if (questionType !== 'list') { - // Show the cleaned value (without {project-root}/) for display - message += color.dim(` (current: ${existingValue})`); - } - } else if (item.example && questionType === 'input') { - // Show example for input fields - let exampleText = typeof item.example === 'string' ? item.example : JSON.stringify(item.example); - // Replace placeholders in example - if (typeof exampleText === 'string') { - exampleText = this.replacePlaceholders(exampleText, moduleName, moduleConfig); - exampleText = exampleText.replace('{project-root}/', ''); - } - message += color.dim(` (e.g., ${exampleText})`); - } - - // Build the question object - const question = { - type: questionType, - name: questionName, - message: message, - }; - - // Set default - if it's dynamic, use a function that the prompt will evaluate with current answers - // But if we have an existing value, always use that instead - if (existingValue !== null && existingValue !== undefined && questionType !== 'list') { - question.default = existingValue; - } else if (dynamicDefault && typeof item.default === 'string') { - const originalDefault = item.default; - question.default = (answers) => { - // Replace placeholders using answers from previous questions in the same batch - let resolved = originalDefault; - resolved = resolved.replaceAll(/{([^}]+)}/g, (match, fieldName) => { - // Look for the answer in the current batch (prefixed with module name) - const answerKey = `${moduleName}_${fieldName}`; - if (answers[answerKey] !== undefined) { - return answers[answerKey]; - } - // Fall back to collected config - return this.collectedConfig[moduleName]?.[fieldName] || match; - }); - // Strip {project-root}/ for cleaner display - if (resolved.includes('{project-root}/')) { - resolved = resolved.replace('{project-root}/', ''); - } - return resolved; - }; - } else { - question.default = defaultValue; - } - - // Add choices for select types - if (choices) { - question.choices = choices; - } - - // Add validation for input fields - if (questionType === 'input') { - question.validate = (input) => { - if (!input && item.required) { - return 'This field is required'; - } - // Validate against regex pattern if provided - if (input && item.regex) { - const regex = new RegExp(item.regex); - if (!regex.test(input)) { - return `Invalid format. Must match pattern: ${item.regex}`; - } - } - return true; - }; - } - - // Add validation for checkbox (multi-select) fields - if (questionType === 'checkbox' && item.required) { - question.validate = (answers) => { - if (!answers || answers.length === 0) { - return 'At least one option must be selected'; - } - return true; - }; - } - - return question; - } - - /** - * Display post-configuration notes for a module - * Shows prerequisite guidance based on collected config values - * Reads notes from the module's `post-install-notes` section in module.yaml - * Supports two formats: - * - Simple string: always displayed - * - Object keyed by config field name, with value-specific messages - * @param {string} moduleName - Module name - * @param {Object} moduleConfig - Parsed module.yaml content - */ - async displayModulePostConfigNotes(moduleName, moduleConfig) { - if (this._silentConfig) return; - if (!moduleConfig || !moduleConfig['post-install-notes']) return; - - const notes = moduleConfig['post-install-notes']; - const color = await prompts.getColor(); - - // Format 1: Simple string - always display - if (typeof notes === 'string') { - await prompts.log.message(''); - for (const line of notes.trim().split('\n')) { - await prompts.log.message(color.dim(line)); - } - return; - } - - // Format 2: Conditional on config values - if (typeof notes === 'object') { - const config = this.collectedConfig[moduleName]; - if (!config) return; - - let hasOutput = false; - for (const [configKey, valueMessages] of Object.entries(notes)) { - const selectedValue = config[configKey]; - if (!selectedValue || !valueMessages[selectedValue]) continue; - - if (hasOutput) await prompts.log.message(''); - hasOutput = true; - - const message = valueMessages[selectedValue]; - for (const line of message.trim().split('\n')) { - const trimmedLine = line.trim(); - if (trimmedLine.endsWith(':') && !trimmedLine.startsWith(' ')) { - await prompts.log.info(color.bold(trimmedLine)); - } else { - await prompts.log.message(color.dim(' ' + trimmedLine)); - } - } - } - } - } - - /** - * Deep merge two objects - * @param {Object} target - Target object - * @param {Object} source - Source object - */ - deepMerge(target, source) { - const result = { ...target }; - - for (const key in source) { - if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { - if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) { - result[key] = this.deepMerge(result[key], source[key]); - } else { - result[key] = source[key]; - } - } else { - result[key] = source[key]; - } - } - - return result; - } -} - -module.exports = { ConfigCollector }; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 6e91d7603..2bf68cec2 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -7,7 +7,6 @@ const { CustomModules } = require('../modules/custom-modules'); const { IdeManager } = require('../ide/manager'); const { FileOps } = require('../../../lib/file-ops'); const { Config } = require('../../../lib/config'); -const { ConfigCollector } = require('./config-collector'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { ManifestGenerator } = require('./manifest-generator'); const { IdeConfigManager } = require('./ide-config-manager'); @@ -27,7 +26,6 @@ class Installer { this.ideManager = new IdeManager(); this.fileOps = new FileOps(); this.config = new Config(); - this.configCollector = new ConfigCollector(); this.ideConfigManager = new IdeConfigManager(); this.installedFiles = new Set(); // Track all installed files this.bmadFolderName = BMAD_FOLDER_NAME; @@ -49,7 +47,7 @@ class Installer { const paths = await InstallPaths.create(config); // Collect configurations for official modules - const moduleConfigs = await this._collectConfigs(config, paths); + await this.officialModules.collectConfigs(config, paths); await this.customModules.discoverPaths(config, paths); @@ -77,7 +75,7 @@ class Installer { await this._cacheCustomModules(paths, addResult); const { officialModules, allModules } = await this._buildModuleLists(config, customConfig, paths); - await this._installAndConfigure(config, customConfig, paths, moduleConfigs, officialModules, allModules, addResult); + await this._installAndConfigure(config, customConfig, paths, officialModules, allModules, addResult); await this._setupIdes(config, ideConfigurations, allModules, paths, addResult); @@ -291,9 +289,10 @@ class Installer { /** * Install modules, create directories, generate configs and manifests. */ - async _installAndConfigure(config, customConfig, paths, moduleConfigs, officialModules, allModules, addResult) { + async _installAndConfigure(config, customConfig, paths, officialModules, allModules, addResult) { const isQuickUpdate = config.isQuickUpdate(); const finalCustomContent = customConfig.customContent; + const moduleConfigs = this.officialModules.moduleConfigs; const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; @@ -305,12 +304,12 @@ class Installer { task: async (message) => { const installedModuleNames = new Set(); - await this._installOfficialModules(config, paths, moduleConfigs, officialModules, addResult, isQuickUpdate, { + await this._installOfficialModules(config, paths, officialModules, addResult, isQuickUpdate, { message, installedModuleNames, }); - await this._installCustomModules(customConfig, paths, moduleConfigs, finalCustomContent, addResult, isQuickUpdate, { + await this._installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, { message, installedModuleNames, }); @@ -336,7 +335,7 @@ class Installer { const result = await this.officialModules.createModuleDirectories(moduleName, paths.bmadDir, { installedIDEs: config.ides || [], moduleConfig: moduleConfigs[moduleName] || {}, - existingModuleConfig: this.configCollector.existingConfig?.[moduleName] || {}, + existingModuleConfig: this.officialModules.existingConfig?.[moduleName] || {}, coreConfig: moduleConfigs.core || {}, logger: moduleLogger, silent: true, @@ -542,6 +541,7 @@ class Installer { force: originalConfig.force || false, actionType: originalConfig.actionType, coreConfig: originalConfig.coreConfig || {}, + moduleConfigs: originalConfig.moduleConfigs || null, hasCoreConfig() { return this.coreConfig && Object.keys(this.coreConfig).length > 0; }, @@ -551,33 +551,6 @@ class Installer { }; } - /** - * Collect configurations for official modules (core + selected). - * Custom module configs are handled separately in CustomModules.discoverPaths. - */ - async _collectConfigs(config, paths) { - // Seed core config if pre-collected from interactive UI - if (config.hasCoreConfig()) { - this.configCollector.collectedConfig.core = config.coreConfig; - this.configCollector.allAnswers = {}; - for (const [key, value] of Object.entries(config.coreConfig)) { - this.configCollector.allAnswers[`core_${key}`] = value; - } - } - - // Quick update already collected everything - if (config.isQuickUpdate()) { - return this.configCollector.collectedConfig; - } - - // Modules to collect — skip core if its config was pre-collected - const toCollect = config.hasCoreConfig() ? config.modules.filter((m) => m !== 'core') : [...config.modules]; - - return await this.configCollector.collectAllConfigurations(toCollect, paths.projectRoot, { - skipPrompts: config.skipPrompts, - }); - } - /** * Scan the custom module cache directory and register any cached custom modules * that aren't already known from the manifest or external module list. @@ -649,7 +622,7 @@ class Installer { config.coreConfig = existingCoreConfig; customConfig.coreConfig = existingCoreConfig; - this.configCollector.collectedConfig.core = existingCoreConfig; + this.officialModules.moduleConfigs.core = existingCoreConfig; } catch (error) { await prompts.log.warn(`Warning: Could not read existing core config: ${error.message}`); } @@ -705,13 +678,12 @@ class Installer { * Install official (non-custom) modules. * @param {Object} config - Installation configuration * @param {Object} paths - InstallPaths instance - * @param {Object} moduleConfigs - Collected module configurations * @param {string[]} officialModules - Official module IDs to install * @param {Function} addResult - Callback to record installation results * @param {boolean} isQuickUpdate - Whether this is a quick update * @param {Object} ctx - Shared context: { message, installedModuleNames } */ - async _installOfficialModules(config, paths, moduleConfigs, officialModules, addResult, isQuickUpdate, ctx) { + async _installOfficialModules(config, paths, officialModules, addResult, isQuickUpdate, ctx) { const { message, installedModuleNames } = ctx; for (const moduleName of officialModules) { @@ -720,7 +692,7 @@ class Installer { message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); - const moduleConfig = this.configCollector.collectedConfig[moduleName] || {}; + const moduleConfig = this.officialModules.moduleConfigs[moduleName] || {}; await this.officialModules.install( moduleName, paths.bmadDir, @@ -743,13 +715,12 @@ class Installer { * Install custom modules from all custom module sources. * @param {Object} config - Installation configuration * @param {Object} paths - InstallPaths instance - * @param {Object} moduleConfigs - Collected module configurations * @param {Object|undefined} finalCustomContent - Custom content from config * @param {Function} addResult - Callback to record installation results * @param {boolean} isQuickUpdate - Whether this is a quick update * @param {Object} ctx - Shared context: { message, installedModuleNames } */ - async _installCustomModules(customConfig, paths, moduleConfigs, finalCustomContent, addResult, isQuickUpdate, ctx) { + async _installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, ctx) { const { message, installedModuleNames } = ctx; // Collect all custom module IDs with their info from all sources @@ -805,7 +776,7 @@ class Installer { this.customModules.paths.set(moduleName, customInfo.path); } - const collectedModuleConfig = moduleConfigs[moduleName] || {}; + const collectedModuleConfig = this.officialModules.moduleConfigs[moduleName] || {}; await this.officialModules.install( moduleName, paths.bmadDir, @@ -1641,19 +1612,19 @@ class Installer { // Load existing configs and collect new fields (if any) await prompts.log.info('Checking for new configuration options...'); - await this.configCollector.loadExistingConfig(projectDir); + await this.officialModules.loadExistingConfig(projectDir); let promptedForNewFields = false; // Check core config for new fields - const corePrompted = await this.configCollector.collectModuleConfigQuick('core', projectDir, true); + const corePrompted = await this.officialModules.collectModuleConfigQuick('core', projectDir, true); if (corePrompted) { promptedForNewFields = true; } // Check each module we're updating for new fields (NOT skipped modules) for (const moduleName of modulesToUpdate) { - const modulePrompted = await this.configCollector.collectModuleConfigQuick(moduleName, projectDir, true); + const modulePrompted = await this.officialModules.collectModuleConfigQuick(moduleName, projectDir, true); if (modulePrompted) { promptedForNewFields = true; } @@ -1664,7 +1635,7 @@ class Installer { } // Add metadata - this.configCollector.collectedConfig._meta = { + this.officialModules.collectedConfig._meta = { version: require(path.join(getProjectRoot(), 'package.json')).version, installDate: new Date().toISOString(), lastModified: new Date().toISOString(), @@ -1675,7 +1646,7 @@ class Installer { directory: projectDir, modules: modulesToUpdate, // Only update modules we have source for (includes core) ides: configuredIdes, - coreConfig: this.configCollector.collectedConfig.core, + coreConfig: this.officialModules.collectedConfig.core, actionType: 'install', // Use regular install flow _quickUpdate: true, // Flag to skip certain prompts _preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them diff --git a/tools/cli/installers/lib/modules/official-modules.js b/tools/cli/installers/lib/modules/official-modules.js index 441bea517..d57f3e692 100644 --- a/tools/cli/installers/lib/modules/official-modules.js +++ b/tools/cli/installers/lib/modules/official-modules.js @@ -3,11 +3,68 @@ const fs = require('fs-extra'); const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); +const { CLIUtils } = require('../../../lib/cli-utils'); const { ExternalModuleManager } = require('./external-manager'); class OfficialModules { constructor(options = {}) { this.externalModuleManager = new ExternalModuleManager(); + // Config collection state (merged from ConfigCollector) + this.collectedConfig = {}; + this._existingConfig = null; + this.currentProjectDir = null; + } + + /** + * Module configurations collected during install. + */ + get moduleConfigs() { + return this.collectedConfig; + } + + /** + * Existing module configurations read from a previous installation. + */ + get existingConfig() { + return this._existingConfig; + } + + /** + * Load module configurations. If pre-collected configs are provided (from UI), + * stores them directly. Otherwise falls back to headless collection for + * programmatic callers (quick-update, tests). + * @param {Object} config - Clean install config + * @param {Object} paths - InstallPaths instance + * @returns {Object} Module configurations (also available as this.moduleConfigs) + */ + async collectConfigs(config, paths) { + // Pre-collected by UI — just store them + if (config.moduleConfigs) { + this.collectedConfig = config.moduleConfigs; + // Load existing config for path-change detection in createModuleDirectories + await this.loadExistingConfig(paths.projectRoot); + return this.moduleConfigs; + } + + // Quick update already collected everything via quickUpdate() + if (config.isQuickUpdate()) { + return this.moduleConfigs; + } + + // Fallback: headless collection (--yes flag from CLI without UI, tests) + if (config.hasCoreConfig()) { + this.collectedConfig.core = config.coreConfig; + this.allAnswers = {}; + for (const [key, value] of Object.entries(config.coreConfig)) { + this.allAnswers[`core_${key}`] = value; + } + } + + const toCollect = config.hasCoreConfig() ? config.modules.filter((m) => m !== 'core') : [...config.modules]; + + return await this.collectAllConfigurations(toCollect, paths.projectRoot, { + skipPrompts: config.skipPrompts, + }); } /** @@ -681,6 +738,1264 @@ class OfficialModules { return files; } + + // ─── Config collection methods (merged from ConfigCollector) ─── + + /** + * Find the bmad installation directory in a project + * V6+ installations can use ANY folder name but ALWAYS have _config/manifest.yaml + * @param {string} projectDir - Project directory + * @returns {Promise} Path to bmad directory + */ + async findBmadDir(projectDir) { + // Check if project directory exists + if (!(await fs.pathExists(projectDir))) { + // Project doesn't exist yet, return default + return path.join(projectDir, 'bmad'); + } + + // V6+ strategy: Look for ANY directory with _config/manifest.yaml + // This is the definitive marker of a V6+ installation + try { + const entries = await fs.readdir(projectDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const manifestPath = path.join(projectDir, entry.name, '_config', 'manifest.yaml'); + if (await fs.pathExists(manifestPath)) { + // Found a V6+ installation + return path.join(projectDir, entry.name); + } + } + } + } catch { + // Ignore errors, fall through to default + } + + // No V6+ installation found, return default + // This will be used for new installations + return path.join(projectDir, 'bmad'); + } + + /** + * Detect the existing BMAD folder name in a project + * @param {string} projectDir - Project directory + * @returns {Promise} Folder name (just the name, not full path) or null if not found + */ + async detectExistingBmadFolder(projectDir) { + // Check if project directory exists + if (!(await fs.pathExists(projectDir))) { + return null; + } + + // Look for ANY directory with _config/manifest.yaml + try { + const entries = await fs.readdir(projectDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const manifestPath = path.join(projectDir, entry.name, '_config', 'manifest.yaml'); + if (await fs.pathExists(manifestPath)) { + // Found a V6+ installation, return just the folder name + return entry.name; + } + } + } + } catch { + // Ignore errors + } + + return null; + } + + /** + * Load existing config if it exists from module config files + * @param {string} projectDir - Target project directory + */ + async loadExistingConfig(projectDir) { + this._existingConfig = {}; + + // Check if project directory exists first + if (!(await fs.pathExists(projectDir))) { + return false; + } + + // Find the actual bmad directory (handles custom folder names) + const bmadDir = await this.findBmadDir(projectDir); + + // Check if bmad directory exists + if (!(await fs.pathExists(bmadDir))) { + return false; + } + + // Dynamically discover all installed modules by scanning bmad directory + // A directory is a module ONLY if it contains a config.yaml file + let foundAny = false; + const entries = await fs.readdir(bmadDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + // Skip the _config directory - it's for system use + if (entry.name === '_config' || entry.name === '_memory') { + continue; + } + + const moduleConfigPath = path.join(bmadDir, entry.name, 'config.yaml'); + + if (await fs.pathExists(moduleConfigPath)) { + try { + const content = await fs.readFile(moduleConfigPath, 'utf8'); + const moduleConfig = yaml.parse(content); + if (moduleConfig) { + this._existingConfig[entry.name] = moduleConfig; + foundAny = true; + } + } catch { + // Ignore parse errors for individual modules + } + } + } + } + + 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 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 OfficialModules 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.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') + * @param {string} projectDir - Target project directory + * @param {Object} options - Additional options + * @param {Map} options.customModulePaths - Map of module ID to source path for custom modules + * @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag) + */ + async collectAllConfigurations(modules, projectDir, options = {}) { + // 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) + const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0; + + // If core wasn't already collected, include it + const allModules = coreAlreadyCollected ? modules.filter((m) => m !== 'core') : ['core', ...modules.filter((m) => m !== 'core')]; + + // Store all answers across modules for cross-referencing + if (!this.allAnswers) { + this.allAnswers = {}; + } + + // 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...'); + try { + 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; + } + } + } finally { + configSpinner.stop(customizeModules.length > 0 ? 'Module defaults applied' : 'Module configuration complete'); + } + } + + // Run customized modules individually (may show interactive prompts) + for (const moduleName of customizeModules) { + await this.collectModuleConfig(moduleName, projectDir); + } + + if (customizeModules.length > 0) { + await prompts.log.step('Module configuration complete'); + } + } + + // Add metadata + this.collectedConfig._meta = { + version: require(path.join(getProjectRoot(), 'package.json')).version, + installDate: new Date().toISOString(), + lastModified: new Date().toISOString(), + }; + + return this.collectedConfig; + } + + /** + * Collect configuration for a single module (Quick Update mode - only new fields) + * @param {string} moduleName - Module name + * @param {string} projectDir - Target project directory + * @param {boolean} silentMode - If true, only prompt for new/missing fields + * @returns {boolean} True if new fields were prompted, false if all fields existed + */ + async collectModuleConfigQuick(moduleName, projectDir, silentMode = true) { + this.currentProjectDir = projectDir; + + // Load existing config if not already loaded + if (!this._existingConfig) { + await this.loadExistingConfig(projectDir); + } + + // Initialize allAnswers if not already initialized + if (!this.allAnswers) { + this.allAnswers = {}; + } + + // Load module's config schema from module.yaml + // First, try the standard src/modules location + let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); + + // If not found in src/modules, we need to find it by searching the project + if (!(await fs.pathExists(moduleConfigPath))) { + const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); + + if (moduleSourcePath) { + moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); + } + } + + let configPath = null; + let isCustomModule = false; + + if (await fs.pathExists(moduleConfigPath)) { + configPath = moduleConfigPath; + } else { + // Check if this is a custom module with custom.yaml + const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); + + if (moduleSourcePath) { + const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); + + if (await fs.pathExists(rootCustomConfigPath)) { + isCustomModule = true; + // For custom modules, we don't have an install-config schema, so just use existing values + // The custom.yaml values will be loaded and merged during installation + } + } + + // No config schema for this module - use existing values + if (this._existingConfig && this._existingConfig[moduleName]) { + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] }; + } + return false; + } + + const configContent = await fs.readFile(configPath, 'utf8'); + const moduleConfig = yaml.parse(configContent); + + if (!moduleConfig) { + return false; + } + + // Compare schema with existing config to find new/missing fields + const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); + const existingKeys = this._existingConfig && this._existingConfig[moduleName] ? Object.keys(this._existingConfig[moduleName]) : []; + + // Check if this module has no configuration keys at all (like CIS) + // Filter out metadata fields and only count actual config objects + const metadataFields = new Set(['code', 'name', 'header', 'subheader', 'default_selected']); + const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key)); + const hasNoConfig = actualConfigKeys.length === 0; + + // If module has no config keys at all, handle it specially + if (hasNoConfig && moduleConfig.subheader) { + const moduleDisplayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`; + await prompts.log.step(moduleDisplayName); + await prompts.log.message(` \u2713 ${moduleConfig.subheader}`); + return false; // No new fields + } + + // Find new interactive fields (with prompt) + const newKeys = configKeys.filter((key) => { + const item = moduleConfig[key]; + // Check if it's a config item and doesn't exist in existing config + return item && typeof item === 'object' && item.prompt && !existingKeys.includes(key); + }); + + // Find new static fields (without prompt, just result) + const newStaticKeys = configKeys.filter((key) => { + const item = moduleConfig[key]; + return item && typeof item === 'object' && !item.prompt && item.result && !existingKeys.includes(key); + }); + + // If in silent mode and no new keys (neither interactive nor static), use existing config and skip prompts + if (silentMode && newKeys.length === 0 && newStaticKeys.length === 0) { + if (this._existingConfig && this._existingConfig[moduleName]) { + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] }; + + // Special handling for user_name: ensure it has a value + if ( + moduleName === 'core' && + (!this.collectedConfig[moduleName].user_name || this.collectedConfig[moduleName].user_name === '[USER_NAME]') + ) { + this.collectedConfig[moduleName].user_name = this.getDefaultUsername(); + } + + // Also populate allAnswers for cross-referencing + for (const [key, value] of Object.entries(this._existingConfig[moduleName])) { + // Ensure user_name is properly set in allAnswers too + let finalValue = value; + if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) { + finalValue = this.getDefaultUsername(); + } + this.allAnswers[`${moduleName}_${key}`] = finalValue; + } + } else if (moduleName === 'core') { + // No existing core config - ensure we at least have user_name + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + if (!this.collectedConfig[moduleName].user_name) { + this.collectedConfig[moduleName].user_name = this.getDefaultUsername(); + this.allAnswers[`${moduleName}_user_name`] = this.getDefaultUsername(); + } + } + + // Show "no config" message for modules with no new questions (that have config keys) + await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module already up to date`); + return false; // No new fields + } + + // If we have new fields (interactive or static), process them + if (newKeys.length > 0 || newStaticKeys.length > 0) { + const questions = []; + const staticAnswers = {}; + + // Build questions for interactive fields + for (const key of newKeys) { + const item = moduleConfig[key]; + const question = await this.buildQuestion(moduleName, key, item, moduleConfig); + if (question) { + questions.push(question); + } + } + + // Prepare static answers (no prompt, just result) + for (const key of newStaticKeys) { + staticAnswers[`${moduleName}_${key}`] = undefined; + } + + // Collect all answers (static + prompted) + let allAnswers = { ...staticAnswers }; + + if (questions.length > 0) { + // Only show header if we actually have questions + await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader); + await prompts.log.message(''); + const promptedAnswers = await prompts.prompt(questions); + + // Merge prompted answers with static answers + Object.assign(allAnswers, promptedAnswers); + } else if (newStaticKeys.length > 0) { + // Only static fields, no questions - show no config message + await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configuration updated`); + } + + // Store all answers for cross-referencing + Object.assign(this.allAnswers, allAnswers); + + // Process all answers (both static and prompted) + // First, copy existing config to preserve values that aren't being updated + if (this._existingConfig && this._existingConfig[moduleName]) { + this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] }; + } else { + this.collectedConfig[moduleName] = {}; + } + + for (const key of Object.keys(allAnswers)) { + const originalKey = key.replace(`${moduleName}_`, ''); + const item = moduleConfig[originalKey]; + const value = allAnswers[key]; + + let result; + if (Array.isArray(value)) { + result = value; + } else if (item.result) { + result = this.processResultTemplate(item.result, value); + } else { + result = value; + } + + // Update the collected config with new/updated values + this.collectedConfig[moduleName][originalKey] = result; + } + } + + // Copy over existing values for fields that weren't prompted + if (this._existingConfig && this._existingConfig[moduleName]) { + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + for (const [key, value] of Object.entries(this._existingConfig[moduleName])) { + if (!this.collectedConfig[moduleName][key]) { + this.collectedConfig[moduleName][key] = value; + this.allAnswers[`${moduleName}_${key}`] = value; + } + } + } + + await this.displayModulePostConfigNotes(moduleName, moduleConfig); + + return newKeys.length > 0 || newStaticKeys.length > 0; // Return true if we had any new fields (interactive or static) + } + + /** + * Process a result template with value substitution + * @param {*} resultTemplate - The result template + * @param {*} value - The value to substitute + * @returns {*} Processed result + */ + processResultTemplate(resultTemplate, value) { + let result = resultTemplate; + + if (typeof result === 'string' && value !== undefined) { + if (typeof value === 'string') { + result = result.replace('{value}', value); + } else if (typeof value === 'boolean' || typeof value === 'number') { + if (result === '{value}') { + result = value; + } else { + result = result.replace('{value}', value); + } + } else { + result = value; + } + + if (typeof result === 'string') { + result = result.replaceAll(/{([^}]+)}/g, (match, configKey) => { + if (configKey === 'project-root') { + return '{project-root}'; + } + if (configKey === 'value') { + return match; + } + + let configValue = this.allAnswers[configKey] || this.allAnswers[`${configKey}`]; + if (!configValue) { + for (const [answerKey, answerValue] of Object.entries(this.allAnswers)) { + if (answerKey.endsWith(`_${configKey}`)) { + configValue = answerValue; + break; + } + } + } + + if (!configValue) { + for (const mod of Object.keys(this.collectedConfig)) { + if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) { + configValue = this.collectedConfig[mod][configKey]; + if (typeof configValue === 'string' && configValue.includes('{project-root}/')) { + configValue = configValue.replace('{project-root}/', ''); + } + break; + } + } + } + + return configValue || match; + }); + } + } + + return result; + } + + /** + * Get the default username from the system + * @returns {string} Capitalized username\ + */ + getDefaultUsername() { + let result = 'BMad'; + try { + const os = require('node:os'); + const userInfo = os.userInfo(); + if (userInfo && userInfo.username) { + const username = userInfo.username; + result = username.charAt(0).toUpperCase() + username.slice(1); + } + } catch { + // Do nothing, just return 'BMad' + } + return result; + } + + /** + * Collect configuration for a single module + * @param {string} moduleName - Module name + * @param {string} projectDir - Target project directory + * @param {boolean} skipLoadExisting - Skip loading existing config (for early core collection) + * @param {boolean} skipCompletion - Skip showing completion message (for early core collection) + */ + async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) { + this.currentProjectDir = projectDir; + // Load existing config if needed and not already loaded + if (!skipLoadExisting && !this._existingConfig) { + await this.loadExistingConfig(projectDir); + } + + // Initialize allAnswers if not already initialized + if (!this.allAnswers) { + this.allAnswers = {}; + } + // Load module's config + // First, check if we have a custom module path for this module + let moduleConfigPath = null; + + if (this.customModulePaths && this.customModulePaths.has(moduleName)) { + const customPath = this.customModulePaths.get(moduleName); + moduleConfigPath = path.join(customPath, 'module.yaml'); + } else { + // Try the standard src/modules location + moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); + } + + // If not found in src/modules or custom paths, search the project + if (!(await fs.pathExists(moduleConfigPath))) { + const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); + + if (moduleSourcePath) { + moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); + } + } + + let configPath = null; + if (await fs.pathExists(moduleConfigPath)) { + configPath = moduleConfigPath; + } else { + // No config for this module + return; + } + + const configContent = await fs.readFile(configPath, 'utf8'); + const moduleConfig = yaml.parse(configContent); + + if (!moduleConfig) { + return; + } + + // Process each config item + const questions = []; + const staticAnswers = {}; + const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); + + for (const key of configKeys) { + const item = moduleConfig[key]; + + // Skip if not a config object + if (!item || typeof item !== 'object') { + continue; + } + + // Handle static values (no prompt, just result) + if (!item.prompt && item.result) { + // Add to static answers with a marker value + staticAnswers[`${moduleName}_${key}`] = undefined; + continue; + } + + // Handle interactive values (with prompt) + if (item.prompt) { + const question = await this.buildQuestion(moduleName, key, item, moduleConfig); + if (question) { + questions.push(question); + } + } + } + + // Collect all answers (static + prompted) + let allAnswers = { ...staticAnswers }; + + // If there are questions to ask, prompt for accepting defaults vs customizing + if (questions.length > 0) { + const moduleDisplayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`; + + // Skip prompts mode: use all defaults without asking + if (this.skipPrompts) { + await prompts.log.info(`Using default configuration for ${moduleDisplayName}`); + // Use defaults for all questions + for (const question of questions) { + const hasDefault = question.default !== undefined && question.default !== null && question.default !== ''; + if (hasDefault && typeof question.default !== 'function') { + allAnswers[question.name] = question.default; + } + } + } else { + if (!this._silentConfig) await prompts.log.step(`Configuring ${moduleDisplayName}`); + let useDefaults = true; + if (moduleName === 'core') { + 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', + name: 'customize', + message: 'Accept Defaults (no to customize)?', + default: true, + }, + ]); + useDefaults = customizeAnswer.customize; + } else { + // Batch mode: use defaults unless module was selected for customization + useDefaults = !this.modulesToCustomize.has(moduleName); + } + + 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 === ''); + + if (questionsWithoutDefaults.length > 0) { + await prompts.log.message(` Asking required questions for ${moduleName.toUpperCase()}...`); + const promptedAnswers = await prompts.prompt(questionsWithoutDefaults); + Object.assign(allAnswers, promptedAnswers); + } + + // For questions with defaults that weren't asked, we need to process them with their default values + const questionsWithDefaults = questions.filter((q) => q.default !== undefined && q.default !== null && q.default !== ''); + for (const question of questionsWithDefaults) { + // Skip function defaults - these are dynamic and will be evaluated later + if (typeof question.default === 'function') { + continue; + } + allAnswers[question.name] = question.default; + } + } else { + const promptedAnswers = await prompts.prompt(questions); + Object.assign(allAnswers, promptedAnswers); + } + } + } + + // Store all answers for cross-referencing + Object.assign(this.allAnswers, allAnswers); + + // Process all answers (both static and prompted) + // Always process if we have any answers or static answers + if (Object.keys(allAnswers).length > 0 || Object.keys(staticAnswers).length > 0) { + const answers = allAnswers; + + // Process answers and build result values + for (const key of Object.keys(answers)) { + const originalKey = key.replace(`${moduleName}_`, ''); + const item = moduleConfig[originalKey]; + const value = answers[key]; + + // Build the result using the template + let result; + + // For arrays (multi-select), handle differently + if (Array.isArray(value)) { + result = value; + } else if (item.result) { + result = item.result; + + // Replace placeholders only for strings + if (typeof result === 'string' && value !== undefined) { + // Replace {value} with the actual value + if (typeof value === 'string') { + result = result.replace('{value}', value); + } else if (typeof value === 'boolean' || typeof value === 'number') { + // For boolean and number values, if result is just "{value}", use the raw value + if (result === '{value}') { + result = value; + } else { + result = result.replace('{value}', value); + } + } else { + result = value; + } + + // Only do further replacements if result is still a string + if (typeof result === 'string') { + // Replace references to other config values + result = result.replaceAll(/{([^}]+)}/g, (match, configKey) => { + // Check if it's a special placeholder + if (configKey === 'project-root') { + return '{project-root}'; + } + + // Skip if it's the 'value' placeholder we already handled + if (configKey === 'value') { + return match; + } + + // Look for the config value across all modules + // First check if it's in the current module's answers + let configValue = answers[`${moduleName}_${configKey}`]; + + // Then check all answers (for cross-module references like outputFolder) + if (!configValue) { + // Try with various module prefixes + for (const [answerKey, answerValue] of Object.entries(this.allAnswers)) { + if (answerKey.endsWith(`_${configKey}`)) { + configValue = answerValue; + break; + } + } + } + + // Check in already collected config + if (!configValue) { + for (const mod of Object.keys(this.collectedConfig)) { + if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) { + configValue = this.collectedConfig[mod][configKey]; + break; + } + } + } + + return configValue || match; + }); + } + } + } else { + result = value; + } + + // Store only the result value (no prompts, defaults, examples, etc.) + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + this.collectedConfig[moduleName][originalKey] = result; + } + + // No longer display completion boxes - keep output clean + } else { + // No questions for this module - show completion message with header if available + const moduleDisplayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`; + + // Check if this module has NO configuration keys at all (like CIS) + // Filter out metadata fields and only count actual config objects + const metadataFields = new Set(['code', 'name', 'header', 'subheader', 'default_selected']); + 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) { + await prompts.log.message(` \u2713 ${moduleConfig.subheader}`); + } else { + await prompts.log.message(` \u2713 No custom configuration required`); + } + } else { + // Module has config but just no questions to ask + 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 + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + + // If we accepted defaults and have no answers, we still need to check + // if there are any static values in the schema that should be applied + if (moduleConfig) { + for (const key of Object.keys(moduleConfig)) { + if (key !== 'prompt' && moduleConfig[key] && typeof moduleConfig[key] === 'object') { + const item = moduleConfig[key]; + // For static items (no prompt, just result), apply the result + if (!item.prompt && item.result) { + // Apply any placeholder replacements to the result + let result = item.result; + if (typeof result === 'string') { + result = this.replacePlaceholders(result, moduleName, moduleConfig); + } + this.collectedConfig[moduleName][key] = result; + } + } + } + } + } + + await this.displayModulePostConfigNotes(moduleName, moduleConfig); + } + + /** + * Replace placeholders in a string with collected config values + * @param {string} str - String with placeholders + * @param {string} currentModule - Current module name (to look up defaults in same module) + * @param {Object} moduleConfig - Current module's config schema (to look up defaults) + * @returns {string} String with placeholders replaced + */ + replacePlaceholders(str, currentModule = null, moduleConfig = null) { + if (typeof str !== 'string') { + return str; + } + + return str.replaceAll(/{([^}]+)}/g, (match, configKey) => { + // Preserve special placeholders + if (configKey === 'project-root' || configKey === 'value' || configKey === 'directory_name') { + return match; + } + + // Look for the config value in allAnswers (already answered questions) + let configValue = this.allAnswers[configKey] || this.allAnswers[`core_${configKey}`]; + + // Check in already collected config + if (!configValue) { + for (const mod of Object.keys(this.collectedConfig)) { + if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) { + configValue = this.collectedConfig[mod][configKey]; + break; + } + } + } + + // If still not found and we're in the same module, use the default from the config schema + if (!configValue && currentModule && moduleConfig && moduleConfig[configKey]) { + const referencedItem = moduleConfig[configKey]; + if (referencedItem && referencedItem.default !== undefined) { + configValue = referencedItem.default; + } + } + + return configValue || match; + }); + } + + /** + * Build a prompt question from a config item + * @param {string} moduleName - Module name + * @param {string} key - Config key + * @param {Object} item - Config item definition + * @param {Object} moduleConfig - Full module config schema (for resolving defaults) + */ + async buildQuestion(moduleName, key, item, moduleConfig = null) { + const questionName = `${moduleName}_${key}`; + + // Check for existing value + let existingValue = null; + if (this._existingConfig && this._existingConfig[moduleName]) { + existingValue = this._existingConfig[moduleName][key]; + + // Clean up existing value - remove {project-root}/ prefix if present + // This prevents duplication when the result template adds it back + if (typeof existingValue === 'string' && existingValue.startsWith('{project-root}/')) { + existingValue = existingValue.replace('{project-root}/', ''); + } + } + + // Special handling for user_name: default to system user + if (moduleName === 'core' && key === 'user_name' && !existingValue) { + item.default = this.getDefaultUsername(); + } + + // Determine question type and default value + let questionType = 'input'; + let defaultValue = item.default; + let choices = null; + + // Check if default contains references to other fields in the same module + const hasSameModuleReference = typeof defaultValue === 'string' && defaultValue.match(/{([^}]+)}/); + let dynamicDefault = false; + + // Replace placeholders in default value with collected config values + if (typeof defaultValue === 'string') { + if (defaultValue.includes('{directory_name}') && this.currentProjectDir) { + const dirName = path.basename(this.currentProjectDir); + defaultValue = defaultValue.replaceAll('{directory_name}', dirName); + } + + // Check if this references another field in the same module (for dynamic defaults) + if (hasSameModuleReference && moduleConfig) { + const matches = defaultValue.match(/{([^}]+)}/g); + if (matches) { + for (const match of matches) { + const fieldName = match.slice(1, -1); // Remove { } + // Check if this field exists in the same module config + if (moduleConfig[fieldName]) { + dynamicDefault = true; + break; + } + } + } + } + + // If not dynamic, replace placeholders now + if (!dynamicDefault) { + defaultValue = this.replacePlaceholders(defaultValue, moduleName, moduleConfig); + } + + // Strip {project-root}/ from defaults since it will be added back by result template + // This makes the display cleaner and user input simpler + if (defaultValue.includes('{project-root}/')) { + defaultValue = defaultValue.replace('{project-root}/', ''); + } + } + + // Handle different question types + if (item['single-select']) { + questionType = 'list'; + choices = item['single-select'].map((choice) => { + // If choice is an object with label and value + if (typeof choice === 'object' && choice.label && choice.value !== undefined) { + return { + name: choice.label, + value: choice.value, + }; + } + // Otherwise it's a simple string choice + return { + name: choice, + value: choice, + }; + }); + if (existingValue) { + defaultValue = existingValue; + } + } else if (item['multi-select']) { + questionType = 'checkbox'; + choices = item['multi-select'].map((choice) => { + // If choice is an object with label and value + if (typeof choice === 'object' && choice.label && choice.value !== undefined) { + return { + name: choice.label, + value: choice.value, + checked: existingValue + ? existingValue.includes(choice.value) + : item.default && Array.isArray(item.default) + ? item.default.includes(choice.value) + : false, + }; + } + // Otherwise it's a simple string choice + return { + name: choice, + value: choice, + checked: existingValue + ? existingValue.includes(choice) + : item.default && Array.isArray(item.default) + ? item.default.includes(choice) + : false, + }; + }); + } else if (typeof defaultValue === 'boolean') { + questionType = 'confirm'; + } + + // Build the prompt message + let message = ''; + + // Handle array prompts for multi-line messages + if (Array.isArray(item.prompt)) { + message = item.prompt.join('\n'); + } else { + message = item.prompt; + } + + // Replace placeholders in prompt message with collected config values + if (typeof message === 'string') { + message = this.replacePlaceholders(message, moduleName, moduleConfig); + } + + // Add current value indicator for existing configs + const color = await prompts.getColor(); + if (existingValue !== null && existingValue !== undefined) { + if (typeof existingValue === 'boolean') { + message += color.dim(` (current: ${existingValue ? 'true' : 'false'})`); + } else if (Array.isArray(existingValue)) { + message += color.dim(` (current: ${existingValue.join(', ')})`); + } else if (questionType !== 'list') { + // Show the cleaned value (without {project-root}/) for display + message += color.dim(` (current: ${existingValue})`); + } + } else if (item.example && questionType === 'input') { + // Show example for input fields + let exampleText = typeof item.example === 'string' ? item.example : JSON.stringify(item.example); + // Replace placeholders in example + if (typeof exampleText === 'string') { + exampleText = this.replacePlaceholders(exampleText, moduleName, moduleConfig); + exampleText = exampleText.replace('{project-root}/', ''); + } + message += color.dim(` (e.g., ${exampleText})`); + } + + // Build the question object + const question = { + type: questionType, + name: questionName, + message: message, + }; + + // Set default - if it's dynamic, use a function that the prompt will evaluate with current answers + // But if we have an existing value, always use that instead + if (existingValue !== null && existingValue !== undefined && questionType !== 'list') { + question.default = existingValue; + } else if (dynamicDefault && typeof item.default === 'string') { + const originalDefault = item.default; + question.default = (answers) => { + // Replace placeholders using answers from previous questions in the same batch + let resolved = originalDefault; + resolved = resolved.replaceAll(/{([^}]+)}/g, (match, fieldName) => { + // Look for the answer in the current batch (prefixed with module name) + const answerKey = `${moduleName}_${fieldName}`; + if (answers[answerKey] !== undefined) { + return answers[answerKey]; + } + // Fall back to collected config + return this.collectedConfig[moduleName]?.[fieldName] || match; + }); + // Strip {project-root}/ for cleaner display + if (resolved.includes('{project-root}/')) { + resolved = resolved.replace('{project-root}/', ''); + } + return resolved; + }; + } else { + question.default = defaultValue; + } + + // Add choices for select types + if (choices) { + question.choices = choices; + } + + // Add validation for input fields + if (questionType === 'input') { + question.validate = (input) => { + if (!input && item.required) { + return 'This field is required'; + } + // Validate against regex pattern if provided + if (input && item.regex) { + const regex = new RegExp(item.regex); + if (!regex.test(input)) { + return `Invalid format. Must match pattern: ${item.regex}`; + } + } + return true; + }; + } + + // Add validation for checkbox (multi-select) fields + if (questionType === 'checkbox' && item.required) { + question.validate = (answers) => { + if (!answers || answers.length === 0) { + return 'At least one option must be selected'; + } + return true; + }; + } + + return question; + } + + /** + * Display post-configuration notes for a module + * Shows prerequisite guidance based on collected config values + * Reads notes from the module's `post-install-notes` section in module.yaml + * Supports two formats: + * - Simple string: always displayed + * - Object keyed by config field name, with value-specific messages + * @param {string} moduleName - Module name + * @param {Object} moduleConfig - Parsed module.yaml content + */ + async displayModulePostConfigNotes(moduleName, moduleConfig) { + if (this._silentConfig) return; + if (!moduleConfig || !moduleConfig['post-install-notes']) return; + + const notes = moduleConfig['post-install-notes']; + const color = await prompts.getColor(); + + // Format 1: Simple string - always display + if (typeof notes === 'string') { + await prompts.log.message(''); + for (const line of notes.trim().split('\n')) { + await prompts.log.message(color.dim(line)); + } + return; + } + + // Format 2: Conditional on config values + if (typeof notes === 'object') { + const config = this.collectedConfig[moduleName]; + if (!config) return; + + let hasOutput = false; + for (const [configKey, valueMessages] of Object.entries(notes)) { + const selectedValue = config[configKey]; + if (!selectedValue || !valueMessages[selectedValue]) continue; + + if (hasOutput) await prompts.log.message(''); + hasOutput = true; + + const message = valueMessages[selectedValue]; + for (const line of message.trim().split('\n')) { + const trimmedLine = line.trim(); + if (trimmedLine.endsWith(':') && !trimmedLine.startsWith(' ')) { + await prompts.log.info(color.bold(trimmedLine)); + } else { + await prompts.log.message(color.dim(' ' + trimmedLine)); + } + } + } + } + } + + /** + * Deep merge two objects + * @param {Object} target - Target object + * @param {Object} source - Source object + */ + deepMerge(target, source) { + const result = { ...target }; + + for (const key in source) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) { + result[key] = this.deepMerge(result[key], source[key]); + } else { + result[key] = source[key]; + } + } else { + result[key] = source[key]; + } + } + + return result; + } } module.exports = { OfficialModules }; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 7280137d6..192d85b58 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -431,7 +431,7 @@ class UI { // Get tool selection const toolSelection = await this.promptToolSelection(confirmedDirectory, options); - const coreConfig = await this.collectCoreConfig(confirmedDirectory, options); + const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options); return { actionType: 'update', @@ -439,7 +439,8 @@ class UI { modules: selectedModules, ides: toolSelection.ides, skipIde: toolSelection.skipIde, - coreConfig: coreConfig, + coreConfig: moduleConfigs.core || {}, + moduleConfigs: moduleConfigs, customContent: customModuleResult.customContentConfig, skipPrompts: options.yes || false, }; @@ -549,7 +550,7 @@ class UI { selectedModules.unshift('core'); } let toolSelection = await this.promptToolSelection(confirmedDirectory, options); - const coreConfig = await this.collectCoreConfig(confirmedDirectory, options); + const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options); return { actionType: 'install', @@ -557,7 +558,8 @@ class UI { modules: selectedModules, ides: toolSelection.ides, skipIde: toolSelection.skipIde, - coreConfig: coreConfig, + coreConfig: moduleConfigs.core || {}, + moduleConfigs: moduleConfigs, customContent: customContentConfig, skipPrompts: options.yes || false, }; @@ -827,16 +829,18 @@ class UI { } /** - * Collect core configuration + * Collect all module configurations (core + selected modules). + * All interactive prompting happens here in the UI layer. * @param {string} directory - Installation directory + * @param {string[]} modules - Modules to configure (including 'core') * @param {Object} options - Command-line options - * @returns {Object} Core configuration + * @returns {Object} Collected module configurations keyed by module name */ - async collectCoreConfig(directory, options = {}) { - const { ConfigCollector } = require('../installers/lib/core/config-collector'); - const configCollector = new ConfigCollector(); + async collectModuleConfigs(directory, modules, options = {}) { + const { OfficialModules } = require('../installers/lib/modules/official-modules'); + const configCollector = new OfficialModules(); - // If options are provided, set them directly + // Seed core config from CLI options if provided if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) { const coreConfig = {}; if (options.userName) { @@ -858,8 +862,6 @@ class UI { // Load existing config to merge with provided options await configCollector.loadExistingConfig(directory); - - // Merge provided options with existing config (or defaults) const existingConfig = configCollector.collectedConfig.core || {}; configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig }; @@ -875,7 +877,6 @@ class UI { await configCollector.loadExistingConfig(directory); const existingConfig = configCollector.collectedConfig.core || {}; - // If no existing config, use defaults if (Object.keys(existingConfig).length === 0) { let safeUsername; try { @@ -892,16 +893,14 @@ class UI { }; await prompts.log.info('Using default configuration (--yes flag)'); } - } else { - // Load existing configs first if they exist - await configCollector.loadExistingConfig(directory); - // Now collect with existing values as defaults (false = don't skip loading, true = skip completion message) - await configCollector.collectModuleConfig('core', directory, false, true); } - const coreConfig = configCollector.collectedConfig.core; - // Ensure we always have a core config object, even if empty - return coreConfig || {}; + // Collect all module configs — core is skipped if already seeded above + await configCollector.collectAllConfigurations(modules, directory, { + skipPrompts: options.yes || false, + }); + + return configCollector.collectedConfig; } /** @@ -1388,37 +1387,6 @@ class UI { return path.resolve(expanded); } - /** - * Load existing configurations to use as defaults - * @param {string} directory - Installation directory - * @returns {Object} Existing configurations - */ - async loadExistingConfigurations(directory) { - const configs = { - hasCustomContent: false, - coreConfig: {}, - ideConfig: { ides: [], skipIde: false }, - }; - - try { - // Load core config - configs.coreConfig = await this.collectCoreConfig(directory); - - // Load IDE configuration - const configuredIdes = await this.getConfiguredIdes(directory); - if (configuredIdes.length > 0) { - configs.ideConfig.ides = configuredIdes; - configs.ideConfig.skipIde = false; - } - - return configs; - } catch { - // If loading fails, return empty configs - await prompts.log.warn('Could not load existing configurations'); - return configs; - } - } - /** * Get configured IDEs from existing installation * @param {string} directory - Installation directory