BMAD-METHOD/tools/cli/lib/installer.js

317 lines
11 KiB
JavaScript

/**
* WDS Installer - Core orchestrator
* Copies WDS source files, compiles agents, creates folder structure, sets up IDE.
*/
const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const ora = require('ora');
const yaml = require('js-yaml');
const inquirer = require('inquirer').default || require('inquirer');
const { compileAgentFile } = require('./compiler');
class Installer {
constructor() {
// Resolve directories relative to this file (tools/cli/lib/ -> up 3 levels)
const repoRoot = path.resolve(__dirname, '..', '..', '..');
this.srcDir = path.join(repoRoot, 'src');
this.docsDir = path.join(repoRoot, 'docs');
}
/**
* Main installation flow
* @param {Object} config - Configuration from UI prompts
*/
async install(config) {
const { projectDir, wdsFolder, root_folder } = config;
const wdsDir = path.join(projectDir, wdsFolder);
const detection = config._detection || { type: 'fresh' };
const action = config._action || 'fresh';
// Handle legacy _wds/ → _bmad/wds/ migration
if (detection.type === 'legacy' && wdsFolder !== '_wds') {
const legacyDir = path.join(projectDir, '_wds');
const legacyConfigPath = path.join(legacyDir, 'config.yaml');
// Save config from legacy location
if (await fs.pathExists(legacyConfigPath)) {
let savedConfig = await fs.readFile(legacyConfigPath, 'utf8');
// Update wds_folder in saved config to new path
savedConfig = savedConfig.replace(/wds_folder:.*/, `wds_folder: ${wdsFolder}`);
config._savedConfigYaml = savedConfig;
}
const migrateSpinner = ora(`Migrating _wds/ → ${wdsFolder}/...`).start();
await fs.ensureDir(path.dirname(wdsDir));
await fs.remove(legacyDir);
// Also remove legacy _wds-learn/ (will be recreated if learning material is selected)
const legacyLearnDir = path.join(projectDir, '_wds-learn');
if (await fs.pathExists(legacyLearnDir)) {
await fs.remove(legacyLearnDir);
}
migrateSpinner.succeed(`Legacy _wds/ removed — installing fresh at ${wdsFolder}/`);
}
// Handle update vs fresh for existing target path
if (action === 'update' && (await fs.pathExists(wdsDir))) {
// Preserve config.yaml during update
const configPath = path.join(wdsDir, 'config.yaml');
if (!config._savedConfigYaml && (await fs.pathExists(configPath))) {
config._savedConfigYaml = await fs.readFile(configPath, 'utf8');
}
const removeSpinner = ora('Updating WDS files...').start();
await fs.remove(wdsDir);
removeSpinner.succeed('Old files cleared');
} else if (action === 'fresh' && (await fs.pathExists(wdsDir))) {
const removeSpinner = ora('Removing existing WDS installation...').start();
await fs.remove(wdsDir);
removeSpinner.succeed('Old installation removed');
}
// On update, extract ides and root_folder from saved config
if (action === 'update' && config._savedConfigYaml) {
try {
const savedData = yaml.load(config._savedConfigYaml);
if (!config.ides && savedData.ides) config.ides = savedData.ides;
if (!config.root_folder && savedData.output_folder) config.root_folder = savedData.output_folder;
} catch {
/* ignore parse errors, defaults will apply */
}
}
// Ensure parent directory exists (for _bmad/wds/)
await fs.ensureDir(path.dirname(wdsDir));
console.log('');
// Step 1: Copy source files
const spinner = ora('Copying WDS files...').start();
try {
await this.copySrcFiles(wdsDir);
spinner.succeed('WDS files copied');
} catch (error) {
spinner.fail('Failed to copy WDS files');
throw error;
}
// Step 2: Write config.yaml
const configSpinner = ora('Writing configuration...').start();
try {
await this.writeConfig(wdsDir, config);
configSpinner.succeed('Configuration saved');
} catch (error) {
configSpinner.fail('Failed to write configuration');
throw error;
}
// Step 3: Compile agents
const agentSpinner = ora('Compiling agents...').start();
try {
const agents = await this.compileAgents(wdsDir, wdsFolder);
agentSpinner.succeed(`Compiled ${agents.length} agents`);
} catch (error) {
agentSpinner.fail('Failed to compile agents');
throw error;
}
// Step 3.5: Setup IDE integrations
if (config.ides && config.ides.length > 0) {
const ideSpinner = ora('Setting up IDE integrations...').start();
try {
const { IdeManager } = require('../installers/lib/ide/manager');
const ideManager = new IdeManager();
const results = await ideManager.setup(projectDir, wdsDir, config.ides, {
logger: {
log: (msg) => {}, // Suppress detailed logs during spinner
warn: (msg) => console.log(msg),
},
wdsFolderName: wdsFolder,
});
const successCount = results.success.length;
const failedCount = results.failed.length;
const skippedCount = results.skipped.length;
if (successCount > 0) {
ideSpinner.succeed(`IDE integrations configured (${successCount} IDE${successCount > 1 ? 's' : ''})`);
} else if (failedCount > 0 || skippedCount > 0) {
ideSpinner.warn(`IDE setup completed with ${failedCount} failed, ${skippedCount} skipped`);
} else {
ideSpinner.succeed('IDE integrations configured');
}
} catch (error) {
ideSpinner.warn(`IDE setup encountered issues: ${error.message}`);
console.log(chalk.dim(' You can still use WDS by manually activating agents'));
}
}
// Step 4: Create work products folder structure
const rootFolder = root_folder || 'design-process';
const docsSpinner = ora(`Creating project folders in ${rootFolder}/...`).start();
try {
await this.createDocsFolders(projectDir, rootFolder, config);
docsSpinner.succeed(`Project folders created in ${rootFolder}/`);
} catch (error) {
docsSpinner.fail('Failed to create project folders');
throw error;
}
// Step 5: Copy learning & reference material (optional)
if (config.install_learning !== false) {
const learnSpinner = ora('Copying learning & reference material...').start();
try {
await this.copyLearningMaterial(projectDir);
learnSpinner.succeed('Learning material added to _bmad/wds/learn/ (safe to remove when no longer needed)');
} catch (error) {
learnSpinner.fail('Failed to copy learning material');
throw error;
}
}
return { success: true, wdsDir, projectDir };
}
/**
* Copy src/ content into the target WDS directory
*/
async copySrcFiles(wdsDir) {
const contentDirs = ['agents', 'data', 'gems', 'skills', 'workflows'];
for (const dir of contentDirs) {
const src = path.join(this.srcDir, dir);
const dest = path.join(wdsDir, dir);
if (await fs.pathExists(src)) {
await fs.copy(src, dest);
}
}
// Copy module.yaml and module-help.csv
const moduleYaml = path.join(this.srcDir, 'module.yaml');
if (await fs.pathExists(moduleYaml)) {
await fs.copy(moduleYaml, path.join(wdsDir, 'module.yaml'));
}
const moduleHelp = path.join(this.srcDir, 'module-help.csv');
if (await fs.pathExists(moduleHelp)) {
await fs.copy(moduleHelp, path.join(wdsDir, 'module-help.csv'));
}
}
/**
* Write config.yaml from user answers (or restore saved config on update)
*/
async writeConfig(wdsDir, config) {
// On update, restore the user's existing config
if (config._savedConfigYaml) {
await fs.writeFile(path.join(wdsDir, 'config.yaml'), config._savedConfigYaml, 'utf8');
return;
}
// Get user name from git or system
const getUserName = () => {
try {
const { execSync } = require('node:child_process');
const gitName = execSync('git config user.name', { encoding: 'utf8' }).trim();
return gitName || 'Designer';
} catch {
return 'Designer';
}
};
const configData = {
user_name: getUserName(),
project_name: config.project_name || 'Untitled Project',
communication_language: 'en',
document_output_language: 'en',
output_folder: config.root_folder || 'design-process',
wds_folder: config.wdsFolder,
ides: config.ides || ['windsurf'],
};
const yamlStr = yaml.dump(configData, { lineWidth: -1 });
await fs.writeFile(path.join(wdsDir, 'config.yaml'), `# WDS Configuration - Generated by installer\n${yamlStr}`, 'utf8');
}
/**
* Compile all .agent.yaml files in the agents directory
*/
async compileAgents(wdsDir, wdsFolder) {
const agentsDir = path.join(wdsDir, 'agents');
const files = await fs.readdir(agentsDir);
const agentFiles = files.filter((f) => f.endsWith('.agent.yaml'));
const results = [];
for (const file of agentFiles) {
const yamlPath = path.join(agentsDir, file);
const result = compileAgentFile(yamlPath, { wdsFolder });
results.push(result);
}
return results;
}
/**
* Copy learning & reference material into _wds-learn/ at project root.
* Users can safely delete this folder without affecting agents or workflows.
*/
async copyLearningMaterial(projectDir) {
const learnDir = path.join(projectDir, '_bmad/wds/learn');
const learningDirs = ['getting-started', 'learn', 'method', 'models', 'tools'];
const excludeDirs = new Set(['course-explainers', 'Webinars']);
for (const dir of learningDirs) {
const src = path.join(this.docsDir, dir);
const dest = path.join(learnDir, dir);
if (await fs.pathExists(src)) {
await fs.copy(src, dest, {
filter: (srcPath) => {
const relative = path.relative(src, srcPath);
const topDir = relative.split(path.sep)[0];
return !excludeDirs.has(topDir);
},
});
}
}
}
/**
* Create the WDS work products folder structure
* @param {string} projectDir - Project root directory
* @param {string} rootFolder - Root folder name (design-process)
* @param {Object} config - Configuration object
*/
async createDocsFolders(projectDir, rootFolder, config) {
const docsPath = path.join(projectDir, rootFolder);
// Simplified 4-phase structure
const folders = ['A-Product-Brief', 'B-Trigger-Map', 'C-UX-Scenarios', 'D-Design-System'];
for (const folder of folders) {
const folderPath = path.join(docsPath, folder);
// Only create folder if it doesn't exist
if (!(await fs.pathExists(folderPath))) {
await fs.ensureDir(folderPath);
// Add .gitkeep to preserve empty directories (only if folder is empty)
const gitkeepPath = path.join(folderPath, '.gitkeep');
const existingFiles = await fs.readdir(folderPath);
if (existingFiles.length === 0) {
await fs.writeFile(gitkeepPath, '# This file ensures the directory is tracked by git\n');
}
}
}
// Create _progress folder for agent tracking
const progressPath = path.join(docsPath, '_progress');
await fs.ensureDir(progressPath);
await fs.ensureDir(path.join(progressPath, 'agent-experiences'));
}
}
module.exports = { Installer };