pnpm 12.15.0 → 12.16.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/README.md +4 -4
  3. package/bin/pnpm.mjs +11 -233
  4. package/dist/node_modules/.pnpm-workspace-state-v1.json +97 -269
  5. package/dist/node_modules/confbox/LICENSE +27 -0
  6. package/dist/node_modules/confbox/dist/_chunks/_format.d.mts +22 -0
  7. package/dist/node_modules/confbox/dist/_chunks/_format.mjs +1 -0
  8. package/dist/node_modules/confbox/dist/_chunks/libs/detect-indent.mjs +1 -0
  9. package/dist/node_modules/confbox/dist/_chunks/libs/ini.mjs +3 -0
  10. package/dist/node_modules/confbox/dist/_chunks/libs/js-yaml.d.mts +33 -0
  11. package/dist/node_modules/confbox/dist/_chunks/libs/js-yaml.mjs +38 -0
  12. package/dist/node_modules/confbox/dist/_chunks/libs/json5.mjs +14 -0
  13. package/dist/node_modules/confbox/dist/_chunks/libs/smol-toml.mjs +11 -0
  14. package/dist/node_modules/confbox/dist/_chunks/libs/strip-json-comments.mjs +4 -0
  15. package/dist/node_modules/confbox/dist/_chunks/rolldown-runtime.mjs +1 -0
  16. package/dist/node_modules/confbox/dist/index.d.mts +7 -0
  17. package/dist/node_modules/confbox/dist/index.mjs +1 -0
  18. package/dist/node_modules/confbox/dist/ini.d.mts +59 -0
  19. package/dist/node_modules/confbox/dist/ini.mjs +1 -0
  20. package/dist/node_modules/confbox/dist/json.d.mts +25 -0
  21. package/dist/node_modules/confbox/dist/json.mjs +1 -0
  22. package/dist/node_modules/confbox/dist/json5.d.mts +55 -0
  23. package/dist/node_modules/confbox/dist/json5.mjs +1 -0
  24. package/dist/node_modules/confbox/dist/jsonc.d.mts +29 -0
  25. package/dist/node_modules/confbox/dist/jsonc.mjs +1 -0
  26. package/dist/node_modules/confbox/dist/toml.d.mts +20 -0
  27. package/dist/node_modules/confbox/dist/toml.mjs +1 -0
  28. package/dist/node_modules/confbox/dist/yaml.d.mts +101 -0
  29. package/dist/node_modules/confbox/dist/yaml.mjs +1 -0
  30. package/dist/node_modules/confbox/package.json +68 -0
  31. package/dist/node_modules/get-pnpm/lib/corepack.js +79 -0
  32. package/dist/node_modules/get-pnpm/lib/downloadExecutable.js +10 -4
  33. package/dist/node_modules/get-pnpm/lib/extractTarballMember.js +45 -35
  34. package/dist/node_modules/get-pnpm/lib/index.js +3 -219
  35. package/dist/node_modules/get-pnpm/lib/install.js +293 -0
  36. package/dist/node_modules/get-pnpm/lib/npmConfig.js +280 -0
  37. package/dist/node_modules/get-pnpm/lib/npmSigningKeys.js +2 -6
  38. package/dist/node_modules/get-pnpm/lib/platformPackageName.js +11 -7
  39. package/dist/node_modules/get-pnpm/lib/pnpmConfig.js +297 -0
  40. package/dist/node_modules/get-pnpm/lib/projectPin.js +201 -0
  41. package/dist/node_modules/get-pnpm/lib/registry.js +265 -41
  42. package/dist/node_modules/get-pnpm/lib/resolveVersion.js +19 -2
  43. package/dist/node_modules/get-pnpm/lib/sameFileContents.js +31 -27
  44. package/dist/node_modules/get-pnpm/lib/shadowingPnpm.js +174 -0
  45. package/dist/node_modules/get-pnpm/lib/undoHint.js +12 -0
  46. package/dist/node_modules/get-pnpm/package.json +20 -14
  47. package/package.json +15 -15
@@ -1,12 +1,18 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { createWriteStream } from 'node:fs';
3
3
  import http from 'node:http';
4
+ import net from 'node:net';
4
5
  import { pipeline } from 'node:stream/promises';
6
+ import tls from 'node:tls';
5
7
  const ABBREVIATED_PACKUMENT = 'application/vnd.npm.install-v1+json';
6
8
  export const DEFAULT_REGISTRY = 'https://registry.npmjs.org/';
