file-operations.js
1 /** 2 * File Operations Module 3 * 4 * Provides safe file reading/writing with backup/restore capabilities and context awareness. 5 * All operations include safety features: 6 * - Automatic backups before editing 7 * - Atomic writes (temp file → move) 8 * - Path traversal prevention (whitelist: src/, tests/, docs/) 9 * - Syntax validation before writing 10 * - Diff generation for all edits 11 * 12 * @module agents/utils/file-operations 13 */ 14 15 import fs from 'fs/promises'; 16 import fsSync from 'fs'; 17 import path from 'path'; 18 import { fileURLToPath } from 'url'; 19 import { ESLint } from 'eslint'; 20 import Logger from '../../utils/logger.js'; 21 22 const __filename = fileURLToPath(import.meta.url); 23 const __dirname = path.dirname(__filename); 24 const PROJECT_ROOT = path.resolve(__dirname, '../../..'); 25 const BACKUP_DIR = path.join(PROJECT_ROOT, '.backups'); 26 27 const logger = new Logger('FileOperations'); 28 29 // Whitelist of allowed directories for file operations 30 const ALLOWED_DIRS = [ 31 path.join(PROJECT_ROOT, 'src'), 32 path.join(PROJECT_ROOT, 'tests'), 33 path.join(PROJECT_ROOT, 'docs'), 34 path.join(PROJECT_ROOT, 'scripts'), 35 path.join(PROJECT_ROOT, 'prompts'), 36 ]; 37 38 // Blacklist of files that should never be modified 39 const BLACKLISTED_FILES = ['.env', '.env.local', '.env.production', 'package-lock.json']; 40 41 /** 42 * Validate that a path is safe to operate on 43 * 44 * @param {string} filePath - Path to validate 45 * @throws {Error} If path is outside allowed directories or blacklisted 46 */ 47 function validatePath(filePath) { 48 const absPath = path.resolve(PROJECT_ROOT, filePath); 49 const relativePath = path.relative(PROJECT_ROOT, absPath); 50 51 // Check for path traversal 52 if (relativePath.startsWith('..')) { 53 throw new Error(`Path traversal detected: ${filePath}`); 54 } 55 56 // Check if path is in an allowed directory 57 const isAllowed = ALLOWED_DIRS.some(dir => absPath.startsWith(dir)); 58 if (!isAllowed) { 59 throw new Error( 60 `Path not in allowed directories: ${filePath}. Allowed: ${ALLOWED_DIRS.map(d => path.relative(PROJECT_ROOT, d)).join(', ')}` 61 ); 62 } 63 64 // Check if file is blacklisted 65 const basename = path.basename(absPath); 66 if (BLACKLISTED_FILES.includes(basename)) { 67 throw new Error(`File is blacklisted: ${basename}`); 68 } 69 70 return absPath; 71 } 72 73 /** 74 * Read a file and return its content with metadata 75 * 76 * @param {string} filePath - Path to file (relative to project root or absolute) 77 * @returns {Promise<{content: string, path: string, size: number, lastModified: Date}>} 78 * @throws {Error} If path is invalid or file cannot be read 79 * 80 * @example 81 * const file = await readFile('src/utils/logger.js'); 82 * console.log(file.content, file.size, file.lastModified); 83 */ 84 export async function readFile(filePath) { 85 const absPath = validatePath(filePath); 86 87 try { 88 const content = await fs.readFile(absPath, 'utf8'); 89 const stats = await fs.stat(absPath); 90 91 logger.info(`Read file: ${path.relative(PROJECT_ROOT, absPath)}`); 92 93 return { 94 content, 95 path: absPath, 96 size: stats.size, 97 lastModified: stats.mtime, 98 }; 99 } catch (error) { 100 logger.error(`Failed to read file ${absPath}: ${error.message}`); 101 throw new Error(`Failed to read file: ${error.message}`); 102 } 103 } 104 105 /** 106 * Create a backup of a file 107 * 108 * @param {string} filePath - Path to file to backup 109 * @returns {Promise<string>} Path to backup file 110 * @throws {Error} If backup fails 111 * 112 * @example 113 * const backupPath = await backupFile('src/utils/logger.js'); 114 * console.log(`Backup created: ${backupPath}`); 115 */ 116 export async function backupFile(filePath) { 117 const absPath = validatePath(filePath); 118 119 // Create backup directory if it doesn't exist 120 await fs.mkdir(BACKUP_DIR, { recursive: true }); 121 122 // Generate unique backup filename with timestamp 123 const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 124 const relativePath = path.relative(PROJECT_ROOT, absPath); 125 const backupName = `${relativePath.replace(/\//g, '_')}.${timestamp}.backup`; 126 const backupPath = path.join(BACKUP_DIR, backupName); 127 128 try { 129 // Ensure backup subdirectories exist 130 await fs.mkdir(path.dirname(backupPath), { recursive: true }); 131 132 // Copy file to backup location 133 await fs.copyFile(absPath, backupPath); 134 135 logger.info(`Created backup: ${path.relative(PROJECT_ROOT, backupPath)}`); 136 137 return backupPath; 138 } catch (error) { 139 logger.error(`Failed to create backup ${backupPath}: ${error.message}`); 140 throw new Error(`Failed to create backup: ${error.message}`); 141 } 142 } 143 144 /** 145 * Restore a file from backup 146 * 147 * @param {string} backupPath - Path to backup file 148 * @returns {Promise<boolean>} True if restore succeeded 149 * @throws {Error} If restore fails 150 * 151 * @example 152 * await restoreBackup('/path/to/backup/file.js.2026-02-15.backup'); 153 */ 154 export async function restoreBackup(backupPath) { 155 const absBackupPath = path.resolve(backupPath); 156 157 // Validate backup is in BACKUP_DIR 158 if (!absBackupPath.startsWith(BACKUP_DIR)) { 159 throw new Error('Backup path must be in .backups directory'); 160 } 161 162 try { 163 // Parse original path from backup filename 164 const backupFilename = path.basename(absBackupPath); 165 const originalPathPart = backupFilename.split('.').slice(0, -2).join('.'); 166 const originalPath = path.join(PROJECT_ROOT, originalPathPart.replace(/_/g, '/')); 167 168 // Validate original path 169 validatePath(originalPath); 170 171 // Restore file 172 await fs.copyFile(absBackupPath, originalPath); 173 174 logger.info(`Restored from backup: ${path.relative(PROJECT_ROOT, originalPath)}`); 175 176 return true; 177 } catch (error) { 178 logger.error(`Failed to restore backup ${backupPath}: ${error.message}`); 179 throw new Error(`Failed to restore backup: ${error.message}`); 180 } 181 } 182 183 /** 184 * Validate JavaScript syntax using ESLint 185 * 186 * @param {string} code - JavaScript code to validate 187 * @returns {Promise<{valid: boolean, errors: Array}>} Validation result 188 * 189 * @example 190 * const result = await validateJavaScript('const x = 1'); 191 * if (!result.valid) { 192 * console.error('Syntax errors:', result.errors); 193 * } 194 */ 195 export async function validateJavaScript(code) { 196 try { 197 const eslint = new ESLint({ 198 overrideConfigFile: true, // Don't use any config file 199 overrideConfig: [ 200 { 201 languageOptions: { 202 ecmaVersion: 2024, 203 sourceType: 'module', 204 parserOptions: { 205 ecmaVersion: 2024, 206 sourceType: 'module', 207 }, 208 }, 209 rules: {}, 210 }, 211 ], 212 }); 213 214 const results = await eslint.lintText(code); 215 const errors = results[0].messages.filter(msg => msg.severity === 2); // Only fatal errors 216 217 return { 218 valid: errors.length === 0, 219 errors: errors.map(e => ({ 220 line: e.line, 221 column: e.column, 222 message: e.message, 223 })), 224 }; 225 } catch (error) { 226 logger.error(`ESLint validation failed: ${error.message}`); 227 return { 228 valid: false, 229 errors: [{ message: error.message }], 230 }; 231 } 232 } 233 234 /** 235 * Generate a diff between two strings 236 * 237 * @param {string} oldContent - Original content 238 * @param {string} newContent - New content 239 * @returns {string} Unified diff format 240 */ 241 function generateDiff(oldContent, newContent) { 242 const oldLines = oldContent.split('\n'); 243 const newLines = newContent.split('\n'); 244 245 const diff = []; 246 diff.push('--- original'); 247 diff.push('+++ modified'); 248 249 let i = 0; 250 let j = 0; 251 252 while (i < oldLines.length || j < newLines.length) { 253 if (i >= oldLines.length) { 254 // Only new lines remain 255 diff.push(`+${newLines[j]}`); 256 j++; 257 } else if (j >= newLines.length) { 258 // Only old lines remain 259 diff.push(`-${oldLines[i]}`); 260 i++; 261 } else if (oldLines[i] === newLines[j]) { 262 // Lines match 263 diff.push(` ${oldLines[i]}`); 264 i++; 265 j++; 266 } else { 267 // Lines differ 268 diff.push(`-${oldLines[i]}`); 269 diff.push(`+${newLines[j]}`); 270 i++; 271 j++; 272 } 273 } 274 275 return diff.join('\n'); 276 } 277 278 /** 279 * Write content to a file with automatic backup and atomic write 280 * 281 * @param {string} filePath - Path to file 282 * @param {string} content - Content to write 283 * @param {Object} options - Write options 284 * @param {boolean} options.backup - Create backup before writing (default: true) 285 * @param {boolean} options.validate - Validate JS syntax before writing (default: true for .js files) 286 * @returns {Promise<{success: boolean, backupPath?: string, diff?: string}>} 287 * @throws {Error} If write fails or validation fails 288 * 289 * @example 290 * const result = await writeFile('src/test.js', 'const x = 1;'); 291 * console.log('Backup:', result.backupPath); 292 * console.log('Diff:', result.diff); 293 */ 294 export async function writeFile(filePath, content, options = {}) { 295 const absPath = validatePath(filePath); 296 const { backup = true, validate = absPath.endsWith('.js') } = options; 297 298 let backupPath; 299 let oldContent = ''; 300 301 try { 302 // Read existing content if file exists 303 try { 304 oldContent = await fs.readFile(absPath, 'utf8'); 305 } catch (error) { 306 // File doesn't exist, that's okay 307 if (error.code !== 'ENOENT') { 308 throw error; 309 } 310 } 311 312 // Create backup if requested and file exists 313 if (backup && oldContent) { 314 backupPath = await backupFile(absPath); 315 } 316 317 // Validate JavaScript syntax 318 if (validate) { 319 const validation = await validateJavaScript(content); 320 if (!validation.valid) { 321 throw new Error( 322 `JavaScript syntax errors:\n${validation.errors.map(e => ` Line ${e.line}: ${e.message}`).join('\n')}` 323 ); 324 } 325 } 326 327 // Atomic write: write to temp file then move 328 const tempPath = `${absPath}.tmp`; 329 await fs.writeFile(tempPath, content, 'utf8'); 330 331 // Ensure parent directory exists 332 await fs.mkdir(path.dirname(absPath), { recursive: true }); 333 334 // Move temp file to final location (atomic operation) 335 await fs.rename(tempPath, absPath); 336 337 // Generate diff 338 const diff = oldContent ? generateDiff(oldContent, content) : null; 339 340 logger.info(`Wrote file: ${path.relative(PROJECT_ROOT, absPath)}`); 341 342 return { 343 success: true, 344 backupPath, 345 diff, 346 }; 347 } catch (error) { 348 logger.error(`Failed to write file ${absPath}: ${error.message}`); 349 350 // Restore from backup if write failed 351 if (backupPath) { 352 try { 353 await restoreBackup(backupPath); 354 logger.info('Restored from backup after failed write'); 355 } catch (restoreError) { 356 logger.error(`Failed to restore backup: ${restoreError.message}`); 357 } 358 } 359 360 throw new Error(`Failed to write file: ${error.message}`); 361 } 362 } 363 364 /** 365 * Edit a file with automatic backup and diff generation 366 * 367 * @param {string} filePath - Path to file 368 * @param {Object} changes - Changes to apply 369 * @param {string} changes.oldContent - Content to replace 370 * @param {string} changes.newContent - Replacement content 371 * @returns {Promise<{success: boolean, backupPath: string, diff: string}>} 372 * @throws {Error} If edit fails 373 * 374 * @example 375 * const result = await editFile('src/test.js', { 376 * oldContent: 'const x = 1;', 377 * newContent: 'const x = 2;' 378 * }); 379 */ 380 export async function editFile(filePath, changes) { 381 const absPath = validatePath(filePath); 382 const { oldContent, newContent } = changes; 383 384 if (!oldContent || !newContent) { 385 throw new Error('Both oldContent and newContent are required'); 386 } 387 388 try { 389 // Read current content 390 const { content } = await readFile(absPath); 391 392 // Find and replace oldContent 393 if (!content.includes(oldContent)) { 394 throw new Error('oldContent not found in file'); 395 } 396 397 const updatedContent = content.replace(oldContent, newContent); 398 399 // Write updated content 400 const result = await writeFile(absPath, updatedContent); 401 402 logger.info(`Edited file: ${path.relative(PROJECT_ROOT, absPath)}`); 403 404 return result; 405 } catch (error) { 406 logger.error(`Failed to edit file ${absPath}: ${error.message}`); 407 throw new Error(`Failed to edit file: ${error.message}`); 408 } 409 } 410 411 /** 412 * Get file context: imports, dependencies, related test files 413 * 414 * @param {string} filePath - Path to file 415 * @returns {Promise<{imports: string[], dependencies: string[], testFiles: string[]}>} 416 * 417 * @example 418 * const context = await getFileContext('src/utils/logger.js'); 419 * console.log('Imports:', context.imports); 420 * console.log('Test files:', context.testFiles); 421 */ 422 export async function getFileContext(filePath) { 423 const absPath = validatePath(filePath); 424 425 try { 426 const { content } = await readFile(absPath); 427 428 // Extract imports 429 const importRegex = /import\s+.*\s+from\s+['"](.+?)['"]/g; 430 const imports = []; 431 let match; 432 while ((match = importRegex.exec(content)) !== null) { 433 imports.push(match[1]); 434 } 435 436 // Extract require statements 437 const requireRegex = /require\(['"](.+?)['"]\)/g; 438 while ((match = requireRegex.exec(content)) !== null) { 439 imports.push(match[1]); 440 } 441 442 // Find dependencies (npm packages) 443 const dependencies = imports.filter(imp => !imp.startsWith('.') && !imp.startsWith('/')); 444 445 // Find related test files 446 const testFiles = []; 447 const basename = path.basename(absPath, path.extname(absPath)); 448 const testsDir = path.join(PROJECT_ROOT, 'tests'); 449 450 try { 451 const testDirFiles = await fs.readdir(testsDir); 452 const relatedTests = testDirFiles.filter( 453 file => 454 file.includes(basename) && 455 (file.endsWith('.test.js') || file.endsWith('.integration.test.js')) 456 ); 457 testFiles.push(...relatedTests.map(f => path.join(testsDir, f))); 458 } catch (error) { 459 // Tests directory might not exist 460 } 461 462 // Check for agent-specific tests 463 try { 464 const agentTestsDir = path.join(PROJECT_ROOT, 'tests/agents'); 465 if (fsSync.existsSync(agentTestsDir)) { 466 const agentTestFiles = await fs.readdir(agentTestsDir); 467 const relatedAgentTests = agentTestFiles.filter( 468 file => 469 file.includes(basename) && 470 (file.endsWith('.test.js') || file.endsWith('.integration.test.js')) 471 ); 472 testFiles.push(...relatedAgentTests.map(f => path.join(agentTestsDir, f))); 473 } 474 } catch (error) { 475 // Agent tests directory might not exist 476 } 477 478 return { 479 imports, 480 dependencies, 481 testFiles, 482 }; 483 } catch (error) { 484 logger.error(`Failed to get file context ${absPath}: ${error.message}`); 485 throw new Error(`Failed to get file context: ${error.message}`); 486 } 487 } 488 489 /** 490 * List all backup files for a given file 491 * 492 * @param {string} filePath - Path to file 493 * @returns {Promise<string[]>} Array of backup file paths, sorted by date (newest first) 494 * 495 * @example 496 * const backups = await listBackups('src/utils/logger.js'); 497 * console.log('Available backups:', backups); 498 */ 499 export async function listBackups(filePath) { 500 const absPath = validatePath(filePath); 501 const relativePath = path.relative(PROJECT_ROOT, absPath); 502 const backupPrefix = relativePath.replace(/\//g, '_'); 503 504 try { 505 await fs.mkdir(BACKUP_DIR, { recursive: true }); 506 const files = await fs.readdir(BACKUP_DIR); 507 508 const backups = files 509 .filter(f => f.startsWith(backupPrefix) && f.endsWith('.backup')) 510 .map(f => path.join(BACKUP_DIR, f)) 511 .sort() 512 .reverse(); // Newest first 513 514 return backups; 515 } catch (error) { 516 logger.error(`Failed to list backups: ${error.message}`); 517 return []; 518 } 519 } 520 521 /** 522 * Delete old backups, keeping only the most recent N backups 523 * 524 * @param {string} filePath - Path to file 525 * @param {number} keepCount - Number of backups to keep (default: 5) 526 * @returns {Promise<number>} Number of backups deleted 527 * 528 * @example 529 * const deleted = await cleanupBackups('src/utils/logger.js', 3); 530 * console.log(`Deleted ${deleted} old backups`); 531 */ 532 export async function cleanupBackups(filePath, keepCount = 5) { 533 const backups = await listBackups(filePath); 534 535 if (backups.length <= keepCount) { 536 return 0; 537 } 538 539 const toDelete = backups.slice(keepCount); 540 let deleted = 0; 541 542 for (const backup of toDelete) { 543 try { 544 await fs.unlink(backup); 545 deleted++; 546 } catch (error) { 547 logger.error(`Failed to delete backup ${backup}: ${error.message}`); 548 } 549 } 550 551 logger.info(`Cleaned up ${deleted} old backups for ${path.relative(PROJECT_ROOT, filePath)}`); 552 553 return deleted; 554 } 555 556 export default { 557 readFile, 558 writeFile, 559 editFile, 560 backupFile, 561 restoreBackup, 562 validateJavaScript, 563 getFileContext, 564 listBackups, 565 cleanupBackups, 566 };