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