Nub runs your code on stock Node and provisions the right version for you. Pin a version, run a file, and the matching Node is downloaded and cached without a second command. The nub node subcommands are for the steps you want to take deliberately: warming the cache before you run, listing what's installed, reclaiming disk, and recording a pin.

Automatic provisioning

Drop a .node-version or .nvmrc in your project (or an engines.node range in package.json) and run nub. If that Node is already on your machine it's used as-is; if not, Nub downloads the matching stock build from nodejs.org — SHA-256 verified and cached under ~/.cache/nub/node — then runs your code. Nub provisions the pinned version itself rather than handing off to nvm, fnm, or mise; with nothing pinned anywhere up the tree, it uses whatever node is already on your PATH.

$ echo 26.7.0 > .node-version
$ nub index.ts
Using Node.js 26.7.0 (resolved from .node-version)
Installing from nodejs.org... (24 MB)
Installed in 8.0s
# ...your script runs, on exactly the Node you pinned

On a fresh machine with no Node at all — you installed Nub before anything else — a plain nub file.ts still runs. With no pin and no node on PATH, Nub provisions the latest release and runs your code on it, reusing the newest version already in its cache before downloading. Pin a version whenever you want a reproducible one (nub node pin <version>); until then, each run picks up the latest.

$ nub app.ts          # fresh machine: no pin, no Node on PATH
Using Node.js 26.7.0 (resolved from latest)
Installing from nodejs.org... (30 MB)
Installed in 5.1s
# ...your script runs

For the full provisioning story — the ~/.cache/nub layout, the per-invocation shim — see Running files → The Node version.

Version precedence

Two questions decide which Node runs: which version is pinned, then where that binary comes from. Both are resolved in a fixed order.

Which version is pinned

Nub walks up from your working directory to the nearest pin, highest precedence first:

  1. NODE_EXECUTABLE — an absolute path to a Node binary (a hard override)
  2. package.json#/devEngines/runtime — the node entry
  3. .node-version
  4. .nvmrc
  5. .tool-versions — the asdf/mise file; its nodejs or node line
  6. package.json#/engines/node — a range or exact version
  7. Nothing pinned anywhere up the tree — whatever node is on your PATH, or the latest release when there's no node at all.

This resolved Node version is auto-installed and cached for future runs.

$ echo 26 > .node-version
$ nub hello.ts
Using Node.js 26.7.0 (resolved from .node-version)
Installed in 9.8s
Hello world!

