UNPKG

@sethdouglasford/claude-flow

Version:

Claude Code Flow - Advanced AI-powered development workflows with SPARC methodology

322 lines • 13.2 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 { stat, readFile, readdir, chmod, writeFile } from "node:fs/promises"; import { logger } from "./logger"; import chalk from "chalk"; import inquirer from "inquirer"; export class RollbackManager { projectPath; backupDir; constructor(projectPath, backupDir = ".claude-backup") { this.projectPath = projectPath; this.backupDir = path.join(projectPath, backupDir); } async createBackup(metadata = {}) { 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 = { 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 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 writeFile(manifestPath, JSON.stringify(backup, null, 2)); // Update backup index await this.updateBackupIndex(backup); logger.success(`Backup created with ${backup.files.length} files`); return backup; } async backupDirectory(sourcePath, targetPath, backup) { await fs.ensureDir(targetPath); const entries = await readdir(sourcePath); for (const entry of entries) { const entrySource = path.join(sourcePath, entry); const entryTarget = path.join(targetPath, entry); const stats = await 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); } } } async backupFile(sourcePath, targetPath, backup, relativePath) { const content = await readFile(sourcePath, "utf-8"); const checksum = crypto.createHash("sha256").update(content).digest("hex"); await fs.ensureDir(path.dirname(targetPath)); await writeFile(targetPath, content); const backupFile = { path: relativePath, content, checksum, permissions: (await stat(sourcePath)).mode.toString(8), }; backup.files.push(backupFile); } async listBackups() { if (!await fs.pathExists(this.backupDir)) { return []; } const backupFolders = await readdir(this.backupDir); const backups = []; for (const folder of backupFolders.sort().reverse()) { const manifestPath = path.join(this.backupDir, folder, "backup-manifest.json"); if (await fs.pathExists(manifestPath)) { try { const manifestContent = await readFile(manifestPath, "utf-8"); const backup = JSON.parse(manifestContent); // Convert timestamp string back to Date backup.timestamp = new Date(backup.timestamp); backups.push(backup); } catch (error) { logger.warn(`Invalid backup manifest in ${folder}: ${error.message}`); } } } return backups; } async rollback(backupId, interactive = true) { const backups = await this.listBackups(); if (backups.length === 0) { throw new Error("No backups found"); } let selectedBackup; 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 } if (!selectedBackup) { throw new Error("Could not determine a backup to rollback to."); } 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; } } async selectBackupInteractively(backups) { 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; } async restoreFiles(backup) { 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 writeFile(targetPath, file.content); // Restore permissions if available if (file.permissions) { try { await chmod(targetPath, parseInt(file.permissions, 8)); } catch (error) { logger.warn(`Could not restore permissions for ${file.path}: ${error.message}`); } } } } async validateRestore(backup) { logger.info("Validating restored files..."); const errors = []; 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 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 = 30, maxBackups = 10) { 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) { const backups = await this.listBackups(); return backups.find(b => b.metadata.backupId === backupId) || null; } async exportBackup(backupId, exportPath) { 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) { const manifestPath = path.join(importPath, "backup-manifest.json"); if (!await fs.pathExists(manifestPath)) { throw new Error("Invalid backup: missing manifest"); } const manifestContent = await readFile(manifestPath, "utf-8"); const backup = JSON.parse(manifestContent); // Convert timestamp string back to Date backup.timestamp = new Date(backup.timestamp); 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; } async updateBackupIndex(backup) { const indexPath = path.join(this.backupDir, "backup-index.json"); let index = {}; if (await fs.pathExists(indexPath)) { const indexContent = await readFile(indexPath, "utf-8"); index = JSON.parse(indexContent); } index[backup.metadata.backupId] = { timestamp: backup.timestamp, version: backup.version, fileCount: backup.files.length, metadata: backup.metadata, }; await writeFile(indexPath, JSON.stringify(index, null, 2)); } printBackupSummary(backups) { 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)}`)); } } //# sourceMappingURL=rollback-manager.js.map