BMAD-METHOD/tools/installer/core/installer.js

1567 lines
60 KiB
JavaScript

const path = require('node:path');
const fs = require('../fs-native');
const { Manifest } = require('./manifest');
const { OfficialModules } = require('../modules/official-modules');
const { IdeManager } = require('../ide/manager');
const { FileOps } = require('../file-ops');
const { Config } = require('./config');
const { getProjectRoot, getSourcePath } = require('../project-root');
const { ManifestGenerator } = require('./manifest-generator');
const prompts = require('../prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const { InstallPaths } = require('./install-paths');
const { ExternalModuleManager } = require('../modules/external-manager');
const { resolveModuleVersion } = require('../modules/version-resolver');
const { MODULE_HELP_CSV_HEADER } = require('../modules/module-help-schema');
const { ExistingInstall } = require('./existing-install');
const { warnPreNativeSkillsLegacy } = require('./legacy-warnings');
class Installer {
constructor() {
this.externalModuleManager = new ExternalModuleManager();
this.manifest = new Manifest();
this.ideManager = new IdeManager();
this.fileOps = new FileOps();
this.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME;
}
/**
* Main installation method
* @param {Object} config - Installation configuration
* @param {string} config.directory - Target directory
* @param {string[]} config.modules - Modules to install (including 'core')
* @param {string[]} config.ides - IDEs to configure
*/
async install(originalConfig) {
let updateState = null;
try {
const config = Config.build(originalConfig);
const paths = await InstallPaths.create(config);
const officialModules = await OfficialModules.build(config, paths);
const existingInstall = await ExistingInstall.detect(paths.bmadDir);
try {
await warnPreNativeSkillsLegacy({
projectRoot: paths.projectRoot,
existingVersion: existingInstall.installed ? existingInstall.version : null,
});
} catch (error) {
// Legacy-dir scan is informational; never let it abort install.
await prompts.log.warn(`Warning: Could not check for legacy BMAD entries: ${error.message}`);
}
if (existingInstall.installed) {
await this._removeDeselectedModules(existingInstall, config, paths);
updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules);
await this._removeDeselectedIdes(existingInstall, config, paths);
}
await this._validateIdeSelection(config);
// Capture pre-install module versions for from→to display
const preInstallVersions = new Map();
if (existingInstall.installed) {
const existingModules = await this.manifest.getAllModuleVersions(paths.bmadDir);
for (const mod of existingModules) {
if (mod.name && mod.version) {
preInstallVersions.set(mod.name, mod.version);
}
}
}
// Results collector for consolidated summary
const results = [];
const addResult = (step, status, detail = '', meta = {}) => results.push({ step, status, detail, ...meta });
// Capture previously installed skill IDs before they get overwritten
const previousSkillIds = new Set();
const prevCsvPath = path.join(paths.bmadDir, '_config', 'skill-manifest.csv');
if (await fs.pathExists(prevCsvPath)) {
try {
const csvParse = require('csv-parse/sync');
const content = await fs.readFile(prevCsvPath, 'utf8');
const records = csvParse.parse(content, { columns: true, skip_empty_lines: true });
for (const r of records) {
if (r.canonicalId) previousSkillIds.add(r.canonicalId);
}
} catch (error) {
await prompts.log.warn(`Failed to parse skill-manifest.csv: ${error.message}`);
}
}
const allModules = config.modules || [];
await this._installAndConfigure(config, originalConfig, paths, allModules, allModules, addResult, officialModules);
await this._setupIdes(config, allModules, paths, addResult, previousSkillIds);
// Skills are now in IDE directories — remove redundant copies from _bmad/.
// Also cleans up skill dirs left by older installer versions.
await this._cleanupSkillDirs(paths.bmadDir);
const restoreResult = await this._restoreUserFiles(paths, updateState);
// Render consolidated summary
await this.renderInstallSummary(results, {
bmadDir: paths.bmadDir,
modules: config.modules,
ides: config.ides,
customFiles: restoreResult.customFiles.length > 0 ? restoreResult.customFiles : undefined,
modifiedFiles: restoreResult.modifiedFiles.length > 0 ? restoreResult.modifiedFiles : undefined,
preInstallVersions,
});
return {
success: true,
path: paths.bmadDir,
modules: config.modules,
ides: config.ides,
projectDir: paths.projectRoot,
};
} catch (error) {
await prompts.log.error('Installation failed');
// Clean up any temp backup directories that were created before the failure
try {
if (updateState?.tempBackupDir && (await fs.pathExists(updateState.tempBackupDir))) {
await fs.remove(updateState.tempBackupDir);
}
if (updateState?.tempModifiedBackupDir && (await fs.pathExists(updateState.tempModifiedBackupDir))) {
await fs.remove(updateState.tempModifiedBackupDir);
}
} catch {
// Best-effort cleanup — don't mask the original error
}
throw error;
}
}
/**
* Remove modules that were previously installed but are no longer selected.
* No confirmation — the user's module selection is the decision.
*/
async _removeDeselectedModules(existingInstall, config, paths) {
const previouslyInstalled = new Set(existingInstall.moduleIds);
const newlySelected = new Set(config.modules || []);
const toRemove = [...previouslyInstalled].filter((m) => !newlySelected.has(m) && m !== 'core');
for (const moduleId of toRemove) {
const modulePath = paths.moduleDir(moduleId);
try {
if (await fs.pathExists(modulePath)) {
await fs.remove(modulePath);
}
} catch (error) {
await prompts.log.warn(`Warning: Failed to remove ${moduleId}: ${error.message}`);
}
}
}
/**
* Fail fast if all selected IDEs are suspended.
*/
async _validateIdeSelection(config) {
if (!config.ides || config.ides.length === 0) return;
await this.ideManager.ensureInitialized();
const suspendedIdes = config.ides.filter((ide) => {
const handler = this.ideManager.handlers.get(ide);
return handler?.platformConfig?.suspended;
});
if (suspendedIdes.length > 0 && suspendedIdes.length === config.ides.length) {
for (const ide of suspendedIdes) {
const handler = this.ideManager.handlers.get(ide);
await prompts.log.error(`${handler.displayName || ide}: ${handler.platformConfig.suspended}`);
}
throw new Error(
`All selected tool(s) are suspended: ${suspendedIdes.join(', ')}. Installation aborted to prevent upgrading _bmad/ without a working IDE configuration.`,
);
}
}
/**
* Remove IDEs that were previously installed but are no longer selected.
* No confirmation — the user's IDE selection is the decision.
*/
async _removeDeselectedIdes(existingInstall, config, paths) {
const previouslyInstalled = new Set(existingInstall.ides);
const newlySelected = new Set(config.ides || []);
const toRemove = [...previouslyInstalled].filter((ide) => !newlySelected.has(ide));
if (toRemove.length === 0) return;
// Pass the newly-selected list as remainingIdes so cleanupByList skips
// target_dir wipes for IDEs whose directory is still owned by a peer
// (e.g. removing 'cursor' while 'gemini' remains — both share .agents/skills).
const results = await this.ideManager.cleanupByList(paths.projectRoot, toRemove, {
remainingIdes: [...newlySelected],
});
for (const result of results || []) {
if (result && result.success === false) {
await prompts.log.warn(`Warning: Failed to remove ${result.ide}: ${result.error || 'unknown error'}`);
}
}
}
/**
* Install modules, create directories, generate configs and manifests.
*/
async _installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules) {
const isQuickUpdate = config.isQuickUpdate();
const moduleConfigs = officialModules.moduleConfigs;
const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
const installTasks = [];
installTasks.push({
title: 'Installing shared scripts',
task: async () => {
await this._installSharedScripts(paths);
addResult('Shared scripts', 'ok');
return 'Shared scripts installed';
},
});
if (allModules.length > 0) {
installTasks.push({
title: isQuickUpdate ? `Updating ${allModules.length} module(s)` : `Installing ${allModules.length} module(s)`,
task: async (message) => {
const installedModuleNames = new Set();
await this._installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, {
message,
installedModuleNames,
});
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
},
});
}
installTasks.push({
title: 'Creating module directories',
task: async (message) => {
const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose;
const moduleLogger = {
log: async (msg) => (verboseMode ? await prompts.log.message(msg) : undefined),
error: async (msg) => await prompts.log.error(msg),
warn: async (msg) => await prompts.log.warn(msg),
};
if (config.modules && config.modules.length > 0) {
for (const moduleName of config.modules) {
message(`Setting up ${moduleName}...`);
const result = await officialModules.createModuleDirectories(moduleName, paths.bmadDir, {
installedIDEs: config.ides || [],
moduleConfig: moduleConfigs[moduleName] || {},
existingModuleConfig: officialModules.existingConfig?.[moduleName] || {},
coreConfig: moduleConfigs.core || {},
logger: moduleLogger,
silent: true,
});
if (result) {
dirResults.createdDirs.push(...result.createdDirs);
dirResults.movedDirs.push(...(result.movedDirs || []));
dirResults.createdWdsFolders.push(...result.createdWdsFolders);
}
}
}
addResult('Module directories', 'ok');
return 'Module directories created';
},
});
const configTask = {
title: 'Generating configurations',
task: async (message) => {
await this.generateModuleConfigs(paths.bmadDir, moduleConfigs);
addResult('Configurations', 'ok', 'generated');
this.installedFiles.add(paths.manifestFile());
this.installedFiles.add(paths.centralConfig());
this.installedFiles.add(paths.centralUserConfig());
message('Generating manifests...');
const manifestGen = new ManifestGenerator();
const allModulesForManifest = config.isQuickUpdate()
? originalConfig._existingModules || allModules || []
: originalConfig._preserveModules
? [...allModules, ...originalConfig._preserveModules]
: allModules || [];
let modulesForCsvPreserve;
if (config.isQuickUpdate()) {
modulesForCsvPreserve = originalConfig._existingModules || allModules || [];
} else {
modulesForCsvPreserve = originalConfig._preserveModules ? [...allModules, ...originalConfig._preserveModules] : allModules;
}
await manifestGen.generateManifests(paths.bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve,
moduleConfigs,
});
// Apply post-install --set TOML patches. Runs after writeCentralConfig
// (inside generateManifests above) so the patch operates on the
// freshly written `_bmad/config.toml` / `_bmad/config.user.toml`.
// See `tools/installer/set-overrides.js` for routing rules.
if (config.setOverrides && Object.keys(config.setOverrides).length > 0) {
const { applySetOverrides } = require('../set-overrides');
const applied = await applySetOverrides(config.setOverrides, paths.bmadDir);
if (applied.length > 0) {
const summary = applied.map((a) => `${a.module}.${a.key}${a.file}`).join(', ');
await prompts.log.info(`Applied --set overrides: ${summary}`);
}
}
message('Generating help catalog...');
await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents);
addResult('Help catalog', 'ok');
return 'Configurations generated';
},
};
installTasks.push(configTask);
// Run install + dirs first, then render dir output, then run config generation
const mainTasks = installTasks.filter((t) => t !== configTask);
await prompts.tasks(mainTasks);
const color = await prompts.getColor();
if (dirResults.movedDirs.length > 0) {
const lines = dirResults.movedDirs.map((d) => ` ${d}`).join('\n');
await prompts.log.message(color.cyan(`Moved directories:\n${lines}`));
}
if (dirResults.createdDirs.length > 0) {
const lines = dirResults.createdDirs.map((d) => ` ${d}`).join('\n');
await prompts.log.message(color.yellow(`Created directories:\n${lines}`));
}
if (dirResults.createdWdsFolders.length > 0) {
const lines = dirResults.createdWdsFolders.map((f) => color.dim(` \u2713 ${f}/`)).join('\n');
await prompts.log.message(color.cyan(`Created WDS folder structure:\n${lines}`));
}
await prompts.tasks([configTask]);
}
/**
* Set up IDE integrations for each selected IDE.
*/
async _setupIdes(config, allModules, paths, addResult, previousSkillIds = new Set()) {
if (config.skipIde || !config.ides || config.ides.length === 0) return;
await this.ideManager.ensureInitialized();
const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string');
if (validIdes.length === 0) {
addResult('IDE configuration', 'warn', 'no valid IDEs selected');
return;
}
const setupResults = await this.ideManager.setupBatch(validIdes, paths.projectRoot, paths.bmadDir, {
selectedModules: allModules || [],
verbose: config.verbose,
previousSkillIds,
});
for (const setupResult of setupResults) {
const ide = setupResult.ide;
if (setupResult.success) {
addResult(ide, 'ok', setupResult.detail || '');
} else {
addResult(ide, 'error', setupResult.error || 'failed');
}
}
}
/**
* Remove skill directories from _bmad/ after IDE installation.
* Skills are self-contained in IDE directories, so _bmad/ only needs
* module-level files (config.yaml, _config/, etc.).
* Also cleans up skill dirs left by older installer versions.
* @param {string} bmadDir - BMAD installation directory
*/
async _cleanupSkillDirs(bmadDir) {
const csv = require('csv-parse/sync');
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return;
const csvContent = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
const bmadFolderName = path.basename(bmadDir);
const bmadPrefix = bmadFolderName + '/';
for (const record of records) {
if (!record.path) continue;
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
const sourceDir = path.dirname(path.join(bmadDir, relativePath));
if (await fs.pathExists(sourceDir)) {
await fs.remove(sourceDir);
}
}
}
/**
* Restore custom and modified files that were backed up before the update.
* No-op for fresh installs (updateState is null).
* @param {Object} paths - InstallPaths instance
* @param {Object|null} updateState - From _prepareUpdateState, or null for fresh installs
* @returns {Object} { customFiles, modifiedFiles } — lists of restored files
*/
async _restoreUserFiles(paths, updateState) {
const noFiles = { customFiles: [], modifiedFiles: [] };
if (!updateState || (updateState.customFiles.length === 0 && updateState.modifiedFiles.length === 0)) {
return noFiles;
}
let restoredCustomFiles = [];
let restoredModifiedFiles = [];
await prompts.tasks([
{
title: 'Finalizing installation',
task: async (message) => {
if (updateState.customFiles.length > 0) {
message(`Restoring ${updateState.customFiles.length} custom files...`);
for (const originalPath of updateState.customFiles) {
const relativePath = path.relative(paths.bmadDir, originalPath);
const backupPath = path.join(updateState.tempBackupDir, relativePath);
if (await fs.pathExists(backupPath)) {
await fs.ensureDir(path.dirname(originalPath));
await fs.copy(backupPath, originalPath, { overwrite: true });
}
}
if (updateState.tempBackupDir && (await fs.pathExists(updateState.tempBackupDir))) {
await fs.remove(updateState.tempBackupDir);
}
restoredCustomFiles = updateState.customFiles;
}
if (updateState.modifiedFiles.length > 0) {
restoredModifiedFiles = updateState.modifiedFiles;
if (updateState.tempModifiedBackupDir && (await fs.pathExists(updateState.tempModifiedBackupDir))) {
message(`Restoring ${restoredModifiedFiles.length} modified files as .bak...`);
for (const modifiedFile of restoredModifiedFiles) {
const relativePath = path.relative(paths.bmadDir, modifiedFile.path);
const tempBackupPath = path.join(updateState.tempModifiedBackupDir, relativePath);
const bakPath = modifiedFile.path + '.bak';
if (await fs.pathExists(tempBackupPath)) {
await fs.ensureDir(path.dirname(bakPath));
await fs.copy(tempBackupPath, bakPath, { overwrite: true });
}
}
await fs.remove(updateState.tempModifiedBackupDir);
}
}
return 'Installation finalized';
},
},
]);
return { customFiles: restoredCustomFiles, modifiedFiles: restoredModifiedFiles };
}
/**
* Common update preparation: detect files, preserve core config, back up.
* @param {Object} paths - InstallPaths instance
* @param {Object} config - Clean config (may have coreConfig updated)
* @param {Object} existingInstall - Detection result
* @param {Object} officialModules - OfficialModules instance
* @returns {Object} Update state: { customFiles, modifiedFiles, tempBackupDir, tempModifiedBackupDir }
*/
async _prepareUpdateState(paths, config, existingInstall, officialModules) {
// Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
const existingFilesManifest = await this.readFilesManifest(paths.bmadDir);
const { customFiles, modifiedFiles } = await this.detectCustomFiles(paths.bmadDir, existingFilesManifest);
// Preserve existing core configuration during updates
// (no-op for quick-update which already has core config from collectModuleConfigQuick)
const coreConfigPath = paths.moduleConfig('core');
if ((await fs.pathExists(coreConfigPath)) && (!config.coreConfig || Object.keys(config.coreConfig).length === 0)) {
try {
const yaml = require('yaml');
const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8');
const existingCoreConfig = yaml.parse(coreConfigContent);
config.coreConfig = existingCoreConfig;
officialModules.moduleConfigs.core = existingCoreConfig;
} catch (error) {
await prompts.log.warn(`Warning: Could not read existing core config: ${error.message}`);
}
}
const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles);
return {
customFiles,
modifiedFiles,
tempBackupDir: backupDirs.tempBackupDir,
tempModifiedBackupDir: backupDirs.tempModifiedBackupDir,
};
}
/**
* Back up custom and modified files to temp directories before overwriting.
* Returns the temp directory paths (or undefined if no files to back up).
* @param {Object} paths - InstallPaths instance
* @param {string[]} customFiles - Absolute paths of custom (user-added) files
* @param {Object[]} modifiedFiles - Array of { path, relativePath } for modified files
* @returns {Object} { tempBackupDir, tempModifiedBackupDir } — undefined if no files
*/
async _backupUserFiles(paths, customFiles, modifiedFiles) {
let tempBackupDir;
let tempModifiedBackupDir;
if (customFiles.length > 0) {
tempBackupDir = path.join(paths.projectRoot, '_bmad-custom-backup-temp');
await fs.ensureDir(tempBackupDir);
for (const customFile of customFiles) {
const relativePath = path.relative(paths.bmadDir, customFile);
const backupPath = path.join(tempBackupDir, relativePath);
await fs.ensureDir(path.dirname(backupPath));
await fs.copy(customFile, backupPath);
}
}
if (modifiedFiles.length > 0) {
tempModifiedBackupDir = path.join(paths.projectRoot, '_bmad-modified-backup-temp');
await fs.ensureDir(tempModifiedBackupDir);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(paths.bmadDir, modifiedFile.path);
const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
await fs.ensureDir(path.dirname(tempBackupPath));
await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true });
}
}
return { tempBackupDir, tempModifiedBackupDir };
}
/**
* Sync src/scripts/* → _bmad/scripts/ so shared Python scripts
* (e.g. resolve_customization.py) are available at install time.
* Wipes the destination first so files removed or renamed in source
* don't linger and get recorded as installed. Also seeds
* _bmad/custom/.gitignore on fresh installs so *.user.toml overrides
* stay out of version control.
*/
async _installSharedScripts(paths) {
const srcScriptsDir = path.join(paths.srcDir, 'src', 'scripts');
if (!(await fs.pathExists(srcScriptsDir))) {
throw new Error(`Shared scripts source directory not found: ${srcScriptsDir}`);
}
await fs.remove(paths.scriptsDir);
await fs.ensureDir(paths.scriptsDir);
await fs.copy(srcScriptsDir, paths.scriptsDir, { overwrite: true });
await this._trackFilesRecursive(paths.scriptsDir);
const customGitignore = path.join(paths.customDir, '.gitignore');
if (!(await fs.pathExists(customGitignore))) {
await fs.writeFile(customGitignore, '*.user.toml\n', 'utf8');
this.installedFiles.add(customGitignore);
}
}
async _trackFilesRecursive(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await this._trackFilesRecursive(full);
} else if (entry.isFile()) {
this.installedFiles.add(full);
}
}
}
/**
* Install official (non-custom) modules.
* @param {Object} config - Installation configuration
* @param {Object} paths - InstallPaths instance
* @param {string[]} officialModuleIds - Official module IDs to install
* @param {Function} addResult - Callback to record installation results
* @param {boolean} isQuickUpdate - Whether this is a quick update
* @param {Object} ctx - Shared context: { message, installedModuleNames }
*/
async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) {
const { message, installedModuleNames } = ctx;
const { CustomModuleManager } = require('../modules/custom-module-manager');
for (const moduleName of officialModuleIds) {
if (installedModuleNames.has(moduleName)) continue;
installedModuleNames.add(moduleName);
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`);
const moduleConfig = officialModules.moduleConfigs[moduleName] || {};
const installResult = await officialModules.install(
moduleName,
paths.bmadDir,
(filePath) => {
this.installedFiles.add(filePath);
},
{
skipModuleInstaller: true,
moduleConfig: moduleConfig,
installer: this,
silent: true,
channelOptions: config.channelOptions,
},
);
// Get display name from source module.yaml and resolve the freshest version metadata we can find locally.
const sourcePath = await officialModules.findModuleSource(moduleName, {
silent: true,
channelOptions: config.channelOptions,
});
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
const displayName = moduleInfo?.name || moduleName;
const externalResolution = officialModules.externalModuleManager.getResolution(moduleName);
let communityResolution = null;
if (!externalResolution) {
const { CommunityModuleManager } = require('../modules/community-manager');
communityResolution = new CommunityModuleManager().getResolution(moduleName);
}
const resolution = externalResolution || communityResolution;
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath: sourcePath,
fallbackVersion: resolution?.version || cachedResolution?.version,
marketplacePluginNames: cachedResolution?.pluginName ? [cachedResolution.pluginName] : [],
});
// Prefer the git tag recorded by the resolution (e.g. "v1.7.0") over
// the on-disk package.json (which may be ahead of the released tag).
const version = resolution?.version || versionInfo.version || '';
addResult(displayName, 'ok', '', {
moduleCode: moduleName,
newVersion: version,
newChannel: resolution?.channel || null,
newSha: resolution?.sha || null,
});
}
}
/**
* Read files-manifest.csv
* @param {string} bmadDir - BMAD installation directory
* @returns {Array} Array of file entries from files-manifest.csv
*/
async readFilesManifest(bmadDir) {
const filesManifestPath = path.join(bmadDir, '_config', 'files-manifest.csv');
if (!(await fs.pathExists(filesManifestPath))) {
return [];
}
try {
const content = await fs.readFile(filesManifestPath, 'utf8');
const lines = content.split('\n');
const files = [];
for (let i = 1; i < lines.length; i++) {
// Skip header
const line = lines[i].trim();
if (!line) continue;
// Parse CSV line properly handling quoted values
const parts = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
} else {
current += char;
}
}
parts.push(current); // Add last part
if (parts.length >= 4) {
files.push({
type: parts[0],
name: parts[1],
module: parts[2],
path: parts[3],
hash: parts[4] || null, // Hash may not exist in old manifests
});
}
}
return files;
} catch (error) {
await prompts.log.warn('Could not read files-manifest.csv: ' + error.message);
return [];
}
}
/**
* Detect custom and modified files
* @param {string} bmadDir - BMAD installation directory
* @param {Array} existingFilesManifest - Previous files from files-manifest.csv
* @returns {Object} Object with customFiles and modifiedFiles arrays
*/
async detectCustomFiles(bmadDir, existingFilesManifest) {
const customFiles = [];
const modifiedFiles = [];
// Memory subtrees (v6.1: _bmad/_memory, current: _bmad/memory) hold
// per-user runtime data generated by agents with sidecars. These files
// aren't installer-managed and must never be reported as "custom" or
// "modified" — they're user state, not user overrides.
const bmadMemoryPaths = ['_memory', 'memory'];
// Check if the manifest has hashes - if not, we can't detect modifications
let manifestHasHashes = false;
if (existingFilesManifest && existingFilesManifest.length > 0) {
manifestHasHashes = existingFilesManifest.some((f) => f.hash);
}
// Build map of previously installed files from files-manifest.csv with their hashes
const installedFilesMap = new Map();
for (const fileEntry of existingFilesManifest) {
if (fileEntry.path) {
const absolutePath = path.join(bmadDir, fileEntry.path);
installedFilesMap.set(path.normalize(absolutePath), {
hash: fileEntry.hash,
relativePath: fileEntry.path,
});
}
}
// Recursively scan bmadDir for all files
const scanDirectory = async (dir) => {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip certain directories
if (entry.name === 'node_modules' || entry.name === '.git') {
continue;
}
await scanDirectory(fullPath);
} else if (entry.isFile()) {
const normalizedPath = path.normalize(fullPath);
const fileInfo = installedFilesMap.get(normalizedPath);
// Skip certain system files that are auto-generated
const relativePath = path.relative(bmadDir, fullPath);
const fileName = path.basename(fullPath);
// Skip _config directory EXCEPT for modified agent customizations
if (relativePath.startsWith('_config/') || relativePath.startsWith('_config\\')) {
// Special handling for .customize.yaml files - only preserve if modified
if (relativePath.includes('/agents/') && fileName.endsWith('.customize.yaml')) {
// Check if the customization file has been modified from manifest
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
if (await fs.pathExists(manifestPath)) {
const crypto = require('node:crypto');
const currentContent = await fs.readFile(fullPath, 'utf8');
const currentHash = crypto.createHash('sha256').update(currentContent).digest('hex');
const yaml = require('yaml');
const manifestContent = await fs.readFile(manifestPath, 'utf8');
const manifestData = yaml.parse(manifestContent);
const originalHash = manifestData.agentCustomizations?.[relativePath];
// Only add to customFiles if hash differs (user modified)
if (originalHash && currentHash !== originalHash) {
customFiles.push(fullPath);
}
}
}
continue;
}
if (bmadMemoryPaths.some((mp) => relativePath === mp || relativePath.startsWith(mp + '/'))) {
continue;
}
// Skip config.yaml files - these are regenerated on each install/update
if (fileName === 'config.yaml') {
continue;
}
if (!fileInfo) {
// File not in manifest = custom file
// EXCEPT: Agent .md files in module folders are generated files, not custom
// Only treat .md files under _config/agents/ as custom
if (!(fileName.endsWith('.md') && relativePath.includes('/agents/') && !relativePath.startsWith('_config/'))) {
customFiles.push(fullPath);
}
} else if (manifestHasHashes && fileInfo.hash) {
// File in manifest with hash - check if it was modified
const currentHash = await this.manifest.calculateFileHash(fullPath);
if (currentHash && currentHash !== fileInfo.hash) {
// Hash changed = file was modified
modifiedFiles.push({
path: fullPath,
relativePath: fileInfo.relativePath,
});
}
}
}
}
} catch {
// Ignore errors scanning directories
}
};
await scanDirectory(bmadDir);
return { customFiles, modifiedFiles };
}
/**
* Generate clean config.yaml files for each installed module
* @param {string} bmadDir - BMAD installation directory
* @param {Object} moduleConfigs - Collected configuration values
*/
async generateModuleConfigs(bmadDir, moduleConfigs) {
const yaml = require('yaml');
// Extract core config values to share with other modules
const coreConfig = moduleConfigs.core || {};
// Get all installed module directories
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
const nonModuleDirs = new Set(['_config', '_memory', 'memory', 'docs', 'scripts', 'custom']);
const installedModules = entries.filter((entry) => entry.isDirectory() && !nonModuleDirs.has(entry.name)).map((entry) => entry.name);
// Generate config.yaml for each installed module
for (const moduleName of installedModules) {
const modulePath = path.join(bmadDir, moduleName);
// Get module-specific config or use empty object if none
const config = moduleConfigs[moduleName] || {};
if (await fs.pathExists(modulePath)) {
const configPath = path.join(modulePath, 'config.yaml');
// Create header
const packageJson = require(path.join(getProjectRoot(), 'package.json'));
const header = `# ${moduleName.toUpperCase()} Module Configuration
# Generated by BMAD installer
# Version: ${packageJson.version}
# Date: ${new Date().toISOString()}
`;
// For non-core modules, add core config values directly
let finalConfig = { ...config };
let coreSection = '';
if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) {
// Add core values directly to the module config
// These will be available for reference in the module
finalConfig = {
...config,
...coreConfig, // Spread core config values directly into the module config
};
// Create a comment section to identify core values
coreSection = '\n# Core Configuration Values\n';
}
// Clean the config to remove any non-serializable values (like functions)
const cleanConfig = structuredClone(finalConfig);
// Convert config to YAML
let yamlContent = yaml.stringify(cleanConfig, {
indent: 2,
lineWidth: 0,
minContentWidth: 0,
});
// If we have core values, reorganize the YAML to group them with their comment
if (coreSection && moduleName !== 'core') {
// Split the YAML into lines
const lines = yamlContent.split('\n');
const moduleConfigLines = [];
const coreConfigLines = [];
// Separate module-specific and core config lines
for (const line of lines) {
const key = line.split(':')[0].trim();
if (Object.prototype.hasOwnProperty.call(coreConfig, key)) {
coreConfigLines.push(line);
} else {
moduleConfigLines.push(line);
}
}
// Rebuild YAML with module config first, then core config with comment
yamlContent = moduleConfigLines.join('\n');
if (coreConfigLines.length > 0) {
yamlContent += coreSection + coreConfigLines.join('\n');
}
}
// Write the clean config file with POSIX-compliant final newline
const content = header + yamlContent;
await fs.writeFile(configPath, content.endsWith('\n') ? content : content + '\n', 'utf8');
// Track the config file in installedFiles
this.installedFiles.add(configPath);
}
}
}
/**
* Merge all module-help.csv files into a single bmad-help.csv.
* Scans all installed modules for module-help.csv and merges them.
* Output preserves the source schema verbatim — see schema below.
* @param {string} bmadDir - BMAD installation directory
* @param {Array<Object>} _agentEntries - Unused; retained for call-site compatibility
*/
async mergeModuleHelpCatalogs(bmadDir, _agentEntries = []) {
const allRows = [];
const headerRow = MODULE_HELP_CSV_HEADER;
const COLUMN_COUNT = 13;
const PHASE_INDEX = 7;
// Get all installed module directories
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
const nonModuleDirs = new Set(['_config', '_memory', 'memory', 'docs', 'scripts', 'custom']);
const installedModules = entries.filter((entry) => entry.isDirectory() && !nonModuleDirs.has(entry.name)).map((entry) => entry.name);
// Add core module to scan (it's installed at root level as _config, but we check src/core-skills)
const coreModulePath = getSourcePath('core-skills');
const modulePaths = new Map();
// Map all module source paths
if (await fs.pathExists(coreModulePath)) {
modulePaths.set('core', coreModulePath);
}
// Map installed module paths
for (const moduleName of installedModules) {
const modulePath = path.join(bmadDir, moduleName);
modulePaths.set(moduleName, modulePath);
}
// Scan each module for module-help.csv
for (const [moduleName, modulePath] of modulePaths) {
const helpFilePath = path.join(modulePath, 'module-help.csv');
if (await fs.pathExists(helpFilePath)) {
try {
const content = await fs.readFile(helpFilePath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#'));
let headerWarned = false;
for (const line of lines) {
// Header row: warn on drift from canonical schema, then skip.
// Data rows are loaded positionally regardless, so the warning
// is advisory — the maintainer should rename their columns.
if (line.startsWith('module,')) {
if (!headerWarned && line.trim() !== headerRow) {
await prompts.log.warn(
` ${moduleName}/module-help.csv header does not match canonical schema. ` +
`Expected: ${headerRow} | Found: ${line.trim()} | Data loaded positionally.`,
);
headerWarned = true;
}
continue;
}
// Parse the line - handle quoted fields with commas
const columns = this.parseCSVLine(line);
if (columns.length < COLUMN_COUNT - 1) continue;
// Pad short rows; truncate over-long rows
const padded = columns.slice(0, COLUMN_COUNT);
while (padded.length < COLUMN_COUNT) padded.push('');
// If module column is empty, fill with this module's name
// (core stays empty so its rows render as universal tools)
if ((!padded[0] || padded[0].trim() === '') && moduleName !== 'core') {
padded[0] = moduleName;
}
allRows.push(padded.map((c) => this.escapeCSVField(c)).join(','));
}
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Merged module-help from: ${moduleName}`);
}
} catch (error) {
await prompts.log.warn(` Warning: Failed to read module-help.csv from ${moduleName}: ${error.message}`);
}
}
}
// Sort by module, then phase. Stable sort preserves authored order within a phase.
const decorated = allRows.map((row, index) => ({ row, index, cols: this.parseCSVLine(row) }));
decorated.sort((a, b) => {
const moduleA = (a.cols[0] || '').toLowerCase();
const moduleB = (b.cols[0] || '').toLowerCase();
if (moduleA !== moduleB) return moduleA.localeCompare(moduleB);
const phaseA = a.cols[PHASE_INDEX] || '';
const phaseB = b.cols[PHASE_INDEX] || '';
if (phaseA !== phaseB) return phaseA.localeCompare(phaseB);
return a.index - b.index;
});
const sortedRows = decorated.map((d) => d.row);
// Write merged catalog
const outputDir = path.join(bmadDir, '_config');
await fs.ensureDir(outputDir);
const outputPath = path.join(outputDir, 'bmad-help.csv');
const mergedContent = [headerRow, ...sortedRows].join('\n');
await fs.writeFile(outputPath, mergedContent, 'utf8');
// Track the installed file
this.installedFiles.add(outputPath);
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Generated bmad-help.csv: ${sortedRows.length} workflows`);
}
}
/**
* Render a consolidated install summary using prompts.note()
* @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail}
* @param {Object} context - {bmadDir, modules, ides, customFiles, modifiedFiles}
*/
async renderInstallSummary(results, context = {}) {
const color = await prompts.getColor();
const selectedIdes = new Set((context.ides || []).map((ide) => String(ide).toLowerCase()));
// Build step lines with status indicators
const preVersions = context.preInstallVersions || new Map();
const lines = [];
for (const r of results) {
const stepLabel = r.step;
let icon;
if (r.status === 'ok') {
icon = color.green('\u2713');
} else if (r.status === 'warn') {
icon = color.yellow('!');
} else {
icon = color.red('\u2717');
}
// Build version detail for module results
let detail = '';
if (r.moduleCode && r.newVersion) {
const oldVersion = preVersions.get(r.moduleCode);
// Format a version label for display:
// "main" → "main @ <short-sha>" (next channel shows what SHA landed)
// "v1.7.0" or "1.7.0" → "v1.7.0" (prefix 'v' when missing)
// anything else (legacy strings) → as-is
const fmt = (v, sha) => {
if (typeof v !== 'string' || !v) return '';
if (v === 'main' || v === 'HEAD') return sha ? `main @ ${sha.slice(0, 7)}` : 'main';
if (/^v?\d+\.\d+\.\d+/.test(v)) return v.startsWith('v') ? v : `v${v}`;
return v;
};
const newV = fmt(r.newVersion, r.newSha);
// 'main'/'HEAD' strings only identify the channel, not the commit, so
// we can't assert "no change" without comparing SHAs — and preVersions
// doesn't carry the old SHA. Render these as a refresh instead of a
// false-negative "no change".
const isMainLike = oldVersion === 'main' || oldVersion === 'HEAD';
if (oldVersion && oldVersion === r.newVersion && !isMainLike) {
detail = ` (${newV}, no change)`;
} else if (oldVersion && isMainLike) {
detail = ` (${newV}, refreshed)`;
} else if (oldVersion) {
detail = ` (${fmt(oldVersion, r.newSha)}${newV})`;
} else {
detail = ` (${newV}, installed)`;
}
} else if (r.detail) {
detail = ` (${r.detail})`;
}
lines.push(` ${icon} ${stepLabel}${detail}`);
}
if ((context.ides || []).length === 0) {
lines.push(` ${color.green('\u2713')} No IDE selected (installed in _bmad only)`);
}
// Context and warnings
lines.push('');
if (context.bmadDir) {
lines.push(` Installed to: ${context.bmadDir}`);
}
if (context.customFiles && context.customFiles.length > 0) {
lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`);
}
if (context.modifiedFiles && context.modifiedFiles.length > 0) {
lines.push(` ${color.yellow(`Modified files backed up (.bak): ${context.modifiedFiles.length}`)}`);
}
// Next steps
lines.push(
'',
' Get started:',
` 1. Launch your AI agent from your project folder`,
` 2. Not sure what to do? Invoke the ${color.cyan('bmad-help')} skill and ask it what to do!`,
'',
` Blog, Docs and Guides: ${color.blue('https://bmadcode.com/')}`,
` Community: ${color.blue('https://discord.gg/gk8jAdXWmj')}`,
);
await prompts.box(lines.join('\n'), 'BMAD is ready to use!', {
rounded: true,
formatBorder: color.green,
});
}
/**
* Quick update method - preserves all settings and only prompts for new config fields
* @param {Object} config - Configuration with directory
* @returns {Object} Update result
*/
async quickUpdate(config) {
const projectDir = path.resolve(config.directory);
const { bmadDir } = await this.findBmadDir(projectDir);
// Check if bmad directory exists
if (!(await fs.pathExists(bmadDir))) {
throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`);
}
// Detect existing installation
const existingInstall = await ExistingInstall.detect(bmadDir);
const installedModules = existingInstall.moduleIds;
const configuredIdes = existingInstall.ides;
const projectRoot = path.dirname(bmadDir);
// Get available modules (what we have source for)
const availableModulesData = await new OfficialModules().listAvailable();
const availableModules = [...availableModulesData.modules];
// Add external official modules to available modules
const externalModules = await this.externalModuleManager.listAvailable();
for (const externalModule of externalModules) {
if (installedModules.includes(externalModule.code) && !availableModules.some((m) => m.id === externalModule.code)) {
availableModules.push({
id: externalModule.code,
name: externalModule.name,
isExternal: true,
fromExternal: true,
});
}
}
// Add installed community modules to available modules
const { CommunityModuleManager } = require('../modules/community-manager');
const communityMgr = new CommunityModuleManager();
const communityModules = await communityMgr.listAll();
for (const communityModule of communityModules) {
if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
availableModules.push({
id: communityModule.code,
name: communityModule.displayName,
isExternal: true,
fromCommunity: true,
});
}
}
// Add installed custom modules to available modules
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();
for (const moduleId of installedModules) {
if (!availableModules.some((m) => m.id === moduleId)) {
const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir });
if (customSource) {
availableModules.push({
id: moduleId,
name: moduleId,
isExternal: true,
fromCustom: true,
});
}
}
}
const availableModuleIds = new Set(availableModules.map((m) => m.id));
// Only update modules that are BOTH installed AND available (we have source for)
const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id));
const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id));
if (skippedModules.length > 0) {
await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`);
}
// Build channel options from the existing manifest FIRST so the config
// collector below (which triggers external-module clones via
// findModuleSource) knows each module's recorded channel and doesn't
// silently redecide it. Without this, modules previously on 'next' or
// 'pinned' would trigger a stable-channel tag lookup at config-collection
// time, burning GitHub API quota and potentially failing.
const manifestData = await this.manifest.read(bmadDir);
const channelOptions = { global: null, nextSet: new Set(), pins: new Map(), warnings: [] };
if (manifestData?.modulesDetailed) {
const { fetchStableTags, classifyUpgrade, parseGitHubRepo } = require('../modules/channel-resolver');
for (const entry of manifestData.modulesDetailed) {
if (!entry?.name || !entry?.channel) continue;
if (entry.channel === 'pinned' && entry.version) {
channelOptions.pins.set(entry.name, entry.version);
continue;
}
if (entry.channel === 'next') {
channelOptions.nextSet.add(entry.name);
continue;
}
// Stable: classify the available upgrade. Patches and minors fall
// through (stable default picks up the top tag). A major upgrade
// requires opt-in, so under quick-update's non-interactive semantics
// we pin to the current version to prevent a silent breaking jump.
if (entry.channel === 'stable' && entry.version && entry.repoUrl) {
const parsed = parseGitHubRepo(entry.repoUrl);
if (!parsed) continue;
try {
const tags = await fetchStableTags(parsed.owner, parsed.repo);
if (tags.length === 0) continue;
const topTag = tags[0].tag;
const cls = classifyUpgrade(entry.version, topTag);
if (cls === 'major') {
channelOptions.pins.set(entry.name, entry.version);
await prompts.log.warn(
`${entry.name} ${entry.version}${topTag} is a new major release; staying on ${entry.version}. ` +
`Run \`bmad install\` (Modify) with \`--pin ${entry.name}=${topTag}\` to accept.`,
);
}
} catch (error) {
// Tag lookup failed (offline, rate-limited). Stay on the current
// version rather than guessing — the existing cache is already
// at that ref, so re-using it keeps the install stable.
channelOptions.pins.set(entry.name, entry.version);
await prompts.log.warn(`Could not check ${entry.name} for updates (${error.message}); staying on ${entry.version}.`);
}
}
}
}
// Load existing configs and collect new fields (if any)
await prompts.log.info('Checking for new configuration options...');
const quickModules = new OfficialModules({ channelOptions });
await quickModules.loadExistingConfig(projectDir);
let promptedForNewFields = false;
const corePrompted = await quickModules.collectModuleConfigQuick('core', projectDir, true);
if (corePrompted) {
promptedForNewFields = true;
}
for (const moduleName of modulesToUpdate) {
if (moduleName === 'core') continue; // Already collected above
const modulePrompted = await quickModules.collectModuleConfigQuick(moduleName, projectDir, true);
if (modulePrompted) {
promptedForNewFields = true;
}
}
if (!promptedForNewFields) {
await prompts.log.success('All configuration is up to date, no new options to configure');
}
quickModules.collectedConfig._meta = {
version: require(path.join(getProjectRoot(), 'package.json')).version,
installDate: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
// Build config and delegate to install()
const installConfig = {
directory: projectDir,
modules: modulesToUpdate,
ides: configuredIdes,
coreConfig: quickModules.collectedConfig.core,
moduleConfigs: quickModules.collectedConfig,
// Forward `--set` overrides so the post-install patch step
// (`applySetOverrides`) runs at the end of quick-update too. The
// installer.install path applies them after writeCentralConfig.
setOverrides: config.setOverrides || {},
actionType: 'install',
_quickUpdate: true,
_preserveModules: skippedModules,
_existingModules: installedModules,
channelOptions,
};
await this.install(installConfig);
return {
success: true,
moduleCount: modulesToUpdate.length,
hadNewFields: promptedForNewFields,
modules: modulesToUpdate,
skippedModules: skippedModules,
ides: configuredIdes,
};
}
/**
* Uninstall BMAD with selective removal options
* @param {string} directory - Project directory
* @param {Object} options - Uninstall options
* @param {boolean} [options.removeModules=true] - Remove _bmad/ directory
* @param {boolean} [options.removeIdeConfigs=true] - Remove IDE configurations
* @param {boolean} [options.removeOutputFolder=false] - Remove user artifacts output folder
* @returns {Object} Result with success status and removed components
*/
async uninstall(directory, options = {}) {
const projectDir = path.resolve(directory);
const { bmadDir } = await this.findBmadDir(projectDir);
if (!(await fs.pathExists(bmadDir))) {
return { success: false, reason: 'not-installed' };
}
// 1. DETECT: Read state BEFORE deleting anything
const existingInstall = await ExistingInstall.detect(bmadDir);
const outputFolder = await this._readOutputFolder(bmadDir);
const removed = { modules: false, ideConfigs: false, outputFolder: false };
// 2. IDE CLEANUP (before _bmad/ deletion so configs are accessible)
if (options.removeIdeConfigs !== false) {
await this.uninstallIdeConfigs(projectDir, existingInstall, { silent: options.silent });
removed.ideConfigs = true;
}
// 3. OUTPUT FOLDER (only if explicitly requested)
if (options.removeOutputFolder === true && outputFolder) {
removed.outputFolder = await this.uninstallOutputFolder(projectDir, outputFolder);
}
// 4. BMAD DIRECTORY (last, after everything that needs it)
if (options.removeModules !== false) {
removed.modules = await this.uninstallModules(projectDir);
}
return { success: true, removed, version: existingInstall.installed ? existingInstall.version : null };
}
/**
* Uninstall IDE configurations only
* @param {string} projectDir - Project directory
* @param {Object} existingInstall - Detection result from detector.detect()
* @param {Object} [options] - Options (e.g. { silent: true })
* @returns {Promise<Object>} Results from IDE cleanup
*/
async uninstallIdeConfigs(projectDir, existingInstall, options = {}) {
await this.ideManager.ensureInitialized();
const cleanupOptions = { isUninstall: true, silent: options.silent };
const ideList = existingInstall.ides;
if (ideList.length > 0) {
return this.ideManager.cleanupByList(projectDir, ideList, cleanupOptions);
}
return this.ideManager.cleanup(projectDir, cleanupOptions);
}
/**
* Remove user artifacts output folder
* @param {string} projectDir - Project directory
* @param {string} outputFolder - Output folder name (relative)
* @returns {Promise<boolean>} Whether the folder was removed
*/
async uninstallOutputFolder(projectDir, outputFolder) {
if (!outputFolder) return false;
const resolvedProject = path.resolve(projectDir);
const outputPath = path.resolve(resolvedProject, outputFolder);
if (!outputPath.startsWith(resolvedProject + path.sep)) {
return false;
}
if (await fs.pathExists(outputPath)) {
await fs.remove(outputPath);
return true;
}
return false;
}
/**
* Remove the _bmad/ directory
* @param {string} projectDir - Project directory
* @returns {Promise<boolean>} Whether the directory was removed
*/
async uninstallModules(projectDir) {
const { bmadDir } = await this.findBmadDir(projectDir);
if (await fs.pathExists(bmadDir)) {
await fs.remove(bmadDir);
return true;
}
return false;
}
/**
* Get installation status
*/
async getStatus(directory) {
const projectDir = path.resolve(directory);
const { bmadDir } = await this.findBmadDir(projectDir);
return await ExistingInstall.detect(bmadDir);
}
/**
* Get available modules
*/
async getAvailableModules() {
return await new OfficialModules().listAvailable();
}
/**
* Get the configured output folder name for a project
* Resolves bmadDir internally from projectDir
* @param {string} projectDir - Project directory
* @returns {string} Output folder name (relative, default: '_bmad-output')
*/
async getOutputFolder(projectDir) {
const { bmadDir } = await this.findBmadDir(projectDir);
return this._readOutputFolder(bmadDir);
}
/**
* Find the bmad installation directory in a project
* Always uses the standard _bmad folder name
* @param {string} projectDir - Project directory
* @returns {Promise<Object>} { bmadDir: string }
*/
async findBmadDir(projectDir) {
const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME);
return { bmadDir };
}
/**
* Read the output_folder setting from module config files
* Checks bmm/config.yaml first, then other module configs
* @param {string} bmadDir - BMAD installation directory
* @returns {string} Output folder path or default
*/
async _readOutputFolder(bmadDir) {
const yaml = require('yaml');
// Check bmm/config.yaml first (most common)
const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml');
if (await fs.pathExists(bmmConfigPath)) {
try {
const content = await fs.readFile(bmmConfigPath, 'utf8');
const config = yaml.parse(content);
if (config && config.output_folder) {
// Strip {project-root}/ prefix if present
return config.output_folder.replace(/^\{project-root\}[/\\]/, '');
}
} catch {
// Fall through to other modules
}
}
// Scan other module config.yaml files
try {
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'bmm' || entry.name.startsWith('_')) continue;
const configPath = path.join(bmadDir, entry.name, 'config.yaml');
if (await fs.pathExists(configPath)) {
try {
const content = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(content);
if (config && config.output_folder) {
return config.output_folder.replace(/^\{project-root\}[/\\]/, '');
}
} catch {
// Continue scanning
}
}
}
} catch {
// Directory scan failed
}
// Default fallback
return '_bmad-output';
}
/**
* Parse a CSV line, handling quoted fields
* @param {string} line - CSV line to parse
* @returns {Array} Array of field values
*/
parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote
current += '"';
i++; // Skip next quote
} else {
// Toggle quote mode
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}
/**
* Escape a CSV field if it contains special characters
* @param {string} field - Field value to escape
* @returns {string} Escaped field
*/
escapeCSVField(field) {
if (field === null || field === undefined) {
return '';
}
const str = String(field);
// If field contains comma, quote, or newline, wrap in quotes and escape inner quotes
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replaceAll('"', '""')}"`;
}
return str;
}
}
module.exports = { Installer };