diff --git a/src/config-field-definitions.ts b/src/config-field-definitions.ts index 77efa9bf..8c265a4e 100644 --- a/src/config-field-definitions.ts +++ b/src/config-field-definitions.ts @@ -20,7 +20,7 @@ export const CONFIG_FIELD_DEFINITIONS = { }, defaultShell: { label: 'Default Shell', - description: 'This is the shell used for new command sessions (for example /bin/bash or /bin/zsh). Only change this if you know your environment requires a specific shell.', + description: 'This is the shell used for new command sessions (for example /bin/bash or /bin/zsh). You can include arguments, for example "pwsh.exe -NoProfile -NoLogo". Only change this if you know your environment requires a specific shell.', valueType: 'string', }, telemetryEnabled: { diff --git a/src/terminal-manager.ts b/src/terminal-manager.ts index 00d431a7..3b63f214 100644 --- a/src/terminal-manager.ts +++ b/src/terminal-manager.ts @@ -82,65 +82,89 @@ interface ShellSpawnConfig { windowsVerbatim?: boolean; } +/** + * Split a shell configuration value into its executable and any extra arguments. + * + * defaultShell (and the per-call shell parameter) may carry flags, e.g. + * "pwsh.exe -NoProfile -NoLogo" or "/bin/bash --norc". Without this split the + * whole string is treated as one executable path, spawn fails with ENOENT and + * the call hangs until the client's timeout. Quotes are honoured so executable + * paths containing spaces survive, e.g. '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile'. + * See issue #448. + */ +function splitShellConfig(shell: string): { executable: string; args: string[] } { + const tokens: string[] = []; + const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(shell)) !== null) { + tokens.push(match[1] ?? match[2] ?? match[3]); + } + const [executable = shell, ...args] = tokens; + return { executable, args }; +} + /** * Get the appropriate spawn configuration for a given shell * This handles login shell flags for different shell types */ -function getShellSpawnArgs(shellPath: string, command: string): ShellSpawnConfig { - const shellName = path.basename(shellPath).toLowerCase(); - +export function getShellSpawnArgs(shellPath: string, command: string): ShellSpawnConfig { + // The shell value may include arguments (e.g. "pwsh.exe -NoProfile"); match on + // the executable name only and keep the caller's extra args ahead of our flags. + const { executable: shellExecutable, args: shellArgs } = splitShellConfig(shellPath); + const shellName = path.basename(shellExecutable).toLowerCase(); + // Unix shells with login flag support if (shellName.includes('bash') || shellName.includes('zsh')) { - return { - executable: shellPath, - args: ['-l', '-c', command], - useShellOption: false + return { + executable: shellExecutable, + args: [...shellArgs, '-l', '-c', command], + useShellOption: false }; } - + // PowerShell Core (cross-platform, supports -Login) if (shellName === 'pwsh' || shellName === 'pwsh.exe') { - return { - executable: shellPath, - args: ['-Login', '-Command', command], - useShellOption: false + return { + executable: shellExecutable, + args: [...shellArgs, '-Login', '-Command', command], + useShellOption: false }; } - + // Windows PowerShell 5.1 (no login flag support) if (shellName === 'powershell' || shellName === 'powershell.exe') { - return { - executable: shellPath, - args: ['-Command', command], - useShellOption: false + return { + executable: shellExecutable, + args: [...shellArgs, '-Command', command], + useShellOption: false }; } - + // CMD if (shellName === 'cmd' || shellName === 'cmd.exe') { - return { - executable: shellPath, - args: ['/c', command], + return { + executable: shellExecutable, + args: [...shellArgs, '/c', command], windowsVerbatim: true, - useShellOption: false + useShellOption: false }; } - + // Fish shell (uses -l for login, -c for command) if (shellName.includes('fish')) { - return { - executable: shellPath, - args: ['-l', '-c', command], - useShellOption: false + return { + executable: shellExecutable, + args: [...shellArgs, '-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 { + return { executable: command, args: [], - useShellOption: shellPath + useShellOption: shellExecutable }; } diff --git a/test/test-default-shell-args.js b/test/test-default-shell-args.js new file mode 100644 index 00000000..71df4545 --- /dev/null +++ b/test/test-default-shell-args.js @@ -0,0 +1,69 @@ +/** + * Tests for defaultShell / shell values that carry arguments (issue #448). + * + * A defaultShell like "pwsh.exe -NoProfile -NoLogo" or "/bin/bash --norc" used to + * be treated as a single executable path, so spawn failed with ENOENT and the + * call hung until the client timed out. getShellSpawnArgs now splits the + * executable from its arguments, matches the shell on the executable name and + * keeps the caller's extra args ahead of the standard flags. Single-token values + * must stay byte-for-byte identical to the old behaviour. + * + * These assertions are platform independent (shell matching for cmd/pwsh keys and + * the POSIX paths used here resolve the same on any OS), so they run on macOS CI. + */ +import { getShellSpawnArgs } from '../dist/terminal-manager.js'; +import assert from 'assert'; + +function runDefaultShellArgsTests() { + // 1. Single-token values are unchanged (no regression) + let cfg = getShellSpawnArgs('/bin/bash', 'echo hi'); + assert.strictEqual(cfg.executable, '/bin/bash'); + assert.deepStrictEqual(cfg.args, ['-l', '-c', 'echo hi']); + console.log('✓ single-token /bin/bash unchanged'); + + cfg = getShellSpawnArgs('pwsh.exe', 'x'); + assert.strictEqual(cfg.executable, 'pwsh.exe'); + assert.deepStrictEqual(cfg.args, ['-Login', '-Command', 'x']); + console.log('✓ single-token pwsh.exe unchanged'); + + // 2. The reported case: pwsh with flags no longer collapses into the executable + cfg = getShellSpawnArgs('pwsh.exe -NoProfile -NoLogo', 'Write-Host hi'); + assert.strictEqual(cfg.executable, 'pwsh.exe'); + assert.deepStrictEqual(cfg.args, ['-NoProfile', '-NoLogo', '-Login', '-Command', 'Write-Host hi']); + console.log('✓ pwsh.exe with flags splits correctly'); + + // 3. Unix shell with a flag + cfg = getShellSpawnArgs('/bin/bash --norc', 'echo hi'); + assert.strictEqual(cfg.executable, '/bin/bash'); + assert.deepStrictEqual(cfg.args, ['--norc', '-l', '-c', 'echo hi']); + console.log('✓ /bin/bash --norc splits correctly'); + + // 4. Quoted executable path with spaces survives + cfg = getShellSpawnArgs('"/opt/my shell/bin/bash" --norc', 'echo hi'); + assert.strictEqual(cfg.executable, '/opt/my shell/bin/bash'); + assert.deepStrictEqual(cfg.args, ['--norc', '-l', '-c', 'echo hi']); + console.log('✓ quoted executable path with spaces preserved'); + + // 5. cmd with a flag keeps windowsVerbatim + cfg = getShellSpawnArgs('cmd.exe /q', 'dir'); + assert.strictEqual(cfg.executable, 'cmd.exe'); + assert.deepStrictEqual(cfg.args, ['/q', '/c', 'dir']); + assert.strictEqual(cfg.windowsVerbatim, true); + console.log('✓ cmd.exe /q splits correctly and stays verbatim'); + + console.log('\n✅ All defaultShell argument-parsing tests passed'); +} + +export default async function runTests() { + try { + runDefaultShellArgsTests(); + return true; + } catch (error) { + console.error('❌ Test failed:', error.message); + return false; + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runDefaultShellArgsTests(); +}