Hook runner ignores script shebang; bash hooks fail under zsh $SHELL
Summary
dots executes post_install (and other lifecycle) hook scripts by
invoking $SHELL with the script path as an argument, rather than
exec'ing the script itself and letting the kernel honor the script's
shebang. On macOS, where $SHELL is /bin/zsh for most users, this
causes hooks written as bash scripts to be run under zsh. Scripts that
rely on bashisms (shopt, BASH_SOURCE, bash arrays, etc.) fail with
command not found or parse errors even when the script declares
#!/usr/bin/env bash on its first line.
Environment
dots --version: 0.7.0
- OS: macOS,
darwin arm64
$SHELL: /bin/zsh
bash available at /bin/bash (system) and/or /opt/homebrew/bin/bash (brew)
Reproduction
Minimal package with a bash-only post_install hook.
example/Dotfile.yaml:
name: example
version: 0.1.0
hooks:
post_install: scripts/post-install.sh
example/scripts/post-install.sh:
#!/usr/bin/env bash
shopt -s nullglob
echo "running as: $0"
echo "BASH_SOURCE: ${BASH_SOURCE[0]}"
Make the script executable:
chmod +x example/scripts/post-install.sh
Run install:
SHELL=/bin/zsh dots install example
Expected
The shebang on line 1 is honored. bash interprets the script.
Output:
running as: /…/example/scripts/post-install.sh
BASH_SOURCE: /…/example/scripts/post-install.sh
Observed
zsh interprets the script. shopt does not exist as a zsh builtin,
so the script aborts before producing any output:
/Users/<user>/.dots/packages/example/scripts/post-install.sh:2: command not found: shopt
Error: post_install hook: hook scripts/post-install.sh failed: exit status 127
The error reproduces with any bashism. Replacing shopt -s nullglob
with echo "${BASH_SOURCE[0]}" produces a different zsh-specific
parse error but the same root cause.
Root cause
pkg/dots/hooks.go resolves which interpreter to run by extension and
falls through to $SHELL for anything not Windows-specific. The
shebang on the script itself is never read.
pkg/dots/hooks.go::resolveShell (lines 67-85):
// resolveShell determines the shell and arguments to use for a hook script.
func resolveShell(scriptPath string) (string, []string) {
ext := strings.ToLower(filepath.Ext(scriptPath))
if runtime.GOOS == "windows" {
if ext == ".ps1" {
return "powershell.exe", []string{"-ExecutionPolicy", "Bypass", "-File"}
}
if ext == ".cmd" || ext == ".bat" {
return "cmd.exe", []string{"/C"}
}
}
// Unix: use $SHELL or /bin/sh
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
return shell, nil
}
The caller in RunHook then exec's that shell with the script path as
a positional argument (lines 33-44):
if info, err := os.Stat(absPath); err == nil && !info.IsDir() {
shell, args := resolveShell(absPath)
cmd := exec.CommandContext(ctx, shell, append(args, absPath)...)
cmd.Dir = pkgDir
cmd.Stdout = h.Stdout
cmd.Stderr = h.Stderr
cmd.Env = env
if err := cmd.Run(); err != nil {
return fmt.Errorf("hook %s failed: %w", hookValue, err)
}
return nil
}
So the runner ends up doing the equivalent of:
/bin/zsh /…/scripts/post-install.sh
That ignores the script's #!/usr/bin/env bash line entirely. The
kernel's execve shebang-handling path is never reached because the
script isn't being exec'd as a program — it's being passed as an
argument to a shell that was chosen by dots.
Proposed behavior
When the hook value resolves to a file on disk, dots should:
- Open the file and read the first line.
- If the first line starts with
#!, parse it as a shebang. Use the
declared interpreter (and any single argument the kernel would
accept, e.g. /usr/bin/env bash) to run the script.
- If there is no shebang, or the shebang is unparseable, fall back to
the current $SHELL behavior.
This matches kernel execve(2) semantics for scripts and is what every
user who writes #!/usr/bin/env bash already expects. The executable
bit is not required for this approach, since dots is reading the file
itself rather than asking the kernel to exec it — so existing packages
where the hook file is not chmod +x will keep working.
Pseudocode:
func resolveInterpreter(scriptPath string) (string, []string, error) {
f, err := os.Open(scriptPath)
if err != nil {
return "", nil, err
}
defer f.Close()
br := bufio.NewReader(f)
first, err := br.ReadString('\n')
if err != nil && err != io.EOF {
return "", nil, err
}
first = strings.TrimRight(first, "\r\n")
if strings.HasPrefix(first, "#!") {
fields := strings.Fields(strings.TrimPrefix(first, "#!"))
if len(fields) >= 1 {
return fields[0], fields[1:], nil
}
}
// Fall back to $SHELL.
shell, args := resolveShell(scriptPath)
return shell, args, nil
}
Windows behavior (.ps1, .cmd, .bat extension dispatch) should be
preserved and take precedence over shebang parsing, since shebangs are
not meaningful on Windows.
Alternative considered
A config knob in Dotfile.yaml along the lines of:
hooks:
shell: bash
post_install: scripts/post-install.sh
would unblock authors and is simpler to implement, but it is strictly
less correct than shebang-honoring: it forces every hook in a package
to use the same interpreter, it doesn't match what the script's first
line already declares, and it duplicates information the script
already carries. Mentioned only for completeness.
Workaround
For hook authors hitting this today: declare the interpreter in the
Dotfile.yaml value itself rather than relying on the script's
shebang. Example:
hooks:
post_install: bash scripts/post-install.sh
This works because RunHook calls os.Stat on the full hook value as
a path first (pkg/dots/hooks.go:32-33); the stat fails when the value
is bash scripts/post-install.sh, so the runner falls through to the
inline-command branch (lines 46-63) which runs $SHELL -c "bash scripts/post-install.sh". That nested invocation exec's bash as a
child process, which correctly interprets the script.
This is a workaround for hook authors. It does not fix the underlying
bug — hooks written the obvious way (a path to a script with a shebang)
should just work.
Acceptance criteria
- A hook script with
#!/usr/bin/env bash and a bashism (shopt -s nullglob, ${BASH_SOURCE[0]}, etc.) runs under bash and succeeds,
regardless of $SHELL.
- A hook script with
#!/bin/sh runs under /bin/sh, regardless of
$SHELL.
- A hook script with no shebang at all preserves the current behavior
(run under $SHELL, falling back to /bin/sh).
- An
exec.Command failure (interpreter not found, permission error,
non-zero exit) surfaces the same stderr and error wrapping
(hook <value> failed: %w) that it does today.
- Windows
.ps1 / .cmd / .bat dispatch in resolveShell is
unchanged.
- The inline-command branch (
hooks.post_install: launchctl load …)
is unchanged — it continues to use $SHELL -c.
Hook runner ignores script shebang; bash hooks fail under zsh
$SHELLSummary
dotsexecutespost_install(and other lifecycle) hook scripts byinvoking
$SHELLwith the script path as an argument, rather thanexec'ing the script itself and letting the kernel honor the script's
shebang. On macOS, where
$SHELLis/bin/zshfor most users, thiscauses hooks written as bash scripts to be run under zsh. Scripts that
rely on bashisms (
shopt,BASH_SOURCE, bash arrays, etc.) fail withcommand not foundor parse errors even when the script declares#!/usr/bin/env bashon its first line.Environment
dots --version:0.7.0darwin arm64$SHELL:/bin/zshbashavailable at/bin/bash(system) and/or/opt/homebrew/bin/bash(brew)Reproduction
Minimal package with a bash-only
post_installhook.example/Dotfile.yaml:example/scripts/post-install.sh:Make the script executable:
Run install:
Expected
The shebang on line 1 is honored.
bashinterprets the script.Output:
Observed
zshinterprets the script.shoptdoes not exist as a zsh builtin,so the script aborts before producing any output:
The error reproduces with any bashism. Replacing
shopt -s nullglobwith
echo "${BASH_SOURCE[0]}"produces a different zsh-specificparse error but the same root cause.
Root cause
pkg/dots/hooks.goresolves which interpreter to run by extension andfalls through to
$SHELLfor anything not Windows-specific. Theshebang on the script itself is never read.
pkg/dots/hooks.go::resolveShell(lines 67-85):The caller in
RunHookthen exec's that shell with the script path asa positional argument (lines 33-44):
So the runner ends up doing the equivalent of:
That ignores the script's
#!/usr/bin/env bashline entirely. Thekernel's
execveshebang-handling path is never reached because thescript isn't being exec'd as a program — it's being passed as an
argument to a shell that was chosen by
dots.Proposed behavior
When the hook value resolves to a file on disk,
dotsshould:#!, parse it as a shebang. Use thedeclared interpreter (and any single argument the kernel would
accept, e.g.
/usr/bin/env bash) to run the script.the current
$SHELLbehavior.This matches kernel
execve(2)semantics for scripts and is what everyuser who writes
#!/usr/bin/env bashalready expects. The executablebit is not required for this approach, since
dotsis reading the fileitself rather than asking the kernel to exec it — so existing packages
where the hook file is not chmod +x will keep working.
Pseudocode:
Windows behavior (
.ps1,.cmd,.batextension dispatch) should bepreserved and take precedence over shebang parsing, since shebangs are
not meaningful on Windows.
Alternative considered
A config knob in
Dotfile.yamlalong the lines of:would unblock authors and is simpler to implement, but it is strictly
less correct than shebang-honoring: it forces every hook in a package
to use the same interpreter, it doesn't match what the script's first
line already declares, and it duplicates information the script
already carries. Mentioned only for completeness.
Workaround
For hook authors hitting this today: declare the interpreter in the
Dotfile.yamlvalue itself rather than relying on the script'sshebang. Example:
This works because
RunHookcallsos.Staton the full hook value asa path first (
pkg/dots/hooks.go:32-33); the stat fails when the valueis
bash scripts/post-install.sh, so the runner falls through to theinline-command branch (lines 46-63) which runs
$SHELL -c "bash scripts/post-install.sh". That nested invocation exec'sbashas achild process, which correctly interprets the script.
This is a workaround for hook authors. It does not fix the underlying
bug — hooks written the obvious way (a path to a script with a shebang)
should just work.
Acceptance criteria
#!/usr/bin/env bashand a bashism (shopt -s nullglob,${BASH_SOURCE[0]}, etc.) runs under bash and succeeds,regardless of
$SHELL.#!/bin/shruns under/bin/sh, regardless of$SHELL.(run under
$SHELL, falling back to/bin/sh).exec.Commandfailure (interpreter not found, permission error,non-zero exit) surfaces the same stderr and error wrapping
(
hook <value> failed: %w) that it does today..ps1/.cmd/.batdispatch inresolveShellisunchanged.
hooks.post_install: launchctl load …)is unchanged — it continues to use
$SHELL -c.