UNPKG

devflow-ai

Version:

Enterprise-grade AI agent orchestration with swarm management UI dashboard

406 lines (321 loc) • 12 kB
/** * Rollback Manager - Handles rollback operations and backup management */ import * as fs from 'fs-extra'; import * as path from 'path'; import * as crypto from 'crypto'; import type { MigrationBackup, BackupFile } from './types.js'; import { logger } from './logger.js'; import * as chalk from 'chalk'; import * as inquirer from 'inquirer'; export class RollbackManager { private projectPath: string; private backupDir: string; constructor(projectPath: string, backupDir: string = '.claude-backup') { this.projectPath = projectPath; this.backupDir = path.join(projectPath, backupDir); } async createBackup(metadata: Record<string, any> = {}): Promise<MigrationBackup> { const timestamp = new Date(); const backupId = timestamp.toISOString().replace(/[:.]/g, '-'); const backupPath = path.join(this.backupDir, backupId); logger.info(`Creating backup at ${backupPath}...`); await fs.ensureDir(backupPath); const backup: MigrationBackup = { timestamp, version: '1.0.0', files: [], metadata: { projectPath: this.projectPath, backupId, ...metadata, }, }; // Backup critical files and directories const backupTargets = [ '.claude', 'CLAUDE.md', '.roomodes', 'package.json', 'memory/memory-store.json', 'coordination/config.json', ]; for (const target of backupTargets) { const sourcePath = path.join(this.projectPath, target); const targetPath = path.join(backupPath, target); if (await fs.pathExists(sourcePath)) { const stats = await fs.stat(sourcePath); if (stats.isDirectory()) { await this.backupDirectory(sourcePath, targetPath, backup); } else { await this.backupFile(sourcePath, targetPath, backup, target); } } } // Save backup manifest const manifestPath = path.join(backupPath, 'backup-manifest.json'); await fs.writeJson(manifestPath, backup, { spaces: 2 }); // Update backup index await this.updateBackupIndex(backup); logger.success(`Backup created with ${backup.files.length} files`); return backup; } private async backupDirectory( sourcePath: string, targetPath: string, backup: MigrationBackup, ): Promise<void> { await fs.ensureDir(targetPath); const entries = await fs.readdir(sourcePath); for (const entry of entries) { const entrySource = path.join(sourcePath, entry); const entryTarget = path.join(targetPath, entry); const stats = await fs.stat(entrySource); if (stats.isDirectory()) { await this.backupDirectory(entrySource, entryTarget, backup); } else { const relativePath = path.relative(this.projectPath, entrySource); await this.backupFile(entrySource, entryTarget, backup, relativePath); } } } private async backupFile( sourcePath: string, targetPath: string, backup: MigrationBackup, relativePath: string, ): Promise<void> { const content = await fs.readFile(sourcePath, 'utf-8'); const checksum = crypto.createHash('sha256').update(content).digest('hex'); await fs.ensureDir(path.dirname(targetPath)); await fs.writeFile(targetPath, content); const backupFile: BackupFile = { path: relativePath, content, checksum, permissions: (await fs.stat(sourcePath)).mode.toString(8), }; backup.files.push(backupFile); } async listBackups(): Promise<MigrationBackup[]> { if (!(await fs.pathExists(this.backupDir))) { return []; } const backupFolders = await fs.readdir(this.backupDir); const backups: MigrationBackup[] = []; for (const folder of backupFolders.sort().reverse()) { const manifestPath = path.join(this.backupDir, folder, 'backup-manifest.json'); if (await fs.pathExists(manifestPath)) { try { const backup = await fs.readJson(manifestPath); backups.push(backup); } catch (error) { logger.warn( `Invalid backup manifest in ${folder}: ${error instanceof Error ? error.message : String(error)}`, ); } } } return backups; } async rollback(backupId?: string, interactive: boolean = true): Promise<void> { const backups = await this.listBackups(); if (backups.length === 0) { throw new Error('No backups found'); } let selectedBackup: MigrationBackup; if (backupId) { selectedBackup = backups.find((b) => b.metadata.backupId === backupId); if (!selectedBackup) { throw new Error(`Backup not found: ${backupId}`); } } else if (interactive) { selectedBackup = await this.selectBackupInteractively(backups); } else { selectedBackup = backups[0]; // Most recent } logger.info(`Rolling back to backup from ${selectedBackup.timestamp.toISOString()}...`); // Confirm rollback if (interactive) { const confirm = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: `Are you sure you want to rollback? This will overwrite current files.`, default: false, }, ]); if (!confirm.proceed) { logger.info('Rollback cancelled'); return; } } // Create pre-rollback backup const preRollbackBackup = await this.createBackup({ type: 'pre-rollback', rollingBackTo: selectedBackup.metadata.backupId, }); try { // Restore files await this.restoreFiles(selectedBackup); // Validate restoration await this.validateRestore(selectedBackup); logger.success('Rollback completed successfully'); } catch (error) { logger.error('Rollback failed, attempting to restore pre-rollback state...'); try { await this.restoreFiles(preRollbackBackup); logger.success('Pre-rollback state restored'); } catch (restoreError) { logger.error('Failed to restore pre-rollback state:', restoreError); throw new Error('Rollback failed and unable to restore previous state'); } throw error; } } private async selectBackupInteractively(backups: MigrationBackup[]): Promise<MigrationBackup> { const choices = backups.map((backup) => ({ name: `${backup.timestamp.toLocaleString()} - ${backup.files.length} files (${backup.metadata.type || 'migration'})`, value: backup, short: backup.metadata.backupId, })); const answer = await inquirer.prompt([ { type: 'list', name: 'backup', message: 'Select backup to rollback to:', choices, pageSize: 10, }, ]); return answer.backup; } private async restoreFiles(backup: MigrationBackup): Promise<void> { logger.info(`Restoring ${backup.files.length} files...`); for (const file of backup.files) { const targetPath = path.join(this.projectPath, file.path); logger.debug(`Restoring ${file.path}`); await fs.ensureDir(path.dirname(targetPath)); await fs.writeFile(targetPath, file.content); // Restore permissions if available if (file.permissions) { try { await fs.chmod(targetPath, parseInt(file.permissions, 8)); } catch (error) { logger.warn( `Could not restore permissions for ${file.path}: ${error instanceof Error ? error.message : String(error)}`, ); } } } } private async validateRestore(backup: MigrationBackup): Promise<void> { logger.info('Validating restored files...'); const errors: string[] = []; for (const file of backup.files) { const filePath = path.join(this.projectPath, file.path); if (!(await fs.pathExists(filePath))) { errors.push(`Missing file: ${file.path}`); continue; } const content = await fs.readFile(filePath, 'utf-8'); const checksum = crypto.createHash('sha256').update(content).digest('hex'); if (checksum !== file.checksum) { errors.push(`Checksum mismatch: ${file.path}`); } } if (errors.length > 0) { throw new Error(`Validation failed:\n${errors.join('\n')}`); } logger.success('Validation passed'); } async cleanupOldBackups(retentionDays: number = 30, maxBackups: number = 10): Promise<void> { const backups = await this.listBackups(); if (backups.length <= maxBackups) { return; // No cleanup needed } const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - retentionDays); const backupsToDelete = backups.filter((backup, index) => { // Keep the most recent maxBackups if (index < maxBackups) { return false; } // Delete old backups return backup.timestamp < cutoffDate; }); if (backupsToDelete.length === 0) { return; } logger.info(`Cleaning up ${backupsToDelete.length} old backups...`); for (const backup of backupsToDelete) { const backupPath = path.join(this.backupDir, backup.metadata.backupId); await fs.remove(backupPath); logger.debug(`Removed backup: ${backup.metadata.backupId}`); } logger.success(`Cleanup completed, removed ${backupsToDelete.length} backups`); } async getBackupInfo(backupId: string): Promise<MigrationBackup | null> { const backups = await this.listBackups(); return backups.find((b) => b.metadata.backupId === backupId) || null; } async exportBackup(backupId: string, exportPath: string): Promise<void> { const backup = await this.getBackupInfo(backupId); if (!backup) { throw new Error(`Backup not found: ${backupId}`); } const backupPath = path.join(this.backupDir, backup.metadata.backupId); await fs.copy(backupPath, exportPath); logger.success(`Backup exported to ${exportPath}`); } async importBackup(importPath: string): Promise<MigrationBackup> { const manifestPath = path.join(importPath, 'backup-manifest.json'); if (!(await fs.pathExists(manifestPath))) { throw new Error('Invalid backup: missing manifest'); } const backup = await fs.readJson(manifestPath); const backupPath = path.join(this.backupDir, backup.metadata.backupId); await fs.copy(importPath, backupPath); await this.updateBackupIndex(backup); logger.success(`Backup imported: ${backup.metadata.backupId}`); return backup; } private async updateBackupIndex(backup: MigrationBackup): Promise<void> { const indexPath = path.join(this.backupDir, 'backup-index.json'); let index: Record<string, any> = {}; if (await fs.pathExists(indexPath)) { index = await fs.readJson(indexPath); } index[backup.metadata.backupId] = { timestamp: backup.timestamp, version: backup.version, fileCount: backup.files.length, metadata: backup.metadata, }; await fs.writeJson(indexPath, index, { spaces: 2 }); } printBackupSummary(backups: MigrationBackup[]): void { if (backups.length === 0) { console.log(chalk.yellow('No backups found')); return; } console.log(chalk.bold('\nšŸ’¾ Available Backups')); console.log(chalk.gray('─'.repeat(70))); backups.forEach((backup, index) => { const isRecent = index === 0; const date = backup.timestamp.toLocaleString(); const type = backup.metadata.type || 'migration'; const fileCount = backup.files.length; console.log( `\n${isRecent ? chalk.green('ā—') : chalk.gray('ā—‹')} ${chalk.bold(backup.metadata.backupId)}`, ); console.log(` ${chalk.gray('Date:')} ${date}`); console.log(` ${chalk.gray('Type:')} ${type}`); console.log(` ${chalk.gray('Files:')} ${fileCount}`); if (backup.metadata.strategy) { console.log(` ${chalk.gray('Strategy:')} ${backup.metadata.strategy}`); } }); console.log(chalk.gray('\n' + '─'.repeat(70))); } }