Skip to main content
Glama

XC-MCP: XCode CLI wrapper

by conorluddy
persistence.ts14.9 kB
import { promises as fs } from 'fs'; import { join } from 'path'; import { homedir, tmpdir } from 'os'; import { randomUUID } from 'crypto'; export interface PersistenceConfig { enabled: boolean; cacheDir: string; schemaVersion: string; } export interface SerializedData { version: string; timestamp: Date; data: unknown; } /** * PersistenceManager handles opt-in file-based persistence for XC-MCP cache systems. * Provides atomic file operations, smart storage location selection, and graceful degradation. */ export class PersistenceManager { private enabled = false; private cacheDir: string | null = null; private readonly schemaVersion = '1.0.0'; private readonly saveQueue = new Map<string, NodeJS.Timeout>(); /** * Enable persistence with optional custom cache directory */ async enable( userCacheDir?: string ): Promise<{ success: boolean; cacheDir: string; message?: string }> { try { const selectedDir = await this.determineStorageLocation(userCacheDir); // Ensure directory exists and is writable if (!(await this.ensureDirectoryWritable(selectedDir))) { return { success: false, cacheDir: selectedDir, message: 'Directory is not writable', }; } this.cacheDir = selectedDir; this.enabled = true; // Create directory structure await this.createDirectoryStructure(); // Create .gitignore if needed await this.ensureGitignore(); // Create version file await this.writeVersionFile(); return { success: true, cacheDir: selectedDir, message: 'Persistence enabled successfully', }; } catch (error) { return { success: false, cacheDir: userCacheDir || 'unknown', message: `Failed to enable persistence: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * Disable persistence with option to clear existing data */ async disable(clearData = false): Promise<{ success: boolean; message?: string }> { try { this.enabled = false; // Clear any pending saves for (const timeout of this.saveQueue.values()) { clearTimeout(timeout); } this.saveQueue.clear(); if (clearData && this.cacheDir) { await this.clearAllData(); } this.cacheDir = null; return { success: true, message: clearData ? 'Persistence disabled and data cleared' : 'Persistence disabled', }; } catch (error) { return { success: false, message: `Failed to disable persistence: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * Get current persistence status and storage information */ async getStatus(includeStorageInfo = true): Promise<{ enabled: boolean; cacheDir?: string; schemaVersion: string; storageInfo?: { diskUsage: number; fileCount: number; lastSave?: Date; isWritable: boolean; }; }> { const status = { enabled: this.enabled, cacheDir: this.cacheDir || undefined, schemaVersion: this.schemaVersion, }; if (includeStorageInfo && this.cacheDir) { const storageInfo = await this.getStorageInfo(); return { ...status, storageInfo }; } return status; } /** * Check if persistence is currently enabled */ isEnabled(): boolean { return this.enabled; } /** * Save state for a specific cache type with debouncing */ async saveState(cacheType: string, data: unknown): Promise<void> { if (!this.enabled || !this.cacheDir) { return; // Silently no-op when disabled } // Clear existing debounced save for this cache type const existingTimeout = this.saveQueue.get(cacheType); if (existingTimeout) { clearTimeout(existingTimeout); } // Debounce saves to avoid excessive disk I/O const timeout = setTimeout(async () => { try { await this.doSaveState(cacheType, data); this.saveQueue.delete(cacheType); } catch (error) { console.warn(`Failed to persist ${cacheType} cache state:`, error); } }, 1000); this.saveQueue.set(cacheType, timeout); } /** * Load state for a specific cache type */ async loadState<T>(cacheType: string): Promise<T | null> { if (!this.enabled || !this.cacheDir) { return null; // Silently no-op when disabled } try { const filePath = this.getCacheFilePath(cacheType); const content = await fs.readFile(filePath, 'utf8'); const serializedData: SerializedData = this.deserialize(content); // Validate schema version if (serializedData.version !== this.schemaVersion) { console.warn( `Schema version mismatch for ${cacheType}: ${serializedData.version} vs ${this.schemaVersion}` ); // For now, ignore old data (future: implement migrations) return null; } // Validate cache data if (!this.validateCacheData(cacheType, serializedData.data)) { console.warn(`Invalid cache data for ${cacheType}, ignoring`); return null; } return serializedData.data as T; } catch (error) { // File doesn't exist or is corrupted - return null for graceful degradation if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { return null; // File doesn't exist yet } console.warn(`Failed to load ${cacheType} cache state:`, error); return null; } } /** * Determine the best storage location based on priority */ private async determineStorageLocation(userCacheDir?: string): Promise<string> { const candidates = [ userCacheDir, // 1. User-specified (highest priority) process.env.XC_MCP_CACHE_DIR, // 2. Environment variable process.env.XDG_CACHE_HOME ? join(process.env.XDG_CACHE_HOME, 'xc-mcp') : null, // 3. XDG standard join(process.cwd(), '.xc-mcp'), // 4. Project-local join(homedir(), '.cache', 'xc-mcp'), // 5. User cache (Linux/macOS) join(homedir(), '.xc-mcp'), // 6. User home fallback join(tmpdir(), 'xc-mcp'), // 7. Temp (last resort) ].filter(Boolean) as string[]; // Return first valid/writable location for (const candidate of candidates) { if (await this.ensureDirectoryWritable(candidate)) { return candidate; } } // Fallback to temp directory const fallback = join(tmpdir(), 'xc-mcp'); await fs.mkdir(fallback, { recursive: true }); return fallback; } /** * Ensure directory exists and is writable */ private async ensureDirectoryWritable(dir: string): Promise<boolean> { try { await fs.mkdir(dir, { recursive: true, mode: 0o755 }); // Test write access const testFile = join(dir, '.write-test'); await fs.writeFile(testFile, 'test'); await fs.unlink(testFile); return true; } catch { return false; } } /** * Create the required directory structure */ private async createDirectoryStructure(): Promise<void> { if (!this.cacheDir) return; const cacheSubDir = join(this.cacheDir, 'cache'); const responsesDir = join(cacheSubDir, 'responses'); await fs.mkdir(cacheSubDir, { recursive: true }); await fs.mkdir(responsesDir, { recursive: true }); // Create marker file const markerFile = join(cacheSubDir, '.persistence-enabled'); await fs.writeFile(markerFile, new Date().toISOString()); } /** * Smart .gitignore handling that preserves existing content */ private async ensureGitignore(): Promise<void> { if (!this.cacheDir) return; const gitignoreFile = join(this.cacheDir, 'cache', '.gitignore'); const marker = '# XC-MCP Cache Files (auto-generated)'; const ignoreEntries = [marker, '*', '!.gitignore', '# End XC-MCP Cache Files']; try { let content = ''; try { content = await fs.readFile(gitignoreFile, 'utf8'); } catch { // File doesn't exist, will create } // Check if already managed if (content.includes(marker)) { return; // Already managed, don't modify } // Append with clear separation const addition = (content ? '\n\n' : '') + ignoreEntries.join('\n') + '\n'; await fs.writeFile(gitignoreFile, content + addition, 'utf8'); } catch (error) { // Log but don't fail - gitignore is nice-to-have console.warn('Could not update .gitignore:', (error as Error).message); } } /** * Write schema version file */ private async writeVersionFile(): Promise<void> { if (!this.cacheDir) return; const versionFile = join(this.cacheDir, 'version'); await fs.writeFile(versionFile, this.schemaVersion); } /** * Perform the actual state save with atomic writes */ private async doSaveState(cacheType: string, data: unknown): Promise<void> { if (!this.cacheDir) return; const filePath = this.getCacheFilePath(cacheType); await this.withFileLock(filePath, async () => { const serializedData: SerializedData = { version: this.schemaVersion, timestamp: new Date(), data, }; const content = this.serialize(serializedData); // Atomic write: write to temp file, then rename const tempFile = `${filePath}.tmp.${randomUUID()}`; await fs.writeFile(tempFile, content, 'utf8'); await fs.rename(tempFile, filePath); }); } /** * Get cache file path for a specific cache type */ private getCacheFilePath(cacheType: string): string { if (!this.cacheDir) throw new Error('Cache directory not set'); const cacheSubDir = join(this.cacheDir, 'cache'); switch (cacheType) { case 'simulators': return join(cacheSubDir, 'simulators.json'); case 'projects': return join(cacheSubDir, 'projects.json'); case 'responses': return join(cacheSubDir, 'responses', 'index.json'); default: return join(cacheSubDir, `${cacheType}.json`); } } /** * Serialize data with support for Map and Date objects */ private serialize<T>(data: T): string { return JSON.stringify( data, (key, value) => { if (value instanceof Map) { return { __type: 'Map', entries: Array.from(value.entries()) }; } if (value instanceof Date) { return { __type: 'Date', value: value.toISOString() }; } return value; }, 2 ); // Pretty print for debugging } /** * Deserialize data with support for Map and Date objects */ private deserialize<T>(json: string): T { return JSON.parse(json, (key, value) => { if (value && typeof value === 'object') { if (value.__type === 'Map') { return new Map(value.entries); } if (value.__type === 'Date') { return new Date(value.value); } } return value; }); } /** * Validate cache data structure */ private validateCacheData(type: string, data: unknown): boolean { if (!data || typeof data !== 'object') { return false; } const dataObj = data as Record<string, unknown>; switch (type) { case 'simulators': return ( typeof data === 'object' && (dataObj.cache === null || typeof dataObj.cache === 'object') && Array.isArray(dataObj.preferredByProject) && Array.isArray(dataObj.lastUsed) ); case 'projects': return ( typeof data === 'object' && Array.isArray(dataObj.projectConfigs) && Array.isArray(dataObj.buildHistory) && Array.isArray(dataObj.dependencyCache) ); case 'responses': return typeof data === 'object'; default: return true; // Unknown cache types are allowed } } /** * File locking for concurrent access protection */ private async withFileLock<T>(filePath: string, operation: () => Promise<T>): Promise<T> { const lockFile = `${filePath}.lock`; const maxRetries = 5; for (let i = 0; i < maxRetries; i++) { try { await fs.writeFile(lockFile, process.pid.toString(), { flag: 'wx' }); try { return await operation(); } finally { await fs.unlink(lockFile).catch(() => {}); // Cleanup lock } } catch (error: unknown) { if ((error as NodeJS.ErrnoException)?.code === 'EEXIST') { // Lock exists, wait and retry await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200)); continue; } throw error; } } throw new Error('Could not acquire file lock'); } /** * Get storage information for status reporting */ private async getStorageInfo(): Promise<{ diskUsage: number; fileCount: number; lastSave?: Date; isWritable: boolean; }> { if (!this.cacheDir) { return { diskUsage: 0, fileCount: 0, isWritable: false }; } let diskUsage = 0; let fileCount = 0; let lastSave: Date | undefined; try { const cacheDir = join(this.cacheDir, 'cache'); const files = await this.getAllFiles(cacheDir); for (const file of files) { try { const stats = await fs.stat(file); diskUsage += stats.size; fileCount++; if (!lastSave || stats.mtime > lastSave) { lastSave = stats.mtime; } } catch { // Skip files we can't stat } } } catch { // Directory doesn't exist or not accessible } const isWritable = await this.ensureDirectoryWritable(this.cacheDir); return { diskUsage, fileCount, lastSave, isWritable }; } /** * Recursively get all files in a directory */ private async getAllFiles(dir: string): Promise<string[]> { const files: string[] = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await this.getAllFiles(fullPath))); } else { files.push(fullPath); } } } catch { // Directory doesn't exist or not accessible } return files; } /** * Clear all persisted data */ private async clearAllData(): Promise<void> { if (!this.cacheDir) return; try { const cacheDir = join(this.cacheDir, 'cache'); await fs.rm(cacheDir, { recursive: true, force: true }); } catch (error) { console.warn('Failed to clear cache data:', error); } } } // Global persistence manager instance export const persistenceManager = new PersistenceManager();

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/conorluddy/xc-mcp'

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