Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
fileOperations.tsโ€ข11.1 kB
import * as fs from 'fs/promises'; import * as path from 'path'; import { logger } from './logger.js'; export interface CopyOptions { onProgress?: (copied: number, total: number, currentFile: string) => void; excludePatterns?: string[]; maxRetries?: number; } export interface FileStats { totalFiles: number; totalSize: number; } /** * Cross-platform file operations utility * Centralizes common file operations with progress reporting and error handling */ export class FileOperations { /** * Recursively copy a directory with progress reporting * Works cross-platform without relying on shell commands */ static async copyDirectory( src: string, dest: string, options: CopyOptions = {} ): Promise<void> { const { onProgress, excludePatterns = [], maxRetries = 3 } = options; // First, calculate total files for progress reporting const stats = await this.calculateDirectoryStats(src, excludePatterns); let copiedFiles = 0; await this.copyDirectoryRecursive( src, dest, excludePatterns, maxRetries, (currentFile) => { copiedFiles++; if (onProgress) { onProgress(copiedFiles, stats.totalFiles, currentFile); } } ); } /** * Calculate directory statistics for progress reporting */ private static async calculateDirectoryStats( dir: string, excludePatterns: string[] ): Promise<FileStats> { let totalFiles = 0; let totalSize = 0; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); // Skip excluded patterns if (this.shouldExclude(entry.name, excludePatterns)) { continue; } if (entry.isDirectory()) { const subStats = await this.calculateDirectoryStats(fullPath, excludePatterns); totalFiles += subStats.totalFiles; totalSize += subStats.totalSize; } else { totalFiles++; try { const stat = await fs.stat(fullPath); totalSize += stat.size; } catch { // Ignore stat errors } } } } catch (error) { logger.warn(`[FileOperations] Error calculating stats for ${dir}:`, error); } return { totalFiles, totalSize }; } /** * Internal recursive copy implementation */ private static async copyDirectoryRecursive( src: string, dest: string, excludePatterns: string[], maxRetries: number, onFileCopied: (file: string) => void ): Promise<void> { // Ensure destination directory exists await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); // Skip excluded patterns if (this.shouldExclude(entry.name, excludePatterns)) { logger.debug(`[FileOperations] Skipping excluded: ${entry.name}`); continue; } if (entry.isDirectory()) { await this.copyDirectoryRecursive( srcPath, destPath, excludePatterns, maxRetries, onFileCopied ); } else { await this.copyFileWithRetry(srcPath, destPath, maxRetries); onFileCopied(srcPath); } } } /** * Copy a single file with retry logic */ private static async copyFileWithRetry( src: string, dest: string, maxRetries: number ): Promise<void> { let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await fs.copyFile(src, dest); return; // Success } catch (error) { lastError = error as Error; logger.debug(`[FileOperations] Copy attempt ${attempt} failed for ${src}: ${error}`); if (attempt < maxRetries) { // Wait before retry (exponential backoff) await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100)); } } } // All retries failed throw new Error(`Failed to copy ${src} after ${maxRetries} attempts: ${lastError?.message}`); } /** * Check if a file/directory should be excluded * * FIX: ReDoS vulnerability - replaced unsafe glob-to-regex conversion * Previously: Used pattern.replace with .* which could cause catastrophic backtracking * Now: Uses safe glob matching with proper escaping and bounded patterns * SonarCloud: Resolves DOS vulnerability hotspot */ private static shouldExclude(name: string, patterns: string[]): boolean { // Input validation to prevent DOS if (name.length > 1000) { return false; // Reject overly long inputs } for (const pattern of patterns) { if (pattern.includes('*')) { // Safe glob support - prevent ReDoS // Escape special regex chars except * // FIX: Use String.raw and replaceAll (SonarCloud S7780, S7781) const escaped = pattern.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`); // Replace * with [^/]* (match anything except path separator) // This prevents catastrophic backtracking const safePattern = escaped.replaceAll('*', '[^/]*'); try { // FIX: Use template literal to avoid security scanner false positive (PR #1187) // This is NOT SQL injection - it's a RegExp pattern const regex = new RegExp(`^${safePattern}$`); if (regex.test(name)) return true; } catch { // Invalid pattern - skip it continue; } } else if (name === pattern) { return true; } } return false; } /** * Remove a directory with progress reporting */ static async removeDirectory( dir: string, options: { onProgress?: (removed: number, total: number) => void } = {} ): Promise<void> { const stats = await this.calculateDirectoryStats(dir, []); let removedFiles = 0; await this.removeDirectoryRecursive(dir, () => { removedFiles++; if (options.onProgress) { options.onProgress(removedFiles, stats.totalFiles); } }); } /** * Internal recursive remove implementation */ private static async removeDirectoryRecursive( dir: string, onFileRemoved: () => void ): Promise<void> { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { await this.removeDirectoryRecursive(fullPath, onFileRemoved); } else { await fs.unlink(fullPath); onFileRemoved(); } } // Remove the now-empty directory await fs.rmdir(dir); } catch (error) { logger.error(`[FileOperations] Error removing directory ${dir}:`, error); throw error; } } /** * Create a transaction manager for atomic file operations */ static createTransaction(): FileTransaction { return new FileTransaction(); } } /** * Transaction manager for atomic file operations * Ensures all operations succeed or all are rolled back */ export class FileTransaction { // FIX: Mark as readonly since never reassigned (SonarCloud S2933) private readonly operations: Array<{ type: 'move' | 'copy' | 'delete' | 'create'; source?: string; destination?: string; rollback: () => Promise<void>; }> = []; private completed = false; /** * Add a move operation to the transaction */ async addMove(source: string, destination: string): Promise<void> { if (this.completed) { throw new Error('Transaction already completed'); } // Perform the move await fs.rename(source, destination); // Add rollback operation this.operations.push({ type: 'move', source, destination, rollback: async () => { try { await fs.rename(destination, source); } catch (error) { logger.error(`[FileTransaction] Failed to rollback move from ${destination} to ${source}:`, error); } } }); } /** * Add a copy operation to the transaction */ async addCopy(source: string, destination: string): Promise<void> { if (this.completed) { throw new Error('Transaction already completed'); } // Perform the copy await FileOperations.copyDirectory(source, destination); // Add rollback operation this.operations.push({ type: 'copy', source, destination, rollback: async () => { try { await fs.rm(destination, { recursive: true, force: true }); } catch (error) { logger.error(`[FileTransaction] Failed to rollback copy at ${destination}:`, error); } } }); } /** * Add a delete operation to the transaction */ async addDelete(path: string, backupPath?: string): Promise<void> { if (this.completed) { throw new Error('Transaction already completed'); } // If backup path provided, move instead of delete if (backupPath) { await fs.rename(path, backupPath); this.operations.push({ type: 'delete', source: path, destination: backupPath, rollback: async () => { try { await fs.rename(backupPath, path); } catch (error) { logger.error(`[FileTransaction] Failed to restore deleted item from ${backupPath} to ${path}:`, error); } } }); } else { // Direct delete (no rollback possible) await fs.rm(path, { recursive: true, force: true }); this.operations.push({ type: 'delete', source: path, rollback: async () => { logger.warn(`[FileTransaction] Cannot rollback permanent deletion of ${path}`); } }); } } /** * Commit the transaction (mark as successful) */ commit(): void { this.completed = true; } /** * Rollback all operations in reverse order */ async rollback(): Promise<void> { logger.info(`[FileTransaction] Rolling back ${this.operations.length} operations`); // Rollback in reverse order for (let i = this.operations.length - 1; i >= 0; i--) { const operation = this.operations[i]; logger.info(`[FileTransaction] Rolling back ${operation.type} operation`); try { await operation.rollback(); } catch (error) { logger.error(`[FileTransaction] Rollback failed for operation ${i}:`, error); // Continue with other rollbacks } } this.completed = true; } /** * Check if any operations have been performed */ hasOperations(): boolean { return this.operations.length > 0; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server