refactor(installer): extract InstallPaths class for path init and validation

Replace inline path construction in install() with a dedicated class
that ensures all structural directories exist, validates permissions,
and provides derived-path methods for manifests and modules.
This commit is contained in:
Alex Verkhovsky 2026-03-21 01:47:20 -06:00
parent efca29485e
commit 1a1909a7f9
2 changed files with 155 additions and 61 deletions

View File

@ -0,0 +1,129 @@
const path = require('node:path');
const fs = require('fs-extra');
const { getProjectRoot } = require('../../../lib/project-root');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
class InstallPaths {
static async create(config) {
const srcDir = getProjectRoot();
await assertReadableDir(srcDir, 'BMAD source root');
const pkgPath = path.join(srcDir, 'package.json');
await assertReadableFile(pkgPath, 'package.json');
const version = require(pkgPath).version;
const projectRoot = path.resolve(config.directory);
await ensureWritableDir(projectRoot, 'project root');
const bmadDir = path.join(projectRoot, BMAD_FOLDER_NAME);
const isUpdate = await fs.pathExists(bmadDir);
const configDir = path.join(bmadDir, '_config');
const agentsDir = path.join(configDir, 'agents');
const customCacheDir = path.join(configDir, 'custom');
const coreDir = path.join(bmadDir, 'core');
for (const [dir, label] of [
[bmadDir, 'bmad directory'],
[configDir, 'config directory'],
[agentsDir, 'agents config directory'],
[customCacheDir, 'custom modules cache'],
[coreDir, 'core module directory'],
]) {
await ensureWritableDir(dir, label);
}
return new InstallPaths({
srcDir,
version,
projectRoot,
bmadDir,
configDir,
agentsDir,
customCacheDir,
coreDir,
isUpdate,
});
}
constructor(props) {
Object.assign(this, props);
Object.freeze(this);
}
manifestFile() {
return path.join(this.configDir, 'manifest.yaml');
}
agentManifest() {
return path.join(this.configDir, 'agent-manifest.csv');
}
filesManifest() {
return path.join(this.configDir, 'files-manifest.csv');
}
helpCatalog() {
return path.join(this.configDir, 'bmad-help.csv');
}
moduleDir(name) {
return path.join(this.bmadDir, name);
}
moduleConfig(name) {
return path.join(this.bmadDir, name, 'config.yaml');
}
}
async function assertReadableDir(dirPath, label) {
const stat = await fs.stat(dirPath).catch(() => null);
if (!stat) {
throw new Error(`${label} does not exist: ${dirPath}`);
}
if (!stat.isDirectory()) {
throw new Error(`${label} is not a directory: ${dirPath}`);
}
try {
await fs.access(dirPath, fs.constants.R_OK);
} catch {
throw new Error(`${label} is not readable: ${dirPath}`);
}
}
async function assertReadableFile(filePath, label) {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat) {
throw new Error(`${label} does not exist: ${filePath}`);
}
if (!stat.isFile()) {
throw new Error(`${label} is not a file: ${filePath}`);
}
try {
await fs.access(filePath, fs.constants.R_OK);
} catch {
throw new Error(`${label} is not readable: ${filePath}`);
}
}
async function ensureWritableDir(dirPath, label) {
const stat = await fs.stat(dirPath).catch(() => null);
if (stat && !stat.isDirectory()) {
throw new Error(`${label} exists but is not a directory: ${dirPath}`);
}
try {
await fs.ensureDir(dirPath);
} catch (error) {
if (error.code === 'EACCES') {
throw new Error(`${label}: permission denied creating directory: ${dirPath}`);
}
if (error.code === 'ENOSPC') {
throw new Error(`${label}: no space left on device: ${dirPath}`);
}
throw new Error(`${label}: cannot create directory: ${dirPath} (${error.message})`);
}
try {
await fs.access(dirPath, fs.constants.R_OK | fs.constants.W_OK);
} catch {
throw new Error(`${label} is not writable: ${dirPath}`);
}
}
module.exports = { InstallPaths };

View File

