UNPKG

@context-sync/server

Version:

MCP server for AI context sync with persistent memory, workspace file access, and intelligent code operations

468 lines 16.7 kB
// File Writing Operations with Safety Controls import * as fs from 'fs'; import { promises as fsAsync } from 'fs'; import * as path from 'path'; export class FileWriter { workspaceDetector; storage; undoStack = new Map(); MAX_UNDO_LEVELS = 10; MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB constructor(workspaceDetector, storage) { this.workspaceDetector = workspaceDetector; this.storage = storage; } /** * Create a new file */ async createFile(relativePath, content, overwrite = false) { const workspace = this.workspaceDetector.getCurrentWorkspace(); if (!workspace) { return { success: false, path: relativePath, message: 'No workspace set. Use set_workspace first.', requiresApproval: false }; } // Validate path const validation = this.validatePath(relativePath); if (!validation.valid) { return { success: false, path: relativePath, message: validation.error, requiresApproval: false }; } const fullPath = path.join(workspace, relativePath); // Check if file exists let fileExists = false; try { await fsAsync.access(fullPath); fileExists = true; } catch { fileExists = false; } if (fileExists && !overwrite) { return { success: false, path: relativePath, message: `File already exists: ${relativePath}. Use overwrite flag to replace.`, requiresApproval: false }; } // Validate content size const size = Buffer.byteLength(content); if (size > this.MAX_FILE_SIZE) { return { success: false, path: relativePath, message: `File too large: ${(size / 1024).toFixed(1)}KB. Max: ${(this.MAX_FILE_SIZE / 1024).toFixed(1)}KB`, requiresApproval: false }; } // Generate preview const preview = this.generateCreatePreview(relativePath, content); return { success: true, path: relativePath, message: 'Ready to create file. Preview shown above.', preview, requiresApproval: true }; } /** * Actually write the file after approval */ async applyCreateFile(relativePath, content) { const workspace = this.workspaceDetector.getCurrentWorkspace(); if (!workspace) { return { success: false, path: relativePath, message: 'No workspace set', requiresApproval: false }; } const fullPath = path.join(workspace, relativePath); try { // Create directory if doesn't exist const dir = path.dirname(fullPath); try { await fsAsync.access(dir); } catch { await fsAsync.mkdir(dir, { recursive: true }); } // Write file await fsAsync.writeFile(fullPath, content, 'utf8'); // Log decision const project = this.storage.getCurrentProject(); if (project) { this.storage.addDecision({ projectId: project.id, type: 'other', description: `Created file: ${relativePath}`, reasoning: 'Generated by Claude with user approval' }); } return { success: true, path: relativePath, message: `✅ Created ${relativePath}`, requiresApproval: false }; } catch (error) { return { success: false, path: relativePath, message: `Error creating file: ${error instanceof Error ? error.message : 'Unknown error'}`, requiresApproval: false }; } } /** * Modify an existing file */ async modifyFile(relativePath, changes) { const workspace = this.workspaceDetector.getCurrentWorkspace(); if (!workspace) { return { success: false, path: relativePath, message: 'No workspace set. Use set_workspace first.', requiresApproval: false }; } const fullPath = path.join(workspace, relativePath); // Check if file exists try { await fsAsync.access(fullPath); } catch { return { success: false, path: relativePath, message: `File not found: ${relativePath}`, requiresApproval: false }; } try { // Read current content const originalContent = await fsAsync.readFile(fullPath, 'utf8'); // Create backup this.createBackup(relativePath, originalContent); // Apply changes const newContent = this.applyChanges(originalContent, changes); // Generate diff preview const preview = this.generateDiffPreview(relativePath, originalContent, newContent); return { success: true, path: relativePath, message: 'Ready to modify file. Preview shown above.', preview, requiresApproval: true }; } catch (error) { return { success: false, path: relativePath, message: `Error reading file: ${error instanceof Error ? error.message : 'Unknown error'}`, requiresApproval: false }; } } /** * Actually apply the modification after approval */ async applyModifyFile(relativePath, changes) { const workspace = this.workspaceDetector.getCurrentWorkspace(); if (!workspace) { return { success: false, path: relativePath, message: 'No workspace set', requiresApproval: false }; } const fullPath = path.join(workspace, relativePath); try { const originalContent = await fsAsync.readFile(fullPath, 'utf8'); const newContent = this.applyChanges(originalContent, changes); // Write modified file await fsAsync.writeFile(fullPath, newContent, 'utf8'); // Log decision const project = this.storage.getCurrentProject(); if (project) { this.storage.addDecision({ projectId: project.id, type: 'other', description: `Modified file: ${relativePath}`, reasoning: 'Changes applied by Claude with user approval' }); } return { success: true, path: relativePath, message: `✅ Modified ${relativePath}`, requiresApproval: false }; } catch (error) { return { success: false, path: relativePath, message: `Error modifying file: ${error instanceof Error ? error.message : 'Unknown error'}`, requiresApproval: false }; } } /** * Undo the last change to a file */ async undoChange(relativePath, steps = 1) { const workspace = this.workspaceDetector.getCurrentWorkspace(); if (!workspace) { return { success: false, path: relativePath, message: 'No workspace set', requiresApproval: false }; } const backups = this.undoStack.get(relativePath); if (!backups || backups.length === 0) { return { success: false, path: relativePath, message: 'No undo history available for this file', requiresApproval: false }; } if (steps > backups.length) { steps = backups.length; } try { // Get the backup to restore const backup = backups[backups.length - steps]; const fullPath = path.join(workspace, relativePath); // Restore the backup await fsAsync.writeFile(fullPath, backup.content, 'utf8'); // Remove undone items from stack backups.splice(-steps); this.undoStack.set(relativePath, backups); return { success: true, path: relativePath, message: `✅ Reverted ${relativePath} (undid ${steps} change${steps > 1 ? 's' : ''})`, requiresApproval: false }; } catch (error) { return { success: false, path: relativePath, message: `Error undoing change: ${error instanceof Error ? error.message : 'Unknown error'}`, requiresApproval: false }; } } /** * Delete a file */ async deleteFile(relativePath) { const workspace = this.workspaceDetector.getCurrentWorkspace(); if (!workspace) { return { success: false, path: relativePath, message: 'No workspace set', requiresApproval: false }; } const fullPath = path.join(workspace, relativePath); try { await fsAsync.access(fullPath); } catch { return { success: false, path: relativePath, message: `File not found: ${relativePath}`, requiresApproval: false }; } // Create backup before deletion const content = await fsAsync.readFile(fullPath, 'utf8'); this.createBackup(relativePath, content); const preview = `⚠️ WARNING: This will DELETE the file!\n\nFile: ${relativePath}\nSize: ${(Buffer.byteLength(content) / 1024).toFixed(1)}KB\n\nThis action can be undone with undo_file_change.`; return { success: true, path: relativePath, message: 'Ready to delete file. Confirm deletion.', preview, requiresApproval: true }; } /** * Actually delete the file after approval */ async applyDeleteFile(relativePath) { const workspace = this.workspaceDetector.getCurrentWorkspace(); if (!workspace) { return { success: false, path: relativePath, message: 'No workspace set', requiresApproval: false }; } const fullPath = path.join(workspace, relativePath); try { fs.unlinkSync(fullPath); return { success: true, path: relativePath, message: `✅ Deleted ${relativePath}`, requiresApproval: false }; } catch (error) { return { success: false, path: relativePath, message: `Error deleting file: ${error instanceof Error ? error.message : 'Unknown error'}`, requiresApproval: false }; } } // ========== PRIVATE HELPER METHODS ========== validatePath(relativePath) { // Check for path traversal if (relativePath.includes('..')) { return { valid: false, error: 'Path traversal not allowed (..)' }; } // Check for absolute paths if (path.isAbsolute(relativePath)) { return { valid: false, error: 'Must use relative paths, not absolute' }; } // Check for forbidden directories const forbidden = ['node_modules', '.git', 'dist', 'build', '.next', '.cache']; for (const dir of forbidden) { if (relativePath.startsWith(dir + '/') || relativePath === dir) { return { valid: false, error: `Cannot modify ${dir} directory` }; } } // Check for system files (unless specifically allowed) if (path.basename(relativePath).startsWith('.') && !this.isAllowedHiddenFile(relativePath)) { return { valid: false, error: 'Cannot modify hidden files (unless configuration)' }; } return { valid: true }; } isAllowedHiddenFile(relativePath) { const allowed = [ '.env.example', '.gitignore', '.eslintrc', '.prettierrc', '.editorconfig' ]; return allowed.some(file => relativePath.endsWith(file)); } createBackup(relativePath, content) { const backups = this.undoStack.get(relativePath) || []; backups.push({ path: relativePath, content, timestamp: new Date() }); // Keep only last MAX_UNDO_LEVELS backups if (backups.length > this.MAX_UNDO_LEVELS) { backups.shift(); } this.undoStack.set(relativePath, backups); } applyChanges(content, changes) { let result = content; for (const change of changes) { switch (change.type) { case 'replace': if (change.oldText) { result = result.replace(change.oldText, change.newText); } break; case 'insert': if (typeof change.line === 'number') { const lines = result.split('\n'); lines.splice(change.line, 0, change.newText); result = lines.join('\n'); } break; case 'delete': if (typeof change.line === 'number') { const lines = result.split('\n'); lines.splice(change.line, 1); result = lines.join('\n'); } break; } } return result; } generateCreatePreview(relativePath, content) { const lines = content.split('\n'); const size = Buffer.byteLength(content); let preview = '📝 Preview: Create New File\n'; preview += '━'.repeat(60) + '\n'; preview += `File: ${relativePath}\n`; preview += `Size: ${(size / 1024).toFixed(1)}KB\n`; preview += `Lines: ${lines.length}\n`; preview += '━'.repeat(60) + '\n'; // Show first 20 lines const previewLines = lines.slice(0, 20); preview += previewLines.join('\n'); if (lines.length > 20) { preview += `\n\n... (${lines.length - 20} more lines)`; } preview += '\n' + '━'.repeat(60); return preview; } generateDiffPreview(relativePath, oldContent, newContent) { const oldLines = oldContent.split('\n'); const newLines = newContent.split('\n'); let preview = '📝 Changes Preview\n'; preview += '━'.repeat(60) + '\n'; preview += `File: ${relativePath}\n`; preview += '━'.repeat(60) + '\n'; // Simple line-by-line diff const maxLines = Math.max(oldLines.length, newLines.length); let changeCount = 0; for (let i = 0; i < maxLines && changeCount < 20; i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; if (oldLine !== newLine) { if (oldLine !== undefined) { preview += `- ${oldLine}\n`; } if (newLine !== undefined) { preview += `+ ${newLine}\n`; } changeCount++; } else if (changeCount > 0 && changeCount < 20) { // Show context line preview += ` ${oldLine}\n`; } } if (changeCount >= 20) { preview += '\n... (more changes below)'; } preview += '\n' + '━'.repeat(60); return preview; } } //# sourceMappingURL=file-writer.js.map