7
- /** The registry npx/npm is configured with, so a mirror stays a mirror. */
8
- export function registryFromEnv() {
9
- return normalizeRegistry(process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? DEFAULT_REGISTRY);
9
+ /** Whether credentials may be sent to `url`: over HTTPS, or to the local machine. */
10
+ export function isUrlSecureForCredentials(url) {
11
+ return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback(url.hostname));
12
+ }
13
+ function isLoopback(hostname) {
14
+ return hostname === 'localhost' || hostname === '[::1]' ||
15
+ (net.isIPv4(hostname) && hostname.startsWith('127.'));
10
16
  }
11
17
  /**
12
18
  * A registry URL that can be used as a base for a relative path.
@@ -20,8 +26,9 @@ export function normalizeRegistry(registry) {
20
26
  const PROXY_VARS = ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY'];
21
27
  /**
22
28
  * Routes every request made until the returned function is called through the
23
- * proxy the environment names in `HTTPS_PROXY`/`HTTP_PROXY`, honouring
24
- * `NO_PROXY`. A no-op when the environment names none.
29
+ * proxy `proxyEnv` names in `HTTPS_PROXY`/`HTTP_PROXY`, honouring `NO_PROXY`.
30
+ * A no-op when it names none, which leaves a proxy Node.js applied at startup
31
+ * in place.
25
32
  *
26
33
  * `fetch` reads those variables only when Node was started with
27
34
  * `NODE_USE_ENV_PROXY=1`, which nothing that embeds this package controls:
@@ -35,10 +42,11 @@ const PROXY_VARS = ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY'];
35
42
  *
36
43
  * The setting is process-global, so overlapping downloads share one
37
44
  * activation: the first call applies the proxy and the last of the returned
38
- * functions to run puts the agents back, never one in the middle.
45
+ * functions to run puts the agents back, never one in the middle. The proxy of
46
+ * that first call is the one they share.
39
47
  */