@ -15,6 +15,7 @@ const { IdeConfigManager } = require('./ide-config-manager');
const { CustomHandler } = require('../custom/handler');
const prompts = require('../../../lib/prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const { InstallPaths } = require('./install-paths');
class Installer {
constructor() {
@ -228,23 +229,14 @@ class Installer {
// Clone config to avoid mutating the caller's object
const config = { ...originalConfig };
// Check if core config was already collected in UI
// if core config isn't collected, we haven't run the UI -> display logo/version
const hasCoreConfig = config.coreConfig && Object.keys(config.coreConfig).length > 0;
// Only display logo if core config wasn't already collected (meaning we're not continuing from UI)
if (!hasCoreConfig) {
// Display BMAD logo
await CLIUtils.displayLogo();
// Display welcome message
await CLIUtils.displaySection('BMad™ Installation', 'Version ' + require(path.join(getProjectRoot(), 'package.json')).version);
}
// Note: Legacy V4 detection now happens earlier in UI.promptInstall()
// before any config collection, so we don't need to check again here
const projectDir = path.resolve(config.directory);
const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME);
const paths = await InstallPaths.create(config);
const { projectRoot, bmadDir, srcDir } = paths;
// If core config was pre-collected (from interactive mode), use it
if (config.coreConfig && Object.keys(config.coreConfig).length > 0) {
@ -285,7 +277,7 @@ class Installer {
// If no sourcePath but we have relativePath, convert it
else if (!absoluteSourcePath && customModule.relativePath) {
// relativePath is relative to the project root (parent of bmad dir)
absoluteSourcePath = path.resolve(projectDir, customModule.relativePath);
absoluteSourcePath = path.resolve(projectRoot, customModule.relativePath);
}
// Ensure sourcePath is absolute for anything else
else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
@ -304,7 +296,7 @@ class Installer {
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
const customHandler = new CustomHandler();
for (const customFile of config.customContent.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile, path.resolve(config.directory));
const customInfo = await customHandler.getCustomInfo(customFile, projectRoot);
if (customInfo && customInfo.id) {
customModulePaths.set(customInfo.id, customInfo.path);
}
@ -357,13 +349,13 @@ class Installer {
if (config.coreConfig && Object.keys(config.coreConfig).length > 0) {
// Core already collected, skip it in config collection
const modulesWithoutCore = allModulesForConfig.filter((m) => m !== 'core');
moduleConfigs = await this.configCollector.collectAllConfigurations(modulesWithoutCore, path.resolve(config.directory), {
moduleConfigs = await this.configCollector.collectAllConfigurations(modulesWithoutCore, projectRoot, {
customModulePaths,
skipPrompts: config.skipPrompts,
});
} else {
// Core not collected yet, include it
moduleConfigs = await this.configCollector.collectAllConfigurations(allModulesForConfig, path.resolve(config.directory), {
moduleConfigs = await this.configCollector.collectAllConfigurations(allModulesForConfig, projectRoot, {
customModulePaths,
skipPrompts: config.skipPrompts,
});
@ -382,26 +374,6 @@ class Installer {
spinner.start('Preparing installation...');
try {
// Create a project directory if it doesn't exist (user already confirmed)
if (!(await fs.pathExists(projectDir))) {
spinner.message('Creating installation directory...');
try {
// fs.ensureDir handles platform-specific directory creation
// It will recursively create all necessary parent directories
await fs.ensureDir(projectDir);
} catch (error) {
spinner.error('Failed to create installation directory');
await prompts.log.error(`Error: ${error.message}`);
// More detailed error for common issues
if (error.code === 'EACCES') {
await prompts.log.error('Permission denied. Check parent directory permissions.');
} else if (error.code === 'ENOSPC') {
await prompts.log.error('No space left on device.');
}
throw new Error(`Cannot create directory: ${projectDir}`);
}
}
// Check existing installation
spinner.message('Checking for existing installation...');
const existingInstall = await this.detector.detect(bmadDir);
@ -457,7 +429,7 @@ class Installer {
for (const moduleId of modulesToRemove) {
const moduleInfo = existingInstall.modules.find((m) => m.id === moduleId);
const displayName = moduleInfo?.name || moduleId;
const modulePath = path.join(bmadDir, moduleId);
const modulePath = paths.moduleDir(moduleId);
await prompts.log.error(` - ${displayName} (${modulePath})`);
}
@ -469,7 +441,7 @@ class Installer {
if (confirmRemoval) {
// Remove module folders
for (const moduleId of modulesToRemove) {
const modulePath = path.join(bmadDir, moduleId);
const modulePath = paths.moduleDir(moduleId);
try {
if (await fs.pathExists(modulePath)) {
await fs.remove(modulePath);
@ -502,7 +474,7 @@ class Installer {
// Preserve existing core configuration during updates
// Read the current core config.yaml to maintain user's settings
const coreConfigPath = path.join(bmadDir, 'core', 'config.yaml');
const coreConfigPath = paths.moduleConfig('core');
if ((await fs.pathExists(coreConfigPath)) && (!config.coreConfig || Object.keys(config.coreConfig).length === 0)) {
try {
const yaml = require('yaml');
@ -520,7 +492,7 @@ class Installer {
}
// Also check cache directory for custom modules (like quick update does)
const cacheDir = path.join(bmadDir, '_config', 'custom');
const cacheDir = paths.customCacheDir;
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
@ -558,7 +530,7 @@ class Installer {
// If there are custom files, back them up temporarily
if (customFiles.length > 0) {
const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp');
const tempBackupDir = path.join(projectRoot, '_bmad-custom-backup-temp');
await fs.ensureDir(tempBackupDir);
spinner.start(`Backing up ${customFiles.length} custom files...`);
@ -575,7 +547,7 @@ class Installer {
// For modified files, back them up to temp directory (will be restored as .bak files after install)
if (modifiedFiles.length > 0) {
const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp');
const tempModifiedBackupDir = path.join(projectRoot, '_bmad-modified-backup-temp');
await fs.ensureDir(tempModifiedBackupDir);
spinner.start(`Backing up ${modifiedFiles.length} modified files...`);
@ -604,7 +576,7 @@ class Installer {
config._modifiedFiles = modifiedFiles;
// Also check cache directory for custom modules (like quick update does)
const cacheDir = path.join(bmadDir, '_config', 'custom');
const cacheDir = paths.customCacheDir;
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
@ -642,7 +614,7 @@ class Installer {
// Back up custom files
if (customFiles.length > 0) {
const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp');
const tempBackupDir = path.join(projectRoot, '_bmad-custom-backup-temp');
await fs.ensureDir(tempBackupDir);
spinner.start(`Backing up ${customFiles.length} custom files...`);
@ -658,7 +630,7 @@ class Installer {
// Back up modified files
if (modifiedFiles.length > 0) {
const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp');
const tempModifiedBackupDir = path.join(projectRoot, '_bmad-modified-backup-temp');
await fs.ensureDir(tempModifiedBackupDir);
spinner.start(`Backing up ${modifiedFiles.length} modified files...`);
@ -701,7 +673,7 @@ class Installer {
// Use config.ides if it's an array (even if empty), null means prompt
const preSelectedIdes = Array.isArray(config.ides) ? config.ides : null;
toolSelection = await this.collectToolConfigurations(
path.resolve(config.directory),
projectRoot,
config.modules,
config._isFullReinstall || false,
config._previouslyConfiguredIdes || [],
@ -774,7 +746,7 @@ class Installer {
try {
const handler = this.ideManager.handlers.get(ide);
if (handler) {
await handler.cleanup(projectDir);
await handler.cleanup(projectRoot);
}
await this.ideConfigManager.deleteIdeConfig(bmadDir, ide);
await prompts.log.message(` Removed: ${ide}`);
@ -811,10 +783,6 @@ class Installer {
spinner.start('Preparing installation...');
}
// Create bmad directory structure
spinner.message('Creating directory structure...');
await this.createDirectoryStructure(bmadDir);
// Cache custom modules if any
if (customModulePaths && customModulePaths.size > 0) {
spinner.message('Caching custom modules...');
@ -835,8 +803,6 @@ class Installer {
addResult('Custom modules cached', 'ok');
}
const projectRoot = getProjectRoot();
// Custom content is already handled in UI before module selection
const finalCustomContent = config.customContent;
@ -867,7 +833,7 @@ class Installer {
// Add custom modules to the installation list
const customHandler = new CustomHandler();
for (const customFile of finalCustomContent.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile, projectDir);
const customInfo = await customHandler.getCustomInfo(customFile, projectRoot);
if (customInfo && customInfo.id) {
allModules.push(customInfo.id);
}
@ -932,7 +898,7 @@ class Installer {
bmadDir: bmadDir,
});
taskResolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, {
taskResolution = await this.dependencyResolver.resolve(srcDir, regularModulesForResolution, {
verbose: config.verbose,
moduleManager: tempModuleManager,
});
@ -982,7 +948,7 @@ class Installer {
if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
const customHandler = new CustomHandler();
for (const customFile of finalCustomContent.selectedFiles) {
const info = await customHandler.getCustomInfo(customFile, projectDir);
const info = await customHandler.getCustomInfo(customFile, projectRoot);
if (info && info.id === moduleName) {
isCustomModule = true;
customInfo = info;
@ -1122,9 +1088,8 @@ class Installer {
addResult('Configurations', 'ok', 'generated');
// Pre-register manifest files
const cfgDir = path.join(bmadDir, '_config');
this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
this.installedFiles.add(paths.manifestFile());
this.installedFiles.add(paths.agentManifest());
// Generate CSV manifests for agents, skills AND ALL FILES with hashes
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
@ -1215,7 +1180,7 @@ class Installer {
console.log = () => {};
}
try {
const setupResult = await this.ideManager.setup(ide, projectDir, bmadDir, {
const setupResult = await this.ideManager.setup(ide, projectRoot, bmadDir, {
selectedModules: allModules || [],
preCollectedConfig: ideConfigurations[ide] || null,
verbose: config.verbose,
@ -1333,7 +1298,7 @@ class Installer {
path: bmadDir,
modules: config.modules,
ides: config.ides,
projectDir: projectDir,
projectDir: projectRoot,
};
} catch (error) {
try {