@pnpm/installing.deps-installer 1101.1.2 → 1101.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import type { ProjectOptions } from '@pnpm/installing.context';
5
5
  import type { HoistingLimits } from '@pnpm/installing.deps-restorer';
6
6
  import type { IncludedDependencies } from '@pnpm/installing.modules-yaml';
7
7
  import type { LockfileObject } from '@pnpm/lockfile.fs';
8
- import type { WorkspacePackages } from '@pnpm/resolving.resolver-base';
8
+ import type { ResolutionPolicyViolation, ResolutionVerifier, WorkspacePackages } from '@pnpm/resolving.resolver-base';
9
9
  import type { StoreController } from '@pnpm/store.controller-types';
10
10
  import type { AllowedDeprecatedVersions, PackageExtension, PackageVulnerabilityAudit, PeerDependencyRules, ReadPackageHook, Registries, RegistryConfig, SupportedArchitectures, TrustPolicy } from '@pnpm/types';
11
11
  import type { ReporterFunction } from '../types.js';
@@ -148,11 +148,62 @@ export interface StrictInstallOptions {
148
148
  ci?: boolean;
149
149
  minimumReleaseAge?: number;
150
150
  minimumReleaseAgeExclude?: string[];
151
+ /**
152
+ * Resolver-agnostic post-tree gate, invoked between
153
+ * `resolveDependencyTree` and `resolvePeers` inside
154
+ * `resolveDependencies`. Receives the violations the verifier
155
+ * fan-out collected from the freshly-resolved tree. Throwing here
156
+ * unwinds the install before peer-dep resolution runs — nothing on
157
+ * disk has changed, and the (potentially expensive) peer pass is
158
+ * skipped on abort.
159
+ *
160
+ * Intentionally policy-neutral. Each verifier owns its violation
161
+ * codes (`MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`, …); the
162
+ * install command filters by code to decide what to do. Future
163
+ * resolvers can plug verifiers in without touching this signature.
164
+ */
165
+ handleResolutionPolicyViolations?: (violations: readonly ResolutionPolicyViolation[]) => Promise<void>;
166
+ /**
167
+ * Resolver-side verifiers that re-check each lockfile-pinned resolution
168
+ * against policies configured upstream (today: at most one,
169
+ * `npm.minimumReleaseAge` in strict mode). Constructed by `createClient`
170
+ * and surfaced via the `createStoreController` return; mutateModules
171
+ * fans out across the list once, right after the lockfile is loaded
172
+ * from disk. Empty when no policy is active.
173
+ */
174
+ resolutionVerifiers: ResolutionVerifier[];
175
+ /**
176
+ * pnpm's on-disk cache directory. When set together with non-empty
177
+ * `resolutionVerifiers`, the lockfile verification result is memoized
178
+ * in `<cacheDir>/lockfile-verified.jsonl` so repeat installs against an
179
+ * unchanged lockfile skip the per-package registry round trip. The
180
+ * record is policy-neutral; each active resolver-side verifier writes
181
+ * its own slot under `verifiers[<key>]`.
182
+ */
183
+ cacheDir?: string;
151
184
  trustPolicy?: TrustPolicy;
152
185
  trustPolicyExclude?: string[];
153
186
  trustPolicyIgnoreAfter?: number;
154
187
  packageVulnerabilityAudit?: PackageVulnerabilityAudit;
155
188
  blockExoticSubdeps?: boolean;
189
+ /**
190
+ * Optional alternative install engine. When set, the frozen-install
191
+ * path invokes this callback instead of `headlessInstall`. The CLI
192
+ * layer constructs it (today: spawning the pacquet binary installed
193
+ * via `configDependencies` and forwarding pnpm's own CLI argv); the
194
+ * installer treats it as an opaque "do the install" hook so it
195
+ * doesn't need to know about pacquet's binary path, CLI surface, or
196
+ * any settings that only pacquet consumes.
197
+ *
198
+ * `filterResolvedProgress` tells the helper to drop the engine's
199
+ * own `pnpm:progress status:resolved` events because pnpm already
200
+ * emitted one per package during a preceding lockfileOnly resolve
201
+ * pass. The frozen-install path passes `false` (or nothing): no
202
+ * resolve pass ran, so the engine's events are the only source.
203
+ */
204
+ runPacquet?: (opts?: {
205
+ filterResolvedProgress?: boolean;
206
+ }) => Promise<void>;
156
207
  /**
157
208
  * If true, `mutateModules` does not emit the per-install `summary` log
158
209
  * event. Used by `pnpm add -g` when it runs multiple isolated installs
@@ -102,6 +102,7 @@ const defaults = (opts) => {
102
102
  peersSuffixMaxLength: 1000,
103
103
  blockExoticSubdeps: false,
104
104
  omitSummaryLog: false,
105
+ resolutionVerifiers: [],
105
106
  };
106
107
  };
107
108
  export function extendOptions(opts) {
@@ -2,7 +2,7 @@ import type { Catalogs } from '@pnpm/catalogs.types';
2
2
  import { PnpmError } from '@pnpm/error';
3
3
  import { type PinnedVersion, type UpdateMatchingFunction, type WantedDependency } from '@pnpm/installing.deps-resolver';
4
4
  import { type InstallationResultStats } from '@pnpm/installing.deps-restorer';
5
- import type { PreferredVersions } from '@pnpm/resolving.resolver-base';
5
+ import type { PreferredVersions, ResolutionPolicyViolation } from '@pnpm/resolving.resolver-base';
6
6
  import type { DependenciesField, DepPath, IgnoredBuilds, PeerDependencyIssues, ProjectId, ProjectManifest, ProjectRootDir, ReadPackageHook } from '@pnpm/types';
7
7
  import { type InstallOptions } from './extendInstallOptions.js';
8
8
  interface InstallMutationOptions {
@@ -45,6 +45,8 @@ export interface InstallResult {
45
45
  updatedCatalogs: Catalogs | undefined;
46
46
  updatedManifest: ProjectManifest;
47
47
  ignoredBuilds: IgnoredBuilds | undefined;
48
+ /** Forwarded from {@link MutateModulesResult.resolutionPolicyViolations}. */
49
+ resolutionPolicyViolations: ResolutionPolicyViolation[];
48
50
  }
