Reliably determining the user’s $PATH environment variable

Introduction

Reliably determining the current user's $PATH environment variable doesn't sound too difficult. Depending on your understanding of shell execution modes, your grasp of the unix process model, and your familiarity with the interaction between shells, processes and terminal emulators works, it might not be. I thought I had a reasonable (if rusty) handle on all three, but I came up short recently, so wanted to share what I learned.

Context

Stackman is a desktop application which acts as a process manager for your Node.js applications. It starts and stops them, restarts them if they crash, funnels their log output to a central time-ordered view, and keeps them running on predictable ports between application and system restarts. I built it to solve my own disorganisation: often drowning in too many terminal tabs when running multiple Next.js stacks at once due to the various SaaS products I've been building. At a basic level you can think of it as a smart wrapper around npm run dev, and as such it needs the correct $PATH to find node, npm, or whichever package manager each project uses.

The problem

The very first version of stackman I shared with a friend (the excellent @MrRio) didn't work. He could add his projects to stackman, but he couldn't actually run them. You know, the whole raison d'être of the application. Every time he tried to start a process it would instantly crash, pointing at a problem retrieving the correct $PATH environment variable needed to resolve the location of node and npm. If stackman was a command-line application the user invoked from a shell we'd already have a $PATH to inherit, but it's not - it's a desktop application which inherits the default system $PATH which is likely to be useless.

I'd had to tackle this to get the application running on my own machine, and hoped my rather crude solution would do the business for others, too. Here's roughly what it looked like:

import os from 'node:os'
import { execSync } from 'node:child_process'

export const getPath = () => {
  // userInfo() returns, among other things, the user's default shell
  const { shell } = os.userInfo()
  // I don't recommend execSync in production, but it keeps the example shorter
  return execSync("bash -c 'echo $PATH'", { shell }).toString().trim()
}

This function was called elsewhere like so:

const process = spawn('npm', ['run', 'dev'], {
  env: {
    ...process.env,
    // merge the hopefully correct path into the env vars passed to the child process
    PATH: getPath(),
  },
})

Okay, look; I know it looks a bit stupid going to the effort of fetching the user's default shell only to use it to launch bash, but I had my reasons: I use fish shell which uses space as a path delimiter, which is awkward to deal with when individual path entries themselves contain spaces. Bash's colon separator is more predictable and standard across other shells, and I knew bash would be available on any supported system, so my logic was as follows:

  • The user's default shell almost certainly has the correct $PATH configured, since it's the shell their terminal sessions launch with
  • Regardless of what that shell is, we can use it to launch bash, which will inherit the correct $PATH from the default shell and provide a normalised $PATH to use

The logic seemed sound and worked fine during my testing: fish launched bash, bash inherited its $PATH from fish, and bash gave me a colon-delimited string in return.

As an aside, it's worth understanding that exec and execSync always use a shell to execute commands, even if you don't explicitly pass one: on unix systems, they'll default to /bin/sh. We're specifying the shell to launch with here, but it's good to know that if you don't, you'll still get one, and to know which one it is. Other child process functions like spawn won't use a shell by default (but they can if you want them to), executing the command directly instead using system calls. These little details are important but very easy to lose track of.

Anyway, you may already be able to see the problem with this approach. I couldn't. And I got a bit stuck.

ChatGPT to the rescue

I've been a jack-of-all-trades developer for nearly 20 years, meaning I know enough about enough to be effective but not always entirely accurate, sometimes lacking the exact terminology to succinctly describe what I'm trying to do. Being succinct and accurate is exactly what you need to successfully Google a problem, but thankfully being vague and verbose is something ChatGPT handles incredibly well. As someone who's spent the majority of the year working alone, I've found it invaluable in situations like this where I'd otherwise progress painfully slowly. It actually gets better with a bit of back-and-forth; continuity which simply doesn't exist with a search engine. Working a problem with ChatGPT is like a turbo-charged pair programming session with the most enthusiastic developer you've ever met, and while you need to fact check the answers you get, it's a great way to sharpen your understanding of a problem and point you in the right direction to solve it.

