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

368 lines
12 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);
// Check if already installed
if (await fs.pathExists(wdsDir)) {
console.log(chalk.yellow(`\n ${wdsFolder}/ already exists.`));
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{ name: 'Update - Replace WDS files, keep config.yaml', value: 'update' },
{ name: 'Fresh install - Remove everything and start over', value: 'fresh' },
{ name: 'Cancel', value: 'cancel' },
],
},
]);
if (action === 'cancel') {
return { success: false };
}
if (action === 'fresh') {
const removeSpinner = ora('Removing existing WDS installation...').start();
await fs.remove(wdsDir);
removeSpinner.succeed('Old installation removed');
} else if (action === 'update') {
// Preserve config.yaml during update
const configPath = path.join(wdsDir, 'config.yaml');
let savedConfig = null;
if (await fs.pathExists(configPath)) {
savedConfig = await fs.readFile(configPath, 'utf8');
}
const removeSpinner = ora('Updating WDS files...').start();
await fs.remove(wdsDir);
removeSpinner.succeed('Old files cleared');
// Will be restored after copy
config._savedConfigYaml = savedConfig;
}
}
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 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 (always included)
const learnSpinner = ora('Copying learning & reference material...').start();
try {
await this.copyLearningMaterial(projectDir);
learnSpinner.succeed('Learning material added to _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('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',
starting_point: config.starting_point || 'brief',
communication_language: 'en',
document_output_language: 'en',
output_folder: 'design-process',
wds_folder: config.wdsFolder,
};
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, '_wds-learn');
const learningDirs = ['getting-started', 'learn-wds', '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 5-phase structure
const folders = [
'A-Product-Brief',
'B-Trigger-Map',
'C-UX-Scenarios',
'D-UX-Design',
'E-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-dialogs'));
// Create 00 guide files in each folder (if they don't exist)
await this.createFolderGuides(docsPath, config);
}
/**
* Create 00 guide files in each folder from templates
*/
async createFolderGuides(docsPath, config) {
const templateDir = path.join(this.srcDir, 'workflows', '0-project-setup', 'templates', 'folder-guides');
// Mapping: template filename → destination folder & filename
const guides = [
{ template: '00-product-brief.template.md', folder: 'A-Product-Brief', filename: '00-product-brief.md' },
{ template: '00-trigger-map.template.md', folder: 'B-Trigger-Map', filename: '00-trigger-map.md' },
{ template: '00-ux-scenarios.template.md', folder: 'C-UX-Scenarios', filename: '00-ux-scenarios.md' },
{ template: '00-design-system.template.md', folder: 'E-Design-System', filename: '00-design-system.md' },
];
// Common placeholder replacements
const replacements = {
'{{project_name}}': config.project_name || 'Untitled Project',
'{{date}}': new Date().toISOString().split('T')[0],
'{{starting_point}}': config.starting_point || 'brief',
'{{user_name}}': config.user_name || 'Designer',
'{{communication_language}}': 'en',
'{{document_output_language}}': 'en',
'{{output_folder}}': 'design-process',
'{{wds_folder}}': config.wdsFolder || '_wds',
};
// Create each folder guide
for (const guide of guides) {
const templatePath = path.join(templateDir, guide.template);
const destPath = path.join(docsPath, guide.folder, guide.filename);
// Skip if file exists (never overwrite) or template doesn't exist
if (await fs.pathExists(destPath)) continue;
if (!(await fs.pathExists(templatePath))) continue;
// Read template
let content = await fs.readFile(templatePath, 'utf8');
// Replace all placeholders
for (const [placeholder, value] of Object.entries(replacements)) {
content = content.split(placeholder).join(value);
}
// Write file
await fs.writeFile(destPath, content, 'utf8');
}
// Also create 00-project-info.md in A-Product-Brief (project settings home)
await this.createProjectInfoFile(docsPath, config);
}
/**
* Create 00-project-info.md in A-Product-Brief from template
*/
async createProjectInfoFile(docsPath, config) {
const productBriefPath = path.join(docsPath, 'A-Product-Brief');
const projectInfoPath = path.join(productBriefPath, '00-project-info.md');
// Only create if it doesn't exist (never overwrite)
if (await fs.pathExists(projectInfoPath)) {
return;
}
const templatePath = path.join(this.srcDir, 'workflows', '1-project-brief', 'templates', '00-project-info.template.md');
// Check if template exists
if (!(await fs.pathExists(templatePath))) {
// Skip if template not found (backward compatibility)
return;
}
// Read template
let template = await fs.readFile(templatePath, 'utf8');
// Replace placeholders
const replacements = {
'{{project_name}}': config.project_name || 'Untitled Project',
'{{date}}': new Date().toISOString().split('T')[0],
'{{starting_point}}': config.starting_point || 'brief',
'{{user_name}}': config.user_name || 'Designer',
'{{communication_language}}': 'en',
'{{document_output_language}}': 'en',
'{{output_folder}}': 'design-process',
'{{wds_folder}}': config.wdsFolder || '_wds',
};
for (const [placeholder, value] of Object.entries(replacements)) {
template = template.split(placeholder).join(value);
}
// Write the file
await fs.writeFile(projectInfoPath, template, 'utf8');
}
}
module.exports = { Installer };