bellpull
Guides

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.

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:

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());
node failing.mjs
true 0
false 1 hello from bin

When it rejects

Only when there is no process to report on:

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);
}
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:

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());
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.

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: 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: the argv array is the boundary, and a shell removes it.

On this page