hash file change checking integrated

This commit is contained in:
Brian Madison
2025-09-30 21:20:13 -05:00
parent c42cd48421
commit 05a3b4f3f1
316 changed files with 354 additions and 38125 deletions

View File

@@ -219,26 +219,59 @@ class Installer {
config._isUpdate = true;
config._existingInstall = existingInstall;
// Detect custom files BEFORE updating (compare current files vs manifest)
const existingManifest = await this.manifest.read(bmadDir);
config._customFiles = await this.detectCustomFiles(bmadDir, existingManifest);
// Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
const existingFilesManifest = await this.readFilesManifest(bmadDir);
console.log(chalk.dim(`DEBUG: Read ${existingFilesManifest.length} files from manifest`));
console.log(chalk.dim(`DEBUG: Manifest has hashes: ${existingFilesManifest.some((f) => f.hash)}`));
const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest);
console.log(chalk.dim(`DEBUG: Found ${customFiles.length} custom files, ${modifiedFiles.length} modified files`));
if (modifiedFiles.length > 0) {
console.log(chalk.yellow('DEBUG: Modified files:'));
for (const f of modifiedFiles) console.log(chalk.dim(` - ${f.path}`));
}
config._customFiles = customFiles;
config._modifiedFiles = modifiedFiles;
// If there are custom files, back them up temporarily
if (config._customFiles.length > 0) {
if (customFiles.length > 0) {
const tempBackupDir = path.join(projectDir, '.bmad-custom-backup-temp');
await fs.ensureDir(tempBackupDir);
spinner.start(`Backing up ${config._customFiles.length} custom files...`);
for (const customFile of config._customFiles) {
spinner.start(`Backing up ${customFiles.length} custom files...`);
for (const customFile of customFiles) {
const relativePath = path.relative(bmadDir, customFile);
const backupPath = path.join(tempBackupDir, relativePath);
await fs.ensureDir(path.dirname(backupPath));
await fs.copy(customFile, backupPath);
}
spinner.succeed(`Backed up ${config._customFiles.length} custom files`);
spinner.succeed(`Backed up ${customFiles.length} custom files`);
config._tempBackupDir = tempBackupDir;
}
// For modified files, back them up to temp directory (will be restored as .bak files after install)
if (modifiedFiles.length > 0) {
const tempModifiedBackupDir = path.join(projectDir, '.bmad-modified-backup-temp');
await fs.ensureDir(tempModifiedBackupDir);
console.log(chalk.yellow(`\nDEBUG: Backing up ${modifiedFiles.length} modified files to temp location`));
spinner.start(`Backing up ${modifiedFiles.length} modified files...`);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
console.log(chalk.dim(`DEBUG: Backing up ${relativePath} to temp`));
await fs.ensureDir(path.dirname(tempBackupPath));
await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true });
}
spinner.succeed(`Backed up ${modifiedFiles.length} modified files`);
config._tempModifiedBackupDir = tempModifiedBackupDir;
} else {
console.log(chalk.dim('DEBUG: No modified files detected'));
}
}
}
@@ -307,12 +340,23 @@ class Installer {
spinner.succeed(`Agent configurations created: ${agentConfigResult.created}`);
}
// Generate CSV manifests for workflows, agents, and tasks BEFORE IDE setup
// Pre-register manifest files that will be created (except files-manifest.csv to avoid recursion)
const cfgDir = path.join(bmadDir, '_cfg');
this.installedFiles.push(
path.join(cfgDir, 'manifest.csv'),
path.join(cfgDir, 'manifest.yaml'),
path.join(cfgDir, 'workflow-manifest.csv'),
path.join(cfgDir, 'agent-manifest.csv'),
path.join(cfgDir, 'task-manifest.csv'),
);
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup
spinner.start('Generating workflow and agent manifests...');
const manifestGen = new ManifestGenerator();
const manifestStats = await manifestGen.generateManifests(bmadDir, config.modules || []);
const manifestStats = await manifestGen.generateManifests(bmadDir, config.modules || [], this.installedFiles);
spinner.succeed(
`Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks`,
`Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.files} files`,
);
// Configure IDEs and copy documentation
@@ -403,31 +447,59 @@ class Installer {
// If this was an update, restore custom files
let customFiles = [];
if (config._isUpdate && config._customFiles && config._customFiles.length > 0) {
spinner.start(`Restoring ${config._customFiles.length} custom files...`);
let modifiedFiles = [];
if (config._isUpdate) {
if (config._customFiles && config._customFiles.length > 0) {
spinner.start(`Restoring ${config._customFiles.length} custom files...`);
for (const originalPath of config._customFiles) {
const relativePath = path.relative(bmadDir, originalPath);
const backupPath = path.join(config._tempBackupDir, relativePath);
for (const originalPath of config._customFiles) {
const relativePath = path.relative(bmadDir, originalPath);
const backupPath = path.join(config._tempBackupDir, relativePath);
if (await fs.pathExists(backupPath)) {
await fs.ensureDir(path.dirname(originalPath));
await fs.copy(backupPath, originalPath, { overwrite: true });
if (await fs.pathExists(backupPath)) {
await fs.ensureDir(path.dirname(originalPath));
await fs.copy(backupPath, originalPath, { overwrite: true });
}
}
// Clean up temp backup
if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) {
await fs.remove(config._tempBackupDir);
}
spinner.succeed(`Restored ${config._customFiles.length} custom files`);
customFiles = config._customFiles;
}
if (config._modifiedFiles && config._modifiedFiles.length > 0) {
modifiedFiles = config._modifiedFiles;
// Restore modified files as .bak files
if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) {
spinner.start(`Restoring ${modifiedFiles.length} modified files as .bak...`);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(config._tempModifiedBackupDir, relativePath);
const bakPath = modifiedFile.path + '.bak';
if (await fs.pathExists(tempBackupPath)) {
await fs.ensureDir(path.dirname(bakPath));
await fs.copy(tempBackupPath, bakPath, { overwrite: true });
}
}
// Clean up temp backup
await fs.remove(config._tempModifiedBackupDir);
spinner.succeed(`Restored ${modifiedFiles.length} modified files as .bak`);
}
}
// Clean up temp backup
if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) {
await fs.remove(config._tempBackupDir);
}
spinner.succeed(`Restored ${config._customFiles.length} custom files`);
customFiles = config._customFiles;
}
spinner.stop();
// Report custom files if any were found
// Report custom and modified files if any were found
if (customFiles.length > 0) {
console.log(chalk.cyan(`\n📁 Custom files preserved: ${customFiles.length}`));
console.log(chalk.dim('The following custom files were found and restored:\n'));
@@ -437,6 +509,16 @@ class Installer {
console.log('');
}
if (modifiedFiles.length > 0) {
console.log(chalk.yellow(`\n⚠️ Modified files detected: ${modifiedFiles.length}`));
console.log(chalk.dim('The following files were modified and backed up with .bak extension:\n'));
for (const file of modifiedFiles) {
console.log(chalk.dim(` - ${file.relativePath}${file.relativePath}.bak`));
}
console.log(chalk.dim('\nThese files have been updated with the new version.'));
console.log(chalk.dim('Review the .bak files to see your changes and merge if needed.\n'));
}
// Display completion message
const { UI } = require('../../../lib/ui');
const ui = new UI();
@@ -643,6 +725,9 @@ class Installer {
// Write the clean config file
await fs.writeFile(configPath, header + yamlContent, 'utf8');
// Track the config file in installedFiles
this.installedFiles.push(configPath);
}
}
}
@@ -1007,25 +1092,89 @@ class Installer {
}
/**
* Detect custom files that were not installed by the installer
* Read files-manifest.csv
* @param {string} bmadDir - BMAD installation directory
* @param {Object} existingManifest - Previous installation manifest
* @returns {Array} List of custom files found
* @returns {Array} Array of file entries from files-manifest.csv
*/
async detectCustomFiles(bmadDir, existingManifest) {
const customFiles = [];
async readFilesManifest(bmadDir) {
const filesManifestPath = path.join(bmadDir, '_cfg', 'files-manifest.csv');
if (!(await fs.pathExists(filesManifestPath))) {
return [];
}
// Build set of previously installed files from manifest
const installedSet = new Set();
if (existingManifest && existingManifest.files) {
for (const fileEntry of existingManifest.files) {
if (fileEntry.file) {
// Files in manifest are stored as relative paths starting with 'bmad/'
// Convert to absolute path
const relativePath = fileEntry.file.startsWith('bmad/') ? fileEntry.file.slice(5) : fileEntry.file;
const absolutePath = path.join(bmadDir, relativePath);
installedSet.add(path.normalize(absolutePath));
try {
const content = await fs.readFile(filesManifestPath, 'utf8');
const lines = content.split('\n');
const files = [];
for (let i = 1; i < lines.length; i++) {
// Skip header
const line = lines[i].trim();
if (!line) continue;
// Parse CSV line properly handling quoted values
const parts = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
} else {
current += char;
}
}
parts.push(current); // Add last part
if (parts.length >= 4) {
files.push({
type: parts[0],
name: parts[1],
module: parts[2],
path: parts[3],
hash: parts[4] || null, // Hash may not exist in old manifests
});
}
}
return files;
} catch (error) {
console.warn('Warning: Could not read files-manifest.csv:', error.message);
return [];
}
}
/**
* Detect custom and modified files
* @param {string} bmadDir - BMAD installation directory
* @param {Array} existingFilesManifest - Previous files from files-manifest.csv
* @returns {Object} Object with customFiles and modifiedFiles arrays
*/
async detectCustomFiles(bmadDir, existingFilesManifest) {
const customFiles = [];
const modifiedFiles = [];
// Check if the manifest has hashes - if not, we can't detect modifications
let manifestHasHashes = false;
if (existingFilesManifest && existingFilesManifest.length > 0) {
manifestHasHashes = existingFilesManifest.some((f) => f.hash);
}
// Build map of previously installed files from files-manifest.csv with their hashes
const installedFilesMap = new Map();
for (const fileEntry of existingFilesManifest) {
if (fileEntry.path) {
// Files in manifest are stored as relative paths starting with 'bmad/'
// Convert to absolute path
const relativePath = fileEntry.path.startsWith('bmad/') ? fileEntry.path.slice(5) : fileEntry.path;
const absolutePath = path.join(bmadDir, relativePath);
installedFilesMap.set(path.normalize(absolutePath), {
hash: fileEntry.hash,
relativePath: relativePath,
});
}
}
@@ -1044,10 +1193,39 @@ class Installer {
await scanDirectory(fullPath);
} else if (entry.isFile()) {
const normalizedPath = path.normalize(fullPath);
// If file is not in the previous manifest, it's custom
if (!installedSet.has(normalizedPath)) {
customFiles.push(fullPath);
const fileInfo = installedFilesMap.get(normalizedPath);
// Skip certain system files that are auto-generated
const relativePath = path.relative(bmadDir, fullPath);
const fileName = path.basename(fullPath);
// Skip _cfg directory - system files
if (relativePath.startsWith('_cfg/') || relativePath.startsWith('_cfg\\')) {
continue;
}
// Skip config.yaml files - these are regenerated on each install/update
// Users should use _cfg/agents/ override files instead
if (fileName === 'config.yaml') {
continue;
}
if (!fileInfo) {
// File not in manifest = custom file
customFiles.push(fullPath);
} else if (manifestHasHashes && fileInfo.hash) {
// File in manifest with hash - check if it was modified
const currentHash = await this.manifest.calculateFileHash(fullPath);
if (currentHash && currentHash !== fileInfo.hash) {
// Hash changed = file was modified
modifiedFiles.push({
path: fullPath,
relativePath: fileInfo.relativePath,
});
}
}
// If manifest doesn't have hashes, we can't detect modifications
// so we just skip files that are in the manifest
}
}
} catch {
@@ -1056,7 +1234,7 @@ class Installer {
};
await scanDirectory(bmadDir);
return customFiles;
return { customFiles, modifiedFiles };
}
/**
@@ -1172,6 +1350,7 @@ class Installer {
configContent += processedTemplate;
await fs.writeFile(configPath, configContent, 'utf8');
this.installedFiles.push(configPath); // Track agent config files
createdCount++;
}

View File

@@ -1,6 +1,7 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const crypto = require('node:crypto');
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
/**
@@ -19,14 +20,17 @@ class ManifestGenerator {
* Generate all manifests for the installation
* @param {string} bmadDir - BMAD installation directory
* @param {Array} selectedModules - Selected modules for installation
* @param {Array} installedFiles - All installed files (optional, for hash tracking)
*/
async generateManifests(bmadDir, selectedModules) {
async generateManifests(bmadDir, selectedModules, installedFiles = []) {
// Create _cfg directory if it doesn't exist
const cfgDir = path.join(bmadDir, '_cfg');
await fs.ensureDir(cfgDir);
// Store modules list
this.modules = ['core', ...selectedModules];
this.bmadDir = bmadDir;
this.allInstalledFiles = installedFiles;
// Collect workflow data
await this.collectWorkflows(selectedModules);
@@ -37,18 +41,21 @@ class ManifestGenerator {
// Collect task data
await this.collectTasks(selectedModules);
// Write manifest files
await this.writeMainManifest(cfgDir);
await this.writeWorkflowManifest(cfgDir);
await this.writeAgentManifest(cfgDir);
await this.writeTaskManifest(cfgDir);
await this.writeFilesManifest(cfgDir);
// Write manifest files and collect their paths
const manifestFiles = [
await this.writeMainManifest(cfgDir),
await this.writeWorkflowManifest(cfgDir),
await this.writeAgentManifest(cfgDir),
await this.writeTaskManifest(cfgDir),
await this.writeFilesManifest(cfgDir),
];
return {
workflows: this.workflows.length,
agents: this.agents.length,
tasks: this.tasks.length,
files: this.files.length,
manifestFiles: manifestFiles,
};
}
@@ -278,6 +285,7 @@ class ManifestGenerator {
/**
* Write main manifest as YAML with installation info only
* @returns {string} Path to the manifest file
*/
async writeMainManifest(cfgDir) {
const manifestPath = path.join(cfgDir, 'manifest.yaml');
@@ -304,10 +312,12 @@ class ManifestGenerator {
});
await fs.writeFile(manifestPath, yamlStr);
return manifestPath;
}
/**
* Write workflow manifest CSV
* @returns {string} Path to the manifest file
*/
async writeWorkflowManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'workflow-manifest.csv');
@@ -321,10 +331,12 @@ class ManifestGenerator {
}
await fs.writeFile(csvPath, csv);
return csvPath;
}
/**
* Write agent manifest CSV
* @returns {string} Path to the manifest file
*/
async writeAgentManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
@@ -338,10 +350,12 @@ class ManifestGenerator {
}
await fs.writeFile(csvPath, csv);
return csvPath;
}
/**
* Write task manifest CSV
* @returns {string} Path to the manifest file
*/
async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
@@ -355,30 +369,85 @@ class ManifestGenerator {
}
await fs.writeFile(csvPath, csv);
return csvPath;
}
/**
* Write files manifest CSV
*/
/**
* 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 '';
}
}
/**
* @returns {string} Path to the manifest file
*/
async writeFilesManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'files-manifest.csv');
// Create CSV header
let csv = 'type,name,module,path\n';
// Create CSV header with hash column
let csv = 'type,name,module,path,hash\n';
// Sort files by type, then module, then name
this.files.sort((a, b) => {
if (a.type !== b.type) return a.type.localeCompare(b.type);
// If we have ALL installed files, use those instead of just workflows/agents/tasks
const allFiles = [];
if (this.allInstalledFiles && this.allInstalledFiles.length > 0) {
// Process all installed files
for (const filePath of this.allInstalledFiles) {
const relativePath = 'bmad' + filePath.replace(this.bmadDir, '').replaceAll('\\', '/');
const ext = path.extname(filePath).toLowerCase();
const fileName = path.basename(filePath, ext);
// Determine module from path
const pathParts = relativePath.split('/');
const module = pathParts.length > 1 ? pathParts[1] : 'unknown';
// Calculate hash
const hash = await this.calculateFileHash(filePath);
allFiles.push({
type: ext.slice(1) || 'file',
name: fileName,
module: module,
path: relativePath,
hash: hash,
});
}
} else {
// Fallback: use the collected workflows/agents/tasks
for (const file of this.files) {
const filePath = path.join(this.bmadDir, file.path.replace('bmad/', ''));
const hash = await this.calculateFileHash(filePath);
allFiles.push({
...file,
hash: hash,
});
}
}
// Sort files by module, then type, then name
allFiles.sort((a, b) => {
if (a.module !== b.module) return a.module.localeCompare(b.module);
if (a.type !== b.type) return a.type.localeCompare(b.type);
return a.name.localeCompare(b.name);
});
// Add rows
for (const file of this.files) {
csv += `"${file.type}","${file.name}","${file.module}","${file.path}"\n`;
for (const file of allFiles) {
csv += `"${file.type}","${file.name}","${file.module}","${file.path}","${file.hash}"\n`;
}
await fs.writeFile(csvPath, csv);
return csvPath;
}
}

View File

@@ -1,12 +1,13 @@
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 to track
* @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.csv');
@@ -22,16 +23,13 @@ class Manifest {
}
const moduleConfigs = await this.loadModuleConfigs(allModules);
// Parse installed files to extract metadata - pass bmadDir for relative paths
const fileMetadata = await this.parseInstalledFiles(installedFiles, bmadDir);
// Don't store installation path in manifest
// Generate CSV content
const csvContent = this.generateManifestCsv({ ...data, modules: allModules }, fileMetadata, moduleConfigs);
// Generate CSV content (no file metadata)
const csvContent = this.generateManifestCsv({ ...data, modules: allModules }, [], moduleConfigs);
await fs.writeFile(manifestPath, csvContent, 'utf8');
return { success: true, path: manifestPath, filesTracked: fileMetadata.length };
return { success: true, path: manifestPath, filesTracked: 0 };
}
/**
@@ -142,6 +140,20 @@ class Manifest {
}
}
/**
* 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
@@ -156,7 +168,10 @@ class Manifest {
// Make path relative to parent of bmad directory, starting with 'bmad/'
const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/');
// Handle markdown files - extract XML metadata
// 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)) {
@@ -164,20 +179,32 @@ class Manifest {
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, etc.)
// 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,
});
}
}
@@ -268,13 +295,8 @@ class Manifest {
csv.push('');
}
// Files section
if (fileMetadata.length > 0) {
csv.push('## Files', 'Type,Path,Name,Title');
for (const file of fileMetadata) {
csv.push([file.type || '', file.file || '', file.name || '', file.title || ''].map((v) => this.escapeCsv(v)).join(','));
}
}
// Files section - NO LONGER USED
// Files are now tracked in files-manifest.csv by ManifestGenerator
return csv.join('\n');
}
@@ -357,8 +379,8 @@ class Manifest {
break;
}
case 'files': {
// Skip header row
if (line === 'Type,Path,Name,Title') continue;
// 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) {
@@ -367,6 +389,7 @@ class Manifest {
file: parts[1] || '',
name: parts[2] || null,
title: parts[3] || null,
hash: parts[4] || null, // Hash column (may not exist in old manifests)
});
}