A lot of conversation followed this opening exchange, but ChatGPT undoubtedly saved me a lot of time and frustration.

A solution

I'm certain the solution below is far from perfect, but so far, it works. As is often the case with these things, there's not actually a lot to it:

import { exec } from 'node:child_process'
import os from 'node:os'
import path from 'path'
import util from 'node:util'

const execAsync = util.promisify(exec)

const getPathArgs = (shell: string) => {
  switch (path.basename(shell)) {
    case 'fish':
      // we still have some shell-ception going on to normalise the path. Sorry ¯\_(ツ)_/¯
      return `-i -c 'bash -c "echo $PATH"'`
    case 'zsh':
      return `-il -c 'echo $PATH'`
    case 'bash':
      return `--login -i -c 'echo $PATH'`
  }
  throw new Error(`Unsupported shell ${shell}`)
}

export const getPath = async (): Promise<string> => {
  const { shell } = os.userInfo()
  const args = getPathArgs(shell)
  const cmd = `${shell} ${args}`

  // TODO: handle errors
  const { stdout } = await execAsync(cmd)
  return stdout.trim()
}

Interactive, non-interactive and login shells

The code above launches different combinations of execution modes for each shell. Interactive and login shells are different things but can be combined together: indeed, the zsh and bash invocations launch an interactive login shell. The fish invocation launches an interactive shell. Let's examine some of the differences.

Non-interactive shells

We're not actually launching any non-interactive shells anymore: all three are launched with at least an -i flag. Non-interactive shells typically don't read any userland configuration files, which is where homebrew, nvm and many others typically wrangle the user's $PATH in weird and wonderful ways. The earlier code launching a non-interactive shell worked mostly by luck, because fish does source some common configuration files in non-interactive mode which in my case was enough.

Interactive shells

All three invocations launch an interactive (-i) shell. This sources extra configuration files in which users often manipulate their $PATH environment variable which will probably look pretty familiar to you: ~/.bashrc, ~/.zshrc and the like. This was take one of the fix, but after asking James - who uses zsh, the default shell since macOS Catalina - to test out a new build of the application spawning interactive shells, things still didn't work. Until...

Login shells

The bash and zsh invocations also launch a login shell (--login and -l). Login shells source even more commonly-used configuration files like ~/.bash_profile and ~/.zprofile, and finally with these tweaks in place stackman worked for James. Phew.

Why not always launch an interactive login shell?

Login and interactive shells are slower to launch than non-interactive shells because they source more configuration, and they can also have side effects like launching extra processes. Side effects - especially unexpected ones - can introduce unpredictability into a system, so you should always start with a non-interactive shell by default. The clue is also in the name: interactive shells are designed to be interactive, but I'm using them to run a command and then exit immediately.

For stackman, launching interactive login shells where required appears to be a necessary trade-off. We want to mimic the environment a user would have if they opened their terminal, navigated to a project, and ran npm run dev themselves. If there's a better way to mimic this than the hoops I'm jumping through above, I'd love to hear about it.

Why not just use process.env.PATH?

I covered this earlier but it's worth reiterating: when a user runs an application from their desktop, on macOS at least, the system $PATH variable is likely to be useless. In my case, it's /usr/bin:/bin:/usr/sbin:/sbin, none of which contain node and friends.

Summary

In hindsight the nature of the problem and the associated solution seem obvious, but at the time I'd been away from the nitty-gritty details of shells and their execution modes for a while, which coupled with my experience building desktop applications being close to zero was enough to trip me up. Apart from the useful refresher on $PATH resolution, the other big takeaway was another reminder to always use the tools at your disposal effectively: when searching a problem comes up short, don't be afraid to ask for help. Thanks, ChatGPT!

If you enjoyed this article, please consider sharing it on X, Bluesky, or your platform of choice - it helps a lot. I’m all ears for any feedback, corrections or errors, so please if you have any. Thanks!