メインコンテンツまでスキップ

pnpm 11.23

· 約16分
Zoltan Kochan
Lead maintainer of pnpm

pnpm 11.23 gives the registries setting a shape that describes each registry once — its tarball layout, the scopes routed to it, the prefix it answers to — so an Artifactory or GitLab registry can finally keep its tarball URLs out of pnpm-lock.yaml. It adds virtualStoreType, makes undeclared imports resolve under ESM with the global virtual store without a plugin, teaches pnpm config get to report the settings pnpm actually acts on, and warns about settings no pnpm version recognizes.

Minor Changes

One declaration per registry

The registries setting now declares a registry once, keyed by its URL, with everything pnpm knows about that registry in the entry:

pnpm-workspace.yaml
registries:
https://artifactory.example.com/artifactory/api/npm/npm-virtual/:
serverType: artifactory
scopes: ['@acme', '@acme-internal']
prefix: work
  • serverType tells pnpm how the registry lays out tarball URLs, which decides whether a URL can be omitted from pnpm-lock.yaml. Undeclared is strict — only the exact canonical URL is treated as reconstructible. npm says the registry behaves like registry.npmjs.org, which also serves a scoped package from its percent-encoded path; declare it for a faithful mirror or caching proxy. artifactory says the registry repeats the scope in a scoped package's tarball filename (@acme/widget/-/@acme/widget-1.0.0.tgz) where the npm registry strips it, so pnpm can rebuild that URL instead of writing it out for every scoped package (get-npm-tarball-url#16).
  • scopes lists the @-prefixed scopes that resolve from this registry. A bare '@' is the scope-less default registry, the one the registry setting names.
  • prefix is the alias a dependency addresses this registry by, as in "foo": "work:^1.0.0". namedRegistries is deprecated in favor of it, and is still read for prefixes registries does not declare.

The layout is never inferred from the URL, so nothing changes unless you declare it, and registry.npmjs.org continues to behave as npm without being declared. Because the lockfile depends on serverType, it is read from pnpm-workspace.yaml only — one developer's machine cannot shape a lockfile their collaborators read back with a different layout. Credentials are rejected in this setting, in a key as well as in a field, and still belong in .npmrc. An entry that routes nothing to itself and matches no configured registry is now reported as a warning rather than sitting there inert.

The older <scope>: <url> shape still works and needs no change. See the new Registries page for the whole setting.

Per-registry time field support

A registry can now declare that its abbreviated metadata carries the time field, so resolutionMode: time-based reads the much larger full metadata document only from the registries that need it:

pnpm-workspace.yaml
resolutionMode: time-based
registries:
https://npm.internal.example/:
supportsTimeField: true

registry.npmjs.org omits time from abbreviated metadata, and the fallback used to be all-or-nothing: registrySupportsTimeField answered for every registry at once, so a project resolving from both the public registry and a Verdaccio instance either paid for full metadata everywhere or claimed a time field npmjs does not serve. The answer is now per registry, and registrySupportsTimeField remains the answer for every registry that does not declare one.

virtualStoreType

The new virtualStoreType setting names where the virtual store lives — one store per machine, or one per project:

pnpm-workspace.yaml
virtualStoreType: global # or: project

It is the canonical spelling of enableGlobalVirtualStore, which keeps working; when a project sets both, virtualStoreType wins. It can also be set through PNPM_CONFIG_VIRTUAL_STORE_TYPE and read back with pnpm config get virtualStoreType. The default is unchanged — project, so the global virtual store stays opt-in. The setting is independent of nodeLinker: isolated and pnp both work with either store type, and hoisted writes no virtual store at all.

Undeclared imports resolve under ESM with the global virtual store

Dependencies that import packages they never declared ("phantom" dependencies) resolved under the global virtual store only for CommonJS, because Node.js ignores NODE_PATH for ESM imports. Working around it meant installing the @pnpm/plugin-esm-node-path config dependency.

