@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
JavaScript
// 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