const path = require('node:path'); 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; } /** * Build a configured OfficialModules instance from install config. * @param {Object} config - Clean install config (from Config.build) * @param {Object} paths - InstallPaths instance * @returns {OfficialModules} */ static async build(config, paths) { const instance = new OfficialModules(); // Pre-collected by UI or quickUpdate — store and load existing for path-change detection if (config.moduleConfigs) { instance.collectedConfig = config.moduleConfigs; await instance.loadExistingConfig(paths.projectRoot); return instance; } // Headless collection (--yes flag from CLI without UI, tests) if (config.hasCoreConfig()) { instance.collectedConfig.core = config.coreConfig; instance.allAnswers = {}; for (const [key, value] of Object.entries(config.coreConfig)) { instance.allAnswers[`core_${key}`] = value; } } const toCollect = config.hasCoreConfig() ? config.modules.filter((m) => m !== 'core') : [...config.modules]; await instance.collectAllConfigurations(toCollect, paths.projectRoot, { skipPrompts: config.skipPrompts, }); return instance; } /** * Copy a file to the target location * @param {string} sourcePath - Source file path * @param {string} targetPath - Target file path * @param {boolean} overwrite - Whether to overwrite existing files (default: true) */ async copyFile(sourcePath, targetPath, overwrite = true) { await fs.copy(sourcePath, targetPath, { overwrite }); } /** * Copy a directory recursively * @param {string} sourceDir - Source directory path * @param {string} targetDir - Target directory path * @param {boolean} overwrite - Whether to overwrite existing files (default: true) */ async copyDirectory(sourceDir, targetDir, overwrite = true) { await fs.ensureDir(targetDir); const entries = await fs.readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); if (entry.isDirectory()) { await this.copyDirectory(sourcePath, targetPath, overwrite); } else { await this.copyFile(sourcePath, targetPath, overwrite); } } } /** * List all available built-in modules (core and bmm). * All other modules come from external-official-modules.yaml * @returns {Object} Object with modules array and customModules array */ async listAvailable() { const modules = []; const customModules = []; // Add built-in core module (directly under src/core-skills) const corePath = getSourcePath('core-skills'); if (await fs.pathExists(corePath)) { const coreInfo = await this.getModuleInfo(corePath, 'core', 'src/core-skills'); if (coreInfo) { modules.push(coreInfo); } } // Add built-in bmm module (directly under src/bmm-skills) const bmmPath = getSourcePath('bmm-skills'); if (await fs.pathExists(bmmPath)) { const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills'); if (bmmInfo) { modules.push(bmmInfo); } } return { modules, customModules }; } /** * Get module information from a module path * @param {string} modulePath - Path to the module directory * @param {string} defaultName - Default name for the module * @param {string} sourceDescription - Description of where the module was found * @returns {Object|null} Module info or null if not a valid module */ async getModuleInfo(modulePath, defaultName, sourceDescription) { // Check for module structure (module.yaml OR custom.yaml) const moduleConfigPath = path.join(modulePath, 'module.yaml'); const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); let configPath = null; if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; } else if (await fs.pathExists(rootCustomConfigPath)) { configPath = rootCustomConfigPath; } // Skip if this doesn't look like a module if (!configPath) { return null; } // Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core const isCustomSource = sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules'; const moduleInfo = { id: defaultName, path: modulePath, name: defaultName .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '), description: 'BMAD Module', version: '5.0.0', source: sourceDescription, isCustom: configPath === rootCustomConfigPath || isCustomSource, }; // Read module config for metadata try { const configContent = await fs.readFile(configPath, 'utf8'); const config = yaml.parse(configContent); // Use the code property as the id if available if (config.code) { moduleInfo.id = config.code; } moduleInfo.name = config.name || moduleInfo.name; moduleInfo.description = config.description || moduleInfo.description; moduleInfo.version = config.version || moduleInfo.version; moduleInfo.dependencies = config.dependencies || []; moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected; } catch (error) { await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`); } return moduleInfo; } /** * Find the source path for a module by searching all possible locations * @param {string} moduleCode - Code of the module to find (from module.yaml) * @returns {string|null} Path to the module source or null if not found */ async findModuleSource(moduleCode, options = {}) { const projectRoot = getProjectRoot(); // Check for core module (directly under src/core-skills) if (moduleCode === 'core') { const corePath = getSourcePath('core-skills'); if (await fs.pathExists(corePath)) { return corePath; } } // Check for built-in bmm module (directly under src/bmm-skills) if (moduleCode === 'bmm') { const bmmPath = getSourcePath('bmm-skills'); if (await fs.pathExists(bmmPath)) { return bmmPath; } } // Check external official modules const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options); if (externalSource) { return externalSource; } return null; } /** * Install a module * @param {string} moduleName - Code of the module to install (from module.yaml) * @param {string} bmadDir - Target bmad directory * @param {Function} fileTrackingCallback - Optional callback to track installed files * @param {Object} options - Additional installation options * @param {Array} options.installedIDEs - Array of IDE codes that were installed * @param {Object} options.moduleConfig - Module configuration from config collector * @param {Object} options.logger - Logger instance for output */ async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { const sourcePath = options.sourcePath || (await this.findModuleSource(moduleName, { silent: options.silent })); const targetPath = path.join(bmadDir, moduleName); // Check if source module exists if (!sourcePath) { // Provide a more user-friendly error message throw new Error( `Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`, ); } // Check if this is a custom module and read its custom.yaml values let customConfig = null; const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml'); if (await fs.pathExists(rootCustomConfigPath)) { try { const customContent = await fs.readFile(rootCustomConfigPath, 'utf8'); customConfig = yaml.parse(customContent); } catch (error) { await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`); } } // If this is a custom module, merge its values into the module config if (customConfig) { options.moduleConfig = { ...options.moduleConfig, ...customConfig }; if (options.logger) { await options.logger.log(` Merged custom configuration for ${moduleName}`); } } // Check if already installed if (await fs.pathExists(targetPath)) { await fs.remove(targetPath); } // Copy module files with filtering await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); // Create directories declared in module.yaml (unless explicitly skipped) if (!options.skipModuleInstaller) { await this.createModuleDirectories(moduleName, bmadDir, options); } // Capture version info for manifest const { Manifest } = require('../core/manifest'); const manifestObj = new Manifest(); const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath); await manifestObj.addModule(bmadDir, moduleName, { version: versionInfo.version, source: versionInfo.source, npmPackage: versionInfo.npmPackage, repoUrl: versionInfo.repoUrl, }); return { success: true, module: moduleName, path: targetPath, versionInfo, }; } /** * Update an existing module * @param {string} moduleName - Name of the module to update * @param {string} bmadDir - Target bmad directory */ async update(moduleName, bmadDir) { const sourcePath = await this.findModuleSource(moduleName); const targetPath = path.join(bmadDir, moduleName); if (!sourcePath) { throw new Error(`Module '${moduleName}' not found in any source location`); } if (!(await fs.pathExists(targetPath))) { throw new Error(`Module '${moduleName}' is not installed`); } await this.syncModule(sourcePath, targetPath); return { success: true, module: moduleName, path: targetPath, }; } /** * Remove a module * @param {string} moduleName - Name of the module to remove * @param {string} bmadDir - Target bmad directory */ async remove(moduleName, bmadDir) { const targetPath = path.join(bmadDir, moduleName); if (!(await fs.pathExists(targetPath))) { throw new Error(`Module '${moduleName}' is not installed`); } await fs.remove(targetPath); return { success: true, module: moduleName, }; } /** * Check if a module is installed * @param {string} moduleName - Name of the module * @param {string} bmadDir - Target bmad directory * @returns {boolean} True if module is installed */ async isInstalled(moduleName, bmadDir) { const targetPath = path.join(bmadDir, moduleName); return await fs.pathExists(targetPath); } /** * Get installed module info * @param {string} moduleName - Name of the module * @param {string} bmadDir - Target bmad directory * @returns {Object|null} Module info or null if not installed */ async getInstalledInfo(moduleName, bmadDir) { const targetPath = path.join(bmadDir, moduleName); if (!(await fs.pathExists(targetPath))) { return null; } const configPath = path.join(targetPath, 'config.yaml'); const moduleInfo = { id: moduleName, path: targetPath, installed: true, }; if (await fs.pathExists(configPath)) { try { const configContent = await fs.readFile(configPath, 'utf8'); const config = yaml.parse(configContent); Object.assign(moduleInfo, config); } catch (error) { await prompts.log.warn(`Failed to read installed module config: ${error.message}`); } } return moduleInfo; } /** * Copy module with filtering for localskip agents and conditional content * @param {string} sourcePath - Source module path * @param {string} targetPath - Target module path * @param {Function} fileTrackingCallback - Optional callback to track installed files * @param {Object} moduleConfig - Module configuration with conditional flags */ async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) { // Get all files in source const sourceFiles = await this.getFileList(sourcePath); for (const file of sourceFiles) { // Skip sub-modules directory - these are IDE-specific and handled separately if (file.startsWith('sub-modules/')) { continue; } // Skip sidecar directories - these contain agent-specific assets not needed at install time const isInSidecarDirectory = path .dirname(file) .split('/') .some((dir) => dir.toLowerCase().endsWith('-sidecar')); if (isInSidecarDirectory) { continue; } // Skip module.yaml at root - it's only needed at install time if (file === 'module.yaml') { continue; } // Skip module root config.yaml only - generated by config collector with actual values // Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied // for custom modules that use workflow-specific configuration if (file === 'config.yaml') { continue; } const sourceFile = path.join(sourcePath, file); const targetFile = path.join(targetPath, file); // Check if this is an agent file if (file.startsWith('agents/') && file.endsWith('.md')) { // Read the file to check for localskip const content = await fs.readFile(sourceFile, 'utf8'); // Check for localskip="true" in the agent tag const agentMatch = content.match(/]*\slocalskip="true"[^>]*>/); if (agentMatch) { await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`); continue; // Skip this agent } } // Copy the file with placeholder replacement await this.copyFile(sourceFile, targetFile); // Track the file if callback provided if (fileTrackingCallback) { fileTrackingCallback(targetFile); } } } /** * Find all .md agent files recursively in a directory * @param {string} dir - Directory to search * @returns {Array} List of .md agent file paths */ async findAgentMdFiles(dir) { const agentFiles = []; async function searchDirectory(searchDir) { const entries = await fs.readdir(searchDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(searchDir, entry.name); if (entry.isFile() && entry.name.endsWith('.md')) { agentFiles.push(fullPath); } else if (entry.isDirectory()) { await searchDirectory(fullPath); } } } await searchDirectory(dir); return agentFiles; } /** * Create directories declared in module.yaml's `directories` key * This replaces the security-risky module installer pattern with declarative config * During updates, if a directory path changed, moves the old directory to the new path * @param {string} moduleName - Name of the module * @param {string} bmadDir - Target bmad directory * @param {Object} options - Installation options * @param {Object} options.moduleConfig - Module configuration from config collector * @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates) * @param {Object} options.coreConfig - Core configuration * @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info */ async createModuleDirectories(moduleName, bmadDir, options = {}) { const moduleConfig = options.moduleConfig || {}; const existingModuleConfig = options.existingModuleConfig || {}; const projectRoot = path.dirname(bmadDir); const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; // Special handling for core module - it's in src/core-skills not src/modules let sourcePath; if (moduleName === 'core') { sourcePath = getSourcePath('core-skills'); } else { sourcePath = await this.findModuleSource(moduleName, { silent: true }); if (!sourcePath) { return emptyResult; // No source found, skip } } // Read module.yaml to find the `directories` key const moduleYamlPath = path.join(sourcePath, 'module.yaml'); if (!(await fs.pathExists(moduleYamlPath))) { return emptyResult; // No module.yaml, skip } let moduleYaml; try { const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); moduleYaml = yaml.parse(yamlContent); } catch { return emptyResult; // Invalid YAML, skip } if (!moduleYaml || !moduleYaml.directories) { return emptyResult; // No directories declared, skip } const directories = moduleYaml.directories; const wdsFolders = moduleYaml.wds_folders || []; const createdDirs = []; const movedDirs = []; const createdWdsFolders = []; for (const dirRef of directories) { // Parse variable reference like "{design_artifacts}" const varMatch = dirRef.match(/^\{([^}]+)\}$/); if (!varMatch) { // Not a variable reference, skip continue; } const configKey = varMatch[1]; const dirValue = moduleConfig[configKey]; if (!dirValue || typeof dirValue !== 'string') { continue; // No value or not a string, skip } // Strip {project-root}/ prefix if present let dirPath = dirValue.replace(/^\{project-root\}\/?/, ''); // Handle remaining {project-root} anywhere in the path dirPath = dirPath.replaceAll('{project-root}', ''); // Resolve to absolute path const fullPath = path.join(projectRoot, dirPath); // Validate path is within project root (prevent directory traversal) const normalizedPath = path.normalize(fullPath); const normalizedRoot = path.normalize(projectRoot); if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) { const color = await prompts.getColor(); await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`)); continue; } // Check if directory path changed from previous config (update/modify scenario) const oldDirValue = existingModuleConfig[configKey]; let oldFullPath = null; let oldDirPath = null; if (oldDirValue && typeof oldDirValue === 'string') { // F3: Normalize both values before comparing to avoid false negatives // from trailing slashes, separator differences, or prefix format variations let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, ''); normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', '')); const normalizedNew = path.normalize(dirPath); if (normalizedOld !== normalizedNew) { oldDirPath = normalizedOld; oldFullPath = path.join(projectRoot, oldDirPath); const normalizedOldAbsolute = path.normalize(oldFullPath); if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) { oldFullPath = null; // Old path escapes project root, ignore it } // F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2) if (oldFullPath) { const normalizedNewAbsolute = path.normalize(fullPath); if ( normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) || normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep) ) { const color = await prompts.getColor(); await prompts.log.warn( color.yellow( `${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`, ), ); oldFullPath = null; } } } } const dirName = configKey.replaceAll('_', ' '); if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) { // Path changed and old dir exists → move old to new location // F1: Use fs.move() instead of fs.rename() for cross-device/volume support // F2: Wrap in try/catch — fallback to creating new dir on failure try { await fs.ensureDir(path.dirname(fullPath)); await fs.move(oldFullPath, fullPath); movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`); } catch (moveError) { const color = await prompts.getColor(); await prompts.log.warn( color.yellow( `Failed to move ${oldDirPath} → ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`, ), ); await fs.ensureDir(fullPath); createdDirs.push(`${dirName}: ${dirPath}`); } } else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) { // F5: Both old and new directories exist — warn user about potential orphaned documents const color = await prompts.getColor(); await prompts.log.warn( color.yellow( `${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`, ), ); } else if (!(await fs.pathExists(fullPath))) { // New directory doesn't exist yet → create it createdDirs.push(`${dirName}: ${dirPath}`); await fs.ensureDir(fullPath); } // Create WDS subfolders if this is the design_artifacts directory if (configKey === 'design_artifacts' && wdsFolders.length > 0) { for (const subfolder of wdsFolders) { const subPath = path.join(fullPath, subfolder); if (!(await fs.pathExists(subPath))) { await fs.ensureDir(subPath); createdWdsFolders.push(subfolder); } } } } return { createdDirs, movedDirs, createdWdsFolders }; } /** * Private: Process module configuration * @param {string} modulePath - Path to installed module * @param {string} moduleName - Module name */ async processModuleConfig(modulePath, moduleName) { const configPath = path.join(modulePath, 'config.yaml'); if (await fs.pathExists(configPath)) { try { let configContent = await fs.readFile(configPath, 'utf8'); // Replace path placeholders configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`); configContent = configContent.replaceAll('{module}', moduleName); await fs.writeFile(configPath, configContent, 'utf8'); } catch (error) { await prompts.log.warn(`Failed to process module config: ${error.message}`); } } } /** * Private: Sync module files (preserving user modifications) * @param {string} sourcePath - Source module path * @param {string} targetPath - Target module path */ async syncModule(sourcePath, targetPath) { // Get list of all source files const sourceFiles = await this.getFileList(sourcePath); for (const file of sourceFiles) { const sourceFile = path.join(sourcePath, file); const targetFile = path.join(targetPath, file); // Check if target file exists and has been modified if (await fs.pathExists(targetFile)) { const sourceStats = await fs.stat(sourceFile); const targetStats = await fs.stat(targetFile); // Skip if target is newer (user modified) if (targetStats.mtime > sourceStats.mtime) { continue; } } // Copy file with placeholder replacement await this.copyFile(sourceFile, targetFile); } } /** * Private: Get list of all files in a directory * @param {string} dir - Directory path * @param {string} baseDir - Base directory for relative paths * @returns {Array} List of relative file paths */ async getFileList(dir, baseDir = dir) { const files = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const subFiles = await this.getFileList(fullPath, baseDir); files.push(...subFiles); } else { files.push(path.relative(baseDir, fullPath)); } } 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 };