doc updates, build folder renamed to tools, readme clarity for v4
This commit is contained in:
444
tools/builders/web-builder.js
Normal file
444
tools/builders/web-builder.js
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user