2904 lines
103 KiB
Diff
2904 lines
103 KiB
Diff
diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js
|
||
new file mode 100644
|
||
index 00000000..6df7b66a
|
||
--- /dev/null
|
||
+++ b/tools/cli/installers/lib/core/installer.js
|
||
@@ -0,0 +1,1803 @@
|
||
+const path = require('node:path');
|
||
+const fs = require('fs-extra');
|
||
+const chalk = require('chalk');
|
||
+const ora = require('ora');
|
||
+const { Detector } = require('./detector');
|
||
+const { Manifest } = require('./manifest');
|
||
+const { ModuleManager } = require('../modules/manager');
|
||
+const { IdeManager } = require('../ide/manager');
|
||
+const { FileOps } = require('../../../lib/file-ops');
|
||
+const { Config } = require('../../../lib/config');
|
||
+const { XmlHandler } = require('../../../lib/xml-handler');
|
||
+const { DependencyResolver } = require('./dependency-resolver');
|
||
+const { ConfigCollector } = require('./config-collector');
|
||
+// processInstallation no longer needed - LLMs understand {project-root}
|
||
+const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||
+const { AgentPartyGenerator } = require('../../../lib/agent-party-generator');
|
||
+const { CLIUtils } = require('../../../lib/cli-utils');
|
||
+const { ManifestGenerator } = require('./manifest-generator');
|
||
+
|
||
+class Installer {
|
||
+ constructor() {
|
||
+ this.detector = new Detector();
|
||
+ this.manifest = new Manifest();
|
||
+ this.moduleManager = new ModuleManager();
|
||
+ this.ideManager = new IdeManager();
|
||
+ this.fileOps = new FileOps();
|
||
+ this.config = new Config();
|
||
+ this.xmlHandler = new XmlHandler();
|
||
+ this.dependencyResolver = new DependencyResolver();
|
||
+ this.configCollector = new ConfigCollector();
|
||
+ this.installedFiles = []; // Track all installed files
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Collect Tool/IDE configurations after module configuration
|
||
+ * @param {string} projectDir - Project directory
|
||
+ * @param {Array} selectedModules - Selected modules from configuration
|
||
+ * @returns {Object} Tool/IDE selection and configurations
|
||
+ */
|
||
+ async collectToolConfigurations(projectDir, selectedModules, isFullReinstall = false, previousIdes = []) {
|
||
+ // Prompt for tool selection
|
||
+ const { UI } = require('../../../lib/ui');
|
||
+ const ui = new UI();
|
||
+ const toolConfig = await ui.promptToolSelection(projectDir, selectedModules);
|
||
+
|
||
+ // Check for already configured IDEs
|
||
+ const { Detector } = require('./detector');
|
||
+ const detector = new Detector();
|
||
+ const bmadDir = path.join(projectDir, 'bmad');
|
||
+
|
||
+ // During full reinstall, use the saved previous IDEs since bmad dir was deleted
|
||
+ // Otherwise detect from existing installation
|
||
+ let previouslyConfiguredIdes;
|
||
+ if (isFullReinstall) {
|
||
+ // During reinstall, treat all IDEs as new (need configuration)
|
||
+ previouslyConfiguredIdes = [];
|
||
+ } else {
|
||
+ const existingInstall = await detector.detect(bmadDir);
|
||
+ previouslyConfiguredIdes = existingInstall.ides || [];
|
||
+ }
|
||
+
|
||
+ // Collect IDE-specific configurations if any were selected
|
||
+ const ideConfigurations = {};
|
||
+
|
||
+ if (!toolConfig.skipIde && toolConfig.ides && toolConfig.ides.length > 0) {
|
||
+ // Determine which IDEs are newly selected (not previously configured)
|
||
+ const newlySelectedIdes = toolConfig.ides.filter((ide) => !previouslyConfiguredIdes.includes(ide));
|
||
+
|
||
+ if (newlySelectedIdes.length > 0) {
|
||
+ console.log('\n'); // Add spacing before IDE questions
|
||
+
|
||
+ for (const ide of newlySelectedIdes) {
|
||
+ // List of IDEs that have interactive prompts
|
||
+ const needsPrompts = ['claude-code', 'github-copilot', 'roo', 'cline', 'auggie', 'codex', 'qwen', 'gemini'].includes(ide);
|
||
+
|
||
+ if (needsPrompts) {
|
||
+ // Get IDE handler and collect configuration
|
||
+ try {
|
||
+ // Dynamically load the IDE setup module
|
||
+ const ideModule = require(`../ide/${ide}`);
|
||
+
|
||
+ // Get the setup class (handle different export formats)
|
||
+ let SetupClass;
|
||
+ const className =
|
||
+ ide
|
||
+ .split('-')
|
||
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||
+ .join('') + 'Setup';
|
||
+
|
||
+ if (ideModule[className]) {
|
||
+ SetupClass = ideModule[className];
|
||
+ } else if (ideModule.default) {
|
||
+ SetupClass = ideModule.default;
|
||
+ } else {
|
||
+ // Skip if no setup class found
|
||
+ continue;
|
||
+ }
|
||
+
|
||
+ const ideSetup = new SetupClass();
|
||
+
|
||
+ // Check if this IDE has a collectConfiguration method
|
||
+ if (typeof ideSetup.collectConfiguration === 'function') {
|
||
+ console.log(chalk.cyan(`\nConfiguring ${ide}...`));
|
||
+ ideConfigurations[ide] = await ideSetup.collectConfiguration({
|
||
+ selectedModules: selectedModules || [],
|
||
+ projectDir,
|
||
+ bmadDir,
|
||
+ });
|
||
+ }
|
||
+ } catch {
|
||
+ // IDE doesn't have a setup file or collectConfiguration method
|
||
+ console.warn(chalk.yellow(`Warning: Could not load configuration for ${ide}`));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Log which IDEs are already configured and being kept
|
||
+ const keptIdes = toolConfig.ides.filter((ide) => previouslyConfiguredIdes.includes(ide));
|
||
+ if (keptIdes.length > 0) {
|
||
+ console.log(chalk.dim(`\nKeeping existing configuration for: ${keptIdes.join(', ')}`));
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return {
|
||
+ ides: toolConfig.ides,
|
||
+ skipIde: toolConfig.skipIde,
|
||
+ configurations: ideConfigurations,
|
||
+ };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Main installation method
|
||
+ * @param {Object} config - Installation configuration
|
||
+ * @param {string} config.directory - Target directory
|
||
+ * @param {boolean} config.installCore - Whether to install core
|
||
+ * @param {string[]} config.modules - Modules to install
|
||
+ * @param {string[]} config.ides - IDEs to configure
|
||
+ * @param {boolean} config.skipIde - Skip IDE configuration
|
||
+ */
|
||
+ async install(config) {
|
||
+ // Display BMAD logo
|
||
+ CLIUtils.displayLogo();
|
||
+
|
||
+ // Display welcome message
|
||
+ CLIUtils.displaySection('BMADÔäó Installation', 'Version ' + require(path.join(getProjectRoot(), 'package.json')).version);
|
||
+
|
||
+ // Preflight: Handle legacy BMAD v4 footprints before any prompts/writes
|
||
+ const projectDir = path.resolve(config.directory);
|
||
+ const legacyV4 = await this.detector.detectLegacyV4(projectDir);
|
||
+ if (legacyV4.hasLegacyV4) {
|
||
+ await this.handleLegacyV4Migration(projectDir, legacyV4);
|
||
+ }
|
||
+
|
||
+ // If core config was pre-collected (from interactive mode), use it
|
||
+ if (config.coreConfig) {
|
||
+ this.configCollector.collectedConfig.core = config.coreConfig;
|
||
+ // Also store in allAnswers for cross-referencing
|
||
+ this.configCollector.allAnswers = {};
|
||
+ for (const [key, value] of Object.entries(config.coreConfig)) {
|
||
+ this.configCollector.allAnswers[`core_${key}`] = value;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Collect configurations for modules (core was already collected in UI.promptInstall if interactive)
|
||
+ const moduleConfigs = await this.configCollector.collectAllConfigurations(config.modules || [], path.resolve(config.directory));
|
||
+
|
||
+ // Tool selection will be collected after we determine if it's a reinstall/update/new install
|
||
+
|
||
+ const spinner = ora('Preparing installation...').start();
|
||
+
|
||
+ try {
|
||
+ // Resolve target directory (path.resolve handles platform differences)
|
||
+ const projectDir = path.resolve(config.directory);
|
||
+
|
||
+ // Create a project directory if it doesn't exist (user already confirmed)
|
||
+ if (!(await fs.pathExists(projectDir))) {
|
||
+ spinner.text = '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.fail('Failed to create installation directory');
|
||
+ console.error(chalk.red(`Error: ${error.message}`));
|
||
+ // More detailed error for common issues
|
||
+ if (error.code === 'EACCES') {
|
||
+ console.error(chalk.red('Permission denied. Check parent directory permissions.'));
|
||
+ } else if (error.code === 'ENOSPC') {
|
||
+ console.error(chalk.red('No space left on device.'));
|
||
+ }
|
||
+ throw new Error(`Cannot create directory: ${projectDir}`);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ const bmadDir = path.join(projectDir, 'bmad');
|
||
+
|
||
+ // Check existing installation
|
||
+ spinner.text = 'Checking for existing installation...';
|
||
+ const existingInstall = await this.detector.detect(bmadDir);
|
||
+
|
||
+ if (existingInstall.installed && !config.force) {
|
||
+ spinner.stop();
|
||
+
|
||
+ console.log(chalk.yellow('\nÔÜá´©Å Existing BMAD installation detected'));
|
||
+ console.log(chalk.dim(` Location: ${bmadDir}`));
|
||
+ console.log(chalk.dim(` Version: ${existingInstall.version}`));
|
||
+
|
||
+ const { action } = await this.promptUpdateAction();
|
||
+ if (action === 'cancel') {
|
||
+ console.log('Installation cancelled.');
|
||
+ return { success: false, cancelled: true };
|
||
+ }
|
||
+
|
||
+ if (action === 'reinstall') {
|
||
+ // Warn about destructive operation
|
||
+ console.log(chalk.red.bold('\nÔÜá´©Å WARNING: This is a destructive operation!'));
|
||
+ console.log(chalk.red('All custom files and modifications in the bmad directory will be lost.'));
|
||
+
|
||
+ const inquirer = require('inquirer');
|
||
+ const { confirmReinstall } = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'confirm',
|
||
+ name: 'confirmReinstall',
|
||
+ message: chalk.yellow('Are you sure you want to delete and reinstall?'),
|
||
+ default: false,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ if (!confirmReinstall) {
|
||
+ console.log('Installation cancelled.');
|
||
+ return { success: false, cancelled: true };
|
||
+ }
|
||
+
|
||
+ // Remember previously configured IDEs before deleting
|
||
+ config._previouslyConfiguredIdes = existingInstall.ides || [];
|
||
+
|
||
+ // Remove existing installation
|
||
+ await fs.remove(bmadDir);
|
||
+ console.log(chalk.green('Ô£ô Removed existing installation\n'));
|
||
+
|
||
+ // Mark this as a full reinstall so we re-collect IDE configurations
|
||
+ config._isFullReinstall = true;
|
||
+ } else if (action === 'update') {
|
||
+ // Store that we're updating for later processing
|
||
+ config._isUpdate = true;
|
||
+ config._existingInstall = existingInstall;
|
||
+
|
||
+ // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
|
||
+ const existingFilesManifest = await this.readFilesManifest(bmadDir);
|
||
+ console.log(chalk.dim(`DEBUG: Read ${existingFilesManifest.length} files from manifest`));
|
||
+ console.log(chalk.dim(`DEBUG: Manifest has hashes: ${existingFilesManifest.some((f) => f.hash)}`));
|
||
+
|
||
+ const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest);
|
||
+
|
||
+ console.log(chalk.dim(`DEBUG: Found ${customFiles.length} custom files, ${modifiedFiles.length} modified files`));
|
||
+ if (modifiedFiles.length > 0) {
|
||
+ console.log(chalk.yellow('DEBUG: Modified files:'));
|
||
+ for (const f of modifiedFiles) console.log(chalk.dim(` - ${f.path}`));
|
||
+ }
|
||
+
|
||
+ config._customFiles = customFiles;
|
||
+ config._modifiedFiles = modifiedFiles;
|
||
+
|
||
+ // If there are custom files, back them up temporarily
|
||
+ if (customFiles.length > 0) {
|
||
+ const tempBackupDir = path.join(projectDir, '.bmad-custom-backup-temp');
|
||
+ await fs.ensureDir(tempBackupDir);
|
||
+
|
||
+ spinner.start(`Backing up ${customFiles.length} custom files...`);
|
||
+ for (const customFile of customFiles) {
|
||
+ const relativePath = path.relative(bmadDir, customFile);
|
||
+ const backupPath = path.join(tempBackupDir, relativePath);
|
||
+ await fs.ensureDir(path.dirname(backupPath));
|
||
+ await fs.copy(customFile, backupPath);
|
||
+ }
|
||
+ spinner.succeed(`Backed up ${customFiles.length} custom files`);
|
||
+
|
||
+ config._tempBackupDir = tempBackupDir;
|
||
+ }
|
||
+
|
||
+ // 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');
|
||
+ await fs.ensureDir(tempModifiedBackupDir);
|
||
+
|
||
+ console.log(chalk.yellow(`\nDEBUG: Backing up ${modifiedFiles.length} modified files to temp location`));
|
||
+ spinner.start(`Backing up ${modifiedFiles.length} modified files...`);
|
||
+ for (const modifiedFile of modifiedFiles) {
|
||
+ const relativePath = path.relative(bmadDir, modifiedFile.path);
|
||
+ const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
|
||
+ console.log(chalk.dim(`DEBUG: Backing up ${relativePath} to temp`));
|
||
+ await fs.ensureDir(path.dirname(tempBackupPath));
|
||
+ await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true });
|
||
+ }
|
||
+ spinner.succeed(`Backed up ${modifiedFiles.length} modified files`);
|
||
+
|
||
+ config._tempModifiedBackupDir = tempModifiedBackupDir;
|
||
+ } else {
|
||
+ console.log(chalk.dim('DEBUG: No modified files detected'));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Now collect tool configurations after we know if it's a reinstall
|
||
+ spinner.stop();
|
||
+ const toolSelection = await this.collectToolConfigurations(
|
||
+ path.resolve(config.directory),
|
||
+ config.modules,
|
||
+ config._isFullReinstall || false,
|
||
+ config._previouslyConfiguredIdes || [],
|
||
+ );
|
||
+
|
||
+ // Merge tool selection into config
|
||
+ config.ides = toolSelection.ides;
|
||
+ config.skipIde = toolSelection.skipIde;
|
||
+ const ideConfigurations = toolSelection.configurations;
|
||
+
|
||
+ spinner.start('Continuing installation...');
|
||
+
|
||
+ // Create bmad directory structure
|
||
+ spinner.text = 'Creating directory structure...';
|
||
+ await this.createDirectoryStructure(bmadDir);
|
||
+
|
||
+ // Resolve dependencies for selected modules
|
||
+ spinner.text = 'Resolving dependencies...';
|
||
+ const projectRoot = getProjectRoot();
|
||
+ const modulesToInstall = config.installCore ? ['core', ...config.modules] : config.modules;
|
||
+
|
||
+ // For dependency resolution, we need to pass the project root
|
||
+ const resolution = await this.dependencyResolver.resolve(projectRoot, config.modules || [], { verbose: config.verbose });
|
||
+
|
||
+ if (config.verbose) {
|
||
+ spinner.succeed('Dependencies resolved');
|
||
+ } else {
|
||
+ spinner.succeed('Dependencies resolved');
|
||
+ }
|
||
+
|
||
+ // Install core if requested or if dependencies require it
|
||
+ if (config.installCore || resolution.byModule.core) {
|
||
+ spinner.start('Installing BMAD core...');
|
||
+ await this.installCoreWithDependencies(bmadDir, resolution.byModule.core);
|
||
+ spinner.succeed('Core installed');
|
||
+ }
|
||
+
|
||
+ // Install modules with their dependencies
|
||
+ if (config.modules && config.modules.length > 0) {
|
||
+ for (const moduleName of config.modules) {
|
||
+ spinner.start(`Installing module: ${moduleName}...`);
|
||
+ await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]);
|
||
+ spinner.succeed(`Module installed: ${moduleName}`);
|
||
+ }
|
||
+
|
||
+ // Install partial modules (only dependencies)
|
||
+ for (const [module, files] of Object.entries(resolution.byModule)) {
|
||
+ if (!config.modules.includes(module) && module !== 'core') {
|
||
+ const totalFiles = files.agents.length + files.tasks.length + files.templates.length + files.data.length + files.other.length;
|
||
+ if (totalFiles > 0) {
|
||
+ spinner.start(`Installing ${module} dependencies...`);
|
||
+ await this.installPartialModule(module, bmadDir, files);
|
||
+ spinner.succeed(`${module} dependencies installed`);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Generate clean config.yaml files for each installed module
|
||
+ spinner.start('Generating module configurations...');
|
||
+ await this.generateModuleConfigs(bmadDir, moduleConfigs);
|
||
+ spinner.succeed('Module configurations generated');
|
||
+
|
||
+ // Create agent configuration files
|
||
+ // Note: Legacy createAgentConfigs removed - using YAML customize system instead
|
||
+ // Customize templates are now created in processAgentFiles when building YAML agents
|
||
+
|
||
+ // Pre-register manifest files that will be created (except files-manifest.csv to avoid recursion)
|
||
+ const cfgDir = path.join(bmadDir, '_cfg');
|
||
+ this.installedFiles.push(
|
||
+ path.join(cfgDir, 'manifest.yaml'),
|
||
+ path.join(cfgDir, 'workflow-manifest.csv'),
|
||
+ path.join(cfgDir, 'agent-manifest.csv'),
|
||
+ path.join(cfgDir, 'task-manifest.csv'),
|
||
+ );
|
||
+
|
||
+ // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup
|
||
+ spinner.start('Generating workflow and agent manifests...');
|
||
+ const manifestGen = new ManifestGenerator();
|
||
+ const manifestStats = await manifestGen.generateManifests(bmadDir, config.modules || [], this.installedFiles, {
|
||
+ ides: config.ides || [],
|
||
+ });
|
||
+
|
||
+ spinner.succeed(
|
||
+ `Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.files} files`,
|
||
+ );
|
||
+
|
||
+ // Configure IDEs and copy documentation
|
||
+ if (!config.skipIde && config.ides && config.ides.length > 0) {
|
||
+ spinner.start('Configuring IDEs...');
|
||
+
|
||
+ // Temporarily suppress console output if not verbose
|
||
+ const originalLog = console.log;
|
||
+ if (!config.verbose) {
|
||
+ console.log = () => {};
|
||
+ }
|
||
+
|
||
+ for (const ide of config.ides) {
|
||
+ spinner.text = `Configuring ${ide}...`;
|
||
+
|
||
+ // Pass pre-collected configuration to avoid re-prompting
|
||
+ await this.ideManager.setup(ide, projectDir, bmadDir, {
|
||
+ selectedModules: config.modules || [],
|
||
+ preCollectedConfig: ideConfigurations[ide] || null,
|
||
+ verbose: config.verbose,
|
||
+ });
|
||
+ }
|
||
+
|
||
+ // Restore console.log
|
||
+ console.log = originalLog;
|
||
+
|
||
+ spinner.succeed(`Configured ${config.ides.length} IDE${config.ides.length > 1 ? 's' : ''}`);
|
||
+
|
||
+ // Copy IDE-specific documentation
|
||
+ spinner.start('Copying IDE documentation...');
|
||
+ await this.copyIdeDocumentation(config.ides, bmadDir);
|
||
+ spinner.succeed('IDE documentation copied');
|
||
+ }
|
||
+
|
||
+ // Run module-specific installers after IDE setup
|
||
+ spinner.start('Running module-specific installers...');
|
||
+
|
||
+ // Run core module installer if core was installed
|
||
+ if (config.installCore || resolution.byModule.core) {
|
||
+ spinner.text = 'Running core module installer...';
|
||
+
|
||
+ await this.moduleManager.runModuleInstaller('core', bmadDir, {
|
||
+ installedIDEs: config.ides || [],
|
||
+ moduleConfig: moduleConfigs.core || {},
|
||
+ logger: {
|
||
+ log: (msg) => console.log(msg),
|
||
+ error: (msg) => console.error(msg),
|
||
+ warn: (msg) => console.warn(msg),
|
||
+ },
|
||
+ });
|
||
+ }
|
||
+
|
||
+ // Run installers for user-selected modules
|
||
+ if (config.modules && config.modules.length > 0) {
|
||
+ for (const moduleName of config.modules) {
|
||
+ spinner.text = `Running ${moduleName} module installer...`;
|
||
+
|
||
+ // Pass installed IDEs and module config to module installer
|
||
+ await this.moduleManager.runModuleInstaller(moduleName, bmadDir, {
|
||
+ installedIDEs: config.ides || [],
|
||
+ moduleConfig: moduleConfigs[moduleName] || {},
|
||
+ logger: {
|
||
+ log: (msg) => console.log(msg),
|
||
+ error: (msg) => console.error(msg),
|
||
+ warn: (msg) => console.warn(msg),
|
||
+ },
|
||
+ });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ spinner.succeed('Module-specific installers completed');
|
||
+
|
||
+ // Note: Manifest files are already created by ManifestGenerator above
|
||
+ // No need to create legacy manifest.csv anymore
|
||
+
|
||
+ // If this was an update, restore custom files
|
||
+ let customFiles = [];
|
||
+ let modifiedFiles = [];
|
||
+ if (config._isUpdate) {
|
||
+ if (config._customFiles && config._customFiles.length > 0) {
|
||
+ spinner.start(`Restoring ${config._customFiles.length} custom files...`);
|
||
+
|
||
+ for (const originalPath of config._customFiles) {
|
||
+ const relativePath = path.relative(bmadDir, originalPath);
|
||
+ const backupPath = path.join(config._tempBackupDir, relativePath);
|
||
+
|
||
+ if (await fs.pathExists(backupPath)) {
|
||
+ await fs.ensureDir(path.dirname(originalPath));
|
||
+ await fs.copy(backupPath, originalPath, { overwrite: true });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Clean up temp backup
|
||
+ if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) {
|
||
+ await fs.remove(config._tempBackupDir);
|
||
+ }
|
||
+
|
||
+ spinner.succeed(`Restored ${config._customFiles.length} custom files`);
|
||
+ customFiles = config._customFiles;
|
||
+ }
|
||
+
|
||
+ if (config._modifiedFiles && config._modifiedFiles.length > 0) {
|
||
+ modifiedFiles = config._modifiedFiles;
|
||
+
|
||
+ // Restore modified files as .bak files
|
||
+ if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) {
|
||
+ spinner.start(`Restoring ${modifiedFiles.length} modified files as .bak...`);
|
||
+
|
||
+ for (const modifiedFile of modifiedFiles) {
|
||
+ const relativePath = path.relative(bmadDir, modifiedFile.path);
|
||
+ const tempBackupPath = path.join(config._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 });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Clean up temp backup
|
||
+ await fs.remove(config._tempModifiedBackupDir);
|
||
+
|
||
+ spinner.succeed(`Restored ${modifiedFiles.length} modified files as .bak`);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ spinner.stop();
|
||
+
|
||
+ // Report custom and modified files if any were found
|
||
+ if (customFiles.length > 0) {
|
||
+ console.log(chalk.cyan(`\nƒôü Custom files preserved: ${customFiles.length}`));
|
||
+ console.log(chalk.dim('The following custom files were found and restored:\n'));
|
||
+ for (const file of customFiles) {
|
||
+ console.log(chalk.dim(` - ${path.relative(bmadDir, file)}`));
|
||
+ }
|
||
+ console.log('');
|
||
+ }
|
||
+
|
||
+ if (modifiedFiles.length > 0) {
|
||
+ console.log(chalk.yellow(`\nÔÜá´©Å Modified files detected: ${modifiedFiles.length}`));
|
||
+ console.log(chalk.dim('The following files were modified and backed up with .bak extension:\n'));
|
||
+ for (const file of modifiedFiles) {
|
||
+ console.log(chalk.dim(` - ${file.relativePath}  ${file.relativePath}.bak`));
|
||
+ }
|
||
+ console.log(chalk.dim('\nThese files have been updated with the new version.'));
|
||
+ console.log(chalk.dim('Review the .bak files to see your changes and merge if needed.\n'));
|
||
+ }
|
||
+
|
||
+ // Display completion message
|
||
+ const { UI } = require('../../../lib/ui');
|
||
+ const ui = new UI();
|
||
+ ui.showInstallSummary({
|
||
+ path: bmadDir,
|
||
+ modules: config.modules,
|
||
+ ides: config.ides,
|
||
+ customFiles: customFiles.length > 0 ? customFiles : undefined,
|
||
+ });
|
||
+
|
||
+ return { success: true, path: bmadDir, modules: config.modules, ides: config.ides };
|
||
+ } catch (error) {
|
||
+ spinner.fail('Installation failed');
|
||
+ throw error;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Update existing installation
|
||
+ */
|
||
+ async update(config) {
|
||
+ const spinner = ora('Checking installation...').start();
|
||
+
|
||
+ try {
|
||
+ const bmadDir = path.join(path.resolve(config.directory), 'bmad');
|
||
+ const existingInstall = await this.detector.detect(bmadDir);
|
||
+
|
||
+ if (!existingInstall.installed) {
|
||
+ spinner.fail('No BMAD installation found');
|
||
+ throw new Error(`No BMAD installation found at ${bmadDir}`);
|
||
+ }
|
||
+
|
||
+ spinner.text = 'Analyzing update requirements...';
|
||
+
|
||
+ // Compare versions and determine what needs updating
|
||
+ const currentVersion = existingInstall.version;
|
||
+ const newVersion = require(path.join(getProjectRoot(), 'package.json')).version;
|
||
+
|
||
+ if (config.dryRun) {
|
||
+ spinner.stop();
|
||
+ console.log(chalk.cyan('\nƒöì Update Preview (Dry Run)\n'));
|
||
+ console.log(chalk.bold('Current version:'), currentVersion);
|
||
+ console.log(chalk.bold('New version:'), newVersion);
|
||
+ console.log(chalk.bold('Core:'), existingInstall.hasCore ? 'Will be updated' : 'Not installed');
|
||
+
|
||
+ if (existingInstall.modules.length > 0) {
|
||
+ console.log(chalk.bold('\nModules to update:'));
|
||
+ for (const mod of existingInstall.modules) {
|
||
+ console.log(` - ${mod.id}`);
|
||
+ }
|
||
+ }
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // Perform actual update
|
||
+ if (existingInstall.hasCore) {
|
||
+ spinner.text = 'Updating core...';
|
||
+ await this.updateCore(bmadDir, config.force);
|
||
+ }
|
||
+
|
||
+ for (const module of existingInstall.modules) {
|
||
+ spinner.text = `Updating module: ${module.id}...`;
|
||
+ await this.moduleManager.update(module.id, bmadDir, config.force);
|
||
+ }
|
||
+
|
||
+ // Update manifest
|
||
+ spinner.text = 'Updating manifest...';
|
||
+ await this.manifest.update(bmadDir, {
|
||
+ version: newVersion,
|
||
+ updateDate: new Date().toISOString(),
|
||
+ });
|
||
+
|
||
+ spinner.succeed('Update complete');
|
||
+ return { success: true };
|
||
+ } catch (error) {
|
||
+ spinner.fail('Update failed');
|
||
+ throw error;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Get installation status
|
||
+ */
|
||
+ async getStatus(directory) {
|
||
+ const bmadDir = path.join(path.resolve(directory), 'bmad');
|
||
+ return await this.detector.detect(bmadDir);
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Get available modules
|
||
+ */
|
||
+ async getAvailableModules() {
|
||
+ return await this.moduleManager.listAvailable();
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Uninstall BMAD
|
||
+ */
|
||
+ async uninstall(directory) {
|
||
+ const bmadDir = path.join(path.resolve(directory), 'bmad');
|
||
+
|
||
+ if (await fs.pathExists(bmadDir)) {
|
||
+ await fs.remove(bmadDir);
|
||
+ }
|
||
+
|
||
+ // Clean up IDE configurations
|
||
+ await this.ideManager.cleanup(path.resolve(directory));
|
||
+
|
||
+ return { success: true };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Private: Create directory structure
|
||
+ */
|
||
+ async createDirectoryStructure(bmadDir) {
|
||
+ await fs.ensureDir(bmadDir);
|
||
+ await fs.ensureDir(path.join(bmadDir, '_cfg'));
|
||
+ await fs.ensureDir(path.join(bmadDir, '_cfg', 'agents'));
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * 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('js-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 installedModules = entries
|
||
+ .filter((entry) => entry.isDirectory() && entry.name !== '_cfg' && entry.name !== 'docs')
|
||
+ .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';
|
||
+ }
|
||
+
|
||
+ // Convert config to YAML
|
||
+ let yamlContent = yaml.dump(finalConfig, {
|
||
+ indent: 2,
|
||
+ lineWidth: -1,
|
||
+ noRefs: true,
|
||
+ sortKeys: false,
|
||
+ });
|
||
+
|
||
+ // 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
|
||
+ await fs.writeFile(configPath, header + yamlContent, 'utf8');
|
||
+
|
||
+ // Track the config file in installedFiles
|
||
+ this.installedFiles.push(configPath);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Install core with resolved dependencies
|
||
+ * @param {string} bmadDir - BMAD installation directory
|
||
+ * @param {Object} coreFiles - Core files to install
|
||
+ */
|
||
+ async installCoreWithDependencies(bmadDir, coreFiles) {
|
||
+ const sourcePath = getModulePath('core');
|
||
+ const targetPath = path.join(bmadDir, 'core');
|
||
+
|
||
+ // Install full core
|
||
+ await this.installCore(bmadDir);
|
||
+
|
||
+ // If there are specific dependency files, ensure they're included
|
||
+ if (coreFiles) {
|
||
+ // Already handled by installCore for core module
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Install module with resolved dependencies
|
||
+ * @param {string} moduleName - Module name
|
||
+ * @param {string} bmadDir - BMAD installation directory
|
||
+ * @param {Object} moduleFiles - Module files to install
|
||
+ */
|
||
+ async installModuleWithDependencies(moduleName, bmadDir, moduleFiles) {
|
||
+ // Use existing module manager for full installation with file tracking
|
||
+ // Note: Module-specific installers are called separately after IDE setup
|
||
+ await this.moduleManager.install(
|
||
+ moduleName,
|
||
+ bmadDir,
|
||
+ (filePath) => {
|
||
+ this.installedFiles.push(filePath);
|
||
+ },
|
||
+ {
|
||
+ skipModuleInstaller: true, // We'll run it later after IDE setup
|
||
+ },
|
||
+ );
|
||
+
|
||
+ // Process agent files to build YAML agents and create customize templates
|
||
+ const modulePath = path.join(bmadDir, moduleName);
|
||
+ await this.processAgentFiles(modulePath, moduleName);
|
||
+
|
||
+ // Dependencies are already included in full module install
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Install partial module (only dependencies needed by other modules)
|
||
+ */
|
||
+ async installPartialModule(moduleName, bmadDir, files) {
|
||
+ const sourceBase = getModulePath(moduleName);
|
||
+ const targetBase = path.join(bmadDir, moduleName);
|
||
+
|
||
+ // Create module directory
|
||
+ await fs.ensureDir(targetBase);
|
||
+
|
||
+ // Copy only the required dependency files
|
||
+ if (files.agents && files.agents.length > 0) {
|
||
+ const agentsDir = path.join(targetBase, 'agents');
|
||
+ await fs.ensureDir(agentsDir);
|
||
+
|
||
+ for (const agentPath of files.agents) {
|
||
+ const fileName = path.basename(agentPath);
|
||
+ const sourcePath = path.join(sourceBase, 'agents', fileName);
|
||
+ const targetPath = path.join(agentsDir, fileName);
|
||
+
|
||
+ if (await fs.pathExists(sourcePath)) {
|
||
+ await fs.copy(sourcePath, targetPath);
|
||
+ this.installedFiles.push(targetPath);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ if (files.tasks && files.tasks.length > 0) {
|
||
+ const tasksDir = path.join(targetBase, 'tasks');
|
||
+ await fs.ensureDir(tasksDir);
|
||
+
|
||
+ for (const taskPath of files.tasks) {
|
||
+ const fileName = path.basename(taskPath);
|
||
+ const sourcePath = path.join(sourceBase, 'tasks', fileName);
|
||
+ const targetPath = path.join(tasksDir, fileName);
|
||
+
|
||
+ if (await fs.pathExists(sourcePath)) {
|
||
+ await fs.copy(sourcePath, targetPath);
|
||
+ this.installedFiles.push(targetPath);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ if (files.templates && files.templates.length > 0) {
|
||
+ const templatesDir = path.join(targetBase, 'templates');
|
||
+ await fs.ensureDir(templatesDir);
|
||
+
|
||
+ for (const templatePath of files.templates) {
|
||
+ const fileName = path.basename(templatePath);
|
||
+ const sourcePath = path.join(sourceBase, 'templates', fileName);
|
||
+ const targetPath = path.join(templatesDir, fileName);
|
||
+
|
||
+ if (await fs.pathExists(sourcePath)) {
|
||
+ await fs.copy(sourcePath, targetPath);
|
||
+ this.installedFiles.push(targetPath);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ if (files.data && files.data.length > 0) {
|
||
+ for (const dataPath of files.data) {
|
||
+ // Preserve directory structure for data files
|
||
+ const relative = path.relative(sourceBase, dataPath);
|
||
+ const targetPath = path.join(targetBase, relative);
|
||
+
|
||
+ await fs.ensureDir(path.dirname(targetPath));
|
||
+
|
||
+ if (await fs.pathExists(dataPath)) {
|
||
+ await fs.copy(dataPath, targetPath);
|
||
+ this.installedFiles.push(targetPath);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Create a marker file to indicate this is a partial installation
|
||
+ const markerPath = path.join(targetBase, '.partial');
|
||
+ await fs.writeFile(
|
||
+ markerPath,
|
||
+ `This module contains only dependencies required by other modules.\nInstalled: ${new Date().toISOString()}\n`,
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Private: Install core
|
||
+ * @param {string} bmadDir - BMAD installation directory
|
||
+ */
|
||
+ async installCore(bmadDir) {
|
||
+ const sourcePath = getModulePath('core');
|
||
+ const targetPath = path.join(bmadDir, 'core');
|
||
+
|
||
+ // Copy core files with filtering for localskip agents
|
||
+ await this.copyDirectoryWithFiltering(sourcePath, targetPath);
|
||
+
|
||
+ // Process agent files to inject activation block
|
||
+ await this.processAgentFiles(targetPath, 'core');
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Copy directory with filtering for localskip agents
|
||
+ * @param {string} sourcePath - Source directory path
|
||
+ * @param {string} targetPath - Target directory path
|
||
+ */
|
||
+ async copyDirectoryWithFiltering(sourcePath, targetPath) {
|
||
+ // Get all files in source directory
|
||
+ const files = await this.getFileList(sourcePath);
|
||
+
|
||
+ for (const file of files) {
|
||
+ // Skip config.yaml templates - we'll generate clean ones with actual values
|
||
+ if (file === 'config.yaml' || file.endsWith('/config.yaml')) {
|
||
+ continue;
|
||
+ }
|
||
+
|
||
+ const sourceFile = path.join(sourcePath, file);
|
||
+ const targetFile = path.join(targetPath, file);
|
||
+
|
||
+ // Check if this is an agent file
|
||
+ if (file.includes('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) {
|
||
+ console.log(chalk.dim(` Skipping web-only agent: ${path.basename(file)}`));
|
||
+ continue; // Skip this agent
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Copy the file
|
||
+ await fs.ensureDir(path.dirname(targetFile));
|
||
+ await fs.copy(sourceFile, targetFile, { overwrite: true });
|
||
+
|
||
+ // Track the installed file
|
||
+ this.installedFiles.push(targetFile);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Get list of all files in a directory recursively
|
||
+ * @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()) {
|
||
+ // Skip _module-installer directories
|
||
+ if (entry.name === '_module-installer') {
|
||
+ continue;
|
||
+ }
|
||
+ const subFiles = await this.getFileList(fullPath, baseDir);
|
||
+ files.push(...subFiles);
|
||
+ } else {
|
||
+ files.push(path.relative(baseDir, fullPath));
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return files;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Process agent files to build YAML agents and inject activation blocks
|
||
+ * @param {string} modulePath - Path to module in bmad/ installation
|
||
+ * @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
|
||
+ }
|
||
+
|
||
+ // Determine project directory (parent of bmad/ directory)
|
||
+ const bmadDir = path.dirname(modulePath);
|
||
+ const projectDir = path.dirname(bmadDir);
|
||
+ const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents');
|
||
+
|
||
+ // Ensure _cfg/agents directory exists
|
||
+ await fs.ensureDir(cfgAgentsDir);
|
||
+
|
||
+ // Get all agent files
|
||
+ const agentFiles = await fs.readdir(agentsPath);
|
||
+
|
||
+ for (const agentFile of agentFiles) {
|
||
+ // Handle YAML agents - build them to .md
|
||
+ if (agentFile.endsWith('.agent.yaml')) {
|
||
+ const agentName = agentFile.replace('.agent.yaml', '');
|
||
+ const yamlPath = path.join(agentsPath, agentFile);
|
||
+ const mdPath = path.join(agentsPath, `${agentName}.md`);
|
||
+ const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
|
||
+
|
||
+ // Create customize template if it doesn't exist
|
||
+ if (!(await fs.pathExists(customizePath))) {
|
||
+ const genericTemplatePath = getSourcePath('utility', 'templates', 'agent.customize.template.yaml');
|
||
+ if (await fs.pathExists(genericTemplatePath)) {
|
||
+ await fs.copy(genericTemplatePath, customizePath);
|
||
+ console.log(chalk.dim(` Created customize: ${moduleName}-${agentName}.customize.yaml`));
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Build YAML + customize to .md
|
||
+ const customizeExists = await fs.pathExists(customizePath);
|
||
+ const xmlContent = await this.xmlHandler.buildFromYaml(yamlPath, customizeExists ? customizePath : null, {
|
||
+ includeMetadata: true,
|
||
+ });
|
||
+
|
||
+ // DO NOT replace {project-root} - LLMs understand this placeholder at runtime
|
||
+ // const processedContent = xmlContent.replaceAll('{project-root}', projectDir);
|
||
+
|
||
+ // Write the built .md file to bmad/{module}/agents/
|
||
+ await fs.writeFile(mdPath, xmlContent, 'utf8');
|
||
+ this.installedFiles.push(mdPath);
|
||
+
|
||
+ // Remove the source YAML file - we can regenerate from installer source if needed
|
||
+ await fs.remove(yamlPath);
|
||
+
|
||
+ console.log(chalk.dim(` Built agent: ${agentName}.md`));
|
||
+ }
|
||
+ // Handle legacy .md agents - inject activation if needed
|
||
+ else if (agentFile.endsWith('.md')) {
|
||
+ const agentPath = path.join(agentsPath, agentFile);
|
||
+ let content = await fs.readFile(agentPath, '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(agentPath, content, 'utf8');
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Build standalone agents in bmad/agents/ directory
|
||
+ * @param {string} bmadDir - Path to bmad directory
|
||
+ * @param {string} projectDir - Path to project directory
|
||
+ */
|
||
+ async buildStandaloneAgents(bmadDir, projectDir) {
|
||
+ const standaloneAgentsPath = path.join(bmadDir, 'agents');
|
||
+ const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents');
|
||
+
|
||
+ // Check if standalone agents directory exists
|
||
+ if (!(await fs.pathExists(standaloneAgentsPath))) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // Get all subdirectories in agents/
|
||
+ const agentDirs = await fs.readdir(standaloneAgentsPath, { withFileTypes: true });
|
||
+
|
||
+ for (const agentDir of agentDirs) {
|
||
+ if (!agentDir.isDirectory()) continue;
|
||
+
|
||
+ const agentDirPath = path.join(standaloneAgentsPath, agentDir.name);
|
||
+
|
||
+ // Find any .agent.yaml file in the directory
|
||
+ const files = await fs.readdir(agentDirPath);
|
||
+ const yamlFile = files.find((f) => f.endsWith('.agent.yaml'));
|
||
+
|
||
+ if (!yamlFile) continue;
|
||
+
|
||
+ const agentName = path.basename(yamlFile, '.agent.yaml');
|
||
+ const sourceYamlPath = path.join(agentDirPath, yamlFile);
|
||
+ const targetMdPath = path.join(agentDirPath, `${agentName}.md`);
|
||
+ const customizePath = path.join(cfgAgentsDir, `${agentName}.customize.yaml`);
|
||
+
|
||
+ // Check for customizations
|
||
+ const customizeExists = await fs.pathExists(customizePath);
|
||
+ let customizedFields = [];
|
||
+
|
||
+ if (customizeExists) {
|
||
+ const customizeContent = await fs.readFile(customizePath, 'utf8');
|
||
+ const yaml = require('js-yaml');
|
||
+ const customizeYaml = yaml.load(customizeContent);
|
||
+
|
||
+ // Detect what fields are customized (similar to rebuildAgentFiles)
|
||
+ if (customizeYaml) {
|
||
+ if (customizeYaml.persona) {
|
||
+ for (const [key, value] of Object.entries(customizeYaml.persona)) {
|
||
+ if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) {
|
||
+ customizedFields.push(`persona.${key}`);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ if (customizeYaml.agent?.metadata) {
|
||
+ for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) {
|
||
+ if (value !== '' && value !== null) {
|
||
+ customizedFields.push(`metadata.${key}`);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ if (customizeYaml.critical_actions && customizeYaml.critical_actions.length > 0) {
|
||
+ customizedFields.push('critical_actions');
|
||
+ }
|
||
+ if (customizeYaml.menu && customizeYaml.menu.length > 0) {
|
||
+ customizedFields.push('menu');
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Build YAML to XML .md
|
||
+ const xmlContent = await this.xmlHandler.buildFromYaml(sourceYamlPath, customizeExists ? customizePath : null, {
|
||
+ includeMetadata: true,
|
||
+ });
|
||
+
|
||
+ // DO NOT replace {project-root} - LLMs understand this placeholder at runtime
|
||
+ // const processedContent = xmlContent.replaceAll('{project-root}', projectDir);
|
||
+
|
||
+ // Write the built .md file
|
||
+ await fs.writeFile(targetMdPath, xmlContent, 'utf8');
|
||
+
|
||
+ // Display result
|
||
+ if (customizedFields.length > 0) {
|
||
+ console.log(chalk.dim(` Built standalone agent: ${agentName}.md `) + chalk.yellow(`(customized: ${customizedFields.join(', ')})`));
|
||
+ } else {
|
||
+ console.log(chalk.dim(` Built standalone agent: ${agentName}.md`));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Rebuild agent files from installer source (for compile command)
|
||
+ * @param {string} modulePath - Path to module in bmad/ installation
|
||
+ * @param {string} moduleName - Module name
|
||
+ */
|
||
+ async rebuildAgentFiles(modulePath, moduleName) {
|
||
+ // Get source agents directory from installer
|
||
+ const sourceAgentsPath =
|
||
+ moduleName === 'core' ? path.join(getModulePath('core'), 'agents') : path.join(getSourcePath(`modules/${moduleName}`), 'agents');
|
||
+
|
||
+ if (!(await fs.pathExists(sourceAgentsPath))) {
|
||
+ return; // No source agents to rebuild
|
||
+ }
|
||
+
|
||
+ // Determine project directory (parent of bmad/ directory)
|
||
+ const bmadDir = path.dirname(modulePath);
|
||
+ const projectDir = path.dirname(bmadDir);
|
||
+ const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents');
|
||
+ const targetAgentsPath = path.join(modulePath, 'agents');
|
||
+
|
||
+ // Ensure target directory exists
|
||
+ await fs.ensureDir(targetAgentsPath);
|
||
+
|
||
+ // Get all YAML agent files from source
|
||
+ const sourceFiles = await fs.readdir(sourceAgentsPath);
|
||
+
|
||
+ for (const file of sourceFiles) {
|
||
+ if (file.endsWith('.agent.yaml')) {
|
||
+ const agentName = file.replace('.agent.yaml', '');
|
||
+ const sourceYamlPath = path.join(sourceAgentsPath, file);
|
||
+ const targetMdPath = path.join(targetAgentsPath, `${agentName}.md`);
|
||
+ const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
|
||
+
|
||
+ // Check for customizations
|
||
+ const customizeExists = await fs.pathExists(customizePath);
|
||
+ let customizedFields = [];
|
||
+
|
||
+ if (customizeExists) {
|
||
+ const customizeContent = await fs.readFile(customizePath, 'utf8');
|
||
+ const yaml = require('js-yaml');
|
||
+ const customizeYaml = yaml.load(customizeContent);
|
||
+
|
||
+ // Detect what fields are customized
|
||
+ if (customizeYaml) {
|
||
+ if (customizeYaml.persona) {
|
||
+ for (const [key, value] of Object.entries(customizeYaml.persona)) {
|
||
+ if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) {
|
||
+ customizedFields.push(`persona.${key}`);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ if (customizeYaml.agent?.metadata) {
|
||
+ for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) {
|
||
+ if (value !== '' && value !== null) {
|
||
+ customizedFields.push(`metadata.${key}`);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ if (customizeYaml.critical_actions && customizeYaml.critical_actions.length > 0) {
|
||
+ customizedFields.push('critical_actions');
|
||
+ }
|
||
+ if (customizeYaml.memories && customizeYaml.memories.length > 0) {
|
||
+ customizedFields.push('memories');
|
||
+ }
|
||
+ if (customizeYaml.menu && customizeYaml.menu.length > 0) {
|
||
+ customizedFields.push('menu');
|
||
+ }
|
||
+ if (customizeYaml.prompts && customizeYaml.prompts.length > 0) {
|
||
+ customizedFields.push('prompts');
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Build YAML + customize to .md
|
||
+ const xmlContent = await this.xmlHandler.buildFromYaml(sourceYamlPath, customizeExists ? customizePath : null, {
|
||
+ includeMetadata: true,
|
||
+ });
|
||
+
|
||
+ // DO NOT replace {project-root} - LLMs understand this placeholder at runtime
|
||
+ // const processedContent = xmlContent.replaceAll('{project-root}', projectDir);
|
||
+
|
||
+ // Write the rebuilt .md file
|
||
+ await fs.writeFile(targetMdPath, xmlContent, 'utf8');
|
||
+
|
||
+ // Display result with customizations if any
|
||
+ if (customizedFields.length > 0) {
|
||
+ console.log(chalk.dim(` Rebuilt agent: ${agentName}.md `) + chalk.yellow(`(customized: ${customizedFields.join(', ')})`));
|
||
+ } else {
|
||
+ console.log(chalk.dim(` Rebuilt agent: ${agentName}.md`));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Compile/rebuild all agents and tasks for quick updates
|
||
+ * @param {Object} config - Compilation configuration
|
||
+ * @returns {Object} Compilation results
|
||
+ */
|
||
+ async compileAgents(config) {
|
||
+ const ora = require('ora');
|
||
+ const spinner = ora('Starting agent compilation...').start();
|
||
+
|
||
+ try {
|
||
+ const projectDir = path.resolve(config.directory);
|
||
+ const bmadDir = path.join(projectDir, 'bmad');
|
||
+
|
||
+ // Check if bmad directory exists
|
||
+ if (!(await fs.pathExists(bmadDir))) {
|
||
+ spinner.fail('No BMAD installation found');
|
||
+ throw new Error(`BMAD not installed at ${bmadDir}`);
|
||
+ }
|
||
+
|
||
+ let agentCount = 0;
|
||
+ let taskCount = 0;
|
||
+
|
||
+ // Process all modules in bmad directory
|
||
+ spinner.text = 'Rebuilding agent files...';
|
||
+ const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||
+
|
||
+ for (const entry of entries) {
|
||
+ if (entry.isDirectory() && entry.name !== '_cfg' && entry.name !== 'docs') {
|
||
+ const modulePath = path.join(bmadDir, entry.name);
|
||
+
|
||
+ // Special handling for standalone agents in bmad/agents/ directory
|
||
+ if (entry.name === 'agents') {
|
||
+ spinner.text = 'Building standalone agents...';
|
||
+ await this.buildStandaloneAgents(bmadDir, projectDir);
|
||
+
|
||
+ // Count standalone agents
|
||
+ const standaloneAgentsPath = path.join(bmadDir, 'agents');
|
||
+ const standaloneAgentDirs = await fs.readdir(standaloneAgentsPath, { withFileTypes: true });
|
||
+ for (const agentDir of standaloneAgentDirs) {
|
||
+ if (agentDir.isDirectory()) {
|
||
+ const agentDirPath = path.join(standaloneAgentsPath, agentDir.name);
|
||
+ const agentFiles = await fs.readdir(agentDirPath);
|
||
+ agentCount += agentFiles.filter((f) => f.endsWith('.md') && !f.endsWith('.agent.yaml')).length;
|
||
+ }
|
||
+ }
|
||
+ } else {
|
||
+ // Rebuild module agents from installer source
|
||
+ const agentsPath = path.join(modulePath, 'agents');
|
||
+ if (await fs.pathExists(agentsPath)) {
|
||
+ await this.rebuildAgentFiles(modulePath, entry.name);
|
||
+ const agentFiles = await fs.readdir(agentsPath);
|
||
+ agentCount += agentFiles.filter((f) => f.endsWith('.md')).length;
|
||
+ }
|
||
+
|
||
+ // Count tasks (already built)
|
||
+ const tasksPath = path.join(modulePath, 'tasks');
|
||
+ if (await fs.pathExists(tasksPath)) {
|
||
+ const taskFiles = await fs.readdir(tasksPath);
|
||
+ taskCount += taskFiles.filter((f) => f.endsWith('.md')).length;
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Regenerate manifests after compilation
|
||
+ spinner.start('Regenerating manifests...');
|
||
+ const installedModules = entries
|
||
+ .filter((e) => e.isDirectory() && e.name !== '_cfg' && e.name !== 'docs' && e.name !== 'agents' && e.name !== 'core')
|
||
+ .map((e) => e.name);
|
||
+ const manifestGen = new ManifestGenerator();
|
||
+
|
||
+ // Get existing IDE list from manifest
|
||
+ const existingManifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||
+ let existingIdes = [];
|
||
+ if (await fs.pathExists(existingManifestPath)) {
|
||
+ const manifestContent = await fs.readFile(existingManifestPath, 'utf8');
|
||
+ const yaml = require('js-yaml');
|
||
+ const manifest = yaml.load(manifestContent);
|
||
+ existingIdes = manifest.ides || [];
|
||
+ }
|
||
+
|
||
+ await manifestGen.generateManifests(bmadDir, installedModules, [], {
|
||
+ ides: existingIdes,
|
||
+ });
|
||
+ spinner.succeed('Manifests regenerated');
|
||
+
|
||
+ // Ask for IDE to update
|
||
+ spinner.stop();
|
||
+ // Note: UI lives in tools/cli/lib/ui.js; from installers/lib/core use '../../../lib/ui'
|
||
+ const { UI } = require('../../../lib/ui');
|
||
+ const ui = new UI();
|
||
+ const toolConfig = await ui.promptToolSelection(projectDir, []);
|
||
+
|
||
+ if (!toolConfig.skipIde && toolConfig.ides && toolConfig.ides.length > 0) {
|
||
+ spinner.start('Updating IDE configurations...');
|
||
+
|
||
+ for (const ide of toolConfig.ides) {
|
||
+ spinner.text = `Updating ${ide}...`;
|
||
+ await this.ideManager.setup(ide, projectDir, bmadDir, {
|
||
+ selectedModules: installedModules,
|
||
+ skipModuleInstall: true, // Skip module installation, just update IDE files
|
||
+ verbose: config.verbose,
|
||
+ });
|
||
+ }
|
||
+
|
||
+ spinner.succeed('IDE configurations updated');
|
||
+ }
|
||
+
|
||
+ return { agentCount, taskCount };
|
||
+ } catch (error) {
|
||
+ spinner.fail('Compilation failed');
|
||
+ throw error;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Private: Update core
|
||
+ */
|
||
+ async updateCore(bmadDir, force = false) {
|
||
+ const sourcePath = getModulePath('core');
|
||
+ const targetPath = path.join(bmadDir, 'core');
|
||
+
|
||
+ if (force) {
|
||
+ await fs.remove(targetPath);
|
||
+ await this.installCore(bmadDir);
|
||
+ } else {
|
||
+ // Selective update - preserve user modifications
|
||
+ await this.fileOps.syncDirectory(sourcePath, targetPath);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Private: Prompt for update action
|
||
+ */
|
||
+ async promptUpdateAction() {
|
||
+ const inquirer = require('inquirer');
|
||
+ return await inquirer.prompt([
|
||
+ {
|
||
+ type: 'list',
|
||
+ name: 'action',
|
||
+ message: 'What would you like to do?',
|
||
+ choices: [
|
||
+ { name: 'Update existing installation', value: 'update' },
|
||
+ { name: 'Remove and reinstall', value: 'reinstall' },
|
||
+ { name: 'Cancel', value: 'cancel' },
|
||
+ ],
|
||
+ },
|
||
+ ]);
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Handle legacy BMAD v4 migration with automatic backup
|
||
+ * @param {string} projectDir - Project directory
|
||
+ * @param {Object} legacyV4 - Legacy V4 detection result with offenders array
|
||
+ */
|
||
+ async handleLegacyV4Migration(projectDir, legacyV4) {
|
||
+ console.log(chalk.yellow.bold('\nÔÜá´©Å Legacy BMAD v4 detected'));
|
||
+ console.log(chalk.dim('The installer found legacy artefacts in your project.\n'));
|
||
+
|
||
+ // Separate .bmad* folders (auto-backup) from other offending paths (manual cleanup)
|
||
+ const bmadFolders = legacyV4.offenders.filter((p) => {
|
||
+ const name = path.basename(p);
|
||
+ return name.startsWith('.bmad'); // Only dot-prefixed folders get auto-backed up
|
||
+ });
|
||
+ const otherOffenders = legacyV4.offenders.filter((p) => {
|
||
+ const name = path.basename(p);
|
||
+ return !name.startsWith('.bmad'); // Everything else is manual cleanup
|
||
+ });
|
||
+
|
||
+ const inquirer = require('inquirer');
|
||
+
|
||
+ // Show warning for other offending paths FIRST
|
||
+ if (otherOffenders.length > 0) {
|
||
+ console.log(chalk.yellow('ÔÜá´©Å Recommended cleanup:'));
|
||
+ console.log(chalk.dim('It is recommended to remove the following items before proceeding:\n'));
|
||
+ for (const p of otherOffenders) console.log(chalk.dim(` - ${p}`));
|
||
+
|
||
+ console.log(chalk.cyan('\nCleanup commands you can copy/paste:'));
|
||
+ console.log(chalk.dim('macOS/Linux:'));
|
||
+ for (const p of otherOffenders) console.log(chalk.dim(` rm -rf '${p}'`));
|
||
+ console.log(chalk.dim('Windows:'));
|
||
+ for (const p of otherOffenders) console.log(chalk.dim(` rmdir /S /Q "${p}"`));
|
||
+
|
||
+ const { cleanedUp } = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'confirm',
|
||
+ name: 'cleanedUp',
|
||
+ message: 'Have you completed the recommended cleanup? (You can proceed without it, but it is recommended)',
|
||
+ default: false,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ if (cleanedUp) {
|
||
+ console.log(chalk.green('Ô£ô Cleanup acknowledged\n'));
|
||
+ } else {
|
||
+ console.log(chalk.yellow('ÔÜá´©Å Proceeding without recommended cleanup\n'));
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Handle .bmad* folders with automatic backup
|
||
+ if (bmadFolders.length > 0) {
|
||
+ console.log(chalk.cyan('The following legacy folders will be moved to v4-backup:'));
|
||
+ for (const p of bmadFolders) console.log(chalk.dim(` - ${p}`));
|
||
+
|
||
+ const { proceed } = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'confirm',
|
||
+ name: 'proceed',
|
||
+ message: 'Proceed with backing up legacy v4 folders?',
|
||
+ default: true,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ if (proceed) {
|
||
+ const backupDir = path.join(projectDir, 'v4-backup');
|
||
+ await fs.ensureDir(backupDir);
|
||
+
|
||
+ for (const folder of bmadFolders) {
|
||
+ const folderName = path.basename(folder);
|
||
+ const backupPath = path.join(backupDir, folderName);
|
||
+
|
||
+ // If backup already exists, add timestamp
|
||
+ let finalBackupPath = backupPath;
|
||
+ if (await fs.pathExists(backupPath)) {
|
||
+ const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-').split('T')[0];
|
||
+ finalBackupPath = path.join(backupDir, `${folderName}-${timestamp}`);
|
||
+ }
|
||
+
|
||
+ await fs.move(folder, finalBackupPath, { overwrite: false });
|
||
+ console.log(chalk.green(`Ô£ô Moved ${folderName} to ${path.relative(projectDir, finalBackupPath)}`));
|
||
+ }
|
||
+ } else {
|
||
+ throw new Error('Installation cancelled by user');
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * 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, '_cfg', '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) {
|
||
+ console.warn('Warning: 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 = [];
|
||
+
|
||
+ // 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) {
|
||
+ // Files in manifest are stored as relative paths starting with 'bmad/'
|
||
+ // Convert to absolute path
|
||
+ const relativePath = fileEntry.path.startsWith('bmad/') ? fileEntry.path.slice(5) : fileEntry.path;
|
||
+ const absolutePath = path.join(bmadDir, relativePath);
|
||
+ installedFilesMap.set(path.normalize(absolutePath), {
|
||
+ hash: fileEntry.hash,
|
||
+ relativePath: relativePath,
|
||
+ });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 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 _cfg directory - system files
|
||
+ if (relativePath.startsWith('_cfg/') || relativePath.startsWith('_cfg\\')) {
|
||
+ continue;
|
||
+ }
|
||
+
|
||
+ // Skip config.yaml files - these are regenerated on each install/update
|
||
+ // Users should use _cfg/agents/ override files instead
|
||
+ if (fileName === 'config.yaml') {
|
||
+ continue;
|
||
+ }
|
||
+
|
||
+ if (!fileInfo) {
|
||
+ // File not in manifest = custom file
|
||
+ 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,
|
||
+ });
|
||
+ }
|
||
+ }
|
||
+ // If manifest doesn't have hashes, we can't detect modifications
|
||
+ // so we just skip files that are in the manifest
|
||
+ }
|
||
+ }
|
||
+ } catch {
|
||
+ // Ignore errors scanning directories
|
||
+ }
|
||
+ };
|
||
+
|
||
+ await scanDirectory(bmadDir);
|
||
+ return { customFiles, modifiedFiles };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Private: Create agent configuration files
|
||
+ * @param {string} bmadDir - BMAD installation directory
|
||
+ * @param {Object} userInfo - User information including name and language
|
||
+ */
|
||
+ async createAgentConfigs(bmadDir, userInfo = null) {
|
||
+ const agentConfigDir = path.join(bmadDir, '_cfg', 'agents');
|
||
+ await fs.ensureDir(agentConfigDir);
|
||
+
|
||
+ // Get all agents from all modules
|
||
+ const agents = [];
|
||
+ const agentDetails = []; // For manifest generation
|
||
+
|
||
+ // Check modules for agents (including core)
|
||
+ const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||
+ for (const entry of entries) {
|
||
+ if (entry.isDirectory() && entry.name !== '_cfg') {
|
||
+ const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents');
|
||
+ if (await fs.pathExists(moduleAgentsPath)) {
|
||
+ const agentFiles = await fs.readdir(moduleAgentsPath);
|
||
+ for (const agentFile of agentFiles) {
|
||
+ if (agentFile.endsWith('.md')) {
|
||
+ const agentPath = path.join(moduleAgentsPath, agentFile);
|
||
+ const agentContent = await fs.readFile(agentPath, 'utf8');
|
||
+
|
||
+ // Skip agents with localskip="true"
|
||
+ const hasLocalSkip = agentContent.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
|
||
+ if (hasLocalSkip) {
|
||
+ continue; // Skip this agent - it should not have been installed
|
||
+ }
|
||
+
|
||
+ const agentName = path.basename(agentFile, '.md');
|
||
+
|
||
+ // Extract any nodes with agentConfig="true"
|
||
+ const agentConfigNodes = this.extractAgentConfigNodes(agentContent);
|
||
+
|
||
+ agents.push({
|
||
+ name: agentName,
|
||
+ module: entry.name,
|
||
+ agentConfigNodes: agentConfigNodes,
|
||
+ });
|
||
+
|
||
+ // Use shared AgentPartyGenerator to extract details
|
||
+ let details = AgentPartyGenerator.extractAgentDetails(agentContent, entry.name, agentName);
|
||
+
|
||
+ // Apply config overrides if they exist
|
||
+ if (details) {
|
||
+ const configPath = path.join(agentConfigDir, `${entry.name}-${agentName}.md`);
|
||
+ if (await fs.pathExists(configPath)) {
|
||
+ const configContent = await fs.readFile(configPath, 'utf8');
|
||
+ details = AgentPartyGenerator.applyConfigOverrides(details, configContent);
|
||
+ }
|
||
+ agentDetails.push(details);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Create config file for each agent
|
||
+ let createdCount = 0;
|
||
+ let skippedCount = 0;
|
||
+
|
||
+ // Load agent config template
|
||
+ const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md');
|
||
+ const templateContent = await fs.readFile(templatePath, 'utf8');
|
||
+
|
||
+ for (const agent of agents) {
|
||
+ const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`);
|
||
+
|
||
+ // Skip if config file already exists (preserve custom configurations)
|
||
+ if (await fs.pathExists(configPath)) {
|
||
+ skippedCount++;
|
||
+ continue;
|
||
+ }
|
||
+
|
||
+ // Build config content header
|
||
+ let configContent = `# Agent Config: ${agent.name}\n\n`;
|
||
+
|
||
+ // Process template and add agent-specific config nodes
|
||
+ let processedTemplate = templateContent;
|
||
+
|
||
+ // Replace {core:user_name} placeholder with actual user name if available
|
||
+ if (userInfo && userInfo.userName) {
|
||
+ processedTemplate = processedTemplate.replaceAll('{core:user_name}', userInfo.userName);
|
||
+ }
|
||
+
|
||
+ // Replace {core:communication_language} placeholder with actual language if available
|
||
+ if (userInfo && userInfo.responseLanguage) {
|
||
+ processedTemplate = processedTemplate.replaceAll('{core:communication_language}', userInfo.responseLanguage);
|
||
+ }
|
||
+
|
||
+ // If this agent has agentConfig nodes, add them after the existing comment
|
||
+ if (agent.agentConfigNodes && agent.agentConfigNodes.length > 0) {
|
||
+ // Find the agent-specific configuration nodes comment
|
||
+ const commentPattern = /(\s*<!-- Agent-specific configuration nodes -->)/;
|
||
+ const commentMatch = processedTemplate.match(commentPattern);
|
||
+
|
||
+ if (commentMatch) {
|
||
+ // Add nodes right after the comment
|
||
+ let agentSpecificNodes = '';
|
||
+ for (const node of agent.agentConfigNodes) {
|
||
+ agentSpecificNodes += `\n ${node}`;
|
||
+ }
|
||
+
|
||
+ processedTemplate = processedTemplate.replace(commentPattern, `$1${agentSpecificNodes}`);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ configContent += processedTemplate;
|
||
+
|
||
+ await fs.writeFile(configPath, configContent, 'utf8');
|
||
+ this.installedFiles.push(configPath); // Track agent config files
|
||
+ createdCount++;
|
||
+ }
|
||
+
|
||
+ // Generate agent manifest with overrides applied
|
||
+ await this.generateAgentManifest(bmadDir, agentDetails);
|
||
+
|
||
+ return { total: agents.length, created: createdCount, skipped: skippedCount };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Generate agent manifest XML file
|
||
+ * @param {string} bmadDir - BMAD installation directory
|
||
+ * @param {Array} agentDetails - Array of agent details
|
||
+ */
|
||
+ async generateAgentManifest(bmadDir, agentDetails) {
|
||
+ const manifestPath = path.join(bmadDir, '_cfg', 'agent-party.xml');
|
||
+ await AgentPartyGenerator.writeAgentParty(manifestPath, agentDetails, { forWeb: false });
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Extract nodes with agentConfig="true" from agent content
|
||
+ * @param {string} content - Agent file content
|
||
+ * @returns {Array} Array of XML nodes that should be added to agent config
|
||
+ */
|
||
+ extractAgentConfigNodes(content) {
|
||
+ const nodes = [];
|
||
+
|
||
+ try {
|
||
+ // Find all XML nodes with agentConfig="true"
|
||
+ // Match self-closing tags and tags with content
|
||
+ const selfClosingPattern = /<([a-zA-Z][a-zA-Z0-9_-]*)\s+[^>]*agentConfig="true"[^>]*\/>/g;
|
||
+ const withContentPattern = /<([a-zA-Z][a-zA-Z0-9_-]*)\s+[^>]*agentConfig="true"[^>]*>([\s\S]*?)<\/\1>/g;
|
||
+
|
||
+ // Extract self-closing tags
|
||
+ let match;
|
||
+ while ((match = selfClosingPattern.exec(content)) !== null) {
|
||
+ // Extract just the tag without children (structure only)
|
||
+ const tagMatch = match[0].match(/<([a-zA-Z][a-zA-Z0-9_-]*)([^>]*)\/>/);
|
||
+ if (tagMatch) {
|
||
+ const tagName = tagMatch[1];
|
||
+ const attributes = tagMatch[2].replace(/\s*agentConfig="true"/, ''); // Remove agentConfig attribute
|
||
+ nodes.push(`<${tagName}${attributes}></${tagName}>`);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Extract tags with content
|
||
+ while ((match = withContentPattern.exec(content)) !== null) {
|
||
+ const fullMatch = match[0];
|
||
+ const tagName = match[1];
|
||
+
|
||
+ // Extract opening tag with attributes (removing agentConfig="true")
|
||
+ const openingTagMatch = fullMatch.match(new RegExp(`<${tagName}([^>]*)>`));
|
||
+ if (openingTagMatch) {
|
||
+ const attributes = openingTagMatch[1].replace(/\s*agentConfig="true"/, '');
|
||
+ // Add empty node structure (no children)
|
||
+ nodes.push(`<${tagName}${attributes}></${tagName}>`);
|
||
+ }
|
||
+ }
|
||
+ } catch (error) {
|
||
+ console.error('Error extracting agentConfig nodes:', error);
|
||
+ }
|
||
+
|
||
+ return nodes;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Copy IDE-specific documentation to BMAD docs
|
||
+ * @param {Array} ides - List of selected IDEs
|
||
+ * @param {string} bmadDir - BMAD installation directory
|
||
+ */
|
||
+ async copyIdeDocumentation(ides, bmadDir) {
|
||
+ const docsDir = path.join(bmadDir, 'docs');
|
||
+ await fs.ensureDir(docsDir);
|
||
+
|
||
+ for (const ide of ides) {
|
||
+ const sourceDocPath = path.join(getProjectRoot(), 'docs', 'ide-info', `${ide}.md`);
|
||
+ const targetDocPath = path.join(docsDir, `${ide}-instructions.md`);
|
||
+
|
||
+ if (await fs.pathExists(sourceDocPath)) {
|
||
+ await fs.copy(sourceDocPath, targetDocPath, { overwrite: true });
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+}
|
||
+
|
||
+module.exports = { Installer };
|
||
diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/cli/installers/lib/core/manifest.js
|
||
new file mode 100644
|
||
index 00000000..7410450f
|
||
--- /dev/null
|
||
+++ b/tools/cli/installers/lib/core/manifest.js
|
||
@@ -0,0 +1,536 @@
|
||
+const path = require('node:path');
|
||
+const fs = require('fs-extra');
|
||
+const crypto = require('node:crypto');
|
||
+
|
||
+class Manifest {
|
||
+ /**
|
||
+ * Create a new manifest
|
||
+ * @param {string} bmadDir - Path to bmad directory
|
||
+ * @param {Object} data - Manifest data
|
||
+ * @param {Array} installedFiles - List of installed files (no longer used, files tracked in files-manifest.csv)
|
||
+ */
|
||
+ async create(bmadDir, data, installedFiles = []) {
|
||
+ const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||
+ const yaml = require('js-yaml');
|
||
+
|
||
+ // Ensure _cfg directory exists
|
||
+ await fs.ensureDir(path.dirname(manifestPath));
|
||
+
|
||
+ // Structure the manifest data
|
||
+ const manifestData = {
|
||
+ installation: {
|
||
+ version: data.version || require(path.join(process.cwd(), 'package.json')).version,
|
||
+ installDate: data.installDate || new Date().toISOString(),
|
||
+ lastUpdated: data.lastUpdated || new Date().toISOString(),
|
||
+ },
|
||
+ modules: data.modules || [],
|
||
+ ides: data.ides || [],
|
||
+ };
|
||
+
|
||
+ // Write YAML manifest
|
||
+ const yamlContent = yaml.dump(manifestData, {
|
||
+ indent: 2,
|
||
+ lineWidth: -1,
|
||
+ noRefs: true,
|
||
+ sortKeys: false,
|
||
+ });
|
||
+
|
||
+ await fs.writeFile(manifestPath, yamlContent, 'utf8');
|
||
+ return { success: true, path: manifestPath, filesTracked: 0 };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Read existing manifest
|
||
+ * @param {string} bmadDir - Path to bmad directory
|
||
+ * @returns {Object|null} Manifest data or null if not found
|
||
+ */
|
||
+ async read(bmadDir) {
|
||
+ const yamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||
+ const yaml = require('js-yaml');
|
||
+
|
||
+ if (await fs.pathExists(yamlPath)) {
|
||
+ try {
|
||
+ const content = await fs.readFile(yamlPath, 'utf8');
|
||
+ const manifestData = yaml.load(content);
|
||
+
|
||
+ // Flatten the structure for compatibility with existing code
|
||
+ return {
|
||
+ version: manifestData.installation?.version,
|
||
+ installDate: manifestData.installation?.installDate,
|
||
+ lastUpdated: manifestData.installation?.lastUpdated,
|
||
+ modules: manifestData.modules || [],
|
||
+ ides: manifestData.ides || [],
|
||
+ };
|
||
+ } catch (error) {
|
||
+ console.error('Failed to read YAML manifest:', error.message);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return null;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Update existing manifest
|
||
+ * @param {string} bmadDir - Path to bmad directory
|
||
+ * @param {Object} updates - Fields to update
|
||
+ * @param {Array} installedFiles - Updated list of installed files
|
||
+ */
|
||
+ async update(bmadDir, updates, installedFiles = null) {
|
||
+ const yaml = require('js-yaml');
|
||
+ const manifest = (await this.read(bmadDir)) || {};
|
||
+
|
||
+ // Merge updates
|
||
+ Object.assign(manifest, updates);
|
||
+ manifest.lastUpdated = new Date().toISOString();
|
||
+
|
||
+ // Convert back to structured format for YAML
|
||
+ const manifestData = {
|
||
+ installation: {
|
||
+ version: manifest.version,
|
||
+ installDate: manifest.installDate,
|
||
+ lastUpdated: manifest.lastUpdated,
|
||
+ },
|
||
+ modules: manifest.modules || [],
|
||
+ ides: manifest.ides || [],
|
||
+ };
|
||
+
|
||
+ const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||
+ await fs.ensureDir(path.dirname(manifestPath));
|
||
+
|
||
+ const yamlContent = yaml.dump(manifestData, {
|
||
+ indent: 2,
|
||
+ lineWidth: -1,
|
||
+ noRefs: true,
|
||
+ sortKeys: false,
|
||
+ });
|
||
+
|
||
+ await fs.writeFile(manifestPath, yamlContent, 'utf8');
|
||
+
|
||
+ return manifest;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Add a module to the manifest
|
||
+ * @param {string} bmadDir - Path to bmad directory
|
||
+ * @param {string} moduleName - Module name to add
|
||
+ */
|
||
+ async addModule(bmadDir, moduleName) {
|
||
+ const manifest = await this.read(bmadDir);
|
||
+ if (!manifest) {
|
||
+ throw new Error('No manifest found');
|
||
+ }
|
||
+
|
||
+ if (!manifest.modules) {
|
||
+ manifest.modules = [];
|
||
+ }
|
||
+
|
||
+ if (!manifest.modules.includes(moduleName)) {
|
||
+ manifest.modules.push(moduleName);
|
||
+ await this.update(bmadDir, { modules: manifest.modules });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Remove a module from the manifest
|
||
+ * @param {string} bmadDir - Path to bmad directory
|
||
+ * @param {string} moduleName - Module name to remove
|
||
+ */
|
||
+ async removeModule(bmadDir, moduleName) {
|
||
+ const manifest = await this.read(bmadDir);
|
||
+ if (!manifest || !manifest.modules) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ const index = manifest.modules.indexOf(moduleName);
|
||
+ if (index !== -1) {
|
||
+ manifest.modules.splice(index, 1);
|
||
+ await this.update(bmadDir, { modules: manifest.modules });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Add an IDE configuration to the manifest
|
||
+ * @param {string} bmadDir - Path to bmad directory
|
||
+ * @param {string} ideName - IDE name to add
|
||
+ */
|
||
+ async addIde(bmadDir, ideName) {
|
||
+ const manifest = await this.read(bmadDir);
|
||
+ if (!manifest) {
|
||
+ throw new Error('No manifest found');
|
||
+ }
|
||
+
|
||
+ if (!manifest.ides) {
|
||
+ manifest.ides = [];
|
||
+ }
|
||
+
|
||
+ if (!manifest.ides.includes(ideName)) {
|
||
+ manifest.ides.push(ideName);
|
||
+ await this.update(bmadDir, { ides: manifest.ides });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Calculate SHA256 hash of a file
|
||
+ * @param {string} filePath - Path to file
|
||
+ * @returns {string} SHA256 hash
|
||
+ */
|
||
+ async calculateFileHash(filePath) {
|
||
+ try {
|
||
+ const content = await fs.readFile(filePath);
|
||
+ return crypto.createHash('sha256').update(content).digest('hex');
|
||
+ } catch {
|
||
+ return null;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Parse installed files to extract metadata
|
||
+ * @param {Array} installedFiles - List of installed file paths
|
||
+ * @param {string} bmadDir - Path to bmad directory for relative paths
|
||
+ * @returns {Array} Array of file metadata objects
|
||
+ */
|
||
+ async parseInstalledFiles(installedFiles, bmadDir) {
|
||
+ const fileMetadata = [];
|
||
+
|
||
+ for (const filePath of installedFiles) {
|
||
+ const fileExt = path.extname(filePath).toLowerCase();
|
||
+ // Make path relative to parent of bmad directory, starting with 'bmad/'
|
||
+ const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/');
|
||
+
|
||
+ // Calculate file hash
|
||
+ const hash = await this.calculateFileHash(filePath);
|
||
+
|
||
+ // Handle markdown files - extract XML metadata if present
|
||
+ if (fileExt === '.md') {
|
||
+ try {
|
||
+ if (await fs.pathExists(filePath)) {
|
||
+ const content = await fs.readFile(filePath, 'utf8');
|
||
+ const metadata = this.extractXmlNodeAttributes(content, filePath, relativePath);
|
||
+
|
||
+ if (metadata) {
|
||
+ // Has XML metadata
|
||
+ metadata.hash = hash;
|
||
+ fileMetadata.push(metadata);
|
||
+ } else {
|
||
+ // No XML metadata - still track the file
|
||
+ fileMetadata.push({
|
||
+ file: relativePath,
|
||
+ type: 'md',
|
||
+ name: path.basename(filePath, fileExt),
|
||
+ title: null,
|
||
+ hash: hash,
|
||
+ });
|
||
+ }
|
||
+ }
|
||
+ } catch (error) {
|
||
+ console.warn(`Warning: Could not parse ${filePath}:`, error.message);
|
||
+ }
|
||
+ }
|
||
+ // Handle other file types (CSV, JSON, YAML, etc.)
|
||
+ else {
|
||
+ fileMetadata.push({
|
||
+ file: relativePath,
|
||
+ type: fileExt.slice(1), // Remove the dot
|
||
+ name: path.basename(filePath, fileExt),
|
||
+ title: null,
|
||
+ hash: hash,
|
||
+ });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return fileMetadata;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Extract XML node attributes from MD file content
|
||
+ * @param {string} content - File content
|
||
+ * @param {string} filePath - File path for context
|
||
+ * @param {string} relativePath - Relative path starting with 'bmad/'
|
||
+ * @returns {Object|null} Extracted metadata or null
|
||
+ */
|
||
+ extractXmlNodeAttributes(content, filePath, relativePath) {
|
||
+ // Look for XML blocks in code fences
|
||
+ const xmlBlockMatch = content.match(/```xml\s*([\s\S]*?)```/);
|
||
+ if (!xmlBlockMatch) {
|
||
+ return null;
|
||
+ }
|
||
+
|
||
+ const xmlContent = xmlBlockMatch[1];
|
||
+
|
||
+ // Extract root XML node (agent, task, template, etc.)
|
||
+ const rootNodeMatch = xmlContent.match(/<(\w+)([^>]*)>/);
|
||
+ if (!rootNodeMatch) {
|
||
+ return null;
|
||
+ }
|
||
+
|
||
+ const nodeType = rootNodeMatch[1];
|
||
+ const attributes = rootNodeMatch[2];
|
||
+
|
||
+ // Extract name and title attributes (id not needed since we have path)
|
||
+ const nameMatch = attributes.match(/name="([^"]*)"/);
|
||
+ const titleMatch = attributes.match(/title="([^"]*)"/);
|
||
+
|
||
+ return {
|
||
+ file: relativePath,
|
||
+ type: nodeType,
|
||
+ name: nameMatch ? nameMatch[1] : null,
|
||
+ title: titleMatch ? titleMatch[1] : null,
|
||
+ };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Generate CSV manifest content
|
||
+ * @param {Object} data - Manifest data
|
||
+ * @param {Array} fileMetadata - File metadata array
|
||
+ * @param {Object} moduleConfigs - Module configuration data
|
||
+ * @returns {string} CSV content
|
||
+ */
|
||
+ generateManifestCsv(data, fileMetadata, moduleConfigs = {}) {
|
||
+ const timestamp = new Date().toISOString();
|
||
+ let csv = [];
|
||
+
|
||
+ // Header section
|
||
+ csv.push(
|
||
+ '# BMAD Manifest',
|
||
+ `# Generated: ${timestamp}`,
|
||
+ '',
|
||
+ '## Installation Info',
|
||
+ 'Property,Value',
|
||
+ `Version,${data.version}`,
|
||
+ `InstallDate,${data.installDate || timestamp}`,
|
||
+ `LastUpdated,${data.lastUpdated || timestamp}`,
|
||
+ );
|
||
+ if (data.language) {
|
||
+ csv.push(`Language,${data.language}`);
|
||
+ }
|
||
+ csv.push('');
|
||
+
|
||
+ // Modules section
|
||
+ if (data.modules && data.modules.length > 0) {
|
||
+ csv.push('## Modules', 'Name,Version,ShortTitle');
|
||
+ for (const moduleName of data.modules) {
|
||
+ const config = moduleConfigs[moduleName] || {};
|
||
+ csv.push([moduleName, config.version || '', config['short-title'] || ''].map((v) => this.escapeCsv(v)).join(','));
|
||
+ }
|
||
+ csv.push('');
|
||
+ }
|
||
+
|
||
+ // IDEs section
|
||
+ if (data.ides && data.ides.length > 0) {
|
||
+ csv.push('## IDEs', 'IDE');
|
||
+ for (const ide of data.ides) {
|
||
+ csv.push(this.escapeCsv(ide));
|
||
+ }
|
||
+ csv.push('');
|
||
+ }
|
||
+
|
||
+ // Files section - NO LONGER USED
|
||
+ // Files are now tracked in files-manifest.csv by ManifestGenerator
|
||
+
|
||
+ return csv.join('\n');
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Parse CSV manifest content back to object
|
||
+ * @param {string} csvContent - CSV content to parse
|
||
+ * @returns {Object} Parsed manifest data
|
||
+ */
|
||
+ parseManifestCsv(csvContent) {
|
||
+ const result = {
|
||
+ modules: [],
|
||
+ ides: [],
|
||
+ files: [],
|
||
+ };
|
||
+
|
||
+ const lines = csvContent.split('\n');
|
||
+ let section = '';
|
||
+
|
||
+ for (const line_ of lines) {
|
||
+ const line = line_.trim();
|
||
+
|
||
+ // Skip empty lines and comments
|
||
+ if (!line || line.startsWith('#')) {
|
||
+ // Check for section headers
|
||
+ if (line.startsWith('## ')) {
|
||
+ section = line.slice(3).toLowerCase();
|
||
+ }
|
||
+ continue;
|
||
+ }
|
||
+
|
||
+ // Parse based on current section
|
||
+ switch (section) {
|
||
+ case 'installation info': {
|
||
+ // Skip header row
|
||
+ if (line === 'Property,Value') continue;
|
||
+
|
||
+ const [property, ...valueParts] = line.split(',');
|
||
+ const value = this.unescapeCsv(valueParts.join(','));
|
||
+
|
||
+ switch (property) {
|
||
+ // Path no longer stored in manifest
|
||
+ case 'Version': {
|
||
+ result.version = value;
|
||
+ break;
|
||
+ }
|
||
+ case 'InstallDate': {
|
||
+ result.installDate = value;
|
||
+ break;
|
||
+ }
|
||
+ case 'LastUpdated': {
|
||
+ result.lastUpdated = value;
|
||
+ break;
|
||
+ }
|
||
+ case 'Language': {
|
||
+ result.language = value;
|
||
+ break;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ break;
|
||
+ }
|
||
+ case 'modules': {
|
||
+ // Skip header row
|
||
+ if (line === 'Name,Version,ShortTitle') continue;
|
||
+
|
||
+ const parts = this.parseCsvLine(line);
|
||
+ if (parts[0]) {
|
||
+ result.modules.push(parts[0]);
|
||
+ }
|
||
+
|
||
+ break;
|
||
+ }
|
||
+ case 'ides': {
|
||
+ // Skip header row
|
||
+ if (line === 'IDE') continue;
|
||
+
|
||
+ result.ides.push(this.unescapeCsv(line));
|
||
+
|
||
+ break;
|
||
+ }
|
||
+ case 'files': {
|
||
+ // Skip header rows (support both old and new format)
|
||
+ if (line === 'Type,Path,Name,Title' || line === 'Type,Path,Name,Title,Hash') continue;
|
||
+
|
||
+ const parts = this.parseCsvLine(line);
|
||
+ if (parts.length >= 2) {
|
||
+ result.files.push({
|
||
+ type: parts[0] || '',
|
||
+ file: parts[1] || '',
|
||
+ name: parts[2] || null,
|
||
+ title: parts[3] || null,
|
||
+ hash: parts[4] || null, // Hash column (may not exist in old manifests)
|
||
+ });
|
||
+ }
|
||
+
|
||
+ break;
|
||
+ }
|
||
+ // No default
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return result;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Parse a CSV line handling quotes and commas
|
||
+ * @param {string} line - CSV line to parse
|
||
+ * @returns {Array} Array of values
|
||
+ */
|
||
+ parseCsvLine(line) {
|
||
+ const result = [];
|
||
+ let current = '';
|
||
+ let inQuotes = false;
|
||
+
|
||
+ for (let i = 0; i < line.length; i++) {
|
||
+ const char = line[i];
|
||
+
|
||
+ if (char === '"') {
|
||
+ if (inQuotes && line[i + 1] === '"') {
|
||
+ // Escaped quote
|
||
+ current += '"';
|
||
+ i++;
|
||
+ } else {
|
||
+ // Toggle quote state
|
||
+ inQuotes = !inQuotes;
|
||
+ }
|
||
+ } else if (char === ',' && !inQuotes) {
|
||
+ // Field separator
|
||
+ result.push(this.unescapeCsv(current));
|
||
+ current = '';
|
||
+ } else {
|
||
+ current += char;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Add the last field
|
||
+ result.push(this.unescapeCsv(current));
|
||
+
|
||
+ return result;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Escape CSV special characters
|
||
+ * @param {string} text - Text to escape
|
||
+ * @returns {string} Escaped text
|
||
+ */
|
||
+ escapeCsv(text) {
|
||
+ if (!text) return '';
|
||
+ const str = String(text);
|
||
+
|
||
+ // If contains comma, newline, or quote, wrap in quotes and escape quotes
|
||
+ if (str.includes(',') || str.includes('\n') || str.includes('"')) {
|
||
+ return '"' + str.replaceAll('"', '""') + '"';
|
||
+ }
|
||
+
|
||
+ return str;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Unescape CSV field
|
||
+ * @param {string} text - Text to unescape
|
||
+ * @returns {string} Unescaped text
|
||
+ */
|
||
+ unescapeCsv(text) {
|
||
+ if (!text) return '';
|
||
+
|
||
+ // Remove surrounding quotes if present
|
||
+ if (text.startsWith('"') && text.endsWith('"')) {
|
||
+ text = text.slice(1, -1);
|
||
+ // Unescape doubled quotes
|
||
+ text = text.replaceAll('""', '"');
|
||
+ }
|
||
+
|
||
+ return text;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Load module configuration files
|
||
+ * @param {Array} modules - List of module names
|
||
+ * @returns {Object} Module configurations indexed by name
|
||
+ */
|
||
+ async loadModuleConfigs(modules) {
|
||
+ const configs = {};
|
||
+
|
||
+ for (const moduleName of modules) {
|
||
+ // Handle core module differently - it's in src/core not src/modules/core
|
||
+ const configPath =
|
||
+ moduleName === 'core'
|
||
+ ? path.join(process.cwd(), 'src', 'core', 'config.yaml')
|
||
+ : path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml');
|
||
+
|
||
+ try {
|
||
+ if (await fs.pathExists(configPath)) {
|
||
+ const yaml = require('js-yaml');
|
||
+ const content = await fs.readFile(configPath, 'utf8');
|
||
+ configs[moduleName] = yaml.load(content);
|
||
+ }
|
||
+ } catch (error) {
|
||
+ console.warn(`Could not load config for module ${moduleName}:`, error.message);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return configs;
|
||
+ }
|
||
+}
|
||
+
|
||
+module.exports = { Manifest };
|
||
diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js
|
||
new file mode 100644
|
||
index 00000000..de576aa0
|
||
--- /dev/null
|
||
+++ b/tools/cli/lib/ui.js
|
||
@@ -0,0 +1,546 @@
|
||
+const chalk = require('chalk');
|
||
+const inquirer = require('inquirer');
|
||
+const path = require('node:path');
|
||
+const os = require('node:os');
|
||
+const fs = require('fs-extra');
|
||
+const { CLIUtils } = require('./cli-utils');
|
||
+
|
||
+/**
|
||
+ * UI utilities for the installer
|
||
+ */
|
||
+class UI {
|
||
+ constructor() {}
|
||
+
|
||
+ /**
|
||
+ * Prompt for installation configuration
|
||
+ * @returns {Object} Installation configuration
|
||
+ */
|
||
+ async promptInstall() {
|
||
+ CLIUtils.displayLogo();
|
||
+ CLIUtils.displaySection('BMADÔäó Setup', 'Build More, Architect Dreams');
|
||
+
|
||
+ const confirmedDirectory = await this.getConfirmedDirectory();
|
||
+
|
||
+ // Check if there's an existing BMAD installation
|
||
+ const fs = require('fs-extra');
|
||
+ const path = require('node:path');
|
||
+ const bmadDir = path.join(confirmedDirectory, 'bmad');
|
||
+ const hasExistingInstall = await fs.pathExists(bmadDir);
|
||
+
|
||
+ // Only show action menu if there's an existing installation
|
||
+ if (hasExistingInstall) {
|
||
+ const { actionType } = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'list',
|
||
+ name: 'actionType',
|
||
+ message: 'What would you like to do?',
|
||
+ choices: [
|
||
+ { name: 'Update BMAD Installation', value: 'install' },
|
||
+ { name: 'Compile Agents (Quick rebuild of all agent .md files)', value: 'compile' },
|
||
+ ],
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ // Handle agent compilation separately
|
||
+ if (actionType === 'compile') {
|
||
+ return {
|
||
+ actionType: 'compile',
|
||
+ directory: confirmedDirectory,
|
||
+ };
|
||
+ }
|
||
+ }
|
||
+ const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
|
||
+ const coreConfig = await this.collectCoreConfig(confirmedDirectory);
|
||
+ const moduleChoices = await this.getModuleChoices(installedModuleIds);
|
||
+ const selectedModules = await this.selectModules(moduleChoices);
|
||
+
|
||
+ console.clear();
|
||
+ CLIUtils.displayLogo();
|
||
+ CLIUtils.displayModuleComplete('core', false); // false = don't clear the screen again
|
||
+
|
||
+ return {
|
||
+ actionType: 'install', // Explicitly set action type
|
||
+ directory: confirmedDirectory,
|
||
+ installCore: true, // Always install core
|
||
+ modules: selectedModules,
|
||
+ // IDE selection moved to after module configuration
|
||
+ ides: [],
|
||
+ skipIde: true, // Will be handled later
|
||
+ coreConfig: coreConfig, // Pass collected core config to installer
|
||
+ };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Prompt for tool/IDE selection (called after module configuration)
|
||
+ * @param {string} projectDir - Project directory to check for existing IDEs
|
||
+ * @param {Array} selectedModules - Selected modules from configuration
|
||
+ * @returns {Object} Tool configuration
|
||
+ */
|
||
+ async promptToolSelection(projectDir, selectedModules) {
|
||
+ // Check for existing configured IDEs
|
||
+ const { Detector } = require('../installers/lib/core/detector');
|
||
+ const detector = new Detector();
|
||
+ const bmadDir = path.join(projectDir || process.cwd(), 'bmad');
|
||
+ const existingInstall = await detector.detect(bmadDir);
|
||
+ const configuredIdes = existingInstall.ides || [];
|
||
+
|
||
+ // Get IDE manager to fetch available IDEs dynamically
|
||
+ const { IdeManager } = require('../installers/lib/ide/manager');
|
||
+ const ideManager = new IdeManager();
|
||
+
|
||
+ const preferredIdes = ideManager.getPreferredIdes();
|
||
+ const otherIdes = ideManager.getOtherIdes();
|
||
+
|
||
+ // Build IDE choices array with separators
|
||
+ const ideChoices = [];
|
||
+ const processedIdes = new Set();
|
||
+
|
||
+ // First, add previously configured IDEs at the top, marked with 
|
||
+ if (configuredIdes.length > 0) {
|
||
+ ideChoices.push(new inquirer.Separator('ÔöÇÔöÇ Previously Configured ÔöÇÔöÇ'));
|
||
+ for (const ideValue of configuredIdes) {
|
||
+ // Find the IDE in either preferred or other lists
|
||
+ const preferredIde = preferredIdes.find((ide) => ide.value === ideValue);
|
||
+ const otherIde = otherIdes.find((ide) => ide.value === ideValue);
|
||
+ const ide = preferredIde || otherIde;
|
||
+
|
||
+ if (ide) {
|
||
+ ideChoices.push({
|
||
+ name: `${ide.name} `,
|
||
+ value: ide.value,
|
||
+ checked: true, // Previously configured IDEs are checked by default
|
||
+ });
|
||
+ processedIdes.add(ide.value);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Add preferred tools (excluding already processed)
|
||
+ const remainingPreferred = preferredIdes.filter((ide) => !processedIdes.has(ide.value));
|
||
+ if (remainingPreferred.length > 0) {
|
||
+ ideChoices.push(new inquirer.Separator('ÔöÇÔöÇ Recommended Tools ÔöÇÔöÇ'));
|
||
+ for (const ide of remainingPreferred) {
|
||
+ ideChoices.push({
|
||
+ name: `${ide.name} Ô¡É`,
|
||
+ value: ide.value,
|
||
+ checked: false,
|
||
+ });
|
||
+ processedIdes.add(ide.value);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Add other tools (excluding already processed)
|
||
+ const remainingOther = otherIdes.filter((ide) => !processedIdes.has(ide.value));
|
||
+ if (remainingOther.length > 0) {
|
||
+ ideChoices.push(new inquirer.Separator('ÔöÇÔöÇ Additional Tools ÔöÇÔöÇ'));
|
||
+ for (const ide of remainingOther) {
|
||
+ ideChoices.push({
|
||
+ name: ide.name,
|
||
+ value: ide.value,
|
||
+ checked: false,
|
||
+ });
|
||
+ }
|
||
+ }
|
||
+
|
||
+ CLIUtils.displaySection('Tool Integration', 'Select AI coding assistants and IDEs to configure');
|
||
+
|
||
+ const answers = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'checkbox',
|
||
+ name: 'ides',
|
||
+ message: 'Select tools to configure:',
|
||
+ choices: ideChoices,
|
||
+ pageSize: 15,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ return {
|
||
+ ides: answers.ides || [],
|
||
+ skipIde: !answers.ides || answers.ides.length === 0,
|
||
+ };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Prompt for update configuration
|
||
+ * @returns {Object} Update configuration
|
||
+ */
|
||
+ async promptUpdate() {
|
||
+ const answers = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'confirm',
|
||
+ name: 'backupFirst',
|
||
+ message: 'Create backup before updating?',
|
||
+ default: true,
|
||
+ },
|
||
+ {
|
||
+ type: 'confirm',
|
||
+ name: 'preserveCustomizations',
|
||
+ message: 'Preserve local customizations?',
|
||
+ default: true,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ return answers;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Prompt for module selection
|
||
+ * @param {Array} modules - Available modules
|
||
+ * @returns {Array} Selected modules
|
||
+ */
|
||
+ async promptModules(modules) {
|
||
+ const choices = modules.map((mod) => ({
|
||
+ name: `${mod.name} - ${mod.description}`,
|
||
+ value: mod.id,
|
||
+ checked: false,
|
||
+ }));
|
||
+
|
||
+ const { selectedModules } = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'checkbox',
|
||
+ name: 'selectedModules',
|
||
+ message: 'Select modules to add:',
|
||
+ choices,
|
||
+ validate: (answer) => {
|
||
+ if (answer.length === 0) {
|
||
+ return 'You must choose at least one module.';
|
||
+ }
|
||
+ return true;
|
||
+ },
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ return selectedModules;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Confirm action
|
||
+ * @param {string} message - Confirmation message
|
||
+ * @param {boolean} defaultValue - Default value
|
||
+ * @returns {boolean} User confirmation
|
||
+ */
|
||
+ async confirm(message, defaultValue = false) {
|
||
+ const { confirmed } = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'confirm',
|
||
+ name: 'confirmed',
|
||
+ message,
|
||
+ default: defaultValue,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ return confirmed;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Display installation summary
|
||
+ * @param {Object} result - Installation result
|
||
+ */
|
||
+ showInstallSummary(result) {
|
||
+ CLIUtils.displaySection('Installation Complete', 'BMADÔäó has been successfully installed');
|
||
+
|
||
+ const summary = [
|
||
+ `ƒôü Installation Path: ${result.path}`,
|
||
+ `ƒôª Modules Installed: ${result.modules?.length > 0 ? result.modules.join(', ') : 'core only'}`,
|
||
+ `ƒöº Tools Configured: ${result.ides?.length > 0 ? result.ides.join(', ') : 'none'}`,
|
||
+ ];
|
||
+
|
||
+ CLIUtils.displayBox(summary.join('\n\n'), {
|
||
+ borderColor: 'green',
|
||
+ borderStyle: 'round',
|
||
+ });
|
||
+
|
||
+ console.log('\n' + chalk.green.bold('Ô£¿ BMAD is ready to use!'));
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Get confirmed directory from user
|
||
+ * @returns {string} Confirmed directory path
|
||
+ */
|
||
+ async getConfirmedDirectory() {
|
||
+ let confirmedDirectory = null;
|
||
+ while (!confirmedDirectory) {
|
||
+ const directoryAnswer = await this.promptForDirectory();
|
||
+ await this.displayDirectoryInfo(directoryAnswer.directory);
|
||
+
|
||
+ if (await this.confirmDirectory(directoryAnswer.directory)) {
|
||
+ confirmedDirectory = directoryAnswer.directory;
|
||
+ }
|
||
+ }
|
||
+ return confirmedDirectory;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Get existing installation info and installed modules
|
||
+ * @param {string} directory - Installation directory
|
||
+ * @returns {Object} Object with existingInstall and installedModuleIds
|
||
+ */
|
||
+ async getExistingInstallation(directory) {
|
||
+ const { Detector } = require('../installers/lib/core/detector');
|
||
+ const detector = new Detector();
|
||
+ const bmadDir = path.join(directory, 'bmad');
|
||
+ const existingInstall = await detector.detect(bmadDir);
|
||
+ const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id));
|
||
+
|
||
+ return { existingInstall, installedModuleIds };
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Collect core configuration
|
||
+ * @param {string} directory - Installation directory
|
||
+ * @returns {Object} Core configuration
|
||
+ */
|
||
+ async collectCoreConfig(directory) {
|
||
+ const { ConfigCollector } = require('../installers/lib/core/config-collector');
|
||
+ const configCollector = new ConfigCollector();
|
||
+ // Load existing configs first if they exist
|
||
+ await configCollector.loadExistingConfig(directory);
|
||
+ // Now collect with existing values as defaults (false = don't skip loading, true = skip completion message)
|
||
+ await configCollector.collectModuleConfig('core', directory, false, true);
|
||
+
|
||
+ return configCollector.collectedConfig.core;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Get module choices for selection
|
||
+ * @param {Set} installedModuleIds - Currently installed module IDs
|
||
+ * @returns {Array} Module choices for inquirer
|
||
+ */
|
||
+ async getModuleChoices(installedModuleIds) {
|
||
+ const { ModuleManager } = require('../installers/lib/modules/manager');
|
||
+ const moduleManager = new ModuleManager();
|
||
+ const availableModules = await moduleManager.listAvailable();
|
||
+
|
||
+ const isNewInstallation = installedModuleIds.size === 0;
|
||
+ return availableModules.map((mod) => ({
|
||
+ name: mod.name,
|
||
+ value: mod.id,
|
||
+ checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
|
||
+ }));
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Prompt for module selection
|
||
+ * @param {Array} moduleChoices - Available module choices
|
||
+ * @returns {Array} Selected module IDs
|
||
+ */
|
||
+ async selectModules(moduleChoices) {
|
||
+ CLIUtils.displaySection('Module Selection', 'Choose the BMAD modules to install');
|
||
+
|
||
+ const moduleAnswer = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'checkbox',
|
||
+ name: 'modules',
|
||
+ message: 'Select modules to install:',
|
||
+ choices: moduleChoices,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ return moduleAnswer.modules || [];
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Prompt for directory selection
|
||
+ * @returns {Object} Directory answer from inquirer
|
||
+ */
|
||
+ async promptForDirectory() {
|
||
+ return await inquirer.prompt([
|
||
+ {
|
||
+ type: 'input',
|
||
+ name: 'directory',
|
||
+ message: `Installation directory:`,
|
||
+ default: process.cwd(),
|
||
+ validate: async (input) => this.validateDirectory(input),
|
||
+ filter: (input) => {
|
||
+ // If empty, use the default
|
||
+ if (!input || input.trim() === '') {
|
||
+ return process.cwd();
|
||
+ }
|
||
+ return this.expandUserPath(input);
|
||
+ },
|
||
+ },
|
||
+ ]);
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Display directory information
|
||
+ * @param {string} directory - The directory path
|
||
+ */
|
||
+ async displayDirectoryInfo(directory) {
|
||
+ console.log(chalk.cyan('\nResolved installation path:'), chalk.bold(directory));
|
||
+
|
||
+ const dirExists = await fs.pathExists(directory);
|
||
+ if (dirExists) {
|
||
+ // Show helpful context about the existing path
|
||
+ const stats = await fs.stat(directory);
|
||
+ if (stats.isDirectory()) {
|
||
+ const files = await fs.readdir(directory);
|
||
+ if (files.length > 0) {
|
||
+ console.log(
|
||
+ chalk.gray(`Directory exists and contains ${files.length} item(s)`) +
|
||
+ (files.includes('bmad') ? chalk.yellow(' including existing bmad installation') : ''),
|
||
+ );
|
||
+ } else {
|
||
+ console.log(chalk.gray('Directory exists and is empty'));
|
||
+ }
|
||
+ }
|
||
+ } else {
|
||
+ const existingParent = await this.findExistingParent(directory);
|
||
+ console.log(chalk.gray(`Will create in: ${existingParent}`));
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Confirm directory selection
|
||
+ * @param {string} directory - The directory path
|
||
+ * @returns {boolean} Whether user confirmed
|
||
+ */
|
||
+ async confirmDirectory(directory) {
|
||
+ const dirExists = await fs.pathExists(directory);
|
||
+
|
||
+ if (dirExists) {
|
||
+ const confirmAnswer = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'confirm',
|
||
+ name: 'proceed',
|
||
+ message: `Install to this directory?`,
|
||
+ default: true,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ if (!confirmAnswer.proceed) {
|
||
+ console.log(chalk.yellow("\nLet's try again with a different path.\n"));
|
||
+ }
|
||
+
|
||
+ return confirmAnswer.proceed;
|
||
+ } else {
|
||
+ // Ask for confirmation to create the directory
|
||
+ const createConfirm = await inquirer.prompt([
|
||
+ {
|
||
+ type: 'confirm',
|
||
+ name: 'create',
|
||
+ message: `The directory '${directory}' doesn't exist. Would you like to create it?`,
|
||
+ default: false,
|
||
+ },
|
||
+ ]);
|
||
+
|
||
+ if (!createConfirm.create) {
|
||
+ console.log(chalk.yellow("\nLet's try again with a different path.\n"));
|
||
+ }
|
||
+
|
||
+ return createConfirm.create;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Validate directory path for installation
|
||
+ * @param {string} input - User input path
|
||
+ * @returns {string|true} Error message or true if valid
|
||
+ */
|
||
+ async validateDirectory(input) {
|
||
+ // Allow empty input to use the default
|
||
+ if (!input || input.trim() === '') {
|
||
+ return true; // Empty means use default
|
||
+ }
|
||
+
|
||
+ let expandedPath;
|
||
+ try {
|
||
+ expandedPath = this.expandUserPath(input.trim());
|
||
+ } catch (error) {
|
||
+ return error.message;
|
||
+ }
|
||
+
|
||
+ // Check if the path exists
|
||
+ const pathExists = await fs.pathExists(expandedPath);
|
||
+
|
||
+ if (!pathExists) {
|
||
+ // Find the first existing parent directory
|
||
+ const existingParent = await this.findExistingParent(expandedPath);
|
||
+
|
||
+ if (!existingParent) {
|
||
+ return 'Cannot create directory: no existing parent directory found';
|
||
+ }
|
||
+
|
||
+ // Check if the existing parent is writable
|
||
+ try {
|
||
+ await fs.access(existingParent, fs.constants.W_OK);
|
||
+ // Path doesn't exist but can be created - will prompt for confirmation later
|
||
+ return true;
|
||
+ } catch {
|
||
+ // Provide a detailed error message explaining both issues
|
||
+ return `Directory '${expandedPath}' does not exist and cannot be created: parent directory '${existingParent}' is not writable`;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // If it exists, validate it's a directory and writable
|
||
+ const stat = await fs.stat(expandedPath);
|
||
+ if (!stat.isDirectory()) {
|
||
+ return `Path exists but is not a directory: ${expandedPath}`;
|
||
+ }
|
||
+
|
||
+ // Check write permissions
|
||
+ try {
|
||
+ await fs.access(expandedPath, fs.constants.W_OK);
|
||
+ } catch {
|
||
+ return `Directory is not writable: ${expandedPath}`;
|
||
+ }
|
||
+
|
||
+ return true;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Find the first existing parent directory
|
||
+ * @param {string} targetPath - The path to check
|
||
+ * @returns {string|null} The first existing parent directory, or null if none found
|
||
+ */
|
||
+ async findExistingParent(targetPath) {
|
||
+ let currentPath = path.resolve(targetPath);
|
||
+
|
||
+ // Walk up the directory tree until we find an existing directory
|
||
+ while (currentPath !== path.dirname(currentPath)) {
|
||
+ // Stop at root
|
||
+ const parent = path.dirname(currentPath);
|
||
+ if (await fs.pathExists(parent)) {
|
||
+ return parent;
|
||
+ }
|
||
+ currentPath = parent;
|
||
+ }
|
||
+
|
||
+ return null; // No existing parent found (shouldn't happen in practice)
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Expands the user-provided path: handles ~ and resolves to absolute.
|
||
+ * @param {string} inputPath - User input path.
|
||
+ * @returns {string} Absolute expanded path.
|
||
+ */
|
||
+ expandUserPath(inputPath) {
|
||
+ if (typeof inputPath !== 'string') {
|
||
+ throw new TypeError('Path must be a string.');
|
||
+ }
|
||
+
|
||
+ let expanded = inputPath.trim();
|
||
+
|
||
+ // Handle tilde expansion
|
||
+ if (expanded.startsWith('~')) {
|
||
+ if (expanded === '~') {
|
||
+ expanded = os.homedir();
|
||
+ } else if (expanded.startsWith('~' + path.sep)) {
|
||
+ const pathAfterHome = expanded.slice(2); // Remove ~/ or ~\
|
||
+ expanded = path.join(os.homedir(), pathAfterHome);
|
||
+ } else {
|
||
+ const restOfPath = expanded.slice(1);
|
||
+ const separatorIndex = restOfPath.indexOf(path.sep);
|
||
+ const username = separatorIndex === -1 ? restOfPath : restOfPath.slice(0, separatorIndex);
|
||
+ if (username) {
|
||
+ throw new Error(`Path expansion for ~${username} is not supported. Please use an absolute path or ~${path.sep}`);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // Resolve to the absolute path relative to the current working directory
|
||
+ return path.resolve(expanded);
|
||
+ }
|
||
+}
|
||
+
|
||
+module.exports = { UI };
|