diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/cli/installers/lib/core/manifest.js new file mode 100644 index 00000000..7410450f --- /dev/null +++ b/tools/cli/installers/lib/core/manifest.js @@ -0,0 +1,536 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const crypto = require('node:crypto'); + +class Manifest { + /** + * Create a new manifest + * @param {string} bmadDir - Path to bmad directory + * @param {Object} data - Manifest data + * @param {Array} installedFiles - List of installed files (no longer used, files tracked in files-manifest.csv) + */ + async create(bmadDir, data, installedFiles = []) { + const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml'); + const yaml = require('js-yaml'); + + // Ensure _cfg directory exists + await fs.ensureDir(path.dirname(manifestPath)); + + // Structure the manifest data + const manifestData = { + installation: { + version: data.version || require(path.join(process.cwd(), 'package.json')).version, + installDate: data.installDate || new Date().toISOString(), + lastUpdated: data.lastUpdated || new Date().toISOString(), + }, + modules: data.modules || [], + ides: data.ides || [], + }; + + // Write YAML manifest + const yamlContent = yaml.dump(manifestData, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + await fs.writeFile(manifestPath, yamlContent, 'utf8'); + return { success: true, path: manifestPath, filesTracked: 0 }; + } + + /** + * Read existing manifest + * @param {string} bmadDir - Path to bmad directory + * @returns {Object|null} Manifest data or null if not found + */ + async read(bmadDir) { + const yamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml'); + const yaml = require('js-yaml'); + + if (await fs.pathExists(yamlPath)) { + try { + const content = await fs.readFile(yamlPath, 'utf8'); + const manifestData = yaml.load(content); + + // Flatten the structure for compatibility with existing code + return { + version: manifestData.installation?.version, + installDate: manifestData.installation?.installDate, + lastUpdated: manifestData.installation?.lastUpdated, + modules: manifestData.modules || [], + ides: manifestData.ides || [], + }; + } catch (error) { + console.error('Failed to read YAML manifest:', error.message); + } + } + + return null; + } + + /** + * Update existing manifest + * @param {string} bmadDir - Path to bmad directory + * @param {Object} updates - Fields to update + * @param {Array} installedFiles - Updated list of installed files + */ + async update(bmadDir, updates, installedFiles = null) { + const yaml = require('js-yaml'); + const manifest = (await this.read(bmadDir)) || {}; + + // Merge updates + Object.assign(manifest, updates); + manifest.lastUpdated = new Date().toISOString(); + + // Convert back to structured format for YAML + const manifestData = { + installation: { + version: manifest.version, + installDate: manifest.installDate, + lastUpdated: manifest.lastUpdated, + }, + modules: manifest.modules || [], + ides: manifest.ides || [], + }; + + const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml'); + await fs.ensureDir(path.dirname(manifestPath)); + + const yamlContent = yaml.dump(manifestData, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + await fs.writeFile(manifestPath, yamlContent, 'utf8'); + + return manifest; + } + + /** + * Add a module to the manifest + * @param {string} bmadDir - Path to bmad directory + * @param {string} moduleName - Module name to add + */ + async addModule(bmadDir, moduleName) { + const manifest = await this.read(bmadDir); + if (!manifest) { + throw new Error('No manifest found'); + } + + if (!manifest.modules) { + manifest.modules = []; + } + + if (!manifest.modules.includes(moduleName)) { + manifest.modules.push(moduleName); + await this.update(bmadDir, { modules: manifest.modules }); + } + } + + /** + * Remove a module from the manifest + * @param {string} bmadDir - Path to bmad directory + * @param {string} moduleName - Module name to remove + */ + async removeModule(bmadDir, moduleName) { + const manifest = await this.read(bmadDir); + if (!manifest || !manifest.modules) { + return; + } + + const index = manifest.modules.indexOf(moduleName); + if (index !== -1) { + manifest.modules.splice(index, 1); + await this.update(bmadDir, { modules: manifest.modules }); + } + } + + /** + * Add an IDE configuration to the manifest + * @param {string} bmadDir - Path to bmad directory + * @param {string} ideName - IDE name to add + */ + async addIde(bmadDir, ideName) { + const manifest = await this.read(bmadDir); + if (!manifest) { + throw new Error('No manifest found'); + } + + if (!manifest.ides) { + manifest.ides = []; + } + + if (!manifest.ides.includes(ideName)) { + manifest.ides.push(ideName); + await this.update(bmadDir, { ides: manifest.ides }); + } + } + + /** + * Calculate SHA256 hash of a file + * @param {string} filePath - Path to file + * @returns {string} SHA256 hash + */ + async calculateFileHash(filePath) { + try { + const content = await fs.readFile(filePath); + return crypto.createHash('sha256').update(content).digest('hex'); + } catch { + return null; + } + } + + /** + * Parse installed files to extract metadata + * @param {Array} installedFiles - List of installed file paths + * @param {string} bmadDir - Path to bmad directory for relative paths + * @returns {Array} Array of file metadata objects + */ + async parseInstalledFiles(installedFiles, bmadDir) { + const fileMetadata = []; + + for (const filePath of installedFiles) { + const fileExt = path.extname(filePath).toLowerCase(); + // Make path relative to parent of bmad directory, starting with 'bmad/' + const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/'); + + // Calculate file hash + const hash = await this.calculateFileHash(filePath); + + // Handle markdown files - extract XML metadata if present + if (fileExt === '.md') { + try { + if (await fs.pathExists(filePath)) { + const content = await fs.readFile(filePath, 'utf8'); + const metadata = this.extractXmlNodeAttributes(content, filePath, relativePath); + + if (metadata) { + // Has XML metadata + metadata.hash = hash; + fileMetadata.push(metadata); + } else { + // No XML metadata - still track the file + fileMetadata.push({ + file: relativePath, + type: 'md', + name: path.basename(filePath, fileExt), + title: null, + hash: hash, + }); + } + } + } catch (error) { + console.warn(`Warning: Could not parse ${filePath}:`, error.message); + } + } + // Handle other file types (CSV, JSON, YAML, etc.) + else { + fileMetadata.push({ + file: relativePath, + type: fileExt.slice(1), // Remove the dot + name: path.basename(filePath, fileExt), + title: null, + hash: hash, + }); + } + } + + return fileMetadata; + } + + /** + * Extract XML node attributes from MD file content + * @param {string} content - File content + * @param {string} filePath - File path for context + * @param {string} relativePath - Relative path starting with 'bmad/' + * @returns {Object|null} Extracted metadata or null + */ + extractXmlNodeAttributes(content, filePath, relativePath) { + // Look for XML blocks in code fences + const xmlBlockMatch = content.match(/```xml\s*([\s\S]*?)```/); + if (!xmlBlockMatch) { + return null; + } + + const xmlContent = xmlBlockMatch[1]; + + // Extract root XML node (agent, task, template, etc.) + const rootNodeMatch = xmlContent.match(/<(\w+)([^>]*)>/); + if (!rootNodeMatch) { + return null; + } + + const nodeType = rootNodeMatch[1]; + const attributes = rootNodeMatch[2]; + + // Extract name and title attributes (id not needed since we have path) + const nameMatch = attributes.match(/name="([^"]*)"/); + const titleMatch = attributes.match(/title="([^"]*)"/); + + return { + file: relativePath, + type: nodeType, + name: nameMatch ? nameMatch[1] : null, + title: titleMatch ? titleMatch[1] : null, + }; + } + + /** + * Generate CSV manifest content + * @param {Object} data - Manifest data + * @param {Array} fileMetadata - File metadata array + * @param {Object} moduleConfigs - Module configuration data + * @returns {string} CSV content + */ + generateManifestCsv(data, fileMetadata, moduleConfigs = {}) { + const timestamp = new Date().toISOString(); + let csv = []; + + // Header section + csv.push( + '# BMAD Manifest', + `# Generated: ${timestamp}`, + '', + '## Installation Info', + 'Property,Value', + `Version,${data.version}`, + `InstallDate,${data.installDate || timestamp}`, + `LastUpdated,${data.lastUpdated || timestamp}`, + ); + if (data.language) { + csv.push(`Language,${data.language}`); + } + csv.push(''); + + // Modules section + if (data.modules && data.modules.length > 0) { + csv.push('## Modules', 'Name,Version,ShortTitle'); + for (const moduleName of data.modules) { + const config = moduleConfigs[moduleName] || {}; + csv.push([moduleName, config.version || '', config['short-title'] || ''].map((v) => this.escapeCsv(v)).join(',')); + } + csv.push(''); + } + + // IDEs section + if (data.ides && data.ides.length > 0) { + csv.push('## IDEs', 'IDE'); + for (const ide of data.ides) { + csv.push(this.escapeCsv(ide)); + } + csv.push(''); + } + + // Files section - NO LONGER USED + // Files are now tracked in files-manifest.csv by ManifestGenerator + + return csv.join('\n'); + } + + /** + * Parse CSV manifest content back to object + * @param {string} csvContent - CSV content to parse + * @returns {Object} Parsed manifest data + */ + parseManifestCsv(csvContent) { + const result = { + modules: [], + ides: [], + files: [], + }; + + const lines = csvContent.split('\n'); + let section = ''; + + for (const line_ of lines) { + const line = line_.trim(); + + // Skip empty lines and comments + if (!line || line.startsWith('#')) { + // Check for section headers + if (line.startsWith('## ')) { + section = line.slice(3).toLowerCase(); + } + continue; + } + + // Parse based on current section + switch (section) { + case 'installation info': { + // Skip header row + if (line === 'Property,Value') continue; + + const [property, ...valueParts] = line.split(','); + const value = this.unescapeCsv(valueParts.join(',')); + + switch (property) { + // Path no longer stored in manifest + case 'Version': { + result.version = value; + break; + } + case 'InstallDate': { + result.installDate = value; + break; + } + case 'LastUpdated': { + result.lastUpdated = value; + break; + } + case 'Language': { + result.language = value; + break; + } + } + + break; + } + case 'modules': { + // Skip header row + if (line === 'Name,Version,ShortTitle') continue; + + const parts = this.parseCsvLine(line); + if (parts[0]) { + result.modules.push(parts[0]); + } + + break; + } + case 'ides': { + // Skip header row + if (line === 'IDE') continue; + + result.ides.push(this.unescapeCsv(line)); + + break; + } + case 'files': { + // Skip header rows (support both old and new format) + if (line === 'Type,Path,Name,Title' || line === 'Type,Path,Name,Title,Hash') continue; + + const parts = this.parseCsvLine(line); + if (parts.length >= 2) { + result.files.push({ + type: parts[0] || '', + file: parts[1] || '', + name: parts[2] || null, + title: parts[3] || null, + hash: parts[4] || null, // Hash column (may not exist in old manifests) + }); + } + + break; + } + // No default + } + } + + return result; + } + + /** + * Parse a CSV line handling quotes and commas + * @param {string} line - CSV line to parse + * @returns {Array} Array of values + */ + parseCsvLine(line) { + const result = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + // Escaped quote + current += '"'; + i++; + } else { + // Toggle quote state + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + // Field separator + result.push(this.unescapeCsv(current)); + current = ''; + } else { + current += char; + } + } + + // Add the last field + result.push(this.unescapeCsv(current)); + + return result; + } + + /** + * Escape CSV special characters + * @param {string} text - Text to escape + * @returns {string} Escaped text + */ + escapeCsv(text) { + if (!text) return ''; + const str = String(text); + + // If contains comma, newline, or quote, wrap in quotes and escape quotes + if (str.includes(',') || str.includes('\n') || str.includes('"')) { + return '"' + str.replaceAll('"', '""') + '"'; + } + + return str; + } + + /** + * Unescape CSV field + * @param {string} text - Text to unescape + * @returns {string} Unescaped text + */ + unescapeCsv(text) { + if (!text) return ''; + + // Remove surrounding quotes if present + if (text.startsWith('"') && text.endsWith('"')) { + text = text.slice(1, -1); + // Unescape doubled quotes + text = text.replaceAll('""', '"'); + } + + return text; + } + + /** + * Load module configuration files + * @param {Array} modules - List of module names + * @returns {Object} Module configurations indexed by name + */ + async loadModuleConfigs(modules) { + const configs = {}; + + for (const moduleName of modules) { + // Handle core module differently - it's in src/core not src/modules/core + const configPath = + moduleName === 'core' + ? path.join(process.cwd(), 'src', 'core', 'config.yaml') + : path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml'); + + try { + if (await fs.pathExists(configPath)) { + const yaml = require('js-yaml'); + const content = await fs.readFile(configPath, 'utf8'); + configs[moduleName] = yaml.load(content); + } + } catch (error) { + console.warn(`Could not load config for module ${moduleName}:`, error.message); + } + } + + return configs; + } +} + +module.exports = { Manifest };