Within a single directory, .node-version wins over .nvmrc (it's the tool-agnostic standard). Discovery walks up the directory tree to the nearest pin and skips pin files that live inside an installed dependency (under node_modules) — a dependency's own CI pin never drives your project. The package.json fields (devEngines.runtime, engines.node) are read from the workspace root manifest when one exists above you, else the nearest package.json — a monorepo pins its Node once at the root.

Both fields take the engines.node grammar, so either can express a range or an exact version:

package.json
{
  "devEngines": { "runtime": { "name": "node", "version": "22.15.0" } },
  "engines": { "node": ">=20 <23" }
}

The first pins exactly 22.15.0, patch included. The second resolves to the newest published release satisfying the range.

Nub reads only the node entry's version under devEngines.runtime, and provisions that version regardless of onFail. That field governs entries declaring a non-Node runtime instead: Nub refuses by default, onFail: "warn" prints a notice and falls through, onFail: "ignore" falls through silently. Setting NODE_EXECUTABLE skips resolution altogether — it bypasses the pin files, the cache, the nvm scan, and any download, though the binary's version is still detected, so the floor check and tier dispatch still apply.

Dependency build scripts follow the same root anchor during an install, so every member of a monorepo builds native dependencies under one Node rather than switching per member. Running a script with nub run inside a member still uses that member's own pin.

Read the full docs for the devEngines and engines fields on docs.npmjs.com.

Where the binary comes from

Once a version is pinned, Nub finds a binary for it in order:

  1. node on PATH, if its version satisfies the pin — so fnm / Volta / mise auto-switching just works.
  2. Nub's own download store (~/.cache/nub/node/<version>/).
  3. An nvm-installed version (nvm scan).
  4. Download the matching stock build from nodejs.org — SHA-256 verified and cached — otherwise error.

The PATH walk skips node_modules/.bin, mirroring how pin discovery skips pin files under node_modules. An entry there is a bin some package declared, so a dependency shipping its own node never becomes your project's runtime.

nub node install

Provision a version into the cache now, instead of on the next nub <file>. The use cases are warming a CI cache in a setup step (the GitHub Action does this for you), or fetching a Node before you go offline.

nub node install 26          # newest 26.x
nub node install lts         # newest LTS line
nub node install 20.11.0     # an exact version
nub node install 20 22       # several at once

Aliases and ranges resolve the same way pins do — lts, latest, lts/<codename>, a bare major (26) or major.minor (22.13), or an exact version. With no argument, nub node install reads your project's pin and provisions that:

# installs whatever .node-version / .nvmrc / engines.node names
nub node install

A version already in the cache is a no-op; a version already available on your PATH (a system install, nvm, fnm, …) is reported and skipped rather than re-downloaded.

$ nub node install 26.7.0
Node 26.7.0 is already available on PATH — skipped

nub node ls

List the versions in Nub's cache, newest first. The version your current directory resolves to is marked.

$ nub node ls
→ 26.7.0
  22.13.0
  20.11.0

Only Nub's own download cache is shown — ls doesn't enumerate your nvm / fnm / system installs.

nub node uninstall

Reclaim disk by deleting a cached version. Nub guards against removing the version your current directory resolves to.

$ nub node uninstall 20.11.0
Removed Node 20.11.0 from the cache

Like ls, this operates on Nub's cache only.

nub node pin

Write a .node-version for the project so every later nub in that tree uses it. This is the explicit form of creating the pin file by hand.

$ nub node pin 26
pinned Node 26 → /path/to/project/.node-version

A few deliberate behaviors:

  • It pins the project, not the subdirectory. Run from anywhere inside the project and the file lands at the project root — the nearest package.json directory — not in your current subfolder.
  • In a workspace, it pins the whole repo. A Node version is a property of the repository, not one package, so in a monorepo the pin is written at the workspace root.
  • It edits the pin file you already have. If the project carries a .nvmrc but no .node-version, pin updates the .nvmrc in place.

Whatever spec you give is written verbatim — including an alias like lts — and pin works offline. The .node-version file is the tool-agnostic convention (nvm, fnm, volta, asdf all read it), so the pin is portable to your other tools.

nub node which

Resolve which Node runs here and where it lives. The binary path goes to stdout; a » resolved from <source> explainer goes to stderr, so it never pollutes a captured value.

$ nub node which
/Users/you/.nvm/versions/node/v26.7.0/bin/node
» resolved from .node-version (26.7.0)

The stdout/stderr split means nub node which composes in a shell — capture the path and the explainer stays out of the variable:

$ NODE=$(nub node which)
» resolved from .node-version (26.7.0)
$ echo "$NODE"
/Users/you/.nvm/versions/node/v26.7.0/bin/node

With no pin the explainer reads » resolved from node on PATH. Bare nub node prints a status block instead — the version, path, and resolution source on separate lines.

nub node shim

Make node itself resolve through Nub, so a machine with no Node installed can run node at all. This is the fresh-install story: brew install nubjs/tap/nub (or the curl script), nub node shim, and now a bare node file.js works — Nub resolves the right version, provisions it if it's missing, and runs it.

$ nub node shim
node shim in /Users/you/.nub/node-shim (created)
  `node` now resolves through nub (version management only — no augmentation; run `nub` for that)
  added /Users/you/.nub/node-shim to PATH in /Users/you/.zshrc
  restart your shell, or run: source /Users/you/.zshrc

The shimmed node runs stock Node, unchanged. It resolves and provisions the version — the precedence above — but adds nothing else: no TypeScript, no injected globals, no automatic .env. So node app.ts type-strips exactly as stock Node does, while nub app.ts transpiles and runs it.

Like nub pm shim, it is opt-in and reversible. It installs a node link in ~/.nub/node-shim and adds that directory to your PATH; because that comes first, a Node you install later (brew install node) is shadowed until you unshim. Re-run nub node shim after nub upgrade to re-link.

$ nub node unshim
removed /Users/you/.nub/node-shim
  PATH: removed the node-shim block from /Users/you/.zshrc

Mirrors and proxies

Node downloads honor HTTP(S)_PROXY and NO_PROXY, and TLS-intercepting corporate CAs work out of the box through the system trust store. To fetch Node from an internal mirror instead of nodejs.org, set the standard env var or the .npmrc key pnpm uses — Nub reads both from a project or user-level file, with no Nub-specific config:

.npmrc
node-mirror:release=https://artifactory.corp.example/node/

The NODEJS_ORG_MIRROR env var (the nvm/n convention) takes precedence over the .npmrc key. A mirror is used for musl builds too — point it at one carrying the unofficial-builds layout on Alpine.

  • Running files — how provisioning fires during a normal nub <file> run.
  • FAQ — supported Node floor and compatibility.