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.