automagik-genie
Version:
Universal AI development companion that can be initialized in any codebase
699 lines (579 loc) • 20.9 kB
JavaScript
const fs = require('fs').promises;
const path = require('path');
const { MetadataManager } = require('./metadata');
const { TemplateManager } = require('./templates');
const { BackupManager } = require('./backup');
const { DiffEngine } = require('./diff');
const { MergeEngine } = require('./merge');
const { UpdateUI } = require('./ui');
/**
* UpdateEngine - Main orchestrator for the update system
* Coordinates all update operations with safety checks and user consent
*/
class UpdateEngine {
constructor(options = {}) {
this.projectPath = options.projectPath || process.cwd();
this.metadata = new MetadataManager();
this.templates = new TemplateManager();
this.backup = new BackupManager();
this.diff = new DiffEngine();
this.merge = new MergeEngine();
this.ui = new UpdateUI();
}
/**
* Execute complete update process
* @param {Object} options - Update options
* @returns {Object} Update results
*/
async executeUpdate(options = {}) {
const {
dryRun = false,
force = false,
agentsOnly = false,
hooksOnly = false
} = options;
const updateResult = {
success: false,
phase: 'initialization',
results: {
analysis: null,
backup: null,
updates: [],
errors: [],
warnings: []
},
timestamp: new Date().toISOString()
};
try {
// Phase 1: Pre-update analysis
updateResult.phase = 'analysis';
this.ui.showPhase('Analysis', 'Analyzing current state and available updates...');
const analysis = await this.preUpdateAnalysis(options);
updateResult.results.analysis = analysis;
if (!analysis.hasUpdates) {
this.ui.showSuccess('No updates available - system is up to date');
updateResult.success = true;
return updateResult;
}
// Phase 2: User consent
updateResult.phase = 'consent';
this.ui.showPhase('Review', 'Presenting update options for user review...');
const userChoices = await this.getUserConsent(analysis, options);
if (userChoices.cancelled) {
this.ui.showInfo('Update cancelled by user');
updateResult.success = true;
return updateResult;
}
// Phase 3: Create backup
if (!dryRun) {
updateResult.phase = 'backup';
this.ui.showPhase('Backup', 'Creating safety backup of current files...');
const backupResult = await this.createUpdateBackup(analysis);
updateResult.results.backup = backupResult;
}
// Phase 4: Execute updates
updateResult.phase = 'execution';
this.ui.showPhase('Update', 'Applying updates based on your choices...');
const updateResults = await this.executeFileUpdates(analysis, userChoices, options);
updateResult.results.updates = updateResults;
// Phase 5: Post-update validation
updateResult.phase = 'validation';
this.ui.showPhase('Validation', 'Validating updated files and system state...');
const validation = await this.postUpdateValidation(updateResults);
updateResult.results.warnings = validation.warnings;
updateResult.results.errors = validation.errors;
if (validation.critical.length > 0) {
throw new Error(`Critical validation failures: ${validation.critical.join(', ')}`);
}
updateResult.success = true;
updateResult.phase = 'complete';
this.ui.showSuccess('Update completed successfully!');
this.ui.showUpdateSummary(updateResult.results);
return updateResult;
} catch (error) {
updateResult.success = false;
updateResult.error = error.message;
updateResult.results.errors.push(error.message);
await this.handleUpdateFailure(error, updateResult, options);
throw error;
}
}
/**
* Analyze current state vs latest templates
* @param {Object} options - Analysis options
* @returns {Object} Analysis results
*/
async preUpdateAnalysis(options) {
const analysis = {
currentVersion: await this.getCurrentVersion(),
latestVersion: null,
hasUpdates: false,
updateCategories: {
agents: [],
hooks: [],
core: []
},
fileAnalysis: [],
risks: [],
recommendations: []
};
try {
// Initialize metadata system
await this.metadata.initializeRegistries();
// Scan current files
const scanResults = await this.metadata.scanExistingFiles(this.projectPath);
analysis.currentFileCount = scanResults.agentCount + scanResults.hookCount;
// Fetch latest release information
const latestRelease = await this.templates.fetchLatestRelease();
analysis.latestVersion = latestRelease.version;
analysis.hasUpdates = analysis.currentVersion !== analysis.latestVersion;
if (!analysis.hasUpdates) {
return analysis;
}
// Download latest templates for comparison
await this.templates.downloadTemplate(analysis.latestVersion);
// Compare current files with latest templates
const comparison = await this.templates.compareWithTemplate(
this.projectPath,
analysis.latestVersion
);
// Analyze each changed file
for (const changedFile of comparison.files.different) {
const fileAnalysis = await this.analyzeFileUpdate(changedFile);
analysis.fileAnalysis.push(fileAnalysis);
// Categorize by type
const category = fileAnalysis.category;
if (analysis.updateCategories[category]) {
analysis.updateCategories[category].push(fileAnalysis);
}
}
// Add new files
for (const newFile of comparison.files.missing) {
const fileAnalysis = {
filePath: newFile.path,
fileName: path.basename(newFile.path),
category: newFile.category,
type: 'new-file',
action: 'add',
risk: 'low',
description: `New ${newFile.type} available: ${newFile.path}`
};
analysis.fileAnalysis.push(fileAnalysis);
if (analysis.updateCategories[newFile.category]) {
analysis.updateCategories[newFile.category].push(fileAnalysis);
}
}
// Generate recommendations and risk assessment
analysis.risks = this.assessUpdateRisks(analysis);
analysis.recommendations = this.generateRecommendations(analysis);
return analysis;
} catch (error) {
throw new Error(`Analysis failed: ${error.message}`);
}
}
/**
* Analyze update for a specific file
* @param {Object} fileInfo - File information from comparison
* @returns {Object} File analysis
*/
async analyzeFileUpdate(fileInfo) {
const filePath = path.join(this.projectPath, fileInfo.path);
const fileName = path.basename(fileInfo.path);
const analysis = {
filePath: fileInfo.path,
fileName,
category: fileInfo.category,
type: fileInfo.type,
action: 'update',
risk: 'medium',
description: '',
details: null
};
try {
// Check if file exists locally
if (await this.fileExists(filePath)) {
const currentContent = await fs.readFile(filePath, 'utf-8');
const templatePath = await this.templates.getCachedTemplate(
await this.getLatestVersion()
);
const templateFilePath = path.join(templatePath, 'files', fileInfo.path);
const templateContent = await fs.readFile(templateFilePath, 'utf-8');
// Use diff engine to analyze changes
analysis.details = await this.diff.analyzeAgentChanges(
fileName,
currentContent,
templateContent
);
// Set risk based on analysis
analysis.risk = this.calculateFileRisk(analysis.details);
analysis.description = this.generateFileDescription(analysis.details);
} else {
analysis.action = 'add';
analysis.risk = 'low';
analysis.description = `New ${fileInfo.type} will be added`;
}
} catch (error) {
analysis.risk = 'high';
analysis.description = `Error analyzing file: ${error.message}`;
}
return analysis;
}
/**
* Get user consent for updates
* @param {Object} analysis - Update analysis
* @param {Object} options - Options including force flag
* @returns {Object} User choices
*/
async getUserConsent(analysis, options) {
if (options.force) {
return this.generateDefaultChoices(analysis);
}
// Show analysis summary
await this.ui.showUpdateSummary(analysis);
// Get user choices for each category
const userChoices = {
cancelled: false,
global: {},
files: {}
};
// Global choices
userChoices.global = await this.ui.promptGlobalChoices(analysis);
if (userChoices.global.cancel) {
userChoices.cancelled = true;
return userChoices;
}
// File-specific choices for complex updates
const complexFiles = analysis.fileAnalysis.filter(f =>
f.risk === 'high' || (f.details && f.details.conflicts.length > 0)
);
if (complexFiles.length > 0) {
userChoices.files = await this.ui.promptFileChoices(complexFiles);
}
return userChoices;
}
/**
* Create backup before updates
* @param {Object} analysis - Update analysis
* @returns {Object} Backup result
*/
async createUpdateBackup(analysis) {
const filesToBackup = analysis.fileAnalysis
.filter(f => f.action === 'update')
.map(f => path.join(this.projectPath, f.filePath))
.filter(async (filePath) => await this.fileExists(filePath));
const backupMetadata = {
type: 'update-backup',
version: analysis.currentVersion,
targetVersion: analysis.latestVersion,
fileCount: filesToBackup.length,
timestamp: new Date().toISOString()
};
return await this.backup.createBackup(filesToBackup, backupMetadata);
}
/**
* Execute file updates based on analysis and user choices
* @param {Object} analysis - Update analysis
* @param {Object} userChoices - User preferences
* @param {Object} options - Update options
* @returns {Array} Update results for each file
*/
async executeFileUpdates(analysis, userChoices, options) {
const updateResults = [];
const { dryRun = false } = options;
for (const fileAnalysis of analysis.fileAnalysis) {
const fileChoice = userChoices.files[fileAnalysis.filePath] ||
userChoices.global.defaultAction ||
'auto';
if (fileChoice === 'skip') {
updateResults.push({
filePath: fileAnalysis.filePath,
action: 'skipped',
success: true,
message: 'Skipped by user choice'
});
continue;
}
try {
let result;
if (fileAnalysis.action === 'add') {
result = await this.addNewFile(fileAnalysis, options);
} else if (fileAnalysis.action === 'update') {
result = await this.updateExistingFile(fileAnalysis, fileChoice, options);
}
updateResults.push({
filePath: fileAnalysis.filePath,
action: fileAnalysis.action,
success: true,
result
});
// Update metadata registry
if (!dryRun) {
await this.updateFileMetadata(fileAnalysis);
}
} catch (error) {
updateResults.push({
filePath: fileAnalysis.filePath,
action: fileAnalysis.action,
success: false,
error: error.message
});
}
}
return updateResults;
}
/**
* Add new file from template
* @param {Object} fileAnalysis - File analysis
* @param {Object} options - Options
* @returns {Object} Add result
*/
async addNewFile(fileAnalysis, options) {
const { dryRun = false } = options;
const templatePath = await this.templates.getCachedTemplate(
await this.getLatestVersion()
);
const sourceFile = path.join(templatePath, 'files', fileAnalysis.filePath);
const targetFile = path.join(this.projectPath, fileAnalysis.filePath);
if (dryRun) {
return {
action: 'add',
message: `Would add new file: ${fileAnalysis.filePath}`
};
}
// Ensure target directory exists
await fs.mkdir(path.dirname(targetFile), { recursive: true });
// Copy template file
const content = await fs.readFile(sourceFile, 'utf-8');
await fs.writeFile(targetFile, content, 'utf-8');
return {
action: 'added',
message: `Added new file: ${fileAnalysis.filePath}`
};
}
/**
* Update existing file
* @param {Object} fileAnalysis - File analysis
* @param {string} userChoice - User's choice for this file
* @param {Object} options - Options
* @returns {Object} Update result
*/
async updateExistingFile(fileAnalysis, userChoice, options) {
const filePath = path.join(this.projectPath, fileAnalysis.filePath);
if (!fileAnalysis.details) {
throw new Error('No detailed analysis available for file update');
}
// Convert user choice to merge engine format
const mergeChoices = this.convertUserChoiceToMergeChoices(
fileAnalysis.details,
userChoice
);
return await this.merge.executeMerge(
filePath,
fileAnalysis.details,
mergeChoices,
{
dryRun: options.dryRun,
createBackup: false // Already created system backup
}
);
}
/**
* Post-update validation
* @param {Array} updateResults - Results from file updates
* @returns {Object} Validation results
*/
async postUpdateValidation(updateResults) {
const validation = {
success: true,
warnings: [],
errors: [],
critical: []
};
// Check for failed updates
const failedUpdates = updateResults.filter(r => !r.success);
if (failedUpdates.length > 0) {
validation.errors.push(`${failedUpdates.length} files failed to update`);
}
// Validate .claude directory structure
const claudeDir = path.join(this.projectPath, '.claude');
if (await this.fileExists(claudeDir)) {
const requiredDirs = ['agents', 'hooks/examples'];
for (const dir of requiredDirs) {
const dirPath = path.join(claudeDir, dir);
if (!await this.fileExists(dirPath)) {
validation.warnings.push(`Missing directory: ${dir}`);
}
}
} else {
validation.critical.push('.claude directory is missing');
}
// Check for conflict markers in files
const updatedFiles = updateResults
.filter(r => r.success && r.action === 'update')
.map(r => path.join(this.projectPath, r.filePath));
for (const filePath of updatedFiles) {
try {
const content = await fs.readFile(filePath, 'utf-8');
if (content.includes('<<<<<<< CURRENT')) {
validation.warnings.push(`Unresolved conflicts in: ${path.basename(filePath)}`);
}
} catch (error) {
validation.errors.push(`Cannot validate file: ${path.basename(filePath)}`);
}
}
// Update system version if successful
if (validation.critical.length === 0) {
await this.updateSystemVersion();
}
return validation;
}
/**
* Handle update failure with recovery
* @param {Error} error - The error that occurred
* @param {Object} updateResult - Current update result
* @param {Object} options - Update options
*/
async handleUpdateFailure(error, updateResult, options) {
this.ui.showError(`Update failed in ${updateResult.phase}: ${error.message}`);
// If we have a backup and we're past the backup phase, offer to restore
if (updateResult.results.backup &&
['execution', 'validation'].includes(updateResult.phase)) {
if (!options.dryRun) {
const shouldRestore = await this.ui.promptRestoreFromBackup();
if (shouldRestore) {
try {
await this.backup.restoreFromBackup(updateResult.results.backup.backupId);
this.ui.showSuccess('Successfully restored from backup');
} catch (restoreError) {
this.ui.showError(`Failed to restore from backup: ${restoreError.message}`);
}
}
}
}
}
// Helper methods
async getCurrentVersion() {
const packageJson = require(path.join(__dirname, '../../package.json'));
return packageJson.version;
}
async getLatestVersion() {
const release = await this.templates.fetchLatestRelease();
return release.version;
}
async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch (error) {
return false;
}
}
calculateFileRisk(details) {
if (!details) return 'medium';
if (details.conflicts.length > 0) return 'high';
if (details.confidence === 'low') return 'high';
if (details.userSections.length > 2) return 'medium';
return 'low';
}
generateFileDescription(details) {
if (!details) return 'File will be updated';
const parts = [];
if (details.templateSections.length > 0) {
parts.push(`${details.templateSections.length} template sections updated`);
}
if (details.userSections.length > 0) {
parts.push(`${details.userSections.length} user sections preserved`);
}
if (details.conflicts.length > 0) {
parts.push(`${details.conflicts.length} conflicts require resolution`);
}
return parts.join(', ') || 'Template updates available';
}
assessUpdateRisks(analysis) {
const risks = [];
const highRiskFiles = analysis.fileAnalysis.filter(f => f.risk === 'high');
if (highRiskFiles.length > 0) {
risks.push({
level: 'high',
description: `${highRiskFiles.length} files have high-risk changes`,
files: highRiskFiles.map(f => f.fileName)
});
}
const conflictFiles = analysis.fileAnalysis.filter(f =>
f.details && f.details.conflicts.length > 0
);
if (conflictFiles.length > 0) {
risks.push({
level: 'medium',
description: `${conflictFiles.length} files have conflicts`,
files: conflictFiles.map(f => f.fileName)
});
}
return risks;
}
generateRecommendations(analysis) {
const recommendations = [];
if (analysis.risks.some(r => r.level === 'high')) {
recommendations.push('Review high-risk changes carefully before proceeding');
}
if (analysis.fileAnalysis.length > 10) {
recommendations.push('Consider updating in smaller batches');
}
recommendations.push('Backup will be created automatically for safety');
return recommendations;
}
generateDefaultChoices(analysis) {
return {
cancelled: false,
global: {
defaultAction: 'auto',
cancel: false
},
files: {}
};
}
convertUserChoiceToMergeChoices(details, userChoice) {
const choices = {};
// Apply user choice to all sections
for (const section of details.templateSections) {
choices[section.title] = userChoice === 'auto' ? 'accept' : userChoice;
}
for (const conflict of details.conflicts) {
choices[conflict.title] = userChoice === 'auto' ? 'manual-merge' : userChoice;
}
return choices;
}
async updateFileMetadata(fileAnalysis) {
const filePath = path.join(this.projectPath, fileAnalysis.filePath);
if (await this.fileExists(filePath)) {
const content = await fs.readFile(filePath, 'utf-8');
const stats = await fs.stat(filePath);
const metadata = {
checksum: this.calculateChecksum(content),
size: stats.size,
modified: stats.mtime.toISOString(),
templateVersion: await this.getLatestVersion(),
lastUpdated: new Date().toISOString()
};
const agentName = path.basename(fileAnalysis.filePath, '.md');
await this.metadata.updateAgentRegistry(agentName, metadata);
}
}
calculateChecksum(content) {
const crypto = require('crypto');
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
}
async updateSystemVersion() {
const systemVersion = await this.metadata.loadSystemVersion();
systemVersion.installedVersion = await this.getLatestVersion();
systemVersion.lastUpdateCheck = new Date().toISOString();
systemVersion.updateHistory.push({
timestamp: new Date().toISOString(),
fromVersion: await this.getCurrentVersion(),
toVersion: await this.getLatestVersion(),
success: true
});
await this.metadata.saveSystemVersion(systemVersion);
}
}
module.exports = { UpdateEngine };