Nub has its own install engine: it resolves the dependency graph, writes the lockfile, and links node_modules. The CLI is pnpm-shaped, so the verbs and flags are the ones you already type.

nub install               # install what package.json declares
nub add react react-dom   # add dependencies
nub update --latest       # move ranges to the newest release
nub dedupe                # collapse duplicate versions

The lockfile is nub.lock. Every field Nub reads to build the graph is one pnpm, npm, Yarn, or Bun already reads — Nub adds no config field of its own, so there is no new syntax here to learn. Running Nub inside a repo that already belongs to one of those tools works too: see lockfile compatibility.

Layout

Layout is how the packages you install are arranged inside node_modules. Set it in .npmrc:

.npmrc
node-linker=hoisted
hoist-pattern=lodash
public-hoist-pattern=@types/*
shamefully-hoist=true
hoist-workspace-packages=false
hoisting-limits=workspaces
modules-dir=vendor_modules
virtual-store-dir=.store

Read the full docs on pnpm.io.

The --node-linker flag does the same for one command. The same two choices are linker and publicHoist in nub.jsonc:

nub.jsonc
{
  // ...
  "install": {
    // "global-virtual-store" (the default), "isolated", or "hoisted"
    "linker": "hoisted",
    "publicHoist": ["@types/*"]  // lift a package to the project root
  }
}

Both fields take an object form too — see install.linker and install.publicHoist for the strategies and their knobs. See the flat opt-out for choosing between the layouts.

Resolution

Resolution decides which version of which package ends up in the graph. Every field below lives in package.json, and every one is a field pnpm, npm, Yarn, or Bun already reads.

overrides

Force one version of a package everywhere in the graph, whoever asked for it. A selector is a bare name, a name carrying a range to match, a path scoping the pin to one parent, or $name to reuse your own dependency's range.

package.json
{
  "overrides": {
    "is-number": "7.0.0",
    "is-number@^6.0.0": "7.0.0",
    "is-odd>is-number": "7.0.0",
    "semver": "$semver"
  }
}

Read the full docs on pnpm.io.

resolutions

The same pins in Yarn's spelling, taking the same selectors. Both fields are read, and overrides wins a conflict — so a repo that declares a pin in both for portability stays silent.

package.json
{
  "resolutions": {
    "is-odd>is-number": "7.0.0"
  }
}

Read the full docs on yarnpkg.com.

packageExtensions

Patch a dependency's own manifest at resolve time, adding to its dependencies, optionalDependencies, peerDependencies, or peerDependenciesMeta. Use it when a published package declares a peer it doesn't need, or omits one it does. An extension only adds what is missing; it never overrides a range the package already declares.

package.json
{
  "packageExtensions": {
    "react-server-dom-webpack@19.2.7": {
      "peerDependenciesMeta": {
        "react": { "optional": true }
      }
    }
  }
}

Read the full docs on pnpm.io.

patchedDependencies

Apply a patch file to a package's contents, keyed by name and version. Running nub patch <pkg> writes both the patch and this entry for you. A key matching no installed package fails the install unless allowUnusedPatches downgrades it to a warning.

package.json
{
  "patchedDependencies": {
    "is-odd@3.0.1": "patches/is-odd@3.0.1.patch"
  },
  "allowUnusedPatches": true
}

Read the full docs on patchedDependencies and allowUnusedPatches on pnpm.io.

workspaces

Monorepo member globs. Members resolve to each other through a workspace: specifier instead of the registry.

package.json
{
  "workspaces": ["packages/*"]
}

Read the full docs on docs.npmjs.com. Pnpm keeps its member globs in pnpm-workspace.yaml rather than this field.

workspaces.catalog

Name a version once and let every member reference it, so a bump is a one-line edit. The default catalog is catalog:; named catalogs are catalog:<name>. A reference naming no defined entry is an error rather than a fallback, so a typo fails the install.

package.json
{
  "workspaces": {
    "packages": ["packages/*"],
    "catalog": { "react": "19.2.0" },
    "catalogs": { "testing": { "vitest": "3.2.4" } }
  }
}
packages/app/package.json
{
  "dependencies": { "react": "catalog:" },
  "devDependencies": { "vitest": "catalog:testing" }
}

Read the full docs on pnpm.io. The catalog: syntax matches pnpm's; the definitions live here rather than in pnpm-workspace.yaml.

allowBuilds

Which dependencies may run install scripts — see lifecycle scripts for the deny-by-default posture around it. A registry dependency is keyed by name, anything else by its full specifier, and an explicit false always wins.

package.json
{
  "allowBuilds": {
    "esbuild": true,
    "sharp": false
  }
}

Read the full docs on pnpm.io.

dependenciesMeta

Per-dependency flags. Setting injected hard-copies a workspace dependency instead of linking it, and built: false denies that package's install scripts.

package.json
{
  "dependenciesMeta": {
    "ui": { "injected": true },
    "esbuild": { "built": false }
  }
}

Read the full docs on pnpm.io.

peerDependenciesMeta

Mark one of your own peer dependencies optional, so a consumer that doesn't install it is not an error.

package.json
{
  "peerDependencies": { "react": "^19.0.0" },
  "peerDependenciesMeta": {
    "react": { "optional": true }
  }
}

Read the full docs on pnpm.io.

allowedDeprecatedVersions

Which deprecated versions may resolve without a warning, by package and range.

package.json
{
  "allowedDeprecatedVersions": {
    "request": "*",
    "lodash": "<4.0.0"
  }
}

Read the full docs on pnpm.io.

engines

The Node and package-manager versions the project requires. Nub provisions the Node named here, and engineStrict decides whether a mismatch warns or fails.

package.json
{
  "engines": {
    "node": ">=22.15"
  }
}

Read the full docs on pnpm.io.

Config keys

Registries, auth, peer-dependency behavior, and save behavior come from the .npmrc cascade, which Nub reads in every project no matter which tool owns it, along with npm_config_* in the environment. Setting names are pnpm's:

KeyReference
resolutionModepnpm.io
autoInstallPeerspnpm.io
strictPeerDependenciespnpm.io
dedupePeerDependentspnpm.io
resolvePeersFromWorkspaceRootpnpm.io
linkWorkspacePackagespnpm.io
saveWorkspaceProtocolpnpm.io
savePrefixpnpm.io
saveExactdocs.npmjs.com
supportedArchitecturespnpm.io
ignoredOptionalDependenciespnpm.io
minimumReleaseAgepnpm.io
minimumReleaseAgeExcludepnpm.io
engineStrictpnpm.io
registries, namedRegistriespnpm.io

Four further install fields live in nub.jsonc, Nub's own config file. Two set the cooling windowminimumReleaseAge and minimumReleaseAgeExclude — and apply only when Nub owns the project. Two set layout, linker and publicHoist, and apply under every incumbent. That is the entire block; see the config reference for each field's shape.

CLI

The install engine is one CLI — pnpm's verbs and flags, driven by Nub.

nub install

Resolves the graph and links node_modules. The verb, aliases, and flags follow pnpm:

nub install                      # alias: nub i
nub install --frozen-lockfile    # fail if the lockfile is out of date
nub install -P                   # --prod / --production
nub install -D                   # dev only
nub install --node-linker hoisted
nub ci                           # clean install from the lockfile

The accepted flags are pnpm's spellings:

--frozen-lockfile / --no-frozen-lockfile / --prefer-frozen-lockfile
--prod, -P            # production install
--dev, -D
--ignore-scripts
--no-optional
--offline / --prefer-offline
--lockfile-only
--force
--node-linker
--registry
--dir, -C             # pnpm's spelling, not npm's --prefix
--reporter <name>     # default, append-only, silent
--silent, -s          # alias for --reporter=silent
--loglevel <level>    # debug, info, warn, error, silent
--os <os>             # which platforms' optional deps to install
--cpu <arch>
--libc <libc>

Packages like esbuild and rollup ship one prebuilt binary per platform as optional dependencies, and an install picks only the one matching the machine it runs on. Pass --os, --cpu, or --libc to pick a different one — when building a Linux container image from a Mac, say:

nub install --os linux --cpu x64

Each flag is repeatable, takes a comma-separated list, and accepts current for the running machine and * for every value. Naming one axis leaves the others alone:

nub install --os linux                  # Linux binaries, host CPU
nub install --os current,linux          # this machine's platform, and Linux
nub install --os '*' --cpu '*'          # every platform variant

These apply to one invocation and are never written to a config file. They also override supportedArchitectures from .npmrc, pnpm-workspace.yaml, or the pnpm object in package.json — Nub reads that setting for pnpm compatibility, and a flag replaces only the axis it names. A value that names no platform warns rather than silently installing nothing.

To quiet the progress output, pass --silent (or -s, or --reporter=silent): nothing reaches stderr but a fatal error, matching pnpm install --silent. The --reporter=append-only form drops the live progress display while keeping the dependency summary, and --loglevel error hides warnings without touching the rest. These spellings apply to every install-family command, and work either after the command (nub install --silent) or before it (nub --silent install).

In a workspace, install and ci accept the same selector flags as script running — install only the packages a filter matches:

--filter <sel>, -F        # pnpm's selector grammar (see /docs/runner/run#--filter)
--recursive, -r           # every workspace package
--filter-prod <sel>       # selector, production deps only
--include-workspace-root  # add the root package to the recursive set
--fail-if-no-match        # error if the filter selects zero packages

nub add

Resolves a package, links it, and writes the dependency into package.json:

nub add <pkg>                  # alias: a
nub add -D <pkg>               # --save-dev
nub add -E <pkg>               # --save-exact (pin, no ^)
nub add -O <pkg>               # --save-optional
nub add --save-peer <pkg>      # peer + dev dependencies (pnpm parity)
nub add -g <pkg>               # global install
nub add -w <pkg>               # write to the workspace root
nub add --save-catalog <pkg>   # add into the workspace catalog
nub add --allow-build=<pkg>    # pre-approve its build scripts for this install
nub add --no-save <pkg>        # link without persisting to package.json
nub add <pkg>@<version>        # pin an exact version
nub add <pkg>@<version> --lockfile-only  # refresh the lockfile, skip node_modules

nub remove

Drops a dependency from package.json and relinks node_modules:

nub remove <pkg>           # rm / uninstall / un / uni
nub remove -D <pkg>        # remove only from devDependencies
nub remove -g <pkg>        # remove a global package
nub remove -w <pkg>        # remove from the workspace root

nub update

Re-resolves dependencies within their ranges; --latest rewrites the package.json ranges to the newest resolved versions:

nub update                 # up — refresh all deps within range
nub update <pkg>           # update a single dependency
nub update <pkg>@<version> # pin one dep to a version, keeping its ^/~ operator
nub update <pkg>@<tag>     # move one dep to a dist-tag (beta, next) as an exact pin
nub update -i              # --interactive: pick each package's target
nub update -L              # --latest: move past the manifest range
nub update -E -L           # pin the rewritten range to an exact version
nub update -D              # devDependencies only
nub update -P              # production only
nub update --lockfile-only # refresh the lockfile, leave node_modules alone

The interactive picker shows one row per outdated dependency, grouped by dependency type. Space (or /) cycles a row between keeping the current version, the newest version inside the manifest range, and the registry's latest — so one invocation covers both in-range refreshes and range-crossing bumps, per package. Nothing is selected by default: press enter and only the rows you flipped are updated. Version targets are colored by semver impact, and a latest that would downgrade a prerelease pin is never offered.

Choose dependency updates
                                         keep             latest in range   latest
  dependencies
❯   @effect/opentelemetry@^4.0.0-beta.1  ■ 4.0.0-beta.90  □ 4.0.0-beta.100  □ 4.0.0-beta.100
    chalk@^4.1.0                         ■ 4.1.0          □ 4.1.2           □ 5.6.2
    react@^17.0.0                        ■ 17.0.1         □ 17.0.2          □ 19.2.8
  devDependencies
    typescript@~5.3.0                    ■ 5.3.2          □ 5.3.3           □ 7.0.2
↑/↓ move · space/←/→ cycle · a cycle all · / filter · enter apply · esc cancel

nub dedupe

Collapses duplicate versions in the lockfile to fewer, shared resolutions:

nub dedupe          # rewrite the lockfile with deduped resolutions
nub dedupe --check  # CI: exit non-zero if dedupe would change anything

nub import

Converts another package manager's lockfile to Nub's pnpm-lock.yaml, without installing:

nub import          # package-lock.json / yarn.lock / bun.lock → pnpm-lock.yaml
nub import --force  # overwrite an existing pnpm-lock.yaml

The full registered verb set covers more:

why          outdated      list, ls
patch        patch-commit  patch-remove
approve-builds  prune      rebuild
fetch        link, unlink  audit
licenses     bin           root
store        config        pkg
publish      pack          dlx          create

nub pm

The install engine is distinct from nub pm, the package meta-manager, which provisions and runs the exact pnpm/npm/yarn your project pins (corepack's job).

  • For "install dependencies," this engine.
  • For "fetch and run the project's pinned PM," nub pm.

The two compose: nub pm shim routes bare npm / pnpm / yarn through the pin while you keep using whatever installer you prefer.

Lifecycle scripts

Some dependencies run build steps on install — preinstall, install, and postinstall scripts declared in their own package.json (across pnpm, npm, and Bun). Nub ships a deny-by-default posture: it does not run them indiscriminately the way npm does. You control which packages build.

nub approve-builds                # approve packages to build, then build them
nub add --allow-build=<pkg> <pkg> # pre-approve its build scripts as you add it
nub rebuild                       # re-run scripts for already-approved packages
nub install --ignore-scripts      # skip dependency build scripts this install

Approval takes effect immediately: nub approve-builds records the decision and runs the just-approved packages' build scripts in the same invocation, matching pnpm — no follow-up nub install or nub rebuild needed.

The neutral allowBuilds field grants permission, and it keys on the package name for a registry dependency or the full specifier for anything else:

package.json
{
  "allowBuilds": {
    "esbuild": true,                        // a registry dependency, by name
    "buildy@file:./buildy-1.0.0.tgz": true, // anything else, by full specifier
    "sharp": false                          // an explicit denial always wins
  }
}

That field applies in any project, whoever owns it. A project owned by another package manager grants permission through its own field as well: pnpm projects use pnpm.onlyBuiltDependencies / pnpm.allowBuilds, and Bun projects use trustedDependencies. A package that wants to build but isn't allowed is skipped, with WARN_NUB_IGNORED_BUILD_SCRIPTS naming it and nub approve-builds as the remedy.

When a build fails

A failed build fails the install. The one exception is a package reachable only through optionalDependencies, which the project has declared it can work without: that failure is reported and the install continues, matching npm and pnpm.

# captured: nub 0.7.5, optfail@1.0.0 whose postinstall exits 3, allowBuilds entry present
$ nub install
WARN optfail@1.0.0 is an optional dependency and failed to build; continuing
     without it: lifecycle script postinstall failed for optfail@1.0.0: script
     `postinstall` exited with code 3   code=WARN_NUB_OPTIONAL_BUILD_FAILED
optionalDependencies:
+ optfail@1.0.0

Optionality is a property of the edge, not the package. A package that anything reaches through a normal dependency is required, and its build failure still fails the install, even when something else depends on it optionally.

The warning is emitted by the install that runs the build. A later install with nothing to do skips the package along with everything else, so it does not repeat. Run nub rebuild <pkg> to attempt the build again and see the failure.

Cooling window

A registry-resolved version must be older than minimumReleaseAge — 24 hours by default — before Nub will install it. The window is what keeps a compromised publish out of your tree during the hours between it going up and being caught.

Asking for a package without naming a version resolves to the newest release that clears the window, so a fresh publish blocks that release rather than the whole command:

$ nub add some-tool
+ some-tool@2.3.0  latest 2.4.0   # ✓ 2.4.0 is still inside the window

The fallback stops at whatever the publisher currently tags latest. A higher version that was published and then untagged is a release they withdrew, so Nub never falls back to one. A prerelease latest fails outright for the same reason — the stable release below it belongs to a line the publisher has moved off.

The report follows the same rule. Both version columns of nub outdated name what an install would land on, and a version the window holds back is marked rather than offered:

$ nub outdated
Package    Current  Wanted   Latest
some-tool  2.3.0    2.3.0 *  2.3.0 *

Note: * marks a version held back by minimumReleaseAge.
  some-tool@2.4.0 becomes installable in about 5h

A project whose only pending upgrade sits inside the window has nothing to act on, so the command exits 0 and a CI check that runs it passes.

Two flags adjust the window for a single command, so getting past a block never means editing config:

$ nub add some-tool
ERR_NUB_NO_MATURE_MATCHING_VERSION            # ❌ every matching version is too new

$ nub add some-tool --minimum-release-age=0                   # turn the window off, this run only
$ nub add some-tool --minimum-release-age=2h                  # or just shorten it
$ nub add some-tool --minimum-release-age-exclude=some-tool   # exempt one package

The duration takes a unit — s, m, h, d, or w — and a bare number means minutes, matching pnpm. Both flags work on every command that resolves from the registry, including the remote bin runner.

The exclude flag replaces the configured minimumReleaseAgeExclude for that run rather than adding to it, matching pnpm — so name every package you still need:

$ nub add some-tool --minimum-release-age-exclude='@internal/*' --minimum-release-age-exclude=some-tool

The window is enforced, not advisory

When nothing satisfying the range is old enough, the install fails — Nub never quietly takes a version that missed the cutoff, and no flag relaxes that. Setting the window to 0 turns it off outright, which is the honest way to say you don't want one.

Publish dates come from the registry's time metadata, so the gate is only as strong as what the registry serves. An age Nub cannot establish counts as a failure rather than a pass:

Registry metadataOutcome
A publish date for the resolved versionChecked against the window
Dates for other versions, none for this oneBlocked
No per-version dates, document older than the windowAllowed — the document's own timestamp bounds every version in it
No per-version dates, document newer than the windowBlocked

Registries that publish no dates at all — some private mirrors, older Verdaccio — hit the last row. The refusal is its own error, ERR_NUB_RELEASE_AGE_MISSING_TIME, rather than the too-new one. Two ways through:

.npmrc
minimumReleaseAgeExclude=internal-pkg   # exempt one package (comma-separated)
minimumReleaseAge=0                     # turn the window off

Setting pnpm's minimumReleaseAgeStrict=false also gets past both, and Nub reads it for compatibility — but it makes an undateable version count as clearing the gate, which is a window you still appear to have and no longer enforce. Prefer minimumReleaseAge=0, which says the same thing plainly.

Default-trust floor

Beyond the packages you approve explicitly, a curated set of well-known packages may build without approval — but only when all three gates hold at once:

GateRequirementOn failure
Registry provenanceResolved from a registry. Git, file, link, tarball, and npm-alias specifiers never qualify — an alias can't borrow a listed name's trust.Not built
Advisory vettingAn OSV MAL-* advisory check ran against this graph, or the graph was inherited from an already-checked lockfile (a frozen install, nub ci, a teammate's clone).Not built
Cooling windowThe resolved version's publish time is older than minimumReleaseAge (default 24 hours).Not built — fails closed on unknown publish time

Explicit decisions outrank the floor in both directions: a package you approve builds regardless of the gates, and an explicit denial still wins.

A fresh resolve, or a lockfile Nub itself wrote (which carries the time: block), gives the floor everything it needs, so curated packages like esbuild build automatically:

# captured: nub 0.0.44, pnpm-incumbent, esbuild@0.21.5 — no allowBuilds entry
$ nub install
WARN defaultTrust: running build scripts for esbuild@0.21.5   # ✓ all three gates passed
dependencies:
+ esbuild@0.21.5

When a gate fails, the floor steps aside rather than guess: the same package is skipped and disclosed, with nub approve-builds as the remedy. Tighten the cooling window past every published version and even a curated package fails closed:

# captured: nub 0.0.44, esbuild — minimumReleaseAge set past every release
$ nub install
WARN ignored build scripts for 1 package(s): esbuild@0.21.5.
     Run `nub approve-builds` to review and enable them.
     code=WARN_NUB_IGNORED_BUILD_SCRIPTS   # ❌ cooling-window gate failed closed
dependencies:
+ esbuild@0.21.5

A foreign lockfile that carries no publish-time data — notably an incumbent bun.lock — trips the same fail-closed path: the cooling gate has nothing to read, so the package is skipped (see the Bun page for the captured A/B).

Advisory gate

The OSV check queries api.osv.dev on a fresh resolve. A confirmed MAL-* hit is a hard block — the install aborts with ERR_NUB_MALICIOUS_PACKAGE, never a skip-and-warn. An osv.dev outage is treated differently: the check fails open, warning and proceeding so a network blip can't brick an offline install. To fail closed on outages too, set advisoryCheck=required (also bundled into paranoid below).

Frozen reinstalls — nub ci, --frozen-lockfile, a teammate's clone — inherit the advisory vetting recorded when the lockfile was written and skip the per-install round-trip, but still enforce the cooling and provenance gates on every install.

Build jail

The OS-level build jail — a network-blocked, filesystem-scoped sandbox around every build script — is compiled in but off by default. Opt in with the neutral paranoid / npm_config_paranoid setting, which also flips the advisory gate to fail-closed. The jail covers macOS and Linux; Windows is a passthrough.

Trust downgrades

Nub also weighs trust evidence across a package's release history — OIDC provenance, a trusted publisher, a staged-publish approval. A resolved version that carries weaker evidence than an earlier-published version of the same package stops the install with ERR_NUB_TRUST_DOWNGRADE, because a maintainer's pipeline that suddenly publishes without the attestation it used to carry is the shape of a token-theft supply-chain attack.

The comparison is by publish date, so a legitimate maintenance release on an older major — shipped after a newer major adopted provenance — can trip it. Nub exempts any version older than 14 days, so an aged, un-yanked backport resolves while a freshly published downgrade is still checked against the full history. Widen the window, clear a single package, or turn the check off in .npmrc:

.npmrc
trustPolicyIgnoreAfter=20160        # age exemption in minutes (default 14 days)
trustPolicyExclude=tailwind-merge   # exempt one package regardless of age
trustPolicy=off                     # disable the check entirely

Store and disk layout

Regardless of the incumbent, Nub installs through a global content-addressed store and links into an isolated virtual store — aube's scheme, under Nub's own directory names.

Global content store

Package files are deduplicated by content hash in a global store at $XDG_DATA_HOME/nub/store/v1/ (default ~/.local/share/nub/store/v1/). Every install imports from it, so a given package version lands on disk once and is shared across projects.

$ nub store path
/Users/you/.local/share/nub/store/v1

Files materialize into node_modules by reflink (APFS/btrfs), hardlink (ext4), or copy fallback — whichever the filesystem supports — so a populated tree costs little extra disk.

Relocating the store

The store location is the store-dir setting. Set it persistently with nub config set, which writes the project's config home:

$ nub config set store-dir /srv/nub-store
set store-dir=/srv/nub-store (/home/you/app/.npmrc)
$ nub store path
/srv/nub-store/v1

For a single invocation — a CI runner, a test sandbox, any run that must not touch or warm-hit the machine's real store — set the environment form instead:

$ npm_config_store_dir=/tmp/scratch-store nub install

Sources, highest first:

  • npm_config_store_dir (environment)
  • storeDir in pnpm-workspace.yaml (pnpm incumbent)
  • store-dir in the project .npmrc
  • store-dir in the user ~/.npmrc
  • $XDG_DATA_HOME/nub/store/ (default)

Nub appends the v1/ schema suffix to the configured directory; a leading ~ expands to the home directory and a relative path resolves against the project root. The packument caches and the shared virtual store live under cache-dir (NUB_CACHE_DIR) instead — move both when a run must stay entirely off the default locations, and point them at the same volume so the virtual store keeps hardlinking out of the CAS.

Cache directory

Registry metadata and the shared virtual store live in a cache at $XDG_CACHE_HOME/nub/pm/ (default ~/.cache/nub/pm/), separate from the content store above. Point it somewhere else — a faster volume, a CI cache mount — with one line in .npmrc:

.npmrc
cache-dir=/mnt/fast/nub-cache

Two environment variables set the same thing, and both outrank the file:

npm_config_cache_dir=/mnt/fast/nub-cache nub install   # neutral — npm and pnpm read it too
NUB_CACHE_DIR=/mnt/fast/nub-cache nub install          # Nub's own spelling, wins over the above

Ask for the effective value rather than guessing which source won:

$ npm_config_cache_dir=/mnt/fast/nub-cache nub config get cache-dir
/mnt/fast/nub-cache

Sharing a volume with store-dir, as the section above recommends, matters only while the shared virtual store is enabled: packages materialize into it by hardlink out of the content store, and a hardlink cannot cross filesystems, so a split degrades every install to a per-file copy — which Nub warns about. In CI the shared store is off by default, so the two can sit on different volumes there at no cost.

Some caches stay at the platform default whatever this is set to: the advisory database, the bootstrapped node-gyp, git clones, and the registry behind nub link -g.

Virtual store

The default node_modules layout is isolated: direct dependencies sit at the top level, transitive packages link into a per-project virtual store, and phantom dependencies fail instead of resolving by accident. Nub's virtual store is node_modules/.store/ (pnpm uses node_modules/.pnpm/) — same shape, not byte-shared, so alternating tools relinks the tree.

Every incumbent defaults to isolated — npm, Yarn, and Bun included, alongside pnpm and Nub's own projects. A project that relies on phantom (undeclared) dependencies opts into the flat, npm-style layout with one line in .npmrc:

.npmrc
node-linker=hoisted

The --node-linker hoisted flag does the same for a single command. When an undeclared package fails to resolve at runtime, Nub's error names it and points at this opt-out. See the virtual store for the per-package-manager breakdown and the flat-versus-project-local choice.

Shared vs per-project store

Outside CI, the isolated virtual store is shared across projects: a package version materializes once per machine and every project links to that copy. It is fast and disk-cheap, but machine-local — the links reach outside the project, so a node_modules copied to another machine won't resolve.

In CI, and under nub ci, each project gets its own self-contained virtual store instead — real directories and relative links, nothing shared. That tree survives a Docker COPY --from into a fresh image, where the shared store wouldn't exist. Force it for any install with one line in .npmrc:

.npmrc
enableGlobalVirtualStore=false

Some packages and tools break when a dependency's real path sits outside the project. Nub detects those and steps around them on its own, so the rest of the tree keeps sharing:

What breaksExampleWhat Nub does
A package importing a backend it never declares@hookform/resolvers/zod reaching your zodMaterializes the adapter into the project
A package writing generated code beside itselfPrisma's postinstall running prisma generateMaterializes the package so generate stays project-local
A bundler that resolves by real pathNext.js; Metro, in bare React Native and Expo before SDK 56Gives the project a self-contained store
A dev server that gates real-path access by allow-listViteWrites node_modules/.modules.yaml, which Vite reads — no vite.config change

Detection scans each package's published code rather than a curated list, so nothing needs maintaining. Add a bundler of your own in .npmrc:

.npmrc
disableGlobalVirtualStoreForPackages=my-bundler   # comma-separated

Offline installs

Like pnpm, Nub relinks from the global store into node_modules when the store already holds every package — no re-download, no byte-for-byte copy. With the default shared virtual store the relink is one symlink per package rather than one link per file. The benchmark below measures the warm reinstall case on a large tree (1,168 packages, 81,398 files): node_modules is removed between runs, packages are already on disk, no network. It runs on Linux, where Bun and Nub's hoisted mode both link with per-file hardlinks, so the hoisted row is a same-layout, same-syscall comparison; the default row is the same install with the per-package relink.

warm reinstall · 1168 packages · Linux (ubuntu-latest)

nub install346 ms
nub install --node-linker hoisted1461 ms · 4.2× slower
bun install1896 ms · 5.5× slower
pnpm install3453 ms · 10× slower
npm ci12945 ms · 37.4× slower

hyperfine, 25 runs / 6 warmup, near-idle ubuntu-latest runner · bun 1.3.14, pnpm 10.34.4, npm on Node 24. View benchmark →

Warm reinstall, not cold

These numbers are the warm-reinstall case — a populated store and an existing lockfile, with node_modules cleared — where the relinking path is the whole cost. A cold install (empty store, fetching from the registry) is a different workload, and Nub does not lead there.

nub install            # offline when the store already holds every package
nub install --offline  # force offline
nub install --prefer-offline  # try the cache first

Lockfile compatibility

Run Nub in a repo that already uses npm, pnpm, Yarn, or Bun and it behaves as that package manager — no migration, no new files, and no nub.lock dropped into a project it doesn't own. Nub infers the incumbent, then mirrors it: same lockfile format, same config files, same manifest fields. Inference walks one precedence chain:

  • packageManager — Corepack standard
  • devEngines.packageManager — object or array form
  • lockfile on disk

In a workspace, the chain runs from any member: Nub walks up to the root, which carries the declaration and lockfile. Two lockfiles for different managers is a hard error unless a declaration names one of them.

IncumbentLockfileRound-trip
npmdocs →package-lock.json, npm-shrinkwrap.jsonread + write
pnpmdocs →pnpm-lock.yaml (v9)read + write
Yarndocs →yarn.lockread-only
Bundocs →bun.lockread + write
Nubdocs →nub.lock (pnpm v9 bytes)read + write

A no-churn guard leaves a graph-equal lockfile untouched. The bun.lockb binary format is rejected — convert to text bun.lock first.

You don't need to use Nub's package manager

The installer is optional. Keep running npm, pnpm, yarn, or bun exactly as you do today, and reach for Nub for everything else — running files, scripts, and binaries.

Config it reads

Config reads are symmetric with the lockfile: under each incumbent Nub reads that tool's branded config and no other's. The neutral .npmrc cascade and npm_config_* are read under every incumbent. Hover a partial chip for the breakdown; each chip is grounded in the detailed table on that incumbent's page.

Package managerConfig it reads
npmpackage-lock.json. Supported. v1 / v2 / v3 read; legacy v1 git/file: deps need a re-lock.npm-shrinkwrap.json. Supported.npmrc. Supportedoverrides. Supportedworkspaces. Supportedengines / os / cpu / libc. Supportednpm_config_*. Supported. Registry-client keys only.
pnpmpnpm-lock.yaml. Supported. v6/v5.4 declined — re-lock under pnpm 9+.pnpm-workspace.yaml. Supported. Workspace and resolution settings; layout keys are not read..pnpmfile.cjs. Supported.npmrc. Supportedpackage.json#pnpm. Supportedpnpm.overrides. Supportedpnpm.packageExtensions. Supportedpnpm.patchedDependencies. Supportedresolutions. Supportedcatalog:. Supportedworkspace:. Supportedworkspaces. SupporteddependenciesMeta.injected. Supportedengines / os / cpu. Supportedpnpm_config_*. Supported. Generic settings under any pnpm version; registry-client keys (registry, proxy, strict-ssl) under pnpm v11+.npm_config_*. Supported
Yarnread-only.npmrc. Supportedresolutions. Supportedcatalog:. Supported. Berry (v2+); a 1.x pin refuses, since Yarn classic has no catalogs.workspace:. Supportedworkspaces. SupportedpackageExtensions. SupporteddependenciesMeta.built. Supportedengines / os / cpu. Supportedyarn.lock. Partially supported. Read-only; writes refused..yarnrc.yml. Partially supported. Not read: per-host proxies, nodeLinker and the layout keys..yarnrc. Partially supported. Registry and auth keys only.YARN_*. Partially supported. Reads the registry, auth token/ident, CA file, proxy, and strict-SSL env values; map-shaped and scoped env config is not translated.nodeLinker: pnp. Not supported. Refused before any write — Berry's default and an explicit pnp both abort.
Bunbun.lock. SupportedtrustedDependencies. Supportedoverrides. Supportedresolutions. SupportedpatchedDependencies. Supportedcatalog:. Supportedworkspace:. Supportedworkspaces. Supportedengines / os / cpu. Supportedbunfig.toml. Partially supported. [install] section only, minus linker — use the neutral .npmrc key or CLI flag.BUN_CONFIG_*. Partially supported. Registry and token only.bun.lockb. Not supported. Binary lockfile rejected — convert to bun.lock text first.

Mirroring runs in both directions, so a field the incumbent would ignore Nub ignores too. A pin written in overrides in a pnpm project changes nothing, because pnpm itself reads resolutions and pnpm.overrides — Nub applies what pnpm would and says which field it skipped:

# captured: nub 0.7.2, pnpm-incumbent project with a top-level overrides block
$ nub install
nub: `overrides` ignored — this project uses pnpm, which doesn't apply it. move these pins to `resolutions`.
dependencies:
+ is-odd@3.0.1

Declaring the same pin in both fields is the portable thing to do and stays silent, since the ignore changes nothing. Branded config from a different manager is never read: Nub in a pnpm project ignores Bun's trustedDependencies, and under its own identity it reads neither that nor pnpm.overrides, pnpm-workspace.yaml, .pnpmfile.cjs, pnpm_config_*, .yarnrc.yml, or bunfig.toml. To keep pnpm hooks or pnpm-named workspace config active, stay pnpm-owned or run nub pm use pnpm.

Layout settings

Nub does not mirror layout settings from an incumbent's branded config file. The rest of that file — registries, auth, resolution settings, overrides — is read normally.

Package managerIts own layout settingsUnder Nub
npminstall-strategy, global-style, legacy-bundlingNot read
pnpmnodeLinker, hoist, symlink, and related keys in pnpm-workspace.yaml or global config.yamlNot read
YarnnodeLinker, nmHoistingLimits, nmModeNot read. Plug'n'Play is refused outright, before any write — there is no node_modules tree for Nub to install into
Bunlinker, under [install] in bunfig.tomlNot read

Set layout with install.linker and install.publicHoist in nub.jsonc, their neutral .npmrc spellings, or command-line flags. Those sources work under every incumbent. Nub reports a branded layout setting when it finds one so the ignored request is not silent.

Switching to Nub

Running nub pm use nub moves a project onto Nub's own surface: it aligns the manifest and lockfile, migrates pnpm workspace config into the neutral package.json fields, and writes nub.lock. A fresh nub install in a project with no declaration and no lockfile does the same thing implicitly.

Either way Nub records itself with a non-locking range rather than an exact pin. Tools that read devEngines by name see the signal, and the caret is a floor, so upgrading Nub just works.

package.json
{
  "devEngines": {
    "packageManager": {
      "name": "nub",
      "version": "^0.7.1",   // a floor, not a pin
      "onFail": "ignore"     // a signal to read, not a rule to enforce
    }
  }
}

To freeze the project at an exact version instead — the corepack-visible hard pin — run nub pm use nub@<version>. Config a Nub-owned project no longer reads is called out rather than dropped:

# captured: Nub-owned project (nub.lock) with a stray pnpm-workspace.yaml
$ nub install
nub: pnpm-workspace.yaml is not read under nub identity — migrate it
     (`nub pm use nub`), delete it, or return to pnpm (`nub pm use pnpm`).

When signals disagree

Nub stops rather than guess. Two lockfiles it cannot choose between:

$ nub install
Error: ERR_NUB_LOCKFILE_AMBIGUOUS

  × multiple lockfiles found: pnpm-lock.yaml, package-lock.json — cannot tell
  │ which package manager owns this project
  help: remove the stale lockfile, or run nub pm use <pm> naming a specific
        manager

A hosted builder is the common way to reach this state: it runs its own install beside the lockfile you committed, leaving two. See Cloudflare for the build-level fix.

A declaration whose lockfile is missing:

$ nub install   # packageManager: "pnpm@9.0.0"
Error: ERR_NUB_LOCKFILE_DECLARATION_MISMATCH

  × package.json declares `pnpm` (via `packageManager`), but
  │ pnpm-lock.yaml is missing — found package-lock.json instead
  help: nub pm use <pm> to declare it, or remove the stale lockfile

In a Nub-owned project, nub.lock beside a foreign lockfile is the same ambiguity error.

Inference vs the pinned PM

This inference picks the install engine's incumbent — the format Nub reads and writes. It is separate from the version nub pm provisions: the meta-manager resolves a pin (.yarnrc.yml yarnPathpackageManagerdevEngines) to fetch and run an exact PM binary. Same signals, different questions: "which format do I install in?" versus "which PM binary do I run?".