Why bellpull
bellpull against execa, cross-spawn and which, one capability per row — including the rows where execa is ahead — every cell linked to the test, grade or source that proves it.
cross-spawn makes spawn work on Windows, which finds a binary on PATH, and execa wraps
both in a promise with a result. bellpull does the first two through drop-in paths graded by
each one's own suite, and builds run() on them with what none of the three has: the PATH
entry the binary came from, a deadline on every run by default with the output before the
kill kept, one result rendered for a person, --json and an agent, and resolver plugins as
data. It has no dependencies.
It is not an execa replacement, and the table says where execa is ahead.
Every mark links to its evidence: a test in this repository for ours, and for theirs the
source file of the exact version compat-oracle grades, or that package's own test suite.
scripts/capabilities-lock.test.ts fails the build when a cited test no longer contains the
title it is cited for, or a local source no longer contains the line it is quoted for or has
gained what we say it lacks. which 7.0.0 is not installed in this repository — cross-spawn's
which 2 is — so its cells link the v7.0.0 tag on GitHub, and those are the cells the lock can
only check are pinned.
✓ yes · ◐ partial, with what is missing · ✗ no · — does not apply. Every mark links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades.
| Capability | bellpull | execa | cross-spawn | which |
|---|---|---|---|---|
| Spawning, on every platform | ||||
.cmd and .bat run on Windows without a shellAn npm bin on Windows is a .cmd script, and bellpull resolves it with PATHEXT and invokes cmd.exe itself, so npm runs everywhere without { shell: true }. | bellpull: yes | execa: yes | cross-spawn: yes | which: does not applyresolves a command; it spawns nothing |
Arguments escaped for cmd.exe and the argv parserEvery argument is quoted for CommandLineToArgvW and every cmd.exe metacharacter escaped, so a & in caller data stays one argument instead of starting a second command. | bellpull: yes | execa: yesand it refuses CR and LF, which cannot be escaped for cmd.exe; bellpull records that as an unverified gap | cross-spawn: yes | which: does not applyresolves a command; it spawns nothing |
No shell unless asked forArguments go to the child as an argv array, so nothing in them is interpreted; shell: true exists, off by default, and is documented as the injection surface it is. | bellpull: yes | execa: yes | cross-spawn: yes | which: does not applyresolves a command; it spawns nothing |
| Which binary ran | ||||
The path, and the PATH entry that answeredwhichSync and result.executable return where the binary is and which PATH directory found it, which is the first question when a build differs between two machines. | bellpull: yes | execa: nothe result carries the command as written (command, escapedCommand), not the file that ran | cross-spawn: noresolves the file only on Windows, internally, and returns a ChildProcess that does not say which | which: partialpartialreturns the path as a string; which PATH entry matched is not reported |
PATHEXT honoured, and the extension reportedOn Windows the extensions are tried in the order PATHEXT gives, and the one that matched comes back as ext, so npm and npm.cmd are told apart. | bellpull: yes | execa: partialpartialhonoured when resolving on Windows; the extension that matched is not reported | cross-spawn: partialpartialhonoured through which 2 on Windows; the extension that matched is not reported | which: partialpartialhonoured; the extension that matched is not reported apart from the path |
PATH entries that follow the working directory are skippedAn empty or relative PATH entry means a different directory wherever the program runs, one somebody else may be able to write to, so the resolver skips it and says why. | bellpull: yes | execa: nooff Windows the operating system searches PATH as given, empty entries included | cross-spawn: nooff Windows it resolves nothing; on Windows it asks which 2, which searches every entry | which: nojoins an empty entry with the command, which resolves against the working directory, and searches relative entries as given |
Every node_modules/.bin from the working directory uprunPath() puts every node_modules/.bin from the working directory to the root in front of PATH, as npm-run-path does, with no dependency. | bellpull: yes | execa: yeswith preferLocal: true, through its npm-run-path dependency | cross-spawn: no | which: nosearches PATH as given |
Resolver plugins, as dataA plugin contributes a search order (an asdf or nvm shims directory, before or after PATH) as plain JSON with no function in it, so an agent can write one and npx bellpull check can validate it. | bellpull: yes | execa: no | cross-spawn: no | which: nothe search is PATH, with no extension point |
| A run that ends, and says how | ||||
A non-zero exit is a value, not an exceptionrun() resolves with ok: false and the code, and rejects only when no process ran at all, so a caller reads the outcome instead of catching it. | bellpull: yes | execa: partialpartialrejects on a non-zero exit unless the caller passes reject: false | cross-spawn: noreturns a ChildProcess: collecting the output and waiting for close is the caller's | which: does not applyruns nothing |
A finite timeout by default, and the output before the kill keptEvery run has a deadline (30 s unless set), the kill is SIGTERM then SIGKILL after a grace window, and stdout and stderr hold what arrived before it, so a CI timeout can be diagnosed. | bellpull: yes | execa: partialpartialno timeout unless one is passed; with one, it escalates to SIGKILL and keeps the output on the error | cross-spawn: nono deadline of its own; Node's timeout option passes through, with one signal and no escalation | which: does not applyruns nothing |
| The child is killed when the parent shuts downA child still running when the parent exits would otherwise be orphaned, still holding its ports and files. | bellpull: partialpartialonly when the caller passes an exit host, such as a closeout registry; by default bellpull claims none of the process's signals | execa: yesby default, through signal-exit | cross-spawn: no | which: does not applyruns nothing |
One result, three renderingsformat() for a person, toJson() for --json and toEvent() for an agent are all derived from one Result and one verdict, so the three cannot disagree. | bellpull: yes | execa: noone result object; a JSON or agent projection is the caller's | cross-spawn: noreturns a ChildProcess; there is no result to render | which: does not applyreturns a path |
| Weight | ||||
| Zero runtime dependenciesbellpull installs one package, where execa installs its twelve direct dependencies and their trees. | bellpull: yes | execa: notwelve direct dependencies | cross-spawn: nopath-key, shebang-command and which | which: noisexe |
| Compatibility | ||||
Passes cross-spawn's own test suitebellpull/cross-spawn is graded by cross-spawn 7.0.6's own tests, each case run four ways, so changing the import keeps cross-spawn's behaviour. | bellpull: yes68 / 68 of its own tests | execa: does not applya different API | cross-spawn: yesits own suite, the control run | which: does not applya different API |
Passes which's own test suitebellpull/node-which is graded by node-which 7.0.0's own tests, each run under posix and win32 and through both which() and which.sync(). | bellpull: yes5 / 5 of its own tests | execa: does not applya different API | cross-spawn: does not applya different API | which: yesits own suite, the control run |
execa's own test suite: not a drop-inexeca 10.0.1's suite is run against bellpull so the answer is measured: every file fails to import execa, which bellpull does not export, and the row is a declared ceiling rather than a migration path. | bellpull: no0 / 1,048 of its own tests | execa: yesits own suite, the control run: 1,048 of 1,048 | cross-spawn: does not applya different API | which: does not applya different API |
Spawning, on every platform
.cmdand.batrun on Windows without a shellAn npm bin on Windows is a
.cmdscript, and bellpull resolves it withPATHEXTand invokescmd.exeitself, sonpmruns everywhere without{ shell: true }.bellpull- bellpull: yes
execa- execa: yes
cross-spawn- cross-spawn: yes
Arguments escaped for
cmd.exeand the argv parserEvery argument is quoted for
CommandLineToArgvWand everycmd.exemetacharacter escaped, so a&in caller data stays one argument instead of starting a second command.bellpull- bellpull: yes
cross-spawn- cross-spawn: yes
No shell unless asked for
Arguments go to the child as an argv array, so nothing in them is interpreted;
shell: trueexists, off by default, and is documented as the injection surface it is.bellpull- bellpull: yes
execa- execa: yes
cross-spawn- cross-spawn: yes
Which binary ran
The path, and the
PATHentry that answeredwhichSyncandresult.executablereturn where the binary is and whichPATHdirectory found it, which is the first question when a build differs between two machines.PATHEXThonoured, and the extension reportedOn Windows the extensions are tried in the order
PATHEXTgives, and the one that matched comes back asext, sonpmandnpm.cmdare told apart.PATHentries that follow the working directory are skippedAn empty or relative
PATHentry means a different directory wherever the program runs, one somebody else may be able to write to, so the resolver skips it and says why.bellpull- bellpull: yes
Every
node_modules/.binfrom the working directory uprunPath()puts everynode_modules/.binfrom the working directory to the root in front ofPATH, asnpm-run-pathdoes, with no dependency.bellpull- bellpull: yes
cross-spawn- cross-spawn: no
Resolver plugins, as data
A plugin contributes a search order (an
asdfornvmshims directory, before or afterPATH) as plain JSON with no function in it, so an agent can write one andnpx bellpull checkcan validate it.bellpull- bellpull: yes
execa- execa: no
cross-spawn- cross-spawn: no
A run that ends, and says how
A non-zero exit is a value, not an exception
run()resolves withok: falseand the code, and rejects only when no process ran at all, so a caller reads the outcome instead of catching it.A finite timeout by default, and the output before the kill kept
Every run has a deadline (30 s unless set), the kill is
SIGTERMthenSIGKILLafter a grace window, andstdoutandstderrhold what arrived before it, so a CI timeout can be diagnosed.bellpull- bellpull: yes
The child is killed when the parent shuts down
A child still running when the parent exits would otherwise be orphaned, still holding its ports and files.
cross-spawn- cross-spawn: no
One result, three renderings
format()for a person,toJson()for--jsonandtoEvent()for an agent are all derived from oneResultand one verdict, so the three cannot disagree.bellpull- bellpull: yes
Weight
Zero runtime dependencies
bellpull installs one package, where execa installs its twelve direct dependencies and their trees.
bellpull- bellpull: yes
which- which: noisexe
Compatibility
Passes cross-spawn's own test suite
bellpull/cross-spawnis graded by cross-spawn 7.0.6's own tests, each case run four ways, so changing the import keeps cross-spawn's behaviour.Passes which's own test suite
bellpull/node-whichis graded by node-which 7.0.0's own tests, each run underposixandwin32and through bothwhich()andwhich.sync().cross-spawn- cross-spawn: does not applya different API
execa's own test suite: not a drop-in
execa 10.0.1's suite is run against
bellpullso the answer is measured: every file fails to importexeca, which bellpull does not export, and the row is a declared ceiling rather than a migration path.cross-spawn- cross-spawn: does not applya different API
Reading it
- "ours" is
run()andbellpull/which. The drop-ins,bellpull/cross-spawnandbellpull/node-which, keep their incumbents' behaviour, which is what their grades measure:bellpull/node-whichstill searches an emptyPATHentry, because node-which does. - Where execa is ahead, it says so. execa kills its children when the parent exits by default; bellpull does only when handed an exit host such as closeout's registry. execa refuses CR and LF in a Windows argument; bellpull records that as an unverified gap.
- Parity rows are here too. execa and cross-spawn both run
.cmdfiles without a shell and escape forcmd.exe; so does bellpull.
What is not built
bellpull's spec states two gaps, and this page does too:
- No execa API. execa 10.0.1's own suite grades
bellpull0 of 1,048: every file importsexeca, which bellpull does not export. The row is declared a ceiling in compat-oracle, so neitherburgee migratenor any recipe names bellpull as execa's replacement.run()resolving on a non-zero exit is the product, and an execa façade would have to throw. The streaming API and the$`cmd`template are out of scope. - The spawn-cost half of the performance ceiling (R8). The bytes half is measured and met:
runbundles to 5,901 B against tinyexec'sxat 5,969 B. The time to spawn a child against tinyexec's has no benchmark yet, so no speed claim is made anywhere on this site.
What is not in the table
- Weight in bytes. Zero dependencies is a row; the installed and bundled sizes are on Benchmarks, measured the same way on both sides, because they are figures rather than a yes or no.
- Windows on a real runner. The escaping is proven by simulating both parsers from any platform, plus a Windows-only suite that drives a real cmd-shim; cross-spawn's own suite never reaches that branch. A row for it would rest on the simulation, and the Windows guide says exactly what is and is not covered instead.
Resolver plugins
bellpull/plugin: a plugin contributes a search order — asdf or nvm shims, a devcontainer's tools — as plain data with no function, validated at register() and checked by npx bellpull check.
Compatibility
How bellpull's drop-ins are graded — cross-spawn 68 / 68 and node-which 5 / 5 by their own suites — and why execa's suite grades bellpull 0 / 1048 and is declared a ceiling, not a drop-in.