diff --git a/eslint.config.mjs b/eslint.config.mjs index d6c20f329..23bf73aa5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -114,17 +114,6 @@ export default [ }, }, - // Module installer scripts use CommonJS for compatibility - { - files: ['**/_module-installer/**/*.js'], - rules: { - // Allow CommonJS patterns for installer scripts - 'unicorn/prefer-module': 'off', - 'n/no-missing-require': 'off', - 'n/no-unpublished-require': 'off', - }, - }, - // ESLint config file should not be checked for publish-related Node rules { files: ['eslint.config.mjs'], diff --git a/src/bmm/_module-installer/installer.js b/src/bmm/_module-installer/installer.js deleted file mode 100644 index 7b844a15d..000000000 --- a/src/bmm/_module-installer/installer.js +++ /dev/null @@ -1,48 +0,0 @@ -const fs = require('fs-extra'); -const path = require('node:path'); -const chalk = require('chalk'); - -// Directories to create from config -const DIRECTORIES = ['output_folder', 'planning_artifacts', 'implementation_artifacts']; - -/** - * BMM Module Installer - * Creates output directories configured in module config - * - * @param {Object} options - Installation options - * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from module.yaml - * @param {Array} options.installedIDEs - Array of IDE codes that were installed - * @param {Object} options.logger - Logger instance for output - * @returns {Promise} - Success status - */ -async function install(options) { - const { projectRoot, config, logger } = options; - - try { - logger.log(chalk.blue('🚀 Installing BMM Module...')); - - // Create configured directories - for (const configKey of DIRECTORIES) { - const configValue = config[configKey]; - if (!configValue) continue; - - const dirPath = configValue.replace('{project-root}/', ''); - const fullPath = path.join(projectRoot, dirPath); - - if (!(await fs.pathExists(fullPath))) { - const dirName = configKey.replace('_', ' '); - logger.log(chalk.yellow(`Creating ${dirName} directory: ${dirPath}`)); - await fs.ensureDir(fullPath); - } - } - - logger.log(chalk.green('✓ BMM Module installation complete')); - return true; - } catch (error) { - logger.error(chalk.red(`Error installing BMM module: ${error.message}`)); - return false; - } -} - -module.exports = { install }; diff --git a/src/bmm/module.yaml b/src/bmm/module.yaml index a9884e586..76f6b7433 100644 --- a/src/bmm/module.yaml +++ b/src/bmm/module.yaml @@ -42,3 +42,9 @@ project_knowledge: # Artifacts from research, document-project output, other lon prompt: "Where should long-term project knowledge be stored? (docs, research, references)" default: "docs" result: "{project-root}/{value}" + +# Directories to create during installation (declarative, no code execution) +directories: + - "{planning_artifacts}" + - "{implementation_artifacts}" + - "{project_knowledge}" diff --git a/src/core/_module-installer/installer.js b/src/core/_module-installer/installer.js deleted file mode 100644 index d77bc62fa..000000000 --- a/src/core/_module-installer/installer.js +++ /dev/null @@ -1,60 +0,0 @@ -const chalk = require('chalk'); - -/** - * Core Module Installer - * Standard module installer function that executes after IDE installations - * - * @param {Object} options - Installation options - * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from module.yaml - * @param {Array} options.installedIDEs - Array of IDE codes that were installed - * @param {Object} options.logger - Logger instance for output - * @returns {Promise} - Success status - */ -async function install(options) { - const { projectRoot, config, installedIDEs, logger } = options; - - try { - logger.log(chalk.blue('🏗️ Installing Core Module...')); - - // Core agent configs are created by the main installer's createAgentConfigs method - // No need to create them here - they'll be handled along with all other agents - - // Handle IDE-specific configurations if needed - if (installedIDEs && installedIDEs.length > 0) { - logger.log(chalk.cyan(`Configuring Core for IDEs: ${installedIDEs.join(', ')}`)); - - // Add any IDE-specific Core configurations here - for (const ide of installedIDEs) { - await configureForIDE(ide, projectRoot, config, logger); - } - } - - logger.log(chalk.green('✓ Core Module installation complete')); - return true; - } catch (error) { - logger.error(chalk.red(`Error installing Core module: ${error.message}`)); - return false; - } -} - -/** - * Configure Core module for specific IDE - * @private - */ -async function configureForIDE(ide) { - // Add IDE-specific configurations here - switch (ide) { - case 'claude-code': { - // Claude Code specific Core configurations - break; - } - // Add more IDEs as needed - default: { - // No specific configuration needed - break; - } - } -} - -module.exports = { install }; diff --git a/tools/cli/external-official-modules.yaml b/tools/cli/external-official-modules.yaml index 431ded4a3..d6ae06ee6 100644 --- a/tools/cli/external-official-modules.yaml +++ b/tools/cli/external-official-modules.yaml @@ -42,13 +42,12 @@ modules: type: bmad-org npmPackage: bmad-method-test-architecture-enterprise -# TODO: Enable once fixes applied: - -# whiteport-design-system: -# url: https://github.com/bmad-code-org/bmad-method-wds-expansion -# module-definition: src/module.yaml -# code: WDS -# name: "Whiteport UX Design System" -# description: "UX design framework with Figma integration" -# defaultSelected: false -# type: community + # whiteport-design-system: + # url: https://github.com/bmad-code-org/bmad-method-wds-expansion + # module-definition: src/module.yaml + # code: wds + # name: "Whiteport UX Design System" + # description: "UX design framework with Figma integration" + # defaultSelected: false + # type: community + # npmPackage: bmad-method-wds-expansion diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index 1a0f50d29..1fd410fa3 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -188,20 +188,18 @@ class ConfigCollector { this.allAnswers = {}; } - // Load module's install config schema + // Load module's config schema from module.yaml // First, try the standard src/modules location - let installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'module.yaml'); 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(installerConfigPath)) && !(await fs.pathExists(moduleConfigPath))) { + if (!(await fs.pathExists(moduleConfigPath))) { // Use the module manager to find the module source const { ModuleManager } = require('../modules/manager'); const moduleManager = new ModuleManager(); const moduleSourcePath = await moduleManager.findModuleSource(moduleName); if (moduleSourcePath) { - installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); } } @@ -211,8 +209,6 @@ class ConfigCollector { if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; - } else if (await fs.pathExists(installerConfigPath)) { - configPath = installerConfigPath; } else { // Check if this is a custom module with custom.yaml const { ModuleManager } = require('../modules/manager'); @@ -221,9 +217,8 @@ class ConfigCollector { if (moduleSourcePath) { const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); - const moduleInstallerCustomPath = path.join(moduleSourcePath, '_module-installer', 'custom.yaml'); - if ((await fs.pathExists(rootCustomConfigPath)) || (await fs.pathExists(moduleInstallerCustomPath))) { + 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 @@ -500,28 +495,24 @@ class ConfigCollector { } // Load module's config // First, check if we have a custom module path for this module - let installerConfigPath = null; let moduleConfigPath = null; if (this.customModulePaths && this.customModulePaths.has(moduleName)) { const customPath = this.customModulePaths.get(moduleName); - installerConfigPath = path.join(customPath, '_module-installer', 'module.yaml'); moduleConfigPath = path.join(customPath, 'module.yaml'); } else { // Try the standard src/modules location - installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'module.yaml'); moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); } // If not found in src/modules or custom paths, search the project - if (!(await fs.pathExists(installerConfigPath)) && !(await fs.pathExists(moduleConfigPath))) { + if (!(await fs.pathExists(moduleConfigPath))) { // Use the module manager to find the module source const { ModuleManager } = require('../modules/manager'); const moduleManager = new ModuleManager(); const moduleSourcePath = await moduleManager.findModuleSource(moduleName); if (moduleSourcePath) { - installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); } } @@ -529,8 +520,6 @@ class ConfigCollector { let configPath = null; if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; - } else if (await fs.pathExists(installerConfigPath)) { - configPath = installerConfigPath; } else { // No config for this module return; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 44f31090d..c25b76748 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -1070,11 +1070,11 @@ class Installer { warn: (msg) => console.warn(msg), // Always show warnings }; - // Run core module installer if core was installed + // Create directories for core module if core was installed if (config.installCore || resolution.byModule.core) { - spinner.message('Running core module installer...'); + spinner.message('Creating core module directories...'); - await this.moduleManager.runModuleInstaller('core', bmadDir, { + await this.moduleManager.createModuleDirectories('core', bmadDir, { installedIDEs: config.ides || [], moduleConfig: moduleConfigs.core || {}, coreConfig: moduleConfigs.core || {}, @@ -1083,13 +1083,13 @@ class Installer { }); } - // Run installers for user-selected modules + // Create directories for user-selected modules if (config.modules && config.modules.length > 0) { for (const moduleName of config.modules) { - spinner.message(`Running ${moduleName} module installer...`); + spinner.message(`Creating ${moduleName} module directories...`); - // Pass installed IDEs and module config to module installer - await this.moduleManager.runModuleInstaller(moduleName, bmadDir, { + // Pass installed IDEs and module config to directory creator + await this.moduleManager.createModuleDirectories(moduleName, bmadDir, { installedIDEs: config.ides || [], moduleConfig: moduleConfigs[moduleName] || {}, coreConfig: moduleConfigs.core || {}, @@ -1904,8 +1904,8 @@ class Installer { continue; } - // Skip _module-installer directory - it's only needed at install time - if (file.startsWith('_module-installer/') || file === 'module.yaml') { + // Skip module.yaml at root - it's only needed at install time + if (file === 'module.yaml') { continue; } @@ -1958,10 +1958,6 @@ class Installer { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - // Skip _module-installer directories - if (entry.name === '_module-installer') { - continue; - } const subFiles = await this.getFileList(fullPath, baseDir); files.push(...subFiles); } else { diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/cli/installers/lib/custom/handler.js index 6256e3cd2..52595e4ff 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/cli/installers/lib/custom/handler.js @@ -55,7 +55,7 @@ class CustomHandler { // Found a custom.yaml file customPaths.push(fullPath); } else if ( - entry.name === 'module.yaml' && // Check if this is a custom module (either in _module-installer or in root directory) + entry.name === 'module.yaml' && // Check if this is a custom module (in root directory) // Skip if it's in src/modules (those are standard modules) !fullPath.includes(path.join('src', 'modules')) ) { diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index a0b50048c..bc199a53d 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -236,17 +236,11 @@ class ModuleManager { async getModuleInfo(modulePath, defaultName, sourceDescription) { // Check for module structure (module.yaml OR custom.yaml) const moduleConfigPath = path.join(modulePath, 'module.yaml'); - const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml'); - const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml'); const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); let configPath = null; if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; - } else if (await fs.pathExists(installerConfigPath)) { - configPath = installerConfigPath; - } else if (await fs.pathExists(customConfigPath)) { - configPath = customConfigPath; } else if (await fs.pathExists(rootCustomConfigPath)) { configPath = rootCustomConfigPath; } @@ -268,7 +262,7 @@ class ModuleManager { description: 'BMAD Module', version: '5.0.0', source: sourceDescription, - isCustom: configPath === customConfigPath || configPath === rootCustomConfigPath || isCustomSource, + isCustom: configPath === rootCustomConfigPath || isCustomSource, }; // Read module config for metadata @@ -541,7 +535,6 @@ class ModuleManager { // Check if this is a custom module and read its custom.yaml values let customConfig = null; const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml'); - const moduleInstallerCustomPath = path.join(sourcePath, '_module-installer', 'custom.yaml'); if (await fs.pathExists(rootCustomConfigPath)) { try { @@ -550,13 +543,6 @@ class ModuleManager { } catch (error) { await prompts.log.warn(`Warning: Failed to read custom.yaml for ${moduleName}: ${error.message}`); } - } else if (await fs.pathExists(moduleInstallerCustomPath)) { - try { - const customContent = await fs.readFile(moduleInstallerCustomPath, 'utf8'); - customConfig = yaml.parse(customContent); - } catch (error) { - await prompts.log.warn(`Warning: Failed to read custom.yaml for ${moduleName}: ${error.message}`); - } } // If this is a custom module, merge its values into the module config @@ -585,9 +571,9 @@ class ModuleManager { // Process agent files to inject activation block await this.processAgentFiles(targetPath, moduleName); - // Call module-specific installer if it exists (unless explicitly skipped) + // Create directories declared in module.yaml (unless explicitly skipped) if (!options.skipModuleInstaller) { - await this.runModuleInstaller(moduleName, bmadDir, options); + await this.createModuleDirectories(moduleName, bmadDir, options); } // Capture version info for manifest @@ -743,8 +729,8 @@ class ModuleManager { continue; } - // Skip _module-installer directory - it's only needed at install time - if (file.startsWith('_module-installer/') || file === 'module.yaml') { + // Skip module.yaml at root - it's only needed at install time + if (file === 'module.yaml') { continue; } @@ -1259,80 +1245,101 @@ class ModuleManager { } /** - * Run module-specific installer if it exists + * Create directories declared in module.yaml's `directories` key + * This replaces the security-risky module installer pattern with declarative config * @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.coreConfig - Core configuration */ - async runModuleInstaller(moduleName, bmadDir, options = {}) { + async createModuleDirectories(moduleName, bmadDir, options = {}) { + const moduleConfig = options.moduleConfig || {}; + const projectRoot = path.dirname(bmadDir); + // Special handling for core module - it's in src/core not src/modules let sourcePath; if (moduleName === 'core') { sourcePath = getSourcePath('core'); } else { - sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); + sourcePath = await this.findModuleSource(moduleName, { silent: true }); if (!sourcePath) { - // No source found, skip module installer - return; + return; // No source found, skip } } - const installerDir = path.join(sourcePath, '_module-installer'); - // Prefer .cjs (always CommonJS) then fall back to .js - const cjsPath = path.join(installerDir, 'installer.cjs'); - const jsPath = path.join(installerDir, 'installer.js'); - const hasCjs = await fs.pathExists(cjsPath); - const installerPath = hasCjs ? cjsPath : jsPath; - - // Check if module has a custom installer - if (!hasCjs && !(await fs.pathExists(jsPath))) { - return; // No custom installer + // Read module.yaml to find the `directories` key + const moduleYamlPath = path.join(sourcePath, 'module.yaml'); + if (!(await fs.pathExists(moduleYamlPath))) { + return; // No module.yaml, skip } + let moduleYaml; try { - // .cjs files are always CommonJS and safe to require(). - // .js files may be ESM (when the package sets "type":"module"), - // so use dynamic import() which handles both CJS and ESM. - let moduleInstaller; - if (hasCjs) { - moduleInstaller = require(installerPath); - } else { - const { pathToFileURL } = require('node:url'); - const imported = await import(pathToFileURL(installerPath).href); - // CJS module.exports lands on .default; ESM default can be object, function, or class - moduleInstaller = imported.default == null ? imported : imported.default; + const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); + moduleYaml = yaml.parse(yamlContent); + } catch { + return; // Invalid YAML, skip + } + + if (!moduleYaml || !moduleYaml.directories) { + return; // No directories declared, skip + } + + // Get color utility for styled output + const color = await prompts.getColor(); + const directories = moduleYaml.directories; + const wdsFolders = moduleYaml.wds_folders || []; + + for (const dirRef of directories) { + // Parse variable reference like "{design_artifacts}" + const varMatch = dirRef.match(/^\{([^}]+)\}$/); + if (!varMatch) { + // Not a variable reference, skip + continue; } - if (typeof moduleInstaller.install === 'function') { - // Get project root (parent of bmad directory) - const projectRoot = path.dirname(bmadDir); + const configKey = varMatch[1]; + const dirValue = moduleConfig[configKey]; + if (!dirValue || typeof dirValue !== 'string') { + continue; // No value or not a string, skip + } - // Prepare logger (use console if not provided) - const logger = options.logger || { - log: console.log, - error: console.error, - warn: console.warn, - }; + // Strip {project-root}/ prefix if present + let dirPath = dirValue.replace(/^\{project-root\}\/?/, ''); - // Call the module installer - const result = await moduleInstaller.install({ - projectRoot, - config: options.moduleConfig || {}, - coreConfig: options.coreConfig || {}, - installedIDEs: options.installedIDEs || [], - logger, - }); + // Handle remaining {project-root} anywhere in the path + dirPath = dirPath.replaceAll('{project-root}', ''); - if (!result) { - await prompts.log.warn(`Module installer for ${moduleName} returned false`); + // 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) { + await prompts.log.warn(color.yellow(`Warning: ${configKey} path escapes project root, skipping: ${dirPath}`)); + continue; + } + + // Create directory if it doesn't exist + if (!(await fs.pathExists(fullPath))) { + const dirName = configKey.replaceAll('_', ' '); + await prompts.log.message(color.yellow(`Creating ${dirName} directory: ${dirPath}`)); + await fs.ensureDir(fullPath); + } + + // Create WDS subfolders if this is the design_artifacts directory + if (configKey === 'design_artifacts' && wdsFolders.length > 0) { + await prompts.log.message(color.cyan('Creating WDS folder structure...')); + for (const subfolder of wdsFolders) { + const subPath = path.join(fullPath, subfolder); + if (!(await fs.pathExists(subPath))) { + await fs.ensureDir(subPath); + await prompts.log.message(color.dim(` ✓ ${subfolder}/`)); + } } } - } catch { - // Post-install scripts are optional; module files are already installed. - // TODO: Eliminate post-install scripts entirely by adding a `directories` key - // to module.yaml that declares which config keys are paths to auto-create. - // The main installer can then handle directory creation centrally, removing - // the need for per-module installer.js scripts and their CJS/ESM issues. } } @@ -1402,10 +1409,6 @@ class ModuleManager { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - // Skip _module-installer directories - if (entry.name === '_module-installer') { - continue; - } const subFiles = await this.getFileList(fullPath, baseDir); files.push(...subFiles); } else { diff --git a/tools/validate-file-refs.js b/tools/validate-file-refs.js index 22b02da7f..bf92f31f8 100644 --- a/tools/validate-file-refs.js +++ b/tools/validate-file-refs.js @@ -42,7 +42,7 @@ const STRICT = process.argv.includes('--strict'); const SCAN_EXTENSIONS = new Set(['.yaml', '.yml', '.md', '.xml', '.csv']); // Skip directories -const SKIP_DIRS = new Set(['node_modules', '_module-installer', '.git']); +const SKIP_DIRS = new Set(['node_modules', '.git']); // Pattern: {project-root}/_bmad/ references const PROJECT_ROOT_REF = /\{project-root\}\/_bmad\/([^\s'"<>})\]`]+)/g;