Skip to content

Hook runner ignores script shebang; bash hooks fail under zsh $SHELL #2

@jlrickert

Description

@jlrickert

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:

  1. Open the file and read the first line.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions