UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

188 lines (187 loc) • 6.86 kB
import fs from 'fs'; import os from 'os'; import path from 'path'; /** * Single source of truth for shells: which shell is the default on this * machine, which shells are installed, and how to invoke each of them. * Config defaults, system info, process tools, the terminal manager and the * config editor all derive their shell choices from here. */ // Shell executables Windows can host. Which of them exist is resolved on PATH. const WINDOWS_SHELLS = ['powershell.exe', 'pwsh.exe', 'cmd.exe', 'bash.exe']; function isWindows() { return os.platform() === 'win32'; } function isExecutableFile(filePath) { try { fs.accessSync(filePath, isWindows() ? fs.constants.F_OK : fs.constants.X_OK); return fs.statSync(filePath).isFile(); } catch { // Missing or not executable: this candidate isn't a usable shell, the next one is tried return false; } } function windowsExecutableExtensions() { const fromEnv = (process.env.PATHEXT ?? '').split(';').map((ext) => ext.trim()).filter(Boolean); return [...new Set(['.EXE', ...fromEnv])]; } /** * Resolves a shell or other program name ("pwsh", "bash.exe", "where") or path * ("/bin/zsh") to an executable file. Bare names are searched in PATH's * folders only, never the working folder. Returns null when it can't be found. */ export function resolveShellPath(shell) { const name = shell.trim(); if (!name) return null; if (path.isAbsolute(name) || name.includes('/') || name.includes('\\')) { return isExecutableFile(name) ? name : null; } const extensions = isWindows() && !path.extname(name) ? windowsExecutableExtensions() : ['']; const pathDirs = (process.env.PATH ?? '').split(path.delimiter) .map((dir) => dir.trim().replace(/^"(.*)"$/, '$1')) .filter(Boolean); for (const dir of pathDirs) { for (const ext of extensions) { const candidate = path.join(dir, name + ext); if (isExecutableFile(candidate)) return candidate; } } return null; } export function isShellAvailable(shell) { return resolveShellPath(shell) !== null; } /** * The full path to start a program by: the one found on PATH. Windows looks * for a bare name ("powershell.exe", "cmd", "tasklist") in the working folder * before PATH, so a file of that name there would run instead; every program * Desktop Commander starts by name goes through here. On Windows a bare name * not found on PATH is an error rather than a name Windows would look up in * the working folder. A path (absolute, or with a folder in it) is kept as * given, and so is a bare name elsewhere, where the working folder isn't searched. */ export function resolveProgramPath(program) { const resolved = resolveShellPath(program); if (resolved) return resolved; const name = program.trim(); if (isWindows() && !name.includes('/') && !name.includes('\\')) { throw new Error(`${name} was not found on PATH`); } return program; } /** * The shell used when the user hasn't configured one. * Windows: Windows PowerShell, falling back to %ComSpec%. * macOS/Linux: $SHELL, falling back to the OS default shell. */ export function getDefaultShell() { if (isWindows()) { if (isShellAvailable('powershell.exe')) return 'powershell.exe'; return process.env.ComSpec || 'cmd.exe'; } const userShell = process.env.SHELL; if (userShell && isShellAvailable(userShell)) return userShell; // $SHELL may be unset when launched from a GUI app such as Claude Desktop. // zsh is the macOS default since Catalina; /bin/sh is guaranteed by POSIX. return os.platform() === 'darwin' ? '/bin/zsh' : '/bin/sh'; } // Login programs /etc/shells can list that don't run a command given with -c, // the way every command here starts: terminal multiplexers (screen, tmux), // git-shell (git commands only), and programs that refuse a login const NOT_COMMAND_SHELLS = new Set(['nologin', 'false', 'true', 'sync', 'git-shell', 'screen', 'tmux']); function readEtcShells() { try { return fs.readFileSync('/etc/shells', 'utf8') .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith('#')) .filter((line) => !NOT_COMMAND_SHELLS.has(path.basename(line))); } catch { // Best-effort discovery only: no readable /etc/shells means no extra shells to list return []; } } /** * Shells installed on this machine, default shell first. Only shells that * resolve to an executable are returned. */ export function detectAvailableShells() { const shells = new Set(); const add = (shell) => { const value = shell?.trim(); if (value && isShellAvailable(value)) shells.add(value); }; add(getDefaultShell()); if (isWindows()) { WINDOWS_SHELLS.forEach(add); } else { add(process.env.SHELL); readEtcShells().forEach(add); } return [...shells]; } /** * Get the appropriate spawn configuration for a given shell * This handles login shell flags for different shell types */ export function getShellSpawnArgs(shellPath, command) { const shellName = path.basename(shellPath).toLowerCase(); const executable = resolveProgramPath(shellPath); // Unix shells with login flag support if (shellName.includes('bash') || shellName.includes('zsh')) { return { executable, args: ['-l', '-c', command], useShellOption: false }; } // PowerShell Core (cross-platform, supports -Login) if (shellName === 'pwsh' || shellName === 'pwsh.exe') { return { executable, args: ['-Login', '-Command', command], useShellOption: false }; } // Windows PowerShell 5.1 (no login flag support) if (shellName === 'powershell' || shellName === 'powershell.exe') { return { executable, args: ['-Command', command], useShellOption: false }; } // CMD if (shellName === 'cmd' || shellName === 'cmd.exe') { return { executable, args: ['/c', command], windowsVerbatim: true, useShellOption: false }; } // Fish shell (uses -l for login, -c for command) if (shellName.includes('fish')) { return { executable, args: ['-l', '-c', command], useShellOption: false }; } // Unknown/other shells - use shell option for safety // This provides a fallback for shells we don't explicitly handle return { executable: command, args: [], useShellOption: executable }; }