Package manager
Nub ships its own installer with a pnpm-shaped CLI and lockfile-compatibility with whatever your project already uses — npm, pnpm, and Bun round-trip, Yarn read-only.
Nub has its own install engine: it resolves the dependency graph and links node_modules. What makes it a new kind of package manager is that it does not force incumbent projects into a Nub-only format — it reads and rewrites the project's native lockfile, so npm, pnpm, and Bun round-trip cleanly and Yarn is consumed read-only. The CLI is pnpm-shaped; the engine is the vendored aube engine, embedded as a library and driven by Nub's own CLI.
Nub never asks which package manager you use — it infers the incumbent and mirrors it. Each one has its own page covering lockfile fidelity, config surfaces, and gaps: pnpm, npm, Bun, Yarn.
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. Nub infers the incumbent, then mirrors it: same lockfile format, same config files, same manifest fields. Inference walks one precedence chain:
packageManager— Corepack standarddevEngines.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. Declaring Nub itself does not settle it: Nub preserves whatever format the project already uses, so with two candidates there is nothing to choose between.
| Incumbent | Lockfile | Round-trip |
|---|---|---|
| npm — docs → | package-lock.json, npm-shrinkwrap.json | read + write |
| pnpm — docs → | pnpm-lock.yaml (v9) | read + write |
| Yarn — docs → | yarn.lock | read-only |
| Bun — docs → | bun.lock | read + write |
| Nub — docs → | nub.lock (pnpm v9 bytes) | read + write |
A no-churn guard leaves a graph-equal lockfile untouched, and Nub never drops its own lockfile into a project it doesn't own. The bun.lockb binary format is rejected — convert to text bun.lock first.
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 manager | Config it reads |
|---|---|
| npm | package-lock.json. Supported. v1 / v2 / v3 read; legacy v1 git/file: deps need a re-lock.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.Registry-client keys only. |
| pnpm | pnpm-lock.yaml. Supported. v6/v5.4 declined — re-lock under pnpm 9+.v6/v5.4 declined — re-lock under pnpm 9+.pnpm-workspace.yaml. Supported. Pnpm 11+ layout and resolution settings.Pnpm 11+ layout and resolution settings..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+.Generic settings under any pnpm version; registry-client keys (registry, proxy, strict-ssl) under pnpm v11+.npm_config_*. Supported |
| Yarnread-only | .npmrc. Supportedresolutions. Supportedworkspace:. Supportedworkspaces. SupportedpackageExtensions. SupporteddependenciesMeta.built. Supportedengines / os / cpu. Supportedyarn.lock. Partially supported. Read-only; writes refused.Read-only; writes refused..yarnrc.yml. Partially supported. Not read: per-host proxies, nodeLinker and the layout keys.Not read: per-host proxies, nodeLinker and the layout keys..yarnrc. Partially supported. Registry and auth keys only.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.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.Refused before any write — Berry's default and an explicit pnp both abort. |
| Bun | bun.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.[install] section only, minus linker — use the neutral .npmrc key or CLI flag.BUN_CONFIG_*. Partially supported. Registry and token only.Registry and token only.bun.lockb. Not supported. Binary lockfile rejected — convert to bun.lock text first.Binary lockfile rejected — convert to bun.lock text first. |
Under its own identity, Nub reads only neutral, cross-tool config — never another manager's branded fields:
| Package manager | Config it reads |
|---|---|
| nub | nub.lock. Supported.npmrc. Supportedoverrides. Supportedresolutions. SupportedpackageExtensions. SupportedpatchedDependencies. Supportedcatalog:. Supportedworkspace:. Supportedworkspaces. Supportedengines.node. Supportednpm_config_*. Supported |
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.
Resolution versus layout
The tables above cover resolution and the supported layout settings for each incumbent. A project keeps the settings its incumbent package manager already carries; the neutral node-linker key in .npmrc remains available where that package-manager version reads it.
Layout support differs by incumbent:
| Incumbent | Setting | Under Nub |
|---|---|---|
| npm | install-strategy | No effect — except nested, which aborts rather than install a tree that resolves differently |
| npm | global-style | No effect |
| npm | legacy-bundling | No effect |
| pnpm 11+ | nodeLinker and the hoisting keys in pnpm-workspace.yaml | Honored |
| pnpm 10 and below | node-linker and the hoisting keys in .npmrc | Honored |
| pnpm | symlink=false | No effect, with a warning — the isolated layout is its symlink graph |
| Yarn | nodeLinker, nmHoistingLimits, nmMode in .yarnrc.yml | No effect |
| Yarn | nodeLinker: pnp | Refused — ERR_NUB_PNP_UNSUPPORTED, before any write |
| Bun | linker under [install] in bunfig.toml | No effect |
Plug'n'Play is the one that errors instead of being ignored, because it is not a preference Nub can decline: a PnP project has no node_modules tree to install into at all. Everything else in those files — registries, auth, resolution settings, overrides — is read exactly as before. To pick a layout, see the flat opt-out.
Nub identity
A project is Nub's own in three cases: it declares Nub through nub pm use nub, nub.lock is the only lockfile signal, or a fresh project has no declaration and no lockfile. The switch aligns the manifest and lockfile, migrates pnpm workspace config into neutral package.json fields, and moves the project onto a neutral surface:
- Lockfile:
nub.lock— the pnpm v9 schema byte-for-byte, under Nub's own basename - Package fields:
workspaces,overrides,resolutions,packageExtensions,patchedDependencies,allowBuilds,engines.node,scripts - Config and env: the
.npmrccascade,npm_config_*, neutral env (CI, proxies), plusNUB_CACHE_DIR,NUB_CONCURRENCY,NUB_PRIMER_TTL - Install state:
node_modules/.store/,.nub-state, and the global store under Nub's own directories
A fresh nub install records Nub as the manager with a non-locking range — devEngines.packageManager set to { name: "nub", version: "^<version>", onFail: "warn" }, never an exact packageManager pin. Tools that read devEngines by name see the signal, and the caret is a floor, so upgrading Nub just works. To freeze the project at an exact Nub version — the corepack-visible hard pin — opt in with nub pm use nub@<version>; the bare nub pm use nub writes only the range.
This is the brand boundary in both directions: Nub never emits its own brand into your config, and under its own identity reads only neutral, cross-tool config — never pnpm-workspace.yaml, .pnpmfile.cjs, the pnpm.* namespace, pnpm_config_*, .yarnrc.yml, bunfig.toml, Bun trustedDependencies, or aube's AUBE_* knobs. To keep pnpm hooks or pnpm-named workspace config active, stay pnpm-owned or run nub pm use pnpm.
# captured: Nub-identity 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`).Contradictions are loud
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
managerA 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 lockfileUnder Nub identity, nub.lock beside a foreign lockfile is the 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 yarnPath → packageManager → devEngines) 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?".
Package extensions
A published package sometimes declares a peer dependency it doesn't actually need, or is missing one it does — and its package.json isn't yours to edit. A top-level packageExtensions field patches a dependency's manifest at resolve time, adding to its dependencies, optionalDependencies, peerDependencies, or peerDependenciesMeta. The selector is a package name, optionally pinned to a version range:
// package.json
{
"packageExtensions": {
"react-server-dom-webpack@19.2.7": {
"peerDependenciesMeta": {
"react": { "optional": true } // relax a peer the package over-declares
}
}
}
}An extension only adds what's missing — it never overrides a range the package already declares. To change the version of a dependency a package does declare, use overrides instead. The semantics match pnpm's packageExtensions, so a project moving off pnpm keeps the same escape hatch under a neutral field.
Compat mode
Runtime augmentation is reversible. Passing --node, or setting a truthy NODE_COMPAT for a whole tree, runs the project's pinned Node vanilla — version provisioning stays on, augmentation comes off. See the runtime overview for the full contract.
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 lockfileThe 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, silentTo 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 packagesnub 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_modulesnub 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 rootnub 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 aloneThe 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 cancelnub 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 anythingnub 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.yamlThe 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 createnub 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 installApproval 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.
Which manifest field grants permission tracks the inferred incumbent: pnpm projects use pnpm.onlyBuiltDependencies / pnpm.allowBuilds, Bun projects use trustedDependencies, and the neutral allowBuilds field plus nub approve-builds apply in any project. An explicit denial (allowBuilds: { pkg: false }, neverBuiltDependencies) always wins. 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.
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.
Publish dates come from the registry's time metadata, so the gate is only as strong as what the registry serves. Under the default minimumReleaseAgeStrict=true, an age Nub cannot establish counts as a failure rather than a pass:
| Registry metadata | Outcome |
|---|---|
| A publish date for the resolved version | Checked against the window |
| Dates for other versions, none for this one | Blocked |
| No per-version dates, document older than the window | Allowed — the document's own timestamp bounds every version in it |
| No per-version dates, document newer than the window | Blocked |
Registries that publish no dates at all — some private mirrors, older Verdaccio — hit the last row, and a packument missing a date for just the version you need hits the second. Either way the refusal is its own error, ERR_NUB_RELEASE_AGE_MISSING_TIME, kept separate from a version being too new because no window would ever have admitted these versions. Two ways through:
minimumReleaseAgeExclude=internal-pkg # exempt one package (comma-separated)
minimumReleaseAge=0 # turn the window offLoosening strictness is a third, and it is not a smaller window. With minimumReleaseAgeStrict=false an undateable version counts as clearing the gate, so the newest version matching your range is installed with no age checked at all. Where versions are dated and all of them are too new, the same setting instead falls back to the lowest satisfying version.
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:
| Gate | Requirement | On failure |
|---|---|---|
| Registry provenance | Resolved 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 vetting | An 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 window | The 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 — through the incumbent's allow-list (pnpm.onlyBuiltDependencies / pnpm.allowBuilds under pnpm, trustedDependencies under Bun), the neutral allowBuilds field, or nub approve-builds — builds regardless; an explicit denial (allowBuilds: { pkg: false }, neverBuiltDependencies) always 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.5When 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.5A 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, because a malicious-package advisory isn't a judgement call. 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:
trustPolicyIgnoreAfter=20160 # age exemption in minutes (default 14 days)
trustPolicyExclude=tailwind-merge # exempt one package regardless of age
trustPolicy=off # disable the check entirelyStore 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/v1Files 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.
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-identity projects. A project that relies on phantom (undeclared) dependencies opts into the flat, npm-style layout with one line in .npmrc:
node-linker=hoistedThe --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:
enableGlobalVirtualStore=falseA few packages statically import a backend you pick at runtime — @hookform/resolvers/zod imports the zod your app installs, without declaring it. Under the shared store the adapter's real path sits outside the project, so Node's directory walk can't reach that backend and the import throws. Nub materializes those adapters into the project automatically so they resolve, while the rest of the tree keeps sharing.
The same treatment covers packages that write generated code back into their own install directory. Prisma's postinstall runs prisma generate, which writes the generated client next to @prisma/client on disk — and under the shared store that spot is machine-global, keyed by version rather than by project or schema. Two projects with different Prisma schemas would otherwise share one generated client and silently overwrite each other's models. Nub materializes @prisma/client into each project so generate stays project-local, matching pnpm. This detection is automatic — it scans each package's real published code, so there is no list to maintain.
A bundler that resolves modules by their real path — Metro (bare React Native, and Expo before SDK 56), Next.js — only crawls the project directory, so it can't see the shared store. Nub gives those projects a self-contained, project-local store automatically. Expo gained store support in SDK 56 (its On-demand Filesystem), so newer Expo apps keep the shared store. Extend the list in .npmrc:
disableGlobalVirtualStoreForPackages[]=my-bundlerVite is the exception that keeps the shared store. Its dev server gates real-path file access through a configurable allow-list, not a resolver crawl, so a store-resident module served over /@fs would otherwise be rejected with 403 … outside of Vite serving allow list. Nub tells Vite about the store automatically: it writes node_modules/.modules.yaml, which Vite 8.1+ reads natively, and for older Vite it backports the same check into the project's own copy. vite dev then serves the store with no vite.config change, and the fix lives on disk in node_modules — so it works whether or not Nub is in the process (a teammate on plain Vite, or CI).
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)
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 firstCreating a project
Scaffold a new TypeScript-first project — manifest, tsconfig, entry file, git, and installed dev dependencies — in one command.
pnpm
Nub mirrors pnpm most closely — native version-9 lockfile read and write, the full dependency-verb surface, workspace selectors, an isolated dependency tree, and pnpm-owned config, all gated on pnpm being the incumbent.