BMAD-METHOD/.patch/477/manifest.js.477.diff

543 lines
16 KiB
Diff

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 };