# 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.

Source: https://bellpull.interlace.tools/docs/why-bellpull

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 (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades.

### Spawning, on every platform

| Capability | **bellpull** | execa | cross-spawn | which |
| :-- | :-- | :-- | :-- | :-- |
| **`.cmd` and `.bat` run on Windows without a shell** — An 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 }`. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/cross-spawn.test.ts) | [✓](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/arguments/command-file.js) | [✓](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/lib/parse.js) | [— resolves a command; it spawns nothing](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **Arguments escaped for `cmd.exe` and the argv parser** — Every 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/escape.test.ts) | [✓ and it refuses CR and LF, which cannot be escaped for `cmd.exe`; bellpull records that as an unverified gap](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/arguments/command-file.js) | [✓](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/lib/parse.js) | [— resolves a command; it spawns nothing](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **No shell unless asked for** — Arguments 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/escape.test.ts) | [✓](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/arguments/command-file.js) | [✓](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/lib/parse.js) | [— resolves a command; it spawns nothing](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |

### Which binary ran

| Capability | **bellpull** | execa | cross-spawn | which |
| :-- | :-- | :-- | :-- | :-- |
| **The path, and the `PATH` entry that answered** — `whichSync` 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/which.test.ts) | [✗ the result carries the command as written (`command`, `escapedCommand`), not the file that ran](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/return/result.js) | [✗ resolves the file only on Windows, internally, and returns a ChildProcess that does not say which](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/lib/parse.js) | [◐ returns the path as a string; which `PATH` entry matched is not reported](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **`PATHEXT` honoured, and the extension reported** — On 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/which.test.ts) | [◐ honoured when resolving on Windows; the extension that matched is not reported](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/arguments/command-file.js) | [◐ honoured through `which` 2 on Windows; the extension that matched is not reported](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/lib/util/resolveCommand.js) | [◐ honoured; the extension that matched is not reported apart from the path](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **`PATH` entries that follow the working directory are skipped** — An 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/which.test.ts) | [✗ off Windows the operating system searches `PATH` as given, empty entries included](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/arguments/command-file.js) | [✗ off Windows it resolves nothing; on Windows it asks `which` 2, which searches every entry](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/lib/parse.js) | [✗ joins an empty entry with the command, which resolves against the working directory, and searches relative entries as given](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **Every `node_modules/.bin` from the working directory up** — `runPath()` puts every `node_modules/.bin` from the working directory to the root in front of `PATH`, as `npm-run-path` does, with no dependency. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/which.test.ts) | [✓ with `preferLocal: true`, through its npm-run-path dependency](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/arguments/options.js) | [✗](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/index.js) | [✗ searches `PATH` as given](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **Resolver plugins, as data** — A 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/plugin.test.ts) | [✗](https://cdn.jsdelivr.net/npm/execa@10.0.1/index.js) | [✗](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/index.js) | [✗ the search is `PATH`, with no extension point](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |

### A run that ends, and says how

| Capability | **bellpull** | execa | cross-spawn | which |
| :-- | :-- | :-- | :-- | :-- |
| **A non-zero exit is a value, not an exception** — `run()` 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/matrix.test.ts) | [◐ rejects on a non-zero exit unless the caller passes `reject: false`](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/arguments/options.js) | [✗ returns a ChildProcess: collecting the output and waiting for `close` is the caller's](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/index.js) | [— runs nothing](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **A finite timeout by default, and the output before the kill kept** — Every 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/matrix.test.ts) | [◐ no timeout unless one is passed; with one, it escalates to SIGKILL and keeps the output on the error](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/terminate/timeout.js) | [✗ no deadline of its own; Node's `timeout` option passes through, with one signal and no escalation](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/index.js) | [— runs nothing](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **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. | [◐ only when the caller passes an exit host, such as a closeout registry; by default bellpull claims none of the process's signals](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/matrix.test.ts) | [✓ by default, through signal-exit](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/terminate/cleanup.js) | [✗](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/index.js) | [— runs nothing](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |
| **One result, three renderings** — `format()` 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. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/matrix.test.ts) | [✗ one result object; a JSON or agent projection is the caller's](https://cdn.jsdelivr.net/npm/execa@10.0.1/lib/return/result.js) | [✗ returns a ChildProcess; there is no result to render](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/index.js) | [— returns a path](https://github.com/npm/node-which/blob/v7.0.0/lib/index.js) |

### Weight

| Capability | **bellpull** | execa | cross-spawn | which |
| :-- | :-- | :-- | :-- | :-- |
| **Zero runtime dependencies** — bellpull installs one package, where execa installs its twelve direct dependencies and their trees. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/weight.test.ts) | [✗ twelve direct dependencies](https://cdn.jsdelivr.net/npm/execa@10.0.1/package.json) | [✗ path-key, shebang-command and which](https://cdn.jsdelivr.net/npm/cross-spawn@7.0.6/package.json) | [✗ isexe](https://github.com/npm/node-which/blob/v7.0.0/package.json) |

### Compatibility

| Capability | **bellpull** | execa | cross-spawn | which |
| :-- | :-- | :-- | :-- | :-- |
| **Passes cross-spawn's own test suite** — `bellpull/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. | [✓ 68 / 68 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/cross-spawn.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/execa/test/arguments/shell.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cross-spawn/test/util/run.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/which/test/index.js) |
| **Passes which's own test suite** — `bellpull/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()`. | [✓ 5 / 5 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/which.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/execa/test/arguments/shell.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cross-spawn/test/util/run.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/which/test/index.js) |
| **execa's own test suite: not a drop-in** — execa 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. | [✗ 0 / 1,048 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/execa.json) | [✓ its own suite, the control run: 1,048 of 1,048](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/execa/test/arguments/shell.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cross-spawn/test/util/run.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/which/test/index.js) |

## Reading it

- **"ours" is `run()` and `bellpull/which`.** The drop-ins, `bellpull/cross-spawn` and
  `bellpull/node-which`, keep their incumbents' behaviour, which is what their grades measure:
  `bellpull/node-which` still searches an empty `PATH` entry, 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 `.cmd` files without a shell
  and escape for `cmd.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 `bellpull` **0 of 1,048**: every file
  imports `execa`, which bellpull does not export. The row is declared a *ceiling* in
  compat-oracle, so neither `burgee migrate` nor 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:
  `run` bundles to 5,901 B against tinyexec's `x` at 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](https://burgee.interlace.tools/docs/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](/docs/guides/windows) guide says exactly what is and is not covered instead.
