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@^12

use 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 pin

Partial 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@^12

jup 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 up

For 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.4

That 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.

lockfileVersionjup runs
9.0its usual default — pnpm 9, 10, 11 and 12 all write this format
6.0 or 6.1pnpm 8
5.4pnpm 7
5.3pnpm 6
5.2pnpm 5.10 or newer 5.x
5.1pnpm 3.5 to 5.9
5pnpm 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@latest

Or 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 on packageManager — 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.0

This 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 --here

use --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.

jup  Pin and run the right package manager or runtime for every project.