@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
393 lines (392 loc) • 15.5 kB
JavaScript
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();