pnpm 12.15.0 → 12.16.1
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.
- package/CHANGELOG.md +213 -0
- package/README.md +4 -4
- package/bin/pnpm.mjs +11 -233
- package/dist/node_modules/.pnpm-workspace-state-v1.json +97 -269
- package/dist/node_modules/confbox/LICENSE +27 -0
- package/dist/node_modules/confbox/dist/_chunks/_format.d.mts +22 -0
- package/dist/node_modules/confbox/dist/_chunks/_format.mjs +1 -0
- package/dist/node_modules/confbox/dist/_chunks/libs/detect-indent.mjs +1 -0
- package/dist/node_modules/confbox/dist/_chunks/libs/ini.mjs +3 -0
- package/dist/node_modules/confbox/dist/_chunks/libs/js-yaml.d.mts +33 -0
- package/dist/node_modules/confbox/dist/_chunks/libs/js-yaml.mjs +38 -0
- package/dist/node_modules/confbox/dist/_chunks/libs/json5.mjs +14 -0
- package/dist/node_modules/confbox/dist/_chunks/libs/smol-toml.mjs +11 -0
- package/dist/node_modules/confbox/dist/_chunks/libs/strip-json-comments.mjs +4 -0
- package/dist/node_modules/confbox/dist/_chunks/rolldown-runtime.mjs +1 -0
- package/dist/node_modules/confbox/dist/index.d.mts +7 -0
- package/dist/node_modules/confbox/dist/index.mjs +1 -0
- package/dist/node_modules/confbox/dist/ini.d.mts +59 -0
- package/dist/node_modules/confbox/dist/ini.mjs +1 -0
- package/dist/node_modules/confbox/dist/json.d.mts +25 -0
- package/dist/node_modules/confbox/dist/json.mjs +1 -0
- package/dist/node_modules/confbox/dist/json5.d.mts +55 -0
- package/dist/node_modules/confbox/dist/json5.mjs +1 -0
- package/dist/node_modules/confbox/dist/jsonc.d.mts +29 -0
- package/dist/node_modules/confbox/dist/jsonc.mjs +1 -0
- package/dist/node_modules/confbox/dist/toml.d.mts +20 -0
- package/dist/node_modules/confbox/dist/toml.mjs +1 -0
- package/dist/node_modules/confbox/dist/yaml.d.mts +101 -0
- package/dist/node_modules/confbox/dist/yaml.mjs +1 -0
- package/dist/node_modules/confbox/package.json +68 -0
- package/dist/node_modules/get-pnpm/lib/corepack.js +79 -0
- package/dist/node_modules/get-pnpm/lib/downloadExecutable.js +10 -4
- package/dist/node_modules/get-pnpm/lib/extractTarballMember.js +45 -35
- package/dist/node_modules/get-pnpm/lib/index.js +3 -219
- package/dist/node_modules/get-pnpm/lib/install.js +293 -0
- package/dist/node_modules/get-pnpm/lib/npmConfig.js +280 -0
- package/dist/node_modules/get-pnpm/lib/npmSigningKeys.js +2 -6
- package/dist/node_modules/get-pnpm/lib/platformPackageName.js +11 -7
- package/dist/node_modules/get-pnpm/lib/pnpmConfig.js +297 -0
- package/dist/node_modules/get-pnpm/lib/projectPin.js +201 -0
- package/dist/node_modules/get-pnpm/lib/registry.js +265 -41
- package/dist/node_modules/get-pnpm/lib/resolveVersion.js +19 -2
- package/dist/node_modules/get-pnpm/lib/sameFileContents.js +31 -27
- package/dist/node_modules/get-pnpm/lib/shadowingPnpm.js +174 -0
- package/dist/node_modules/get-pnpm/lib/undoHint.js +12 -0
- package/dist/node_modules/get-pnpm/package.json +20 -14
- 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
|
-
/**
|
|
8
|
-
export function
|
|
9
|
-
return
|
|
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
|
|
24
|
-
*
|
|
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) =>
|
|
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(
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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,
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
143
|
-
let response;
|
|
291
|
+
async function fetchHop(url, { accept, signal, timeoutMs, opts }) {
|
|
144
292
|
try {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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 (
|
|
160
|
-
throw new
|
|
332
|
+
if (received === 0) {
|
|
333
|
+
throw new RequestFailure(`Empty response from ${url.href}`, { retryable: true });
|
|
161
334
|
}
|
|
162
|
-
|
|
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`),
|
|
6
|
-
*
|
|
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
|
-
* `
|
|
10
|
-
* other than a readable regular file of the same size is
|
|
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(
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
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
|
|
19
|
-
let
|
|
18
|
+
let fileFd;
|
|
19
|
+
let candidateFd;
|
|
20
20
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 (
|
|
41
|
-
fs.closeSync(
|
|
42
|
-
if (
|
|
43
|
-
fs.closeSync(
|
|
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
|
+
}
|