mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-01-30 04:32:02 +00:00
1038 lines
31 KiB
JavaScript
1038 lines
31 KiB
JavaScript
const path = require('node:path');
|
|
const fs = require('fs-extra');
|
|
const crypto = require('node:crypto');
|
|
const { getProjectRoot } = require('../../../lib/project-root');
|
|
|
|
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, '_config', 'manifest.yaml');
|
|
const yaml = require('yaml');
|
|
|
|
// Ensure _config directory exists
|
|
await fs.ensureDir(path.dirname(manifestPath));
|
|
|
|
// Get the BMad version from package.json
|
|
const bmadVersion = data.version || require(path.join(process.cwd(), 'package.json')).version;
|
|
|
|
// Convert module list to new detailed format
|
|
const moduleDetails = [];
|
|
if (data.modules && Array.isArray(data.modules)) {
|
|
for (const moduleName of data.modules) {
|
|
// Core and BMM modules use the BMad version
|
|
const moduleVersion = moduleName === 'core' || moduleName === 'bmm' ? bmadVersion : null;
|
|
const now = data.installDate || new Date().toISOString();
|
|
|
|
moduleDetails.push({
|
|
name: moduleName,
|
|
version: moduleVersion,
|
|
installDate: now,
|
|
lastUpdated: now,
|
|
source: moduleName === 'core' || moduleName === 'bmm' ? 'built-in' : 'unknown',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Structure the manifest data
|
|
const manifestData = {
|
|
installation: {
|
|
version: bmadVersion,
|
|
installDate: data.installDate || new Date().toISOString(),
|
|
lastUpdated: data.lastUpdated || new Date().toISOString(),
|
|
},
|
|
modules: moduleDetails,
|
|
ides: data.ides || [],
|
|
};
|
|
|
|
// Write YAML manifest
|
|
// Clean the manifest data to remove any non-serializable values
|
|
const cleanManifestData = structuredClone(manifestData);
|
|
|
|
const yamlContent = yaml.stringify(cleanManifestData, {
|
|
indent: 2,
|
|
lineWidth: 0,
|
|
sortKeys: false,
|
|
});
|
|
|
|
// Ensure POSIX-compliant final newline
|
|
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
|
|
await fs.writeFile(manifestPath, content, '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, '_config', 'manifest.yaml');
|
|
const yaml = require('yaml');
|
|
|
|
if (await fs.pathExists(yamlPath)) {
|
|
try {
|
|
const content = await fs.readFile(yamlPath, 'utf8');
|
|
const manifestData = yaml.parse(content);
|
|
|
|
// Handle new detailed module format
|
|
const modules = manifestData.modules || [];
|
|
|
|
// For backward compatibility: if modules is an array of strings (old format),
|
|
// the calling code may need the array of names
|
|
const moduleNames = modules.map((m) => (typeof m === 'string' ? m : m.name));
|
|
|
|
// Check if we have the new detailed format
|
|
const hasDetailedModules = modules.length > 0 && typeof modules[0] === 'object';
|
|
|
|
// Flatten the structure for compatibility with existing code
|
|
return {
|
|
version: manifestData.installation?.version,
|
|
installDate: manifestData.installation?.installDate,
|
|
lastUpdated: manifestData.installation?.lastUpdated,
|
|
modules: moduleNames, // Simple array of module names for backward compatibility
|
|
modulesDetailed: hasDetailedModules ? modules : null, // New detailed format
|
|
customModules: manifestData.customModules || [], // Keep for backward compatibility
|
|
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('yaml');
|
|
const manifest = (await this._readRaw(bmadDir)) || {
|
|
installation: {},
|
|
modules: [],
|
|
ides: [],
|
|
};
|
|
|
|
// Handle module updates
|
|
if (updates.modules) {
|
|
// If modules is being updated, we need to preserve detailed module info
|
|
const existingDetailed = manifest.modules || [];
|
|
const incomingNames = updates.modules;
|
|
|
|
// Build updated modules array
|
|
const updatedModules = [];
|
|
for (const name of incomingNames) {
|
|
const existing = existingDetailed.find((m) => m.name === name);
|
|
if (existing) {
|
|
// Preserve existing details, update lastUpdated if this module is being updated
|
|
updatedModules.push({
|
|
...existing,
|
|
lastUpdated: new Date().toISOString(),
|
|
});
|
|
} else {
|
|
// New module - add with minimal details
|
|
updatedModules.push({
|
|
name,
|
|
version: null,
|
|
installDate: new Date().toISOString(),
|
|
lastUpdated: new Date().toISOString(),
|
|
source: 'unknown',
|
|
});
|
|
}
|
|
}
|
|
|
|
manifest.modules = updatedModules;
|
|
}
|
|
|
|
// Merge other updates
|
|
if (updates.version) {
|
|
manifest.installation.version = updates.version;
|
|
}
|
|
if (updates.installDate) {
|
|
manifest.installation.installDate = updates.installDate;
|
|
}
|
|
manifest.installation.lastUpdated = new Date().toISOString();
|
|
|
|
if (updates.ides) {
|
|
manifest.ides = updates.ides;
|
|
}
|
|
|
|
// Handle per-module version updates
|
|
if (updates.moduleVersions) {
|
|
for (const [moduleName, versionInfo] of Object.entries(updates.moduleVersions)) {
|
|
const moduleIndex = manifest.modules.findIndex((m) => m.name === moduleName);
|
|
if (moduleIndex !== -1) {
|
|
manifest.modules[moduleIndex] = {
|
|
...manifest.modules[moduleIndex],
|
|
...versionInfo,
|
|
lastUpdated: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle adding a new module with version info
|
|
if (updates.addModule) {
|
|
const { name, version, source, npmPackage, repoUrl } = updates.addModule;
|
|
const existing = manifest.modules.find((m) => m.name === name);
|
|
if (!existing) {
|
|
manifest.modules.push({
|
|
name,
|
|
version: version || null,
|
|
installDate: new Date().toISOString(),
|
|
lastUpdated: new Date().toISOString(),
|
|
source: source || 'external',
|
|
npmPackage: npmPackage || null,
|
|
repoUrl: repoUrl || null,
|
|
});
|
|
}
|
|
}
|
|
|
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
await fs.ensureDir(path.dirname(manifestPath));
|
|
|
|
// Clean the manifest data to remove any non-serializable values
|
|
const cleanManifestData = structuredClone(manifest);
|
|
|
|
const yamlContent = yaml.stringify(cleanManifestData, {
|
|
indent: 2,
|
|
lineWidth: 0,
|
|
sortKeys: false,
|
|
});
|
|
|
|
// Ensure POSIX-compliant final newline
|
|
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
|
|
await fs.writeFile(manifestPath, content, 'utf8');
|
|
|
|
// Return the flattened format for compatibility
|
|
return this._flattenManifest(manifest);
|
|
}
|
|
|
|
/**
|
|
* Read raw manifest data without flattening
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @returns {Object|null} Raw manifest data or null if not found
|
|
*/
|
|
async _readRaw(bmadDir) {
|
|
const yamlPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
const yaml = require('yaml');
|
|
|
|
if (await fs.pathExists(yamlPath)) {
|
|
try {
|
|
const content = await fs.readFile(yamlPath, 'utf8');
|
|
return yaml.parse(content);
|
|
} catch (error) {
|
|
console.error('Failed to read YAML manifest:', error.message);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Flatten manifest for backward compatibility
|
|
* @param {Object} manifest - Raw manifest data
|
|
* @returns {Object} Flattened manifest
|
|
*/
|
|
_flattenManifest(manifest) {
|
|
const modules = manifest.modules || [];
|
|
const moduleNames = modules.map((m) => (typeof m === 'string' ? m : m.name));
|
|
const hasDetailedModules = modules.length > 0 && typeof modules[0] === 'object';
|
|
|
|
return {
|
|
version: manifest.installation?.version,
|
|
installDate: manifest.installation?.installDate,
|
|
lastUpdated: manifest.installation?.lastUpdated,
|
|
modules: moduleNames,
|
|
modulesDetailed: hasDetailedModules ? modules : null,
|
|
customModules: manifest.customModules || [],
|
|
ides: manifest.ides || [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add a module to the manifest with optional version info
|
|
* If module already exists, update its version info
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @param {string} moduleName - Module name to add
|
|
* @param {Object} options - Optional version info
|
|
*/
|
|
async addModule(bmadDir, moduleName, options = {}) {
|
|
const manifest = await this._readRaw(bmadDir);
|
|
if (!manifest) {
|
|
throw new Error('No manifest found');
|
|
}
|
|
|
|
if (!manifest.modules) {
|
|
manifest.modules = [];
|
|
}
|
|
|
|
const existingIndex = manifest.modules.findIndex((m) => m.name === moduleName);
|
|
|
|
if (existingIndex === -1) {
|
|
// Module doesn't exist, add it
|
|
manifest.modules.push({
|
|
name: moduleName,
|
|
version: options.version || null,
|
|
installDate: new Date().toISOString(),
|
|
lastUpdated: new Date().toISOString(),
|
|
source: options.source || 'unknown',
|
|
npmPackage: options.npmPackage || null,
|
|
repoUrl: options.repoUrl || null,
|
|
});
|
|
} else {
|
|
// Module exists, update its version info
|
|
const existing = manifest.modules[existingIndex];
|
|
manifest.modules[existingIndex] = {
|
|
...existing,
|
|
version: options.version === undefined ? existing.version : options.version,
|
|
source: options.source || existing.source,
|
|
npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
|
|
repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
|
|
lastUpdated: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
await this._writeRaw(bmadDir, manifest);
|
|
}
|
|
|
|
/**
|
|
* 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._readRaw(bmadDir);
|
|
if (!manifest || !manifest.modules) {
|
|
return;
|
|
}
|
|
|
|
const index = manifest.modules.findIndex((m) => m.name === moduleName);
|
|
if (index !== -1) {
|
|
manifest.modules.splice(index, 1);
|
|
await this._writeRaw(bmadDir, manifest);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a single module's version info
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @param {string} moduleName - Module name
|
|
* @param {Object} versionInfo - Version info to update
|
|
*/
|
|
async updateModuleVersion(bmadDir, moduleName, versionInfo) {
|
|
const manifest = await this._readRaw(bmadDir);
|
|
if (!manifest || !manifest.modules) {
|
|
return;
|
|
}
|
|
|
|
const index = manifest.modules.findIndex((m) => m.name === moduleName);
|
|
if (index !== -1) {
|
|
manifest.modules[index] = {
|
|
...manifest.modules[index],
|
|
...versionInfo,
|
|
lastUpdated: new Date().toISOString(),
|
|
};
|
|
await this._writeRaw(bmadDir, manifest);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get version info for a specific module
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @param {string} moduleName - Module name
|
|
* @returns {Object|null} Module version info or null
|
|
*/
|
|
async getModuleVersion(bmadDir, moduleName) {
|
|
const manifest = await this._readRaw(bmadDir);
|
|
if (!manifest || !manifest.modules) {
|
|
return null;
|
|
}
|
|
|
|
return manifest.modules.find((m) => m.name === moduleName) || null;
|
|
}
|
|
|
|
/**
|
|
* Get all modules with their version info
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @returns {Array} Array of module info objects
|
|
*/
|
|
async getAllModuleVersions(bmadDir) {
|
|
const manifest = await this._readRaw(bmadDir);
|
|
if (!manifest || !manifest.modules) {
|
|
return [];
|
|
}
|
|
|
|
return manifest.modules;
|
|
}
|
|
|
|
/**
|
|
* Write raw manifest data to file
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @param {Object} manifestData - Raw manifest data to write
|
|
*/
|
|
async _writeRaw(bmadDir, manifestData) {
|
|
const yaml = require('yaml');
|
|
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
|
|
await fs.ensureDir(path.dirname(manifestPath));
|
|
|
|
const cleanManifestData = structuredClone(manifestData);
|
|
|
|
const yamlContent = yaml.stringify(cleanManifestData, {
|
|
indent: 2,
|
|
lineWidth: 0,
|
|
sortKeys: false,
|
|
});
|
|
|
|
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
|
|
await fs.writeFile(manifestPath, content, 'utf8');
|
|
}
|
|
|
|
/**
|
|
* 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('yaml');
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
configs[moduleName] = yaml.parse(content);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Could not load config for module ${moduleName}:`, error.message);
|
|
}
|
|
}
|
|
|
|
return configs;
|
|
}
|
|
/**
|
|
* Add a custom module to the manifest with its source path
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @param {Object} customModule - Custom module info
|
|
*/
|
|
async addCustomModule(bmadDir, customModule) {
|
|
const manifest = await this.read(bmadDir);
|
|
if (!manifest) {
|
|
throw new Error('No manifest found');
|
|
}
|
|
|
|
if (!manifest.customModules) {
|
|
manifest.customModules = [];
|
|
}
|
|
|
|
// Check if custom module already exists
|
|
const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id);
|
|
if (existingIndex === -1) {
|
|
// Add new entry
|
|
manifest.customModules.push(customModule);
|
|
} else {
|
|
// Update existing entry
|
|
manifest.customModules[existingIndex] = customModule;
|
|
}
|
|
|
|
await this.update(bmadDir, { customModules: manifest.customModules });
|
|
}
|
|
|
|
/**
|
|
* Remove a custom module from the manifest
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @param {string} moduleId - Module ID to remove
|
|
*/
|
|
async removeCustomModule(bmadDir, moduleId) {
|
|
const manifest = await this.read(bmadDir);
|
|
if (!manifest || !manifest.customModules) {
|
|
return;
|
|
}
|
|
|
|
const index = manifest.customModules.findIndex((m) => m.id === moduleId);
|
|
if (index !== -1) {
|
|
manifest.customModules.splice(index, 1);
|
|
await this.update(bmadDir, { customModules: manifest.customModules });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get module version info from source
|
|
* @param {string} moduleName - Module name/code
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @param {string} moduleSourcePath - Optional source path for custom modules
|
|
* @returns {Object} Version info object with version, source, npmPackage, repoUrl
|
|
*/
|
|
async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) {
|
|
const os = require('node:os');
|
|
const yaml = require('yaml');
|
|
|
|
// Built-in modules use BMad version (only core and bmm are in BMAD-METHOD repo)
|
|
if (['core', 'bmm'].includes(moduleName)) {
|
|
const bmadVersion = require(path.join(getProjectRoot(), 'package.json')).version;
|
|
return {
|
|
version: bmadVersion,
|
|
source: 'built-in',
|
|
npmPackage: null,
|
|
repoUrl: null,
|
|
};
|
|
}
|
|
|
|
// Check if this is an external official module
|
|
const { ExternalModuleManager } = require('../modules/external-manager');
|
|
const extMgr = new ExternalModuleManager();
|
|
const moduleInfo = await extMgr.getModuleByCode(moduleName);
|
|
|
|
if (moduleInfo) {
|
|
// External module - try to get version from npm registry first, then fall back to cache
|
|
let version = null;
|
|
|
|
if (moduleInfo.npmPackage) {
|
|
// Fetch version from npm registry
|
|
try {
|
|
version = await this.fetchNpmVersion(moduleInfo.npmPackage);
|
|
} catch {
|
|
// npm fetch failed, try cache as fallback
|
|
}
|
|
}
|
|
|
|
// If npm didn't work, try reading from cached repo's package.json
|
|
if (!version) {
|
|
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleName);
|
|
const packageJsonPath = path.join(cacheDir, 'package.json');
|
|
|
|
if (await fs.pathExists(packageJsonPath)) {
|
|
try {
|
|
const pkg = require(packageJsonPath);
|
|
version = pkg.version;
|
|
} catch (error) {
|
|
console.warn(`Failed to read package.json for ${moduleName}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: version,
|
|
source: 'external',
|
|
npmPackage: moduleInfo.npmPackage || null,
|
|
repoUrl: moduleInfo.url || null,
|
|
};
|
|
}
|
|
|
|
// Custom module - check cache directory
|
|
const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName);
|
|
const moduleYamlPath = path.join(cacheDir, 'module.yaml');
|
|
|
|
if (await fs.pathExists(moduleYamlPath)) {
|
|
try {
|
|
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
|
|
const moduleConfig = yaml.parse(yamlContent);
|
|
return {
|
|
version: moduleConfig.version || null,
|
|
source: 'custom',
|
|
npmPackage: moduleConfig.npmPackage || null,
|
|
repoUrl: moduleConfig.repoUrl || null,
|
|
};
|
|
} catch (error) {
|
|
console.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Unknown module
|
|
return {
|
|
version: null,
|
|
source: 'unknown',
|
|
npmPackage: null,
|
|
repoUrl: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch latest version from npm for a package
|
|
* @param {string} packageName - npm package name
|
|
* @returns {string|null} Latest version or null
|
|
*/
|
|
async fetchNpmVersion(packageName) {
|
|
try {
|
|
const https = require('node:https');
|
|
const { execSync } = require('node:child_process');
|
|
|
|
// Try using npm view first (more reliable)
|
|
try {
|
|
const result = execSync(`npm view ${packageName} version`, {
|
|
encoding: 'utf8',
|
|
stdio: 'pipe',
|
|
timeout: 10_000,
|
|
});
|
|
return result.trim();
|
|
} catch {
|
|
// Fallback to npm registry API
|
|
return new Promise((resolve, reject) => {
|
|
https
|
|
.get(`https://registry.npmjs.org/${packageName}`, (res) => {
|
|
let data = '';
|
|
res.on('data', (chunk) => (data += chunk));
|
|
res.on('end', () => {
|
|
try {
|
|
const pkg = JSON.parse(data);
|
|
resolve(pkg['dist-tags']?.latest || pkg.version || null);
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
});
|
|
})
|
|
.on('error', () => resolve(null));
|
|
});
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for available updates for installed modules
|
|
* @param {string} bmadDir - Path to bmad directory
|
|
* @returns {Array} Array of update info objects
|
|
*/
|
|
async checkForUpdates(bmadDir) {
|
|
const modules = await this.getAllModuleVersions(bmadDir);
|
|
const updates = [];
|
|
|
|
for (const module of modules) {
|
|
if (!module.npmPackage) {
|
|
continue; // Skip modules without npm package (built-in)
|
|
}
|
|
|
|
const latestVersion = await this.fetchNpmVersion(module.npmPackage);
|
|
if (!latestVersion) {
|
|
continue;
|
|
}
|
|
|
|
if (module.version !== latestVersion) {
|
|
updates.push({
|
|
name: module.name,
|
|
installedVersion: module.version,
|
|
latestVersion: latestVersion,
|
|
npmPackage: module.npmPackage,
|
|
updateAvailable: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return updates;
|
|
}
|
|
|
|
/**
|
|
* Compare two semantic versions
|
|
* @param {string} v1 - First version
|
|
* @param {string} v2 - Second version
|
|
* @returns {number} -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
|
*/
|
|
compareVersions(v1, v2) {
|
|
if (!v1 || !v2) return 0;
|
|
|
|
const normalize = (v) => {
|
|
// Remove leading 'v' if present
|
|
v = v.replace(/^v/, '');
|
|
// Handle prerelease tags
|
|
const parts = v.split('-');
|
|
const main = parts[0].split('.');
|
|
const prerelease = parts[1];
|
|
return { main, prerelease };
|
|
};
|
|
|
|
const n1 = normalize(v1);
|
|
const n2 = normalize(v2);
|
|
|
|
// Compare main version parts
|
|
for (let i = 0; i < 3; i++) {
|
|
const num1 = parseInt(n1.main[i] || '0', 10);
|
|
const num2 = parseInt(n2.main[i] || '0', 10);
|
|
if (num1 !== num2) {
|
|
return num1 < num2 ? -1 : 1;
|
|
}
|
|
}
|
|
|
|
// If main versions are equal, compare prerelease
|
|
if (n1.prerelease && n2.prerelease) {
|
|
return n1.prerelease < n2.prerelease ? -1 : n1.prerelease > n2.prerelease ? 1 : 0;
|
|
}
|
|
if (n1.prerelease) return -1; // Prerelease is older than stable
|
|
if (n2.prerelease) return 1; // Stable is newer than prerelease
|
|
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
module.exports = { Manifest };
|