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.
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
npm install bellpullIt has no dependencies. It is ESM with a default condition, so require('bellpull') also
works from CommonJS on Node 20.19+ and 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:
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:
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
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 greets ran:
false 3 hello from bin
ran bin/greet found in binEvery 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: running, deadlines, resolution, Windows without a shell, one result for people and agents, resolver plugins.
- 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 and which: change one import. From execa it is a rewrite, and the page says so.
- API reference: every export of every entry point.
bellpull
The cord you pull to ring a bell in another room. Subprocesses with executable resolution and a structured result every caller can read — human, JSON envelope or agent event. Drop-in paths for cross-spawn and which; its own run() and resolver are the execa alternative, not a drop-in. Zero dependencies.
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.