mirror of
https://github.com/bmadcode/BMAD-METHOD.git
synced 2025-12-17 09:45:25 +00:00
251 lines
6.8 KiB
JavaScript
251 lines
6.8 KiB
JavaScript
/**
|
|
* Custom Module Source Cache
|
|
* Caches custom module sources under _cfg/custom/ to ensure they're never lost
|
|
* and can be checked into source control
|
|
*/
|
|
|
|
const fs = require('fs-extra');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
|
|
class CustomModuleCache {
|
|
constructor(bmadDir) {
|
|
this.bmadDir = bmadDir;
|
|
this.customCacheDir = path.join(bmadDir, '_cfg', 'custom');
|
|
this.manifestPath = path.join(this.customCacheDir, 'cache-manifest.yaml');
|
|
}
|
|
|
|
/**
|
|
* Ensure the custom cache directory exists
|
|
*/
|
|
async ensureCacheDir() {
|
|
await fs.ensureDir(this.customCacheDir);
|
|
}
|
|
|
|
/**
|
|
* Get cache manifest
|
|
*/
|
|
async getCacheManifest() {
|
|
if (!(await fs.pathExists(this.manifestPath))) {
|
|
return {};
|
|
}
|
|
|
|
const content = await fs.readFile(this.manifestPath, 'utf8');
|
|
const yaml = require('yaml');
|
|
return yaml.parse(content) || {};
|
|
}
|
|
|
|
/**
|
|
* Update cache manifest
|
|
*/
|
|
async updateCacheManifest(manifest) {
|
|
const yaml = require('yaml');
|
|
const content = yaml.stringify(manifest, {
|
|
indent: 2,
|
|
lineWidth: 0,
|
|
sortKeys: false,
|
|
});
|
|
|
|
await fs.writeFile(this.manifestPath, content);
|
|
}
|
|
|
|
/**
|
|
* Stream a file into the hash to avoid loading entire file into memory
|
|
*/
|
|
async hashFileStream(filePath, hash) {
|
|
return new Promise((resolve, reject) => {
|
|
const stream = require('node:fs').createReadStream(filePath);
|
|
stream.on('data', (chunk) => hash.update(chunk));
|
|
stream.on('end', resolve);
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate hash of a file or directory using streaming to minimize memory usage
|
|
*/
|
|
async calculateHash(sourcePath) {
|
|
const hash = crypto.createHash('sha256');
|
|
|
|
const isDir = (await fs.stat(sourcePath)).isDirectory();
|
|
|
|
if (isDir) {
|
|
// For directories, hash all files
|
|
const files = [];
|
|
async function collectFiles(dir) {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isFile()) {
|
|
files.push(path.join(dir, entry.name));
|
|
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
await collectFiles(path.join(dir, entry.name));
|
|
}
|
|
}
|
|
}
|
|
|
|
await collectFiles(sourcePath);
|
|
files.sort(); // Ensure consistent order
|
|
|
|
for (const file of files) {
|
|
const relativePath = path.relative(sourcePath, file);
|
|
// Hash the path first, then stream file contents
|
|
hash.update(relativePath + '|');
|
|
await this.hashFileStream(file, hash);
|
|
}
|
|
} else {
|
|
// For single files, stream directly into hash
|
|
await this.hashFileStream(sourcePath, hash);
|
|
}
|
|
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Cache a custom module source
|
|
* @param {string} moduleId - Module ID
|
|
* @param {string} sourcePath - Original source path
|
|
* @param {Object} metadata - Additional metadata to store
|
|
* @returns {Object} Cached module info
|
|
*/
|
|
async cacheModule(moduleId, sourcePath, metadata = {}) {
|
|
await this.ensureCacheDir();
|
|
|
|
const cacheDir = path.join(this.customCacheDir, moduleId);
|
|
const cacheManifest = await this.getCacheManifest();
|
|
|
|
// Check if already cached and unchanged
|
|
if (cacheManifest[moduleId]) {
|
|
const cached = cacheManifest[moduleId];
|
|
if (cached.originalHash && cached.originalHash === (await this.calculateHash(sourcePath))) {
|
|
// Source unchanged, return existing cache info
|
|
return {
|
|
moduleId,
|
|
cachePath: cacheDir,
|
|
...cached,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Remove existing cache if it exists
|
|
if (await fs.pathExists(cacheDir)) {
|
|
await fs.remove(cacheDir);
|
|
}
|
|
|
|
// Copy module to cache
|
|
await fs.copy(sourcePath, cacheDir, {
|
|
filter: (src) => {
|
|
const relative = path.relative(sourcePath, src);
|
|
// Skip node_modules, .git, and other common ignore patterns
|
|
return !relative.includes('node_modules') && !relative.startsWith('.git') && !relative.startsWith('.DS_Store');
|
|
},
|
|
});
|
|
|
|
// Calculate hash of the source
|
|
const sourceHash = await this.calculateHash(sourcePath);
|
|
const cacheHash = await this.calculateHash(cacheDir);
|
|
|
|
// Update manifest - don't store originalPath for source control friendliness
|
|
cacheManifest[moduleId] = {
|
|
originalHash: sourceHash,
|
|
cacheHash: cacheHash,
|
|
cachedAt: new Date().toISOString(),
|
|
...metadata,
|
|
};
|
|
|
|
await this.updateCacheManifest(cacheManifest);
|
|
|
|
return {
|
|
moduleId,
|
|
cachePath: cacheDir,
|
|
...cacheManifest[moduleId],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get cached module info
|
|
* @param {string} moduleId - Module ID
|
|
* @returns {Object|null} Cached module info or null
|
|
*/
|
|
async getCachedModule(moduleId) {
|
|
const cacheManifest = await this.getCacheManifest();
|
|
const cached = cacheManifest[moduleId];
|
|
|
|
if (!cached) {
|
|
return null;
|
|
}
|
|
|
|
const cacheDir = path.join(this.customCacheDir, moduleId);
|
|
|
|
if (!(await fs.pathExists(cacheDir))) {
|
|
// Cache dir missing, remove from manifest
|
|
delete cacheManifest[moduleId];
|
|
await this.updateCacheManifest(cacheManifest);
|
|
return null;
|
|
}
|
|
|
|
// Verify cache integrity
|
|
const currentCacheHash = await this.calculateHash(cacheDir);
|
|
if (currentCacheHash !== cached.cacheHash) {
|
|
console.warn(`Warning: Cache integrity check failed for ${moduleId}`);
|
|
}
|
|
|
|
return {
|
|
moduleId,
|
|
cachePath: cacheDir,
|
|
...cached,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all cached modules
|
|
* @returns {Array} Array of cached module info
|
|
*/
|
|
async getAllCachedModules() {
|
|
const cacheManifest = await this.getCacheManifest();
|
|
const cached = [];
|
|
|
|
for (const [moduleId, info] of Object.entries(cacheManifest)) {
|
|
const cachedModule = await this.getCachedModule(moduleId);
|
|
if (cachedModule) {
|
|
cached.push(cachedModule);
|
|
}
|
|
}
|
|
|
|
return cached;
|
|
}
|
|
|
|
/**
|
|
* Remove a cached module
|
|
* @param {string} moduleId - Module ID to remove
|
|
*/
|
|
async removeCachedModule(moduleId) {
|
|
const cacheManifest = await this.getCacheManifest();
|
|
const cacheDir = path.join(this.customCacheDir, moduleId);
|
|
|
|
// Remove cache directory
|
|
if (await fs.pathExists(cacheDir)) {
|
|
await fs.remove(cacheDir);
|
|
}
|
|
|
|
// Remove from manifest
|
|
delete cacheManifest[moduleId];
|
|
await this.updateCacheManifest(cacheManifest);
|
|
}
|
|
|
|
/**
|
|
* Sync cached modules with a list of module IDs
|
|
* @param {Array<string>} moduleIds - Module IDs to keep
|
|
*/
|
|
async syncCache(moduleIds) {
|
|
const cached = await this.getAllCachedModules();
|
|
|
|
for (const cachedModule of cached) {
|
|
if (!moduleIds.includes(cachedModule.moduleId)) {
|
|
await this.removeCachedModule(cachedModule.moduleId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = { CustomModuleCache };
|