npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bellpull

v1.0.10

Published

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.

Readme

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

npm install bellpull
pnpm add bellpull
yarn add bellpull
bun add bellpull

Quick start

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

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:

// 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:

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
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:

- import spawn from 'cross-spawn';
+ import spawn from 'bellpull/cross-spawn';
- 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).

From execa there is no drop-in: run() is the alternative, and 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.

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: 97,766 bytes against 765,553 for the incumbents it replaces — a ratio of 0.1277 (measured).

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 docs are machine-readable at bellpull.interlace.tools/llms.txt and llms-full.txt.

API

The entry points are under Entry points; every export, with its types, is on bellpull.interlace.tools.

Where it sits

Plugins register under the resolvers key, against the one schema the whole family shares.

burgee and caique build on it, and it builds on nothing in this family.

The family

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

No package declares a dependency from outside the family; react and react-reconciler are optional peers of controlroom, which npm does not install. package-shape-lock holds that for every manifest, and independence-install-lock 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 <package> dist.attestations shows. trusted-publishing-lock keeps both true.

| Package | What it is | Migrates from | | :-- | :-- | :-- | | burgee | The CLI framework: one declaration, every surface | commander, yargs and meow | | roundel | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors | | flagstaff | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | caique | Prompts that are flags first, and never hang | @inquirer/core | | linegauge | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line | | paratext | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | seniority | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc | | closeout | 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 | Keyboard-driven terminal screens, inline or full-screen | ink |

Every migration guide, and the family-wide compatibility and benchmarks pages, are on burgee.interlace.tools.

Contributing

Issues and pull requests are welcome at ofri-peretz/burgee; read CONTRIBUTING.md first. Report a vulnerability privately, as SECURITY.md describes — never in a public issue.

Licence

MIT © Ofri Peretz — see LICENSE.