refactor: replace module installer scripts with declarative directories config

Removes the security-risky _module-installer pattern (code execution at
install time) in favor of a declarative `directories` key in module.yaml.
The main installer now handles directory creation centrally based on this
config, eliminating per-module installer.js scripts and their CJS/ESM issues.

Changes:
- Delete src/bmm/_module-installer/installer.js
- Delete src/core/_module-installer/installer.js
- Add `directories` key to src/bmm/module.yaml
- Rename runModuleInstaller() -> createModuleDirectories()
- Remove _module-installer from ESLint overrides
- Remove _module-installer from file-ref validator skip dirs
This commit is contained in:
Brian Madison 2026-02-08 19:21:48 -06:00
parent 90ea3cbed7
commit c563cef0c2
10 changed files with 107 additions and 233 deletions

View File

@ -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'],

View File

@ -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<string>} options.installedIDEs - Array of IDE codes that were installed
* @param {Object} options.logger - Logger instance for output
* @returns {Promise<boolean>} - 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 };

View File

@ -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}"

View File

@ -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<string>} options.installedIDEs - Array of IDE codes that were installed
* @param {Object} options.logger - Logger instance for output
* @returns {Promise<boolean>} - 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 };

View File

@ -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
# code: wds
# name: "Whiteport UX Design System"
# description: "UX design framework with Figma integration"
# defaultSelected: false
# type: community
# npmPackage: bmad-method-wds-expansion

View File

@ -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;

View File

@ -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 {

View File

@ -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'))
) {

View File

@ -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;
}
if (typeof moduleInstaller.install === 'function') {
// Get project root (parent of bmad directory)
const projectRoot = path.dirname(bmadDir);
// Prepare logger (use console if not provided)
const logger = options.logger || {
log: console.log,
error: console.error,
warn: console.warn,
};
// Call the module installer
const result = await moduleInstaller.install({
projectRoot,
config: options.moduleConfig || {},
coreConfig: options.coreConfig || {},
installedIDEs: options.installedIDEs || [],
logger,
});
if (!result) {
await prompts.log.warn(`Module installer for ${moduleName} returned false`);
}
}
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
moduleYaml = yaml.parse(yamlContent);
} 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.
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;
}
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) {
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}/`));
}
}
}
}
}
@ -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 {

View File

@ -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;