@pnpm/installing.env-installer 1101.0.9 → 1101.1.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.
@@ -1,12 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { checkPackage } from '@pnpm/config.package-is-installable';
3
4
  import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
4
- import { installingConfigDepsLogger } from '@pnpm/core-loggers';
5
- import { calcLeafGlobalVirtualStorePath } from '@pnpm/deps.graph-hasher';
5
+ import { installingConfigDepsLogger, skippedOptionalDependencyLogger } from '@pnpm/core-loggers';
6
+ import { calcGlobalVirtualStorePathWithSubdeps, calcLeafGlobalVirtualStorePath } from '@pnpm/deps.graph-hasher';
6
7
  import { PnpmError } from '@pnpm/error';
7
8
  import { readModulesDir } from '@pnpm/fs.read-modules-dir';
8
9
  import { readEnvLockfile } from '@pnpm/lockfile.fs';
9
- import { safeReadPackageJsonFromDir } from '@pnpm/pkg-manifest.reader';
10
10
  import { rimraf } from '@zkochan/rimraf';
11
11
  import getNpmTarballUrl from 'get-npm-tarball-url';
12
12
  import { symlinkDir } from 'symlink-dir';
@@ -29,16 +29,27 @@ export async function installConfigDeps(configDepsOrLockfile, opts) {
29
29
  const installedConfigDeps = [];
30
30
  await Promise.all(Object.entries(normalizedDeps).map(async ([pkgName, pkg]) => {
31
31
  const configDepPath = path.join(configModulesDir, pkgName);
32
- const existingPkgJson = existingConfigDeps.includes(pkgName)
33
- ? await safeReadPackageJsonFromDir(configDepPath)
34
- : null;
35
- if (existingPkgJson != null && existingPkgJson.name === pkgName && existingPkgJson.version === pkg.version) {
36
- return;
37
- }
38
- installingConfigDepsLogger.debug({ status: 'started' });
39
32
  const fullPkgId = `${pkgName}@${pkg.version}:${pkg.resolution.integrity}`;
40
- const relPath = calcLeafGlobalVirtualStorePath(fullPkgId, pkgName, pkg.version);
33
+ // The parent's GVS hash must incorporate its optional subdeps; otherwise
34
+ // changing a subdep version while keeping the parent pinned would collide
35
+ // on the same leaf and silently overwrite the previous sibling symlinks.
36
+ const optionalSubdepIds = {};
37
+ for (const subdep of pkg.optionalSubdeps ?? []) {
38
+ optionalSubdepIds[subdep.name] = `${subdep.name}@${subdep.version}:${subdep.resolution.integrity}`;
39
+ }
40
+ const relPath = calcGlobalVirtualStorePathWithSubdeps(fullPkgId, pkgName, pkg.version, optionalSubdepIds);
41
41
  const pkgDirInGlobalVirtualStore = path.join(globalVirtualStoreDir, relPath, 'node_modules', pkgName);
42
+ // The leaf hash captures parent+subdep identities from the lockfile but
43
+ // not the host's `process.arch`/`process.platform` selection. So even if
44
+ // the symlink target is already the expected leaf, the sibling links
45
+ // inside that leaf may target the wrong platform binary if the host's
46
+ // effective arch changed between runs (e.g. Rosetta x64 vs arm64 on
47
+ // macOS). Short-circuit only the parent's re-import/re-symlink in that
48
+ // case; always run installOptionalSubdeps so platform-specific siblings
49
+ // get pruned and relinked.
50
+ const parentSymlinkAlreadyCorrect = existingConfigDeps.includes(pkgName) &&
51
+ await symlinkPointsTo(configDepPath, pkgDirInGlobalVirtualStore);
52
+ installingConfigDepsLogger.debug({ status: 'started' });
42
53
  if (!fs.existsSync(path.join(pkgDirInGlobalVirtualStore, 'package.json'))) {
43
54
  const { fetching } = await opts.store.fetchPackage({
44
55
  force: true,
@@ -55,6 +66,22 @@ export async function installConfigDeps(configDepsOrLockfile, opts) {
55
66
  filesResponse,
56
67
  });
57
68
  }
69
+ if (pkg.optionalSubdeps?.length) {
70
+ await installOptionalSubdeps({
71
+ parentName: pkgName,
72
+ parentVersion: pkg.version,
73
+ subdeps: pkg.optionalSubdeps,
74
+ // path.dirname would land in the scope subdir for scoped parents; use
75
+ // the leaf's node_modules root so sibling symlinks resolve correctly.
76
+ parentNodeModulesDir: path.join(globalVirtualStoreDir, relPath, 'node_modules'),
77
+ globalVirtualStoreDir,
78
+ rootDir: opts.rootDir,
79
+ store: opts.store,
80
+ });
81
+ }
82
+ if (parentSymlinkAlreadyCorrect) {
83
+ return;
84
+ }
58
85
  if (existingConfigDeps.includes(pkgName)) {
59
86
  await rimraf(configDepPath);
60
87
  }
@@ -113,14 +140,114 @@ function normalizeFromLockfile(lockfile, registries) {
113
140
  throw new PnpmError('ENV_LOCKFILE_CORRUPTED', `pnpm-lock.yaml is corrupted or incomplete: missing integrity for "${pkgKey}"`);
114
141
  }
115
142
  const registry = pickRegistryForPackage(registries, pkgName);
143
+ const snapshot = lockfile.snapshots[pkgKey];
144
+ const optionalSubdeps = snapshot?.optionalDependencies
145
+ ? readOptionalSubdepsFromLockfile(pkgName, snapshot.optionalDependencies, lockfile, registries)
146
+ : undefined;
116
147
  deps[pkgName] = {
117
148
  version,
118
149
  resolution: {
119
150
  integrity: resolution.integrity,
120
151
  tarball: resolution.tarball ?? getNpmTarballUrl(pkgName, version, { registry }),
121
152
  },
153
+ optionalSubdeps,
122
154
  };
123
155
  }
124
156
  return deps;
125
157
  }
158
+ function readOptionalSubdepsFromLockfile(parentName, optionalDeps, lockfile, registries) {
159
+ const subdeps = [];
160
+ for (const [subdepName, subdepVersion] of Object.entries(optionalDeps)) {
161
+ const subdepKey = `${subdepName}@${subdepVersion}`;
162
+ const subdepInfo = lockfile.packages[subdepKey];
163
+ if (!subdepInfo) {
164
+ throw new PnpmError('ENV_LOCKFILE_CORRUPTED', `pnpm-lock.yaml is corrupted or incomplete: missing packages entry for "${subdepKey}" ` +
165
+ `referenced from optionalDependencies of config dependency "${parentName}"`);
166
+ }
167
+ const subdepResolution = subdepInfo.resolution;
168
+ if (!subdepResolution.integrity) {
169
+ throw new PnpmError('ENV_LOCKFILE_CORRUPTED', `pnpm-lock.yaml is corrupted or incomplete: missing integrity for "${subdepKey}"`);
170
+ }
171
+ const registry = pickRegistryForPackage(registries, subdepName);
172
+ subdeps.push({
173
+ name: subdepName,
174
+ version: subdepVersion,
175
+ resolution: {
176
+ integrity: subdepResolution.integrity,
177
+ tarball: subdepResolution.tarball ?? getNpmTarballUrl(subdepName, subdepVersion, { registry }),
178
+ },
179
+ os: subdepInfo.os,
180
+ cpu: subdepInfo.cpu,
181
+ libc: subdepInfo.libc,
182
+ });
183
+ }
184
+ return subdeps;
185
+ }
186
+ async function installOptionalSubdeps(opts) {
187
+ const parentLogInfo = { id: `${opts.parentName}@${opts.parentVersion}`, name: opts.parentName, version: opts.parentVersion };
188
+ const compatibleSubdeps = opts.subdeps.filter((subdep) => {
189
+ if (!subdep.os && !subdep.cpu && !subdep.libc)
190
+ return true;
191
+ // Use checkPackage rather than packageIsInstallable: the latter emits a
192
+ // user-visible warn for every incompatible variant, which would fire on
193
+ // every install since the env lockfile records all platform variants for
194
+ // portability. We log skipped subdeps at debug instead.
195
+ const error = checkPackage(`${subdep.name}@${subdep.version}`, { os: subdep.os, cpu: subdep.cpu, libc: subdep.libc }, {});
196
+ if (error == null)
197
+ return true;
198
+ skippedOptionalDependencyLogger.debug({
199
+ details: error.toString(),
200
+ package: { id: `${subdep.name}@${subdep.version}`, name: subdep.name, version: subdep.version },
201
+ parents: [parentLogInfo],
202
+ prefix: opts.rootDir,
203
+ reason: error.code === 'ERR_PNPM_UNSUPPORTED_ENGINE' ? 'unsupported_engine' : 'unsupported_platform',
204
+ });
205
+ return false;
206
+ });
207
+ const expectedSiblings = new Set([opts.parentName, ...compatibleSubdeps.map((s) => s.name)]);
208
+ const existingSiblings = await readModulesDir(opts.parentNodeModulesDir) ?? [];
209
+ await Promise.all(existingSiblings
210
+ .filter((name) => !expectedSiblings.has(name))
211
+ .map((name) => rimraf(path.join(opts.parentNodeModulesDir, name))));
212
+ await Promise.all(compatibleSubdeps.map(async (subdep) => {
213
+ const subdepFullPkgId = `${subdep.name}@${subdep.version}:${subdep.resolution.integrity}`;
214
+ const subdepRelPath = calcLeafGlobalVirtualStorePath(subdepFullPkgId, subdep.name, subdep.version);
215
+ const subdepDirInGlobalVirtualStore = path.join(opts.globalVirtualStoreDir, subdepRelPath, 'node_modules', subdep.name);
216
+ if (!fs.existsSync(path.join(subdepDirInGlobalVirtualStore, 'package.json'))) {
217
+ const { fetching } = await opts.store.fetchPackage({
218
+ force: true,
219
+ lockfileDir: opts.rootDir,
220
+ pkg: {
221
+ id: `${subdep.name}@${subdep.version}`,
222
+ resolution: subdep.resolution,
223
+ },
224
+ });
225
+ const { files: filesResponse } = await fetching();
226
+ await opts.store.importPackage(subdepDirInGlobalVirtualStore, {
227
+ force: true,
228
+ requiresBuild: false,
229
+ filesResponse,
230
+ });
231
+ }
232
+ const linkPath = path.join(opts.parentNodeModulesDir, subdep.name);
233
+ await fs.promises.mkdir(path.dirname(linkPath), { recursive: true });
234
+ await symlinkDir(subdepDirInGlobalVirtualStore, linkPath);
235
+ }));
236
+ }
237
+ async function symlinkPointsTo(linkPath, expectedTarget) {
238
+ try {
239
+ // Realpath both sides: the expected target itself may live under a
240
+ // symlinked storeDir, and on case-insensitive filesystems the literal
241
+ // string forms can disagree about casing even when they refer to the
242
+ // same inode.
243
+ const [linkReal, targetReal] = await Promise.all([
244
+ fs.promises.realpath(linkPath),
245
+ fs.promises.realpath(expectedTarget),
246
+ ]);
247
+ return linkReal === targetReal;
248
+ }
249
+ catch {
250
+ return false;
251
+ }
252
+ }
126
253
  //# sourceMappingURL=installConfigDeps.js.map
@@ -4,6 +4,18 @@ export interface NormalizedConfigDep {
4
4
  integrity: string;
5
5
  tarball: string;
6
6
  };
7
+ optionalSubdeps?: NormalizedSubdep[];
8
+ }
9
+ export interface NormalizedSubdep {
10
+ name: string;
11
+ version: string;
12
+ resolution: {
13
+ integrity: string;
14
+ tarball: string;
15
+ };
16
+ os?: string[];
17
+ cpu?: string[];
18
+ libc?: string[];
7
19
  }
8
20
  export declare function parseIntegrity(pkgName: string, pkgSpec: string): {
9
21
  version: string;
@@ -9,6 +9,7 @@ import getNpmTarballUrl from 'get-npm-tarball-url';
9
9
  import { installConfigDeps } from './installConfigDeps.js';
10
10
  import { parseIntegrity } from './parseIntegrity.js';
11
11
  import { pruneEnvLockfile } from './pruneEnvLockfile.js';
12
+ import { resolveOptionalSubdeps } from './resolveOptionalSubdeps.js';
12
13
  /**
13
14
  * Resolves any config dependencies that are missing from the env lockfile,
14
15
  * then installs all config dependencies.
@@ -101,7 +102,13 @@ export async function resolveAndInstallConfigDeps(configDeps, opts) {
101
102
  envLockfile.packages[pkgKey] = {
102
103
  resolution: toLockfileResolution({ name, version }, resolution.resolution, registry),
103
104
  };
104
- envLockfile.snapshots[pkgKey] = {};
105
+ const optionalSubdeps = await resolveOptionalSubdeps(name, resolution.manifest, {
106
+ envLockfile,
107
+ lockfileDir: opts.rootDir,
108
+ registries: opts.registries,
109
+ resolveFromNpm,
110
+ });
111
+ envLockfile.snapshots[pkgKey] = optionalSubdeps ? { optionalDependencies: optionalSubdeps } : {};
105
112
  }));
106
113
  pruneEnvLockfile(envLockfile);
107
114
  await writeEnvLockfile(opts.rootDir, envLockfile);
@@ -8,6 +8,7 @@ import { createFetchFromRegistry } from '@pnpm/network.fetch';
8
8
  import { createNpmResolver } from '@pnpm/resolving.npm-resolver';
9
9
  import { parseWantedDependency } from '@pnpm/resolving.parse-wanted-dependency';
10
10
  import { installConfigDeps } from './installConfigDeps.js';
11
+ import { resolveOptionalSubdeps } from './resolveOptionalSubdeps.js';
11
12
  export async function resolveConfigDeps(configDeps, opts) {
12
13
  if (opts.frozenLockfile) {
13
14
  throw new PnpmError('FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE', 'Cannot resolve configDependencies with "frozen-lockfile" because the lockfile is not up to date');
@@ -45,7 +46,13 @@ export async function resolveConfigDeps(configDeps, opts) {
45
46
  envLockfile.packages[pkgKey] = {
46
47
  resolution: toLockfileResolution({ name: pkgName, version }, resolution.resolution, registry),
47
48
  };
48
- envLockfile.snapshots[pkgKey] = {};
49
+ const optionalSubdeps = await resolveOptionalSubdeps(pkgName, resolution.manifest, {
50
+ envLockfile,
51
+ lockfileDir: opts.rootDir,
52
+ registries: opts.registries,
53
+ resolveFromNpm,
54
+ });
55
+ envLockfile.snapshots[pkgKey] = optionalSubdeps ? { optionalDependencies: optionalSubdeps } : {};
49
56
  }));
50
57
  await Promise.all([
51
58
  writeSettings({
@@ -0,0 +1,13 @@
1
+ import type { EnvLockfile } from '@pnpm/lockfile.fs';
2
+ import type { ResolvedDependencies } from '@pnpm/lockfile.types';
3
+ import type { createNpmResolver } from '@pnpm/resolving.npm-resolver';
4
+ import type { DependencyManifest, Registries } from '@pnpm/types';
5
+ type ResolveFromNpm = ReturnType<typeof createNpmResolver>['resolveFromNpm'];
6
+ export interface ResolveOptionalSubdepsOpts {
7
+ envLockfile: EnvLockfile;
8
+ lockfileDir: string;
9
+ registries: Registries;
10
+ resolveFromNpm: ResolveFromNpm;
11
+ }
12
+ export declare function resolveOptionalSubdeps(parentName: string, parentManifest: DependencyManifest, opts: ResolveOptionalSubdepsOpts): Promise<ResolvedDependencies | undefined>;
13
+ export {};
@@ -0,0 +1,84 @@
1
+ import util from 'node:util';
2
+ import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
3
+ import { skippedOptionalDependencyLogger } from '@pnpm/core-loggers';
4
+ import { PnpmError } from '@pnpm/error';
5
+ import { toLockfileResolution } from '@pnpm/lockfile.utils';
6
+ import semver from 'semver';
7
+ export async function resolveOptionalSubdeps(parentName, parentManifest, opts) {
8
+ const optionalDeps = parentManifest.optionalDependencies;
9
+ if (!optionalDeps || Object.keys(optionalDeps).length === 0) {
10
+ return undefined;
11
+ }
12
+ const resolved = {};
13
+ await Promise.all(Object.entries(optionalDeps).map(async ([subdepName, subdepSpec]) => {
14
+ if (semver.valid(subdepSpec) == null) {
15
+ // Ranges and tags would let the resolved version drift between machines
16
+ // even with a stable parent integrity, breaking the lockfile's promise
17
+ // of reproducible config-dep installs.
18
+ throw new PnpmError('CONFIG_DEP_OPTIONAL_NOT_EXACT', `Cannot install "${subdepName}@${subdepSpec}" as an optionalDependency of config dependency "${parentName}": only exact versions are supported (got "${subdepSpec}")`);
19
+ }
20
+ let resolution;
21
+ try {
22
+ // `optional: true` opts into full registry metadata so the resolver
23
+ // returns `libc` (and any other fields the abbreviated metadata strips).
24
+ // See pnpm/pnpm#9950.
25
+ resolution = await opts.resolveFromNpm({ alias: subdepName, bareSpecifier: subdepSpec, optional: true }, {
26
+ lockfileDir: opts.lockfileDir,
27
+ preferredVersions: {},
28
+ projectDir: opts.lockfileDir,
29
+ });
30
+ }
31
+ catch (err) {
32
+ // Trust-downgrade is a security signal that must fail the install even
33
+ // for optional deps; everything else mirrors npm's optionalDependencies
34
+ // semantics — log and skip.
35
+ if (util.types.isNativeError(err) && 'code' in err && err.code === 'ERR_PNPM_TRUST_DOWNGRADE') {
36
+ throw err;
37
+ }
38
+ skippedOptionalDependencyLogger.debug({
39
+ details: util.types.isNativeError(err) ? err.toString() : String(err),
40
+ package: {
41
+ name: subdepName,
42
+ // No resolved version yet; surface the requested specifier so log
43
+ // consumers that format `${name}@${version}` don't render `@undefined`.
44
+ version: subdepSpec,
45
+ bareSpecifier: subdepSpec,
46
+ },
47
+ parents: [{ id: `${parentName}@${parentManifest.version}`, name: parentName, version: parentManifest.version }],
48
+ prefix: opts.lockfileDir,
49
+ reason: 'resolution_failure',
50
+ });
51
+ return;
52
+ }
53
+ if (resolution?.resolution == null ||
54
+ !('integrity' in resolution.resolution) ||
55
+ typeof resolution.resolution.integrity !== 'string' ||
56
+ !resolution.resolution.integrity ||
57
+ resolution.manifest == null) {
58
+ throw new PnpmError('BAD_CONFIG_DEP', `Cannot resolve optionalDependency "${subdepName}" of config dependency "${parentName}" because it has no integrity`);
59
+ }
60
+ const subdepVersion = resolution.manifest.version;
61
+ const registry = pickRegistryForPackage(opts.registries, subdepName);
62
+ const subdepKey = `${subdepName}@${subdepVersion}`;
63
+ opts.envLockfile.packages[subdepKey] = {
64
+ resolution: toLockfileResolution({ name: subdepName, version: subdepVersion }, resolution.resolution, registry),
65
+ ...pickPlatformFields(resolution.manifest),
66
+ };
67
+ if (opts.envLockfile.snapshots[subdepKey] == null) {
68
+ opts.envLockfile.snapshots[subdepKey] = {};
69
+ }
70
+ resolved[subdepName] = subdepVersion;
71
+ }));
72
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
73
+ }
74
+ function pickPlatformFields(manifest) {
75
+ const out = {};
76
+ if (manifest.os?.length)
77
+ out.os = manifest.os;
78
+ if (manifest.cpu?.length)
79
+ out.cpu = manifest.cpu;
80
+ if (manifest.libc?.length)
81
+ out.libc = manifest.libc;
82
+ return out;
83
+ }
84
+ //# sourceMappingURL=resolveOptionalSubdeps.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/installing.env-installer",
3
- "version": "1101.0.9",
3
+ "version": "1101.1.0",
4
4
  "description": "Installer for configurational dependencies",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -27,40 +27,43 @@
27
27
  "dependencies": {
28
28
  "@zkochan/rimraf": "^4.0.0",
29
29
  "get-npm-tarball-url": "^2.1.0",
30
+ "semver": "^7.7.2",
30
31
  "symlink-dir": "^10.0.1",
31
- "@pnpm/config.pick-registry-for-package": "1100.0.3",
32
- "@pnpm/config.writer": "1100.0.7",
32
+ "@pnpm/config.package-is-installable": "1100.0.6",
33
+ "@pnpm/config.writer": "1100.0.9",
34
+ "@pnpm/config.pick-registry-for-package": "1100.0.4",
33
35
  "@pnpm/constants": "1100.0.0",
34
- "@pnpm/core-loggers": "1100.0.2",
35
- "@pnpm/deps.graph-hasher": "1100.1.5",
36
+ "@pnpm/deps.graph-hasher": "1100.2.1",
36
37
  "@pnpm/error": "1100.0.0",
37
- "@pnpm/installing.deps-resolver": "1100.0.10",
38
- "@pnpm/lockfile.fs": "1100.0.8",
38
+ "@pnpm/installing.deps-resolver": "1100.1.1",
39
39
  "@pnpm/fs.read-modules-dir": "1100.0.1",
40
- "@pnpm/lockfile.types": "1100.0.5",
41
- "@pnpm/lockfile.pruner": "1100.0.5",
42
- "@pnpm/lockfile.utils": "1100.0.7",
43
- "@pnpm/network.auth-header": "1100.0.2",
44
- "@pnpm/resolving.npm-resolver": "1101.1.1",
45
- "@pnpm/network.fetch": "1100.0.4",
46
- "@pnpm/pkg-manifest.reader": "1100.0.3",
40
+ "@pnpm/lockfile.fs": "1100.1.1",
41
+ "@pnpm/lockfile.utils": "1100.0.9",
42
+ "@pnpm/lockfile.types": "1100.0.7",
43
+ "@pnpm/lockfile.pruner": "1100.0.7",
44
+ "@pnpm/network.auth-header": "1100.0.3",
45
+ "@pnpm/network.fetch": "1100.0.6",
46
+ "@pnpm/pkg-manifest.reader": "1100.0.4",
47
47
  "@pnpm/resolving.parse-wanted-dependency": "1100.0.1",
48
- "@pnpm/types": "1101.1.0",
49
- "@pnpm/store.controller": "1101.0.6",
50
- "@pnpm/store.controller-types": "1100.0.7"
48
+ "@pnpm/store.controller-types": "1100.1.1",
49
+ "@pnpm/store.controller": "1101.0.8",
50
+ "@pnpm/types": "1101.1.1",
51
+ "@pnpm/resolving.npm-resolver": "1101.3.0",
52
+ "@pnpm/core-loggers": "1100.1.1"
51
53
  },
52
54
  "peerDependencies": {
53
55
  "@pnpm/logger": ">=1001.0.0 <1002.0.0",
54
- "@pnpm/worker": "^1100.1.5"
56
+ "@pnpm/worker": "^1100.1.7"
55
57
  },
56
58
  "devDependencies": {
57
59
  "@jest/globals": "30.3.0",
58
60
  "@pnpm/registry-mock": "6.0.0",
61
+ "@types/semver": "7.7.1",
59
62
  "load-json-file": "^7.0.1",
60
63
  "read-yaml-file": "^3.0.0",
61
- "@pnpm/installing.env-installer": "1101.0.9",
62
- "@pnpm/prepare": "1100.0.8",
63
- "@pnpm/testing.temp-store": "1100.0.16"
64
+ "@pnpm/installing.env-installer": "1101.1.0",
65
+ "@pnpm/prepare": "1100.0.10",
66
+ "@pnpm/testing.temp-store": "1100.1.1"
64
67
  },
65
68
  "engines": {
66
69
  "node": ">=22.13"