It works from the terminal

The app ran perfectly when I launched it from a shell and failed with ENOENT when a user double-clicked it. A GUI app does not have your PATH, and the only thing that knows your PATH is your shell.

VEX starts your dev server for you. You point it at a project, it works out that there is an npm run dev in there, and it spawns it so the browser has something to edit.

It worked for months. Then I packaged the app, double-clicked it in Finder like a person would, and:

Error: spawn npm ENOENT

npm is on my machine. npm is on my PATH. which npm answers instantly. And the process I had just launched could not find it.

Why the app you launched is not the app you ran

A program started from a terminal inherits that terminal’s environment, and your terminal ran ~/.zshrc — which is where nvm, fnm, n, asdf and volta put their shims. That is the only reason npm resolves for you at all.

An app launched from Finder or the Dock inherits none of it. launchd hands it a minimal PATH, and no shell rc file has ever been read in that process’s ancestry. If your toolchain is installed by any version manager — which for Node is nearly everyone — it is simply not there.

The cruel part is which developers this hits. If you installed Node once from the .pkg into /usr/local/bin, everything works. If you manage versions properly, it breaks. The bug selects for the more careful user.

The fix I shipped first, and why it was not enough

The original code had a hardcoded list:

const extra = [
  path.join(home, ".local", "bin"),
  path.join(home, ".npm-global", "bin"),
  "/opt/homebrew/bin",
  "/usr/local/bin",
  "/usr/bin",
  "/bin",
];

Prepend those to the child’s PATH and ship it. This works for Homebrew users. It works for npm -g users. It does not work for nvm, whose binaries live under a version-numbered directory you cannot guess, and it silently picks the wrong Node for anyone whose version manager exists specifically to decide which Node is correct in this directory.

A static list is a guess about someone else’s machine. It fails in the worst possible way: not by erroring, but by finding a plausible binary that is not the one the user meant.

Ask the shell

The thing that knows the user’s real PATH is the user’s shell, because computing it is the shell’s job. So ask it:

const out = execFileSync(
  shell,
  ["-ilc", `printf '%s' "${DELIM}$PATH${DELIM}"`],
  { encoding: "utf8", timeout: SHELL_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }
);

-i and -l are the whole trick: an interactive login shell sources the rc files where version managers export themselves. What comes back is exactly what a terminal would have — including the nvm shim for the Node version the user actually chose.

Three details in that call are the difference between a fix and a new bug:

The delimiter. printf '%s' "__VEX_PATH_DELIM__$PATH__VEX_PATH_DELIM__" — an interactive shell is entitled to print things. Banners, direnv chatter, version-manager warnings, that motivational quote somebody installed in 2019. Reading stdout naively means one day a user’s fortune cookie becomes part of their PATH. Sentinels mean I take what is between them and ignore the rest.

The timeout. Five seconds, and failure is not fatal. A shell that hangs on startup — waiting on a network mount, a slow completion init — must not hang app launch. On timeout we fall back to the static list. Degraded is a state; frozen is not.

The cache. Once per app lifetime. Spawning an interactive shell is not free and the answer does not change while the app is open.

Then the merge order, which is the actual policy:

segments.push(...loginPath.split(path.delimiter));   // what the user's terminal has
segments.push(...commonBinDirs());                   // backfill for odd setups
segments.push(...(process.env.PATH ?? "").split(path.delimiter));

Login shell first, so the user’s chosen toolchain wins. Common dirs second, as a safety net for the shell-less case. Process PATH last so nothing is lost. Deduped and order-preserving, because in PATH, order is the decision.

Then stop resolving by name

Having found the real PATH, there is one more move: do not spawn npm, spawn the absolute path to it.

const exe = resolveExecutable(pkgManager) ?? pkgManager;
return { exe, args: ["run", scriptKey] };

resolveExecutable walks the enhanced PATH, checks each candidate is a file and is executable, and returns the full path. Handing spawn an absolute path removes every remaining question about how the OS resolves a bare command name for a child process with an inherited-but-modified environment. The ?? pkgManager keeps the old behaviour when nothing is found, so the failure mode is the one we already understood.

Both call sites — the bundled Python backend and the user’s dev server — go through the same helper. This bug had already been fixed once, locally, inside ProcessManager, with that hardcoded list. Extracting it was how the dev server inherited a fix that had existed for weeks in the file next door.

Lesson: when the same environment problem is solved privately in two places, the second occurrence is not a bug to fix — it is the module you failed to write the first time.

The wider point

“Works on my machine” is a joke about dependencies. This is the sharper version: it worked on my machine, in the same account, in the same second, with the same binaries — and differed only by how the process was started.

Every desktop app that shells out to a developer toolchain has this bug until someone hits it. It cannot be caught by tests, because your test runner is started from a shell. It cannot be caught in development, because npm run dev is started from a shell. It appears at exactly one moment: the first time a real user double-clicks the icon.

Which is to say — the first time it matters.