feat(installing): delegate resolution to pacquet >= 0.11.7 when configured (#12210)

* feat(installing): delegate resolution to pacquet >= 0.11 when configured

When pacquet is declared in configDependencies, pnpm previously always
ran it with --frozen-lockfile (pnpm resolved, pacquet materialized). If
the installed pacquet is >= 0.11 it ships its own resolver, so a
non-frozen plain install on the default isolated linker is now delegated
end-to-end: pacquet resolves, writes pnpm-lock.yaml, and materializes in
a single pass. Older pacquet keeps the resolve-then-materialize split,
and add/update/remove still resolve in pnpm.

* test(pnpm): cover pacquet 0.11 resolution delegation end-to-end

Bump the e2e pacquet pin to 0.11.0 (published under both pacquet and
@pnpm/pacquet) and add tests for the resolve path, the materialize-only
fallback (pinned to 0.2.14), and the scoped alias. Un-skip the add/update
tests now that pacquet 0.11 writes a compatible .modules.yaml, and fix
the update test (is-positive has no v4; update from 1.0.0 instead).

Also preserve the configDependencies env document when pacquet resolves:
pacquet rewrites pnpm-lock.yaml without the leading env YAML doc, which
dropped configDependencies and broke the next --frozen-lockfile install.
Capture it before delegating and restore it after.

* fix(installing): restore configDependencies env document even when pacquet fails

On the pacquet resolve-delegation path the configDependencies env document
was only restored after a successful pacquet run. A non-zero exit could
leave a rewritten pnpm-lock.yaml without it, breaking the next
--frozen-lockfile install's config-deps freshness gate. Restore it in a
finally block, swallowing (and warning on) any restore error so it cannot
mask the original pacquet failure.

* fix(installing): require pacquet 0.11.7 for resolving installs

* fix(installing): skip pacquet in lockfile check mode

* fix(installing): harden pacquet lockfile handoff

* fix(installing): preserve policy handling with pacquet

* fix(installing): skip pacquet when lockfile is disabled

* fix(installing): skip pacquet with branch lockfiles
This commit is contained in:
Zoltan Kochan
2026-06-14 16:51:25 +02:00
committed by GitHub
parent da248c3eef
commit 74a2dc9027
10 changed files with 539 additions and 103 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/installing.commands": minor
"@pnpm/installing.deps-installer": minor
"pnpm": minor
---
When [`pacquet`](https://github.com/pnpm/pnpm/tree/main/pacquet) (the Rust port of pnpm) is declared in `configDependencies`, pnpm now delegates dependency **resolution** to it too — not just materialization — provided the installed pacquet is new enough to support full resolving installs (>= 0.11.7).
Previously pacquet only ran in frozen-install mode: pnpm always resolved the dependency graph itself (writing `pnpm-lock.yaml`) and handed pacquet a finished lockfile to fetch / import / link. With pacquet >= 0.11.7, a non-frozen `pnpm install` (default isolated `nodeLinker`, plain install) is delegated to pacquet end-to-end in a single pass — pacquet resolves the manifests, writes the lockfile, and materializes `node_modules`. pnpm detects the capability from the installed pacquet's version; older pacquet releases keep the resolve-then-materialize split, and `add` / `update` / `remove` still resolve in pnpm (it has to mutate the manifests first). This remains an opt-in preview of the Rust install engine [#11723](https://github.com/pnpm/pnpm/issues/11723).

View File

@@ -123,11 +123,14 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
pnpmfile: string[]
/**
* Alternative install engine (today: pacquet) the deps-installer
* delegates the materialization phase to. Built in `installDeps`
* when `configDependencies.pacquet` is declared, threaded through
* here so the recursive workspace path picks it up too.
* delegates the install to. Built in `installDeps` when
* `configDependencies.pacquet` is declared, threaded through here so
* the recursive workspace path picks it up too.
*/
runPacquet?: () => Promise<void>
runPacquet?: {
supportsResolution: boolean
run: (opts?: { filterResolvedProgress?: boolean, resolve?: boolean }) => Promise<void>
}
} & Partial<
Pick<Config,
| 'ci'

View File

@@ -51,21 +51,6 @@ export interface MakeRunPacquetOpts {
isInstallCommand: boolean
}
/**
* Build the install-engine callback `mutateModules` invokes when
* `configDependencies` declares pacquet.
*
* The callback spawns the pacquet binary installed under
* `node_modules/.pnpm-config/pacquet`. From `pnpm install`/`pnpm i` it
* forwards the user's own pnpm CLI flags to pacquet's `install`
* subcommand; from `add`/`update`/`dedupe` it doesn't forward (warning
* instead). Pacquet's NDJSON stderr is parsed line-by-line and the
* valid JSON records are re-emitted on pnpm's global `streamParser` so
* `@pnpm/cli.default-reporter` renders pacquet's events the same way it
* renders pnpm's own. Non-JSON stderr lines (panic backtraces,
* unexpected diagnostics) are forwarded to the real stderr verbatim so
* they reach the user.
*/
/** Args the deps-installer passes per pacquet invocation. */
export interface RunPacquetCallOpts {
/**
@@ -79,9 +64,56 @@ export interface RunPacquetCallOpts {
* source.
*/
filterResolvedProgress?: boolean
/**
* `true` to let pacquet perform the resolution itself rather than
* materialize an already-resolved lockfile. Drops the injected
* `--frozen-lockfile`, so pacquet resolves the manifests, writes
* `pnpm-lock.yaml`, and materializes in a single pass. Only valid
* when {@link PacquetEngine.supportsResolution} is `true` (pacquet
* >= 0.11.7).
*/
resolve?: boolean
}
export function makeRunPacquet (opts: MakeRunPacquetOpts): (callOpts?: RunPacquetCallOpts) => Promise<void> {
/**
* Handle to the pacquet install engine: its capabilities plus the
* callback `mutateModules` invokes to run it.
*/
export interface PacquetEngine {
/**
* `true` when the installed pacquet is new enough (>= 0.11.7) to
* perform dependency resolution itself. When `false`, pacquet can
* only materialize an already-resolved lockfile, so the deps-installer
* runs its own resolve pass first and hands the written lockfile to
* pacquet.
*/
supportsResolution: boolean
run: (callOpts?: RunPacquetCallOpts) => Promise<void>
}
/**
* Build the pacquet install engine `mutateModules` delegates to when
* `configDependencies` declares pacquet.
*
* `run` spawns the pacquet binary installed under
* `node_modules/.pnpm-config/pacquet`. From `pnpm install`/`pnpm i` it
* forwards the user's own pnpm CLI flags to pacquet's `install`
* subcommand; from `add`/`update`/`dedupe` it doesn't forward (warning
* instead). Pacquet's NDJSON stderr is parsed line-by-line and the
* valid JSON records are re-emitted on pnpm's global `streamParser` so
* `@pnpm/cli.default-reporter` renders pacquet's events the same way it
* renders pnpm's own. Non-JSON stderr lines (panic backtraces,
* unexpected diagnostics) are forwarded to the real stderr verbatim so
* they reach the user.
*/
export function makeRunPacquet (opts: MakeRunPacquetOpts): PacquetEngine {
return {
supportsResolution: pacquetSupportsResolution(resolvePacquetVersion(opts.lockfileDir, opts.packageName)),
run: makeRun(opts),
}
}
function makeRun (opts: MakeRunPacquetOpts): (callOpts?: RunPacquetCallOpts) => Promise<void> {
return async (callOpts) => {
const pacquetBin = resolvePacquetBin(opts.lockfileDir, opts.packageName)
// From `pnpm install`/`pnpm i` we forward the user's flags through to
@@ -94,16 +126,21 @@ export function makeRunPacquet (opts: MakeRunPacquetOpts): (callOpts?: RunPacque
// `pnpm-workspace.yaml` / `.npmrc` on its own, so a non-install
// delegation isn't broken by the omission.
const forwardedFlags = opts.isInstallCommand ? collectForwardedFlags(opts.argv) : []
// `--ignore-manifest-check` tells pacquet to skip its per-importer
// `package.json` ↔ `pnpm-lock.yaml` freshness gate. pnpm just
// resolved and wrote the lockfile itself; on `pnpm up` / `add` /
// `remove` the manifest on disk is still the pre-mutation copy
// (pnpm writes it after `mutateModules` returns), so pacquet's own
// check would always fire here. See
// In resolve mode pacquet does the resolution itself, so it must not
// be pinned to the existing lockfile — drop both injected flags.
//
// Otherwise (frozen materialization) inject `--frozen-lockfile` plus
// `--ignore-manifest-check`. The latter tells pacquet to skip its
// per-importer `package.json` ↔ `pnpm-lock.yaml` freshness gate:
// pnpm just resolved and wrote the lockfile itself; on `pnpm up` /
// `add` / `remove` the manifest on disk is still the pre-mutation
// copy (pnpm writes it after `mutateModules` returns), so pacquet's
// own check would always fire here. See
// https://github.com/pnpm/pnpm/issues/11797. The flag is narrow
// (only the manifest check); settings drift like `overrides` is
// still enforced and was already re-validated by pnpm.
const args = ['--reporter=ndjson', 'install', '--frozen-lockfile', '--ignore-manifest-check', ...forwardedFlags]
const frozenArgs = callOpts?.resolve === true ? [] : ['--frozen-lockfile', '--ignore-manifest-check']
const args = ['--reporter=ndjson', 'install', ...frozenArgs, ...forwardedFlags]
const droppedFlags = opts.isInstallCommand ? [] : collectDroppedFlags(opts.argv)
if (droppedFlags.length > 0) {
logger.warn({
@@ -193,6 +230,35 @@ export function pacquetPlatformPkgName (): string {
return `@pacquet/${process.platform}-${process.arch}${libc}`
}
/**
* Read the installed pacquet's version from its `package.json` under
* `node_modules/.pnpm-config/<packageName>`. Returns `undefined` if it
* can't be read — callers treat that as "assume the older,
* materialization-only pacquet" so a missing/garbled manifest degrades
* to the safe path rather than failing the install.
*/
function resolvePacquetVersion (lockfileDir: string, packageName: 'pacquet' | '@pnpm/pacquet'): string | undefined {
try {
const pacquetPkg = fs.realpathSync(path.join(lockfileDir, 'node_modules/.pnpm-config', packageName, 'package.json'))
const { version } = JSON.parse(fs.readFileSync(pacquetPkg, 'utf8')) as { version?: string }
return version
} catch {
return undefined
}
}
/**
* pacquet gained full resolving installs in 0.11.7; earlier releases
* stay on pnpm's resolve-then-materialize path. Pre-release builds of
* 0.11.7 (e.g. `0.11.7-rc.1`) count as supporting it.
*/
function pacquetSupportsResolution (version: string | undefined): boolean {
if (version == null) return false
const [major, minor, patch] = version.split('.', 3).map((part) => parseInt(part, 10))
if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) return false
return major > 0 || (major === 0 && (minor > 11 || (minor === 11 && patch >= 7)))
}
/**
* From `pnpm install`/`pnpm i`, return everything in argv that should
* ride along to pacquet's own `install` subcommand. Drops the

View File

@@ -0,0 +1,57 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from '@jest/globals'
import { temporaryDirectory } from 'tempy'
import { makeRunPacquet } from '../lib/runPacquet.js'
function setupPacquetConfigDep (version: string | undefined, packageName: 'pacquet' | '@pnpm/pacquet' = 'pacquet'): string {
const lockfileDir = temporaryDirectory()
const pkgDir = path.join(lockfileDir, 'node_modules/.pnpm-config', packageName)
fs.mkdirSync(pkgDir, { recursive: true })
fs.writeFileSync(
path.join(pkgDir, 'package.json'),
JSON.stringify(version == null ? { name: packageName } : { name: packageName, version })
)
return lockfileDir
}
function makeEngine (lockfileDir: string, packageName: 'pacquet' | '@pnpm/pacquet' = 'pacquet'): ReturnType<typeof makeRunPacquet> {
return makeRunPacquet({
lockfileDir,
packageName,
argv: { original: [], remain: [] },
isInstallCommand: true,
})
}
test.each([
['0.11.7', true],
['0.11.7-rc.1', true],
['0.12.3', true],
['1.0.0', true],
['0.11.6', false],
['0.11.0', false],
['0.10.99', false],
['0.2.2', false],
['0.0.1', false],
])('pacquet %s -> supportsResolution %s', (version, expected) => {
const engine = makeEngine(setupPacquetConfigDep(version))
expect(engine.supportsResolution).toBe(expected)
})
test('supportsResolution is false when the pacquet version cannot be read', () => {
const engine = makeEngine(setupPacquetConfigDep(undefined))
expect(engine.supportsResolution).toBe(false)
})
test('supportsResolution is false when the pacquet config dependency is absent', () => {
const engine = makeEngine(temporaryDirectory())
expect(engine.supportsResolution).toBe(false)
})
test('the version is read from the @pnpm/pacquet scoped alias too', () => {
const lockfileDir = setupPacquetConfigDep('0.11.7', '@pnpm/pacquet')
expect(makeEngine(lockfileDir, '@pnpm/pacquet').supportsResolution).toBe(true)
})

View File

@@ -235,21 +235,30 @@ export interface StrictInstallOptions {
packageVulnerabilityAudit?: PackageVulnerabilityAudit
blockExoticSubdeps?: boolean
/**
* Optional alternative install engine. When set, the frozen-install
* path invokes this callback instead of `headlessInstall`. The CLI
* layer constructs it (today: spawning the pacquet binary installed
* via `configDependencies` and forwarding pnpm's own CLI argv); the
* installer treats it as an opaque "do the install" hook so it
* doesn't need to know about pacquet's binary path, CLI surface, or
* any settings that only pacquet consumes.
* Optional alternative install engine. When set, the installer
* delegates the install to `run` instead of calling `headlessInstall`.
* The CLI layer constructs it (today: the pacquet binary installed via
* `configDependencies`, forwarding pnpm's own CLI argv); the installer
* treats it as an opaque "do the install" hook so it doesn't need to
* know about pacquet's binary path, CLI surface, or any settings that
* only pacquet consumes.
*
* `filterResolvedProgress` tells the helper to drop the engine's
* own `pnpm:progress status:resolved` events because pnpm already
* emitted one per package during a preceding lockfileOnly resolve
* pass. The frozen-install path passes `false` (or nothing): no
* resolve pass ran, so the engine's events are the only source.
* `supportsResolution` is `true` when the engine can resolve
* dependencies itself (pacquet >= 0.11.7). When `false` the installer
* runs its own resolve pass first and the engine only materializes the
* written lockfile.
*
* `run`'s `filterResolvedProgress` tells the helper to drop the
* engine's own `pnpm:progress status:resolved` events because pnpm
* already emitted one per package during a preceding lockfileOnly
* resolve pass. `resolve` tells the engine to do the resolution
* itself (non-frozen install). The frozen/materialize paths leave
* both unset.
*/
runPacquet?: (opts?: { filterResolvedProgress?: boolean }) => Promise<void>
runPacquet?: {
supportsResolution: boolean
run: (opts?: { filterResolvedProgress?: boolean, resolve?: boolean }) => Promise<void>
}
/**
* If true, `mutateModules` does not emit the per-install `summary` log
* event. Used by `pnpm add -g` when it runs multiple isolated installs

View File

@@ -46,8 +46,11 @@ import {
isEmptyLockfile,
type LockfileObject,
type ProjectSnapshot,
readEnvLockfile,
readWantedLockfile,
readWantedLockfileFile,
writeCurrentLockfile,
writeEnvLockfile,
writeLockfiles,
writeWantedLockfile,
} from '@pnpm/lockfile.fs'
@@ -371,11 +374,12 @@ export async function mutateModules (
// install even mid-flight, and an install that finishes first is held back
// until the verdict arrives.
//
// Skipped when we already know pacquet will run the install: pacquet's
// frozen-install path applies the same resolver-policy gate (port of
// this function), so re-running here would duplicate the work — and
// for `minimumReleaseAge` in strict mode each lockfile entry is an
// HTTP probe.
// Skipped when we already know pacquet will run the install: pacquet
// applies the same resolver-policy gate (port of this function) whether
// it materializes a frozen lockfile or re-resolves from the manifests,
// so re-running here would duplicate the work — and for
// `minimumReleaseAge` in strict mode each lockfile entry is an HTTP
// probe.
//
// The predicate mirrors every short-circuit `tryFrozenInstall` checks
// before reaching the pacquet branch: anything that would make it
@@ -385,13 +389,28 @@ export async function mutateModules (
// isn't known here — so verification still runs in that window, the
// duplicate is bounded to it.
const willDelegateToPacquet = opts.runPacquet != null &&
opts.useLockfile &&
!opts.useGitBranchLockfile &&
!opts.mergeGitBranchLockfiles &&
opts.lockfileCheck == null &&
opts.enableModulesDir &&
installsOnly &&
!opts.lockfileOnly &&
!opts.fixLockfile &&
!opts.dedupe &&
!ctx.lockfileHadConflicts &&
ctx.existsNonEmptyWantedLockfile &&
(opts.frozenLockfile === true || opts.frozenLockfileIfExists === true)
(
// Frozen materialization: pacquet reads the existing lockfile and
// re-applies the resolver-policy gate as it walks it.
(ctx.existsNonEmptyWantedLockfile &&
(opts.frozenLockfile === true || opts.frozenLockfileIfExists === true)) ||
// Resolving install: pacquet (>= 0.11.7) re-resolves from the
// manifests itself — applying the policy during fresh resolution —
// so the existing lockfile entries verified here would just be
// discarded. If a policy handler is active, keep resolution in pnpm
// so violations can be returned to the command layer.
(opts.saveLockfile && opts.runPacquet.supportsResolution && opts.frozenLockfile !== true && opts.nodeLinker !== 'hoisted' && opts.handleResolutionPolicyViolations == null)
)
let verifyLockfilePromise: Promise<void> | undefined
if (!willDelegateToPacquet && !opts.trustLockfile) {
const cacheActive = opts.cacheDir != null && opts.resolutionVerifiers.length > 0
@@ -1044,9 +1063,9 @@ Note that in CI environments, this setting is enabled by default.`,
} else {
logger.info({ message: 'Lockfile is up to date, resolution step is skipped', prefix: opts.lockfileDir })
}
if (opts.runPacquet != null) {
if (opts.runPacquet != null && opts.useLockfile && !opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles && opts.lockfileCheck == null && opts.enableModulesDir) {
try {
await opts.runPacquet()
await opts.runPacquet.run()
} catch (err) {
// Same reasoning as the verifyLockfileResolutions catch above: this
// is the user-facing failure path, so detach the reporter listener
@@ -1921,6 +1940,29 @@ function allMutationsAreInstalls (projects: MutatedProject[]): boolean {
return projects.every((project) => project.mutation === 'install' && !project.update && !project.updateMatching)
}
/**
* The `InstallFunctionResult` for an install pacquet resolved and
* materialized end-to-end. pacquet wrote `pnpm-lock.yaml` and the
* `node_modules` tree itself. `ctx.wantedLockfile` has already been
* refreshed from disk, and pacquet reports its own stats / ignored-builds
* via NDJSON, so the structured `stats` / `ignoredBuilds` fall back to
* their no-op defaults. Resolution-policy handlers are guarded out before
* this path, so there are no command-layer policy violations to return.
* Manifests are returned unchanged — this path only runs for plain
* installs, which don't rewrite `package.json`.
*/
function pacquetResolveResult (projects: ImporterToUpdate[], ctx: PnpmContext): InstallFunctionResult {
return {
newLockfile: ctx.wantedLockfile,
projects: projects.map((project) => ({
manifest: project.originalManifest ?? project.manifest,
rootDir: project.rootDir,
})),
depsRequiringBuild: [],
resolutionPolicyViolations: [],
}
}
/**
* Run the pacquet binary if it's configured, otherwise run the JS
* `headlessInstall`. Callers can hand off any code path that materializes
@@ -1937,16 +1979,28 @@ function allMutationsAreInstalls (projects: MutatedProject[]): boolean {
* stats record and a no-op ignoredBuilds iteration).
*/
async function materializeOrDelegate (
opts: { runPacquet?: (opts?: { filterResolvedProgress?: boolean }) => Promise<void> },
opts: {
mergeGitBranchLockfiles?: boolean
runPacquet?: { run: (opts?: { filterResolvedProgress?: boolean }) => Promise<void> }
saveLockfile?: boolean
useGitBranchLockfile?: boolean
useLockfile?: boolean
},
runHeadlessInstall: () => Promise<{ stats: InstallationResultStats, ignoredBuilds: IgnoredBuilds | undefined }>
): Promise<{ stats?: InstallationResultStats, ignoredBuilds?: IgnoredBuilds }> {
if (opts.runPacquet != null) {
if (
opts.runPacquet != null &&
opts.useLockfile !== false &&
opts.saveLockfile !== false &&
opts.useGitBranchLockfile !== true &&
opts.mergeGitBranchLockfiles !== true
) {
// Reached only from the resolve-then-materialize call sites
// (workspace-partial, hoisted-linker, pnpr server install). Each ran a
// lockfileOnly resolve pass that emitted one
// `pnpm:progress status:resolved` per package, so pacquet's
// duplicate `resolved` events would double the reporter's count.
await opts.runPacquet({ filterResolvedProgress: true })
await opts.runPacquet.run({ filterResolvedProgress: true })
return {}
}
return runHeadlessInstall()
@@ -1958,7 +2012,7 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => {
if (!opts.frozenLockfile && opts.useLockfile) {
const allProjectsLocatedInsideWorkspace = Object.values(ctx.projects)
.filter((project) => isPathInsideWorkspace(project.rootDirRealPath ?? project.rootDir))
if (allProjectsLocatedInsideWorkspace.length > projects.length) {
if (allProjectsLocatedInsideWorkspace.length > projects.length && opts.lockfileCheck == null && opts.enableModulesDir) {
const newProjects = [...projects]
const getWantedDepsOpts = {
autoInstallPeers: opts.autoInstallPeers,
@@ -2010,7 +2064,7 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => {
}
}
}
if (opts.nodeLinker === 'hoisted' && !opts.lockfileOnly) {
if (opts.nodeLinker === 'hoisted' && !opts.lockfileOnly && opts.lockfileCheck == null && opts.enableModulesDir) {
const result = await _installInContext(projects, ctx, {
...opts,
lockfileOnly: true,
@@ -2036,20 +2090,67 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => {
ignoredBuilds,
}
}
// Isolated `nodeLinker` (the default) with a non-frozen install:
// pacquet doesn't ship a resolver yet, so split the install in two —
// ask `_installInContext` for a `lockfileOnly` resolve pass (writes
// `pnpm-lock.yaml`), then hand the freshly-written lockfile to
// pacquet for the fetch / import / link / build phases. The frozen
// branch is handled earlier in `tryFrozenInstall`; the hoisted
// branch above already runs the same resolve-then-materialize
// sequence (it had to even before pacquet existed). When no pacquet
// is configured this falls through to the full single-pass install.
if (opts.runPacquet != null && !opts.lockfileOnly) {
// Isolated `nodeLinker` (the default) with a non-frozen install.
// The frozen branch is handled earlier in `tryFrozenInstall`; the
// hoisted branch above runs a resolve-then-materialize sequence.
if (opts.runPacquet != null && opts.useLockfile && opts.saveLockfile && !opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles && !opts.lockfileOnly && opts.lockfileCheck == null && opts.enableModulesDir) {
// pacquet >= 0.11.7 resolves itself: hand it the whole install
// (resolve + fetch + import + link + build, writing the lockfile)
// in a single non-frozen pass. Only for plain installs — `add` /
// `update` / `remove` need pnpm to mutate the manifests and
// resolve the new specs first (pacquet's `install` reads
// package.json from disk, which pnpm hasn't rewritten yet).
if (opts.runPacquet.supportsResolution && !opts.frozenLockfile && opts.handleResolutionPolicyViolations == null && allMutationsAreInstalls(projects)) {
// `configDependencies` are recorded in a YAML document prepended
// to `pnpm-lock.yaml` — purely a pnpm concept that pacquet doesn't
// model. Capture it before pacquet rewrites the lockfile and
// restore it afterwards (`writeEnvLockfile` re-reads pacquet's main
// document and re-prepends the env document), otherwise the next
// `--frozen-lockfile` install fails its config-deps freshness gate.
// The restore runs even if pacquet fails partway: a non-zero exit can
// still leave a rewritten lockfile behind, so the env document must be
// put back regardless.
const envLockfile = await readEnvLockfile(ctx.lockfileDir)
let pacquetError: unknown
try {
await opts.runPacquet.run({ resolve: true })
} catch (err: unknown) {
pacquetError = err
throw err
} finally {
if (envLockfile != null) {
await writeEnvLockfile(ctx.lockfileDir, envLockfile).catch((restoreErr: Error) => {
if (pacquetError == null) {
throw restoreErr
}
logger.warn({
error: restoreErr,
message: `Failed to restore the configDependencies document in pnpm-lock.yaml: ${restoreErr.message}`,
prefix: ctx.lockfileDir,
})
})
}
}
const wantedLockfile = await readWantedLockfile(ctx.lockfileDir, {
ignoreIncompatible: opts.force || opts.ci === true,
mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
useGitBranchLockfile: opts.useGitBranchLockfile,
wantedVersions: [LOCKFILE_VERSION],
})
if (wantedLockfile == null) {
throw new PnpmError('PACQUET_LOCKFILE_READ_FAILED', `pacquet did not write a readable ${WANTED_LOCKFILE}`)
}
ctx.wantedLockfile = wantedLockfile
return pacquetResolveResult(projects, ctx)
}
// Older pacquet can only materialize: split the install in two —
// ask `_installInContext` for a `lockfileOnly` resolve pass (writes
// `pnpm-lock.yaml`), then hand the freshly-written lockfile to
// pacquet for the fetch / import / link / build phases. The resolve
// pass emitted a `pnpm:progress status:resolved` per package; ask
// pacquet to drop its own duplicates.
const result = await _installInContext(projects, ctx, { ...opts, lockfileOnly: true })
// The resolve pass above emitted a `pnpm:progress status:resolved`
// per package; ask pacquet to drop its own duplicates.
await opts.runPacquet({ filterResolvedProgress: true })
await opts.runPacquet.run({ filterResolvedProgress: true })
return result
}
return await _installInContext(projects, ctx, opts)

View File

@@ -1,7 +1,9 @@
import { expect, test } from '@jest/globals'
import { expect, jest, test } from '@jest/globals'
import { LOCKFILE_VERSION, WANTED_LOCKFILE } from '@pnpm/constants'
import { addDependenciesToPackage, install } from '@pnpm/installing.deps-installer'
import { prepareEmpty } from '@pnpm/prepare'
import { addDistTag } from '@pnpm/testing.registry-mock'
import { writeYamlFileSync } from 'write-yaml-file'
import { testDefaults } from '../utils/index.js'
@@ -25,6 +27,111 @@ test('prefer version ranges specified for top dependencies', async () => {
expect(lockfile.packages).not.toHaveProperty(['@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0'])
})
test('does not delegate lockfile check mode to pacquet', async () => {
prepareEmpty()
await install({
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '100.0.0',
},
}, testDefaults())
const lockfileCheck = jest.fn()
const runPacquet = jest.fn<() => Promise<void>>().mockResolvedValue(undefined)
await install({
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '100.0.0',
},
}, testDefaults({
dedupe: true,
lockfileCheck,
runPacquet: {
supportsResolution: true,
run: runPacquet,
},
}))
expect(lockfileCheck).toHaveBeenCalled()
expect(runPacquet).not.toHaveBeenCalled()
})
test('does not delegate no-lockfile installs to pacquet', async () => {
const project = prepareEmpty()
const runPacquet = jest.fn<() => Promise<void>>().mockResolvedValue(undefined)
await install({
dependencies: {
'is-positive': '1.0.0',
},
}, testDefaults({
runPacquet: {
supportsResolution: true,
run: runPacquet,
},
useLockfile: false,
}))
expect(runPacquet).not.toHaveBeenCalled()
expect(project.readLockfile()).toBeFalsy()
})
test('uses the lockfile written by pacquet for post-install checks', async () => {
prepareEmpty()
await install({
dependencies: {
'is-positive': '1.0.0',
},
}, testDefaults({
allowBuilds: {
'@pnpm.e2e/pre-and-postinstall-scripts-example': true,
},
}))
const depPath = '@pnpm.e2e/pre-and-postinstall-scripts-example@1.0.0'
const runPacquet = jest.fn<(opts?: { resolve?: boolean }) => Promise<void>>().mockImplementation(async () => {
writeYamlFileSync(WANTED_LOCKFILE, {
importers: {
'.': {
dependencies: {
'@pnpm.e2e/pre-and-postinstall-scripts-example': {
specifier: '1.0.0',
version: '1.0.0',
},
},
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
[depPath]: {
resolution: {
integrity: 'sha512-test',
},
},
},
snapshots: {
[depPath]: {},
},
}, { lineWidth: 1000 })
})
const { ignoredBuilds } = await install({
dependencies: {
'@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0',
},
}, testDefaults({
allowBuilds: {},
runPacquet: {
supportsResolution: true,
run: runPacquet,
},
}))
expect(runPacquet).toHaveBeenCalledWith({ resolve: true })
expect(Array.from(ignoredBuilds ?? [])).toContain(depPath)
})
test('prefer version ranges specified for top dependencies, when doing named installation', async () => {
const project = prepareEmpty()

View File

@@ -34,6 +34,30 @@ test('install with git-branch-lockfile = true', async () => {
expect(fs.existsSync(WANTED_LOCKFILE)).toBe(false)
})
test('git-branch-lockfile installs are not delegated to pacquet', async () => {
prepareEmpty()
const branchName: string = 'main-branch'
jest.mocked(getCurrentBranch).mockReturnValue(Promise.resolve(branchName))
const runPacquet = jest.fn<() => Promise<void>>().mockResolvedValue(undefined)
await install({
dependencies: {
'is-positive': '^3.0.0',
},
}, testDefaults({
runPacquet: {
supportsResolution: true,
run: runPacquet,
},
useGitBranchLockfile: true,
}))
expect(runPacquet).not.toHaveBeenCalled()
expect(fs.existsSync(`pnpm-lock.${branchName}.yaml`)).toBe(true)
expect(fs.existsSync(WANTED_LOCKFILE)).toBe(false)
})
test('install with git-branch-lockfile = true and no lockfile changes', async () => {
prepareEmpty()

View File

@@ -1,4 +1,4 @@
import { expect, test } from '@jest/globals'
import { expect, jest, test } from '@jest/globals'
import { addDependenciesToPackage, install } from '@pnpm/installing.deps-installer'
import { readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile.fs'
import { prepareEmpty } from '@pnpm/prepare'
@@ -251,6 +251,35 @@ test('loose mode surfaces immature fresh picks in the install result', async ()
)
})
test('pacquet materializes after pnpm resolves when policy violations must be surfaced', async () => {
prepareEmpty()
const opts = testDefaults({ minimumReleaseAge: allImmatureMinimumReleaseAge })
const runPacquet = jest.fn<(opts?: { filterResolvedProgress?: boolean, resolve?: boolean }) => Promise<void>>().mockResolvedValue(undefined)
const result = await install({
dependencies: {
'is-odd': '0.1',
},
}, {
...opts,
handleResolutionPolicyViolations: async () => {},
runPacquet: {
supportsResolution: true,
run: runPacquet,
},
})
expect(runPacquet).toHaveBeenCalledWith({ filterResolvedProgress: true })
expect(runPacquet).not.toHaveBeenCalledWith({ resolve: true })
expect(result.resolutionPolicyViolations).toContainEqual(
expect.objectContaining({
name: 'is-odd',
version: '0.1.0',
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
})
)
})
test('versions excluded via minimumReleaseAgeExclude are not surfaced as violations', async () => {
prepareEmpty()

View File

@@ -7,11 +7,14 @@ import { writeYamlFileSync } from 'write-yaml-file'
import { execPnpm, execPnpmSync } from '../utils/index.js'
// `pacquet` is fetched from the real npm registry — registry-mock doesn't
// carry it (or its platform-specific binary sub-packages). Pinned to a
// version known to ship the `configDependencies` integration surface this
// PR depends on; tests are gated on the public registry being reachable.
// carry it (or its platform-specific binary sub-packages). Tests are gated
// on the public registry being reachable.
const PUBLIC_REGISTRY = '--config.registry=https://registry.npmjs.org/'
const PACQUET_VERSION = '0.2.2'
// pacquet >= 0.11.7 supports full resolving installs, so pnpm delegates
// non-frozen plain installs to it too.
const PACQUET_VERSION = '0.11.7'
// pacquet < 0.11.7 stays on pnpm's resolve-then-materialize path.
const PACQUET_RESOLVE_WITH_PNPM_VERSION = '0.11.6'
// Each test runs two or three installs against the public registry; raise
// the per-test timeout above jest's 5s default to allow for cold caches.
@@ -21,6 +24,8 @@ interface PrepareOpts {
manifest?: { dependencies?: Record<string, string>, devDependencies?: Record<string, string> }
/** Which `configDependencies` slot declares pacquet. Both work. */
pacquetConfigDepName?: 'pacquet' | '@pnpm/pacquet'
/** Which pacquet version to declare. Defaults to {@link PACQUET_VERSION}. */
version?: string
}
/** Set up a temp project + workspace yaml + initial install. */
@@ -28,7 +33,7 @@ async function prepareWithPacquet (opts: PrepareOpts = {}): Promise<void> {
prepare(opts.manifest ?? {})
writeYamlFileSync('pnpm-workspace.yaml', {
configDependencies: {
[opts.pacquetConfigDepName ?? 'pacquet']: PACQUET_VERSION,
[opts.pacquetConfigDepName ?? 'pacquet']: opts.version ?? PACQUET_VERSION,
},
})
// Initial install populates pnpm-lock.yaml plus configDependencies
@@ -56,14 +61,13 @@ test('pnpm install --frozen-lockfile delegates to pacquet when declared in confi
expect(fs.existsSync('node_modules/is-positive/package.json')).toBe(true)
}, TIMEOUT)
test('bare `pnpm install` (no --frozen-lockfile) delegates the materialization to pacquet', async () => {
test('bare `pnpm install` (no --frozen-lockfile) delegates to pacquet when the lockfile is up to date', async () => {
await prepareWithPacquet({ manifest: { dependencies: { 'is-positive': '3.1.0' } } })
await fs.promises.rm('node_modules', { recursive: true, force: true })
// No `--frozen-lockfile` flag. The expected path is: pnpm runs a
// lockfileOnly resolve pass (the lockfile is already up-to-date so
// it's a no-op write), then hands fetch / import / link off to
// pacquet via the default-isolated-linker branch.
// No `--frozen-lockfile` flag, but the lockfile is already up to date
// with the manifest, so no resolution is needed: pnpm delegates the
// whole install to pacquet just as it would for a frozen install.
const { stdout, status } = execPnpmSync(
[PUBLIC_REGISTRY, 'install'],
{ env: { pnpm_config_silent: 'false' }, stdio: 'pipe', expectSuccess: true }
@@ -73,17 +77,50 @@ test('bare `pnpm install` (no --frozen-lockfile) delegates the materialization t
expect(fs.existsSync('node_modules/is-positive/package.json')).toBe(true)
}, TIMEOUT)
// Skipped until pacquet writes a `.modules.yaml` whose `publicHoistPattern`
// matches what pnpm computes on a follow-up command. Today pacquet's
// materialization writes a different value, so the second pnpm command
// in the same project fails with
// `ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF`. Bare `--frozen-lockfile` /
// `install` tests escape this by wiping `node_modules` between
// invocations; `pnpm add` and `pnpm update` can't, because they need
// the prior install's state to do anything meaningful. Tracked as a
// pacquet-side parity gap; re-enable once pacquet's `.modules.yaml`
// shape matches pnpm's.
test.skip('`pnpm add <pkg>` resolves the new dep with pnpm and materializes with pacquet', async () => {
test('pnpm install resolves a newly-added dependency with pacquet >= 0.11.7', async () => {
// `prepare` installs with no dependencies, so the lockfile has no entry
// for `is-positive`. Adding it to the manifest forces a real resolution
// on the next install — which pacquet performs itself, in a single
// non-frozen pass (resolve + materialize), without a pnpm resolve pass.
await prepareWithPacquet()
const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8'))
manifest.dependencies = { 'is-positive': '3.1.0' }
fs.writeFileSync('package.json', JSON.stringify(manifest, null, 2))
const { stdout, status } = execPnpmSync(
[PUBLIC_REGISTRY, 'install'],
{ env: { pnpm_config_silent: 'false' }, stdio: 'pipe', expectSuccess: true }
)
expect(status).toBe(0)
const output = stdout.toString()
expect(output).toContain('Using pacquet for this install')
expect(output).toContain('Progress: resolved')
expect(output.indexOf('Using pacquet for this install')).toBeLessThan(output.indexOf('Progress: resolved'))
expect(fs.existsSync('node_modules/is-positive/package.json')).toBe(true)
}, TIMEOUT)
test('pnpm install resolves a newly-added dependency itself when pacquet < 0.11.7', async () => {
// Same setup as the resolving test above, but with an older
// pacquet: pnpm runs its own lockfileOnly resolve pass for the new dep
// and hands the freshly-written lockfile to pacquet to materialize.
await prepareWithPacquet({ version: PACQUET_RESOLVE_WITH_PNPM_VERSION })
const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8'))
manifest.dependencies = { 'is-positive': '3.1.0' }
fs.writeFileSync('package.json', JSON.stringify(manifest, null, 2))
const { stdout, status } = execPnpmSync(
[PUBLIC_REGISTRY, 'install'],
{ env: { pnpm_config_silent: 'false' }, stdio: 'pipe', expectSuccess: true }
)
expect(status).toBe(0)
const output = stdout.toString()
expect(output).toContain('Using pacquet for this install')
expect(output).toContain('Progress: resolved')
expect(output.indexOf('Progress: resolved')).toBeLessThan(output.indexOf('Using pacquet for this install'))
expect(fs.existsSync('node_modules/is-positive/package.json')).toBe(true)
}, TIMEOUT)
test('`pnpm add <pkg>` resolves the new dep with pnpm and materializes with pacquet', async () => {
await prepareWithPacquet()
const { stdout, status } = execPnpmSync(
@@ -101,14 +138,14 @@ test.skip('`pnpm add <pkg>` resolves the new dep with pnpm and materializes with
expect(manifest.dependencies?.['is-positive']).toBeDefined()
}, TIMEOUT)
// Same skip reason as the `pnpm add` test above:
// `ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF` on the second invocation.
test.skip('`pnpm update <pkg>` resolves a new version with pnpm and materializes with pacquet', async () => {
// Start pinned to an older minor so `update` has something to do.
await prepareWithPacquet({ manifest: { dependencies: { 'is-positive': '^3.0.0' } } })
test('`pnpm update <pkg>` resolves a new version with pnpm and materializes with pacquet', async () => {
// Start pinned to an old exact version so `update --latest` has
// something to do (is-positive's latest is 3.1.0).
await prepareWithPacquet({ manifest: { dependencies: { 'is-positive': '1.0.0' } } })
const oldVersion = JSON.parse(
await fs.promises.readFile('node_modules/is-positive/package.json', 'utf8')
).version as string
expect(oldVersion).toBe('1.0.0')
const { stdout, status } = execPnpmSync(
[PUBLIC_REGISTRY, 'update', 'is-positive', '--latest'],
@@ -119,17 +156,11 @@ test.skip('`pnpm update <pkg>` resolves a new version with pnpm and materializes
const newVersion = JSON.parse(
await fs.promises.readFile('node_modules/is-positive/package.json', 'utf8')
).version as string
// is-positive@4 is the current latest and is a major bump from the 3.x
// line; `update --latest` should move past the original `^3.0.0` pin.
// `update --latest` moves the `1.0.0` pin to the current latest (3.1.0).
expect(newVersion).not.toBe(oldVersion)
}, TIMEOUT)
// Skipped until pacquet ships a release built with the updated
// `generate-packages.mjs` (this PR's change) so the `@pnpm/pacquet`
// scoped alias actually exists on npm. The pinned PACQUET_VERSION
// above doesn't publish that mirror yet. Re-enable when the next
// pacquet release ships under both names.
test.skip('the `@pnpm/pacquet` scoped alias is recognized in configDependencies', async () => {
test('the `@pnpm/pacquet` scoped alias is recognized in configDependencies', async () => {
await prepareWithPacquet({
manifest: { dependencies: { 'is-positive': '3.1.0' } },
pacquetConfigDepName: '@pnpm/pacquet',