diff --git a/.changeset/pacquet-resolving-install-delegation.md b/.changeset/pacquet-resolving-install-delegation.md new file mode 100644 index 0000000000..deb42476a7 --- /dev/null +++ b/.changeset/pacquet-resolving-install-delegation.md @@ -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). diff --git a/installing/commands/src/recursive.ts b/installing/commands/src/recursive.ts index 393f114419..93af6cfad1 100755 --- a/installing/commands/src/recursive.ts +++ b/installing/commands/src/recursive.ts @@ -123,11 +123,14 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick Promise + runPacquet?: { + supportsResolution: boolean + run: (opts?: { filterResolvedProgress?: boolean, resolve?: boolean }) => Promise + } } & Partial< Pick= 0.11.7). + */ + resolve?: boolean } -export function makeRunPacquet (opts: MakeRunPacquetOpts): (callOpts?: RunPacquetCallOpts) => Promise { +/** + * 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 +} + +/** + * 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 { 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/`. 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 diff --git a/installing/commands/test/runPacquet.ts b/installing/commands/test/runPacquet.ts new file mode 100644 index 0000000000..655cb44506 --- /dev/null +++ b/installing/commands/test/runPacquet.ts @@ -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 { + 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) +}) diff --git a/installing/deps-installer/src/install/extendInstallOptions.ts b/installing/deps-installer/src/install/extendInstallOptions.ts index ba95e2cff7..3aa181e400 100644 --- a/installing/deps-installer/src/install/extendInstallOptions.ts +++ b/installing/deps-installer/src/install/extendInstallOptions.ts @@ -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 + runPacquet?: { + supportsResolution: boolean + run: (opts?: { filterResolvedProgress?: boolean, resolve?: boolean }) => Promise + } /** * If true, `mutateModules` does not emit the per-install `summary` log * event. Used by `pnpm add -g` when it runs multiple isolated installs diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index d4414cdf62..ae57759ede 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -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 | 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 }, + opts: { + mergeGitBranchLockfiles?: boolean + runPacquet?: { run: (opts?: { filterResolvedProgress?: boolean }) => Promise } + 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) diff --git a/installing/deps-installer/test/install/dedupe.ts b/installing/deps-installer/test/install/dedupe.ts index dcb089e9db..5110659a93 100644 --- a/installing/deps-installer/test/install/dedupe.ts +++ b/installing/deps-installer/test/install/dedupe.ts @@ -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>().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>().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>().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() diff --git a/installing/deps-installer/test/install/gitBranchLockfile.test.ts b/installing/deps-installer/test/install/gitBranchLockfile.test.ts index 8aa0cd8a07..85ff9eb840 100644 --- a/installing/deps-installer/test/install/gitBranchLockfile.test.ts +++ b/installing/deps-installer/test/install/gitBranchLockfile.test.ts @@ -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>().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() diff --git a/installing/deps-installer/test/install/minimumReleaseAge.ts b/installing/deps-installer/test/install/minimumReleaseAge.ts index 63310eaf5f..6a8cb37e27 100644 --- a/installing/deps-installer/test/install/minimumReleaseAge.ts +++ b/installing/deps-installer/test/install/minimumReleaseAge.ts @@ -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>().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() diff --git a/pnpm/test/install/pacquet.ts b/pnpm/test/install/pacquet.ts index 27bbcee387..df7798b8d8 100644 --- a/pnpm/test/install/pacquet.ts +++ b/pnpm/test/install/pacquet.ts @@ -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, devDependencies?: Record } /** 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 { 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 ` 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 ` 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 ` 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 ` 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 ` 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 ` 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',