UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

393 lines (392 loc) • 15.5 kB
import fs from 'fs/promises'; import path from 'path'; import { existsSync, watch } from 'fs'; import { mkdir } from 'fs/promises'; import os from 'os'; import lockfile from 'proper-lockfile'; import { VERSION } from './version.js'; import { CONFIG_FILE } from './config.js'; export function normalizeTelemetryEnabledValue(value) { if (typeof value !== 'string') { return value; } const normalized = value.trim().toLowerCase(); if (normalized === 'true') { return true; } if (normalized === 'false') { return false; } return value; } export function isTelemetryDisabledValue(value) { return normalizeTelemetryEnabledValue(value) === false; } /** * Singleton config manager for the server */ class ConfigManager { constructor() { this.config = {}; this.initialized = false; this._isFirstRun = false; // Track if this is the first run (config was just created) // Serializes all disk writes so concurrent saves can't corrupt config.json. this.writeChain = Promise.resolve(); // True while a coalesced background write is already queued (see scheduleSave). this.saveScheduled = false; this.pendingMutations = []; this.watcher = null; this.reloadTimer = null; // Get user's home directory // Define config directory and file paths this.configPath = CONFIG_FILE; } /** * Initialize configuration - load from disk or create default. * Creation and legacy migration use the same cross-process mutation path as * normal writes so two processes starting together cannot clobber each other. */ async init() { if (this.initialized) return; try { const configDir = path.dirname(this.configPath); if (!existsSync(configDir)) { await mkdir(configDir, { recursive: true }); } try { this.config = await this.readConfigFromDisk(); this._isFirstRun = false; if (this.config['welcomeOnboardingEligible'] === undefined) { await this.performConfigMutation((latest) => { if (latest['welcomeOnboardingEligible'] === undefined) { latest['welcomeOnboardingEligible'] = false; latest['pendingWelcomeOnboarding'] = false; } }); } } catch (error) { if (error?.code !== 'ENOENT') throw error; let created = false; await this.performConfigMutation((latest, existed) => { if (!existed) { Object.assign(latest, this.getDefaultConfig()); created = true; } }); this._isFirstRun = created; } this.config['version'] = VERSION; this.initialized = true; this.startConfigWatcher(); } catch (error) { console.error('Failed to initialize config:', error); this.config = this.getDefaultConfig(); this.initialized = true; this.startConfigWatcher(); } } /** * Alias for init() to maintain backward compatibility */ async loadConfig() { return this.init(); } /** * Create default configuration */ getDefaultConfig() { return { blockedCommands: [ // Disk and partition management "mkfs", // Create a filesystem on a device "format", // Format a storage device (cross-platform) "mount", // Mount a filesystem "umount", // Unmount a filesystem "fdisk", // Manipulate disk partition tables "dd", // Convert and copy files, can write directly to disks "parted", // Disk partition manipulator "diskpart", // Windows disk partitioning utility // System administration and user management "sudo", // Execute command as superuser "su", // Substitute user identity "passwd", // Change user password "adduser", // Add a user to the system "useradd", // Create a new user "usermod", // Modify user account "groupadd", // Create a new group "chsh", // Change login shell "visudo", // Edit the sudoers file // System control "shutdown", // Shutdown the system "reboot", // Restart the system "halt", // Stop the system "poweroff", // Power off the system "init", // Change system runlevel // Network and security "iptables", // Linux firewall administration "firewall", // Generic firewall command "netsh", // Windows network configuration // Windows system commands "sfc", // System File Checker "bcdedit", // Boot Configuration Data editor "reg", // Windows registry editor "net", // Network/user/service management "sc", // Service Control manager "runas", // Execute command as another user "cipher", // Encrypt/decrypt files or wipe data "takeown" // Take ownership of files ], defaultShell: (() => { if (os.platform() === 'win32') { return 'powershell.exe'; } // Use user's actual shell from environment // On macOS, default to zsh (default since Catalina) since process.env.SHELL // may not be set when running inside Claude Desktop const fallbackShell = os.platform() === 'darwin' ? '/bin/zsh' : '/bin/sh'; const userShell = process.env.SHELL || fallbackShell; // Return just the shell path - we'll handle login shell flag elsewhere return userShell; })(), allowedDirectories: [], telemetryEnabled: true, // Default to opt-out approach (telemetry on by default) fileWriteLineLimit: 50, // Default line limit for file write operations (changed from 100) fileReadLineLimit: 1000, // Default line limit for file read operations (changed from character-based) pendingWelcomeOnboarding: true, // New install flag - triggers A/B test for welcome page welcomeOnboardingEligible: true // Distinguishes new installs from migrated legacy configs }; } async readConfigFromDisk() { let lastError; for (let attempt = 0; attempt < 5; attempt++) { try { return JSON.parse(await fs.readFile(this.configPath, 'utf8')); } catch (error) { lastError = error; if (error?.code === 'ENOENT') throw error; if (!(error instanceof SyntaxError) || attempt === 4) throw error; await new Promise((resolve) => setTimeout(resolve, 10)); } } throw lastError; } async writeConfigAtomically(config) { const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; try { await fs.writeFile(tempPath, JSON.stringify(config, null, 2), 'utf8'); await fs.rename(tempPath, this.configPath); } finally { await fs.unlink(tempPath).catch(() => { }); } } async acquireConfigLock() { return lockfile.lock(this.configPath, { realpath: false, stale: 30000, update: 10000, retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 } }); } async performConfigMutation(mutate) { const release = await this.acquireConfigLock(); try { let latest; let existed = true; try { latest = await this.readConfigFromDisk(); } catch (error) { if (error?.code !== 'ENOENT') throw error; latest = {}; existed = false; } mutate(latest, existed); await this.writeConfigAtomically(latest); this.config = { ...latest, version: VERSION }; return latest; } finally { try { await release(); } catch (error) { // The atomic rename is the commit boundary. A release failure after it // must not make callers replay a mutation that already persisted. console.error('Failed to release config lock:', error); } } } queueMutation(mutate) { this.pendingMutations.push(mutate); this.scheduleSave(); } /** Non-blocking, coalesced persistence for high-frequency state updates. */ scheduleSave() { if (this.saveScheduled) return; this.saveScheduled = true; const write = this.writeChain.then(async () => { this.saveScheduled = false; const mutations = this.pendingMutations.splice(0); if (mutations.length === 0) return; try { await this.performConfigMutation((latest) => { for (const mutate of mutations) mutate(latest); }); } catch (error) { // Persistence failed before commit, so keep these mutations for a later retry. this.pendingMutations.unshift(...mutations); console.error('Failed to save config (background), will retry:', error); const retry = setTimeout(() => this.scheduleSave(), 250); retry.unref?.(); } }); this.writeChain = write.catch(() => { }); } startConfigWatcher() { if (this.watcher) return; try { const dir = path.dirname(this.configPath); const filename = path.basename(this.configPath); this.watcher = watch(dir, { persistent: false }, (_event, changed) => { if (changed && changed.toString() !== filename) return; if (this.reloadTimer) clearTimeout(this.reloadTimer); this.reloadTimer = setTimeout(() => void this.reloadConfigFromDisk(), 20); }); this.watcher.on('error', (error) => console.error('Config watcher error:', error)); } catch (error) { console.error('Failed to watch config:', error); } } async reloadConfigFromDisk() { try { const latest = await this.readConfigFromDisk(); for (const mutate of this.pendingMutations) mutate(latest); latest['version'] = VERSION; this.config = latest; } catch (error) { if (error?.code !== 'ENOENT') console.error('Failed to reload config:', error); } } /** * Get the entire config */ async getConfig() { await this.init(); return { ...this.config }; } /** * Get a specific configuration value */ async getValue(key) { await this.init(); return this.config[key]; } /** Set a specific configuration value and wait for durable persistence. */ async setValue(key, value) { await this.init(); if (key === 'telemetryEnabled') value = normalizeTelemetryEnabledValue(value); if (key === 'telemetryEnabled' && isTelemetryDisabledValue(value)) { const currentValue = this.config[key]; if (!isTelemetryDisabledValue(currentValue)) { const { capture } = await import('./utils/capture.js'); await capture('server_telemetry_opt_out', { reason: 'user_disabled', prev_value: currentValue }); } } const nextValue = value; const write = this.writeChain.then(() => this.performConfigMutation((latest) => { latest[key] = nextValue; })); this.writeChain = write.then(() => { }, () => { }); await write; } /** Update one value under the cross-process lock and return the durable value. */ async updateValue(key, updater) { await this.init(); let updatedValue; const write = this.writeChain.then(() => this.performConfigMutation((latest) => { updatedValue = updater(latest[key]); latest[key] = updatedValue; })); this.writeChain = write.then(() => { }, () => { }); await write; return updatedValue; } /** * Set a value without waiting on disk. The queued operation is applied to the * latest on-disk config while holding the cross-process lock. */ async setValueNonBlocking(key, value) { await this.init(); this.config[key] = value; this.queueMutation((latest) => { latest[key] = value; }); } /** * Atomically update one value without blocking the caller on persistence. * The updater is replayed against the latest disk value under the lock, which * makes counter-style updates safe across multiple Desktop Commander processes. */ async updateValueNonBlocking(key, updater) { await this.init(); const next = updater(this.config[key]); this.config[key] = next; this.queueMutation((latest) => { latest[key] = updater(latest[key]); }); return next; } /** Update multiple configuration values at once. */ async updateConfig(updates) { await this.init(); const write = this.writeChain.then(() => this.performConfigMutation((latest) => { Object.assign(latest, updates); })); this.writeChain = write.then(() => { }, () => { }); return { ...(await write) }; } /** Reset configuration to defaults. This intentionally replaces all keys. */ async resetConfig() { await this.init(); const defaults = this.getDefaultConfig(); const write = this.writeChain.then(() => this.performConfigMutation((latest) => { for (const key of Object.keys(latest)) delete latest[key]; Object.assign(latest, defaults); })); this.writeChain = write.then(() => { }, () => { }); return { ...(await write) }; } /** * Check if this is the first run (config file was just created) */ isFirstRun() { return this._isFirstRun; } /** * Get or create a persistent client ID for analytics and A/B tests */ async getOrCreateClientId() { const { randomUUID } = await import('crypto'); return await this.updateValue('clientId', (current) => current || randomUUID()); } } // Export singleton instance export const configManager = new ConfigManager();