1376 lines
52 KiB
JavaScript
1376 lines
52 KiB
JavaScript
const path = require('node:path');
|
|
const fs = require('fs-extra');
|
|
const yaml = require('yaml');
|
|
const prompts = require('../../../lib/prompts');
|
|
const { XmlHandler } = require('../../../lib/xml-handler');
|
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
|
const { filterCustomizationData } = require('../../../lib/agent/compiler');
|
|
const { ExternalModuleManager } = require('./external-manager');
|
|
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
|
|
|
/**
|
|
* Manages the installation, updating, and removal of BMAD modules.
|
|
* Handles module discovery, dependency resolution, configuration processing,
|
|
* and agent file management including XML activation block injection.
|
|
*
|
|
* @class ModuleManager
|
|
* @requires fs-extra
|
|
* @requires yaml
|
|
* @requires prompts
|
|
* @requires XmlHandler
|
|
*
|
|
* @example
|
|
* const manager = new ModuleManager();
|
|
* const modules = await manager.listAvailable();
|
|
* await manager.install('core-module', '/path/to/bmad');
|
|
*/
|
|
class ModuleManager {
|
|
constructor(options = {}) {
|
|
this.xmlHandler = new XmlHandler();
|
|
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
|
this.customModulePaths = new Map(); // Initialize custom module paths
|
|
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
|
|
}
|
|
|
|
/**
|
|
* Set the bmad folder name for placeholder replacement
|
|
* @param {string} bmadFolderName - The bmad folder name
|
|
*/
|
|
setBmadFolderName(bmadFolderName) {
|
|
this.bmadFolderName = bmadFolderName;
|
|
}
|
|
|
|
/**
|
|
* Set the core configuration for access during module installation
|
|
* @param {Object} coreConfig - Core configuration object
|
|
*/
|
|
setCoreConfig(coreConfig) {
|
|
this.coreConfig = coreConfig;
|
|
}
|
|
|
|
/**
|
|
* Set custom module paths for priority lookup
|
|
* @param {Map<string, string>} customModulePaths - Map of module ID to source path
|
|
*/
|
|
setCustomModulePaths(customModulePaths) {
|
|
this.customModulePaths = customModulePaths;
|
|
}
|
|
|
|
/**
|
|
* 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 copyFileWithPlaceholderReplacement(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 copyDirectoryWithPlaceholderReplacement(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.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
|
|
} else {
|
|
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copy sidecar directory to _bmad/_memory location with update-safe handling
|
|
* @param {string} sourceSidecarPath - Source sidecar directory path
|
|
* @param {string} agentName - Name of the agent (for naming)
|
|
* @param {string} bmadMemoryPath - This should ALWAYS be _bmad/_memory
|
|
* @param {boolean} isUpdate - Whether this is an update (default: false)
|
|
* @param {string} bmadDir - BMAD installation directory
|
|
* @param {Object} installer - Installer instance for file tracking
|
|
*/
|
|
async copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate = false, bmadDir = null, installer = null) {
|
|
const crypto = require('node:crypto');
|
|
const sidecarTargetDir = path.join(bmadMemoryPath, `${agentName}-sidecar`);
|
|
|
|
// Ensure target directory exists
|
|
await fs.ensureDir(bmadMemoryPath);
|
|
await fs.ensureDir(sidecarTargetDir);
|
|
|
|
// Get existing files manifest for update checking
|
|
let existingFilesManifest = [];
|
|
if (isUpdate && installer) {
|
|
existingFilesManifest = await installer.readFilesManifest(bmadDir);
|
|
}
|
|
|
|
// Build map of existing sidecar files with their hashes
|
|
const existingSidecarFiles = new Map();
|
|
for (const fileEntry of existingFilesManifest) {
|
|
if (fileEntry.path && fileEntry.path.includes(`${agentName}-sidecar/`)) {
|
|
existingSidecarFiles.set(fileEntry.path, fileEntry.hash);
|
|
}
|
|
}
|
|
|
|
// Get all files in source sidecar
|
|
const sourceFiles = await this.getFileList(sourceSidecarPath);
|
|
|
|
for (const file of sourceFiles) {
|
|
const sourceFilePath = path.join(sourceSidecarPath, file);
|
|
const targetFilePath = path.join(sidecarTargetDir, file);
|
|
|
|
// Calculate current source file hash
|
|
const sourceHash = crypto
|
|
.createHash('sha256')
|
|
.update(await fs.readFile(sourceFilePath))
|
|
.digest('hex');
|
|
|
|
// Path relative to bmad directory
|
|
const relativeToBmad = path.join('_memory', `${agentName}-sidecar`, file);
|
|
|
|
if (isUpdate && (await fs.pathExists(targetFilePath))) {
|
|
// Calculate current target file hash
|
|
const currentTargetHash = crypto
|
|
.createHash('sha256')
|
|
.update(await fs.readFile(targetFilePath))
|
|
.digest('hex');
|
|
|
|
// Get the last known hash from files-manifest
|
|
const lastKnownHash = existingSidecarFiles.get(relativeToBmad);
|
|
|
|
if (lastKnownHash) {
|
|
// We have a record of this file
|
|
if (currentTargetHash === lastKnownHash) {
|
|
// File hasn't been modified by user, safe to update
|
|
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
await prompts.log.message(` Updated sidecar file: ${relativeToBmad}`);
|
|
}
|
|
} else {
|
|
// User has modified the file, preserve it
|
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
await prompts.log.message(` Preserving user-modified file: ${relativeToBmad}`);
|
|
}
|
|
}
|
|
} else {
|
|
// First time seeing this file in manifest, copy it
|
|
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
await prompts.log.message(` Added new sidecar file: ${relativeToBmad}`);
|
|
}
|
|
}
|
|
} else {
|
|
// New installation
|
|
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
await prompts.log.message(` Copied sidecar file: ${relativeToBmad}`);
|
|
}
|
|
}
|
|
|
|
// Track the file in the installer's file tracking system
|
|
if (installer && installer.installedFiles) {
|
|
installer.installedFiles.add(targetFilePath);
|
|
}
|
|
}
|
|
|
|
// Return list of files that were processed
|
|
const processedFiles = sourceFiles.map((file) => path.join('_memory', `${agentName}-sidecar`, file));
|
|
return processedFiles;
|
|
}
|
|
|
|
/**
|
|
* List all available modules (excluding core which is always installed)
|
|
* bmm is the only built-in module, directly under src/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 bmm module (directly under src/bmm)
|
|
const bmmPath = getSourcePath('bmm');
|
|
if (await fs.pathExists(bmmPath)) {
|
|
const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm');
|
|
if (bmmInfo) {
|
|
modules.push(bmmInfo);
|
|
}
|
|
}
|
|
|
|
// Check for cached custom modules in _config/custom/
|
|
if (this.bmadDir) {
|
|
const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
|
|
if (await fs.pathExists(customCacheDir)) {
|
|
const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
|
|
for (const entry of cacheEntries) {
|
|
if (entry.isDirectory()) {
|
|
const cachePath = path.join(customCacheDir, entry.name);
|
|
const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom');
|
|
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
|
|
moduleInfo.isCustom = true;
|
|
moduleInfo.fromCache = true;
|
|
customModules.push(moduleInfo);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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' && sourceDescription !== 'src/core' && 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();
|
|
|
|
// First check custom module paths if they exist
|
|
if (this.customModulePaths && this.customModulePaths.has(moduleCode)) {
|
|
return this.customModulePaths.get(moduleCode);
|
|
}
|
|
|
|
// Check for built-in bmm module (directly under src/bmm)
|
|
if (moduleCode === 'bmm') {
|
|
const bmmPath = getSourcePath('bmm');
|
|
if (await fs.pathExists(bmmPath)) {
|
|
return bmmPath;
|
|
}
|
|
}
|
|
|
|
// Check external official modules
|
|
const externalSource = await this.findExternalModuleSource(moduleCode, options);
|
|
if (externalSource) {
|
|
return externalSource;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if a module is an external official module
|
|
* @param {string} moduleCode - Code of the module to check
|
|
* @returns {boolean} True if the module is external
|
|
*/
|
|
async isExternalModule(moduleCode) {
|
|
return await this.externalModuleManager.hasModule(moduleCode);
|
|
}
|
|
|
|
/**
|
|
* Get the cache directory for external modules
|
|
* @returns {string} Path to the external modules cache directory
|
|
*/
|
|
getExternalCacheDir() {
|
|
const os = require('node:os');
|
|
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
|
|
return cacheDir;
|
|
}
|
|
|
|
/**
|
|
* Clone an external module repository to cache
|
|
* @param {string} moduleCode - Code of the external module
|
|
* @returns {string} Path to the cloned repository
|
|
*/
|
|
async cloneExternalModule(moduleCode, options = {}) {
|
|
const { execSync } = require('node:child_process');
|
|
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
|
|
|
|
if (!moduleInfo) {
|
|
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
|
|
}
|
|
|
|
const cacheDir = this.getExternalCacheDir();
|
|
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
|
const silent = options.silent || false;
|
|
|
|
// Create cache directory if it doesn't exist
|
|
await fs.ensureDir(cacheDir);
|
|
|
|
// Helper to create a spinner or a no-op when silent
|
|
const createSpinner = async () => {
|
|
if (silent) {
|
|
return {
|
|
start() {},
|
|
stop() {},
|
|
error() {},
|
|
message() {},
|
|
cancel() {},
|
|
clear() {},
|
|
get isSpinning() {
|
|
return false;
|
|
},
|
|
get isCancelled() {
|
|
return false;
|
|
},
|
|
};
|
|
}
|
|
return await prompts.spinner();
|
|
};
|
|
|
|
// Track if we need to install dependencies
|
|
let needsDependencyInstall = false;
|
|
let wasNewClone = false;
|
|
|
|
// Check if already cloned
|
|
if (await fs.pathExists(moduleCacheDir)) {
|
|
// Try to update if it's a git repo
|
|
const fetchSpinner = await createSpinner();
|
|
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
|
try {
|
|
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
// Fetch and reset to remote - works better with shallow clones than pull
|
|
execSync('git fetch origin --depth 1', {
|
|
cwd: moduleCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
});
|
|
execSync('git reset --hard origin/HEAD', {
|
|
cwd: moduleCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
});
|
|
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
|
|
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
|
// Force dependency install if we got new code
|
|
if (currentRef !== newRef) {
|
|
needsDependencyInstall = true;
|
|
}
|
|
} catch {
|
|
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
|
|
// If update fails, remove and re-clone
|
|
await fs.remove(moduleCacheDir);
|
|
wasNewClone = true;
|
|
}
|
|
} else {
|
|
wasNewClone = true;
|
|
}
|
|
|
|
// Clone if not exists or was removed
|
|
if (wasNewClone) {
|
|
const fetchSpinner = await createSpinner();
|
|
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
|
try {
|
|
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
});
|
|
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
|
} catch (error) {
|
|
fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
|
|
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Install dependencies if package.json exists
|
|
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
|
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
|
|
if (await fs.pathExists(packageJsonPath)) {
|
|
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
|
|
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
|
|
|
|
// Force install if we updated or cloned new
|
|
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
|
|
const installSpinner = await createSpinner();
|
|
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
|
try {
|
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
cwd: moduleCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 120_000, // 2 minute timeout
|
|
});
|
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
|
} catch (error) {
|
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
|
}
|
|
} else {
|
|
// Check if package.json is newer than node_modules
|
|
let packageJsonNewer = false;
|
|
try {
|
|
const packageStats = await fs.stat(packageJsonPath);
|
|
const nodeModulesStats = await fs.stat(nodeModulesPath);
|
|
packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime;
|
|
} catch {
|
|
// If stat fails, assume we need to install
|
|
packageJsonNewer = true;
|
|
}
|
|
|
|
if (packageJsonNewer) {
|
|
const installSpinner = await createSpinner();
|
|
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
|
try {
|
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
cwd: moduleCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 120_000, // 2 minute timeout
|
|
});
|
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
|
} catch (error) {
|
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return moduleCacheDir;
|
|
}
|
|
|
|
/**
|
|
* Find the source path for an external module
|
|
* @param {string} moduleCode - Code of the external module
|
|
* @returns {string|null} Path to the module source or null if not found
|
|
*/
|
|
async findExternalModuleSource(moduleCode, options = {}) {
|
|
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
|
|
|
|
if (!moduleInfo) {
|
|
return null;
|
|
}
|
|
|
|
// Clone the external module repo
|
|
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
|
|
|
// The module-definition specifies the path to module.yaml relative to repo root
|
|
// We need to return the directory containing module.yaml
|
|
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml'
|
|
const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath));
|
|
|
|
return moduleDir;
|
|
}
|
|
|
|
/**
|
|
* 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 = 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);
|
|
}
|
|
|
|
// Vendor cross-module workflows BEFORE copying
|
|
// This reads source agent.yaml files and copies referenced workflows
|
|
await this.vendorCrossModuleWorkflows(sourcePath, targetPath, moduleName);
|
|
|
|
// Copy module files with filtering
|
|
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
|
|
|
// Compile any .agent.yaml files to .md format
|
|
await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer);
|
|
|
|
// Process agent files to inject activation block
|
|
await this.processAgentFiles(targetPath, moduleName);
|
|
|
|
// 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
|
|
* @param {boolean} force - Force update (overwrite modifications)
|
|
*/
|
|
async update(moduleName, bmadDir, force = false, options = {}) {
|
|
const sourcePath = await this.findModuleSource(moduleName);
|
|
const targetPath = path.join(bmadDir, moduleName);
|
|
|
|
// Check if source module exists
|
|
if (!sourcePath) {
|
|
throw new Error(`Module '${moduleName}' not found in any source location`);
|
|
}
|
|
|
|
// Check if module is installed
|
|
if (!(await fs.pathExists(targetPath))) {
|
|
throw new Error(`Module '${moduleName}' is not installed`);
|
|
}
|
|
|
|
if (force) {
|
|
// Force update - remove and reinstall
|
|
await fs.remove(targetPath);
|
|
return await this.install(moduleName, bmadDir, null, { installer: options.installer });
|
|
} else {
|
|
// Selective update - preserve user modifications
|
|
await this.syncModule(sourcePath, targetPath);
|
|
|
|
// Recompile agents (#1133)
|
|
await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer);
|
|
await this.processAgentFiles(targetPath, moduleName);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Only skip sidecar directories - they are handled separately during agent compilation
|
|
// But still allow other files in agent directories
|
|
const isInAgentDirectory = file.startsWith('agents/');
|
|
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;
|
|
}
|
|
|
|
// Skip .agent.yaml files - they will be compiled separately
|
|
if (file.endsWith('.agent.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.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
|
|
|
// Track the file if callback provided
|
|
if (fileTrackingCallback) {
|
|
fileTrackingCallback(targetFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compile .agent.yaml files to .md format in modules
|
|
* @param {string} sourcePath - Source module path
|
|
* @param {string} targetPath - Target module path
|
|
* @param {string} moduleName - Module name
|
|
* @param {string} bmadDir - BMAD installation directory
|
|
* @param {Object} installer - Installer instance for file tracking
|
|
*/
|
|
async compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, installer = null) {
|
|
const sourceAgentsPath = path.join(sourcePath, 'agents');
|
|
const targetAgentsPath = path.join(targetPath, 'agents');
|
|
const cfgAgentsDir = path.join(bmadDir, '_config', 'agents');
|
|
|
|
// Check if agents directory exists in source
|
|
if (!(await fs.pathExists(sourceAgentsPath))) {
|
|
return; // No agents to compile
|
|
}
|
|
|
|
// Get all agent YAML files recursively
|
|
const agentFiles = await this.findAgentFiles(sourceAgentsPath);
|
|
|
|
for (const agentFile of agentFiles) {
|
|
if (!agentFile.endsWith('.agent.yaml')) continue;
|
|
|
|
const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/');
|
|
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
|
|
|
|
await fs.ensureDir(targetDir);
|
|
|
|
const agentName = path.basename(agentFile, '.agent.yaml');
|
|
const sourceYamlPath = agentFile;
|
|
const targetMdPath = path.join(targetDir, `${agentName}.md`);
|
|
const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
|
|
|
|
// Read and compile the YAML
|
|
try {
|
|
const yamlContent = await fs.readFile(sourceYamlPath, 'utf8');
|
|
const { compileAgent } = require('../../../lib/agent/compiler');
|
|
|
|
// Create customize template if it doesn't exist
|
|
if (!(await fs.pathExists(customizePath))) {
|
|
const { getSourcePath } = require('../../../lib/project-root');
|
|
const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
|
|
if (await fs.pathExists(genericTemplatePath)) {
|
|
await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath);
|
|
// Only show customize creation in verbose mode
|
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
await prompts.log.message(` Created customize: ${moduleName}-${agentName}.customize.yaml`);
|
|
}
|
|
|
|
// Store original hash for modification detection
|
|
const crypto = require('node:crypto');
|
|
const customizeContent = await fs.readFile(customizePath, 'utf8');
|
|
const originalHash = crypto.createHash('sha256').update(customizeContent).digest('hex');
|
|
|
|
// Store in main manifest
|
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
let manifestData = {};
|
|
if (await fs.pathExists(manifestPath)) {
|
|
const manifestContent = await fs.readFile(manifestPath, 'utf8');
|
|
const yaml = require('yaml');
|
|
manifestData = yaml.parse(manifestContent);
|
|
}
|
|
if (!manifestData.agentCustomizations) {
|
|
manifestData.agentCustomizations = {};
|
|
}
|
|
manifestData.agentCustomizations[path.relative(bmadDir, customizePath)] = originalHash;
|
|
|
|
// Write back to manifest
|
|
const yaml = require('yaml');
|
|
// Clean the manifest data to remove any non-serializable values
|
|
const cleanManifestData = structuredClone(manifestData);
|
|
|
|
const updatedContent = yaml.stringify(cleanManifestData, {
|
|
indent: 2,
|
|
lineWidth: 0,
|
|
});
|
|
await fs.writeFile(manifestPath, updatedContent, 'utf8');
|
|
}
|
|
}
|
|
|
|
// Check for customizations and build answers object
|
|
let customizedFields = [];
|
|
let answers = {};
|
|
if (await fs.pathExists(customizePath)) {
|
|
const customizeContent = await fs.readFile(customizePath, 'utf8');
|
|
const customizeData = yaml.parse(customizeContent);
|
|
customizedFields = customizeData.customized_fields || [];
|
|
|
|
// Build answers object from customizations
|
|
if (customizeData.persona) {
|
|
answers.persona = customizeData.persona;
|
|
}
|
|
if (customizeData.agent?.metadata) {
|
|
const filteredMetadata = filterCustomizationData(customizeData.agent.metadata);
|
|
if (Object.keys(filteredMetadata).length > 0) {
|
|
Object.assign(answers, { metadata: filteredMetadata });
|
|
}
|
|
}
|
|
if (customizeData.critical_actions && customizeData.critical_actions.length > 0) {
|
|
answers.critical_actions = customizeData.critical_actions;
|
|
}
|
|
if (customizeData.memories && customizeData.memories.length > 0) {
|
|
answers.memories = customizeData.memories;
|
|
}
|
|
if (customizeData.menu && customizeData.menu.length > 0) {
|
|
answers.menu = customizeData.menu;
|
|
}
|
|
if (customizeData.prompts && customizeData.prompts.length > 0) {
|
|
answers.prompts = customizeData.prompts;
|
|
}
|
|
}
|
|
|
|
// Check if agent has sidecar
|
|
let hasSidecar = false;
|
|
try {
|
|
const agentYaml = yaml.parse(yamlContent);
|
|
hasSidecar = agentYaml?.agent?.metadata?.hasSidecar === true;
|
|
} catch {
|
|
// Continue without sidecar processing
|
|
}
|
|
|
|
// Compile with customizations if any
|
|
const { xml } = await compileAgent(yamlContent, answers, agentName, relativePath, { config: this.coreConfig || {} });
|
|
|
|
// Write the compiled agent
|
|
await fs.writeFile(targetMdPath, xml, 'utf8');
|
|
|
|
// Handle sidecar copying if present
|
|
if (hasSidecar) {
|
|
// Get the agent's directory to look for sidecar
|
|
const agentDir = path.dirname(agentFile);
|
|
const sidecarDirName = `${agentName}-sidecar`;
|
|
const sourceSidecarPath = path.join(agentDir, sidecarDirName);
|
|
|
|
// Check if sidecar directory exists
|
|
if (await fs.pathExists(sourceSidecarPath)) {
|
|
// Memory is always in _bmad/_memory
|
|
const bmadMemoryPath = path.join(bmadDir, '_memory');
|
|
|
|
// Determine if this is an update (by checking if agent already exists)
|
|
const isUpdate = await fs.pathExists(targetMdPath);
|
|
|
|
// Copy sidecar to memory location with update-safe handling
|
|
const copiedFiles = await this.copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate, bmadDir, installer);
|
|
|
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true' && copiedFiles.length > 0) {
|
|
await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`);
|
|
}
|
|
} else if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
await prompts.log.warn(` Agent marked as having sidecar but ${sidecarDirName} directory not found`);
|
|
}
|
|
}
|
|
|
|
// Copy any non-sidecar files from agent directory (e.g., foo.md)
|
|
const agentDir = path.dirname(agentFile);
|
|
const agentEntries = await fs.readdir(agentDir, { withFileTypes: true });
|
|
|
|
for (const entry of agentEntries) {
|
|
if (entry.isFile() && !entry.name.endsWith('.agent.yaml') && !entry.name.endsWith('.md')) {
|
|
// Copy additional files (like foo.md) to the agent target directory
|
|
const sourceFile = path.join(agentDir, entry.name);
|
|
const targetFile = path.join(targetDir, entry.name);
|
|
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
|
}
|
|
}
|
|
|
|
// Only show compilation details in verbose mode
|
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
await prompts.log.message(
|
|
` Compiled agent: ${agentName} -> ${path.relative(targetPath, targetMdPath)}${hasSidecar ? ' (with sidecar)' : ''}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
await prompts.log.warn(` Failed to compile agent ${agentName}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find all .agent.yaml files recursively in a directory
|
|
* @param {string} dir - Directory to search
|
|
* @returns {Array} List of .agent.yaml file paths
|
|
*/
|
|
async findAgentFiles(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('.agent.yaml')) {
|
|
agentFiles.push(fullPath);
|
|
} else if (entry.isDirectory()) {
|
|
await searchDirectory(fullPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
await searchDirectory(dir);
|
|
return agentFiles;
|
|
}
|
|
|
|
/**
|
|
* Process agent files to inject activation block
|
|
* @param {string} modulePath - Path to installed module
|
|
* @param {string} moduleName - Module name
|
|
*/
|
|
async processAgentFiles(modulePath, moduleName) {
|
|
// const agentsPath = path.join(modulePath, 'agents');
|
|
// // Check if agents directory exists
|
|
// if (!(await fs.pathExists(agentsPath))) {
|
|
// return; // No agents to process
|
|
// }
|
|
// // Get all agent MD files recursively
|
|
// const agentFiles = await this.findAgentMdFiles(agentsPath);
|
|
// for (const agentFile of agentFiles) {
|
|
// if (!agentFile.endsWith('.md')) continue;
|
|
// let content = await fs.readFile(agentFile, 'utf8');
|
|
// // Check if content has agent XML and no activation block
|
|
// if (content.includes('<agent') && !content.includes('<activation')) {
|
|
// // Inject the activation block using XML handler
|
|
// content = this.xmlHandler.injectActivationSimple(content);
|
|
// await fs.writeFile(agentFile, content, 'utf8');
|
|
// }
|
|
// }
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Vendor cross-module workflows referenced in agent files
|
|
* Scans SOURCE agent.yaml files for workflow-install and copies workflows to destination
|
|
* @param {string} sourcePath - Source module path
|
|
* @param {string} targetPath - Target module path (destination)
|
|
* @param {string} moduleName - Module name being installed
|
|
*/
|
|
async vendorCrossModuleWorkflows(sourcePath, targetPath, moduleName) {
|
|
const sourceAgentsPath = path.join(sourcePath, 'agents');
|
|
|
|
// Check if source agents directory exists
|
|
if (!(await fs.pathExists(sourceAgentsPath))) {
|
|
return; // No agents to process
|
|
}
|
|
|
|
// Get all agent YAML files from source
|
|
const agentFiles = await fs.readdir(sourceAgentsPath);
|
|
const yamlFiles = agentFiles.filter((f) => f.endsWith('.agent.yaml') || f.endsWith('.yaml'));
|
|
|
|
if (yamlFiles.length === 0) {
|
|
return; // No YAML agent files
|
|
}
|
|
|
|
let workflowsVendored = false;
|
|
|
|
for (const agentFile of yamlFiles) {
|
|
const agentPath = path.join(sourceAgentsPath, agentFile);
|
|
const agentYaml = yaml.parse(await fs.readFile(agentPath, 'utf8'));
|
|
|
|
// Check if agent has menu items with workflow-install
|
|
const menuItems = agentYaml?.agent?.menu || [];
|
|
const workflowInstallItems = menuItems.filter((item) => item['workflow-install']);
|
|
|
|
if (workflowInstallItems.length === 0) {
|
|
continue; // No workflow-install in this agent
|
|
}
|
|
|
|
if (!workflowsVendored) {
|
|
await prompts.log.info(`\n Vendoring cross-module workflows for ${moduleName}...`);
|
|
workflowsVendored = true;
|
|
}
|
|
|
|
await prompts.log.message(` Processing: ${agentFile}`);
|
|
|
|
for (const item of workflowInstallItems) {
|
|
const sourceWorkflowPath = item.workflow; // Where to copy FROM
|
|
const installWorkflowPath = item['workflow-install']; // Where to copy TO
|
|
|
|
// Parse SOURCE workflow path
|
|
// Example: {project-root}/_bmad/bmm/workflows/4-implementation/create-story/workflow.md
|
|
const sourceMatch = sourceWorkflowPath.match(/\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/);
|
|
if (!sourceMatch) {
|
|
await prompts.log.warn(` Could not parse workflow path: ${sourceWorkflowPath}`);
|
|
continue;
|
|
}
|
|
|
|
const [, sourceModule, sourceWorkflowSubPath] = sourceMatch;
|
|
|
|
// Parse INSTALL workflow path
|
|
// Example: {project-root}/_bmad/bmgd/workflows/4-production/create-story/workflow.md
|
|
const installMatch = installWorkflowPath.match(/\{project-root\}\/(_bmad)\/([^/]+)\/workflows\/(.+)/);
|
|
if (!installMatch) {
|
|
await prompts.log.warn(` Could not parse workflow-install path: ${installWorkflowPath}`);
|
|
continue;
|
|
}
|
|
|
|
const installWorkflowSubPath = installMatch[2];
|
|
|
|
const sourceModulePath = getModulePath(sourceModule);
|
|
const actualSourceWorkflowPath = path.join(sourceModulePath, 'workflows', sourceWorkflowSubPath.replace(/\/workflow\.md$/, ''));
|
|
|
|
const actualDestWorkflowPath = path.join(targetPath, 'workflows', installWorkflowSubPath.replace(/\/workflow\.md$/, ''));
|
|
|
|
// Check if source workflow exists
|
|
if (!(await fs.pathExists(actualSourceWorkflowPath))) {
|
|
await prompts.log.warn(` Source workflow not found: ${actualSourceWorkflowPath}`);
|
|
continue;
|
|
}
|
|
|
|
// Copy the entire workflow folder
|
|
await prompts.log.message(
|
|
` Vendoring: ${sourceModule}/workflows/${sourceWorkflowSubPath.replace(/\/workflow\.md$/, '')} → ${moduleName}/workflows/${installWorkflowSubPath.replace(/\/workflow\.md$/, '')}`,
|
|
);
|
|
|
|
await fs.ensureDir(path.dirname(actualDestWorkflowPath));
|
|
// Copy the workflow directory recursively with placeholder replacement
|
|
await this.copyDirectoryWithPlaceholderReplacement(actualSourceWorkflowPath, actualDestWorkflowPath);
|
|
}
|
|
}
|
|
|
|
if (workflowsVendored) {
|
|
await prompts.log.success(` Workflow vendoring complete\n`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 not src/modules
|
|
let sourcePath;
|
|
if (moduleName === 'core') {
|
|
sourcePath = getSourcePath('core');
|
|
} 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.copyFileWithPlaceholderReplacement(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;
|
|
}
|
|
}
|
|
|
|
module.exports = { ModuleManager };
|