Finding the executable
bellpull/which: whichSync returns the path and the PATH entry that found it, skips empty and relative PATH entries, honours and reports PATHEXT, and runPath puts node_modules/.bin in front.
"Which node was that?" is the first question of every build that differs between two
machines. bellpull/which answers it: the file, and the PATH entry that found it.
import { relative } from 'node:path';
import { whichAllSync, whichSync } from 'bellpull/which';
import { install, runtimeWith } from './tools.mjs';
install();
const runtime = runtimeWith('bin', 'vendor/bin');
const show = (r) => `${relative(runtime.cwd, r.path)} (from ${relative(runtime.cwd, r.from)})`;
console.log('first:', show(whichSync('greet', { runtime })));
for (const hit of whichAllSync('greet', { runtime })) console.log('every:', show(hit));
console.log('missing:', whichSync('no-such-tool', { runtime }));first: bin/greet (from bin)
every: bin/greet (from bin)
every: vendor/bin/greet (from vendor/bin)
missing: undefined| function | returns |
|---|---|
whichSync(cmd, { runtime }) | the first { path, from, ext }, or undefined |
whichOrThrowSync(cmd, { runtime }) | the same, or throws NotFoundError (code: 'ENOENT') |
whichAllSync(cmd, { runtime }) | every match, best first — "there are two of these" is often the finding |
searchPath({ runtime }) | the directories that would be searched, and the ones that would not, with why |
runPath({ runtime }) | PATH with every node_modules/.bin from the working directory up in front |
from is '' when the command carried its own path (./bin/greet), so a lookup and a named
file are distinguishable. The answer is always absolute, and a file is only an answer if it is
executable: a non-executable file or a directory with the right name is passed over.
Holes in PATH are skipped, and shown
An empty PATH entry (/usr/bin::/opt/bin) means the current directory to a POSIX shell,
and a relative one (tools) means a different directory wherever the program runs — possibly
one somebody else can write to. bellpull searches neither, and searchPath says so:
import { searchPath } from 'bellpull/which';
const runtime = { platform: 'linux', env: { PATH: '/usr/bin::tools:/opt/bin' }, cwd: '/home/me/project' };
for (const entry of searchPath({ runtime })) console.log(JSON.stringify(entry));{"raw":"/usr/bin","dir":"/usr/bin"}
{"raw":"","skipped":"empty"}
{"raw":"tools","skipped":"relative"}
{"raw":"/opt/bin","dir":"/opt/bin"}bellpull/node-which, the drop-in, keeps node-which's behaviour here — it searches them —
because that is what node-which's suite grades.
Windows rules, from any machine
The platform is part of the runtime, so Windows resolution can be run — and tested — on a
Mac. PATHEXT is tried in the order it gives, a command that already has a dot is tried as
written first, and the extension that matched comes back as ext:
import { extensionCandidates, pathExtensions } from 'bellpull/which';
const runtime = { platform: 'win32', env: { Path: 'C:\\tools', PATHEXT: '.COM;.EXE;.BAT;.CMD' }, cwd: 'C:\\work' };
console.log(pathExtensions({ runtime }));
console.log(extensionCandidates('npm', { runtime }));
console.log(extensionCandidates('npm.cmd', { runtime }));[ '.COM', '.EXE', '.BAT', '.CMD' ]
[ '.COM', '.EXE', '.BAT', '.CMD' ]
[ '', '.COM', '.EXE', '.BAT', '.CMD' ]On Windows PATH is read under whichever case the environment spelled it (Path above),
split on ;, quoted entries are unquoted, and the working directory is searched first, as
the platform does. Off Windows no extension is added: a file is executable or it is not.
One caveat on Windows: run('npm') resolves npm.cmd itself and reports it, but hands the
command line to cmd.exe by the name you wrote, as cross-spawn does, so cmd.exe resolves it
again in the child's environment. The two agree as long as the environment you passed is the
one the child gets, which is the default.
node_modules/.bin
runPath({ runtime }) returns PATH with every node_modules/.bin from the working
directory up to the root in front of it — npm-run-path's contract in a dozen lines, with the
walk stopping at the filesystem root (or the drive root on Windows). Pass it as the child's
PATH to run a project's local tools by name.
What is tested
which.test.ts: thePATHentry that answered, and a different one when the order changes; every hit, best first; the executable bit; empty and relative entries skipped and reported; the Windows rules from a Mac;runPathup to the root.
Deadlines and the kill ladder
Every run is bounded: 30 s by default, SIGTERM then SIGKILL after a grace window, and the output written before the kill is kept — plus killing the child when the parent shuts down.
Windows without a shell
How bellpull runs npm.cmd and other batch files on Windows without shell: true: resolve with PATHEXT, invoke cmd.exe itself, and escape every argument for both parsers so caller data cannot become a command.