444 lines
16 KiB
JavaScript
444 lines
16 KiB
JavaScript
/**
|
|
* BMAD v4 Web Builder
|
|
* Builds optimized web bundles and standalone agents
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const yaml = require('js-yaml');
|
|
const DependencyResolver = require('../lib/dependency-resolver');
|
|
const BundleOptimizer = require('../lib/bundle-optimizer');
|
|
|
|
class WebBuilder {
|
|
constructor(rootPath = process.cwd()) {
|
|
this.rootPath = rootPath;
|
|
this.agentsPath = path.join(rootPath, 'agents');
|
|
this.outputPath = path.join(rootPath, 'dist');
|
|
this.sampleUpdatePath = path.join(rootPath, 'web-bundles');
|
|
this.resolver = new DependencyResolver(rootPath);
|
|
this.optimizer = new BundleOptimizer(rootPath);
|
|
this.sampleUpdateEnabled = false;
|
|
}
|
|
|
|
/**
|
|
* Enable sample update mode to output to web-bundles directory as well
|
|
*/
|
|
enableSampleUpdate() {
|
|
this.sampleUpdateEnabled = true;
|
|
}
|
|
|
|
/**
|
|
* Build all web bundles
|
|
*/
|
|
async buildAll() {
|
|
console.log('🚀 Building all bundles...');
|
|
|
|
const results = {
|
|
teams: [],
|
|
bundles: [],
|
|
agents: [],
|
|
errors: []
|
|
};
|
|
|
|
try {
|
|
// Ensure output directories exist
|
|
this.ensureOutputDirectory();
|
|
if (this.sampleUpdateEnabled) {
|
|
this.ensureSampleUpdateDirectory();
|
|
}
|
|
|
|
// Build orchestrator bundles
|
|
const bundleConfigs = this.loadBundleConfigs();
|
|
for (const config of bundleConfigs) {
|
|
try {
|
|
const result = await this.buildBundle(config);
|
|
const isTeamBundle = config.name.toLowerCase().includes('team');
|
|
if (isTeamBundle) {
|
|
results.teams.push(result);
|
|
} else {
|
|
results.bundles.push(result);
|
|
}
|
|
const bundleType = isTeamBundle ? 'team bundle' : 'bundle';
|
|
console.log(`✅ Built ${bundleType}: ${config.name}`);
|
|
} catch (error) {
|
|
const isTeamBundle = config.name.toLowerCase().includes('team');
|
|
const bundleType = isTeamBundle ? 'team bundle' : 'bundle';
|
|
const errorType = isTeamBundle ? 'team' : 'bundle';
|
|
console.error(`❌ Failed to build ${bundleType} ${config.name}:`, error.message);
|
|
results.errors.push({ type: errorType, name: config.name, error: error.message });
|
|
}
|
|
}
|
|
|
|
// Build all individual agents as standalone bundles
|
|
const availableAgents = this.resolver.getAvailableAgents();
|
|
|
|
// Filter out team bundles and include all non-team agents
|
|
const individualAgents = availableAgents.filter(agentId => {
|
|
// Skip team bundles
|
|
if (agentId.startsWith('team-')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const config = this.resolver.loadAgentConfig(agentId);
|
|
// Build all agents that don't explicitly disable web
|
|
return config.environments?.web?.available !== false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
for (const agentId of individualAgents) {
|
|
try {
|
|
const result = await this.buildStandaloneAgent(agentId);
|
|
results.agents.push(result);
|
|
console.log(`✅ Built agent bundle: ${agentId}`);
|
|
} catch (error) {
|
|
console.error(`❌ Failed to build agent ${agentId}:`, error.message);
|
|
results.errors.push({ type: 'agent', name: agentId, error: error.message });
|
|
}
|
|
}
|
|
|
|
console.log(`\n📊 Build Summary:`);
|
|
console.log(` Teams: ${results.teams.length} built, ${results.errors.filter(e => e.type === 'team').length} failed`);
|
|
if (results.bundles.length > 0 || results.errors.filter(e => e.type === 'bundle').length > 0) {
|
|
console.log(` Bundles: ${results.bundles.length} built, ${results.errors.filter(e => e.type === 'bundle').length} failed`);
|
|
}
|
|
console.log(` Agents: ${results.agents.length} built, ${results.errors.filter(e => e.type === 'agent').length} failed`);
|
|
|
|
return results;
|
|
|
|
} catch (error) {
|
|
console.error('❌ Build failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a specific bundle
|
|
*/
|
|
async buildBundle(bundleConfig) {
|
|
const isTeamBundle = bundleConfig.name.toLowerCase().includes('team');
|
|
const emoji = isTeamBundle ? '👥' : '📦';
|
|
const bundleType = isTeamBundle ? 'team bundle' : 'bundle';
|
|
console.log(`${emoji} Building ${bundleType}: ${bundleConfig.name}`);
|
|
|
|
// Ensure agents is an array of strings
|
|
const agentIds = Array.isArray(bundleConfig.agents) ? bundleConfig.agents : [];
|
|
|
|
// Resolve dependencies
|
|
const agentDependencies = this.resolver.resolveBundleDependencies(
|
|
agentIds,
|
|
bundleConfig.target_environment,
|
|
bundleConfig.optimize !== false
|
|
);
|
|
|
|
// Optimize bundle
|
|
const optimizedBundle = this.optimizer.optimizeBundle(bundleConfig, agentDependencies);
|
|
|
|
// Validate bundle
|
|
const validation = this.optimizer.validateBundle(optimizedBundle, {
|
|
maxBundleSize: bundleConfig.max_bundle_size
|
|
});
|
|
|
|
if (!validation.valid) {
|
|
throw new Error(`Bundle validation failed: ${validation.issues.map(i => i.message).join(', ')}`);
|
|
}
|
|
|
|
// Write output files
|
|
const outputDir = path.join(this.outputPath, 'teams');
|
|
this.ensureDirectory(outputDir);
|
|
|
|
const outputs = [];
|
|
|
|
// Default to single_file format if not specified
|
|
const outputFormat = bundleConfig.output?.format || 'single_file';
|
|
const outputFilename = bundleConfig.output?.filename || bundleConfig.filename || `${bundleConfig.name.toLowerCase().replace(/\s+/g, '-')}.txt`;
|
|
|
|
if (outputFormat === 'single_file') {
|
|
// Create single bundle file
|
|
const bundleContent = this.createBundleContent(optimizedBundle, bundleConfig);
|
|
const bundleFile = path.join(outputDir, outputFilename);
|
|
|
|
fs.writeFileSync(bundleFile, bundleContent);
|
|
outputs.push(bundleFile);
|
|
|
|
// Also write to web-bundles if sample update is enabled
|
|
if (this.sampleUpdateEnabled) {
|
|
const sampleOutputDir = path.join(this.sampleUpdatePath, 'teams');
|
|
this.ensureDirectory(sampleOutputDir);
|
|
const sampleBundleFile = path.join(sampleOutputDir, outputFilename);
|
|
fs.writeFileSync(sampleBundleFile, bundleContent);
|
|
}
|
|
|
|
// For single_file format, everything is in the bundle file
|
|
// No need for separate orchestrator files
|
|
}
|
|
|
|
return {
|
|
name: bundleConfig.name,
|
|
type: 'bundle',
|
|
outputs: outputs,
|
|
statistics: optimizedBundle.statistics,
|
|
validation: validation
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a standalone agent
|
|
*/
|
|
async buildStandaloneAgent(agentId) {
|
|
console.log(`👤 Building standalone agent: ${agentId}`);
|
|
|
|
const optimizedBundle = this.optimizer.createStandaloneAgent(agentId, 'web');
|
|
|
|
// Write standalone agent file
|
|
const outputDir = path.join(this.outputPath, 'agents');
|
|
this.ensureDirectory(outputDir);
|
|
|
|
const agentFile = path.join(outputDir, `${agentId}.txt`);
|
|
fs.writeFileSync(agentFile, optimizedBundle.standaloneContent);
|
|
|
|
// Also write to web-bundles if sample update is enabled
|
|
if (this.sampleUpdateEnabled) {
|
|
const sampleOutputDir = path.join(this.sampleUpdatePath, 'agents');
|
|
this.ensureDirectory(sampleOutputDir);
|
|
const sampleAgentFile = path.join(sampleOutputDir, `${agentId}.txt`);
|
|
fs.writeFileSync(sampleAgentFile, optimizedBundle.standaloneContent);
|
|
}
|
|
|
|
return {
|
|
name: agentId,
|
|
type: 'standalone_agent',
|
|
outputs: [agentFile],
|
|
statistics: optimizedBundle.statistics
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create bundle content for single file output
|
|
*/
|
|
createBundleContent(bundle, config) {
|
|
// For a truly self-contained bundle, start with the orchestrator prompt
|
|
let content = this.createOrchestratorPrompt(bundle, config);
|
|
|
|
content += '\n\n';
|
|
|
|
// Add bundle metadata as a comment
|
|
content += `<!-- Bundle: ${bundle.metadata.name} -->\n`;
|
|
content += `<!-- Generated: ${bundle.metadata.generatedAt} -->\n`;
|
|
content += `<!-- Environment: ${bundle.metadata.environment} -->\n\n`;
|
|
|
|
// Add agent configurations section
|
|
content += `==================== START: agent-config ====================\n`;
|
|
content += yaml.dump({
|
|
name: bundle.metadata.name,
|
|
version: bundle.metadata.version || '1.0.0',
|
|
agents: bundle.agents,
|
|
commands: config.output?.orchestrator_commands || []
|
|
});
|
|
content += `==================== END: agent-config ====================\n\n`;
|
|
|
|
// Add resource sections
|
|
bundle.sections.forEach(section => {
|
|
content += section.content + '\n\n';
|
|
});
|
|
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Create orchestrator files (agent-prompt.txt and agent-config.txt)
|
|
*/
|
|
createOrchestratorFiles(bundle, config) {
|
|
const files = [];
|
|
const outputDir = path.join(this.outputPath, 'teams');
|
|
|
|
// Create agent-config.txt
|
|
const agentConfigContent = yaml.dump({
|
|
name: bundle.metadata.name,
|
|
version: bundle.metadata.version || '1.0.0',
|
|
environment: bundle.metadata.environment,
|
|
agents: bundle.agents,
|
|
commands: config.output?.orchestrator_commands || [],
|
|
metadata: {
|
|
generatedAt: bundle.metadata.generatedAt,
|
|
totalResources: bundle.statistics.totalResources,
|
|
optimization: bundle.metadata.optimization
|
|
}
|
|
});
|
|
|
|
files.push({
|
|
path: path.join(outputDir, 'agent-config.txt'),
|
|
content: agentConfigContent
|
|
});
|
|
|
|
// Create agent-prompt.txt (orchestrator instructions)
|
|
const promptContent = this.createOrchestratorPrompt(bundle, config);
|
|
files.push({
|
|
path: path.join(outputDir, 'agent-prompt.txt'),
|
|
content: promptContent
|
|
});
|
|
|
|
// Create individual section files
|
|
bundle.sections.forEach(section => {
|
|
files.push({
|
|
path: path.join(outputDir, section.filename),
|
|
content: section.content
|
|
});
|
|
});
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Create orchestrator prompt content
|
|
*/
|
|
createOrchestratorPrompt(bundle, config) {
|
|
// Try to use the bmad persona as the orchestrator base
|
|
const bmadPersonaPath = path.join(this.rootPath, 'bmad-core', 'personas', 'bmad.md');
|
|
|
|
if (fs.existsSync(bmadPersonaPath)) {
|
|
const bmadContent = fs.readFileSync(bmadPersonaPath, 'utf8');
|
|
// Append bundle-specific agent information
|
|
let prompt = bmadContent + '\n\n';
|
|
prompt += `## Available Agents in ${bundle.metadata.name}\n\n`;
|
|
|
|
Object.entries(bundle.agents).forEach(([id, agent]) => {
|
|
const command = config.output?.orchestrator_commands?.find(cmd => cmd.includes(id)) || `/${id}`;
|
|
prompt += `### ${agent.name} (${command})\n`;
|
|
prompt += `- **Role:** ${agent.title}\n`;
|
|
prompt += `- **Description:** ${agent.description}\n`;
|
|
if (agent.customize) {
|
|
prompt += `- **Customization:** ${agent.customize}\n`;
|
|
}
|
|
prompt += '\n';
|
|
});
|
|
|
|
return prompt;
|
|
}
|
|
|
|
// Fallback to basic prompt if bmad persona not found
|
|
|
|
let prompt = `# BMAD ${bundle.metadata.name} Orchestrator\n\n`;
|
|
prompt += `You are the BMAD orchestrator for the ${bundle.metadata.name}. `;
|
|
prompt += `You can transform into any of the following specialized agents:\n\n`;
|
|
|
|
// List available agents
|
|
Object.entries(bundle.agents).forEach(([id, agent]) => {
|
|
prompt += `## ${agent.name} (${config.output?.orchestrator_commands?.find(cmd => cmd.includes(id)) || `/${id}`})\n`;
|
|
prompt += `**Role:** ${agent.title}\n`;
|
|
prompt += `${agent.description}\n`;
|
|
if (agent.customize) {
|
|
prompt += `**Customization:** ${agent.customize}\n`;
|
|
}
|
|
prompt += '\n';
|
|
if (agent.capabilities && agent.capabilities.length > 0) {
|
|
prompt += `**Capabilities:**\n`;
|
|
agent.capabilities.forEach(cap => prompt += `- ${cap}\n`);
|
|
prompt += '\n';
|
|
}
|
|
});
|
|
|
|
prompt += `## Usage\n\n`;
|
|
prompt += `To transform into a specific agent, use the corresponding command:\n`;
|
|
(config.output?.orchestrator_commands || []).forEach(cmd => {
|
|
prompt += `- \`${cmd}\` - Transform into the corresponding agent\n`;
|
|
});
|
|
|
|
prompt += `\n## Resources Available\n\n`;
|
|
prompt += `This bundle includes ${bundle.statistics.totalResources} resources:\n`;
|
|
Object.entries(bundle.statistics.resourcesByType).forEach(([type, count]) => {
|
|
prompt += `- ${count} ${type}\n`;
|
|
});
|
|
|
|
return prompt;
|
|
}
|
|
|
|
/**
|
|
* Load all bundle configurations
|
|
*/
|
|
loadBundleConfigs() {
|
|
const configs = [];
|
|
const agentFiles = this.findAgentFiles(this.agentsPath);
|
|
|
|
agentFiles.forEach(file => {
|
|
try {
|
|
const content = fs.readFileSync(file, 'utf8');
|
|
const config = yaml.load(content);
|
|
const filename = path.basename(file);
|
|
|
|
// Check if this is a team bundle (team-*.yml) with bundle config
|
|
if (filename.startsWith('team-') && config.bundle) {
|
|
// Only include web bundles (exclude IDE-specific bundles)
|
|
if (config.bundle.target_environment === 'web') {
|
|
// Merge agents list from root level into bundle config
|
|
const bundleConfig = { ...config.bundle };
|
|
if (config.agents && !bundleConfig.agents) {
|
|
bundleConfig.agents = config.agents;
|
|
}
|
|
configs.push(bundleConfig);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Warning: Failed to load config ${file}:`, error.message);
|
|
}
|
|
});
|
|
|
|
return configs;
|
|
}
|
|
|
|
/**
|
|
* Find all agent configuration files
|
|
*/
|
|
findAgentFiles(dir) {
|
|
const files = [];
|
|
|
|
if (!fs.existsSync(dir)) {
|
|
return files;
|
|
}
|
|
|
|
const items = fs.readdirSync(dir);
|
|
items.forEach(item => {
|
|
const itemPath = path.join(dir, item);
|
|
const stat = fs.statSync(itemPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
files.push(...this.findAgentFiles(itemPath));
|
|
} else if (item.endsWith('.yml') || item.endsWith('.yaml')) {
|
|
files.push(itemPath);
|
|
}
|
|
});
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Ensure directory exists
|
|
*/
|
|
ensureDirectory(dir) {
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure output directory exists
|
|
*/
|
|
ensureOutputDirectory() {
|
|
this.ensureDirectory(this.outputPath);
|
|
this.ensureDirectory(path.join(this.outputPath, 'teams'));
|
|
this.ensureDirectory(path.join(this.outputPath, 'agents'));
|
|
}
|
|
|
|
/**
|
|
* Ensure sample update directory exists
|
|
*/
|
|
ensureSampleUpdateDirectory() {
|
|
this.ensureDirectory(this.sampleUpdatePath);
|
|
this.ensureDirectory(path.join(this.sampleUpdatePath, 'teams'));
|
|
this.ensureDirectory(path.join(this.sampleUpdatePath, 'agents'));
|
|
}
|
|
}
|
|
|
|
module.exports = WebBuilder; |