ultra-runner intercepts your npm and yarn calls and only runs the scripts that matter
🏃⛰ Ultra fast monorepo script runner and build tool
At a glance
- What is it?
- ultra-runner is a script runner for JavaScript monorepos that parses package.json, works out which scripts actually need to run, and executes them concurrently across workspaces, with an incremental build cache. It is zero-config and non-intrusive, and the trade is that it sits in the middle of your package manager invocations, including hijacking npm, pnpm, yarn and npx calls.
- Who is it for?
- ultra-runner fits a team whose monorepo builds are slow because every package rebuilds every time, or whose scripts spawn a package manager per step. It is the wrong choice for a single package repository, where a plain npm run is faster to reason about.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.
Editorial analysis
It parses your scripts instead of spawning a package manager
The mechanism is stated in the README under the heading it calls Smart. Ultra parses your package.json and hijacks any npm run, yarn and npx calls, and shell operators like &&, ; and || are also interpreted. The example shows a script that chains three linters, each of which itself calls npx.
{
"scripts": {
"lint": "yarn lint:ts && yarn lint:eslint && yarn lint:docs",
"lint:eslint": "npx eslint bin/*.js src/*.ts __tests__/*.ts --cache",
"lint:docs": "npx markdownlint README.md",
"lint:ts": "npx tsc -p tsconfig.build.json --noEmit",
"lint:fix": "yarn lint:eslint --fix"
}
}The consequence the README spells out is process count. Running ultra lint:fix will spawn exactly one child process, directly with the correct command, instead of spawning yarn intermediately. Ultra will additionally execute any configured pre and post scripts, just like npm run and yarn run.
The reason this is faster is in the Fast section. Ultra parses the scripts and only executes the commands that are really needed, and script interdependencies are resolved during the parsing stage, so there is little overhead from Ultra itself since it only runs once. yarn run and npm run, by contrast, spawn new yarn or npm child processes as needed by the package scripts. The README also has a comparison table across npm run, npx, yarn, yarn exec and ultra for which kinds of invocation each supports: package.json scripts, node_modules binaries and system binaries.
That hijacking behaviour is the defining characteristic, and it is worth being clear about before adopting it: you are replacing npm, pnpm, yarn and npx with a tool that interprets their semantics itself.
Workspaces are detected, and there is a recursive fallback
The workspace story is deliberately zero-config. The feature list says it works out of the box with your existing monorepo, is non-intrusive with no need to make changes to your package.json files, detects packages in existing lerna, yarn, npm@7 and pnpm workspaces, or recursively searches for them.
The recursive execution section restates the detection order: lerna, pnpm, yarn workspace, and when no monorepo manager was found, it looks recursively for packages. That fallback is what makes the tool usable in a repository that has no workspace configuration at all, at the cost of it having to walk the tree to find candidate packages.
The recursive flag is -r or --recursive, and the command is executed in every package of the repository, excluding the root package. If you also want the root package, the README says to combine --recursive with --root.
Concurrency is on by default. Commands are always run concurrently with a default concurrency of 10, which can be changed with --concurrency. There is also a second, finer-grained mechanism: concurrency within scripts, where you add optional configuration to package.json to run parts of a script in parallel, with no need to change the actual scripts.
"ultra": {
"concurrent": [
"lint",
"prebuild",
"ultra"
]
}One more behaviour saves time in a large monorepo: missing scripts. When executing scripts recursively, only packages that have the script defined will execute it, so a repository where only some packages have a test script does not fail on the others.
Filtering understands wildcards and dependency prefixes
The --filter option is how you narrow a recursive run, and the syntax has two parts that are worth understanding separately.
The first is wildcards, which filter on package names and subdirectories.
$ ultra -r --filter "@scope/app" pwd
...
$ ultra -r --filter "@scope/*" pwd
...
$ ultra -r --filter "apps/*" pwd
...The second is the + prefix. When the filter is prefixed with a +, all dependencies of the filtered packages are included as well. The README's example: if a package called app1 depends on lib1, then the filter +app1 executes the command on both app1 and lib1, using the workspace topology.
That is a different idea from a name filter. A name filter selects what you typed, and the + form selects what you typed plus what it depends on, which is the selection you want for a build or a test run that has to happen in dependency order. It is also the point where the tool starts to overlap with the workspace managers it detects, since the topology information has to come from somewhere, and here it comes from the workspace configuration that lerna, pnpm, yarn or npm@7 already describes.
The related build behaviour follows the same idea. Ultra automatically detects workspace dependencies while still allowing parallel builds, and packages are built concurrently as soon as their dependencies are built, also concurrently.
The build cache lives in .ultra.cache.json next to each package
The Builds section is where the incremental behaviour is implemented, and it is per package rather than global.
Every package directory contains a .ultra.cache.json file holding hashes of all files and build artifacts in the repository. Internally this uses git ls-files for files under source control and simple mtime timestamps for build artifacts. When building a package, the current state is compared with the .ultra.cache.json, and builds are skipped when no changes were detected.
The split between git ls-files and mtime is a pragmatic detail that answers an obvious question: source files are hashed by content, which is stable across a git checkout, while build outputs are tracked by timestamp, which is cheap and good enough for artifacts that are regenerated anyway.
Three command forms trigger the optimized build path: the build script itself, and --build with a custom script or command.
$ ultra -r --build
...
$ ultra -r build
...
$ ultra -r --build mycustombuildscript
...Two escape hatches exist. If you want to rebuild a package regardless, there is --rebuild or the rebuild script. And if you want some files excluded from the cache, you can create a .ultraignore file, whose format is similar to .gitignore. Whenever a file changes that is listed in the .ultraignore, a rebuild will not be triggered.
The repository uses this on itself, with a .ultraignore at the root, so the tool is dogfooded in its own workflow.
Monitor mode and output zooming for concurrent runs
Two features exist for the case where many commands run at once and the terminal becomes unreadable.
The first is output zooming. When executing multiple commands in parallel, ultra will try to keep as much concurrent output on the screen as possible, by only showing the last lines of the commands. Once the commands complete running, a full log is written to the terminal. The README says this is very useful when building a bunch of packages with --watch for instance.
That is a real trade, and it is worth being explicit about: in zoomed mode you are watching the tail of each output rather than the whole thing, and the complete log only exists after the commands finish. A CI job that greps live output would see a different view than a human at a terminal.
The second is the node process monitor, with ultra --monitor, which the feature list describes as a node process monitor, like top for node. For every process you can also see the package where the command was executed and a clean command line.
The monitor is a diagnostic for the monorepo case rather than a system monitor. When a recursive run leaves processes behind, or when you want to know which package a running process belongs to, that package attribution is the piece of information a general purpose process list cannot give you.
Licence, packaging and a release record that has not moved in years
The package is published on npm as ultra-runner, with the homepage pointing at the npm package page, and the bin entry maps the command to bin/ultra.js, so the executable you get is ultra. The manifest requires Node 10.0.0 or above, and the package files are lib, bin and the markdown files.
The licence needs a note. The repository licence is listed as Apache 2.0, while the licence field inside package.json says MIT. Both identifiers appear in the project files, and the discrepancy is worth resolving with the maintainer if you are shipping the tool in a product and need to know which terms apply to the code as distributed on npm.
The release record is the other thing to look at. The listed releases are v3.10.5, v3.10.4 and v3.10.3, all from the end of February 2021, and the version in package.json is 3.10.5, matching the newest tag. The last push to the repository was on 2026-10-17, so the source is being changed while the published version stays where it was. The repository does have a CHANGELOG.md, a release.config.js for semantic-release, and a renovate.json, and the release script in the manifest runs yarn build and then npx semantic-release --no-ci.
Whether the published package reflects recent work is therefore the question to settle before you depend on it, and the CHANGELOG.md and the npm page are where the answer is. The repository layout itself is a well organised small project: src/, bin/, __tests__/, assets/, a jest config, tsconfig files and a.husky/ directory for git hooks.
Editorial conclusion
ultra-runner fits a team whose monorepo builds are slow because every package rebuilds every time, or whose scripts spawn a package manager per step. It is the wrong choice for a single package repository, where a plain npm run is faster to reason about. Verify first that the script names in your package.json do not depend on shell semantics that ultra interprets differently, and check the CHANGELOG.md before adopting it, since the published releases are from 2021 even though the repository was pushed to on 2026-10-17.
Frequently asked questions
How does ultra-runner make builds faster?
It parses the package.json scripts, resolves script interdependencies during parsing and only executes the commands that are really needed, so it spawns one child process directly instead of spawning a package manager for each step. Builds are also skipped when the .ultra.cache.json shows no changes.
Which monorepo managers does ultra-runner support?
It detects packages in existing lerna, yarn, npm@7 and pnpm workspaces, and when no monorepo manager is found it recursively searches for packages. Package detection does not require changes to your package.json files.
What does the + prefix do in an ultra-runner filter?
When a filter is prefixed with a +, all dependencies of the filtered packages are included as well. The README's example says that with a package app1 depending on lib1, the filter +app1 runs the command on both app1 and lib1, using the workspace topology.
How do I force ultra-runner to rebuild a package?
Use the --rebuild flag or the rebuild script. To exclude files from the cache entirely you can also create a .ultraignore file, whose format is similar to .gitignore, and changes to files listed there will not trigger a rebuild.
How do I run a script in parallel across packages?
Use -r or --recursive, which runs the command in every package except the root, and add --root to include the root package. Commands run concurrently with a default concurrency of 10, changeable with --concurrency, and the README also documents a package.json ultra.concurrent setting to run parts of a script in parallel.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/folke-ultra-runner)Was this useful?