diff --git a/tests/zsh/startup.bats b/tests/zsh/startup.bats index f92c8862..5359132a 100644 --- a/tests/zsh/startup.bats +++ b/tests/zsh/startup.bats @@ -49,7 +49,7 @@ EOF XDG_CACHE_HOME="$TEST_HOME/.cache" \ FPATH="$BATS_TEST_TMPDIR/functions" \ PATH="$TEST_BIN:$PATH" \ - zsh -dfi -c "source '$REPO_ROOT/zsh/.zshrc'" + zsh -dfi -c "source '$REPO_ROOT/zsh/.zshenv'; source '$REPO_ROOT/zsh/.zshrc'" [ "$status" -eq 0 ] grep -Fx -- "--quiet -S hl=herdr --session main" "$ABBR_LOG" @@ -61,7 +61,7 @@ EOF XDG_CACHE_HOME="$TEST_HOME/.cache" \ FPATH="$BATS_TEST_TMPDIR/functions" \ PATH="$TEST_BIN:$PATH" \ - zsh -dfi -c "source '$REPO_ROOT/zsh/.zshrc'" + zsh -dfi -c "source '$REPO_ROOT/zsh/.zshenv'; source '$REPO_ROOT/zsh/.zshrc'" [ "$status" -eq 0 ] grep -Fx -- \ @@ -75,7 +75,7 @@ EOF XDG_CACHE_HOME="$TEST_HOME/.cache" \ FPATH="$BATS_TEST_TMPDIR/functions" \ PATH="$TEST_BIN:$PATH" \ - zsh -dfi -c "source '$REPO_ROOT/zsh/.zshrc'" + zsh -dfi -c "source '$REPO_ROOT/zsh/.zshenv'; source '$REPO_ROOT/zsh/.zshrc'" [ "$status" -eq 0 ] [ "$(cat "$COMPINIT_LOG")" = "-C -d $TEST_HOME/.cache/zsh/.zcompdump" ] @@ -91,6 +91,154 @@ EOF ! [[ "$stderr" == *"brew shellenv"* ]] } +@test "clean login startup orders Nix profiles and fallback tools before system entries" { + local _user_profile="$TEST_HOME/.nix-profile/bin" + local _per_user_profile="/etc/profiles/per-user/startup-test/bin" + local _system_profile="/run/current-system/sw/bin" + local _default_profile="/nix/var/nix/profiles/default/bin" + + run_startup_path "$_user_profile:$_per_user_profile:$_system_profile:$_default_profile:/usr/bin:$TEST_BIN:/bin" -lic + + [ "$status" -eq 0 ] + local _expected=( + "$_user_profile" + "$_per_user_profile" + "$_system_profile" + "$_default_profile" + "$TEST_HOME/.local/bin" + "$TEST_HOME/.cargo/bin" + "$TEST_HOME/.gem/bin" + /opt/homebrew/bin + /Library/TeX/texbin + /usr/bin + "$TEST_BIN" + /bin + /Applications/Obsidian.app/Contents/MacOS + ) + [ "${#lines[@]}" -eq "${#_expected[@]}" ] + local _index + for _index in "${!_expected[@]}"; do + [ "${lines[_index]}" = "${_expected[_index]}" ] || { + printf 'unexpected PATH entry at %s: %s\n' "$_index" "$output" >&2 + return 1 + } + done +} + +@test "startup preserves inherited devShell PATH order in all shell modes" { + local _inherited="/nix/store/devshell-tools/bin:/workspace/project/bin:$TEST_HOME/.cargo/bin:/opt/homebrew/bin:$TEST_HOME/.nix-profile/bin:/usr/bin:$TEST_HOME/.local/bin:/Library/TeX/texbin:$TEST_BIN:/bin" + local _mode + for _mode in -c -ic -lic; do + run_startup_path "$_inherited:$_inherited" "$_mode" + + [ "$status" -eq 0 ] + local _expected=( + /nix/store/devshell-tools/bin + /workspace/project/bin + "$TEST_HOME/.cargo/bin" + /opt/homebrew/bin + "$TEST_HOME/.nix-profile/bin" + "$TEST_HOME/.gem/bin" + /usr/bin + "$TEST_HOME/.local/bin" + /Library/TeX/texbin + "$TEST_BIN" + /bin + ) + if [ "$_mode" = -lic ]; then + _expected+=(/Applications/Obsidian.app/Contents/MacOS) + fi + [ "${#lines[@]}" -eq "${#_expected[@]}" ] + local _index + for _index in "${!_expected[@]}"; do + [ "${lines[_index]}" = "${_expected[_index]}" ] || { + printf 'unexpected PATH entry in %s at %s: %s\n' "$_mode" "$_index" "$output" >&2 + return 1 + } + done + done +} + +@test "startup inserts missing fallback tools after inherited devShell entries in all shell modes" { + local _inherited="/nix/store/devshell-tools/bin:/workspace/project/bin:$TEST_BIN" + local _mode + for _mode in -c -ic -lic; do + run_startup_path "$_inherited:/usr/bin:/bin" "$_mode" + + [ "$status" -eq 0 ] + local _expected=( + /nix/store/devshell-tools/bin + /workspace/project/bin + "$TEST_BIN" + "$TEST_HOME/.local/bin" + "$TEST_HOME/.cargo/bin" + "$TEST_HOME/.gem/bin" + ) + if [ "$_mode" != -c ]; then + _expected+=(/opt/homebrew/bin /Library/TeX/texbin) + fi + _expected+=(/usr/bin /bin) + if [ "$_mode" = -lic ]; then + _expected+=(/Applications/Obsidian.app/Contents/MacOS) + fi + [ "$output" = "$(printf '%s\n' "${_expected[@]}")" ] || { + printf 'unexpected PATH in %s: %s\n' "$_mode" "$output" >&2 + return 1 + } + done +} + +@test "startup inserts before the first system entry or appends when none exists" { + local _system + for _system in /usr/local/bin /usr/bin /bin /usr/sbin /sbin ''; do + local _inherited="$TEST_BIN" + local _expected=( + "$TEST_BIN" + "$TEST_HOME/.local/bin" + "$TEST_HOME/.cargo/bin" + "$TEST_HOME/.gem/bin" + ) + if [ -n "$_system" ]; then + _inherited+=":$_system:/custom/bin:/usr/bin" + _expected+=("$_system" /custom/bin) + if [ "$_system" != /usr/bin ]; then + _expected+=(/usr/bin) + fi + fi + run_startup_path "$_inherited" -c + + [ "$status" -eq 0 ] + [ "$output" = "$(printf '%s\n' "${_expected[@]}")" ] || { + printf 'unexpected PATH for system entry %s: %s\n' "$_system" "$output" >&2 + return 1 + } + done +} + +run_startup_path() { + # Source repository files explicitly to isolate host startup configuration. + run --separate-stderr env -i \ + HOME="$TEST_HOME" \ + USER=startup-test \ + XDG_CACHE_HOME="$TEST_HOME/.cache" \ + FPATH="$BATS_TEST_TMPDIR/functions" \ + COMPINIT_LOG="$COMPINIT_LOG" \ + ABBR_LOG="$ABBR_LOG" \ + REPO_ROOT="$REPO_ROOT" \ + PATH="$1" \ + "$(command -v zsh)" -df "$2" ' + source "$REPO_ROOT/zsh/.zshenv" + source "$REPO_ROOT/zsh/.zshenv" + if [[ -o login ]]; then + source "$REPO_ROOT/zsh/.zprofile" + fi + if [[ -o interactive ]]; then + source "$REPO_ROOT/zsh/.zshrc" + fi + print -rl -- "${path[@]}" + ' +} + @test "Home Manager activation invokes the packaged Zsh cache builder" { run --separate-stderr nix build --no-link --print-out-paths \ "$TEST_FLAKE#homeConfigurations.\"kaito@mbp\".activationPackage" diff --git a/zsh/.zprofile b/zsh/.zprofile index f306f628..8ac53fb9 100644 --- a/zsh/.zprofile +++ b/zsh/.zprofile @@ -1,7 +1,5 @@ -export PATH="$HOME/.local/bin:$PATH" - # Added by OrbStack: command-line tools and integration source ~/.orbstack/shell/init.zsh 2>/dev/null || : # Added by Obsidian -export PATH="$PATH:/Applications/Obsidian.app/Contents/MacOS" +path+=(/Applications/Obsidian.app/Contents/MacOS) diff --git a/zsh/.zshenv b/zsh/.zshenv index 18efee13..3f720c4c 100644 --- a/zsh/.zshenv +++ b/zsh/.zshenv @@ -1,4 +1,22 @@ -export PATH=$HOME/.gem/bin:$HOME/.cargo/bin:$PATH +# Preserve inherited tool precedence and keep each entry only once. +typeset -U path PATH +function _insert_fallback_paths() { + local -a _missing + local _entry _index=1 + for _entry in "$@"; do + (( ${path[(Ie)$_entry]} )) || _missing+=("$_entry") + done + (( ${#_missing} )) || return 0 + for _entry in "${path[@]}"; do + case "$_entry" in + /usr/local/bin|/usr/bin|/bin|/usr/sbin|/sbin) break ;; + esac + (( _index++ )) + done + path=("${(@)path[1,_index-1]}" "${_missing[@]}" "${(@)path[_index,-1]}") +} + +_insert_fallback_paths "$HOME/.local/bin" "$HOME/.cargo/bin" "$HOME/.gem/bin" # Enforced on every zsh start so stale inherited environments # (e.g. a long-running herdr server snapshot) cannot override it. export EDITOR=nvim diff --git a/zsh/.zshrc b/zsh/.zshrc index 370e3bd8..4576ae7b 100644 --- a/zsh/.zshrc +++ b/zsh/.zshrc @@ -10,6 +10,9 @@ if [[ "$OSTYPE" == darwin* && -z "${SSH_AUTH_SOCK:-}" ]]; then } fi +# Global tools remain fallbacks without changing inherited tool precedence. +_insert_fallback_paths /opt/homebrew/bin /Library/TeX/texbin + # Completion ZSH_COMPDUMP="${XDG_CACHE_HOME:-$HOME/.cache}/zsh/.zcompdump" autoload -Uz compinit @@ -75,11 +78,7 @@ function command_not_found_handler() { DOTFILES="$HOME/ghq/github.com/furedea/dotfiles" -export PATH="/usr/bin:$PATH" -export PATH="/opt/homebrew/bin:$PATH" export XDG_CONFIG_HOME="$HOME/.config" -export PATH="/Library/TeX/texbin:$PATH" -export PATH="$HOME/.local/bin:$PATH" # Plugins source "$XDG_CONFIG_HOME/zsh/nix-plugins.zsh"