There’s something that has been driving me crazy about macOS since the switch to ZSH: the system zprofile
runs a utility that rewrites your shell $PATH
.
The net effect of this is that, if you use any version managers like nodenv or rbenv, it becomes almost impossible to correctly configure them.
Background
Shell initialization is complex. Personally, I always forget how it works and have to look it up again. In case I’m not alone in this,
here’s a quick refresher:
- The shell is launched as either interactive or non-interactive. When you run shell scripts, e.g., the shell is in non-interactive mode.
- Similarly, the shell can be launched as login or non-login. This is a complex topic.
There’s a lot of individual configuration files that zsh loads. Here are the relevant ones, in the order they are read:
File | Description |
---|---|
zshenv | Read first and by every type of shell |
zprofile | For login shells |
zshrc | For interactive shells |
The "correct" place to update your $PATH
is typically in the zshenv
file, because it’s read first and regardless of the kind of shell. As mentioned earlier, this won’t work because the system zprofile
launches a utility that will rewrite the order of your paths, putting the system paths at the top.
Normally you won’t have to worry too much about this. But there’s one very common exception: when you’re using a version manager for a tool that comes preinstalled on the system, like a Ruby version manager. In this case, you want your version manager’s path to be at
the front of the paths list, so that it has priority over the system version.
While a tempting solution is to just load your version managers in your zshrc
, there are edge cases that will leak through.
A zprofile Workaround
After running into these edge cases recently, I decided to try and fix it for good. The solution I came up with is to:
- In my
zshenv
, take note of the current$PATH
- Load my version managers in my
zshenv
as intended - After loading the version managers, take note of the new paths that have been added by the version managers
- Later, in my own
zprofile
, put the paths I’ve taken note of back at the front of the list.
Here’s how my zshenv looks:
path_before_user_env=($path[@])
eval "$(rbenv init -)"
eval "$(direnv hook zsh)"
eval "$(nodenv init -)"
if [[ -z $KNOWN_USER_ENV_PATH ]]; then
local path_after_user_env=($path[@])
local user_env_paths=()
for dir in $path_after_user_env; do
if [[ $path_before_user_env[(Ie)$dir] -gt 0 ]] continue
user_env_paths+=($dir)
done
export KNOWN_USER_ENV_PATH=${(j.:.)user_env_paths}
unset path_after_user_env
unset user_env_paths
fi
You’ll notice that I do nothing if the environment variable $KNOWN_USER_ENV_PATH
is defined. Doing so avoids problems when
launching sub-shells.
Here’s my zshrc
:
disturbed_path=($path[@])
PATH=$KNOWN_USER_ENV_PATH
for dir in $disturbed_path; do
if [[ $path[(Ie)$dir] -eq 0 ]]; then
path+=($dir)
fi
done
export path
unset disturbed_path
unset dir
Essentially, this snippet will take the paths in $KNOWN_USER_ENV_PATH
and move them to the very front of the list. This will happen each time an interactive shell is launched but is quick and nondestructive.
Working Around the System zprofile on macOS
If you’ve also run into this zprofile issue on macOS, I hope this helps. It’s been a subtle but nice improvement to my system, especially for my editors that try to do smart things with my shell environment.