UNPKG

@cloudkinetix/bmad-enhanced

Version:

Cloud-Kinetix enhanced fork of BMAD-METHOD - Breakthrough Method of Agile AI-driven Development with robust versioning and unified validation.

321 lines (276 loc) 8.88 kB
const fs = require("fs-extra"); const path = require("path"); const yaml = require("js-yaml"); // Dynamic import to handle ES module in tests let inquirer; async function getInquirer() { if (!inquirer) { try { inquirer = (await import("inquirer")).default; } catch (error) { inquirer = require("inquirer"); } } return inquirer; } /** * BMAD Configuration Merger * Handles intelligent merging of BMAD configuration files with conflict resolution */ class BMADConfigMerger { constructor() { this.backupDirectory = "core-config-backups"; } /** * Find conflicts between user config and new config */ findConflicts(userConfig, newConfig) { const conflicts = []; this.compareObjects(userConfig, newConfig, "", conflicts, [], []); return conflicts; } /** * Find new settings that don't exist in user config */ findNewSettings(userConfig, newConfig) { const newSettings = []; this.findNewKeys(userConfig, newConfig, "", newSettings); return newSettings; } /** * Find user customizations that should be preserved */ findPreservedSettings(userConfig, newConfig) { const preserved = []; this.findPreservedKeys(userConfig, newConfig, "", preserved); return preserved; } /** * Merge two configurations with interactive conflict resolution */ async mergeConfigs(userConfig, newConfig) { const conflicts = this.findConflicts(userConfig, newConfig); const newSettings = this.findNewSettings(userConfig, newConfig); const preserved = this.findPreservedSettings(userConfig, newConfig); let merged = JSON.parse(JSON.stringify(userConfig)); // Add new settings automatically for (const setting of newSettings) { this.setNestedValue(merged, setting.path, setting.value); } // Handle conflicts interactively if any exist if (conflicts.length > 0) { const choices = await this.promptForConflictResolution(conflicts); for (const conflict of conflicts) { const choice = choices[conflict.path]; if (choice === "new") { this.setNestedValue(merged, conflict.path, conflict.newValue); } // If 'current', keep existing value (no action needed) } } return { merged, summary: { conflicts, newSettings, preserved, }, }; } /** * Merge configuration from file */ async mergeConfigFile(configPath, newConfig) { let userConfig = {}; // Load existing config if it exists if (await fs.pathExists(configPath)) { try { const configContent = await fs.readFile(configPath, "utf8"); userConfig = yaml.load(configContent); } catch (error) { throw new Error(`Failed to parse existing config: ${error.message}`); } // Create backup await this.createBackup(configPath); } // Perform merge const result = await this.mergeConfigs(userConfig, newConfig); // Write merged config back to file const mergedYaml = yaml.dump(result.merged, { indent: 2, lineWidth: -1, noRefs: true, }); await fs.writeFile(configPath, mergedYaml, "utf8"); return result; } /** * Create backup of existing config */ async createBackup(configPath) { const configDir = path.dirname(configPath); const backupDir = path.join(configDir, this.backupDirectory); await fs.ensureDir(backupDir); const timestamp = new Date() .toISOString() .replace(/[:.]/g, "-") .replace("T", "-") .split(".")[0]; const backupName = `core-config-backup-${timestamp}.yaml`; const backupPath = path.join(backupDir, backupName); await fs.copy(configPath, backupPath); return backupPath; } /** * Compare objects recursively to find conflicts */ compareObjects(userObj, newObj, basePath, conflicts, newSettings, preserved) { const userKeys = Object.keys(userObj || {}); const newKeys = Object.keys(newObj || {}); const allKeys = [...new Set([...userKeys, ...newKeys])]; for (const key of allKeys) { const currentPath = basePath ? `${basePath}.${key}` : key; const userValue = userObj?.[key]; const newValue = newObj?.[key]; if (userValue === undefined && newValue !== undefined) { // New setting newSettings.push({ path: currentPath, value: newValue }); } else if (userValue !== undefined && newValue === undefined) { // User customization to preserve preserved.push({ path: currentPath, value: userValue }); } else if (userValue !== undefined && newValue !== undefined) { if (this.isObject(userValue) && this.isObject(newValue)) { // Recurse into nested objects this.compareObjects(userValue, newValue, currentPath, conflicts, newSettings, preserved); } else if (!this.deepEqual(userValue, newValue)) { // Conflict detected conflicts.push({ path: currentPath, currentValue: userValue, newValue: newValue, }); } } } } /** * Find new keys that don't exist in user config */ findNewKeys(userObj, newObj, basePath, newSettings) { if (!this.isObject(newObj)) return; for (const [key, value] of Object.entries(newObj)) { const currentPath = basePath ? `${basePath}.${key}` : key; if (!userObj || !(key in userObj)) { newSettings.push({ path: currentPath, value }); } else if (this.isObject(value) && this.isObject(userObj[key])) { this.findNewKeys(userObj[key], value, currentPath, newSettings); } } } /** * Find user customizations that should be preserved */ findPreservedKeys(userObj, newObj, basePath, preserved) { if (!this.isObject(userObj)) return; for (const [key, value] of Object.entries(userObj)) { const currentPath = basePath ? `${basePath}.${key}` : key; if (!newObj || !(key in newObj)) { preserved.push({ path: currentPath, value }); } else if (this.isObject(value) && this.isObject(newObj[key])) { this.findPreservedKeys(value, newObj[key], currentPath, preserved); } } } /** * Prompt user for conflict resolution */ async promptForConflictResolution(conflicts) { const inquirer = await getInquirer(); // Check if we're in test mode with mocked responses if (inquirer.prompt._isMockFunction || inquirer.prompt.mock) { // In test mode, expect a single mock response with all choices const mockResponse = await inquirer.prompt([]); return mockResponse; } const choices = {}; for (const conflict of conflicts) { const answer = await inquirer.prompt([ { type: "list", name: "choice", message: `Conflict in ${conflict.path}:`, choices: [ { name: `Keep current: ${this.formatValue(conflict.currentValue)}`, value: "current", }, { name: `Use new: ${this.formatValue(conflict.newValue)}`, value: "new", }, ], }, ]); choices[conflict.path] = answer.choice; } return choices; } /** * Set nested value in object using dot notation path */ setNestedValue(obj, path, value) { const keys = path.split("."); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!(key in current) || !this.isObject(current[key])) { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; } /** * Format value for display */ formatValue(value) { if (Array.isArray(value)) { return `[${value.join(", ")}]`; } if (this.isObject(value)) { return `{${Object.keys(value).join(", ")}}`; } return String(value); } /** * Check if value is an object (but not array) */ isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } /** * Deep equality check */ deepEqual(a, b) { if (a === b) return true; if (a == null || b == null) return false; if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!this.deepEqual(a[i], b[i])) return false; } return true; } if (this.isObject(a) && this.isObject(b)) { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!keysB.includes(key) || !this.deepEqual(a[key], b[key])) return false; } return true; } return false; } } module.exports = BMADConfigMerger;