Working Around the System zprofile on macOS

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.