49
51
  export declare function install(manifest: ProjectManifest, opts: Opts): Promise<InstallResult>;
50
52
  export type MutatedProject = DependenciesMutation & {
@@ -60,6 +62,8 @@ export interface MutateModulesInSingleProjectResult {
60
62
  updatedCatalogs: Catalogs | undefined;
61
63
  updatedProject: UpdatedProject;
62
64
  ignoredBuilds: IgnoredBuilds | undefined;
65
+ /** Forwarded from {@link MutateModulesResult.resolutionPolicyViolations}. */
66
+ resolutionPolicyViolations: ResolutionPolicyViolation[];
63
67
  }
64
68
  export declare function mutateModulesInSingleProject(project: MutatedProject & {
65
69
  binsDir?: string;
@@ -73,6 +77,15 @@ export interface MutateModulesResult {
73
77
  stats: InstallationResultStats;
74
78
  depsRequiringBuild?: DepPath[];
75
79
  ignoredBuilds: IgnoredBuilds | undefined;
80
+ /**
81
+ * Resolver-policy violations the post-resolution scan found in the
82
+ * freshly-resolved lockfile. Each violation carries a verifier code
83
+ * (e.g. `MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`); the
84
+ * install command filters by code to decide what to do (persist to
85
+ * `minimumReleaseAgeExclude`, log, etc.). Empty array when no
86
+ * verifier reported a violation or no policy was active.
87
+ */
88
+ resolutionPolicyViolations: ResolutionPolicyViolation[];
76
89
  }
77
90
  export declare function mutateModules(projects: MutatedProject[], maybeOpts: MutateModulesOptions): Promise<MutateModulesResult>;
78
91
  export declare function addDependenciesToPackage(manifest: ProjectManifest, dependencySelectors: string[], opts: Omit<InstallOptions, 'allProjects'> & {
@@ -15,7 +15,7 @@ import { getContext } from '@pnpm/installing.context';
15
15
  import { getWantedDependencies, resolveDependencies, } from '@pnpm/installing.deps-resolver';
16
16
  import { extendProjectsWithTargetDirs, headlessInstall } from '@pnpm/installing.deps-restorer';
17
17
  import { writeModulesManifest } from '@pnpm/installing.modules-yaml';
18
- import { cleanGitBranchLockfiles, readWantedLockfile, writeCurrentLockfile, writeLockfiles, writeWantedLockfile, } from '@pnpm/lockfile.fs';
18
+ import { cleanGitBranchLockfiles, getWantedLockfileName, readWantedLockfile, writeCurrentLockfile, writeLockfiles, writeWantedLockfile, } from '@pnpm/lockfile.fs';
19
19
  import { getPreferredVersionsFromLockfileAndManifests } from '@pnpm/lockfile.preferred-versions';
20
20
  import { calcPatchHashes, createOverridesMapFromParsed, getOutdatedLockfileSetting, } from '@pnpm/lockfile.settings-checker';
21
21
  import { writePnpFile } from '@pnpm/lockfile.to-pnp';
@@ -38,6 +38,9 @@ import { extendOptions, } from './extendInstallOptions.js';
38
38
  import { linkPackages } from './link.js';
39
39
  import { reportPeerDependencyIssues } from './reportPeerDependencyIssues.js';
40
40
  import { validateModules } from './validateModules.js';
41
+ import { verifyLockfileResolutions } from './verifyLockfileResolutions.js';
42
+ import { writeLockfilesAndRecordVerified } from './writeLockfilesAndRecordVerified.js';
43
+ import { writeWantedLockfileAndRecordVerified } from './writeWantedLockfileAndRecordVerified.js';
41
44
  class LockfileConfigMismatchError extends PnpmError {
42
45
  constructor(outdatedLockfileSettingName) {
43
46
  super('LOCKFILE_CONFIG_MISMATCH', `Cannot proceed with the frozen installation. The current "${outdatedLockfileSettingName}" configuration doesn't match the value found in the lockfile`, {
@@ -57,7 +60,7 @@ export async function install(manifest, opts) {
57
60
  if (opts.agent) {
58
61
  return installFromPnpmRegistry(manifest, rootDir, opts);
59
62
  }
60
- const { updatedCatalogs, updatedProjects: projects, ignoredBuilds } = await mutateModules([
63
+ const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations } = await mutateModules([
61
64
  {
62
65
  mutation: 'install',
63
66
  pruneDirectDependencies: opts.pruneDirectDependencies,
@@ -76,7 +79,7 @@ export async function install(manifest, opts) {
76
79
  binsDir: opts.binsDir,
77
80
  }],
78
81
  });
79
- return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds };
82
+ return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds, resolutionPolicyViolations };
80
83
  }
81
84
  export async function mutateModulesInSingleProject(project, maybeOpts) {
82
85
  const result = await mutateModules([
@@ -98,6 +101,7 @@ export async function mutateModulesInSingleProject(project, maybeOpts) {
98
101
  updatedCatalogs: result.updatedCatalogs,
99
102
  updatedProject: result.updatedProjects[0],
100
103
  ignoredBuilds: result.ignoredBuilds,
104
+ resolutionPolicyViolations: result.resolutionPolicyViolations,
101
105
  };
102
106
  }
103
107
  const pickCatalogSpecifier = {
@@ -107,6 +111,11 @@ const pickCatalogSpecifier = {
107
111
  };
108
112
  export async function mutateModules(projects, maybeOpts) {
109
113
  const reporter = maybeOpts?.reporter;
114
+ const detachReporter = (reporter != null) && typeof reporter === 'function'
115
+ ? () => {
116
+ streamParser.removeListener('data', reporter);
117
+ }
118
+ : () => { };
110
119
  if ((reporter != null) && typeof reporter === 'function') {
111
120
  streamParser.on('data', reporter);
112
121
  }
@@ -153,6 +162,60 @@ export async function mutateModules(projects, maybeOpts) {
153
162
  ctx = await getContext(opts);
154
163
  }
155
164
  }
165
+ // Re-validate every entry in the lockfile against the policies the
166
+ // resolver chain was built with (today: minimumReleaseAge in strict mode
167
+ // via the npm verifier; the abstraction supports other resolvers
168
+ // attaching their own verifiers). The threat model is a lockfile that
169
+ // someone else resolved — committed to the repo, restored from a CI
170
+ // cache, etc. — bypassing the local resolver's policy filters; the local
171
+ // resolver's own filters already cover fresh resolution. We run this
172
+ // exactly once, right after the lockfile is loaded from disk, before any
173
+ // path branches.
174
+ //
175
+ // Skipped when we already know pacquet will run the install: pacquet's
176
+ // frozen-install path applies the same resolver-policy gate (port of
177
+ // this function), so re-running here would duplicate the work — and
178
+ // for `minimumReleaseAge` in strict mode each lockfile entry is an
179
+ // HTTP probe.
180
+ //
181
+ // The predicate mirrors every short-circuit `tryFrozenInstall` checks
182
+ // before reaching the pacquet branch: anything that would make it
183
+ // return null, throw, or fall through to the JS path must keep
184
+ // verification on. The optimistic `preferFrozenLockfile` path decides
185
+ // whether to delegate later (based on `allProjectsAreUpToDate`), which
186
+ // isn't known here — so verification still runs in that window, the
187
+ // duplicate is bounded to it.
188
+ const willDelegateToPacquet = opts.runPacquet != null &&
189
+ installsOnly &&
190
+ !opts.lockfileOnly &&
191
+ !opts.fixLockfile &&
192
+ !opts.dedupe &&
193
+ !ctx.lockfileHadConflicts &&
194
+ ctx.existsNonEmptyWantedLockfile &&
195
+ (opts.frozenLockfile === true || opts.frozenLockfileIfExists === true);
196
+ if (!willDelegateToPacquet) {
197
+ const cacheActive = opts.cacheDir != null && opts.resolutionVerifiers.length > 0;
198
+ const wantedLockfilePath = cacheActive
199
+ ? path.resolve(ctx.lockfileDir, await getWantedLockfileName({
200
+ useGitBranchLockfile: opts.useGitBranchLockfile,
201
+ mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
202
+ }))
203
+ : undefined;
204
+ try {
205
+ await verifyLockfileResolutions(ctx.wantedLockfile, opts.resolutionVerifiers, {
206
+ cacheDir: opts.cacheDir,
207
+ lockfilePath: wantedLockfilePath,
208
+ });
209
+ }
210
+ catch (err) {
211
+ // verifyLockfileResolutions is the one throw site in this function
212
+ // that's part of normal user-facing operation (a rejected lockfile);
213
+ // other throws here are unexpected. Detach the reporter listener so
214
+ // long-lived processes don't leak it on every rejected install.
215
+ detachReporter();
216
+ throw err;
217
+ }
218
+ }
156
219
  if (opts.hooks.preResolution) {
157
220
  for (const preResolution of opts.hooks.preResolution) {
158
221
  // eslint-disable-next-line no-await-in-loop
@@ -227,15 +290,14 @@ export async function mutateModules(projects, maybeOpts) {
227
290
  ignoredScriptsLogger.debug({
228
291
  packageNames: ignoredBuilds ? dedupePackageNamesFromIgnoredBuilds(ignoredBuilds) : [],
229
292
  });
230
- if ((reporter != null) && typeof reporter === 'function') {
231
- streamParser.removeListener('data', reporter);
232
- }
293
+ detachReporter();
233
294
  return {
234
295
  updatedCatalogs: result.updatedCatalogs,
235
296
  updatedProjects: result.updatedProjects,
236
297
  stats: result.stats ?? { added: 0, removed: 0, linkedToRoot: 0 },
237
298
  depsRequiringBuild: result.depsRequiringBuild,
238
299
  ignoredBuilds,
300
+ resolutionPolicyViolations: result.resolutionPolicyViolations ?? [],
239
301
  };
240
302
  async function _install() {
241
303
  const scriptsOpts = {
@@ -523,6 +585,7 @@ export async function mutateModules(projects, maybeOpts) {
523
585
  stats: result.stats,
524
586
  depsRequiringBuild: result.depsRequiringBuild,
525
587
  ignoredBuilds: result.ignoredBuilds,
588
+ resolutionPolicyViolations: result.resolutionPolicyViolations,
526
589
  };
527
590
  }
528
591
  /**
@@ -633,6 +696,28 @@ Note that in CI environments, this setting is enabled by default.`,
633
696
  else {
634
697
  logger.info({ message: 'Lockfile is up to date, resolution step is skipped', prefix: opts.lockfileDir });
635
698
  }
699
+ if (opts.runPacquet != null) {
700
+ try {
701
+ await opts.runPacquet();
702
+ }
703
+ catch (err) {
704
+ // Same reasoning as the verifyLockfileResolutions catch above: this
705
+ // is the user-facing failure path, so detach the reporter listener
706
+ // before rethrowing so long-lived processes don't leak it.
707
+ detachReporter();
708
+ throw err;
709
+ }
710
+ return {
711
+ updatedProjects: projects.map((mutatedProject) => {
712
+ const project = ctx.projects[mutatedProject.rootDir];
713
+ return {
714
+ ...project,
715
+ manifest: project.originalManifest ?? project.manifest,
716
+ };
717
+ }),
718
+ ignoredBuilds: undefined,
719
+ };
720
+ }
636
721
  try {
637
722
  const { stats, ignoredBuilds } = await headlessInstall({
638
723
  ...ctx,
@@ -811,7 +896,7 @@ function getPerDepCatalogName(wantedDep, globalSaveCatalogName) {
811
896
  }
812
897
  export async function addDependenciesToPackage(manifest, dependencySelectors, opts) {
813
898
  const rootDir = (opts.dir ?? process.cwd());
814
- const { updatedCatalogs, updatedProjects: projects, ignoredBuilds } = await mutateModules([
899
+ const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations } = await mutateModules([
815
900
  {
816
901
  allowNew: opts.allowNew,
817
902
  dependencySelectors,
@@ -837,7 +922,7 @@ export async function addDependenciesToPackage(manifest, dependencySelectors, op
837
922
  },
838
923
  ],
839
924
  });
840
- return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds };
925
+ return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds, resolutionPolicyViolations };
841
926
  }
842
927
  const _installInContext = async (projects, ctx, opts) => {
843
928
  // The wanted lockfile is mutated during installation. To compare changes, a
@@ -910,7 +995,7 @@ const _installInContext = async (projects, ctx, opts) => {
910
995
  // ignores preferred versions from the lockfile.
911
996
  forgetResolutionsOfAllPrevWantedDeps(ctx.wantedLockfile);
912
997
  }
913
- let { dependenciesGraph, dependenciesByProjectId, linkedDependenciesByProjectId, updatedCatalogs, newLockfile, outdatedDependencies, peerDependencyIssuesByProjects, wantedToBeSkippedPackageIds, waitTillAllFetchingsFinish, } = await resolveDependencies(projects, {
998
+ let { dependenciesGraph, dependenciesByProjectId, linkedDependenciesByProjectId, updatedCatalogs, newLockfile, outdatedDependencies, peerDependencyIssuesByProjects, wantedToBeSkippedPackageIds, waitTillAllFetchingsFinish, resolutionPolicyViolations, } = await resolveDependencies(projects, {
914
999
  allowBuild: opts.allowBuild,
915
1000
  allowedDeprecatedVersions: opts.allowedDeprecatedVersions,
916
1001
  allowUnusedPatches: opts.allowUnusedPatches,
@@ -964,6 +1049,7 @@ const _installInContext = async (projects, ctx, opts) => {
964
1049
  trustPolicyIgnoreAfter: opts.trustPolicyIgnoreAfter,
965
1050
  blockExoticSubdeps: opts.blockExoticSubdeps,
966
1051
  allProjectIds: Object.values(ctx.projects).map((p) => p.id),
1052
+ handleResolutionPolicyViolations: opts.handleResolutionPolicyViolations,
967
1053
  });
968
1054
  if (!opts.include.optionalDependencies || !opts.include.devDependencies || !opts.include.dependencies) {
969
1055
  linkedDependenciesByProjectId = mapValues((linkedDeps) => linkedDeps.filter((linkedDep) => !(linkedDep.dev && !opts.include.devDependencies ||
@@ -1178,11 +1264,13 @@ const _installInContext = async (projects, ctx, opts) => {
1178
1264
  const currentLockfileDir = path.join(ctx.rootModulesDir, '.pnpm');
1179
1265
  await Promise.all([
1180
1266
  opts.useLockfile && opts.saveLockfile
1181
- ? writeLockfiles({
1267
+ ? writeLockfilesAndRecordVerified({
1182
1268
  currentLockfile: result.currentLockfile,
1183
1269
  currentLockfileDir,
1184
1270
  wantedLockfile: newLockfile,
1185
1271
  wantedLockfileDir: ctx.lockfileDir,
1272
+ cacheDir: opts.cacheDir,
1273
+ resolutionVerifiers: opts.resolutionVerifiers,
1186
1274
  ...lockfileOpts,
1187
1275
  })
1188
1276
  : writeCurrentLockfile(ctx.virtualStoreDir, result.currentLockfile),
@@ -1234,10 +1322,24 @@ const _installInContext = async (projects, ctx, opts) => {
1234
1322
  }
1235
1323
  else {
1236
1324
  if (opts.useLockfile && opts.saveLockfile && !isInstallationOnlyForLockfileCheck) {
1237
- await writeWantedLockfile(ctx.lockfileDir, newLockfile, lockfileOpts);
1325
+ await writeWantedLockfileAndRecordVerified({
1326
+ lockfileDir: ctx.lockfileDir,
1327
+ lockfile: newLockfile,
1328
+ cacheDir: opts.cacheDir,
1329
+ resolutionVerifiers: opts.resolutionVerifiers,
1330
+ ...lockfileOpts,
1331
+ });
1238
1332
  }
1239
- if (opts.nodeLinker !== 'hoisted') {
1240
- // This is only needed because otherwise the reporter will hang
1333
+ if (opts.nodeLinker !== 'hoisted' && opts.runPacquet == null) {
1334
+ // This is only needed because otherwise the reporter will hang.
1335
+ // Skipped when pacquet is about to take over the materialization
1336
+ // phase: the default reporter completes the progress stream for
1337
+ // this prefix on `importing_done`, so emitting it from the
1338
+ // lockfileOnly resolve pass would prematurely close the stream
1339
+ // and pacquet's own `importing_started` / progress events would
1340
+ // render to a stale stream. Pacquet emits its own
1341
+ // `importing_done` after the install, which closes the stream
1342
+ // normally.
1241
1343
  stageLogger.debug({
1242
1344
  prefix: opts.lockfileDir,
1243
1345
  stage: 'importing_done',
@@ -1261,7 +1363,15 @@ const _installInContext = async (projects, ctx, opts) => {
1261
1363
  strictPeerDependencies: opts.strictPeerDependencies,
1262
1364
  rules: opts.peerDependencyRules,
1263
1365
  });
1264
- if (!opts.omitSummaryLog) {
1366
+ // Skipped when pacquet will take over the materialization. The
1367
+ // default reporter's `reportSummary` `take(1)`s the first summary
1368
+ // event and combines it with whatever `pkgsDiff` it has at that
1369
+ // moment — which is empty here, since pacquet hasn't emitted its
1370
+ // per-direct-dep `pnpm:root` events yet. Letting pnpm fire summary
1371
+ // now would lock in an empty diff. Pacquet emits its own
1372
+ // `pnpm:summary` after the install completes, by which point its
1373
+ // root events have populated the diff.
1374
+ if (!opts.omitSummaryLog && opts.runPacquet == null) {
1265
1375
  summaryLogger.debug({ prefix: opts.lockfileDir });
1266
1376
  }
1267
1377
  // Similar to the sequencing for when the original wanted lockfile is
@@ -1282,11 +1392,39 @@ const _installInContext = async (projects, ctx, opts) => {
1282
1392
  stats,
1283
1393
  depsRequiringBuild,
1284
1394
  ignoredBuilds,
1395
+ resolutionPolicyViolations,
1285
1396
  };
1286
1397
  };
1287
1398
  function allMutationsAreInstalls(projects) {
1288
1399
  return projects.every((project) => project.mutation === 'install' && !project.update && !project.updateMatching);
1289
1400
  }
1401
+ /**
1402
+ * Run the pacquet binary if it's configured, otherwise run the JS
1403
+ * `headlessInstall`. Callers can hand off any code path that materializes
1404
+ * an already-resolved lockfile (workspace partial install, hoisted
1405
+ * linker, agent-server install, frozen install) without restating the
1406
+ * delegation choice.
1407
+ *
1408
+ * Pacquet reads the wanted lockfile from disk and produces its own
1409
+ * `pnpm:stats` / `pnpm:ignored-scripts` log events that drive the
1410
+ * reporter. The structured stats / ignoredBuilds return values that
1411
+ * `headlessInstall` produces aren't recovered here — pacquet doesn't
1412
+ * surface them through any return path — so callers get `undefined` for
1413
+ * both. `mutateModules` already tolerates that (it falls back to a zero
1414
+ * stats record and a no-op ignoredBuilds iteration).
1415
+ */
1416
+ async function materializeOrDelegate(opts, runHeadlessInstall) {
1417
+ if (opts.runPacquet != null) {
1418
+ // Reached only from the resolve-then-materialize call sites
1419
+ // (workspace-partial, hoisted-linker, agent install). Each ran a
1420
+ // lockfileOnly resolve pass that emitted one
1421
+ // `pnpm:progress status:resolved` per package, so pacquet's
1422
+ // duplicate `resolved` events would double the reporter's count.
1423
+ await opts.runPacquet({ filterResolvedProgress: true });
1424
+ return {};
1425
+ }
1426
+ return runHeadlessInstall();
1427
+ }
1290
1428
  const installInContext = async (projects, ctx, opts) => {
1291
1429
  try {
1292
1430
  const isPathInsideWorkspace = isSubdir.bind(null, opts.lockfileDir);
@@ -1323,7 +1461,7 @@ const installInContext = async (projects, ctx, opts) => {
1323
1461
  ...opts,
1324
1462
  lockfileOnly: true,
1325
1463
  });
1326
- const { stats, ignoredBuilds } = await headlessInstall({
1464
+ const { stats, ignoredBuilds } = await materializeOrDelegate(opts, () => headlessInstall({
1327
1465
  ...ctx,
1328
1466
  ...opts,
1329
1467
  currentEngine: {
@@ -1337,7 +1475,7 @@ const installInContext = async (projects, ctx, opts) => {
1337
1475
  wantedLockfile: result.newLockfile,
1338
1476
  useLockfile: opts.useLockfile && ctx.wantedLockfileIsModified,
1339
1477
  hoistWorkspacePackages: opts.hoistWorkspacePackages,
1340
- });
1478
+ }));
1341
1479
  return {
1342
1480
  ...result,
1343
1481
  stats,
@@ -1350,7 +1488,7 @@ const installInContext = async (projects, ctx, opts) => {
1350
1488
  ...opts,
1351
1489
  lockfileOnly: true,
1352
1490
  });
1353
- const { stats, ignoredBuilds } = await headlessInstall({
1491
+ const { stats, ignoredBuilds } = await materializeOrDelegate(opts, () => headlessInstall({
1354
1492
  ...ctx,
1355
1493
  ...opts,
1356
1494
  currentEngine: {
@@ -1364,13 +1502,29 @@ const installInContext = async (projects, ctx, opts) => {
1364
1502
  wantedLockfile: result.newLockfile,
1365
1503
  useLockfile: opts.useLockfile && ctx.wantedLockfileIsModified,
1366
1504
  hoistWorkspacePackages: opts.hoistWorkspacePackages,
1367
- });
1505
+ }));
1368
1506
  return {
1369
1507
  ...result,
1370
1508
  stats,
1371
1509
  ignoredBuilds,
1372
1510
  };
1373
1511
  }
1512
+ // Isolated `nodeLinker` (the default) with a non-frozen install:
1513
+ // pacquet doesn't ship a resolver yet, so split the install in two —
1514
+ // ask `_installInContext` for a `lockfileOnly` resolve pass (writes
1515
+ // `pnpm-lock.yaml`), then hand the freshly-written lockfile to
1516
+ // pacquet for the fetch / import / link / build phases. The frozen
1517
+ // branch is handled earlier in `tryFrozenInstall`; the hoisted
1518
+ // branch above already runs the same resolve-then-materialize
1519
+ // sequence (it had to even before pacquet existed). When no pacquet
1520
+ // is configured this falls through to the full single-pass install.
1521
+ if (opts.runPacquet != null && !opts.lockfileOnly) {
1522
+ const result = await _installInContext(projects, ctx, { ...opts, lockfileOnly: true });
1523
+ // The resolve pass above emitted a `pnpm:progress status:resolved`
1524
+ // per package; ask pacquet to drop its own duplicates.
1525
+ await opts.runPacquet({ filterResolvedProgress: true });
1526
+ return result;
1527
+ }
1374
1528
  return await _installInContext(projects, ctx, opts);
1375
1529
  }
1376
1530
  catch (error) { // eslint-disable-line
@@ -1645,6 +1799,14 @@ async function mutateModulesViaAgent(projects, opts) {
1645
1799
  * packages into node_modules.
1646
1800
  */
1647
1801
  async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjects) {
1802
+ // The agent path skips client-side resolution, so resolver-side policies
1803
+ // can't be enforced locally. `minimumReleaseAge` is forwarded to the
1804
+ // agent and enforced server-side. `trustPolicy` has no server-side
1805
+ // counterpart yet, so refuse to run under it instead of silently
1806
+ // letting through a lockfile the local verifier would reject.
1807
+ if (opts.trustPolicy === 'no-downgrade') {
1808
+ throw new PnpmError('TRUST_POLICY_INCOMPATIBLE_WITH_AGENT', 'The pnpm agent does not yet enforce `trustPolicy: no-downgrade`, so running an install through the agent under this policy would produce a lockfile that the local verifier rejects.', { hint: 'Unset `trustPolicy` for this install, or disable the agent (unset `--agent` / `agent` in pnpm-workspace.yaml) so resolution runs locally and the trust check applies.' });
1809
+ }
1648
1810
  const { fetchFromPnpmRegistry } = await import('@pnpm/agent.client');
1649
1811
  const { StoreIndex } = await import('@pnpm/store.index');
1650
1812
  const { setImportConcurrency } = await import('@pnpm/worker');
@@ -1695,7 +1857,14 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
1695
1857
  finally {
1696
1858
  storeIndex.close();
1697
1859
  }
1698
- await writeWantedLockfile(lockfileDir, lockfile);
1860
+ await writeWantedLockfileAndRecordVerified({
1861
+ lockfileDir,
1862
+ lockfile,
1863
+ cacheDir: opts.cacheDir,
1864
+ resolutionVerifiers: opts.resolutionVerifiers,
1865
+ useGitBranchLockfile: opts.useGitBranchLockfile,
1866
+ mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
1867
+ });
1699
1868
  logger.info({
1700
1869
  message: `Resolved ${agentStats.totalPackages} packages: ${agentStats.alreadyInStore} cached, ${agentStats.filesToDownload} files to download`,
1701
1870
  prefix: rootDir,
@@ -1767,14 +1936,25 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
1767
1936
  skipped: new Set(),
1768
1937
  wantedLockfile: lockfile,
1769
1938
  };
1939
+ const { ignoredBuilds, stats } = await materializeOrDelegate(opts,
1770
1940
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1771
- const { ignoredBuilds, stats } = await headlessInstall(headlessOpts);
1941
+ () => headlessInstall(headlessOpts));
1772
1942
  return {
1773
1943
  updatedCatalogs: undefined,
1774
1944
  updatedManifest: manifest,
1775
1945
  ignoredBuilds,
1776
- stats,
1946
+ // Pacquet doesn't surface a structured stats return; default to
1947
+ // zeros so the agent-path's non-optional `stats` slot is filled.
1948
+ // The reporter still renders accurate counts from pacquet's
1949
+ // `pnpm:stats` log events.
1950
+ stats: stats ?? { added: 0, removed: 0, linkedToRoot: 0 },
1777
1951
  lockfile,
1952
+ // Server-side resolution (pnpm agent) enforces `minimumReleaseAge`
1953
+ // itself — the agent picks only mature versions and the lockfile
1954
+ // can't contain immature entries to auto-collect. `trustPolicy` is
1955
+ // guarded above (we refuse to enter this path when it's set), so
1956
+ // there's nothing for the install command to react to here.
1957
+ resolutionPolicyViolations: [],
1778
1958
  };
1779
1959
  }
1780
1960
  finally {
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { progressLogger, stageLogger, statsLogger, } from '@pnpm/core-loggers';
4
- import { calcDepState } from '@pnpm/deps.graph-hasher';
4
+ import { calcDepState, findRuntimeNodeVersion } from '@pnpm/deps.graph-hasher';
5
5
  import { symlinkDependency } from '@pnpm/fs.symlink-dependency';
6
6
  import { linkDirectDeps } from '@pnpm/installing.linking.direct-dep-linker';
7
7
  import { hoist } from '@pnpm/installing.linking.hoist';
@@ -318,6 +318,11 @@ async function selectNewFromWantedDeps(wantedRelDepPaths, currentLockfile, depGr
318
318
  }
319
319
  const limitLinking = pLimit(16);
320
320
  async function linkAllPkgs(storeController, depNodes, opts) {
321
+ // Resolved `engines.runtime` Node version (when present) so the
322
+ // side-effects-cache key prefix tracks the script-runner Node
323
+ // rather than pnpm's own `process.version`. Computed once outside
324
+ // the per-node loop.
325
+ const nodeVersion = findRuntimeNodeVersion(Object.keys(opts.depGraph));
321
326
  await Promise.all(depNodes.map(async (depNode) => {
322
327
  const { files } = await depNode.fetching();
323
328
  depNode.requiresBuild = files.requiresBuild;
@@ -328,6 +333,7 @@ async function linkAllPkgs(storeController, depNodes, opts) {
328
333
  includeDepGraphHash: !opts.ignoreScripts && depNode.requiresBuild, // true when is built
329
334
  patchFileHash: depNode.patch?.hash,
330
335
  supportedArchitectures: opts.supportedArchitectures,
336
+ nodeVersion,
331
337
  });
332
338
  }
333
339
  }
@@ -0,0 +1,22 @@
1
+ import type { LockfileObject } from '@pnpm/lockfile.fs';
2
+ import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
3
+ export interface RecordLockfileVerifiedOptions {
4
+ cacheDir?: string;
5
+ /** Absolute path of the lockfile the next install will read.
6
+ * Under `useGitBranchLockfile` this is the branch-suffixed name. */
7
+ lockfilePath: string;
8
+ /** The writer's canonical return value — see {@link writeWantedLockfile}.
9
+ * Passing the raw in-memory write object would record a hash the
10
+ * next install can't match (YAML drops undefined fields). */
11
+ lockfile: LockfileObject;
12
+ resolutionVerifiers: readonly ResolutionVerifier[] | undefined;
13
+ }
14
+ /**
15
+ * Records the post-resolution lockfile as verified so the next install
16
+ * skips the registry round-trip. Skipping is safe: fresh local picks
17
+ * are filtered by the resolver (see
18
+ * `resolving/npm-resolver/src/pickPackage.ts`) and carried-over entries
19
+ * already passed the gate at the top of `mutateModules`, so the
20
+ * recorded lockfile is policy-clean by construction.
21
+ */
22
+ export declare function recordLockfileVerified(opts: RecordLockfileVerifiedOptions): void;
@@ -0,0 +1,24 @@
1
+ import { hashObject } from '@pnpm/crypto.object-hasher';
2
+ import { recordVerification } from './verifyLockfileResolutionsCache.js';
3
+ /**
4
+ * Records the post-resolution lockfile as verified so the next install
5
+ * skips the registry round-trip. Skipping is safe: fresh local picks
6
+ * are filtered by the resolver (see
7
+ * `resolving/npm-resolver/src/pickPackage.ts`) and carried-over entries
8
+ * already passed the gate at the top of `mutateModules`, so the
9
+ * recorded lockfile is policy-clean by construction.
10
+ */
11
+ export function recordLockfileVerified(opts) {
12
+ if (!opts.cacheDir)
13
+ return;
14
+ if (!opts.resolutionVerifiers?.length)
15
+ return;
16
+ if (!opts.lockfile.packages)
17
+ return;
18
+ recordVerification(opts.cacheDir, {
19
+ lockfilePath: opts.lockfilePath,
20
+ verifiers: opts.resolutionVerifiers,
21
+ hashLockfile: () => hashObject(opts.lockfile),
22
+ });
23
+ }
24
+ //# sourceMappingURL=recordLockfileVerified.js.map