319 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			319 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import * as plugins from './mod.plugins.js';
 | 
						|
import * as paths from '../paths.js';
 | 
						|
import type { IFormatOperation } from './interfaces.format.js';
 | 
						|
 | 
						|
export class RollbackManager {
 | 
						|
  private backupDir: string;
 | 
						|
  private manifestPath: string;
 | 
						|
 | 
						|
  constructor() {
 | 
						|
    this.backupDir = plugins.path.join(paths.cwd, '.nogit', 'gitzone-backups');
 | 
						|
    this.manifestPath = plugins.path.join(this.backupDir, 'manifest.json');
 | 
						|
  }
 | 
						|
 | 
						|
  async createOperation(): Promise<IFormatOperation> {
 | 
						|
    await this.ensureBackupDir();
 | 
						|
 | 
						|
    const operation: IFormatOperation = {
 | 
						|
      id: this.generateOperationId(),
 | 
						|
      timestamp: Date.now(),
 | 
						|
      files: [],
 | 
						|
      status: 'pending',
 | 
						|
    };
 | 
						|
 | 
						|
    await this.updateManifest(operation);
 | 
						|
    return operation;
 | 
						|
  }
 | 
						|
 | 
						|
  async backupFile(filepath: string, operationId: string): Promise<void> {
 | 
						|
    const operation = await this.getOperation(operationId);
 | 
						|
    if (!operation) {
 | 
						|
      throw new Error(`Operation ${operationId} not found`);
 | 
						|
    }
 | 
						|
 | 
						|
    const absolutePath = plugins.path.isAbsolute(filepath)
 | 
						|
      ? filepath
 | 
						|
      : plugins.path.join(paths.cwd, filepath);
 | 
						|
 | 
						|
    // Check if file exists
 | 
						|
    const exists = await plugins.smartfile.fs.fileExists(absolutePath);
 | 
						|
    if (!exists) {
 | 
						|
      // File doesn't exist yet (will be created), so we skip backup
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Read file content and metadata
 | 
						|
    const content = plugins.smartfile.fs.toStringSync(absolutePath);
 | 
						|
    const stats = await plugins.smartfile.fs.stat(absolutePath);
 | 
						|
    const checksum = this.calculateChecksum(content);
 | 
						|
 | 
						|
    // Create backup
 | 
						|
    const backupPath = this.getBackupPath(operationId, filepath);
 | 
						|
    await plugins.smartfile.fs.ensureDir(plugins.path.dirname(backupPath));
 | 
						|
    await plugins.smartfile.memory.toFs(content, backupPath);
 | 
						|
 | 
						|
    // Update operation
 | 
						|
    operation.files.push({
 | 
						|
      path: filepath,
 | 
						|
      originalContent: content,
 | 
						|
      checksum,
 | 
						|
      permissions: stats.mode.toString(8),
 | 
						|
    });
 | 
						|
 | 
						|
    await this.updateManifest(operation);
 | 
						|
  }
 | 
						|
 | 
						|
  async rollback(operationId: string): Promise<void> {
 | 
						|
    const operation = await this.getOperation(operationId);
 | 
						|
    if (!operation) {
 | 
						|
      // Operation doesn't exist, might have already been rolled back or never created
 | 
						|
      console.warn(`Operation ${operationId} not found for rollback, skipping`);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    if (operation.status === 'rolled-back') {
 | 
						|
      throw new Error(`Operation ${operationId} has already been rolled back`);
 | 
						|
    }
 | 
						|
 | 
						|
    // Restore files in reverse order
 | 
						|
    for (let i = operation.files.length - 1; i >= 0; i--) {
 | 
						|
      const file = operation.files[i];
 | 
						|
      const absolutePath = plugins.path.isAbsolute(file.path)
 | 
						|
        ? file.path
 | 
						|
        : plugins.path.join(paths.cwd, file.path);
 | 
						|
 | 
						|
      // Verify backup integrity
 | 
						|
      const backupPath = this.getBackupPath(operationId, file.path);
 | 
						|
      const backupContent = plugins.smartfile.fs.toStringSync(backupPath);
 | 
						|
      const backupChecksum = this.calculateChecksum(backupContent);
 | 
						|
 | 
						|
      if (backupChecksum !== file.checksum) {
 | 
						|
        throw new Error(`Backup integrity check failed for ${file.path}`);
 | 
						|
      }
 | 
						|
 | 
						|
      // Restore file
 | 
						|
      await plugins.smartfile.memory.toFs(file.originalContent, absolutePath);
 | 
						|
 | 
						|
      // Restore permissions
 | 
						|
      const mode = parseInt(file.permissions, 8);
 | 
						|
      // Note: Permissions restoration may not work on all platforms
 | 
						|
    }
 | 
						|
 | 
						|
    // Update operation status
 | 
						|
    operation.status = 'rolled-back';
 | 
						|
    await this.updateManifest(operation);
 | 
						|
  }
 | 
						|
 | 
						|
  async markComplete(operationId: string): Promise<void> {
 | 
						|
    const operation = await this.getOperation(operationId);
 | 
						|
    if (!operation) {
 | 
						|
      throw new Error(`Operation ${operationId} not found`);
 | 
						|
    }
 | 
						|
 | 
						|
    operation.status = 'completed';
 | 
						|
    await this.updateManifest(operation);
 | 
						|
  }
 | 
						|
 | 
						|
  async cleanOldBackups(retentionDays: number): Promise<void> {
 | 
						|
    const manifest = await this.getManifest();
 | 
						|
    const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
 | 
						|
 | 
						|
    const operationsToDelete = manifest.operations.filter(
 | 
						|
      (op) => op.timestamp < cutoffTime && op.status === 'completed',
 | 
						|
    );
 | 
						|
 | 
						|
    for (const operation of operationsToDelete) {
 | 
						|
      // Remove backup files
 | 
						|
      const operationDir = plugins.path.join(
 | 
						|
        this.backupDir,
 | 
						|
        'operations',
 | 
						|
        operation.id,
 | 
						|
      );
 | 
						|
      await plugins.smartfile.fs.remove(operationDir);
 | 
						|
 | 
						|
      // Remove from manifest
 | 
						|
      manifest.operations = manifest.operations.filter(
 | 
						|
        (op) => op.id !== operation.id,
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    await this.saveManifest(manifest);
 | 
						|
  }
 | 
						|
 | 
						|
  async verifyBackup(operationId: string): Promise<boolean> {
 | 
						|
    const operation = await this.getOperation(operationId);
 | 
						|
    if (!operation) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    for (const file of operation.files) {
 | 
						|
      const backupPath = this.getBackupPath(operationId, file.path);
 | 
						|
      const exists = await plugins.smartfile.fs.fileExists(backupPath);
 | 
						|
 | 
						|
      if (!exists) {
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
 | 
						|
      const content = plugins.smartfile.fs.toStringSync(backupPath);
 | 
						|
      const checksum = this.calculateChecksum(content);
 | 
						|
 | 
						|
      if (checksum !== file.checksum) {
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  async listBackups(): Promise<IFormatOperation[]> {
 | 
						|
    const manifest = await this.getManifest();
 | 
						|
    return manifest.operations;
 | 
						|
  }
 | 
						|
 | 
						|
  private async ensureBackupDir(): Promise<void> {
 | 
						|
    await plugins.smartfile.fs.ensureDir(this.backupDir);
 | 
						|
    await plugins.smartfile.fs.ensureDir(
 | 
						|
      plugins.path.join(this.backupDir, 'operations'),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  private generateOperationId(): string {
 | 
						|
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 | 
						|
    const random = Math.random().toString(36).substring(2, 8);
 | 
						|
    return `${timestamp}-${random}`;
 | 
						|
  }
 | 
						|
 | 
						|
  private getBackupPath(operationId: string, filepath: string): string {
 | 
						|
    const filename = plugins.path.basename(filepath);
 | 
						|
    const dir = plugins.path.dirname(filepath);
 | 
						|
    const safeDir = dir.replace(/[/\\]/g, '__');
 | 
						|
    return plugins.path.join(
 | 
						|
      this.backupDir,
 | 
						|
      'operations',
 | 
						|
      operationId,
 | 
						|
      'files',
 | 
						|
      safeDir,
 | 
						|
      `${filename}.backup`,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  private calculateChecksum(content: string | Buffer): string {
 | 
						|
    return plugins.crypto.createHash('sha256').update(content).digest('hex');
 | 
						|
  }
 | 
						|
 | 
						|
  private async getManifest(): Promise<{ operations: IFormatOperation[] }> {
 | 
						|
    const defaultManifest = { operations: [] };
 | 
						|
 | 
						|
    const exists = await plugins.smartfile.fs.fileExists(this.manifestPath);
 | 
						|
    if (!exists) {
 | 
						|
      return defaultManifest;
 | 
						|
    }
 | 
						|
 | 
						|
    try {
 | 
						|
      const content = plugins.smartfile.fs.toStringSync(this.manifestPath);
 | 
						|
      const manifest = JSON.parse(content);
 | 
						|
 | 
						|
      // Validate the manifest structure
 | 
						|
      if (this.isValidManifest(manifest)) {
 | 
						|
        return manifest;
 | 
						|
      } else {
 | 
						|
        console.warn(
 | 
						|
          'Invalid rollback manifest structure, returning default manifest',
 | 
						|
        );
 | 
						|
        return defaultManifest;
 | 
						|
      }
 | 
						|
    } catch (error) {
 | 
						|
      console.warn(
 | 
						|
        `Failed to read rollback manifest: ${error.message}, returning default manifest`,
 | 
						|
      );
 | 
						|
      // Try to delete the corrupted file
 | 
						|
      try {
 | 
						|
        await plugins.smartfile.fs.remove(this.manifestPath);
 | 
						|
      } catch (removeError) {
 | 
						|
        // Ignore removal errors
 | 
						|
      }
 | 
						|
      return defaultManifest;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  private async saveManifest(manifest: {
 | 
						|
    operations: IFormatOperation[];
 | 
						|
  }): Promise<void> {
 | 
						|
    // Validate before saving
 | 
						|
    if (!this.isValidManifest(manifest)) {
 | 
						|
      throw new Error('Invalid rollback manifest structure, cannot save');
 | 
						|
    }
 | 
						|
 | 
						|
    // Ensure directory exists
 | 
						|
    await this.ensureBackupDir();
 | 
						|
 | 
						|
    // Write directly with proper JSON stringification
 | 
						|
    const jsonContent = JSON.stringify(manifest, null, 2);
 | 
						|
    await plugins.smartfile.memory.toFs(jsonContent, this.manifestPath);
 | 
						|
  }
 | 
						|
 | 
						|
  private async getOperation(
 | 
						|
    operationId: string,
 | 
						|
  ): Promise<IFormatOperation | null> {
 | 
						|
    const manifest = await this.getManifest();
 | 
						|
    return manifest.operations.find((op) => op.id === operationId) || null;
 | 
						|
  }
 | 
						|
 | 
						|
  private async updateManifest(operation: IFormatOperation): Promise<void> {
 | 
						|
    const manifest = await this.getManifest();
 | 
						|
    const existingIndex = manifest.operations.findIndex(
 | 
						|
      (op) => op.id === operation.id,
 | 
						|
    );
 | 
						|
 | 
						|
    if (existingIndex !== -1) {
 | 
						|
      manifest.operations[existingIndex] = operation;
 | 
						|
    } else {
 | 
						|
      manifest.operations.push(operation);
 | 
						|
    }
 | 
						|
 | 
						|
    await this.saveManifest(manifest);
 | 
						|
  }
 | 
						|
 | 
						|
  private isValidManifest(
 | 
						|
    manifest: any,
 | 
						|
  ): manifest is { operations: IFormatOperation[] } {
 | 
						|
    // Check if manifest has the required structure
 | 
						|
    if (!manifest || typeof manifest !== 'object') {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    // Check required fields
 | 
						|
    if (!Array.isArray(manifest.operations)) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    // Check each operation entry
 | 
						|
    for (const operation of manifest.operations) {
 | 
						|
      if (
 | 
						|
        !operation ||
 | 
						|
        typeof operation !== 'object' ||
 | 
						|
        typeof operation.id !== 'string' ||
 | 
						|
        typeof operation.timestamp !== 'number' ||
 | 
						|
        typeof operation.status !== 'string' ||
 | 
						|
        !Array.isArray(operation.files)
 | 
						|
      ) {
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
 | 
						|
      // Check each file in the operation
 | 
						|
      for (const file of operation.files) {
 | 
						|
        if (
 | 
						|
          !file ||
 | 
						|
          typeof file !== 'object' ||
 | 
						|
          typeof file.path !== 'string' ||
 | 
						|
          typeof file.checksum !== 'string'
 | 
						|
        ) {
 | 
						|
          return false;
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
}
 |