40
- export function useProxyFromEnv() {
41
- if (!PROXY_VARS.some((name) => process.env[name]))
48
+ export function useProxyFromEnv(proxyEnv = process.env) {
49
+ if (!PROXY_VARS.some((name) => proxyEnv[name]))
42
50
  return () => { };
43
51
  const { setGlobalProxyFromEnv } = http;
44
52
  if (setGlobalProxyFromEnv == null)
@@ -46,7 +54,7 @@ export function useProxyFromEnv() {
46
54
  // Counted only once the activation took: a call that threw on a malformed
47
55
  // proxy URL holds nothing to release, and a retry after a fix has to activate.
48
56
  if (proxyUsers === 0)
49
- restoreProxy = setGlobalProxyFromEnv(process.env);
57
+ restoreProxy = setGlobalProxyFromEnv(proxyEnv);
50
58
  proxyUsers++;
51
59
  let released = false;
52
60
  return () => {
@@ -59,17 +67,84 @@ export function useProxyFromEnv() {
59
67
  }
60
68
  let proxyUsers = 0;
61
69
  let restoreProxy = () => { };
70
+ /**
71
+ * Adds `ca`, PEM certificates, to the CAs every TLS connection trusts until
72
+ * the returned function is called. Node.js's own CAs stay trusted, so a
73
+ * download host the registry names stays reachable.
74
+ *
75
+ * `fetch` takes no CA of its own, so this is process-global, like the proxy:
76
+ * overlapping calls each add theirs, and the defaults come back once the last
77
+ * returned function has run. A connection or TLS session opened while the CAs
78
+ * were trusted can still be reused after that.
79
+ *
80
+ * Node.js before 22.19 and 24.5 cannot add a CA after startup, so there it only
81
+ * says to use `NODE_EXTRA_CA_CERTS`.
82
+ */
83
+ export function useCertificateAuthorities(ca = []) {
84
+ if (ca.length === 0)
85
+ return () => { };
86
+ if (typeof tls.setDefaultCACertificates !== 'function') {
87
+ console.warn(`Node.js ${process.version} cannot trust the CA certificates of the npm config after startup. Set NODE_EXTRA_CA_CERTS to a file holding them instead.`);
88
+ return () => { };
89
+ }
90
+ const added = [...ca];
91
+ const builtIn = defaultCertificates ?? tls.getCACertificates('default');
92
+ try {
93
+ tls.setDefaultCACertificates([...builtIn, ...[...addedCertificates, added].flat()]);
94
+ }
95
+ catch (err) {
96
+ throw new Error(`Could not trust the CA certificates of the npm config: ${err.message}`, { cause: err });
97
+ }
98
+ defaultCertificates = builtIn;
99
+ addedCertificates.add(added);
100
+ return () => {
101
+ if (!addedCertificates.delete(added))
102
+ return;
103
+ tls.setDefaultCACertificates([...builtIn, ...[...addedCertificates].flat()]);
104
+ if (addedCertificates.size === 0)
105
+ defaultCertificates = undefined;
106
+ };
107
+ }
108
+ /** What `tls` trusted before the first active {@link useCertificateAuthorities} call. */
109
+ let defaultCertificates;
110
+ const addedCertificates = new Set();
62
111
  // Node's fetch applies no timeout of its own: a registry that accepts the
63
112
  // connection and then stalls would hang the install with no output. Metadata is
64
113
  // small and quick; the tarball budget has to survive a slow connection carrying
65
114
  // a ~150 MB download.
66
115
  const METADATA_TIMEOUT_MS = 30_000;
67
116
  const TARBALL_TIMEOUT_MS = 15 * 60_000;
68
- export async function fetchPackument(registry, pkgName, headers) {
69
- return fetchJson(new URL(pkgName, registry), ABBREVIATED_PACKUMENT, headers);
117
+ // The tarball budget is too long to wait out a connection that stopped sending
118
+ // partway through. Going this long without a byte abandons the attempt so it
119
+ // can be retried, while a slow download that keeps making progress is left be.
120
+ const TARBALL_STALL_TIMEOUT_MS = 30_000;
121
+ // A reset connection or a 502 from a load balancer in front of the registry is
122
+ // transient, and should not fail the whole install. `fetch` retries none of
123
+ // this on its own, so it is done here.
124
+ const MAX_ATTEMPTS = 3;
125
+ const RETRY_DELAY_MS = 500;
126
+ export async function fetchPackument(registry, pkgName, headers, headersPath) {
127
+ return fetchJson(new URL(pkgName, registry), ABBREVIATED_PACKUMENT, { registry, headers, headersPath });
128
+ }
129
+ export async function fetchVersionMeta(registry, pkgName, version, headers, headersPath) {
130
+ return fetchJson(new URL(`${pkgName}/${version}`, registry), 'application/json', { registry, headers, headersPath });
70
131
  }
71
- export async function fetchVersionMeta(registry, pkgName, version, headers) {
72
- return fetchJson(new URL(`${pkgName}/${version}`, registry), 'application/json', headers);
132
+ /**
133
+ * The registry signatures of `pkgName@version`.
134
+ *
135
+ * Some registry proxies, JFrog Artifactory among them, strip `dist.signatures`
136
+ * from the version document but keep them in the package document, so that is
137
+ * where they are looked up when `meta` carries none. Only the signatures are
138
+ * taken from it: they cover `<name>@<version>:<integrity>`, so they still have
139
+ * to verify against the checksum in `meta`, which the tarball is checked
140
+ * against.
141
+ */
142
+ export async function fetchSignatures(meta, opts) {
143
+ if (meta.dist.signatures?.length)
144
+ return meta.dist.signatures;
145
+ // Escaped the way npm requests a scoped package, which every registry serves.
146
+ const packument = await fetchJson(new URL(opts.pkgName.replaceAll('/', '%2f'), opts.registry), ABBREVIATED_PACKUMENT, { registry: opts.registry, headers: opts.headers, headersPath: opts.headersPath });
147
+ return packument.versions?.[opts.version]?.dist?.signatures;
73
148
  }
74
149
  /**
75
150
  * Streams `meta.dist.tarball` to `dest`, verifying the checksum the registry
@@ -82,21 +157,34 @@ export async function fetchVersionMeta(registry, pkgName, version, headers) {
82
157
  */
83
158
  export async function downloadTarball(meta, dest, opts = {}) {
84
159
  const url = tarballUrl(meta, opts.registry);
85
- const response = await request(url, undefined, TARBALL_TIMEOUT_MS, headersFor(url, opts));
86
160
  const [algorithm, expected] = checksum(meta);
87
- const hash = createHash(algorithm);
88
- const body = response.body;
89
- await pipeline(async function* () {
90
- for await (const chunk of body) {
91
- hash.update(chunk);
92
- yield chunk;
93
- }
94
- }, createWriteStream(dest));
95
- const actual = hash.digest('base64');
161
+ // The whole download is retried, not just the request: a connection reset
162
+ // halfway through a ~150 MB body is the likeliest blip of all. Each attempt
163
+ // rewrites `dest` from the start.
164
+ const actual = await withRetries(() => downloadAttempt(url, dest, algorithm, opts));
96
165
  if (actual !== expected) {
97
166
  throw new Error(`The download from ${url.href} does not match the checksum the npm registry published for it. Refusing to install.`);
98
167
  }
99
168
  }
169
+ /** One attempt of `downloadTarball`. @returns the digest of what was written. */
170
+ async function downloadAttempt(url, dest, algorithm, opts) {
171
+ const stall = stallWatchdog(opts.stallTimeoutMs ?? TARBALL_STALL_TIMEOUT_MS);
172
+ try {
173
+ const response = await request(url, undefined, TARBALL_TIMEOUT_MS, opts, stall.signal);
174
+ const hash = createHash(algorithm);
175
+ await pipeline(readBody(response, url, TARBALL_TIMEOUT_MS), async function* (body) {
176
+ for await (const chunk of body) {
177
+ stall.reset();
178
+ hash.update(chunk);
179
+ yield chunk;
180
+ }
181
+ }, createWriteStream(dest));
182
+ return hash.digest('base64');
183
+ }
184
+ finally {
185
+ stall.clear();
186
+ }
187
+ }
100
188
  /**
101
189
  * Where to download `meta`'s tarball from.
102
190
  *
@@ -114,7 +202,13 @@ export function tarballUrl(meta, registry) {
114
202
  function headersFor(url, opts) {
115
203
  if (opts.headers == null || opts.registry == null)
116
204
  return undefined;
117
- return url.origin === new URL(opts.registry).origin ? opts.headers : undefined;
205
+ const registry = new URL(normalizeRegistry(opts.registry));
206
+ if (url.origin !== registry.origin)
207
+ return undefined;
208
+ if (opts.headersPath == null)
209
+ return opts.headers;
210
+ const scope = opts.headersPath.endsWith('/') ? opts.headersPath : `${opts.headersPath}/`;
211
+ return url.pathname.startsWith(scope) ? opts.headers : undefined;
118
212
  }
119
213
  /**
120
214
  * The algorithm and digest to check a tarball against.
@@ -135,29 +229,159 @@ function checksum(meta) {
135
229
  }
136
230
  return [entry.slice(0, separator), entry.slice(separator + 1)];
137
231
  }
138
- async function fetchJson(url, accept, headers) {
139
- const response = await request(url, accept, METADATA_TIMEOUT_MS, headers);
140
- return await response.json();
232
+ async function fetchJson(url, accept, opts = {}) {
233
+ return withRetries(async () => {
234
+ const response = await request(url, accept, METADATA_TIMEOUT_MS, opts);
235
+ const chunks = [];
236
+ for await (const chunk of readBody(response, url, METADATA_TIMEOUT_MS))
237
+ chunks.push(chunk);
238
+ // Malformed JSON is what the server sent, and would be sent again, so a
239
+ // parse failure is not retried.
240
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
241
+ });
242
+ }
243
+ /** A request failure, marked so `withRetries` knows whether retrying it can help. */
244
+ class RequestFailure extends Error {
245
+ retryable;
246
+ constructor(message, opts) {
247
+ super(message, { cause: opts.cause });
248
+ this.retryable = opts.retryable;
249
+ }
250
+ }
251
+ async function withRetries(attempt) {
252
+ for (let attemptNo = 1;; attemptNo++) {
253
+ try {
254
+ // eslint-disable-next-line no-await-in-loop -- each attempt waits for the previous one to fail
255
+ return await attempt();
256
+ }
257
+ catch (err) {
258
+ if (attemptNo >= MAX_ATTEMPTS || !(err instanceof RequestFailure) || !err.retryable)
259
+ throw err;
260
+ // eslint-disable-next-line no-await-in-loop -- the delay is what spaces the attempts out
261
+ await delay(RETRY_DELAY_MS * attemptNo);
262
+ }
263
+ }
264
+ }
265
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
266
+ const MAX_REDIRECTS = 20;
267
+ /**
268
+ * Follows redirects by hand, so each hop gets only the credentials
269
+ * `headersFor` allows it: `fetch` would carry them to any path on the same
270
+ * origin, outside the scope the token was configured for.
271
+ */
272
+ async function request(url, accept, timeoutMs, opts = {}, stallSignal) {
273
+ const timeout = AbortSignal.timeout(timeoutMs);
274
+ const signal = stallSignal == null ? timeout : AbortSignal.any([timeout, stallSignal]);
275
+ let currentUrl = url;
276
+ for (let redirects = 0;; redirects++) {
277
+ // eslint-disable-next-line no-await-in-loop -- each hop is known only once the previous one answers
278
+ const response = await fetchHop(currentUrl, { accept, signal, timeoutMs, opts });
279
+ if (!REDIRECT_STATUSES.has(response.status) && response.ok)
280
+ return response;
281
+ // Left unread, the body would hold the connection until it is collected.
282
+ // eslint-disable-next-line no-await-in-loop -- the connection is released before the next hop opens one
283
+ await response.body?.cancel().catch(() => { });
284
+ const target = redirectTarget(response, currentUrl);
285
+ if (redirects >= MAX_REDIRECTS) {
286
+ throw new RequestFailure(`Could not download ${url.href}: too many redirects`, { retryable: false });
287
+ }
288
+ currentUrl = target;
289
+ }
141
290
  }
142
- async function request(url, accept, timeoutMs, headers) {
143
- let response;
291
+ async function fetchHop(url, { accept, signal, timeoutMs, opts }) {
144
292
  try {
145
- response = await fetch(url, {
146
- signal: AbortSignal.timeout(timeoutMs),
147
- headers: { ...headers, ...(accept ? { accept } : {}) },
293
+ return await fetch(url, {
294
+ redirect: 'manual',
295
+ signal,
296
+ headers: { ...headersFor(url, opts), ...(accept ? { accept } : {}) },
148
297
  });
149
298
  }
150
299
  catch (err) {
151
- const reason = err.name === 'TimeoutError'
152
- ? `timed out after ${Math.round(timeoutMs / 1000)}s`
153
- : err.message;
154
- throw new Error(`Could not reach ${url.href}: ${reason}`, { cause: err });
300
+ throw new RequestFailure(`Could not reach ${url.href}: ${reasonFor(err, timeoutMs)}`, { cause: err, retryable: isRetryableError(err, timeoutMs) });
155
301
  }
156
- if (!response.ok) {
157
- throw new Error(`Could not download ${url.href}: ${response.status} ${response.statusText}`);
302
+ }
303
+ /** Where the redirect `response` points; throws for a response that is not one. */
304
+ function redirectTarget(response, url) {
305
+ if (!REDIRECT_STATUSES.has(response.status)) {
306
+ throw new RequestFailure(`Could not download ${url.href}: ${response.status} ${response.statusText}`, { retryable: isRetryableStatus(response.status) });
307
+ }
308
+ const location = response.headers.get('location');
309
+ if (location == null) {
310
+ throw new RequestFailure(`Could not download ${url.href}: ${response.status} redirect without a Location header`, { retryable: false });
311
+ }
312
+ return new URL(location, url);
313
+ }
314
+ /**
315
+ * `response`'s body, with a connection lost partway through, or a body that
316
+ * never came, marked as worth retrying. A 200 with nothing in it is a proxy or
317
+ * CDN hiccup, not an answer.
318
+ */
319
+ async function* readBody(response, url, timeoutMs) {
320
+ let received = 0;
321
+ if (response.body != null) {
322
+ try {
323
+ for await (const chunk of response.body) {
324
+ received += chunk.byteLength;
325
+ yield chunk;
326
+ }
327
+ }
328
+ catch (err) {
329
+ throw interrupted(url, err, timeoutMs);
330
+ }
158
331
  }
159
- if (response.body == null) {
160
- throw new Error(`Empty response from ${url.href}`);
332
+ if (received === 0) {
333
+ throw new RequestFailure(`Empty response from ${url.href}`, { retryable: true });
161
334
  }
162
- return response;
335
+ }
336
+ /**
337
+ * A signal that aborts once `timeoutMs` passes without a call to `reset`.
338
+ *
339
+ * It aborts with a plain `Error` rather than a `TimeoutError`, so the failure
340
+ * reads as an interrupted connection and is retried like one.
341
+ */
342
+ function stallWatchdog(timeoutMs) {
343
+ const controller = new AbortController();
344
+ let timer;
345
+ const clear = () => {
346
+ clearTimeout(timer);
347
+ };
348
+ const reset = () => {
349
+ clear();
350
+ timer = setTimeout(() => {
351
+ controller.abort(new Error(`no data received for ${Math.round(timeoutMs / 1000)}s`));
352
+ }, timeoutMs);
353
+ };
354
+ reset();
355
+ return { signal: controller.signal, reset, clear };
356
+ }
357
+ function interrupted(url, err, timeoutMs) {
358
+ return new RequestFailure(`The download from ${url.href} was interrupted: ${reasonFor(err, timeoutMs)}`, { cause: err, retryable: isRetryableError(err, timeoutMs) });
359
+ }
360
+ function reasonFor(err, timeoutMs) {
361
+ return err.name === 'TimeoutError'
362
+ ? `timed out after ${Math.round(timeoutMs / 1000)}s`
363
+ : err.message;
364
+ }
365
+ /**
366
+ * A connection that could not be made, or was reset, is exactly the kind of
367
+ * blip retrying is for. So is a metadata request that stalled. A tarball that
368
+ * ran out its whole budget is not: that budget is already sized for a slow
369
+ * connection, and each retry would hang the install for as long again.
370
+ */
371
+ function isRetryableError(err, timeoutMs) {
372
+ return err.name !== 'TimeoutError' || timeoutMs < TARBALL_TIMEOUT_MS;
373
+ }
374
+ /**
375
+ * A 4xx means the request itself is wrong (a missing package, a bad
376
+ * credential) — retrying would just ask the same wrong question again. The
377
+ * exceptions are a request timeout and rate limiting, which say "not now"
378
+ * rather than "no". A 5xx is the server's problem, and often a transient one.
379
+ */
380
+ function isRetryableStatus(status) {
381
+ return status === 408 || status === 429 || status >= 500;
382
+ }
383
+ async function delay(ms) {
384
+ await new Promise((resolve) => {
385
+ setTimeout(resolve, ms);
386
+ });
163
387
  }
@@ -1,9 +1,11 @@
1
+ import semver from 'semver';
1
2
  /**
2
3
  * Turns what the user asked for into a concrete pnpm version.
3
4
  *
4
5
  * `spec` may be a dist-tag (`latest`, `next-12`), an exact version (`11.20.0`,
5
- * with an optional leading `v`), or a bare major (`12`), which picks that
6
- * major's stable release and falls back to its prerelease lane.
6
+ * with an optional leading `v`), a bare major (`12`), which picks that major's
7
+ * stable release and falls back to its prerelease lane, or a semver range
8
+ * (`^12.1.0`, `>=11 <13`).
7
9
  *
8
10
  * Dist-tags win over exact versions, matching `install.ps1`.
9
11
  *
@@ -21,8 +23,23 @@ export function resolveVersion(packument, spec) {
21
23
  if (majorTag)
22
24
  return majorTag;
23
25
  }
26
+ const inRange = semver.validRange(spec) == null ? undefined : versionInRange(packument, spec);
27
+ if (inRange != null)
28
+ return inRange;
24
29
  throw new Error(`Sorry! pnpm version "${spec}" could not be found. Available tags: ${Object.keys(distTags).sort().join(', ')}`);
25
30
  }
31
+ /**
32
+ * The version npm would install for `range`: the `latest` release when it is
33
+ * in range, otherwise the highest that is. A prerelease is picked only by a
34
+ * range that names one, as in npm and in pnpm's switch to a project's pinned
35
+ * version.
36
+ */
37
+ function versionInRange(packument, range) {
38
+ const latest = packument['dist-tags'].latest;
39
+ if (latest != null && semver.satisfies(latest, range))
40
+ return latest;
41
+ return semver.maxSatisfying(Object.keys(packument.versions), range) ?? undefined;
42
+ }
26
43
  /** The major of an exact version, as the package layout depends on it. */
27
44
  export function majorVersion(version) {
28
45
  const field = version.split('.')[0] ?? '';
@@ -6,40 +6,44 @@ const CHUNK_SIZE = 64 * 1024;
6
6
  * Compared rather than hashed: the answer is usually "no" at the first
7
7
  * differing byte, and there is nothing to gain from reading further.
8
8
  *
9
- * `b` is the untrusted side — a path something else may hold — so anything
10
- * other than a readable regular file of the same size is simply not the same
11
- * file, rather than an error.
9
+ * `candidate` is the untrusted side — a path something else may hold — so
10
+ * anything other than a readable regular file of the same size is not
11
+ * the same file, rather than an error.
12
12
  */
13
- export function sameFileContents(a, b) {
14
- const sizeA = fs.statSync(a).size;
15
- const statB = fs.lstatSync(b, { throwIfNoEntry: false });
16
- if (statB?.isFile() !== true || statB.size !== sizeA)
13
+ export function sameFileContents(file, candidate) {
14
+ const size = fs.statSync(file).size;
15
+ const candidateStat = fs.lstatSync(candidate, { throwIfNoEntry: false });
16
+ if (candidateStat?.isFile() !== true || candidateStat.size !== size)
17
17
  return false;
18
- let fdA;
19
- let fdB;
18
+ let fileFd;
19
+ let candidateFd;
20
20
  try {
21
- fdA = fs.openSync(a, 'r');
22
- fdB = fs.openSync(b, 'r');
23
- const bufferA = Buffer.alloc(CHUNK_SIZE);
24
- const bufferB = Buffer.alloc(CHUNK_SIZE);
25
- while (true) {
26
- const readA = fs.readSync(fdA, bufferA, 0, CHUNK_SIZE, null);
27
- const readB = fs.readSync(fdB, bufferB, 0, CHUNK_SIZE, null);
28
- if (readA !== readB)
29
- return false;
30
- if (readA === 0)
31
- return true;
32
- if (!bufferA.subarray(0, readA).equals(bufferB.subarray(0, readB)))
33
- return false;
34
- }
21
+ fileFd = fs.openSync(file, 'r');
22
+ candidateFd = fs.openSync(candidate, 'r');
23
+ return sameBytes(fileFd, candidateFd);
35
24
  }
36
25
  catch {
37
26
  return false;
38
27
  }
39
28
  finally {
40
- if (fdA !== undefined)
41
- fs.closeSync(fdA);
42
- if (fdB !== undefined)
43
- fs.closeSync(fdB);
29
+ if (fileFd !== undefined)
30
+ fs.closeSync(fileFd);
31
+ if (candidateFd !== undefined)
32
+ fs.closeSync(candidateFd);
33
+ }
34
+ }
35
+ /** Whether the rest of two open files is the same, read chunk by chunk. */
36
+ function sameBytes(fdA, fdB) {
37
+ const bufferA = Buffer.alloc(CHUNK_SIZE);
38
+ const bufferB = Buffer.alloc(CHUNK_SIZE);
39
+ while (true) {
40
+ const readA = fs.readSync(fdA, bufferA, 0, CHUNK_SIZE, null);
41
+ const readB = fs.readSync(fdB, bufferB, 0, CHUNK_SIZE, null);
42
+ if (readA !== readB)
43
+ return false;
44
+ if (readA === 0)
45
+ return true;
46
+ if (!bufferA.subarray(0, readA).equals(bufferB.subarray(0, readB)))
47
+ return false;
44
48
  }
45
49
  }
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /**
5
+ * Shim scripts (npm's `.cmd` files, Corepack's) name their target inside;
6
+ * anything larger than this is a real executable, not a script.
7
+ */
8
+ const SHIM_SCRIPT_MAX_SIZE = 64 * 1024;
9
+ const REMOVAL_COMMANDS = {
10
+ npm: 'npm uninstall -g pnpm',
11
+ homebrew: 'brew uninstall pnpm',
12
+ corepack: 'corepack disable pnpm',
13
+ volta: 'volta uninstall pnpm',
14
+ scoop: 'scoop uninstall pnpm',
15
+ unknown: undefined,
16
+ };
17
+ const ORIGIN_DESCRIPTIONS = {
18
+ npm: 'installed with npm',
19
+ homebrew: 'installed with Homebrew',
20
+ corepack: 'a Corepack shim',
21
+ volta: 'installed with Volta',
22
+ scoop: 'installed with Scoop',
23
+ unknown: 'not installed by pnpm',
24
+ };
25
+ /**
26
+ * Where `pnpm setup` installs: `PNPM_HOME`, or pnpm's default for the
27
+ * platform, resolved the way pnpm resolves it.
28
+ */
29
+ export function pnpmHomeDir(env = process.env, platform = process.platform) {
30
+ if (env.PNPM_HOME)
31
+ return env.PNPM_HOME;
32
+ if (env.XDG_DATA_HOME)
33
+ return path.join(env.XDG_DATA_HOME, 'pnpm');
34
+ switch (platform) {
35
+ case 'darwin': return path.join(os.homedir(), 'Library', 'pnpm');
36
+ case 'win32': return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'pnpm') : path.join(os.homedir(), '.pnpm');
37
+ default: return path.join(os.homedir(), '.local', 'share', 'pnpm');
38
+ }
39
+ }
40
+ /**
41
+ * The `pnpm` that a lookup through `pathEnv` finds ahead of the one `pnpm
42
+ * setup` installed under `pnpmHome`, if any.
43
+ *
44
+ * A `pnpm` from another installer (npm, Homebrew, Corepack, Volta, Scoop)
45
+ * that sits ahead of `$PNPM_HOME/bin` on PATH keeps running after the
46
+ * installer reports success, so every version it installs looks like it
47
+ * never took.
48
+ *
49
+ * A `pnpm` in `$PNPM_HOME/bin`, in `$PNPM_HOME` itself (which the v10 layout
50
+ * linked into directly), or a symlink resolving into either, is pnpm's own
51
+ * and never shadows.
52
+ */
53
+ export function findShadowingPnpm(pnpmHome, opts) {
54
+ if (opts.pathEnv == null)
55
+ return undefined;
56
+ const platform = opts.platform ?? process.platform;
57
+ const [executable, ...others] = lookUpAll(opts.pathEnv, platform, opts.pathExt);
58
+ if (executable == null || isOwnPnpm(executable, pnpmHome))
59
+ return undefined;
60
+ return {
61
+ executable,
62
+ origin: detectInstallOrigin(executable),
63
+ binOnPath: others.some((candidate) => isOwnPnpm(candidate, pnpmHome)),
64
+ };
65
+ }
66
+ /**
67
+ * The warning to print once `pnpm setup` installed under `pnpmHome` and
68
+ * `shadowing` still answers to `pnpm`.
69
+ */
70
+ export function renderShadowingPnpmWarning(shadowing, pnpmHome) {
71
+ const bin = path.join(pnpmHome, 'bin');
72
+ const origin = ORIGIN_DESCRIPTIONS[shadowing.origin];
73
+ const reorder = `move ${bin} ahead of ${path.dirname(shadowing.executable)} in PATH`;
74
+ const removal = REMOVAL_COMMANDS[shadowing.origin];
75
+ const fix = removal != null ? `run "${removal}" or ${reorder}` : reorder;
76
+ if (shadowing.binOnPath) {
77
+ return `Warning: "pnpm" on PATH is ${shadowing.executable} (${origin}), which comes before ${bin}.\n` +
78
+ `Your shell keeps running that pnpm, not the one pnpm installed to ${bin}.\n` +
79
+ `To finish switching, ${fix}.`;
80
+ }
81
+ return `Warning: "pnpm" on PATH is ${shadowing.executable} (${origin}), and ${bin} is not on PATH yet.\n` +
82
+ `Once a new shell adds it, it has to come first: ${fix}.`;
83
+ }
84
+ export function detectInstallOrigin(executable) {
85
+ return originFromPath(realpathOrSelf(executable)) ??
86
+ originFromPath(executable) ??
87
+ originFromShimScript(executable) ??
88
+ 'unknown';
89
+ }
90
+ /** Every `pnpm` on `pathEnv`, in lookup order, the way the shell would find them. */
91
+ function lookUpAll(pathEnv, platform, pathExt) {
92
+ const names = platform === 'win32'
93
+ ? (pathExt ?? process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean).map((ext) => `pnpm${ext}`)
94
+ : ['pnpm'];
95
+ return pathEnv.split(platform === 'win32' ? ';' : ':').flatMap((dir) => {
96
+ const base = dir === '' ? '.' : dir;
97
+ const spellings = platform === 'win32' ? spellingsOnDisk(base) : undefined;
98
+ return names
99
+ .map((name) => path.join(base, spellings?.get(name.toLowerCase()) ?? name))
100
+ .filter((candidate) => isExecutableFile(candidate, platform));
101
+ });
102
+ }
103
+ /**
104
+ * The names in `dir` keyed by their lower case form. Windows matches
105
+ * `pnpm.CMD` from PATHEXT to `pnpm.cmd` on disk, and the warning should name
106
+ * the file as it is spelled there.
107
+ */
108
+ function spellingsOnDisk(dir) {
109
+ try {
110
+ return new Map(fs.readdirSync(dir).map((entry) => [entry.toLowerCase(), entry]));
111
+ }
112
+ catch {
113
+ return new Map();
114
+ }
115
+ }
116
+ function isExecutableFile(file, platform) {
117
+ try {
118
+ if (!fs.statSync(file).isFile())
119
+ return false;
120
+ if (platform !== 'win32')
121
+ fs.accessSync(file, fs.constants.X_OK);
122
+ return true;
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ function originFromPath(executable) {
129
+ const components = executable.split(/[\\/]/).map((component) => component.toLowerCase());
130
+ const follows = (first, second) => components.some((component, index) => component === first && components[index + 1] === second);
131
+ if (follows('cellar', 'pnpm'))
132
+ return 'homebrew';
133
+ if (components.includes('corepack'))
134
+ return 'corepack';
135
+ if (components.includes('.volta'))
136
+ return 'volta';
137
+ if (components.includes('scoop'))
138
+ return 'scoop';
139
+ if (follows('node_modules', 'pnpm'))
140
+ return 'npm';
141
+ return undefined;
142
+ }
143
+ function originFromShimScript(executable) {
144
+ let script;
145
+ try {
146
+ if (fs.statSync(executable).size > SHIM_SCRIPT_MAX_SIZE)
147
+ return undefined;
148
+ script = fs.readFileSync(executable, 'utf8');
149
+ }
150
+ catch {
151
+ return undefined;
152
+ }
153
+ if (script.includes('corepack'))
154
+ return 'corepack';
155
+ if (script.includes('node_modules/pnpm/') || script.includes('node_modules\\pnpm\\'))
156
+ return 'npm';
157
+ return undefined;
158
+ }
159
+ function isOwnPnpm(executable, pnpmHome) {
160
+ const ownDirs = [path.join(pnpmHome, 'bin'), pnpmHome];
161
+ const dirs = [path.dirname(executable), path.dirname(realpathOrSelf(executable))];
162
+ return dirs.some((dir) => ownDirs.some((own) => sameDir(dir, own)));
163
+ }
164
+ function sameDir(left, right) {
165
+ return path.relative(left, right) === '' || path.relative(realpathOrSelf(left), realpathOrSelf(right)) === '';
166
+ }
167
+ function realpathOrSelf(file) {
168
+ try {
169
+ return fs.realpathSync(file);
170
+ }
171
+ catch {
172
+ return file;
173
+ }
174
+ }