1988 lines
72 KiB
JavaScript
1988 lines
72 KiB
JavaScript
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<string>} 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(/<agent[^>]*\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<string>} 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<string|null>} 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>} 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 };
|