Now every process pnpm spawns for the project — pnpm run, pnpm exec, lifecycle scripts — receives both a NODE_PATH pointing at the project's hoisted node_modules and a NODE_OPTIONS --import flag that registers a resolve hook restoring NODE_PATH lookups for ESM (#9618). Tools run by pnpm dlx resolve such dependencies too: the JS CLI passes them the same environment, while the Rust CLI's dlx cache is self-contained, so its layout already exposes them. A project that sets extendNodePath to false opts out of the whole mechanism, resolve hook included.

pnpm config reports the settings pnpm acts on

pnpm config get and pnpm config list now show the settings pnpm acts on, under their documented names, rather than the raw values a particular file happens to carry:

  • registries shows the registries pnpm resolves from, merged across every source (.npmrc, pnpm-workspace.yaml, the global config, CLI flags), in the shape the setting is written in. Built-in routes are included — the @jsr scope and the npmjs and gh prefixes — unless pointed elsewhere. Previously pnpm config get registries printed undefined.
  • registry and @scope:registry show the merged routes rather than raw .npmrc values, so they always agree with the registries view.
  • update and audit show the effective sections, whichever spelling set them; the deprecated internal spellings (updateConfig, auditConfig, auditLevel) are no longer listed.
  • catalogs shows the complete resolved catalog set — the singular catalog block is its default entry — whichever spelling declared it.

A warning for settings no pnpm version recognizes

A key in the global config file that this version of pnpm does not read is no longer reported with advice to move it to a project-level pnpm-workspace.yaml, where it would be ignored too. The warning now says the setting is not recognized by this version of pnpm, names the pnpm version that does read it when there is one (globalShims, for instance, is a pnpm v12 setting), and suggests the closest real setting name when the key looks like a typo. Unrecognized and non-camelCase keys in a project's pnpm-workspace.yaml, previously ignored silently, are now reported the same way.

pnpm config get <key> and pnpm get <key> no longer print config-load warnings, so a script capturing the value gets the value alone.

The importPackage pnpmfile hook is deprecated

pnpm now warns when a pnpmfile defines importPackage, and the hook will be removed in the next major version. It also opts the installation out of the parallel package importer, making installation slower. If you rely on it, comment on #14101.

Reading an installed tree uses the project's own configuration

node_modules/.modules.yaml no longer records the registries an install resolved from, and the recorded copy is dropped from the file on the first install that rewrites it.

It dated from the lockfile format that spelled a dependency's path relative to its registry, where reading an installed tree meant knowing the registries it was installed with. Dependency paths have not carried a registry for several major versions, and the recorded copy outlived its use: pnpm list, pnpm why, and single-project installs preferred it over the project's own configuration, so a project whose registry had changed since its last install was still read through the old one. They now use the configured registries, like every other command already did.

Re-hashed store files are reported

An install that had to re-hash store files to verify them now says so. If that cost more than a second, it reports how long — The integrity of N files was checked in 2.5s. — and if it was quick but covered more than a thousand files, it names the cause instead: their timestamps changed since the store recorded them, which a backup tool, an antivirus scan, or a copied store can do.

pnpr

A pnpr resolve request now carries the client's registries the way the registries setting declares them — keyed by URL, with the scopes routed to each, the bare-specifier prefix each answers to, and each one's serverType — in place of the prefix map it used to send. The server routes them through the same inversion the config reader runs, so a pnpr-served install resolves a scoped dependency from the registry that scope is routed to, which it previously could not: only the default registry and the prefix-addressed ones reached the server.

