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 <ananas-viber@users.noreply.github.com>
This commit is contained in:
ananas-viber
2026-01-06 16:37:42 +00:00
committed by GitHub
parent 4a38e70fa8
commit d331b484f9

View File

@@ -5,16 +5,17 @@ import { existsSync } from "fs"
import { homedir } from "os" import { homedir } from "os"
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"] 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 { function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir() return process.env.HOME || process.env.USERPROFILE || homedir()
} }
function findZshPath(customZshPath?: string): string | null { function findShellPath(defaultPaths: string[], customPath?: string): string | null {
if (customZshPath && existsSync(customZshPath)) { if (customPath && existsSync(customPath)) {
return customZshPath return customPath
} }
for (const path of DEFAULT_ZSH_PATHS) { for (const path of defaultPaths) {
if (existsSync(path)) { if (existsSync(path)) {
return path return path
} }
@@ -22,6 +23,14 @@ function findZshPath(customZshPath?: string): string | null {
return 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) const execAsync = promisify(exec)
export interface CommandResult { export interface CommandResult {
@@ -55,10 +64,18 @@ export async function executeHookCommand(
let finalCommand = expandedCommand let finalCommand = expandedCommand
if (options?.forceZsh) { 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) { if (zshPath) {
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
finalCommand = `${zshPath} -lc '${escapedCommand}'` 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
} }
} }