440 lines
16 KiB
JavaScript
440 lines
16 KiB
JavaScript
const path = require('node:path');
|
|
const fs = require('../fs-native');
|
|
const crypto = require('node:crypto');
|
|
const { resolveModuleVersion } = require('../modules/version-resolver');
|
|
const prompts = require('../prompts');
|
|
|
|
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
|
|
ides: manifestData.ides || [],
|
|
};
|
|
} catch (error) {
|
|
await prompts.log.error(`Failed to read YAML manifest: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
await prompts.log.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,
|
|
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 = {}) {
|
|
let manifest = await this._readRaw(bmadDir);
|
|
if (!manifest) {
|
|
// Bootstrap a minimal manifest if it doesn't exist yet
|
|
// (e.g., skill-only modules with no agents to compile)
|
|
manifest = { modules: [] };
|
|
}
|
|
|
|
if (!manifest.modules) {
|
|
manifest.modules = [];
|
|
}
|
|
|
|
const existingIndex = manifest.modules.findIndex((m) => m.name === moduleName);
|
|
|
|
if (existingIndex === -1) {
|
|
// Module doesn't exist, add it
|
|
const entry = {
|
|
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,
|
|
};
|
|
if (options.channel) entry.channel = options.channel;
|
|
if (options.sha) entry.sha = options.sha;
|
|
if (options.localPath) entry.localPath = options.localPath;
|
|
if (options.rawSource) entry.rawSource = options.rawSource;
|
|
if (options.registryApprovedTag) entry.registryApprovedTag = options.registryApprovedTag;
|
|
if (options.registryApprovedSha) entry.registryApprovedSha = options.registryApprovedSha;
|
|
manifest.modules.push(entry);
|
|
} 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,
|
|
localPath: options.localPath === undefined ? existing.localPath : options.localPath,
|
|
channel: options.channel === undefined ? existing.channel : options.channel,
|
|
sha: options.sha === undefined ? existing.sha : options.sha,
|
|
rawSource: options.rawSource === undefined ? existing.rawSource : options.rawSource,
|
|
registryApprovedTag: options.registryApprovedTag === undefined ? existing.registryApprovedTag : options.registryApprovedTag,
|
|
registryApprovedSha: options.registryApprovedSha === undefined ? existing.registryApprovedSha : options.registryApprovedSha,
|
|
lastUpdated: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
await this._writeRaw(bmadDir, manifest);
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
// Resolve source type first, then read version with the correct path context
|
|
if (['core', 'bmm'].includes(moduleName)) {
|
|
const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
|
|
return {
|
|
version: versionInfo.version,
|
|
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) {
|
|
const externalResolution = extMgr.getResolution(moduleName);
|
|
const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
|
|
return {
|
|
// Git tag recorded during install trumps the on-disk package.json
|
|
// version, so the manifest carries "v1.7.0" instead of "1.7.0".
|
|
version: externalResolution?.version || versionInfo.version,
|
|
source: 'external',
|
|
npmPackage: moduleInfo.npmPackage || null,
|
|
repoUrl: moduleInfo.url || null,
|
|
channel: externalResolution?.channel || null,
|
|
sha: externalResolution?.sha || null,
|
|
};
|
|
}
|
|
|
|
// Check if this is a community module
|
|
const { CommunityModuleManager } = require('../modules/community-manager');
|
|
const communityMgr = new CommunityModuleManager();
|
|
const communityInfo = await communityMgr.getModuleByCode(moduleName);
|
|
if (communityInfo) {
|
|
const communityResolution = communityMgr.getResolution(moduleName);
|
|
const versionInfo = await resolveModuleVersion(moduleName, {
|
|
moduleSourcePath,
|
|
fallbackVersion: communityInfo.version,
|
|
});
|
|
return {
|
|
version: communityResolution?.version || versionInfo.version || communityInfo.version,
|
|
source: 'community',
|
|
npmPackage: communityInfo.npmPackage || null,
|
|
repoUrl: communityInfo.url || null,
|
|
channel: communityResolution?.channel || null,
|
|
sha: communityResolution?.sha || null,
|
|
registryApprovedTag: communityResolution?.registryApprovedTag || null,
|
|
registryApprovedSha: communityResolution?.registryApprovedSha || null,
|
|
};
|
|
}
|
|
|
|
// Check if this is a custom module (from user-provided URL or local path)
|
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
|
const customMgr = new CustomModuleManager();
|
|
const resolved = customMgr.getResolution(moduleName);
|
|
const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir });
|
|
if (customSource || resolved) {
|
|
const versionInfo = await resolveModuleVersion(moduleName, {
|
|
moduleSourcePath: moduleSourcePath || customSource,
|
|
fallbackVersion: resolved?.version,
|
|
marketplacePluginNames: resolved?.pluginName ? [resolved.pluginName] : [],
|
|
});
|
|
const hasGitClone = !!resolved?.repoUrl;
|
|
return {
|
|
// Prefer the git ref we actually cloned over the package.json version.
|
|
version: resolved?.cloneRef || (hasGitClone ? 'main' : versionInfo.version),
|
|
source: 'custom',
|
|
npmPackage: null,
|
|
repoUrl: resolved?.repoUrl || null,
|
|
localPath: resolved?.localPath || null,
|
|
channel: hasGitClone ? (resolved?.cloneRef ? 'pinned' : 'next') : null,
|
|
sha: resolved?.cloneSha || null,
|
|
rawSource: resolved?.rawInput || null,
|
|
};
|
|
}
|
|
|
|
// Unknown module
|
|
const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
|
|
return {
|
|
version: versionInfo.version,
|
|
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 semver = require('semver');
|
|
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;
|
|
}
|
|
|
|
const installedVersion = semver.valid(module.version) || semver.valid(semver.coerce(module.version || ''));
|
|
const availableVersion = semver.valid(latestVersion) || semver.valid(semver.coerce(latestVersion));
|
|
|
|
if (installedVersion && availableVersion && semver.gt(availableVersion, installedVersion)) {
|
|
updates.push({
|
|
name: module.name,
|
|
installedVersion: module.version,
|
|
latestVersion: latestVersion,
|
|
npmPackage: module.npmPackage,
|
|
updateAvailable: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return updates;
|
|
}
|
|
}
|
|
|
|
module.exports = { Manifest };
|