Configure a project
A project pin tells jup which tool and version to run.
#Package-manager pins
The simplest pin lives in package.json:
{
"packageManager": "pnpm@12.0.0"
}Do not write a bare name such as "pnpm". A manifest pin must include a
version, range, tag, or supported URL form.
The easiest way to create a pin is:
jup use pnpm@^12use accepts:
jup use pnpm # current stable release, then write an exact pin
jup use pnpm@12.0.0 # exact release
jup use pnpm@^12 # keep this range and create jup.lock
jup use pnpm@latest # resolve the tag now, then write an exact pinPartial versions such as 12 and 12.1 are also accepted as ranges. The caret
form makes the intent clearer. jup keeps a range as typed
and records its current answer in jup.lock.
Automatic selection skips prereleases unless the selector asks for one or
JUP_ENABLE_PRERELEASES=1 is set.
#Exact pins and digests
An exact pin is reproducible by itself. For a portable artifact, jup writes the checked digest in the version suffix:
{
"devEngines": {
"packageManager": {
"name": "yarn",
"version": "4.0.0+sha512.0123abcd..."
}
}
}Do not calculate this value by hand. jup use writes the digest of the bytes it
verified.
Some tools publish different artifacts for each operating system and architecture. Their manifest pin stays digest-free:
{
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "12.0.0"
}
}
}jup still verifies the current host's artifact. A single host-specific digest in
package.json would break the project on another host.
#Keep a range with jup.lock
A range records intent:
{
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^12"
}
}
}Run this once to select and record a release:
jup use --lock pnpm@^12jup keeps the range in package.json and creates jup.lock beside it. Commit
both files. Future runs use the recorded release without asking which matching
release is newest.
Without --lock, jup use pnpm@^12 still writes the range and installs a
matching release; it just records the choice in the short-lived cache below
instead of creating a file in your tree. Once jup.lock exists, use and up
keep it up to date without the flag.
Update that decision when you choose:
jup upFor a range, up changes the jup.lock entry and leaves the range alone. Only
use and up change the committed lockfile.
up refreshes an existing jup.lock; it creates one only with --lock. On
a project with no committed lockfile it selects and installs the newest matching
release, records it in the short-lived cache below, and leaves the project
without a lockfile.
For tools with per-host artifacts, the lockfile can hold one integrity value per
host. The selected version is shared. A new host with no digest entry can still
use that version after registry signature verification; run use or up on
each target host if your policy requires every host digest to be committed.
Set JUP_FROZEN_LOCKFILE=1 when a command must not create, refresh, or remove a
lockfile entry. It refuses only the commands that would change the file, so jup up on a project with no jup.lock still runs.
Exact pins do not use the lockfile, so --lock has no effect on them.
To stop committing a lockfile, delete jup.lock. Later runs then resolve the
range through the short-lived cache again.
#The package manager's own lockfile
pnpm 12 and later record the version they ran for a devEngines range in
pnpm-lock.yaml, under the root importer:
importers:
.:
packageManagerDependencies:
pnpm:
specifier: ^12
version: 12.3.4That file is committed, so jup reads it. When a range has no jup.lock answer,
the version recorded there is the version jup runs. Every checkout then runs the
same pnpm, and pnpm install stops rewriting the line on each machine.
jup only reads it, and only when the recorded specifier is the range your
manifest currently declares and the version still satisfies it. jup.lock wins
where both are present, and jup up still moves a range forward. Set
JUP_ENABLE_PM_LOCKFILE=0 to ignore the file.
#A project with no spec at all
If your package.json names no package manager, jup normally runs its default
version. A pnpm-lock.yaml beside it can still say which major to run: the
lockfileVersion on its first line is the format pnpm wrote it in, and pnpm
changes that format on major boundaries.
lockfileVersion | jup runs |
|---|---|
9.0 | its usual default — pnpm 9, 10, 11 and 12 all write this format |
6.0 or 6.1 | pnpm 8 |
5.4 | pnpm 7 |
5.3 | pnpm 6 |
5.2 | pnpm 5.10 or newer 5.x |
5.1 | pnpm 3.5 to 5.9 |
5 | pnpm 3.0 to 3.4 |
This matters because running the wrong major over an old lockfile is not a no-op:
a newer pnpm rewrites the file in its own format and re-resolves your pinned
versions while doing it, and pnpm install --frozen-lockfile fails outright. So
a fresh clone of an old project gets the pnpm that project's lockfile was written
for, without anyone editing anything.
It is a fallback and nothing more. Anything you actually declare —
packageManager, devEngines.packageManager, jup.lock — decides instead, the
guessed version is never recorded as your machine's default, and
JUP_ENABLE_PM_LOCKFILE=0 switches it off along with the read above. To settle
the question for good, pin it:
jup use pnpm@8#The short-lived resolution cache
If a range has no committed answer in either file, jup may remember its registry
answer in node_modules/.jup/jup.lock for 24 hours. This is a disposable cache, not a
project lockfile. It is only created when node_modules already exists.
When that entry expires and the registry has a temporary outage, jup can use the stale version and print an advisory. It does not use stale data to bypass a disabled network, authentication error, TLS failure, release-age policy, or ordinary 4xx response.
#Registry tags
A tag in the manifest, such as pnpm@latest, changes as the registry changes.
Its short-lived answer may be cached, but it is not a committed decision and
jup up will not update it.
For repeatable builds, resolve it to an exact pin:
jup use pnpm@latestOr replace it with a range and commit jup.lock.
#devEngines.packageManager
A project may use Node's structured form:
{
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^12",
"onFail": "error"
}
}
}version is a semver range. onFail can be:
error— stop on a mismatch (also the default);warn— print a warning and continue;ignore— continue silently;download— continue silently, because jup has already downloaded and run the version this object declares. Where jup cannot do that — the object is malformed, or names no version, or its digest is unusable or disagrees with the one onpackageManager— it warns instead.
download is pnpm's; npm documents only the first three.
This is where jup use and jup up write the pin, and where jup reads it
first.
When both this object and packageManager exist, the structured object wins as
long as it names a version; a disagreement is still reported according to
onFail. An object that names only a package manager has not said which release
to run, so the top-level packageManager answers instead.
Writing a pin consolidates the two. The pin goes into this object, and an
existing top-level packageManager is removed rather than updated beside it:
one pin, in the one field that can carry a range, an onFail and a digest at
once. jup never creates a packageManager field.
jup use replaces whatever the object declared, including a range: the object
is the pin, so a range left behind would be what the next run resolved. jup up
against a declared range does not touch the manifest at all — it refreshes the
recorded release in jup.lock.
A pin records the release digest. In this object, the digest is stored as SRI in
integrity, next to a clean semver version. In the top-level packageManager
string, it is stored in the <version>+<algo>.<hex> suffix. Both forms are read
the same way.
To commit the version alone:
jup use --no-integrity yarn@4.6.0This also removes any existing digest from the manifest. The pin is still resolved and installed, and the registry signature still verifies the download.
Tools that read only packageManager — Corepack, npm, Yarn — will not see a pin
jup wrote. If you need them to, keep a packageManager field by hand; note that
jup use and jup up remove it again the next time they write a pin, and that
it can only ever hold an exact version, never a range.
#Pin Node.js
Node.js is a runtime, not a package manager. Put it in devEngines.runtime:
jup use node@^22{
"devEngines": {
"runtime": {
"name": "node",
"version": "^22"
}
}
}Because ^22 is a range, this also creates a jup.lock entry for the selected
Node release. Use an exact selector such as node@22.1.0 when you want the
manifest pin itself to be exact.
A project can pin Node and a package manager at the same time. The two settings
do not conflict. jup use node@... writes the runtime pin and does not run a
package-manager install afterward.
Never put Node in packageManager; jup rejects that and points to
devEngines.runtime.
#bun, deno, and nub
These three are package managers and runtimes. They are pinned like any other
package manager — packageManager or devEngines.packageManager — and jup use bun@1.4.0 writes that pin.
Because they are also runtimes, another manager's pin never blocks them: in a
pnpm project, bun server.ts and deno run main.ts run the requested tool at
its own version rather than failing with "This project is configured to use
pnpm". Note that this covers every invocation, bun install included, so a
project that wants installs to go through its pinned manager should rely on the
manager's own lockfile rather than on jup rejecting the command.
#Use .nvmrc
When devEngines.runtime is absent, jup reads the nearest .nvmrc. It accepts
numeric versions and ranges, with an optional leading v. node and stable
mean the latest release. Comments, blank lines, and nvm-style key=value lines
are ignored.
jup does not interpret local nvm state. Aliases such as system, default,
iojs, lts/*, LTS codenames, and user aliases cannot be resolved: jup warns,
ignores the file, and runs its default Node version. Replace the alias with a
version/range or add devEngines.runtime to say what it meant.
A .nvmrc that carries no version at all — empty, or two of them — is an error
rather than a warning, because nvm rejects it too.
jup only reads .nvmrc; it never edits it. A manifest runtime declaration wins.
#Workspaces and --here
Read-only commands walk upward and let nested packages inherit a declaration.
Commands that write stop at a workspace root (a manifest with workspaces or a
directory with pnpm-workspace.yaml). jup prints every path it changes.
Target only the current directory with:
jup use --here pnpm@^12
jup up --hereuse --here can create package.json. up --here needs an existing usable pin.
#Project env files
jup can load the nearest .jup.env; if that name is absent in the same
directory, it tries .corepack.env. The search stops at the project boundary.
The real process environment always wins.
Only approved JUP_/compatible COREPACK_ variables are accepted. Project env
files cannot choose the store, shim directory, credentials, trust keys, TLS
weakening, a spec override, host runtimes, or other protected settings.
See Settings for the exact list.