A registry a request only declares is no longer refused up front for being off the server's allowlist, so a stray @scope:registry in a developer's ~/.npmrc no longer fails every install against a pnpr server that does not serve it. The boundary moves to the fetch itself: an origin the resolve does reach is refused before the request leaves the server, with the same message. A request also carries the client's resolutionMode now — time-based and lowest-direct reached the server as nothing at all, leaving it on its highest default — and honors the client's autoInstallPeers, dedupePeers, and excludeLinksFromLockfile (#13389).

These changes alter the resolve and verify-lockfile request bodies. A pnpr server and its clients have to be on matching versions; the protocol is still experimental and unversioned.

Patch Changes

pnpm audit no longer points at versions that do not exist

The patched version an advisory implies is now checked against the registry packument. A range such as >=4.17.24, inferred from <=4.17.23, is corrected to the lowest non-deprecated published version that satisfies it (>=4.18.1, when 4.17.24 does not exist and 4.18.0 is deprecated), and when no published version satisfies it the report shows Patched versions: None (#13824).

This also keeps pnpm audit --fix from writing overrides or minimumReleaseAgeExclude entries for patches that do not exist — an entry for an unpublished version would have let a later publish of it bypass the release-age gate (#11563). In --json output, patched_versions is now null for such an advisory, so tooling can tell "no fix available" from "fix available at version X".

A bumped pin can no longer pass a frozen install

A frozen install no longer rewrites the packageManagerDependencies block of pnpm-lock.yaml. When the pnpm version pinned by devEngines.packageManager (or by packageManager) is missing from the lockfile or no longer matches it, --frozen-lockfile fails with ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE instead of resolving the version and saving it, so a manifest whose pin was bumped without regenerating the lockfile can no longer pass CI (#14009).

pnpm init writes a pin Corepack accepts

pnpm init now pins the exact pnpm version instead of a ^ range, and records it in the packageManager field alongside devEngines.packageManager. Corepack reads only packageManager and accepts nothing but an exact version, so it rejected the generated package.json with "expected a semver version" (#13969). A package created inside an existing workspace is still left unpinned — it follows the pin at the workspace root — and --no-init-package-manager still scaffolds a manifest without any pin.

pnpm update <name>@<version> stays where you pointed it

An exact pinned update now touches only the packages you named. Unrelated dependencies keep their locked resolution, and in a recursive run copies of the same package on another major line — or, for a 0.x request, another minor line — are left alone. A selector that renames the package it installs, pnpm update <alias>@npm:<pkg>@<version> or the jsr: equivalent, targets the package the alias installs rather than the alias.

When the package is not a direct dependency of any selected project, the command now fails with ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP instead of quietly updating it to whatever a fresh install would resolve: there is nowhere to record the version in that case, so the error points at the overrides entry that does pin a transitive dependency. Ranges and tags are unaffected.

Registries that serve scoped packages only from an encoded path

Installing from GitHub Enterprise Server and similar registries failed with 404. Outside registry.npmjs.org, a tarball URL that encodes the scope separator as %2f or %2F is no longer mistaken for one pnpm can rebuild from the package's name, version, and registry, so it is kept in pnpm-lock.yaml and requested verbatim on the next install (#13534).

trustPolicy honors the missing-time opt-in

trustPolicy: no-downgrade no longer aborts the install with ERR_PNPM_MISSING_TIME on registries that serve no per-version time field when minimumReleaseAgeIgnoreMissingTime is set. The trust check reads the same publish dates the minimumReleaseAge check does, so it now honors the same opt-in and skips the affected package with a warning (#12446). The warning also names the check it is reporting on, so a package whose minimumReleaseAge and trustPolicy checks are both skipped warns about both instead of only the first.

That opt-in covers a registry that cannot date its releases, not a package a registry says it never published: during lockfile verification, a lockfile entry missing from a packument that does date every version it lists remains a hard failure.

Build approvals

  • pnpm add --allow-build adds to the allowBuilds entries already in pnpm-workspace.yaml instead of replacing them (#13872), and pending build approvals survive the removal of an unrelated dependency.
  • pnpm approve-builds removes onlyBuiltDependencies, onlyBuiltDependenciesFile, neverBuiltDependencies, and ignoredBuiltDependencies from pnpm-workspace.yaml when it writes allowBuilds. Those settings were replaced by allowBuilds in pnpm 11 and silently ignored since, so a workspace migrated from pnpm 10 kept them around looking active.
  • pnpm remove prunes undecided entries ("set this to true or false") from allowBuilds when sharedWorkspaceLockfile: true and the corresponding packages are removed (#13892).

Other notable fixes

  • pnpm deploy --prod and pnpm deploy --no-optional no longer list the excluded dependency groups in the deployed package.json and pnpm-lock.yaml. The deployed lockfile referenced packages the deploy left out of its graph, so installing in the deploy directory afterwards created dangling symlinks (#13623).
  • Overrides are no longer written into the metadata cache, so a removed override stops applying on the next install (#13918). An override change is also absorbed by the fast lockfile update even when another, unchanged override uses the catalog: protocol — previously any catalog:-valued override forced a full re-resolution whenever the override list changed.
  • trustPolicyExclude and minimumReleaseAgeExclude set to a single string instead of a list were read one character at a time, so the exclusion never matched the package it named — and a * anywhere in it matched every package, silently switching the policy off. Bare and wildcard rules now evaluate consistently in the evaluator and the normalizer (#13725).
  • On Windows, upgrading pnpm no longer leaves a stale pnpm.ps1 behind. PowerShell resolves pnpm.ps1 ahead of pnpm.cmd, so a shim written by an older installation kept running the previous version (#13919).
  • A git dependency installed over HTTPS from a hosted repository keeps its branch, tag, or version range in the specifier recorded in package.json. It was written back without one, so the next pnpm update moved the dependency to the repository's default branch (#13999).
  • pnpm update --global --latest no longer fails with a 404 for a package that was not added from the registry by name. Packages installed from a local path, a git repository, a tarball URL, an npm: alias, or a named registry keep their spec during a global update (#12854).
  • A runtime installed through devEngines.runtime matches the host when supportedArchitectures lists several platforms. Listing os: [darwin, linux] and cpu: [x64, arm64] used to install the runtime built for the first entry of each list, so a Linux arm64 machine got a macOS x64 Node.js that could not execute (#13898).
  • Installs are faster in workspaces that declare inter-workspace dependencies with plain ranges ("*", "^1.2.3") rather than the workspace: protocol: with preferWorkspacePackages enabled, linking such a dependency no longer makes a registry request that cannot change the outcome, and workspace packages that were never published no longer cost a 404 on every install. Resolution also reuses cached metadata when lockfile preferences prove no uncached version can win (#13976).
  • pnpm install sometimes did not exit after printing Done in Xs (#12297), and failed to read .modules.yaml files containing long dependency paths — the manifest is now parsed as JSON, the format pnpm writes it in, falling back to the YAML parser only for manifests written by old pnpm versions (#13875).
  • pnpm set-script updates package.json instead of failing with ERR_PNPM_NOT_IMPLEMENTED (#13956), pnpm pkg get/set accept hyphens inside a dot-notation property path, pnpm version <bump> --dry-run no longer edits package.json files (#13953), and pnpm sbom fails with ERR_PNPM_SBOM_MISSING_IMPORTERS when the lockfile has no entry for a selected project instead of writing an SBOM that under-reports it.
  • pnpm patch-commit works in project and edit paths containing non-ASCII characters, and packed workspace package manifests preserve dependency order, making repeated pnpm pack output deterministic (#10167).
  • pnpm change sizes its package and bump pickers from the terminal height instead of always showing 7 rows (#13815), and canceling the prompt with Ctrl-c reports Change canceled and exits successfully instead of printing a stack trace (#13814).
  • verifyDepsBeforeRun is no longer ignored when set to install, warn, error, or prompt through PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN or --config.verify-deps-before-run — only the boolean values were accepted before (#13816).
  • A custom fetcher can no longer replace the archive integrity that pnpm-lock.yaml pins: the locked value is restored after a canFetch or fetch hook rewrites the resolution, and delegating a locked archive to a directory or git source now fails instead of installing unverified content.
  • Full metadata is re-fetched when minimumReleaseAge is enabled and an abbreviated packument's time map omits timestamps for some versions, which used to filter out mature versions and drop resolution to the lowest match (#13741).

For everything else in this release, see the full notes for v11.23.0.