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.
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:
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));lint: 0 problems
from node_modules/.binThe 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.
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.
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.