# bellpull/cross-spawn > Every export of bellpull/cross-spawn, with its signature and doc comment: _enoent, crossSpawn, parse, spawn, sync, ChildProcess and 1 more, plus 3 types. Source: https://bellpull.interlace.tools/docs/api/cross-spawn `bellpull/cross-spawn` — the drop-in path for `cross-spawn` (design R7, Y3). ```ts import crossSpawn from 'bellpull/cross-spawn'; import { _enoent, crossSpawn, parse, … } from 'bellpull/cross-spawn'; ``` ## Functions ### parse `cross-spawn._parse`: exported because its suite and its ecosystem both reach for it. ```ts function parse(command: string, args?: ArgsOrOptions, options?: SpawnOptions | null): Parsed; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `args` (optional) | `ArgsOrOptions` | | `options` (optional) | `SpawnOptions \| null` | **Returns** `Parsed` ### spawn `cross-spawn(command, args?, options?)` — a `ChildProcess`, spawned the way the platform needs. ```ts function spawn(command: string, args?: ArgsOrOptions, options?: SpawnOptions | null): ChildProcess; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `args` (optional) | `ArgsOrOptions` | | `options` (optional) | `SpawnOptions \| null` | **Returns** `ChildProcess` ### sync `cross-spawn.sync(command, args?, options?)`. ```ts function sync(command: string, args?: ArgsOrOptions, options?: SpawnOptions | null): SpawnSyncReturns; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `args` (optional) | `ArgsOrOptions` | | `options` (optional) | `SpawnOptions \| null` | **Returns** `SpawnSyncReturns` ## Classes ### ChildProcess Instances of the `ChildProcess` represent spawned child processes. Instances of `ChildProcess` are not intended to be created directly. Rather, use the {@link spawn}, {@link exec},{@link execFile}, or {@link fork} methods to create instances of `ChildProcess`. ```ts class ChildProcess implements EventEmitter { /** * A `Writable Stream` that represents the child process's `stdin`. * * If a child process waits to read all of its input, the child will not continue * until this stream has been closed via `end()`. * * If the child was spawned with `stdio[0]` set to anything other than `'pipe'`, * then this will be `null`. * * `subprocess.stdin` is an alias for `subprocess.stdio[0]`. Both properties will * refer to the same value. * * The `subprocess.stdin` property can be `null` or `undefined` if the child process could not be successfully spawned. * @since v0.1.90 */ stdin: Writable | null; /** * A `Readable Stream` that represents the child process's `stdout`. * * If the child was spawned with `stdio[1]` set to anything other than `'pipe'`, * then this will be `null`. * * `subprocess.stdout` is an alias for `subprocess.stdio[1]`. Both properties will * refer to the same value. * * ```js * import { spawn } from 'node:child_process'; * * const subprocess = spawn('ls'); * * subprocess.stdout.on('data', (data) => { * console.log(`Received chunk ${data}`); * }); * ``` * * The `subprocess.stdout` property can be `null` or `undefined` if the child process could not be successfully spawned. * @since v0.1.90 */ stdout: Readable | null; /** * A `Readable Stream` that represents the child process's `stderr`. * * If the child was spawned with `stdio[2]` set to anything other than `'pipe'`, * then this will be `null`. * * `subprocess.stderr` is an alias for `subprocess.stdio[2]`. Both properties will * refer to the same value. * * The `subprocess.stderr` property can be `null` or `undefined` if the child process could not be successfully spawned. * @since v0.1.90 */ stderr: Readable | null; /** * The `subprocess.channel` property is a reference to the child's IPC channel. If * no IPC channel exists, this property is `undefined`. * @since v7.1.0 */ readonly channel?: Control | null; /** * A sparse array of pipes to the child process, corresponding with positions in * the `stdio` option passed to {@link spawn} that have been set * to the value `'pipe'`. `subprocess.stdio[0]`, `subprocess.stdio[1]`, and `subprocess.stdio[2]` are also available as `subprocess.stdin`, `subprocess.stdout`, and `subprocess.stderr`, * respectively. * * In the following example, only the child's fd `1` (stdout) is configured as a * pipe, so only the parent's `subprocess.stdio[1]` is a stream, all other values * in the array are `null`. * * ```js * import assert from 'node:assert'; * import fs from 'node:fs'; * import child_process from 'node:child_process'; * * const subprocess = child_process.spawn('ls', { * stdio: [ * 0, // Use parent's stdin for child. * 'pipe', // Pipe child's stdout to parent. * fs.openSync('err.out', 'w'), // Direct child's stderr to a file. * ], * }); * * assert.strictEqual(subprocess.stdio[0], null); * assert.strictEqual(subprocess.stdio[0], subprocess.stdin); * * assert(subprocess.stdout); * assert.strictEqual(subprocess.stdio[1], subprocess.stdout); * * assert.strictEqual(subprocess.stdio[2], null); * assert.strictEqual(subprocess.stdio[2], subprocess.stderr); * ``` * * The `subprocess.stdio` property can be `undefined` if the child process could * not be successfully spawned. * @since v0.7.10 */ readonly stdio: [ Writable | null, // stdin Readable | null, // stdout Readable | null, // stderr Readable | Writable | null | undefined, // extra Readable | Writable | null | undefined, // extra ]; /** * The `subprocess.killed` property indicates whether the child process * successfully received a signal from `subprocess.kill()`. The `killed` property * does not indicate that the child process has been terminated. * @since v0.5.10 */ readonly killed: boolean; /** * Returns the process identifier (PID) of the child process. If the child process * fails to spawn due to errors, then the value is `undefined` and `error` is * emitted. * * ```js * import { spawn } from 'node:child_process'; * const grep = spawn('grep', ['ssh']); * * console.log(`Spawned child pid: ${grep.pid}`); * grep.stdin.end(); * ``` * @since v0.1.90 */ readonly pid?: number | undefined; /** * The `subprocess.connected` property indicates whether it is still possible to * send and receive messages from a child process. When `subprocess.connected` is `false`, it is no longer possible to send or receive messages. * @since v0.7.2 */ readonly connected: boolean; /** * The `subprocess.exitCode` property indicates the exit code of the child process. * If the child process is still running, the field will be `null`. * * When the child process is terminated by a signal, `subprocess.exitCode` will be * `null` and `subprocess.signalCode` will be set. To get the corresponding * POSIX exit code, use * `util.convertProcessSignalToExitCode(subprocess.signalCode)`. */ readonly exitCode: number | null; /** * The `subprocess.signalCode` property indicates the signal received by * the child process if any, else `null`. */ readonly signalCode: NodeJS.Signals | null; /** * The `subprocess.spawnargs` property represents the full list of command-line * arguments the child process was launched with. */ readonly spawnargs: string[]; /** * The `subprocess.spawnfile` property indicates the executable file name of * the child process that is launched. * * For {@link fork}, its value will be equal to `process.execPath`. * For {@link spawn}, its value will be the name of * the executable file. * For {@link exec}, its value will be the name of the shell * in which the child process is launched. */ readonly spawnfile: string; /** * The `subprocess.kill()` method sends a signal to the child process. If no * argument is given, the process will be sent the `'SIGTERM'` signal. See [`signal(7)`](http://man7.org/linux/man-pages/man7/signal.7.html) for a list of available signals. This function * returns `true` if [`kill(2)`](http://man7.org/linux/man-pages/man2/kill.2.html) succeeds, and `false` otherwise. * * ```js * import { spawn } from 'node:child_process'; * const grep = spawn('grep', ['ssh']); * * grep.on('close', (code, signal) => { * console.log( * `child process terminated due to receipt of signal ${signal}`); * }); * * // Send SIGHUP to process. * grep.kill('SIGHUP'); * ``` * * The `ChildProcess` object may emit an `'error'` event if the signal * cannot be delivered. Sending a signal to a child process that has already exited * is not an error but may have unforeseen consequences. Specifically, if the * process identifier (PID) has been reassigned to another process, the signal will * be delivered to that process instead which can have unexpected results. * * While the function is called `kill`, the signal delivered to the child process * may not actually terminate the process. * * See [`kill(2)`](http://man7.org/linux/man-pages/man2/kill.2.html) for reference. * * On Windows, where POSIX signals do not exist, the `signal` argument will be * ignored, and the process will be killed forcefully and abruptly (similar to `'SIGKILL'`). * See `Signal Events` for more details. * * On Linux, child processes of child processes will not be terminated * when attempting to kill their parent. This is likely to happen when running a * new process in a shell or with the use of the `shell` option of `ChildProcess`: * * ```js * import { spawn } from 'node:child_process'; * * const subprocess = spawn( * 'sh', * [ * '-c', * `node -e "setInterval(() => { * console.log(process.pid, 'is alive') * }, 500);"`, * ], { * stdio: ['inherit', 'inherit', 'inherit'], * }, * ); * * setTimeout(() => { * subprocess.kill(); // Does not terminate the Node.js process in the shell. * }, 2000); * ``` * @since v0.1.90 */ kill(signal?: NodeJS.Signals | number): boolean; /** * Calls {@link ChildProcess.kill} with `'SIGTERM'`. * @since v20.5.0 */ [Symbol.dispose](): void; /** * When an IPC channel has been established between the parent and child ( * i.e. when using {@link fork}), the `subprocess.send()` method can * be used to send messages to the child process. When the child process is a * Node.js instance, these messages can be received via the `'message'` event. * * The message goes through serialization and parsing. The resulting * message might not be the same as what is originally sent. * * For example, in the parent script: * * ```js * import cp from 'node:child_process'; * const n = cp.fork(`${__dirname}/sub.js`); * * n.on('message', (m) => { * console.log('PARENT got message:', m); * }); * * // Causes the child to print: CHILD got message: { hello: 'world' } * n.send({ hello: 'world' }); * ``` * * And then the child script, `'sub.js'` might look like this: * * ```js * process.on('message', (m) => { * console.log('CHILD got message:', m); * }); * * // Causes the parent to print: PARENT got message: { foo: 'bar', baz: null } * process.send({ foo: 'bar', baz: NaN }); * ``` * * Child Node.js processes will have a `process.send()` method of their own * that allows the child to send messages back to the parent. * * There is a special case when sending a `{cmd: 'NODE_foo'}` message. Messages * containing a `NODE_` prefix in the `cmd` property are reserved for use within * Node.js core and will not be emitted in the child's `'message'` event. Rather, such messages are emitted using the `'internalMessage'` event and are consumed internally by Node.js. * Applications should avoid using such messages or listening for `'internalMessage'` events as it is subject to change without notice. * * The optional `sendHandle` argument that may be passed to `subprocess.send()` is * for passing a TCP server or socket object to the child process. The child will * receive the object as the second argument passed to the callback function * registered on the `'message'` event. Any data that is received and buffered in * the socket will not be sent to the child. Sending IPC sockets is not supported on Windows. * * The optional `callback` is a function that is invoked after the message is * sent but before the child may have received it. The function is called with a * single argument: `null` on success, or an `Error` object on failure. * * If no `callback` function is provided and the message cannot be sent, an `'error'` event will be emitted by the `ChildProcess` object. This can * happen, for instance, when the child process has already exited. * * `subprocess.send()` will return `false` if the channel has closed or when the * backlog of unsent messages exceeds a threshold that makes it unwise to send * more. Otherwise, the method returns `true`. The `callback` function can be * used to implement flow control. * * #### Example: sending a server object * * The `sendHandle` argument can be used, for instance, to pass the handle of * a TCP server object to the child process as illustrated in the example below: * * ```js * import { createServer } from 'node:net'; * import { fork } from 'node:child_process'; * const subprocess = fork('subprocess.js'); * * // Open up the server object and send the handle. * const server = createServer(); * server.on('connection', (socket) => { * socket.end('handled by parent'); * }); * server.listen(1337, () => { * subprocess.send('server', server); * }); * ``` * * The child would then receive the server object as: * * ```js * process.on('message', (m, server) => { * if (m === 'server') { * server.on('connection', (socket) => { * socket.end('handled by child'); * }); * } * }); * ``` * * Once the server is now shared between the parent and child, some connections * can be handled by the parent and some by the child. * * While the example above uses a server created using the `node:net` module, `node:dgram` module servers use exactly the same workflow with the exceptions of * listening on a `'message'` event instead of `'connection'` and using `server.bind()` instead of `server.listen()`. This is, however, only * supported on Unix platforms. * * #### Example: sending a socket object * * Similarly, the `sendHandler` argument can be used to pass the handle of a * socket to the child process. The example below spawns two children that each * handle connections with "normal" or "special" priority: * * ```js * import { createServer } from 'node:net'; * import { fork } from 'node:child_process'; * const normal = fork('subprocess.js', ['normal']); * const special = fork('subprocess.js', ['special']); * * // Open up the server and send sockets to child. Use pauseOnConnect to prevent * // the sockets from being read before they are sent to the child process. * const server = createServer({ pauseOnConnect: true }); * server.on('connection', (socket) => { * * // If this is special priority... * if (socket.remoteAddress === '74.125.127.100') { * special.send('socket', socket); * return; * } * // This is normal priority. * normal.send('socket', socket); * }); * server.listen(1337); * ``` * * The `subprocess.js` would receive the socket handle as the second argument * passed to the event callback function: * * ```js * process.on('message', (m, socket) => { * if (m === 'socket') { * if (socket) { * // Check that the client socket exists. * // It is possible for the socket to be closed between the time it is * // sent and the time it is received in the child process. * socket.end(`Request handled with ${process.argv[2]} priority`); * } * } * }); * ``` * * Do not use `.maxConnections` on a socket that has been passed to a subprocess. * The parent cannot track when the socket is destroyed. * * Any `'message'` handlers in the subprocess should verify that `socket` exists, * as the connection may have been closed during the time it takes to send the * connection to the child. * @since v0.5.9 * @param sendHandle `undefined`, or a [`net.Socket`](https://nodejs.org/docs/latest-v26.x/api/net.html#class-netsocket), [`net.Server`](https://nodejs.org/docs/latest-v26.x/api/net.html#class-netserver), or [`dgram.Socket`](https://nodejs.org/docs/latest-v26.x/api/dgram.html#class-dgramsocket) object. * @param options The `options` argument, if present, is an object used to parameterize the sending of certain types of handles. `options` supports the following properties: */ send(message: Serializable, callback?: (error: Error | null) => void): boolean; send(message: Serializable, sendHandle?: SendHandle, callback?: (error: Error | null) => void): boolean; send( message: Serializable, sendHandle?: SendHandle, options?: MessageOptions, callback?: (error: Error | null) => void, ): boolean; /** * Closes the IPC channel between parent and child, allowing the child to exit * gracefully once there are no other connections keeping it alive. After calling * this method the `subprocess.connected` and `process.connected` properties in * both the parent and child (respectively) will be set to `false`, and it will be * no longer possible to pass messages between the processes. * * The `'disconnect'` event will be emitted when there are no messages in the * process of being received. This will most often be triggered immediately after * calling `subprocess.disconnect()`. * * When the child process is a Node.js instance (e.g. spawned using {@link fork}), the `process.disconnect()` method can be invoked * within the child process to close the IPC channel as well. * @since v0.7.2 */ disconnect(): void; /** * By default, the parent will wait for the detached child to exit. To prevent the * parent from waiting for a given `subprocess` to exit, use the `subprocess.unref()` method. Doing so will cause the parent's event loop to not * include the child in its reference count, allowing the parent to exit * independently of the child, unless there is an established IPC channel between * the child and the parent. * * ```js * import { spawn } from 'node:child_process'; * * const subprocess = spawn(process.argv[0], ['child_program.js'], { * detached: true, * stdio: 'ignore', * }); * * subprocess.unref(); * ``` * @since v0.7.10 */ unref(): void; /** * Calling `subprocess.ref()` after making a call to `subprocess.unref()` will * restore the removed reference count for the child process, forcing the parent * to wait for the child to exit before exiting itself. * * ```js * import { spawn } from 'node:child_process'; * * const subprocess = spawn(process.argv[0], ['child_program.js'], { * detached: true, * stdio: 'ignore', * }); * * subprocess.unref(); * subprocess.ref(); * ``` * @since v0.7.10 */ ref(): void; } interface ChildProcess extends InternalEventEmitter {} ``` ## Constants ### default The default export, declared as `crossSpawn`. The callable default, with the named exports hung off it. ```ts const crossSpawn: typeof spawn & { spawn: typeof spawn; sync: typeof sync; parse: typeof parse; _parse: typeof parse; _enoent: { hookChildProcess: typeof hookChildProcess; verifyENOENT: typeof verifyENOENT; notFoundError: typeof notFoundError; }; }; ``` ### _enoent `cross-spawn._enoent`, the shape its dependants import. ```ts const _enoent: { hookChildProcess: typeof hookChildProcess; verifyENOENT: typeof verifyENOENT; notFoundError: typeof notFoundError; }; ``` ### crossSpawn The callable default, with the named exports hung off it. ```ts const crossSpawn: typeof spawn & { spawn: typeof spawn; sync: typeof sync; parse: typeof parse; _parse: typeof parse; _enoent: { hookChildProcess: typeof hookChildProcess; verifyENOENT: typeof verifyENOENT; notFoundError: typeof notFoundError; }; }; ``` ### module.exports What `require()` returns: the default export, `crossSpawn`, under the name Node's `require(esm)` reads. ## Interfaces ### Parsed What was asked for and what will be run. Mirrors `cross-spawn`'s `parsed` exactly. ```ts interface Parsed { command: string; args: string[]; options: SpawnOptions; /** * The resolved executable, or `undefined` when nothing resolved. Windows-only, and the * flag `enoent.ts` reads: `cmd.exe` exits 1 for a command it could not find, which is * indistinguishable from a command that ran and failed unless somebody remembered whether * the file was there. */ file: string | undefined; /** What the caller wrote, kept for the error message: it names their command, not ours. */ original: { command: string; args: string[]; }; } ``` ### SpawnOptions Options a caller may pass through to `child_process`, plus the two this reads. ```ts interface SpawnOptions { cwd?: string | undefined; env?: Record | undefined; /** * Hand the whole thing to a shell. Off by default and **documented as the injection * surface it is**: with it on, an argument is text in a command line and a `;` or a `&` * in caller-supplied data is another command. Nothing in this package turns it on to * solve a Windows problem — that is what `escape.ts` is for. */ shell?: boolean | string | undefined; /** `cross-spawn`'s own test hook: take the `cmd.exe` path even for a `.exe`. */ forceShell?: boolean | undefined; windowsVerbatimArguments?: boolean | undefined; [key: string]: unknown; } ``` ### SpawnSyncReturns ```ts interface SpawnSyncReturns { pid: number; output: Array; stdout: T; stderr: T; status: number | null; signal: NodeJS.Signals | null; error?: Error; } ``` --- # bellpull > Every export of bellpull, with its signature and doc comment: ambientRuntime, DEFAULT_GRACE, DEFAULT_TIMEOUT, escapeArgument, escapeCommand, extensionCandidates and 20 more, plus 9 types. Source: https://bellpull.interlace.tools/docs/api bellpull — run another program and get back a result you can read. ```ts import { ambientRuntime, DEFAULT_GRACE, DEFAULT_TIMEOUT, … } from 'bellpull'; ``` ## Functions ### ambientRuntime The runtime of the process this is running in, or a runtime that admits it knows nothing. ```ts function ambientRuntime(): Runtime; ``` **Returns** `Runtime` ### escapeArgument Escape one argument so `cmd.exe` and then the callee's argv parser both give it back unchanged. ```ts function escapeArgument(argument: unknown, doubleEscape?: boolean): string; ``` | Parameter | Type | | :-- | :-- | | `argument` | `unknown` | | `doubleEscape` (optional) | `boolean` | **Returns** `string` ### escapeCommand Escape a command *name* for `cmd.exe`. ```ts function escapeCommand(command: string): string; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | **Returns** `string` ### format How a result reads to a person: one line, and the failure's output when there is one. ```ts function format(result: Result): string; ``` | Parameter | Type | | :-- | :-- | | `result` | `Result` | **Returns** `string` ### isWindows Whether this runtime plays by Windows' rules. Cygwin and msys announce themselves in `OSTYPE`. ```ts function isWindows(runtime: Runtime): boolean; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `boolean` ### pathDelimiter The separator between `PATH` entries: `;` on Windows, `:` everywhere else. ```ts function pathDelimiter(runtime: Runtime): string; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `string` ### pathKey The key `PATH` is spelled under in this environment. ```ts function pathKey(runtime: Runtime): string; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `string` ### pathOf The `PATH` value for this runtime, under whichever key it is spelled. ```ts function pathOf(runtime: Runtime): string; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `string` ### readShebang The interpreter the file at `file` declares, or `undefined` — for a file that is not there, is not readable, is a directory, or simply has no shebang. ```ts function readShebang(file: string): string | undefined; ``` | Parameter | Type | | :-- | :-- | | `file` | `string` | **Returns** `string \| undefined` ### run Run a program and come back with a {@link Result}. ```ts function run(command: string, args: readonly unknown[] | undefined, options: RunOptions): Promise; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `args` | `readonly unknown[] \| undefined` | | `options` | `RunOptions` | **Returns** `Promise` ### shebangCommand The interpreter a `#!` line names, or `undefined`. ```ts function shebangCommand(source: string): string | undefined; ``` | Parameter | Type | | :-- | :-- | | `source` | `string` | **Returns** `string \| undefined` ### toEvent The agent form: {@link outcomeOf}'s one word, plus the record's own fields. ```ts function toEvent(result: Result): RunEvent; ``` | Parameter | Type | | :-- | :-- | | `result` | `Result` | **Returns** `RunEvent` ### toJson The `--json` envelope: the record, flat, with nothing computed that is not in it. ```ts function toJson(result: Result): Record; ``` | Parameter | Type | | :-- | :-- | | `result` | `Result` | **Returns** `Record` ## Classes ### SpawnError Spawning failed for a reason that is not a missing executable. ```ts class SpawnError extends Error { readonly command: string; readonly cause: unknown; readonly code = "ERR_SPAWN_FAILED"; constructor(command: string, cause: unknown); } ``` ## Constants ### DEFAULT_GRACE Milliseconds between `SIGTERM` and `SIGKILL`, for a child that declines to leave. ```ts const DEFAULT_GRACE = 5000; ``` ### DEFAULT_TIMEOUT Milliseconds a child may take before it is killed. Finite by default — Y10. ```ts const DEFAULT_TIMEOUT = 30000; ``` ### name The package's own name, kept from the reserved-name release so nothing that read it breaks. ```ts const name: "bellpull"; ``` ## Interfaces ### ExitHost A host that can register work to run when the process is shutting down. ```ts interface ExitHost { /** Register; the returned function unregisters. `closeout`'s `Registry.add` fits as-is. */ add(handler: () => unknown, spec?: unknown): () => void; } ``` ### Result What a run produced. One value, three renderings (see `project.ts`). ```ts interface Result { /** The exit was 0 and the run was not killed. The only field most callers branch on. */ ok: boolean; /** The exit code, or `null` when the child died of a signal — the shape Node reports. */ code: number | null; /** The signal that killed it, or `null`. */ signal: NodeJS.Signals | null; /** Everything the child wrote to stdout. `''` when stdio was not captured. */ stdout: string; /** Everything the child wrote to stderr. */ stderr: string; /** * Wall-clock milliseconds from spawn to close — **including Node's own scheduling**, not * the child's CPU time (design R11). Stated here rather than in a README because the * caveat is what makes the number usable: it is the right figure for "how long did the * user wait" and the wrong one for "how expensive is this program". */ duration: number; /** The command as the caller wrote it. */ command: string; /** The arguments as the caller wrote them. */ args: readonly string[]; /** * The file that actually ran, and the `PATH` entry it came from — the field that answers * "which binary was this" without a second investigation. `undefined` when resolution was * skipped (`shell: true`, where the shell resolves). */ executable: { path: string; from: string; } | undefined; /** The deadline fired and the child was killed. `ok` is `false`. */ timedOut: boolean; } ``` ### RunEvent One event on an agent stream. `type` is what a consumer switches on. ```ts interface RunEvent { type: 'run'; outcome: Outcome; command: string; args: readonly string[]; code: number | null; signal: string | null; durationMs: number; executable: string | null; /** Where the executable came from, so an agent can report a `PATH` surprise itself. */ from: string | null; } ``` ### RunOptions ```ts interface RunOptions extends SpawnOptions { /** The ambient state. Required: nothing here reads `process` (Y9). */ runtime: Runtime; /** Milliseconds before the child is killed. `0` disables the deadline, deliberately. */ timeout?: number | undefined; /** Milliseconds between `SIGTERM` and `SIGKILL` on a breach. */ grace?: number | undefined; /** * Where to register the child's kill so it is not orphaned if the parent is shut down. */ exitHost?: ExitHost | undefined; /** Passed to `child_process`. `'inherit'` is what a pass-through subcommand wants. */ stdio?: 'pipe' | 'inherit' | 'ignore' | readonly unknown[] | undefined; } ``` ### Runtime The ambient state this package reads. Structural on purpose: a caller may pass Node's own `process`, a fake, or an object assembled from a container's environment. ```ts interface Runtime { /** `process.platform`. `win32` selects `PATHEXT`, backslashes and the cmd.exe rules. */ readonly platform: Platform; /** The environment. `PATH` (or whichever case Windows used) is read out of it. */ readonly env: Readonly>; /** The working directory a relative command or `cwd` option is resolved against. */ readonly cwd: string; /** Effective uid, where the platform has one. Absent means "do not check ownership". */ readonly uid?: number | undefined; /** Effective gid, where the platform has one. */ readonly gid?: number | undefined; } ``` ## Types ### Platform A platform string, as `process.platform` spells it. Only `win32` is ever special-cased. ```ts type Platform = string; ``` ## Re-exported Documented on the page of the entry point that declares them. | Export | Kind | Documented in | | :-- | :-- | :-- | | `extensionCandidates` | function | [`bellpull/which`](/docs/api/which#extensioncandidates) | | `pathExtensions` | function | [`bellpull/which`](/docs/api/which#pathextensions) | | `resolveExecutable` | function | [`bellpull/which`](/docs/api/which#resolveexecutable) | | `runPath` | function | [`bellpull/which`](/docs/api/which#runpath) | | `searchPath` | function | [`bellpull/which`](/docs/api/which#searchpath) | | `whichAllSync` | function | [`bellpull/which`](/docs/api/which#whichallsync) | | `whichOrThrowSync` | function | [`bellpull/which`](/docs/api/which#whichorthrowsync) | | `whichSync` | function | [`bellpull/which`](/docs/api/which#whichsync) | | `NotFoundError` | class | [`bellpull/which`](/docs/api/which#notfounderror) | | `Resolution` | interface | [`bellpull/which`](/docs/api/which#resolution) | | `SearchEntry` | interface | [`bellpull/which`](/docs/api/which#searchentry) | | `WhichOptions` | interface | [`bellpull/which`](/docs/api/which#whichoptions) | --- # bellpull/node-which > Every export of bellpull/node-which, with its signature and doc comment: which, plus 1 type. Source: https://bellpull.interlace.tools/docs/api/node-which ```ts import which from 'bellpull/node-which'; import type { NodeWhichOptions } from 'bellpull/node-which'; ``` ## Functions ### default The default export, declared as `which`. node-which's `which`: the same search, answered as a promise. ```ts function which(cmd: string, opt?: NodeWhichOptions): Promise; namespace which { var sync: typeof whichSync; } ``` | Parameter | Type | | :-- | :-- | | `cmd` | `string` | | `opt` (optional) | `NodeWhichOptions` | **Returns** `Promise` ### module.exports What `require()` returns: the default export, `which`, under the name Node's `require(esm)` reads. ## Interfaces ### NodeWhichOptions ```ts interface NodeWhichOptions { /** Return every match rather than the first. */ all?: boolean; /** Return `null` rather than throwing when nothing is found. */ nothrow?: boolean; /** Search this instead of `PATH`. */ path?: string; /** Use these extensions instead of `PATHEXT`, on Windows. */ pathExt?: string; /** Split `path` and `pathExt` on this instead of the platform's delimiter. */ delimiter?: string; } ``` --- # bellpull/plugin > Every export of bellpull/plugin, with its signature and doc comment: validate, register, reset, registered, contributions, substitute and 3 more, plus 6 types. Source: https://bellpull.interlace.tools/docs/api/plugin The plugin host for bellpull's half of the contract — `plugin-contract` R5a, R6, R7, R8, and PLAN step 1.5. ```ts import { validate, register, reset, … } from 'bellpull/plugin'; ``` ## Functions ### contributions Every resolver every registered plugin contributed, in the order they will be searched. ```ts function contributions(): Contribution[]; ``` **Returns** `Contribution[]` ### directories The directories registered plugins contribute for this runtime, in search order. ```ts function directories(runtime: Runtime): ContributedDirectory[]; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `ContributedDirectory[]` ### register Register a plugin. Later wins, like ESLint flat config — though "wins" is narrower here than for a colour token: two resolvers under the same name shadow, and two resolvers under different names both apply, in rank order. ```ts function register(plugin: unknown): void; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `void` ### registered The plugins registered, in registration order. ```ts function registered(): readonly Plugin[]; ``` **Returns** `readonly Plugin[]` ### reset Forget every registered plugin. For tests, and for a program that re-plugs at runtime. ```ts function reset(): void; ``` **Returns** `void` ### substitute Substitute `{VAR}` from the environment. ```ts function substitute(template: string, runtime: Runtime): string | undefined; ``` | Parameter | Type | | :-- | :-- | | `template` | `string` | | `runtime` | `Runtime` | **Returns** `string \| undefined` ### validate Refuse a plugin that cannot contribute a resolver, at the door. ```ts function validate(plugin: unknown): asserts plugin is Plugin; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `asserts plugin is Plugin` ## Classes ### PluginError A refused plugin says what is wrong and what to do about it — the family's one vocabulary. ```ts class PluginError extends Error { readonly code: PluginErrorCode; readonly fix: string; constructor(code: PluginErrorCode, message: string, fix: string); } ``` ## Constants ### CONTRACT The plugin contract version. One number for the family — the same `1` flagstaff, roundel, caique and closeout declare, written out rather than imported for the reason above. ```ts const CONTRACT = 1; ``` ## Interfaces ### ContributedDirectory A contributed directory, and which resolver put it there. ```ts interface ContributedDirectory { dir: string; /** The resolver's name, so a report can say why this directory is being searched. */ resolver: string; rank: number; } ``` ### Contribution One contributed resolver, with the plugin it came from and what it shadowed. ```ts interface Contribution { name: string; from: string; resolver: Resolver; /** Plugins that contributed this name earlier and were overridden, in order. */ shadowed: string[]; } ``` ### Plugin The keys bellpull reads. Declared structurally: any object with these fields is a plugin here, whatever else it carries. ```ts interface Plugin { name: string; contract?: number; resolvers?: Record; } ``` ### Resolver One contributed way of finding an executable. ```ts interface Resolver { /** * Where this sits relative to `PATH`, which is rank `0`. A negative rank searches before * `PATH` — which is what a version manager's shims want, because being *after* `PATH` is * the same as not being installed. Ties keep registration order. */ rank: number; /** * Directories to search, in order. Absolute after substitution, or dropped. * `{VAR}` is replaced by `runtime.env.VAR`; an unset variable drops the entry. */ paths: readonly string[]; /** Extra extensions to try, in order — `PATHEXT` for one resolver rather than the machine. */ extensions?: readonly string[]; /** When it applies. Absent means always. */ when?: ResolverWhen; } ``` ### ResolverWhen When a resolver applies. Every clause must hold; each array is an OR within itself. ```ts interface ResolverWhen { /** Platforms, as `process.platform` spells them. Absent means every platform. */ platform?: readonly string[]; /** Any one of these environment variables merely being set — how a version manager announces itself. */ envAny?: readonly string[]; } ``` ## Types ### PluginErrorCode ```ts type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_PLUGIN_CONTRACT' | 'E_NO_CONTRIBUTION'; ``` --- # bellpull/which > Every export of bellpull/which, with its signature and doc comment: pathExtensions, extensionCandidates, searchPath, whichAllSync, whichSync, resolveExecutable and 5 more, plus 3 types. Source: https://bellpull.interlace.tools/docs/api/which ```ts import { pathExtensions, extensionCandidates, searchPath, … } from 'bellpull/which'; ``` ## Functions ### extensionCandidates The extensions tried for this command, in order. ```ts function extensionCandidates(command: string, options: WhichOptions): string[]; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `options` | `WhichOptions` | **Returns** `string[]` ### pathExtensions The `PATHEXT` list for this runtime, as an array. `['']` off Windows: no expansion. ```ts function pathExtensions(options: WhichOptions): string[]; ``` | Parameter | Type | | :-- | :-- | | `options` | `WhichOptions` | **Returns** `string[]` ### resolveExecutable Where `command` resolves to for the purpose of **spawning it** — two `PATHEXT` attempts, not one, as `cross-spawn` does. ```ts function resolveExecutable(command: string, options: WhichOptions): Resolution | undefined; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `options` | `WhichOptions` | **Returns** `Resolution \| undefined` ### runPath `PATH` with every `node_modules/.bin` from `cwd` upward in front of it — `npm-run-path`'s contract (104 M/wk, 2 dependencies, last published 2024-08-26). ```ts function runPath(options: WhichOptions): string; ``` | Parameter | Type | | :-- | :-- | | `options` | `WhichOptions` | **Returns** `string` ### searchPath The directories this runtime would search, in order — the static projection of resolution (PRINCIPLES rule 6). ```ts function searchPath(options: WhichOptions): SearchEntry[]; ``` | Parameter | Type | | :-- | :-- | | `options` | `WhichOptions` | **Returns** `SearchEntry[]` ### whichAllSync Every place `command` resolves to, best first. Empty when it resolves nowhere. ```ts function whichAllSync(command: string, options: WhichOptions): Resolution[]; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `options` | `WhichOptions` | **Returns** `Resolution[]` ### whichOrThrowSync Where `command` resolves to, throwing {@link NotFoundError} when it resolves nowhere. ```ts function whichOrThrowSync(command: string, options: WhichOptions): Resolution; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `options` | `WhichOptions` | **Returns** `Resolution` ### whichSync Where `command` resolves to, or `undefined`. ```ts function whichSync(command: string, options: WhichOptions): Resolution | undefined; ``` | Parameter | Type | | :-- | :-- | | `command` | `string` | | `options` | `WhichOptions` | **Returns** `Resolution \| undefined` ## Classes ### NotFoundError `Error` with the `code` a caller switches on, and the command it could not find. ```ts class NotFoundError extends Error { readonly command: string; readonly code = "ENOENT"; constructor(command: string); } ``` ## Constants ### delimiter The platform-specific file delimiter. ';' or ':'. ```ts const delimiter: ";" | ":"; ``` ### sep The platform-specific file separator. '\\' or '/'. ```ts const sep: "\\" | "/"; ``` ## Interfaces ### Resolution Where an executable was found, and what found it. ```ts interface Resolution { /** The absolute path of the file that would be executed. */ path: string; /** * The `PATH` entry it was found under, or `''` when the command carried its own path and * `PATH` was never consulted. This is the field no incumbent returns. */ from: string; /** * The extension appended from `PATHEXT` to find it — `.CMD`, `.EXE`. Always `''` off * Windows, where the file is executable or it is not. */ ext: string; } ``` ### SearchEntry One directory to search, and whether it was skipped and why. ```ts interface SearchEntry { /** The entry as it appeared on `PATH`, before quotes were stripped or it was resolved. */ raw: string; /** The absolute directory that will be searched, when it will be. */ dir?: string; /** Why it will not be, when it will not: a reader can see the hole rather than infer it. */ skipped?: 'empty' | 'relative'; } ``` ### WhichOptions ```ts interface WhichOptions { /** The ambient state. Required in spirit; defaulted only by the façades. */ runtime: Runtime; /** Override the `PATH` string. Overrides `runtime.env`'s, as `which`'s `path` does. */ path?: string | undefined; /** Override `PATHEXT`. `''` disables extension expansion entirely. */ pathExt?: string | undefined; /** Resolve relative entries and relative commands against this instead of `runtime.cwd`. */ cwd?: string | undefined; } ``` --- # Changelog > Every release of bellpull, newest first, from its CHANGELOG.md — what changed and the pull request it came from. Source: https://bellpull.interlace.tools/docs/changelog ## 1.0.11 ### Patch Changes - [#1483](https://github.com/ofri-peretz/burgee/pull/1483) [`3e69fb2`](https://github.com/ofri-peretz/burgee/commit/3e69fb2d8d068210347ac71d2886d6a4309859d1) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The package manifest names its author and publishes with `publishConfig` `{ "access": "public", "provenance": true }`. burgee's `homepage` is its landing page, https://burgee.interlace.tools. oxmutate gains `keywords`, so npm search finds it, and a `sideEffects` list (its CLI, its worker and its Vitest runner run on load), so a bundler can drop the rest. No code changes. ## 1.0.10 ### Patch Changes - [#1258](https://github.com/ofri-peretz/burgee/pull/1258) [`e3b3a41`](https://github.com/ofri-peretz/burgee/commit/e3b3a41745ecdc853d741c457306c566541271b6) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` reads a `.json` plugin file as data. It handed every file to `import()`, and Node refuses a `.json` module without an import attribute (ERR_IMPORT_ATTRIBUTE_MISSING), so a plugin written as JSON got Node's message and no verdict, from the host's own `check` and through `burgee check`, which runs it. - [#1266](https://github.com/ofri-peretz/burgee/pull/1266) [`02e6f9f`](https://github.com/ofri-peretz/burgee/commit/02e6f9fcd779d892e7097659a6bd4c2f0e5b5a04) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `formats`, burgee's output-format key. This package reads nothing new, and a plugin carrying `formats` registers here as before. - [#1252](https://github.com/ofri-peretz/burgee/pull/1252) [`efc994e`](https://github.com/ofri-peretz/burgee/commit/efc994ea20dae1f31990a7afef9dfa3c34fee596) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README names the Node builtin the package builds on, and says what the package adds over it: `Intl.Segmenter` and `util.stripVTControlCharacters` (linegauge), `process.loadEnvFile` and `util.parseEnv` (seniority), `node:child_process` (bellpull), `process.on(signal)` (closeout). The README's opening names the package and its incumbents only, and every benchmark figure in it links to where it is measured. ## 1.0.9 ### Patch Changes - [#1186](https://github.com/ofri-peretz/burgee/pull/1186) [`af24328`](https://github.com/ofri-peretz/burgee/commit/af243288e27cbffbcffac18af7112172e46a003b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Published declarations now keep each doc comment's first paragraph, its code examples and its `@param`, `@returns`, `@throws`, `@example`, `@deprecated`, `@default` and `@see` tags, and drop the longer design notes below them, so the tarballs are smaller (burgee by 5.7% gzipped, bellpull and closeout by about a fifth). Types are unchanged, and hover text in an editor keeps its summary. `burgee`'s README also names `burgee/program-schema.json`, the JSON Schema every `--schema` document validates against. ## 1.0.8 ### Patch Changes - [#1160](https://github.com/ofri-peretz/burgee/pull/1160) [`0b381a0`](https://github.com/ofri-peretz/burgee/commit/0b381a0d394092edf2ea21a9143a10111aa72fe4) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Export `./package.json`. `require('/package.json')` threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, which broke tools that read a dependency's manifest that way: bundlers, license scanners and version checks. ## 1.0.7 ### Patch Changes - [#1130](https://github.com/ofri-peretz/burgee/pull/1130) [`c05c86e`](https://github.com/ofri-peretz/burgee/commit/c05c86e2caa0fc3a89f97f7fe9c37bd7b876bfaf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every drop-in subpath now type-checks under `moduleResolution: node10`, and under `module: commonjs` with no resolution set, which is TypeScript's default there. `import … from 'burgee/commander'` used to fail with TS2307 in such a project, although `require()` of the same specifier worked. Each package now declares `types` and a `typesVersions` map, derived from `exports`, with one key per typed subpath pointing at the file `exports` already names. `exports` is unchanged, so `node16`, `nodenext` and `bundler` read exactly what they read before. `controlroom/ink`'s declarations no longer name the untyped `react-reconciler`, which was TS7016 for consumers with `skipLibCheck: false`. ## 1.0.6 ### Patch Changes - [#1045](https://github.com/ofri-peretz/burgee/pull/1045) [`c4fd9a3`](https://github.com/ofri-peretz/burgee/commit/c4fd9a3cb10bc36bef8c468365addfc6deb572cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `locales`, burgee's locale-pack key. A plugin carrying `locales` validates against the shared schema as it does against `burgee/locale-pack-schema.json`, which lists every message id. This package reads nothing new, and a plugin carrying `locales` registers here as before. ## 1.0.5 ### Patch Changes - [#1022](https://github.com/ofri-peretz/burgee/pull/1022) [`ecb0a00`](https://github.com/ofri-peretz/burgee/commit/ecb0a00ca68e9a3b3584aefabb9dcf97e3ff967a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The tarball no longer carries declaration files that no `exports` entry reaches. TypeScript could never open them, because a deep import is refused by `exports`. Every entry's types are unchanged. The ten packages drop 71 files and 118,312 bytes between them, burgee 35 files and 76,504 bytes, which takes its tarball from 251,814 to 225,278 bytes gzipped. ## 1.0.4 ### Patch Changes - [#938](https://github.com/ofri-peretz/burgee/pull/938) [`9babb6d`](https://github.com/ofri-peretz/burgee/commit/9babb6da1ae7783c7e8367f89e3da9dffc85ffdb) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - npm description and README: the description says what the package does, then its dependency fact in one of two wordings ("Zero dependencies." or "No dependency outside the burgee family."), then its drop-in paths. The README's family section states how the family is built — the leaves, one-way dependencies, nothing from outside the family, trusted publishing with provenance — and its table column is "Migrates from". No API, compatibility grade or benchmark changes. ## 1.0.3 ### Patch Changes - [#917](https://github.com/ofri-peretz/burgee/pull/917) [`735f0f6`](https://github.com/ofri-peretz/burgee/commit/735f0f68048f2d0f5e4effad151ed5132b480bb8) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: the opening paragraph says what the package does first. The drop-in path follows it as one line, naming the incumbent and `npx burgee migrate`, and every compatibility row and benchmark is unchanged. ## 1.0.2 ### Patch Changes - [#897](https://github.com/ofri-peretz/burgee/pull/897) [`f29f159`](https://github.com/ofri-peretz/burgee/commit/f29f159ca0c9746f58d8ba84eec9be72b1140093) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom has its own docs site at https://controlroom.interlace.tools: its README's Docs line, its `homepage`, and every family table now link there. ## 1.0.1 ### Patch Changes - [#857](https://github.com/ofri-peretz/burgee/pull/857) [`d80b2d0`](https://github.com/ofri-peretz/burgee/commit/d80b2d0f4a535400c6825b42ac2d8e1be2827f4e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A resolver's `when` refuses any key other than `platform` and `envAny`, at `register()` and in `bellpull check`, with `E_PLUGIN_SCHEMA` naming the key and the keys allowed. A misspelled condition such as `when: { env: [...] }` was ignored, so the resolver applied on every machine and `check` reported `ok`. - [#848](https://github.com/ofri-peretz/burgee/pull/848) [`6ae533e`](https://github.com/ofri-peretz/burgee/commit/6ae533e4610c32bad557f5d74a24d0c9a0f07f7e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` describes what five values look like. `contract` is `1`, and burgee refuses a plugin that declares none. A caique widget's `static(spec)` gets `{ kind, message, ...sample.done }`. A seniority `rank` sits between the built-in layers at flag 0, environment 10, config file 20, `package.json` 30 and default 40. A burgee hook's `filter` is `{ command: RegExp }`, now with `command` required, so the schema and the host refuse the same filters. - [#857](https://github.com/ofri-peretz/burgee/pull/857) [`d80b2d0`](https://github.com/ofri-peretz/burgee/commit/d80b2d0f4a535400c6825b42ac2d8e1be2827f4e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` closes a bellpull resolver's `when` with `additionalProperties: false`, so the schema and bellpull's own validation refuse the same unknown keys. - [#848](https://github.com/ofri-peretz/burgee/pull/848) [`6ae533e`](https://github.com/ofri-peretz/burgee/commit/6ae533e4610c32bad557f5d74a24d0c9a0f07f7e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README now shows a whole plugin as a default export, the command that checks it (from an install, and from a clone where `dist/` has to be built first), and the report `check` prints when the plugin passes. caique says what a widget's `static(spec)` receives. seniority gives the `rank` for each slot between the built-in layers. bellpull says that `{VAR}` is the only substitution a path gets. ## 1.0.0 ### Major Changes - [#830](https://github.com/ofri-peretz/burgee/pull/830) [`1945d65`](https://github.com/ofri-peretz/burgee/commit/1945d65f22aafabd380d67f1e40341d6ef3aa6fa) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - bellpull 1.0.0. The spec is fully built, every drop-in passes its incumbent's own suite at the latest release level with the incumbent itself, and coverage is 100% on all four measures (D-20261006-foundation-1-0-evidence). 1.0 makes the published entries, the plugin `schema.json` and the `bellpull` bin a semver contract. No API changed in this release. ## 0.5.2 ### Patch Changes - [#816](https://github.com/ofri-peretz/burgee/pull/816) [`5230016`](https://github.com/ofri-peretz/burgee/commit/52300169a98ece41b3544e5316833464e63a7c83) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom hosts plugins (R10): `keymaps` and `panes` register through `controlroom/plugin`'s `register()` against the family schema, a screen takes either by name, and `controlroom check ` reports what a plugin contributes. The family schema every host ships gains the `keymaps` and `panes` definitions. ## 0.5.1 ### Patch Changes - [#777](https://github.com/ofri-peretz/burgee/pull/777) [`1857ff6`](https://github.com/ofri-peretz/burgee/commit/1857ff65ca0e1b0b5686480c1a37dc8834bf688b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The `bellpull/node-which` façade reads the environment once per lookup, and builds each candidate path only as it tries it, rather than joining every `PATH` entry with every extension up front. A hit in the first directory now costs one `stat`. In B5, `bellpull/node-which` ÷ which is 0.30× locally (0.43× on CI before this change). ## 0.5.0 ### Minor Changes - [#766](https://github.com/ofri-peretz/burgee/pull/766) [`e928581`](https://github.com/ofri-peretz/burgee/commit/e928581996fdeb317b4c849ae285593bfecf6b4d) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `whichSync`, and so `resolveExecutable` and every `run()`, stops at the first `PATH` entry that answers instead of statting every entry after it, and an absent candidate is no longer an exception (`statSync` with `throwIfNoEntry: false`). Resolution on an 18-entry ubuntu-latest `PATH` went from ~195 µs a call to a few µs, which was the whole of `run`'s 6 % over tinyexec's `x` on spawn time. `whichAllSync` still walks every entry. A candidate whose stat fails for any other reason (`ELOOP`, `EACCES`) is still a miss, not a throw. ## 0.4.5 ### Patch Changes - [#752](https://github.com/ofri-peretz/burgee/pull/752) [`22e6dae`](https://github.com/ofri-peretz/burgee/commit/22e6dae1b71bfa478265829a1b98d20a6f9de4f7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A plugin that declares `contract: 0` or a negative contract is now refused with `E_PLUGIN_CONTRACT`, as `schema.json`'s minimum of 1 always said. These hosts checked only that a contract was not newer than the one they know, so 0 and below registered. ## 0.4.4 ### Patch Changes - [#738](https://github.com/ofri-peretz/burgee/pull/738) [`3db5125`](https://github.com/ofri-peretz/burgee/commit/3db512576725fb460c8d998484918220c6b6ffb5) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `shebangCommand`'s doc comment describes its trailing-space cases in words, so the generated API reference renders them without a code span that ends in a space. ## 0.4.3 ### Patch Changes - [#730](https://github.com/ofri-peretz/burgee/pull/730) [`3250edf`](https://github.com/ofri-peretz/burgee/commit/3250edf439a45ccfd203213eddec0d9fb091e8cd) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A `#!` line with a trailing space, or with two spaces after `env`, is read the way `shebang-command` reads it. `#!/usr/local/bin/node` followed by a space used to name the interpreter `node` with the space kept, a name that resolves nowhere, so on Windows `bellpull/cross-spawn` and `run()` sent that script to `cmd.exe` under a command that does not exist. `#!/usr/bin/env node` used to name an empty interpreter, which then replaced the command. Code paths no input could reach are removed, and behaviour is otherwise unchanged. `bellpull check` no longer carries a "(replaces …)" suffix it could never print, because it loads one plugin into an emptied registry. The Windows executability check no longer accepts a symbolic link from a `statSync`, which follows links and never reports one, and no longer special-cases an empty `PATHEXT` entry that `endsWith('')` already accepts. The POSIX check no longer tests the caller's uid and gid for `undefined` before comparing them with a stat's, which are always numbers. - [#674](https://github.com/ofri-peretz/burgee/pull/674) [`e9f45d8`](https://github.com/ofri-peretz/burgee/commit/e9f45d85d9db5b2e3dcaa1e43f292a1281a6952a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: family header, badges, install, migrating, the family table. Every package README now opens the same way — lockup, tagline, one badge row in one order (npm version, downloads, Quality Gate, the package's own coverage, OpenSSF Scorecard, unpacked size, dependencies, types, Node, licence, npm provenance), a row of compatibility badges read from the graded baseline — and carries the same sections in the same order: Install for npm, pnpm, yarn and bun, Quick start, Migrating as a before/after diff, Compatibility, Benchmarks, For agents, API, and a generated table of the nine packages. Links are absolute, so they work on npm as well as GitHub. ## 0.4.2 ### Patch Changes - [#604](https://github.com/ofri-peretz/burgee/pull/604) [`0e7b1e8`](https://github.com/ofri-peretz/burgee/commit/0e7b1e88a7021350cf689f109c7728f799be1589) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README links to its migration guides under the docs link: "Migrating from: chalk", "ora · log-update · boxen · cli-table3", and so on. That puts a path from the npm page to the guide for the library you are replacing. No code changes. ## 0.4.1 ### Patch Changes - [#588](https://github.com/ofri-peretz/burgee/pull/588) [`073037a`](https://github.com/ofri-peretz/burgee/commit/073037ab38b13490ab119c84122a46c7c605be14) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - READMEs and package descriptions now match what each drop-in path is graded at. bellpull names `bellpull/node-which` as the drop-in for npm `which` (5 / 5) and no longer lists execa as a drop-in. paratext's ansi-escapes row is 4 / 4, with the CSI half implemented. seniority's rc row is 1 / 1, dotenv's `config()` defaults to `process.env`, and lilconfig is 77 / 77. linegauge documents `ambiguousIsNarrow` and `strip` as shipped. No code changes. ## 0.4.0 ### Minor Changes - [#490](https://github.com/ofri-peretz/burgee/pull/490) [`8a02d68`](https://github.com/ofri-peretz/burgee/commit/8a02d68fbf30e7837efc7f38899b1f4beb4cee69) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `bellpull/node-which` is a drop-in replacement for node-which 7: `which(cmd, opts)` returns a promise and `which.sync` runs synchronously, with node-which's `all`, `nothrow`, `path`, `pathExt` and `delimiter` options and its `ENOENT` error. It passes node-which's own test suite, 5 of 5. `bellpull/which` is unchanged: it stays bellpull's own resolution API and never reads the process. `require('bellpull/node-which')` returns the function with `.sync` on it, as `require('which')` does, and `burgee migrate` now rewrites `which` to it. ### Patch Changes - [#474](https://github.com/ofri-peretz/burgee/pull/474) [`1955419`](https://github.com/ofri-peretz/burgee/commit/19554194342b55f8893161f894a8c2a4df1b0f21) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - burgee plugins can hook two more stages. `parse` runs before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin maps `d` to `deploy`. `shutdown` runs once as the program exits, whether the command succeeded or failed. The family `schema.json` shipped in every package now describes both stages. - [#522](https://github.com/ofri-peretz/burgee/pull/522) [`f4be6a8`](https://github.com/ofri-peretz/burgee/commit/f4be6a8733e338bea4483992edeaa72fcddd36fd) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `require('bellpull/cross-spawn')`, `require('flagstaff/cli-table3')`, `require('burgee/yargs')`, `require('seniority/dotenv')` and `require('seniority/rc')` now return what the incumbent's `require()` does — the function, the class, the factory, the object — instead of an ES module namespace. Each exports its default as `'module.exports'`, which is what Node hands a CommonJS caller, and which yargs' own entry already does. `const spawn = require('…'); spawn(…)` threw before. ## 0.3.1 ### Patch Changes - [#508](https://github.com/ofri-peretz/burgee/pull/508) [`1aae1e2`](https://github.com/ofri-peretz/burgee/commit/1aae1e2186ce88421067df5317795773419e53d0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - ` --help` and `--version` answer instead of crashing. The bin took its first argument as the plugin file to import, so `roundel --help` failed with `Cannot find module '…/--help'` and exit 1. `-h`/`--help` now print usage and exit 0, `-V`/`--version` print the version and exit 0, and any other flag where the plugin file belongs is a usage error, exit 2. ## 0.3.0 ### Minor Changes - [#507](https://github.com/ofri-peretz/burgee/pull/507) [`b8e97dc`](https://github.com/ofri-peretz/burgee/commit/b8e97dcb64772e413f0b6f9e17e063c73314d242) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Runs on Node 20 and 22, not just 24+: `engines.node` is now `^20.19.0 || >=22.13.0`. Those are the first releases where `require(esm)` loads without a warning, so the CommonJS `require()` path keeps working. Every package's test suite runs on exactly 20.19.0 and 22.13.0, on Linux, macOS and Windows. caique's prompts no longer call `Promise.withResolvers`, which Node 20 doesn't have. ### Patch Changes - [#505](https://github.com/ofri-peretz/burgee/pull/505) [`9800b43`](https://github.com/ofri-peretz/burgee/commit/9800b43d9c74a49dfb66d04a40fd0d1c48892e20) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Docs: the Benchmarks section's weight ceiling is re-measured against a fresh install of each incumbent's latest release (cosmiconfig 10.0.1, slice-ansi 9.0.1, which 7.0.0, dotenv 18.0.3, …) instead of the copies hoisted in this workspace, and names incumbents that were measured but left out of the ceiling as exactly that. ## 0.2.3 ### Patch Changes - [#494](https://github.com/ofri-peretz/burgee/pull/494) [`f7f6d4b`](https://github.com/ofri-peretz/burgee/commit/f7f6d4b8e8f9d9c7010bd4c81fda4b4d106fc9f0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each package's `homepage` and README docs link now point at its own documentation site, `https://.interlace.tools`, instead of a page on burgee's site. The old `burgee.interlace.tools/docs/packages/` URLs answer with a 301 to the new host, so nothing already linked breaks. closeout's README override example also resolves to the current release again (`npm:closeout@^0.4`; the 0.4.0 release left it at `^0.3`). ## 0.2.2 ### Patch Changes - [#465](https://github.com/ofri-peretz/burgee/pull/465) [`acf98f3`](https://github.com/ofri-peretz/burgee/commit/acf98f3e612c6d79e6c2b78a847abcd06a063cbc) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README now opens with the incumbent it replaces and the agent surface it serves (`--json`, an agent event, or a static projection), so npm shows both above the fold. README text only; no code changed. ## 0.2.1 ### Patch Changes - [#454](https://github.com/ofri-peretz/burgee/pull/454) [`b4584e7`](https://github.com/ofri-peretz/burgee/commit/b4584e719bc0064b294aab5ea6da1c11f698f0e9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package's npm `homepage` now points at its page on the docs site, `https://burgee.interlace.tools/docs/packages/`, and each README links it under the header. The keywords add what people and models search for: `burgee` gains `cli-framework`, `argument-parser`, `subcommands`, `json-schema`, `mcp-server`, `model-context-protocol`, `ai-agent`, `llm`, `shell-completion`, `typescript`, `zero-dependency`, `commander-alternative` and `yargs-alternative`; the other eight gain `agent`, `ai-agent`, `non-tty`, `json` and `zero-dependency` where the package does that — `zero-dependency` only on the six that install nothing at all. `burgee`'s README gains a short FAQ (commander alternative, agent use, MCP, dependencies) and states the compatibility counts the oracle holds — 1,360 / 1,360 of commander's tests and 804 / 804 of yargs' — where it had said 1,215 and 1,185. `caique`'s README no longer calls a released package pre-release. - [#442](https://github.com/ofri-peretz/burgee/pull/442) [`bdaf364`](https://github.com/ofri-peretz/burgee/commit/bdaf364f81564c1700cf1adec18f927afe6c60c9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now lists `plugin`, `plugins` and `extensible` in its npm keywords, because every package takes plugins through one shared contract. A plugin is a plain object, validated against the `schema.json` that ships in every package, and checked with the package's own `check` command. Each package reads its own key and ignores the rest, so one object can extend any subset of the family. The [plugins page](https://github.com/ofri-peretz/burgee/blob/main/apps/docs/content/docs/plugins.mdx) has a nine-layer example that every package's `check` accepts in CI. - [#435](https://github.com/ofri-peretz/burgee/pull/435) [`7888524`](https://github.com/ofri-peretz/burgee/commit/78885245eb292cd4a40541fe09382a198c9c45cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` now describes every plugin host in the family. The one schema each package ships as its plugin contract used to cover only four hosts: roundel's `tokens`, flagstaff's `glyphs`, `spinners`, `borders` and `components`, paratext's `capabilities`, and linegauge's `widths`. Five hosts validated their keys in their own code, but the file an author (or a model) writes against said nothing about them. It now describes all of them: - bellpull `resolvers`, including the absolute-path rule on `paths` - caique `widgets` - closeout `handlers`, including the phases a plugin may use - seniority `sources`, including the rank bounds - burgee `commands`, `hooks` and `enforce` Where the schema can express a rule, it gives the same verdict as the host's own validator, and a test holds the two together. Function-valued fields (`static`, `run`, `read`, `handler`) are described and required, but not typed, because JSON Schema can't say "function". **flagstaff** now validates a plugin against only its own keys, not the whole family schema. It no longer refuses a plugin over another host's key, which lets one plugin object contribute to several hosts. Its entry points are also 4.7–5.9 KB lighter for it. - [#445](https://github.com/ofri-peretz/burgee/pull/445) [`dac303e`](https://github.com/ofri-peretz/burgee/commit/dac303e944e889ac4175ac38c94e4ca0f0ca5358) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now declares `sideEffects` truthfully, so bundlers can drop what you don't import. Six packages declared nothing, so no bundler could drop any of their modules. A named import from the root now bundles to the same bytes as the same import from its subpath: | import | before | after | | :------------------------------------ | ------: | ------: | | `import { explain } from 'seniority'` | 2,939 B | 1,067 B | | `import { decide } from 'caique'` | 1,235 B | 734 B | | `import { strip } from 'linegauge'` | 1,102 B | 940 B | | `import { once } from 'closeout'` | 353 B | 235 B | flagstaff and roundel used to declare `false`, but each ships a `check` command whose file runs when loaded. Each now lists that file, which is the true statement. paratext also lists the two modules that register its built-in capabilities when they load. ## 0.2.0 ### Minor Changes - [#421](https://github.com/ofri-peretz/burgee/pull/421) [`db3c59e`](https://github.com/ofri-peretz/burgee/commit/db3c59e3dcd373c7e6e4a057715adb173766523f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every plugin host has a `check` command. ```bash npx linegauge check ./my-widths.mjs npx burgee check ./my-plugin.mjs --json ``` PRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a **`check` command that shows it every way it can be seen**, and the bar is measured. The first was built in all nine hosts; the second existed in `flagstaff` alone. So an author writing a plugin for any other host found out what it did by shipping it into a program — and a surface nobody can check is a surface nobody outside this repository can write against. Each command validates, registers, and shows what the host does with the plugin, in the host's own terms: linegauge measures each code point **before and after** the override, paratext shows a capability's `encode` **and** its `fallback`, roundel each token and what it replaced, caique each widget's static projection rendered with its own sample. burgee's returns a **document** rather than printing one, so `burgee check --json` is the form an agent that just wrote a plugin reads. They share one contract with the author, held identically across all nine: - a readable report, contribution by contribution, with **`ok` as the last line**; - a refusal with a code from the family's vocabulary and a `fix`, exit 1; - **`E_NO_CONTRIBUTION`** for a plugin that contributes nothing to this host — the schema allows unknown keys so one object registers everywhere, which makes a misspelled key silent, and this is how that typo tells on itself; - exit 2 with no file. Each host also gains an eval case measuring the one-turn claim, proved to discriminate before it was committed: green against a correct plugin, red against the same plugin with one field broken. ### Patch Changes - [#415](https://github.com/ofri-peretz/burgee/pull/415) [`47b541a`](https://github.com/ofri-peretz/burgee/commit/47b541ade1977e781968bdfb94a41c2bd990b203) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The matrix suite no longer leaks a `SIGTERM`-ignoring child when a hook times out. Two of its fixtures are pinned open by a `setInterval` and one declines `SIGTERM` on purpose — it is the control case for R2, the whole reason `startDeadline` has a second rung. That rung is a timer in the _parent_, so a parent that dies first never fires it: when a hook in the file timed out, vitest tore the worker down and left the child spinning. One was found hours later with its temp directory already deleted out from under it. The leak paid for itself in the wrong direction. Every timed-out hook left a process that made the next timeout likelier, which is why this suite read as merely load-sensitive (D-091) and degraded over a long session rather than flaking at random. Both fixtures now carry a 60-second self-limit — far longer than any case here, whose deadlines are in the hundreds of milliseconds, and far shorter than _until the machine is rebooted_. The two `afterAll` teardowns get the 60 s budget every other filesystem- and process-touching hook in the file already had. `fixture-lifetime.test.ts` reads the fixture sources and refuses a long-lived one without its own limit; it fails on the unfixed file. No change to `bellpull`'s published behaviour — this is the suite, not the package. - [#430](https://github.com/ofri-peretz/burgee/pull/430) [`4d1b2b3`](https://github.com/ofri-peretz/burgee/commit/4d1b2b399cff354864d1e2e843a19fde80ef1f30) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` now reports every refusal with its code and its fix, wherever it was raised. Some plugin files register themselves on import: they call `register()` at the top of the module and export the result. Until now, when such a file was refused, the error was thrown inside `check`'s `import()`, before the only `try` that turns a `PluginError` into `E_PLUGIN_SCHEMA: …` plus a `fix:` line. The author got the bare message on stderr, with no code and no fix. Now the whole of `check` runs inside that one handler, so every refusal comes out the same way on every host. ## 0.1.1 ### Patch Changes - [#373](https://github.com/ofri-peretz/burgee/pull/373) [`a1f1d40`](https://github.com/ofri-peretz/burgee/commit/a1f1d40b7d1e7244f3a180943b641bb3b491d256) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Stage 2's artifact is now `spec.md`, the name Anthropic's AI-Native SDLC playbook gives it, so the source comments and README sections that cite a package's own design document point at `spec.md` rather than `design.md`. No behaviour changes. The published tarballs do move, by two bytes per surviving reference — `design.md` is nine characters and `spec.md` is seven — so the four packages carrying a weight band were re-measured against it: linegauge 83,538 to 83,536; paratext 66,343 to 66,341; closeout 84,455 to 84,453; bellpull 86,113 to 86,107. ## 0.1.0 ### Minor Changes - [#332](https://github.com/ofri-peretz/burgee/pull/332) [`3ea38c3`](https://github.com/ofri-peretz/burgee/commit/3ea38c363b9f0cee9f1c1c2dae4037b047e98328) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `bellpull` was a reserved name exporting one string. It now runs programs. `run(cmd, args, { runtime })` returns a `Result` — `{ ok, code, signal, stdout, stderr, duration, command, args, executable, timedOut }` — for **every** outcome a process can have. A non-zero exit resolves with `ok: false`; the promise rejects only where no process ran, which is a missing executable or a failed spawn. `execa` throws on a non-zero exit, so every caller wraps it and every wrapper rebuilds these same fields out of the error; this repository already had two of those, in `compat-oracle/src/run.ts` and `vendor.ts`, both reading a result through a `catch`. `timeout` is finite by default (30 s) and the kill is a ladder — `SIGTERM`, then `SIGKILL` after a grace window — because a child that traps `SIGTERM` makes a single-signal timeout a timeout that times nothing out. Output written before the kill is kept: a CI timeout with the output discarded is undiagnosable. `bellpull/which` resolves an executable and reports **which `PATH` entry answered**, which is the open position in this layer — `which` + `isexe` + `path-key` is 779 M downloads a week across three packages and none of them returns it, and neither zero-dependency rival in the spawn layer resolves at all. Two `PATH` entries are refused rather than searched: an empty one, which POSIX reads as the working directory and is the oldest privilege-escalation trick there is, and a relative one, whose meaning changes with wherever the program was run from. `searchPath()` reports each skip with its reason rather than quietly doing less than its author expected. `bellpull/cross-spawn` is the drop-in, graded **68 / 68** by `cross-spawn`'s own vendored suite against a control of 68 / 68 on the same machine (macOS, 2026-09-15). On POSIX it is a pass-through, because `cross-spawn` is one — everything it is famous for is Windows-only, and a façade that "improved" on the pass-through would change the error a caller sees and the process tree. The Windows half invokes `cmd.exe` itself with `windowsVerbatimArguments`, having quoted each argument for `CommandLineToArgvW` and caret-escaped every `cmd.exe` metacharacter. It never sets `shell: true` to solve a Windows problem; `shell` is available, off by default, and documented as the injection surface it is. `bellpull/plugin` hosts **`resolvers`** (`plugin-contract` R5a, PLAN 1.5) — how an executable is found, since `which` is the part every environment does differently. A resolver is `{ rank, paths, extensions?, when? }` and carries **no function at all**, so it survives JSON and a `plugin check` can print a search order without running anything; `{VAR}` in a path is substituted from the environment, the way paratext templates an OSC payload. A path that is not absolute after substitution is refused at `register()`, because a resolver's directories are searched ahead of `PATH` and a relative one means a different directory every time the program runs from somewhere else. Ships `bellpull/schema.json`, byte-identical to flagstaff's (R2). Zero dependencies on every entry point, and `bellpull/which` is a leaf that loads two files. Installed, tree-inclusive: 82,270 bytes against a ceiling of 714,984 for `execa` + `cross-spawn` + `which` — a ratio of 0.1151, up from 0.0067 when the package did nothing, which is the honest direction. Not built, and said rather than implied: `execa`'s streaming API and its template-literal form are out of scope, and no number is claimed against `tinyexec`, which is not installed in this workspace. ### Patch Changes - [#332](https://github.com/ofri-peretz/burgee/pull/332) [`3ea38c3`](https://github.com/ofri-peretz/burgee/commit/3ea38c363b9f0cee9f1c1c2dae4037b047e98328) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `bellpull/cross-spawn` reads `spawn` off the default import instead of a captured named binding, so a consumer under `commander`'s own mocks sees the mock. This is a compatibility requirement, not a style. `commander`'s suite does `t.mock.method(childProcess, 'spawn', …)` in roughly 23 `executableSubcommand` cases. A named ESM binding is captured at import and never re-syncs without `syncBuiltinESMExports()`, so a consumer calls the real thing instead: measured, wiring `burgee/commander` to this package took it from **1360 / 1360 to ungradeable** — an un-mocked spawn ran a real subcommand whose exit killed the test runner. A property read off the default import sees it. `cross-spawn` stays **68 / 68** either way, so the change costs this package nothing and is the whole blocker between bellpull and its first consumer. `ChildProcess` and `SpawnSyncReturns` are re-exported because a consumer that declared them itself would trip `inline-implementation-lock`, which matches a type-only `node:child_process` import too. - [#332](https://github.com/ofri-peretz/burgee/pull/332) [`3ea38c3`](https://github.com/ofri-peretz/burgee/commit/3ea38c363b9f0cee9f1c1c2dae4037b047e98328) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `bellpull` failed on Windows — the one platform it exists for. Twenty-five cases red on `Node 24 · windows-latest`, and it was the only package failing there. One of them was a real defect; the rest were tests that had only ever described POSIX. **The defect.** `run()` resolved the command with a single `PATHEXT` walk while `parse()` used two — the second with expansion disabled, which is how a file with no extension and a `#!` line is found at all. So on Windows a shebang script resolved for the parse and was then refused by `run()` with `NotFoundError`: a command the package had already worked out how to run, rejected by the half that runs it. The walk is now one exported function, `resolveExecutable`, and both callers use it. Off Windows `PATHEXT` expansion is always `['']`, so the second attempt is the first and the divergence could not show up — which is how it survived a 68 / 68 grading against `cross-spawn`'s own suite. **The tests.** `which.test.ts` built a `platform: 'linux'` runtime and then fed it `mkdtemp` paths from the host, so on Windows it joined `C:\…\a` and `C:\…\b` with `:` and read two drive letters as two relative entries. Policy cases (search order, refusals, `PATHEXT`, `runPath`) now take invented runtimes and are pure functions of them; filesystem cases take a runtime describing the actual machine, with fixtures named `tool.CMD` on Windows so they are executable under that platform's own rule. Both branches of `executableByName` now run somewhere. `matrix.test.ts` joined its fixture `PATH` with `:` under a `process.platform` runtime; it uses `node:path`'s `delimiter`. `escape.test.ts` asserted that `x; touch ` fires through `shell: true` — on Windows there is no `touch` and `;` separates nothing, so the vector is now written for each platform's own shell, and a Windows-only block runs a hostile argument end-to-end through a real `node_modules/.bin/*.cmd` shim, which is the first execution of the double-escape path anywhere. **`weight.test.ts` was the joke.** It measured the package with `execFileSync('npm', …)`, and on Windows `npm` is `npm.cmd`, which Node has refused to spawn without `shell: true` since the fix for CVE-2024-27980. The weight lock of the package that exists because `npm` is `npm.cmd` broke on `npm` being `npm.cmd`. It now spawns through this package's own `parse`. **The kill ladder is proven without a clock.** The end-to-end cell handed `run()` a 300 ms deadline and asserted `SIGKILL`, but the stubborn child installs its `SIGTERM` handler on its first executed line — so a deadline shorter than `node`'s cold start kills it by the default action and the run truthfully reports `SIGTERM`. One cold start in twelve took 265 ms on an idle Mac, and macOS CI went red on exactly that. `startDeadline` is exported and takes a structural `Killable`, so both rungs are now asserted on fake timers — which signal at which tick, with no process and no scheduler. The end-to-end cells calibrate their deadline against a cold start measured on the runner they are running on, and each asserts the child's own output first, because that line is the proof it was armed. `newLine: 'lf'` is pinned for the build, so `npm pack`'s byte count is a fact about the package rather than about the operating system that compiled it. Weight moved 82,141 → 85,129 B against the same 714,984 B ceiling, ratio 0.1149 → 0.1191. - [#339](https://github.com/ofri-peretz/burgee/pull/339) [`f295630`](https://github.com/ofri-peretz/burgee/commit/f2956301d5f9dcbcac0b001b00ebaf0315891fac) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` constrains token names, because it was promising something no host honours. `tokens` was described as any name to a `#rrggbb` colour. `roundel`'s `validate()` accepts ten semantic names — `error`, `warn`, `ok`, `hint`, `muted`, `command`, `flag`, `value`, `heading`, `ground` — and throws on everything else. So a plugin author doing exactly what their own `E_PLUGIN_SCHEMA` error tells them, comparing their object against `roundel/schema.json`, got a green from the schema and `"accent" is not a token` from `register()`. Measured 2026-09-16 with `{ accent: '#336699' }`. The schema now carries `propertyNames.enum`, and `scripts/plugin-contract-lock.test.ts` pins the enum and the runtime set to each other from both sides, so neither can grow a name the other does not know. Every host ships a byte-identical copy of this file (`plugin-schema-lock.test.ts` asserts it), which is why nine packages are listed. Only the key `roundel` owns is constrained: describing `widgets`, `handlers`, `sources`, `resolvers` or `commands` in a file all eight hosts share is what made _flagstaff_ start validating caique's key last time (`PluginError: plugin.widgets.later: expected object, got boolean`), and those stay in `plugin-schema-lock`'s `UNDESCRIBED` list with that reason. `linegauge` is in the list for a different change: `ceilings.json`'s R9 block now records the bar as D1's tree-inclusive ceiling — 83,538 against 170,342, a ratio of 0.4904 — and keeps the superseded `get-east-asian-width` bar beside it with the count of entries that cleared it. --- # Coming from cross-spawn > A cross-spawn alternative with a drop-in path: import spawn from bellpull/cross-spawn, graded 68 / 68 by cross-spawn's own test suite — then zero dependencies, and a run() whose result says which binary ran and treats a non-zero exit as a value. Source: https://bellpull.interlace.tools/docs/coming-from/cross-spawn **bellpull** is a **cross-spawn alternative** you adopt by changing one import. `bellpull/cross-spawn` is cross-spawn's API — the callable default and `.sync` — and cross-spawn's own test suite is the grade. ## Migrate from cross-spawn in one import ```diff - import spawn from 'cross-spawn'; + import spawn from 'bellpull/cross-spawn'; ``` Everything else stays: `spawn(command, args?, options?)` returns a `ChildProcess`, `spawn.sync(command, args?, options?)` returns what `spawnSync` returns, and `_parse` and `_enoent` hang off the default as they do on cross-spawn. A missing command is reported the way cross-spawn reports it — an `ENOENT` error event, or `result.error` from `.sync`. The CommonJS form works too, on the Node versions bellpull supports: ```diff - const spawn = require('cross-spawn'); + const spawn = require('bellpull/cross-spawn'); ``` ## Is bellpull compatible with cross-spawn? Graded, not claimed. cross-spawn's own suite, vendored at 7.0.6 and unmodified apart from the import specifier, runs against `bellpull/cross-spawn` beside a control that runs it against real cross-spawn: | | passing | rate | | :-- | --: | --: | | `bellpull/cross-spawn` | 68 / 68 | 100.0% | | cross-spawn itself (control) | 68 / 68 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. The suite runs each case four ways — `spawn`, `spawn` with a forced shell, `sync`, and `sync` with a forced shell — so a divergence in one path cannot hide behind the other three. What 68 / 68 does not cover: on POSIX cross-spawn is a pass-through, so its suite never reaches the Windows escaping or the `cmd.exe` branch. That half is covered by bellpull's own tests, which round-trip a 37-vector injection corpus through simulations of both Windows parsers. ## What you gain over cross-spawn `bellpull/cross-spawn` is deliberately cross-spawn's behaviour — on POSIX, being identical is the product. The façade changes the dependency tree; the gain is the rest of the package, which you adopt a call site at a time: - **Zero dependencies.** cross-spawn 7.0.6 pulls `path-key`, `shebang-command` and `which`. - **A non-zero exit is a value.** `run()` from `bellpull` resolves with `result.ok === false` and the code; it rejects only when no process ran. - **It says which binary ran.** `result.executable` is the path *and* the `PATH` entry it came from — the first question of every "works on my machine" investigation. - **Bounded by default.** `timeout` is finite, the kill is a ladder (`SIGTERM`, then `SIGKILL`), and the output written before the kill is kept. - **One result, three readers.** `format()` for a person, `toJson()` for `--json`, `toEvent()` for an agent stream, all derived from one record. ```js import { run } from 'bellpull'; const runtime = { platform: process.platform, env: process.env, cwd: process.cwd() }; const result = await run('npm', ['test'], { runtime }); // result.ok, result.code, result.executable — no try/catch ``` ## When to switch from cross-spawn - You want cross-spawn's Windows handling without its dependency tree. - Your callers, human or agent, need a subprocess's outcome as text and as JSON. If you need a raw `ChildProcess` and nothing more, the one-import swap is the whole migration. The entry points and the `resolvers` plugin host are on [bellpull](/docs). --- # Coming from execa > An execa alternative with zero dependencies: bellpull's run() treats a non-zero exit as a value, not an exception, says which binary ran, and renders one result as text, --json or an agent event. Not an execa drop-in: execa's own suite grades it 0 / 1179, and that zero is the design. Source: https://bellpull.interlace.tools/docs/coming-from/execa **bellpull** is an **execa alternative** for running subprocesses: executable resolution, a bounded run and a structured result every caller can read. It is **not** an execa drop-in — read this page before you plan a one-line swap. ## Migrate from execa: not one import There is no `bellpull/execa`. execa's streaming API and its `` $`cmd` `` template form are a different product and are out of scope, so moving is a rewrite of each call site, not of the import line: ```diff - import { execa } from 'execa'; - const { stdout } = await execa('git', ['rev-parse', 'HEAD']); + import { run } from 'bellpull'; + const runtime = { platform: process.platform, env: process.env, cwd: process.cwd() }; + const result = await run('git', ['rev-parse', 'HEAD'], { runtime }); + if (result.ok) console.log(result.stdout); ``` The one-import path bellpull does ship is for **cross-spawn**, the other spawner in this layer: ```diff - import spawn from 'cross-spawn'; + import spawn from 'bellpull/cross-spawn'; ``` Same callable default, same `.sync`. ## Is bellpull compatible with execa? **No — and that is measured, not just stated.** The [Compatibility](https://burgee.interlace.tools/docs/compatibility) page, generated by `npm run compat:page` from the oracle's last run, grades execa's own suite against `bellpull` and publishes the result as a **ceiling**, not as a drop-in on its way to 100%: | | passing | rate | | :-- | --: | --: | | `bellpull` against execa 10.2.1's suite | 0 / 1360 | 0.0% | | execa itself (control) | 1360 / 1360 | 100.0% | | `bellpull/cross-spawn` against cross-spawn's suite | 68 / 68 | 100.0% | | cross-spawn itself (control) | 68 / 68 | 100.0% | The 1360 are execa's `arguments/`, `methods/` and `return/` directories — how a command is built, the entry points, and the result and error a caller reads — less one case, `write to fast-exit subprocess`, which runs a compiled executable execa ships beside its tests. The oracle never vendors a binary, so that case is excluded for execa and bellpull alike, and the count is the same on every machine. Every file imports `execa`, which bellpull does not export, so every one fails before a case runs; the other nine directories are vendored too and read the same zero, and are left out of the recurring grade only because the whole suite takes eleven minutes to run. bellpull's README records what that 68 / 68 does not cover: on POSIX cross-spawn is a pass-through, so its suite never reaches the Windows escaping or the `cmd.exe` branch. That behaviour is covered by bellpull's own tests. ## What you gain over execa - **A non-zero exit is a value.** execa throws when a child exits non-zero, so every caller wraps every call. `run()` resolves with `result.ok === false` and the code; it rejects only when no process ran — the executable did not resolve, or the spawn failed. - **It says which binary ran.** `result.executable` is the path *and* the `PATH` entry it came from — the first question of every "works on my machine" investigation. - **Bounded by default.** `timeout` is finite, the kill is a ladder (`SIGTERM`, then `SIGKILL`), and the output written before the kill is kept. - **One result, three readers.** `format()` for a person, `toJson()` for `--json`, `toEvent()` for an agent stream — all derived from one record, so a `--json` flag cannot report something the human output did not. - **No shell, ever, to solve a Windows problem.** `.cmd` files run through a quoted `cmd.exe` invocation, not `{ shell: true }`, which would re-open command injection. ## When to switch from execa - Your callers, human or agent, need the same result as text and as JSON. - You are tired of `try`/`catch` around every subprocess to read its exit code. If you depend on execa's streaming or its template syntax, stay on execa. The entry points and the `resolvers` plugin host are on [bellpull](/docs). --- # Coming from which > A which alternative with a drop-in path: import which from bellpull/node-which, graded 5 / 5 by node-which's own test suite — then zero dependencies, and a resolver that returns the PATH entry the binary came from. Source: https://bellpull.interlace.tools/docs/coming-from/which **bellpull** is a **which alternative** — for npm's `which` package, node-which — that you adopt by changing one import. `bellpull/node-which` is node-which 7's API, and node-which's own test suite is the grade. ## Migrate from which in one import ```diff - import which from 'which'; + import which from 'bellpull/node-which'; ``` Everything else stays: `which(cmd, options?)` returns a promise, `which.sync(cmd, options?)` returns the answer, and the options are node-which's — `all`, `nothrow`, `path`, `pathExt`, `delimiter`. A miss throws node-which's error, `not found: ` with `code: 'ENOENT'`, or returns `null` under `nothrow`. So do its search rules: a command with a slash is checked as given, Windows searches the working directory first and tries `PATHEXT`'s extensions, and a quoted `PATH` part is unquoted. Two things are not carried. node-which's `which` command-line binary is not shipped — this is the library. And `which()` runs the same search as `which.sync()` and hands it back as a promise, rather than walking the filesystem asynchronously. Mind the neighbouring subpath: **`bellpull/which` is not the drop-in.** It is bellpull's own resolver, with a different shape — see below. ## Is bellpull compatible with which? Graded, not claimed. node-which's own suite, vendored at 7.0.0 and unmodified apart from the import specifier, runs against `bellpull/node-which` beside a control that runs it against real node-which: | | passing | rate | | :-- | --: | --: | | `bellpull/node-which` | 5 / 5 | 100.0% | | which itself (control) | 5 / 5 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority, and its front-end column reads `bellpull` because the row's import is composed onto the package root as `bellpull/node-which`. Five is the suite's count of top-level tests; each runs its assertions under both `posix` and `win32` and through both `which()` and `which.sync()`. The suite's `test/bin.js`, which spawns node-which's CLI, is not graded, because the CLI is not shipped. ## What you gain over which - **Zero dependencies.** which 7.0.0 depends on `isexe`. - **The PATH entry that answered.** `bellpull/which` returns where the binary is *and* which `PATH` entry found it — the fact every "two `node`s on this machine" investigation starts by reconstructing with `echo $PATH`. - **An empty `PATH` entry is not the current directory.** POSIX reads `/usr/bin:` as including `.`; bellpull's resolver skips empty and relative entries, and `searchPath()` lists each one with the reason. The drop-in keeps node-which's behaviour here, so this is a reason to move to the resolver. - **The answer is always absolute**, and the resolver reads no global: the environment is an argument, so a test hands it a `PATH` rather than mutating `process.env`. ```js import { whichSync, whichAllSync } from 'bellpull/which'; const runtime = { platform: process.platform, env: process.env, cwd: process.cwd() }; whichSync('node', { runtime }); // { path: '/opt/homebrew/bin/node', from: '/opt/homebrew/bin', ext: '' } whichAllSync('node', { runtime }); // every match, best first ``` ## When to switch from which - You want node-which's answers without its dependency. - A build differs between two machines and you need to know which binary each one found. The resolver, the `run()` it feeds and the `resolvers` plugin host are on [bellpull](/docs). --- # 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 / 1360 and is declared a ceiling, not a drop-in. Source: https://bellpull.interlace.tools/docs/drop-ins `bellpull/cross-spawn` and `bellpull/node-which` are graded, not described as compatible. Each is run against its incumbent's **own test suite**, by [compat-oracle](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/README.md), in CI. execa's suite is run too, to measure the answer rather than state it. ✓ 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. ### 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,360 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) | The family's [compatibility page](https://burgee.interlace.tools/docs/compatibility) is generated from the oracle's last run and is the authority for the current figures. ## How a suite is graded 1. The incumbent's repository is cloned at the release tag of the graded version — cross-spawn 7.0.6, which 7.0.0, execa 10.2.1 — and its test directory copied into `packages/compat-oracle/vendor/`, with a `PROVENANCE` file naming the tag, the commit and the command that reproduces it. 2. The only edit is the import that reaches the library, rewritten to a shim generated per run. 3. A **control run** points the shim at the real incumbent first; its total is what every rate is measured against. 4. The **target run** points the same shim at bellpull. ## What each grade covers - **cross-spawn — 68 of 68.** Every case runs four ways — `spawn`, `spawn` with a forced shell, `sync`, and `sync` with a forced shell. On POSIX cross-spawn is a pass-through, so the suite never reaches the Windows escaping or the `cmd.exe` branch: replacing the escaper with `` arg => `"${arg}"` `` — the injection bug in its purest form — leaves the row at 68 / 68. That half is covered by bellpull's own tests ([Windows](/docs/guides/windows)). - **which — 5 of 5.** node-which 7's `test/index.js`, against `bellpull/node-which`. Five is the suite's count of top-level tests, each run under `posix` and `win32` and through both `which()` and `which.sync()`. `test/bin.js` grades node-which's command-line tool, which bellpull does not ship, and is not graded. - **execa — 0 of 1,360, a ceiling.** The 1,360 are execa's `arguments/`, `methods/` and `return/` directories, less one case that runs a compiled executable the oracle does not vendor, excluded for execa and bellpull alike. Every file imports `execa`, which bellpull does not export, so each dies before a case runs; the other nine directories read the same zero and are left out of the recurring grade because the whole suite takes about eleven minutes. Declaring the row a ceiling keeps `burgee migrate` from ever rewriting an execa import to bellpull. ## Known differences - **`bellpull/node-which` is synchronous underneath.** `which()` runs the same search as `which.sync()` and returns it as a promise, rather than walking the filesystem asynchronously. - **`bellpull/which` is not the drop-in.** It is bellpull's own resolver: it returns `{ path, from, ext }` rather than a string, and skips empty and relative `PATH` entries. - **`bellpull/cross-spawn` on Windows hands `cmd.exe` the command as written**, exactly as cross-spawn does, so `cmd.exe` resolves a bare name again in the child's environment. Building the line from the resolved path would be a divergence from cross-spawn that its suite could not see; it is recorded in `run.ts` rather than quietly changed. ## Moving one import ```diff - import spawn from 'cross-spawn'; + import spawn from 'bellpull/cross-spawn'; ``` ```diff - import which from 'which'; + import which from 'bellpull/node-which'; ``` `npx burgee migrate --dry-run` lists every import it would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` makes the change. [Incremental migration](/docs/recipes/incremental-migration) moves a call site from the drop-in to `run()`. --- # FAQ > Short answers about bellpull — execa, non-zero exits, the default timeout, shells, Windows, which binary ran, plugins, CommonJS and what is not built — each with where to read more. Source: https://bellpull.interlace.tools/docs/faq ## Is bellpull a drop-in for execa? No. execa's own suite grades it 0 of 1,048, and the row is declared a ceiling so no tool rewrites an execa import to bellpull. `run()` is the alternative, and moving to it is a rewrite of each call site: [Coming from execa](/docs/coming-from/execa). ## Why doesn't `run()` throw when the command fails? Because a failed command is an outcome, not an error in your program. `run()` resolves with `ok: false`, the code and the output, and rejects only when no process ran — the command did not resolve, or the spawn failed. [Running a program](/docs/guides/running). ## My long-running child was killed after 30 seconds. That is the default deadline. Pass `timeout` with a longer bound, or `timeout: 0` for a child that is meant to run until you stop it. [Deadlines](/docs/guides/deadlines). ## Does it use a shell? Not unless you pass `shell: true`, which is documented as the injection surface it is. On Windows, `.cmd` files are run through `cmd.exe` invoked by bellpull itself with every argument escaped. [Windows without a shell](/docs/guides/windows). ## How do I find out which binary ran? `result.executable` is `{ path, from }`: the file, and the `PATH` entry that found it. `whichAllSync` lists every match, best first. [Which binary ran?](/docs/recipes/which-binary-ran) ## Why does bellpull skip an empty entry in my PATH? Because to a POSIX shell it means the current directory, which changes with wherever the program is run from. `searchPath()` shows each skipped entry and why. The drop-in, `bellpull/node-which`, keeps node-which's behaviour and searches it. ## Are children killed when my program exits? Only if you pass an `exitHost` — closeout's registry, or anything with the same `add(handler)` shape. bellpull does not install signal handlers on its own. execa does this by default; this is one place it is ahead. [Deadlines](/docs/guides/deadlines#when-the-parent-shuts-down). ## Do resolver plugins change what `run()` finds? Not on their own. `directories(runtime)` gives the contributed search order as data, and you splice it into `PATH` for the call. [Resolver plugins](/docs/guides/plugins#using-the-directories). ## Can I use it from CommonJS? Yes, on Node 22.13+ through `require(esm)`; `shape.test.ts` installs the packed tarball and does exactly that. `require('bellpull/cross-spawn')` returns the callable default, as `require('cross-spawn')` does. ## Is bellpull faster than tinyexec? Nobody knows yet: the spawn-time comparison has no benchmark, and this site makes no speed claim. The bytes comparison is measured: `run` bundles to 5,901 B against tinyexec's 5,969 B. --- # Getting started > Install bellpull, run a program, and read one result back: whether it succeeded, its output, and which binary on PATH actually ran — no try/catch. Source: https://bellpull.interlace.tools/docs/getting-started bellpull runs another program and hands back one value you can read: whether it succeeded, its exit code or signal, its output, how long it took, and **which executable ran and which `PATH` directory it came from**. A non-zero exit is part of that value, not an exception. ## Install ```bash npm install bellpull ``` It has no dependencies. It is ESM with a `default` condition, so `require('bellpull')` also works from CommonJS on Node 22.13+. ## The runtime Nothing in bellpull reads `process`. Every call takes a **runtime** — the platform, the environment and the working directory — so resolution is a function of its arguments, and a test can hand it a `PATH` instead of editing `process.env`. For the real process, `ambientRuntime()` builds one: ```js import { ambientRuntime } from 'bellpull'; const runtime = ambientRuntime(); // { platform, env, cwd, uid, gid } ``` Every example on this site runs against a small fixture: two installs of one tool, `greet`, in two directories, the situation behind every "works on my machine". `tools.mjs` makes them, and puts the directories you name in front of `PATH`: ```js title="tools.mjs" import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { ambientRuntime } from 'bellpull'; /** Two installs of one tool, `greet`, in two directories — the shape of every "which one ran?". */ export function install() { for (const dir of ['bin', 'vendor/bin']) { mkdirSync(dir, { recursive: true }); const file = join(dir, 'greet'); writeFileSync(file, `#!/usr/bin/env node\nconsole.log('hello from ${dir}');\nprocess.exitCode = Number(process.argv[2] ?? 0);\n`); chmodSync(file, 0o755); } } /** The real process's runtime, with `dirs` in front of PATH. */ export function runtimeWith(...dirs) { const base = ambientRuntime(); const path = [...dirs.map((d) => join(base.cwd, d)), base.env.PATH].join(':'); return { ...base, env: { ...base.env, PATH: path } }; } ``` ## A first run ```js title="first.mjs" import { relative } from 'node:path'; import { run } from 'bellpull'; import { install, runtimeWith } from './tools.mjs'; install(); const runtime = runtimeWith('bin', 'vendor/bin'); const result = await run('greet', ['3'], { runtime }); console.log(result.ok, result.code, result.stdout.trim()); console.log('ran', relative(runtime.cwd, result.executable.path), 'found in', relative(runtime.cwd, result.executable.from)); ``` `greet 3` exits 3. The promise still resolves, with `ok: false` and the code, and the result says which of the two `greet`s ran: ```text title="node first.mjs" false 3 hello from bin ran bin/greet found in bin ``` Every output block on this site is checked: `tests/examples.test.ts` writes each titled file, runs the command in the block's title, and compares. ## The result | field | what it is | | :-- | :-- | | `ok` | `true` only for exit 0, inside the deadline | | `code` / `signal` | the exit code, or the signal that ended the child; one of them is `null` | | `stdout` / `stderr` | everything the child wrote, decoded as UTF-8 — kept even when it was killed | | `duration` | wall-clock milliseconds from spawn to close, Node's own scheduling included | | `command` / `args` | what you asked to run | | `executable` | `{ path, from }`: the file that ran, and the `PATH` entry that found it (`''` when you named a path) | | `timedOut` | whether the deadline killed it | `run()` **rejects** only when no process ran: the command did not resolve (`NotFoundError`, `code: 'ENOENT'`) or the spawn failed (`SpawnError`). Anything a process did, it resolves with. ## Where next - [Guides](/docs/guides/running): running, deadlines, resolution, Windows without a shell, one result for people and agents, resolver plugins. - [Why bellpull](/docs/why-bellpull): what it does that execa, cross-spawn and which do not — and where execa is ahead — cell by cell, with the evidence. - [Coming from cross-spawn](/docs/coming-from/cross-spawn) and [which](/docs/coming-from/which): change one import. [From execa](/docs/coming-from/execa) it is a rewrite, and the page says so. - [API reference](/docs/api): every export of every entry point. --- # 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. Source: https://bellpull.interlace.tools/docs/guides/deadlines A child that never exits strands whatever is waiting for it — a CI job until its own timeout, an agent forever. So every `run()` has a deadline unless the caller turns it off. | option | default | meaning | | :-- | :-- | :-- | | `timeout` | `DEFAULT_TIMEOUT`, 30 000 ms | how long before the child is asked to stop; `0` turns the deadline off | | `grace` | `DEFAULT_GRACE`, 5 000 ms | how long after `SIGTERM` before `SIGKILL` | ## The ladder, and what survives it On a breach the child gets `SIGTERM`. A child that ignores it gets `SIGKILL` once `grace` has passed. Either way the promise resolves, with `timedOut: true` — and **with the output the child wrote before it was killed**. A CI timeout with the output thrown away cannot be diagnosed. ```js title="stubborn.mjs" import { run } from 'bellpull'; import { runtimeWith } from './tools.mjs'; // A child that ignores SIGTERM, says one thing, and then never finishes. const stubborn = "process.on('SIGTERM', () => {}); console.log('partial output'); setInterval(() => {}, 1000);"; const result = await run(process.execPath, ['-e', stubborn], { runtime: runtimeWith(), timeout: 1000, grace: 200 }); console.log(result.ok, result.timedOut, result.signal, JSON.stringify(result.stdout)); ``` ```text title="node stubborn.mjs" false true SIGKILL "partial output\n" ``` A child that leaves when asked ends on the first rung: `timedOut` is still `true` and `ok` is still `false`, because a run that was cut short did not succeed. On Windows there is no deliverable `SIGTERM`: Node maps it to `TerminateProcess`, so the first rung already ends the child, and `signal` reads `SIGTERM`. ## Turning it off `timeout: 0` arms nothing. That is for a caller who means it — a watch process, a server the program supervises — and it is spelled out so it cannot happen by accident. ## When the parent shuts down A child still running when its parent exits is orphaned: it keeps its ports, its files and its CPU. bellpull does not claim the process's signals itself, because a library that installs signal handlers changes how every program that imports it dies. Instead, `run()` takes an **exit host** — anything with closeout's `add(handler) => unregister` — and while the child is alive, the host's shutdown kills it: ```js title="host.mjs" import { run } from 'bellpull'; import { install } from 'closeout'; import { runtimeWith } from './tools.mjs'; const { registry } = install(); const forever = "console.log('serving'); setInterval(() => {}, 1000);"; const pending = run(process.execPath, ['-e', forever], { runtime: runtimeWith(), exitHost: registry, timeout: 0 }); setTimeout(() => registry.run({ code: null, signal: 'SIGTERM' }), 1000); // the parent is told to stop const result = await pending; console.log(result.signal, JSON.stringify(result.stdout)); ``` ```text title="node host.mjs" SIGKILL "serving\n" ``` The handler is registered when the child starts and removed when it ends, so a long-lived parent does not collect one per run. Without an `exitHost`, nothing is registered. ## What is tested - [`matrix.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/matrix.test.ts): the ladder on a fake clock — nothing before the deadline, `SIGTERM` on it, `SIGKILL` after grace, both rungs stood down when the child settles; end to end, a child that ignores `SIGTERM` still settles with its partial output; `timeout: 0` opts out; with a host the child is killed on shutdown, and without one nothing is registered. --- # 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. Source: https://bellpull.interlace.tools/docs/guides/plugins `PATH` is the part every environment does differently: `nvm` and `asdf` put shims in a directory computed from an environment variable, `pnpm` places binaries where `npm` does not, a devcontainer mounts tools outside every `PATH` the host had. Each is the same search with a different list of directories, so a plugin contributes the list — as **data**. ```js title="asdf-plugin.mjs" import { contributions, directories, register } from 'bellpull/plugin'; register({ name: 'asdf', contract: 1, resolvers: { asdf: { rank: -10, paths: ['{ASDF_DATA_DIR}/shims'], when: { envAny: ['ASDF_DATA_DIR'] } }, }, }); console.log(JSON.stringify(contributions())); const runtime = { platform: 'linux', env: { ASDF_DATA_DIR: '/home/me/.asdf' }, cwd: '/home/me' }; console.log(JSON.stringify(directories(runtime))); console.log(JSON.stringify(directories({ ...runtime, env: {} }))); ``` ```text title="node asdf-plugin.mjs" [{"name":"asdf","from":"asdf","resolver":{"rank":-10,"paths":["{ASDF_DATA_DIR}/shims"],"when":{"envAny":["ASDF_DATA_DIR"]}},"shadowed":[]}] [{"dir":"/home/me/.asdf/shims","resolver":"asdf","rank":-10}] [] ``` | field | meaning | | :-- | :-- | | `rank` | where it sits relative to `PATH`: negative searches before it | | `paths` | absolute directories; `{VAR}` is substituted from the runtime's environment | | `when` | `platform` and `envAny` conditions; a resolver whose condition fails contributes nothing | A resolver with no function in it survives JSON, so an agent can write one and a tool can print the search order it contributes without running anything. A later plugin with the same resolver name wins, and `contributions()` says whom it shadowed. ## Using the directories `directories(runtime)` is the list, in search order; nothing in it searches. The caller splices it around `PATH` — for example as the `path` option of `whichSync`, or the child's `PATH` in the runtime passed to `run()`. `whichSync` and `run()` do not read the plugin registry on their own, so `bellpull/which` stays a leaf that loads nothing else. ## Refused at the door A resolver's directories are searched before `PATH`, which is real privilege, so `register()` refuses what could point somewhere unintended: - **a relative path** — it would mean a different directory every time the program ran from somewhere else, including one somebody else can write to; - a `{VAR}` that expands to a relative path, carries a `PATH` separator, or is unset is **dropped** at search time rather than guessed at; - a resolver with no `paths`, no `rank`, or a malformed `when`; - a plugin with no name, or a newer `contract` than this bellpull knows. Each refusal is a `PluginError` with a code and a fix. `npx bellpull check ` runs the same validation on a plugin module's default export before it ships, and exits 0, 1 with the code and the fix, or 2 on a usage error: ```js title="relative.mjs" export default { name: 'asdf', contract: 1, resolvers: { asdf: { rank: -10, paths: ['shims'] }, }, }; ``` ```text title="npx bellpull check relative.mjs" exit="1" E_PLUGIN_SCHEMA: plugin "asdf": resolver "asdf": "shims" is not an absolute path fix: use an absolute path, or start it with a `{VAR}` that holds one — a relative entry means a different directory every time the program is run from somewhere else, including one somebody else can write to ``` ## What is tested - [`plugin.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/plugin.test.ts): a plugin is one plain object shared by the family; a resolver is data and survives JSON; `when` decides whether it applies; only absolute directories are contributed; every refusal carries a code and a fix. --- # One result for people and agents > format, toJson and toEvent: three renderings of one bellpull Result, over one verdict — ok, failed, timedOut or signalled — so a --json flag cannot say something the human output did not. Source: https://bellpull.interlace.tools/docs/guides/reports A CLI that runs a subprocess reports it to three readers: a person at a terminal, a script reading `--json`, and an agent reading an event stream. bellpull renders all three from the one `Result`, over one verdict, so they cannot disagree. | function | for | shape | | :-- | :-- | :-- | | `format(result)` | a person | a headline, the binary and the `PATH` entry, and on failure the last ten lines of output | | `toJson(result)` | `--json` | every field of the result, `duration` as `durationMs` | | `toEvent(result)` | an agent | `type: 'run'`, the verdict as `outcome`, and the executable and its `PATH` entry | The verdict is one of `ok`, `failed` (a non-zero exit), `timedOut` and `signalled`. ```js title="render.mjs" import { format, run, toEvent, toJson } from 'bellpull'; import { install, runtimeWith } from './tools.mjs'; install(); const runtime = runtimeWith('bin'); const result = await run('greet', ['2'], { runtime }); // The duration is wall-clock time and the paths are this machine's, so this page pins them. const steady = { ...result, duration: 0, executable: { path: 'bin/greet', from: 'bin' } }; console.log(format(steady)); console.log(JSON.stringify(toJson(steady))); console.log(JSON.stringify(toEvent(steady))); ``` ```text title="node render.mjs" failed greet exited 2 after 0 ms bin/greet (from bin) hello from bin {"ok":false,"code":2,"signal":null,"stdout":"hello from bin\n","stderr":"","durationMs":0,"command":"greet","args":["2"],"executable":{"path":"bin/greet","from":"bin"},"timedOut":false} {"type":"run","outcome":"failed","command":"greet","args":["2"],"code":2,"signal":null,"durationMs":0,"executable":"bin/greet","from":"bin"} ``` `format` shows stderr when there is any and stdout otherwise, because plenty of programs report their failure on stdout. On success it shows only the headline and the binary. `duration` is wall-clock time from spawn to close, including Node's own scheduling — not the child's CPU time. The type's doc comment says the same, where an editor shows it. ## What is tested - [`matrix.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/matrix.test.ts): the human form names the failure and where the binary came from; the JSON carries the same facts and no others; the event collapses the outcome to one word; every rendering comes from one value. --- # 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. Source: https://bellpull.interlace.tools/docs/guides/resolution "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**. ```js title="which.mjs" 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 })); ``` ```text title="node which.mjs" 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: ```js title="holes.mjs" 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)); ``` ```text title="node holes.mjs" {"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`: ```js title="pathext.mjs" 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 })); ``` ```text title="node pathext.mjs" [ '.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`](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/which.test.ts): the `PATH` entry 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; `runPath` up to the root. --- # Running a program > run(command, args, { runtime }): an argv array, no shell, output collected, and a result that resolves on a non-zero exit — rejecting only when no process ran. Source: https://bellpull.interlace.tools/docs/guides/running ```js import { ambientRuntime, run } from 'bellpull'; const result = await run('git', ['rev-parse', 'HEAD'], { runtime: ambientRuntime() }); ``` `run` resolves the command on `PATH`, spawns the file it found with the arguments as an **argv array** — never through a shell — collects `stdout` and `stderr`, and resolves when the child has exited and its output is drained. ## A failure is a value A child that exits non-zero is a result with `ok: false`, not a thrown error. The caller reads it instead of catching it, and the output is right there: ```js title="failing.mjs" import { run } from 'bellpull'; import { install, runtimeWith } from './tools.mjs'; install(); const runtime = runtimeWith('bin'); const passed = await run('greet', [], { runtime }); const failed = await run('greet', ['1'], { runtime }); console.log(passed.ok, passed.code); console.log(failed.ok, failed.code, failed.stdout.trim()); ``` ```text title="node failing.mjs" true 0 false 1 hello from bin ``` ## When it rejects Only when there is no process to report on: ```js title="missing.mjs" import { run } from 'bellpull'; import { runtimeWith } from './tools.mjs'; try { await run('no-such-tool', [], { runtime: runtimeWith() }); } catch (error) { console.log(error.name, error.code, error.message); } ``` ```text title="node missing.mjs" NotFoundError ENOENT not found: no-such-tool ``` A spawn that fails for another reason rejects with `SpawnError`, `code: 'ERR_SPAWN_FAILED'`. ## Arguments are data Each argument arrives in the child exactly as written — a `;`, a `$(…)` or a `&` in caller data is a character, not a command: ```js title="argv.mjs" import { run } from 'bellpull'; import { runtimeWith } from './tools.mjs'; const hostile = 'a; echo injected $(whoami)'; const result = await run(process.execPath, ['-e', 'console.log(JSON.stringify(process.argv[1]))', hostile], { runtime: runtimeWith() }); console.log(result.stdout.trim()); ``` ```text title="node argv.mjs" "a; echo injected $(whoami)" ``` `shell: true` is accepted, off by default, and is the one way to lose that property: with a shell, an argument is text in a command line. `escape.test.ts` runs the same hostile argument both ways and asserts the shell executes it. On Windows, where `.cmd` files need `cmd.exe`, bellpull quotes and escapes for it rather than reaching for a shell — see [Windows without a shell](/docs/guides/windows). ## The runtime is the child's world too The runtime's `env` and `cwd` are what the child is spawned with, unless the call passes its own `env` or `cwd`. So the `PATH` that resolved the command is the `PATH` the child sees, and the `executable` field names the binary that actually ran. Every other option is Node's `spawn` option — `stdio: 'inherit'` for a pass-through subcommand, `signal` for an `AbortSignal`, and the rest. ## What is tested - [`matrix.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/matrix.test.ts): resolves on success and on a non-zero exit; keeps stderr separate; passes arguments as an argv array; rejects when the executable does not resolve; does not mutate the caller's arguments. - [`escape.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/escape.test.ts): the argv array is the boundary, and a shell removes it. --- # 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. Source: https://bellpull.interlace.tools/docs/guides/windows On Windows `npm` is `npm.cmd`, and a `.cmd` is a script `CreateProcess` will not run. The tempting fix is `{ shell: true }`, and it re-opens command injection: through a shell an argument is text in a command line, so a `&` in caller data starts a second command. bellpull does what cross-spawn does instead: 1. **Resolve** the command with `PATHEXT` — `npm` becomes `C:\…\npm.cmd`. 2. If the file is not an `.exe` or `.com`, **invoke `cmd.exe` itself**: `cmd.exe /d /s /c "…"`. 3. **Escape** the command and every argument for both parsers the line passes through: `CommandLineToArgvW`'s quoting, then `cmd.exe`'s metacharacters, with a caret before each. An argument bound for a `node_modules\.bin\*.cmd` shim is escaped twice, because the shim re-expands it. 4. Spawn with `windowsVerbatimArguments`, so Node does not quote it all a second time. `shell: true` stays available, off by default, and documented as the injection surface it is. ## What the escaping produces The escapers are exported, and they are pure — this runs the same on any platform: ```js title="escape.mjs" import { escapeArgument, escapeCommand } from 'bellpull'; for (const argument of ['plain', 'two words', 'foo & calc', '"(foo|bar>baz)"', '%PATH%']) { console.log(`${argument.padEnd(16)} -> ${escapeArgument(argument)}`); } console.log(escapeCommand('my&tool')); ``` ```text title="node escape.mjs" plain -> ^"plain^" two words -> ^"two^ words^" foo & calc -> ^"foo^ ^&^ calc^" "(foo|bar>baz)" -> ^"\^"^(foo^|bar^>baz^)\^"^" %PATH% -> ^"^%PATH^%^" my^&tool ``` The command is escaped without quotes, because `cmd.exe` resolves it before it parses arguments. ## How it is checked `escape.test.ts` round-trips a 37-vector injection corpus through simulations of both parsers — `cmd.exe`'s caret and quote handling, then `CommandLineToArgvW` — and asserts each vector comes back as exactly one argument, unchanged. It then asserts the naive fix (quote it and hand it to a shell) lets the dangerous half of the corpus through, so the contrast is a real one. On a Windows runner, the same suite runs a hostile argument through a real cmd-shim and checks that an argument written to create a file does not create it. **What is not covered:** CR and LF. `cmd.exe` treats both as command separators and has no escape for them. bellpull does not caret-escape them — the same as cross-spawn — and records that as an unverified gap on Windows. execa 10 refuses arguments containing either, which is the stricter answer. cross-spawn's own suite, which grades `bellpull/cross-spawn` 68 of 68, is a pass-through on POSIX: it never reaches this branch. The Windows behaviour is covered by bellpull's own tests. ## What is tested - [`escape.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/escape.test.ts): the corpus through both parsers; the naive fix, broken; the double escape through a cmd-shim; the argv array as the boundary. - [`cross-spawn.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/src/cross-spawn.test.ts): the Windows branch, driven from a Mac — an unresolvable command routed through `cmd.exe` rather than a shell option, every argument escaped, a POSIX path normalised, and POSIX left alone. --- # bellpull > Runs a subprocess, resolves the executable it names, and returns one structured result every caller can read: a line for a person, a --json envelope or an agent event. Zero dependencies. Drop-in paths for cross-spawn and which. Source: https://bellpull.interlace.tools/docs **bellpull** runs a subprocess, resolves the executable it names, and returns one structured result that every caller can read: `format()` for a person, `toJson()` for `--json`, `toEvent()` for an agent. A `--json` flag cannot report something the human output did not. Zero dependencies. Drop-in for **cross-spawn** and npm's **which**: `npx burgee migrate` moves the imports to `bellpull/cross-spawn` and `bellpull/node-which`, each graded by the incumbent's own test suite. **execa** has no drop-in; `run()` below is the alternative. **Over the platform.** Every run goes through `node:child_process`. What bellpull adds is what `spawn` does not do by name: resolve an executable the way the shell would (a `.cmd` shim on Windows, `PATHEXT`, a shebang), and hand back one result every caller can read. The repository's absorption probe asks every supported Node line whether `child_process` alone now starts the same commands. A **bellpull** is the cord in one room wired to a bell in another. You pull it here; a bell rings there; someone comes back to you. That is a subprocess. Request work at a distance, work happens elsewhere, a result returns. **That last clause is the package.** ## Install ```bash npm install bellpull pnpm add bellpull yarn add bellpull bun add bellpull ``` ## Quick start ```ts import { run, format } from 'bellpull'; const runtime = { platform: process.platform, env: process.env, cwd: process.cwd() }; const result = await run('git', ['rev-parse', 'HEAD'], { runtime }); result.ok; // false when it exited non-zero — no exception, no try/catch result.code; // 128 result.executable; // { path: '/opt/homebrew/bin/git', from: '/opt/homebrew/bin' } ``` `execa` throws when a child exits non-zero, so every caller wraps every call and every wrapper rebuilds the same fields out of the error. Here a non-zero exit is a value. The promise rejects only when **no process ran**: the executable did not resolve, or the spawn failed. ## What it does - **Tells you which binary ran.** `whichSync` returns the path *and* the `PATH` entry it came from. "Which `node` was that" is the first question of every build-differs-between-machines investigation and no resolver in the layer answers it. - **Bounds the run by default.** `timeout` is finite, the kill is a ladder (`SIGTERM`, then `SIGKILL`), and **the output written before the kill is kept** — a CI timeout with the output discarded is undiagnosable. `execa` and `tinyexec` both default to no timeout at all. - **Renders the same result three ways.** `format()` for a person, `toJson()` for `--json`, `toEvent()` for an agent stream, all derived from one record — so a `--json` flag cannot report something the human output did not. - **Zero dependencies.** `cross-spawn` pulls three packages; `execa` pulls sixteen. **What it is not.** `execa`'s streaming API and its `` $`cmd` `` form are a different product and are out of scope — see [`spec.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/intents/bellpull/spec.md). And the weight pitch in this layer is already taken: `tinyexec` is zero-dependency, has 119.5 M downloads a week, and was published 2026-09-03. The claim here is resolution and shape, not bytes. ## Entry points | Import | What it gives you | In place of | | :-- | :-- | :-- | | `bellpull` | `run()` and the `Result`, plus resolution and the three projections | `execa`, `tinyexec`, `nano-spawn` | | `bellpull/which` | bellpull's own resolver, not a drop-in: resolution alone, and the `PATH` entry that answered | `isexe`, `path-key`, `npm-run-path` | | `bellpull/node-which` | the drop-in for npm's `which` — same `which()` and `.sync`, same options; graded 5 / 5 by node-which's own suite | `which` | | `bellpull/cross-spawn` | the drop-in — same callable default, same `.sync` | `cross-spawn` | | `bellpull/plugin` | the `resolvers` plugin host | — | `bellpull/which` is a leaf: it loads two files and nothing that has ever heard of `process`. A program that only needs to find a binary does not pay for a spawner. ## No shell, ever, to solve a Windows problem `npm` on Windows is `npm.cmd`, and a `.cmd` is a script `CreateProcess` will not run. The tempting fix is `{ shell: true }`, and it re-opens command injection: with a shell, an argument is text in a command line, so a `&` in caller-supplied data is another command. `bellpull` does what `cross-spawn` does — it invokes `cmd.exe` itself, having quoted every argument for `CommandLineToArgvW` and escaped every `cmd.exe` metacharacter, with `windowsVerbatimArguments` so Node does not quote them a second time. `shell: true` is available, off by default, and documented as the injection surface it is. `src/escape.test.ts` checks that by round-tripping a 37-vector injection corpus through simulations of both parsers, and by asserting the naive fix fails on the dangerous half of it first. ## The `resolvers` plugin host `which` is the part every environment does differently — `nvm`, `asdf`, `pnpm`, a devcontainer. A plugin contributes a search order as **data**, with no function anywhere in it: ```ts register({ name: 'asdf', contract: 1, resolvers: { asdf: { rank: -10, paths: ['{ASDF_DATA_DIR}/shims'], when: { envAny: ['ASDF_DATA_DIR'] } }, }, }); ``` A negative `rank` searches before `PATH`. A path must be absolute after `{VAR}` substitution, or it is refused at `register()` — a relative entry means a different directory every time the program runs from somewhere else, including one somebody else can write to. `{VAR}` is the only substitution: `$VAR` and `~` are not expanded, and are refused as relative. - **`rank`** — a number: negative searches before `PATH`, positive after it. - **`paths`** — one or more directories, each absolute or starting with a `{VAR}` that holds an absolute path. - **`when`** — optional. `envAny: ['VAR', …]` applies the resolver only while one of those variables is set; `platform: ['linux', …]` only on those `process.platform` values. Leave it out and the resolver always applies. Any other key under `when` is refused. - **`extensions`** — optional, Windows only: what to try in place of `PATHEXT`. The same plugin as a file of its own — one module whose default export is a plain object, with no import, which is the shape `check` loads: ```js // asdf-plugin.mjs export default { name: 'asdf', contract: 1, resolvers: { asdf: { rank: -10, paths: ['{ASDF_DATA_DIR}/shims'], when: { envAny: ['ASDF_DATA_DIR'] } }, }, }; ``` Check it before it ships: ```bash npx bellpull check ./asdf-plugin.mjs # in a clone of this repository, where dist/ is not committed: npx turbo run build --filter=bellpull && node packages/bellpull/dist/cli.js check ./asdf-plugin.mjs ``` ```text asdf — 1 resolvers asdf asdf: ok ``` The census comes first and `ok` last; the resolvers are listed in the order they are searched. A refusal exits 1 with a code and the edit to make — `E_PLUGIN_SCHEMA` for a missing `rank`, a relative path, or a `resolvers` that is not an object keyed by name. ## Migrating From **cross-spawn** or npm's **which**, one import: ```diff - import spawn from 'cross-spawn'; + import spawn from 'bellpull/cross-spawn'; ``` ```diff - import which from 'which'; + import which from 'bellpull/node-which'; ``` `require('bellpull/cross-spawn')` works too. Or let the codemod make the change: `npx burgee migrate --dry-run` lists every import it would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` makes it ([Migrate](https://burgee.interlace.tools/docs/migrate)). From **execa** there is no drop-in: `run()` is the alternative, and [Coming from execa](https://bellpull.interlace.tools/docs/coming-from/execa) walks the change. ## Compatibility Both drop-ins are graded by the incumbent's own suite, run unedited through `compat-oracle` beside a control run against the incumbent itself. Measured on 2026-09-15: | | control (`cross-spawn` itself) | `bellpull/cross-spawn` | | :-- | --: | --: | | `cross-spawn` suite, macOS | 68 / 68 | **68 / 68** | **What the 68 / 68 does not cover.** On POSIX `cross-spawn` is a pass-through, so its suite never reaches the escaping or the `cmd.exe` branch at all. Measured: replacing `escapeArgument` with `` arg => `"${arg}"` `` — the injection bug in its purest form — leaves the row at 68 / 68. The Windows behaviour is covered by this package's own tests and by nothing else until a Windows runner exists. ## Benchmarks Every number here is produced by `npm run bench` and published at [burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks). Graded by the incumbent's own test suite: | suite | passing | | :-- | --: | | `cross-spawn` | 68 / 68 | | `execa` | 0 / 1360 | | `which` | 5 / 5 | Weight, installed and tree-inclusive: **98,062 bytes** against **765,553** for the incumbents it replaces — a ratio of **0.1281** ([measured](https://burgee.interlace.tools/docs/benchmarks)). ## For agents - **One result, three readers.** `format()` renders it for a person, `toJson()` for `--json`, `toEvent()` for an agent stream — one record, so the three cannot disagree. - **A non-zero exit is a value, not an exception**: `result.ok` is `false`, and the promise rejects only when no process ran at all. - **`result.executable` says which binary ran**, and from which `PATH` entry. - **A resolver plugin can be checked before it ships.** `npx bellpull check ./asdf.mjs` validates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage error. The whole plugin and the report are under [The `resolvers` plugin host](#the-resolvers-plugin-host). - **The docs are machine-readable** at [bellpull.interlace.tools/llms.txt](https://bellpull.interlace.tools/llms.txt) and [llms-full.txt](https://bellpull.interlace.tools/llms-full.txt). ## API The entry points are under [Entry points](#entry-points); every export, with its types, is on [bellpull.interlace.tools](https://bellpull.interlace.tools/docs). ## Where it sits Plugins register under the `resolvers` key, against the one schema the whole family shares. `burgee`, `caique`, `oxmutate` build on it, and it builds on nothing in this family. ## The family Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each owns one job. Six of them — `bellpull`, `closeout`, `linegauge`, `paratext`, `roundel`, `seniority` — depend on nothing; the others depend only on packages in this table, and every dependency points one way, down the [layers](https://burgee.interlace.tools/docs/concepts/family). No package declares a dependency from outside the family; `react` and `react-reconciler` are optional peers of `controlroom`, which npm does not install; `vitest` is an optional peer of `oxmutate`, which npm does not install. [`package-shape-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) holds that for every manifest, and [`independence-install-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/independence-install-lock.test.ts) installs each package alone and finds nothing but the family packages it declares. Releases are published by one workflow through npm trusted publishing: no npm token is used, and each release carries SLSA provenance, which `npm view dist.attestations` shows. [`trusted-publishing-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/trusted-publishing-lock.test.ts) keeps both true. | Package | What it is | Migrates from | | :-- | :-- | :-- | | [burgee](https://burgee.interlace.tools/docs/packages/burgee) | The CLI framework: one declaration, every surface | commander, yargs and meow | | [roundel](https://roundel.interlace.tools/docs) | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors | | [flagstaff](https://flagstaff.interlace.tools/docs) | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | [caique](https://caique.interlace.tools/docs) | Prompts that are flags first, and never hang | @inquirer/core | | [linegauge](https://linegauge.interlace.tools/docs) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line | | [paratext](https://paratext.interlace.tools/docs) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | [seniority](https://seniority.interlace.tools/docs) | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc | | [closeout](https://closeout.interlace.tools/docs) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime | | **bellpull** (this package) | Subprocesses, and which executable actually ran | cross-spawn and which | | [controlroom](https://controlroom.interlace.tools/docs) | Keyboard-driven terminal screens, inline or full-screen | ink | | [oxmutate](https://oxmutate.interlace.tools/docs) | Mutation testing on the oxc engine, inside your own Vitest | Stryker | Every migration guide, and the family-wide [compatibility](https://burgee.interlace.tools/docs/compatibility) and [benchmarks](https://burgee.interlace.tools/docs/benchmarks) pages, are on [burgee.interlace.tools](https://burgee.interlace.tools/docs/packages). ## Contributing Issues and pull requests are welcome at [ofri-peretz/burgee](https://github.com/ofri-peretz/burgee/issues); read [CONTRIBUTING.md](https://github.com/ofri-peretz/burgee/blob/main/CONTRIBUTING.md) first. Report a vulnerability privately, as [SECURITY.md](https://github.com/ofri-peretz/burgee/blob/main/SECURITY.md) describes — never in a public issue. ## Licence MIT © Ofri Peretz — see [LICENSE](https://github.com/ofri-peretz/burgee/blob/main/packages/bellpull/LICENSE). --- # Subprocess results for --json and agents > Give a CLI's --json mode and an agent's event stream the same facts about a subprocess the terminal shows, from one bellpull Result. Source: https://bellpull.interlace.tools/docs/recipes/agent-output A CLI that shells out usually prints something for a person and, under `--json`, builds a second report by hand — and the two drift. With bellpull the report is the result, rendered: ```js title="ship.mjs" import { format, run, toEvent, toJson } from 'bellpull'; import { install, runtimeWith } from './tools.mjs'; install(); const runtime = runtimeWith('bin'); const mode = process.argv[2] ?? 'human'; const result = await run('greet', ['1'], { runtime }); const steady = { ...result, duration: 0, executable: { path: 'bin/greet', from: 'bin' } }; // pinned for this page if (mode === '--json') console.log(JSON.stringify(toJson(steady))); else if (mode === '--events') console.log(JSON.stringify(toEvent(steady))); else console.log(format(steady)); process.exitCode = result.ok ? 0 : 1; ``` ```text title="node ship.mjs" exit="1" failed greet exited 1 after 0 ms bin/greet (from bin) hello from bin ``` ```text title="node ship.mjs --events" exit="1" {"type":"run","outcome":"failed","command":"greet","args":["1"],"code":1,"signal":null,"durationMs":0,"executable":"bin/greet","from":"bin"} ``` An agent branches on `outcome` — `ok`, `failed`, `timedOut` or `signalled` — rather than re-deriving it from `code`, `signal` and `timedOut`, and gets the binary that ran without asking a second question. The duration and paths are pinned above only so this page can be checked byte for byte; a real program passes `result` straight through. --- # Incremental migration from cross-spawn > Swap cross-spawn for bellpull/cross-spawn in one import, then move call sites to run() one at a time, and know what changes at each step. Source: https://bellpull.interlace.tools/docs/recipes/incremental-migration ## 1. Change the import ```diff - import spawn from 'cross-spawn'; + import spawn from 'bellpull/cross-spawn'; ``` `bellpull/cross-spawn` passes cross-spawn 7.0.6's own suite, 68 of 68, and `require()` works too. The dependency tree goes from four packages to none. `npx burgee migrate --dry-run` lists the imports it would rewrite. ## 2. Move a call site to run() The drop-in returns a `ChildProcess`, and the caller collects output and waits for `close`. `run()` does that and returns the result: ```js title="migrated.mjs" import spawn from 'bellpull/cross-spawn'; import { run } from 'bellpull'; import { install, runtimeWith } from './tools.mjs'; install(); const runtime = runtimeWith('bin'); // Before: a ChildProcess, and the bookkeeping around it. const child = spawn('greet', ['1'], { env: runtime.env }); let out = ''; child.stdout.on('data', (chunk) => (out += chunk)); const code = await new Promise((resolve) => child.on('close', resolve)); console.log('spawn:', code, out.trim()); // After: one value. const result = await run('greet', ['1'], { runtime }); console.log('run: ', result.code, result.stdout.trim(), result.ok); ``` ```text title="node migrated.mjs" spawn: 1 hello from bin run: 1 hello from bin false ``` What changes at a call site that moves: - **A deadline appears.** `run()` kills a child after 30 s unless you pass `timeout`; a long-running child — a dev server, a watcher — needs `timeout: 0`. - **A missing command rejects** with `NotFoundError`, where `spawn` emitted an `error` event. - **Output is collected.** For a child whose output should go straight to the terminal, pass `stdio: 'inherit'`; the result then has empty `stdout` and `stderr`. - **The environment is explicit.** The runtime's `env` and `cwd` are the child's, unless the call passes its own. ## From which ```diff - import which from 'which'; + import which from 'bellpull/node-which'; ``` graded 5 of 5 by node-which's suite. Moving on to `bellpull/which` changes the answer's shape (`{ path, from, ext }` instead of a string) and stops searching empty and relative `PATH` entries — see [Finding the executable](/docs/guides/resolution). --- # Run a project's local tools > Run the tools a project installed in node_modules/.bin by name, from any directory inside it, with runPath — npm-run-path's contract, without the dependency. Source: https://bellpull.interlace.tools/docs/recipes/local-tools `npm run` puts every `node_modules/.bin` from the working directory up in front of `PATH`, which is why a script can say `lint` rather than `./node_modules/.bin/lint`. `runPath()` builds the same `PATH`, so a program can run a project's tools the way its scripts do: ```js title="local.mjs" import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; import { relative } from 'node:path'; import { run } from 'bellpull'; import { runPath } from 'bellpull/which'; import { runtimeWith } from './tools.mjs'; // A project with a locally installed tool, and a script one directory down. mkdirSync('node_modules/.bin', { recursive: true }); writeFileSync('node_modules/.bin/lint', "#!/usr/bin/env node\nconsole.log('lint: 0 problems');\n"); chmodSync('node_modules/.bin/lint', 0o755); mkdirSync('packages/app', { recursive: true }); const base = runtimeWith(); const runtime = { ...base, cwd: `${base.cwd}/packages/app` }; const local = { ...runtime, env: { ...runtime.env, PATH: runPath({ runtime }) } }; const result = await run('lint', [], { runtime: local }); console.log(result.stdout.trim()); console.log('from', relative(base.cwd, result.executable.from)); ``` ```text title="node local.mjs" lint: 0 problems from node_modules/.bin ``` The walk starts at the runtime's working directory, goes up one directory at a time, and stops at the filesystem root — or the drive root on Windows, where the walk is in Windows dialect. The nearest `node_modules/.bin` comes first, so a workspace package's own install wins over the root's. `which.test.ts` checks all three. --- # Testing resolution without process.env > Test what your program would run — on Linux, on Windows, with a hostile PATH — by passing a literal runtime instead of mutating process.env. Source: https://bellpull.interlace.tools/docs/recipes/testing-without-process Every bellpull function takes the environment as an argument, so a test states the world it is about instead of editing the one it runs in. Nothing to restore afterwards, nothing that leaks between tests, and Windows rules run on a Mac: ```js title="resolution-test.mjs" import assert from 'node:assert/strict'; import { searchPath, whichSync } from 'bellpull/which'; import { install } from './tools.mjs'; install(); const cwd = process.cwd(); const linux = (PATH) => ({ runtime: { platform: 'linux', env: { PATH }, cwd } }); // The PATH order decides, and the answer says which entry won. assert.equal(whichSync('greet', linux(`${cwd}/vendor/bin:${cwd}/bin`))?.from, `${cwd}/vendor/bin`); assert.equal(whichSync('greet', linux(`${cwd}/bin:${cwd}/vendor/bin`))?.from, `${cwd}/bin`); // A relative entry is never searched, even when the tool is right there. assert.equal(whichSync('greet', linux('bin')), undefined); assert.deepEqual(searchPath(linux('bin')), [{ raw: 'bin', skipped: 'relative' }]); // Windows reads PATH under whatever case it was spelled, and splits it on `;`. const windows = { runtime: { platform: 'win32', env: { Path: 'C:\\a;C:\\b' }, cwd: 'C:\\work' } }; assert.deepEqual(searchPath(windows).map((e) => e.dir), ['C:\\work', 'C:\\a', 'C:\\b']); console.log('ok'); ``` ```text title="node resolution-test.mjs" ok ``` For the spawning half, `run()` takes the same runtime: the child is spawned with its `env` and `cwd`, so a test that controls `PATH` controls which binary runs. bellpull's own suite drives the Windows branch of `bellpull/cross-spawn` from a Mac the same way. --- # Which binary ran? > Diagnose a build that differs between machines: list every install of a tool on PATH, best first, and report which one a run actually used. Source: https://bellpull.interlace.tools/docs/recipes/which-binary-ran Two machines, one command, two answers — and usually two installs of the same tool on one of them. The fastest way to the finding is to ask for every match, and for the `PATH` entry each came from: ```js title="doctor.mjs" import { relative } from 'node:path'; import { run } from 'bellpull'; import { whichAllSync } from 'bellpull/which'; import { install, runtimeWith } from './tools.mjs'; install(); const runtime = runtimeWith('bin', 'vendor/bin'); const here = (p) => relative(runtime.cwd, p); const hits = whichAllSync('greet', { runtime }); if (hits.length > 1) console.log(`${hits.length} installs of greet on PATH:`); for (const [i, hit] of hits.entries()) console.log(` ${i === 0 ? '→' : ' '} ${here(hit.path)} (PATH entry ${here(hit.from)})`); const result = await run('greet', [], { runtime }); console.log(`ran ${here(result.executable.path)}: ${result.stdout.trim()}`); ``` ```text title="node doctor.mjs" 2 installs of greet on PATH: → bin/greet (PATH entry bin) vendor/bin/greet (PATH entry vendor/bin) ran bin/greet: hello from bin ``` Put this behind a `doctor` subcommand and a bug report carries the answer instead of a request for `echo $PATH`. The `run` result's `executable` is the one that actually ran, because bellpull spawns the file it resolved rather than handing the name back to be resolved again. --- # 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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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,360 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.