298 lines
9.6 KiB
TypeScript
298 lines
9.6 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) {
|
|
throw new Error(`Operation ${operationId} not found`);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
// Use atomic write: write to temp file, then move it
|
|
const tempPath = `${this.manifestPath}.tmp`;
|
|
|
|
try {
|
|
// Write to temporary file
|
|
const jsonContent = JSON.stringify(manifest, null, 2);
|
|
await plugins.smartfile.memory.toFs(jsonContent, tempPath);
|
|
|
|
// Move temp file to actual manifest (atomic-like operation)
|
|
// Since smartfile doesn't have rename, we copy and delete
|
|
await plugins.smartfile.fs.copy(tempPath, this.manifestPath);
|
|
await plugins.smartfile.fs.remove(tempPath);
|
|
} catch (error) {
|
|
// Clean up temp file if it exists
|
|
try {
|
|
await plugins.smartfile.fs.remove(tempPath);
|
|
} catch (removeError) {
|
|
// Ignore removal errors
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
} |