From d331b484f9e5cc2c94e1e540cb7112fc8afefed6 Mon Sep 17 00:00:00 2001 From: ananas-viber Date: Tue, 6 Jan 2026 16:37:42 +0000 Subject: [PATCH] fix: verify zsh exists before using it for hook execution (#544) The `forceZsh` option on Linux/macOS would use a hardcoded zshPath without checking if zsh actually exists on the system. This caused hook commands to fail silently with exit code 127 on systems without zsh installed. Changes: - Always verify zsh exists via findZshPath() before using it - Fall back to bash -lc if zsh not found (preserves login shell PATH) - Fall through to spawn with shell:true if neither found The bash fallback ensures user PATH from .profile/.bashrc is available, which is important for hooks that depend on custom tool locations. Tested with opencode v1.1.3 - PreToolUse hooks now execute correctly on systems without zsh. Co-authored-by: Anas Viber --- src/shared/command-executor.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/shared/command-executor.ts b/src/shared/command-executor.ts index b95c83a..9baa85a 100644 --- a/src/shared/command-executor.ts +++ b/src/shared/command-executor.ts @@ -5,16 +5,17 @@ import { existsSync } from "fs" import { homedir } from "os" const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"] +const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir() } -function findZshPath(customZshPath?: string): string | null { - if (customZshPath && existsSync(customZshPath)) { - return customZshPath +function findShellPath(defaultPaths: string[], customPath?: string): string | null { + if (customPath && existsSync(customPath)) { + return customPath } - for (const path of DEFAULT_ZSH_PATHS) { + for (const path of defaultPaths) { if (existsSync(path)) { return path } @@ -22,6 +23,14 @@ function findZshPath(customZshPath?: string): string | null { return null } +function findZshPath(customZshPath?: string): string | null { + return findShellPath(DEFAULT_ZSH_PATHS, customZshPath) +} + +function findBashPath(): string | null { + return findShellPath(DEFAULT_BASH_PATHS) +} + const execAsync = promisify(exec) export interface CommandResult { @@ -55,10 +64,18 @@ export async function executeHookCommand( let finalCommand = expandedCommand if (options?.forceZsh) { - const zshPath = options.zshPath || findZshPath() + // Always verify shell exists before using it + const zshPath = findZshPath(options.zshPath) + const escapedCommand = expandedCommand.replace(/'/g, "'\\''") if (zshPath) { - const escapedCommand = expandedCommand.replace(/'/g, "'\\''") finalCommand = `${zshPath} -lc '${escapedCommand}'` + } else { + // Fall back to bash login shell to preserve PATH from user profile + const bashPath = findBashPath() + if (bashPath) { + finalCommand = `${bashPath} -lc '${escapedCommand}'` + } + // If neither zsh nor bash found, fall through to spawn with shell: true } }