@mawesome/npm-vm 0.0.0-alpha.0 → 0.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.
- package/LICENSE +21 -0
- package/README.md +50 -1
- package/dist/cli.js +4157 -0
- package/dist/cli.js.map +1 -0
- package/dist/index-B071ZJDk.d.cts +164 -0
- package/dist/index-B071ZJDk.d.ts +164 -0
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/internal.cjs +2709 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +139 -0
- package/dist/internal.d.ts +139 -0
- package/dist/internal.js +2706 -0
- package/dist/internal.js.map +1 -0
- package/dist/provenance--sOYCeF2.js +322 -0
- package/dist/provenance--sOYCeF2.js.map +1 -0
- package/dist/provenance-DDegWCyS.js +2 -0
- package/dist/runtime/atomic-C3shKdH9.cjs +88 -0
- package/dist/runtime/bootstrap.cjs +271 -0
- package/dist/runtime/commonjs-AANzyd21.cjs +7393 -0
- package/dist/runtime/dispatch-B9OSfqsn.cjs +25156 -0
- package/dist/runtime/dist-CXwBT6rE.cjs +30766 -0
- package/dist/runtime/home-BaoZZSBy.cjs +91 -0
- package/dist/runtime/install-C_M5WK98.cjs +13598 -0
- package/dist/runtime/journal-_bs-eJRJ.cjs +282 -0
- package/dist/runtime/launchers/cmd.tmpl +2 -0
- package/dist/runtime/launchers/ps1.tmpl +5 -0
- package/dist/runtime/launchers/sh.tmpl +3 -0
- package/dist/runtime/lease-HBD4CmBx.cjs +224 -0
- package/dist/runtime/lock-DBeYN-Zg.cjs +141 -0
- package/dist/runtime/messages-vUDkl1-0.cjs +174 -0
- package/dist/runtime/p-map-b8pXdaFZ.cjs +105 -0
- package/dist/runtime/proc-hB8N9WjN.cjs +127 -0
- package/dist/runtime/provenance-B4bajGXQ.cjs +2 -0
- package/dist/runtime/rolldown-runtime-BGT596CS.cjs +64 -0
- package/dist/runtime/runtime.cjs +143 -0
- package/dist/runtime/src-SpeGbMJx.cjs +314 -0
- package/dist/src-Bc9nN1Kl.cjs +2125 -0
- package/dist/src-Bc9nN1Kl.cjs.map +1 -0
- package/dist/src-NhNG2mnQ.js +1798 -0
- package/dist/src-NhNG2mnQ.js.map +1 -0
- package/package.json +87 -17
- package/index.js +0 -3
|
@@ -0,0 +1,1798 @@
|
|
|
1
|
+
import { closeSync, createReadStream, createWriteStream, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, matchesGlob, posix, relative, resolve, sep, win32 } from "node:path";
|
|
3
|
+
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
|
|
4
|
+
import semver from "semver";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
7
|
+
import { Transform } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { createGunzip } from "node:zlib";
|
|
10
|
+
import { Parser } from "tar";
|
|
11
|
+
import Config from "@npmcli/config";
|
|
12
|
+
import npmDefinitions from "@npmcli/config/lib/definitions/index.js";
|
|
13
|
+
//#region src/errors.ts
|
|
14
|
+
/** Exit codes of `npm-vm` itself and of the shim before npm starts (spec section 10). */
|
|
15
|
+
const EXIT = {
|
|
16
|
+
ok: 0,
|
|
17
|
+
problems: 1,
|
|
18
|
+
partial: 2,
|
|
19
|
+
resolution: 3,
|
|
20
|
+
transport: 4,
|
|
21
|
+
verification: 5,
|
|
22
|
+
abi: 6,
|
|
23
|
+
usage: 64
|
|
24
|
+
};
|
|
25
|
+
/** A failure with a message id from spec section 12 and the exit code it maps to. */
|
|
26
|
+
var NpmVmError = class extends Error {
|
|
27
|
+
name = "NpmVmError";
|
|
28
|
+
id;
|
|
29
|
+
exitCode;
|
|
30
|
+
constructor(id, exitCode, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.id = id;
|
|
33
|
+
this.exitCode = exitCode;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
function isNpmVmError(error) {
|
|
37
|
+
return error instanceof NpmVmError;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/messages.ts
|
|
41
|
+
/** Message builders for spec section 12; every message names the cause and one fix. */
|
|
42
|
+
/** Every URL in `text` with its user info replaced, so a registry token never reaches a terminal (spec SEC-10). */
|
|
43
|
+
function redactUrls(text) {
|
|
44
|
+
return text.replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^\s/]*)@/gi, "$1***@");
|
|
45
|
+
}
|
|
46
|
+
function describeSpecs(specs) {
|
|
47
|
+
return specs.map((spec) => spec.hash ? `${spec.exact}+${spec.hash.algorithm}.${spec.hash.hex.slice(0, 8)}…` : spec.range).join(" or ");
|
|
48
|
+
}
|
|
49
|
+
function engineMessage(version, enginesNode, nodeVersion) {
|
|
50
|
+
return [`npm ${version} needs Node ${enginesNode}, but the active Node is ${nodeVersion}.`, `Switch Node with \`nvm install <version>\` or \`fnm use <version>\`, or relax the npm range in package.json.`].join("\n");
|
|
51
|
+
}
|
|
52
|
+
function noMatchMessage(specs, file, reason) {
|
|
53
|
+
return [`No npm version satisfies ${describeSpecs(specs)} (${file}): ${reason}.`, `Run \`npm-vm which\` to see the resolution.`].join("\n");
|
|
54
|
+
}
|
|
55
|
+
function transportMessage(ownVersion, detail) {
|
|
56
|
+
return [`Cannot download npm: ${ownVersion === null ? "no npm beside the active Node" : `Node's own npm ${ownVersion}`} (${detail}); npm 8 or newer is needed as the transport.`, `Reinstall Node or run \`npm-vm install <version>\` from a Node whose npm works.`].join("\n");
|
|
57
|
+
}
|
|
58
|
+
function keyMessage(keyid) {
|
|
59
|
+
return [`The registry signed npm with key ${keyid}, which npm-vm does not know.`, `Run \`npm-vm self-update\` to pick up new registry keys.`].join("\n");
|
|
60
|
+
}
|
|
61
|
+
function unsignedMessage(registry, version) {
|
|
62
|
+
return [`${redactUrls(registry)} serves npm ${version} without a registry signature.`, `Pin a tarball hash in devEngines.packageManager (\`${version}+sha512.<hex>\`) to allow unsigned registries.`].join("\n");
|
|
63
|
+
}
|
|
64
|
+
function integrityMessage(expected, actual, detail) {
|
|
65
|
+
return [`npm tarball verification failed: ${detail}.`, `Expected ${expected}, got ${actual}. Clear the entry with \`npm-vm cache prune\` and retry.`].join("\n");
|
|
66
|
+
}
|
|
67
|
+
function abiMessage(detail) {
|
|
68
|
+
return [`npm-vm launcher mismatch: ${detail}.`, `Run \`npx --prefix ~ -y @mawesome/npm-vm setup\` to reinstall the launchers.`].join("\n");
|
|
69
|
+
}
|
|
70
|
+
/** M-LOCKED: names the file, the holder, and the one recovery that is safe for where the holder runs. */
|
|
71
|
+
function lockedMessage(path, holder, live, foreign) {
|
|
72
|
+
if (holder === null) return `${path} is locked by an unreadable owner. If no npm-vm command is running, delete that file and retry.`;
|
|
73
|
+
const who = `PID ${holder.pid} on ${holder.host} since ${holder.createdAt || "an unknown time"}`;
|
|
74
|
+
if (foreign) return [`${path} is locked by ${who}, which cannot be verified from this machine.`, `Confirm on ${holder.host} that no npm-vm command runs, then delete ${path} and retry.`].join("\n");
|
|
75
|
+
if (live) return `${path} is locked by ${who}, which is still running. Wait for it to finish and retry.`;
|
|
76
|
+
return `${path} is locked by ${who}, which is no longer running. Delete ${path} and retry.`;
|
|
77
|
+
}
|
|
78
|
+
function provenanceMessage(name, version, detail) {
|
|
79
|
+
return [`${name}@${version} failed provenance verification: ${detail}.`, "The installed version is unchanged. Retry later, or install the release by hand after checking it at https://www.npmjs.com/package/@mawesome/npm-vm."].join("\n");
|
|
80
|
+
}
|
|
81
|
+
function dirPermsMessage(dir, problem, fix) {
|
|
82
|
+
return [`${dir} ${problem}.`, `Run \`${fix}\` and retry, or point NPM_VM_HOME at a private directory.`].join("\n");
|
|
83
|
+
}
|
|
84
|
+
function changedMessage(file, what) {
|
|
85
|
+
return `${file} changed since npm-vm wrote it; left untouched. Remove the ${what} by hand if you still want it gone.`;
|
|
86
|
+
}
|
|
87
|
+
function restartMessage() {
|
|
88
|
+
return "Restart open terminals and IDEs so they pick up the new PATH.";
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/own-npm.ts
|
|
92
|
+
/**
|
|
93
|
+
* Locates Node's own npm from the running binary (spec section 1).
|
|
94
|
+
* The lib dir, not the `bin/npm` link: under `--link` that link is the shim.
|
|
95
|
+
*/
|
|
96
|
+
function ownNpmRoot(execPath, platform = process.platform) {
|
|
97
|
+
if (platform === "win32") return win32.join(win32.dirname(execPath), "node_modules", "npm");
|
|
98
|
+
let real = execPath;
|
|
99
|
+
try {
|
|
100
|
+
real = realpathSync(execPath);
|
|
101
|
+
} catch {}
|
|
102
|
+
return posix.join(posix.dirname(real), "..", "lib", "node_modules", "npm");
|
|
103
|
+
}
|
|
104
|
+
function findOwnNpm(execPath = process.execPath, platform = process.platform) {
|
|
105
|
+
const root = ownNpmRoot(execPath, platform);
|
|
106
|
+
const { join } = platform === "win32" ? win32 : posix;
|
|
107
|
+
const manifest = join(root, "package.json");
|
|
108
|
+
if (!existsSync(manifest)) return null;
|
|
109
|
+
let version;
|
|
110
|
+
try {
|
|
111
|
+
version = JSON.parse(readFileSync(manifest, "utf8")).version;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (typeof version !== "string") return null;
|
|
116
|
+
return {
|
|
117
|
+
root,
|
|
118
|
+
version,
|
|
119
|
+
cliPath: join(root, "bin", "npm-cli.js"),
|
|
120
|
+
npxCliPath: join(root, "bin", "npx-cli.js")
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/state/proc.ts
|
|
125
|
+
function powershellPath(env = process.env) {
|
|
126
|
+
return join(env["SystemRoot"] ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
127
|
+
}
|
|
128
|
+
const SHELL_DIRS = [
|
|
129
|
+
"/bin",
|
|
130
|
+
"/usr/bin",
|
|
131
|
+
"/usr/local/bin",
|
|
132
|
+
"/opt/homebrew/bin",
|
|
133
|
+
"/snap/bin"
|
|
134
|
+
];
|
|
135
|
+
/**
|
|
136
|
+
* An absolute path for a shell to probe (spec SEC-8): the login shell when `$SHELL` is absolute, else system dirs.
|
|
137
|
+
* Never PATH, which inside an npm script starts with `node_modules/.bin`.
|
|
138
|
+
*/
|
|
139
|
+
function shellBinary(shell, env = process.env, platform = process.platform) {
|
|
140
|
+
const name = shell === "powershell" ? "pwsh" : shell;
|
|
141
|
+
if (platform === "win32") {
|
|
142
|
+
const pwsh = join(env["ProgramFiles"] ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe");
|
|
143
|
+
return shell === "powershell" && existsSync(pwsh) ? pwsh : null;
|
|
144
|
+
}
|
|
145
|
+
const login = env["SHELL"];
|
|
146
|
+
return [...login !== void 0 && isAbsolute(login) && basename(login) === name ? [login] : [], ...SHELL_DIRS.map((dir) => join(dir, name))].find((candidate) => existsSync(candidate)) ?? null;
|
|
147
|
+
}
|
|
148
|
+
/** The start identity of `pid`, or null when the lookup fails or is ambiguous. */
|
|
149
|
+
function startIdentity(pid, platform = process.platform, env = process.env) {
|
|
150
|
+
try {
|
|
151
|
+
if (platform === "linux") {
|
|
152
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
153
|
+
const start = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/)[19];
|
|
154
|
+
return start !== void 0 && /^\d+$/.test(start) ? start : null;
|
|
155
|
+
}
|
|
156
|
+
if (platform === "darwin") {
|
|
157
|
+
const out = execFileSync("/bin/ps", [
|
|
158
|
+
"-o",
|
|
159
|
+
"lstart=",
|
|
160
|
+
"-p",
|
|
161
|
+
String(pid)
|
|
162
|
+
], {
|
|
163
|
+
encoding: "utf8",
|
|
164
|
+
env: {
|
|
165
|
+
LC_ALL: "C",
|
|
166
|
+
PATH: "/usr/bin:/bin"
|
|
167
|
+
}
|
|
168
|
+
}).trim();
|
|
169
|
+
return out === "" || out.includes("\n") ? null : out;
|
|
170
|
+
}
|
|
171
|
+
if (platform === "win32") {
|
|
172
|
+
const out = execFileSync(powershellPath(env), [
|
|
173
|
+
"-NoProfile",
|
|
174
|
+
"-NonInteractive",
|
|
175
|
+
"-Command",
|
|
176
|
+
`(Get-Process -Id ${pid}).StartTime.ToFileTimeUtc()`
|
|
177
|
+
], {
|
|
178
|
+
encoding: "utf8",
|
|
179
|
+
windowsHide: true
|
|
180
|
+
}).trim();
|
|
181
|
+
return /^\d+$/.test(out) ? out : null;
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
function ownIdentity(platform, env) {
|
|
189
|
+
return {
|
|
190
|
+
pid: process.pid,
|
|
191
|
+
start: startIdentity(process.pid, platform, env)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Whether the recorded process still runs: dead only when the PID is gone or its start identity differs.
|
|
196
|
+
* Any failure to look up means live, so nothing ever expires a process that merely cannot be inspected.
|
|
197
|
+
*/
|
|
198
|
+
function isLive(identity, platform = process.platform) {
|
|
199
|
+
try {
|
|
200
|
+
process.kill(identity.pid, 0);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error.code === "ESRCH") return false;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (identity.start === null) return true;
|
|
206
|
+
const current = startIdentity(identity.pid, platform);
|
|
207
|
+
return current === null || current === identity.start;
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/state/lease.ts
|
|
211
|
+
function leasesDir(entry) {
|
|
212
|
+
return `${entry}.leases`;
|
|
213
|
+
}
|
|
214
|
+
function gatePath(entry) {
|
|
215
|
+
return `${entry}.prune`;
|
|
216
|
+
}
|
|
217
|
+
function newToken() {
|
|
218
|
+
return randomBytes(8).toString("hex");
|
|
219
|
+
}
|
|
220
|
+
function ownerRecord(machineId, identity = ownIdentity()) {
|
|
221
|
+
return {
|
|
222
|
+
...identity,
|
|
223
|
+
host: hostname(),
|
|
224
|
+
machineId,
|
|
225
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function readOwner(file) {
|
|
229
|
+
try {
|
|
230
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
231
|
+
if (typeof parsed.pid !== "number") return null;
|
|
232
|
+
return {
|
|
233
|
+
pid: parsed.pid,
|
|
234
|
+
start: typeof parsed.start === "string" ? parsed.start : null,
|
|
235
|
+
host: typeof parsed.host === "string" ? parsed.host : "?",
|
|
236
|
+
machineId: typeof parsed.machineId === "string" ? parsed.machineId : "?",
|
|
237
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : ""
|
|
238
|
+
};
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** A file in the per-machine cache belongs to a dead owner only when PROC-1 says so; unreadable means live. */
|
|
244
|
+
function ownerLive(file) {
|
|
245
|
+
const owner = readOwner(file);
|
|
246
|
+
return owner === null ? true : isLive(owner);
|
|
247
|
+
}
|
|
248
|
+
/** Creates a shared lease on `entry`; the file name is unique, so a prune can never delete a live one by mistake. */
|
|
249
|
+
function createLease(entry, owner) {
|
|
250
|
+
const dir = leasesDir(entry);
|
|
251
|
+
for (let attempt = 0;; attempt += 1) {
|
|
252
|
+
mkdirSync(dir, {
|
|
253
|
+
recursive: true,
|
|
254
|
+
mode: 448
|
|
255
|
+
});
|
|
256
|
+
const path = join(dir, newToken());
|
|
257
|
+
try {
|
|
258
|
+
writeFileSync(path, JSON.stringify(owner), {
|
|
259
|
+
mode: 384,
|
|
260
|
+
flag: "wx"
|
|
261
|
+
});
|
|
262
|
+
} catch (error) {
|
|
263
|
+
rmSync(path, { force: true });
|
|
264
|
+
if (error.code === "ENOENT" && attempt < 3) continue;
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
path,
|
|
269
|
+
release() {
|
|
270
|
+
rmSync(path, { force: true });
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Whether a lease created before a gate check is still in place (spec LEASE-1).
|
|
277
|
+
* A prune that passed its own lease check first moves the whole leases dir away, taking the file with it.
|
|
278
|
+
*/
|
|
279
|
+
function leaseSurvived(lease) {
|
|
280
|
+
return existsSync(lease.path);
|
|
281
|
+
}
|
|
282
|
+
function listLeases(entry) {
|
|
283
|
+
try {
|
|
284
|
+
return readdirSync(leasesDir(entry)).map((name) => join(leasesDir(entry), name));
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function sleep(ms) {
|
|
290
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
291
|
+
}
|
|
292
|
+
const GATE_WAIT_MS = 5e3;
|
|
293
|
+
/**
|
|
294
|
+
* The runtime's shared lease (spec LEASE-1): lease first, then look for a live prune gate.
|
|
295
|
+
* A live gate means the entry is going away: wait briefly, retry once, else return null so the caller fetches a private entry.
|
|
296
|
+
*/
|
|
297
|
+
async function acquireShared(entry, owner, options = {}) {
|
|
298
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
299
|
+
const lease = createLease(entry, owner);
|
|
300
|
+
const gate = gatePath(entry);
|
|
301
|
+
let gated = false;
|
|
302
|
+
try {
|
|
303
|
+
gated = ownerLive(gate);
|
|
304
|
+
} catch {
|
|
305
|
+
gated = false;
|
|
306
|
+
}
|
|
307
|
+
if (!gated || !gateExists(gate)) {
|
|
308
|
+
if (leaseSurvived(lease)) return lease;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
lease.release();
|
|
312
|
+
const deadline = Date.now() + (options.waitMs ?? GATE_WAIT_MS);
|
|
313
|
+
while (Date.now() < deadline && gateExists(gate)) await sleep(50);
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
function gateExists(gate) {
|
|
318
|
+
try {
|
|
319
|
+
return readOwner(gate) !== null;
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Creates the prune gate with O_EXCL; false when another gate exists (there is only ever one prune, so a leftover). */
|
|
325
|
+
function createGate(entry, owner) {
|
|
326
|
+
try {
|
|
327
|
+
writeFileSync(gatePath(entry), JSON.stringify(owner), {
|
|
328
|
+
flag: "wx",
|
|
329
|
+
mode: 384
|
|
330
|
+
});
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error.code === "EEXIST") return false;
|
|
333
|
+
rmSync(gatePath(entry), { force: true });
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
function removeGate(entry) {
|
|
339
|
+
rmSync(gatePath(entry), { force: true });
|
|
340
|
+
}
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/spec.ts
|
|
343
|
+
var SpecError = class extends Error {
|
|
344
|
+
name = "SpecError";
|
|
345
|
+
field;
|
|
346
|
+
/** The project's policy for this failure, when the entry declaring it could still be read. */
|
|
347
|
+
onFail = "error";
|
|
348
|
+
constructor(field, message) {
|
|
349
|
+
super(message);
|
|
350
|
+
this.field = field;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
const MAX_RANGE_LENGTH = 256;
|
|
354
|
+
const HASH_HEX_LENGTH = {
|
|
355
|
+
sha512: 128,
|
|
356
|
+
sha256: 64,
|
|
357
|
+
sha224: 56
|
|
358
|
+
};
|
|
359
|
+
const ON_FAIL = /* @__PURE__ */ new Set([
|
|
360
|
+
"ignore",
|
|
361
|
+
"warn",
|
|
362
|
+
"error",
|
|
363
|
+
"download"
|
|
364
|
+
]);
|
|
365
|
+
/**
|
|
366
|
+
* Validates one version string before it reaches a transport argv or a message (spec RES-4).
|
|
367
|
+
* Build metadata other than a well-formed hash suffix is rejected: semver would silently drop it.
|
|
368
|
+
*/
|
|
369
|
+
function parseVersionSpec(raw, field) {
|
|
370
|
+
if (raw.length > MAX_RANGE_LENGTH) throw new SpecError(field, `${field}: version is longer than ${MAX_RANGE_LENGTH} characters`);
|
|
371
|
+
const plus = raw.indexOf("+");
|
|
372
|
+
const range = (plus === -1 ? raw : raw.slice(0, plus)).trim();
|
|
373
|
+
let hash = null;
|
|
374
|
+
if (plus !== -1) hash = parseHash(raw.slice(plus + 1), field);
|
|
375
|
+
if (semver.validRange(range) === null) throw new SpecError(field, `${field}: ${JSON.stringify(raw)} is not a semver range`);
|
|
376
|
+
const exact = semver.valid(range);
|
|
377
|
+
if (hash !== null && exact === null) throw new SpecError(field, `${field}: a hash needs an exact version, got ${JSON.stringify(raw)}`);
|
|
378
|
+
return {
|
|
379
|
+
range,
|
|
380
|
+
exact,
|
|
381
|
+
hash
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function parseHash(suffix, field) {
|
|
385
|
+
const match = /^(sha512|sha256|sha224)\.([0-9a-fA-F]+)$/.exec(suffix);
|
|
386
|
+
if (match === null) throw new SpecError(field, `${field}: unsupported hash suffix ${JSON.stringify(`+${suffix}`)}`);
|
|
387
|
+
const algorithm = match[1];
|
|
388
|
+
const hex = match[2].toLowerCase();
|
|
389
|
+
if (hex.length !== HASH_HEX_LENGTH[algorithm]) throw new SpecError(field, `${field}: ${algorithm} hash must be ${HASH_HEX_LENGTH[algorithm]} hex characters`);
|
|
390
|
+
return {
|
|
391
|
+
algorithm,
|
|
392
|
+
hex
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function isObject(value) {
|
|
396
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Reads the npm requirements of a parsed `package.json` (spec RES-4).
|
|
400
|
+
* Returns null when the project declares nothing for npm, including when it declares only pnpm or yarn.
|
|
401
|
+
*/
|
|
402
|
+
function parseSpecs(pkg) {
|
|
403
|
+
if (!isObject(pkg)) return null;
|
|
404
|
+
const devEngines = pkg["devEngines"];
|
|
405
|
+
if (isObject(devEngines) && devEngines["packageManager"] !== void 0) {
|
|
406
|
+
const fromDevEngines = parseDevEngines(devEngines["packageManager"]);
|
|
407
|
+
if (fromDevEngines !== null) return fromDevEngines;
|
|
408
|
+
}
|
|
409
|
+
const packageManager = pkg["packageManager"];
|
|
410
|
+
if (typeof packageManager === "string" && packageManager.startsWith("npm@")) {
|
|
411
|
+
const field = "packageManager";
|
|
412
|
+
return {
|
|
413
|
+
specs: [parseVersionSpec(packageManager.slice(4), field)],
|
|
414
|
+
onFail: "error",
|
|
415
|
+
field
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
function parseDevEngines(value) {
|
|
421
|
+
const field = "devEngines.packageManager";
|
|
422
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
423
|
+
if (entries.length === 0) return null;
|
|
424
|
+
const specs = [];
|
|
425
|
+
const last = entries[entries.length - 1];
|
|
426
|
+
const onFail = isObject(last) ? parseOnFail(last, field) : "error";
|
|
427
|
+
try {
|
|
428
|
+
for (const entry of entries) {
|
|
429
|
+
if (!isObject(entry)) throw new SpecError(field, `${field}: every entry must be an object`);
|
|
430
|
+
if (entry["name"] !== "npm") continue;
|
|
431
|
+
const version = entry["version"];
|
|
432
|
+
if (version === void 0) specs.push({
|
|
433
|
+
range: "*",
|
|
434
|
+
exact: null,
|
|
435
|
+
hash: null
|
|
436
|
+
});
|
|
437
|
+
else if (typeof version === "string") specs.push(parseVersionSpec(version, field));
|
|
438
|
+
else throw new SpecError(field, `${field}: version must be a string`);
|
|
439
|
+
}
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error instanceof SpecError) error.onFail = onFail;
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
if (specs.length === 0) return null;
|
|
445
|
+
return {
|
|
446
|
+
specs,
|
|
447
|
+
onFail,
|
|
448
|
+
field: "devEngines"
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function parseOnFail(entry, field) {
|
|
452
|
+
const onFail = entry.onFail;
|
|
453
|
+
if (onFail === void 0) return "error";
|
|
454
|
+
if (typeof onFail !== "string" || !ON_FAIL.has(onFail)) throw new SpecError(field, `${field}: onFail must be ignore, warn, error or download`);
|
|
455
|
+
return onFail;
|
|
456
|
+
}
|
|
457
|
+
/** Whether a concrete version satisfies the spec's range, hash aside (spec RES-5 and RES-6). */
|
|
458
|
+
function matchesRange(version, spec) {
|
|
459
|
+
return semver.satisfies(version, spec.range);
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/select.ts
|
|
463
|
+
function engineAccepts(candidate, nodeVersion) {
|
|
464
|
+
return candidate.enginesNode === null || semver.satisfies(nodeVersion, candidate.enginesNode, { includePrerelease: true });
|
|
465
|
+
}
|
|
466
|
+
function hashAccepts(candidate, spec) {
|
|
467
|
+
if (spec.hash === null) return candidate.keyid !== null;
|
|
468
|
+
if (spec.hash.algorithm === "sha512") return sriHex(candidate.integrity) === spec.hash.hex;
|
|
469
|
+
if (candidate.digests === void 0 || candidate.digests === null) return true;
|
|
470
|
+
return candidate.digests[spec.hash.algorithm] === spec.hash.hex;
|
|
471
|
+
}
|
|
472
|
+
/** The hex digest of a `sha512-<base64>` SRI string, or null for any other form. */
|
|
473
|
+
function sriHex(integrity) {
|
|
474
|
+
const match = /^sha512-([A-Za-z0-9+/=]+)$/.exec(integrity.trim());
|
|
475
|
+
return match === null ? null : Buffer.from(match[1], "base64").toString("hex");
|
|
476
|
+
}
|
|
477
|
+
/** Whether Node's own npm satisfies a spec without a hash (spec RES-5). */
|
|
478
|
+
function ownNpmSatisfies(ownVersion, specs) {
|
|
479
|
+
return specs.some((spec) => spec.hash === null && matchesRange(ownVersion, spec));
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Picks the highest candidate matching any spec and the active Node (spec RES-6), cached first.
|
|
483
|
+
* Returns the reason when nothing fits, distinguishing an `engines.node` exclusion from no match at all.
|
|
484
|
+
*/
|
|
485
|
+
function selectCandidate(specs, candidates, nodeVersion) {
|
|
486
|
+
let best = null;
|
|
487
|
+
let excluded = null;
|
|
488
|
+
for (const candidate of candidates) {
|
|
489
|
+
const matching = specs.filter((entry) => matchesRange(candidate.version, entry) && hashAccepts(candidate, entry));
|
|
490
|
+
if (matching.length === 0) continue;
|
|
491
|
+
if (!engineAccepts(candidate, nodeVersion)) {
|
|
492
|
+
if (excluded === null || semver.gt(candidate.version, excluded.version)) excluded = candidate;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (best === null || semver.gt(candidate.version, best.candidate.version)) best = {
|
|
496
|
+
candidate,
|
|
497
|
+
hashes: matching.some((entry) => entry.hash === null) ? [] : matching.flatMap((entry) => entry.hash === null ? [] : [entry.hash])
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (best !== null) return {
|
|
501
|
+
kind: "selected",
|
|
502
|
+
selection: best
|
|
503
|
+
};
|
|
504
|
+
if (excluded !== null) return {
|
|
505
|
+
kind: "failed",
|
|
506
|
+
failure: {
|
|
507
|
+
kind: "engine",
|
|
508
|
+
version: excluded.version,
|
|
509
|
+
enginesNode: excluded.enginesNode ?? "*"
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
return {
|
|
513
|
+
kind: "failed",
|
|
514
|
+
failure: {
|
|
515
|
+
kind: "nomatch",
|
|
516
|
+
reason: "no published version matches"
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/install/verify.ts
|
|
522
|
+
/** Every digest a spec or the registry may ask for, in one pass over the file. */
|
|
523
|
+
async function fileDigests(file) {
|
|
524
|
+
const sha512 = createHash("sha512");
|
|
525
|
+
const sha256 = createHash("sha256");
|
|
526
|
+
const sha224 = createHash("sha224");
|
|
527
|
+
await pipeline(createReadStream(file), new Transform({ transform(chunk, _encoding, callback) {
|
|
528
|
+
sha512.update(chunk);
|
|
529
|
+
sha256.update(chunk);
|
|
530
|
+
sha224.update(chunk);
|
|
531
|
+
callback();
|
|
532
|
+
} }));
|
|
533
|
+
const sha512Digest = sha512.digest();
|
|
534
|
+
return {
|
|
535
|
+
sha512: `sha512-${sha512Digest.toString("base64")}`,
|
|
536
|
+
sha512hex: sha512Digest.toString("hex"),
|
|
537
|
+
sha256hex: sha256.digest("hex"),
|
|
538
|
+
sha224hex: sha224.digest("hex")
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function integrityFailure(expected, actual, detail) {
|
|
542
|
+
return new NpmVmError("M-INTEGRITY", EXIT.verification, integrityMessage(expected, actual, detail));
|
|
543
|
+
}
|
|
544
|
+
/** The tarball must match the registry's integrity and, when the project pins hashes, one of them (spec SEC-2). */
|
|
545
|
+
function checkIntegrity(digests, integrity, hashes) {
|
|
546
|
+
if (digests.sha512 !== integrity.trim()) throw integrityFailure(integrity, digests.sha512, "the tarball does not match the registry integrity");
|
|
547
|
+
if (hashes.length === 0) return;
|
|
548
|
+
const actual = {
|
|
549
|
+
sha512: digests.sha512hex,
|
|
550
|
+
sha256: digests.sha256hex,
|
|
551
|
+
sha224: digests.sha224hex
|
|
552
|
+
};
|
|
553
|
+
if (!hashes.some((hash) => actual[hash.algorithm] === hash.hex)) {
|
|
554
|
+
const [first] = hashes;
|
|
555
|
+
throw integrityFailure(`${first.algorithm}.${first.hex}`, `${first.algorithm}.${actual[first.algorithm]}`, "the tarball does not match the pinned hash");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Checks the registry signatures over `name@version:integrity` against the pinned keyring (spec SEC-1).
|
|
560
|
+
* Returns the key id that verified; throws M-KEY for an unknown key and M-UNSIGNED when no signature is usable.
|
|
561
|
+
*/
|
|
562
|
+
async function verifySignatures(input) {
|
|
563
|
+
if (input.signatures.length === 0) {
|
|
564
|
+
if (input.pinned) return null;
|
|
565
|
+
throw new NpmVmError("M-UNSIGNED", EXIT.verification, unsignedMessage(input.registry, input.version));
|
|
566
|
+
}
|
|
567
|
+
const payload = Buffer.from(`${input.name}@${input.version}:${input.integrity}`, "utf8");
|
|
568
|
+
let unknown = null;
|
|
569
|
+
for (const signature of input.signatures) {
|
|
570
|
+
const key = input.keys.find((entry) => entry.keyid === signature.keyid);
|
|
571
|
+
if (key === void 0) {
|
|
572
|
+
unknown ??= signature.keyid;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (key.revoked === true || !await keyCurrent(key, input.publishedAt)) continue;
|
|
576
|
+
const publicKey = createPublicKey({
|
|
577
|
+
key: Buffer.from(key.key, "base64"),
|
|
578
|
+
format: "der",
|
|
579
|
+
type: "spki"
|
|
580
|
+
});
|
|
581
|
+
if (verify("sha256", payload, publicKey, Buffer.from(signature.sig, "base64"))) return key.keyid;
|
|
582
|
+
}
|
|
583
|
+
if (unknown !== null) throw new NpmVmError("M-KEY", EXIT.verification, keyMessage(unknown));
|
|
584
|
+
throw new NpmVmError("M-UNSIGNED", EXIT.verification, unsignedMessage(input.registry, input.version));
|
|
585
|
+
}
|
|
586
|
+
/** An expired key still vouches for versions published before it expired; an unknown time fails closed. */
|
|
587
|
+
async function keyCurrent(key, publishedAt) {
|
|
588
|
+
if (key.expires === null) return true;
|
|
589
|
+
const published = await publishedAt();
|
|
590
|
+
return published !== null && Date.parse(published) < Date.parse(key.expires);
|
|
591
|
+
}
|
|
592
|
+
const DEFAULT_LIMITS = {
|
|
593
|
+
maxEntries: 1e4,
|
|
594
|
+
maxBytes: 268435456
|
|
595
|
+
};
|
|
596
|
+
function unsafe(detail) {
|
|
597
|
+
return integrityFailure("a safe npm tarball", "an unsafe entry", detail);
|
|
598
|
+
}
|
|
599
|
+
/** The destination-relative path of a tarball entry, or a thrown M-INTEGRITY for anything outside `package/` (spec SEC-3). */
|
|
600
|
+
function entryRelativePath(path) {
|
|
601
|
+
if (path.includes("\\") || [...path].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) throw unsafe(`entry name contains a control character or backslash: ${JSON.stringify(path)}`);
|
|
602
|
+
if (path.startsWith("/") || /^[A-Za-z]:/.test(path)) throw unsafe(`absolute entry name: ${path}`);
|
|
603
|
+
const segments = path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
604
|
+
if (segments.includes("..")) throw unsafe(`entry name climbs out of the archive: ${path}`);
|
|
605
|
+
if (segments[0] !== "package") throw unsafe(`entry outside package/: ${path}`);
|
|
606
|
+
return segments.length === 1 ? "" : posix.join(...segments.slice(1));
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Extracts regular files and directories under `package/` into `dest`, rejecting everything else (spec SEC-3).
|
|
610
|
+
* Modes are `0644` or `0755` by the owner exec bit; ownership and extended attributes are ignored.
|
|
611
|
+
*/
|
|
612
|
+
async function safeExtract(tarball, dest, limits = DEFAULT_LIMITS) {
|
|
613
|
+
let entries = 0;
|
|
614
|
+
let bytes = 0;
|
|
615
|
+
let decompressed = 0;
|
|
616
|
+
const writes = [];
|
|
617
|
+
const parser = new Parser({ strict: true });
|
|
618
|
+
let aborted = null;
|
|
619
|
+
const abort = (error) => {
|
|
620
|
+
aborted ??= error;
|
|
621
|
+
parser.abort(error);
|
|
622
|
+
};
|
|
623
|
+
parser.on("entry", (entry) => {
|
|
624
|
+
try {
|
|
625
|
+
entries += 1;
|
|
626
|
+
bytes += entry.size;
|
|
627
|
+
if (entries > limits.maxEntries) throw unsafe(`more than ${limits.maxEntries} entries`);
|
|
628
|
+
if (bytes > limits.maxBytes) throw unsafe(`more than ${limits.maxBytes} bytes`);
|
|
629
|
+
const relative = entryRelativePath(entry.path);
|
|
630
|
+
const target = relative === "" ? dest : join(dest, relative);
|
|
631
|
+
if (entry.type === "Directory") {
|
|
632
|
+
mkdirSync(target, {
|
|
633
|
+
recursive: true,
|
|
634
|
+
mode: 493
|
|
635
|
+
});
|
|
636
|
+
entry.resume();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (entry.type !== "File" || relative === "") throw unsafe(`${entry.type} entry: ${entry.path}`);
|
|
640
|
+
mkdirSync(dirname(target), {
|
|
641
|
+
recursive: true,
|
|
642
|
+
mode: 493
|
|
643
|
+
});
|
|
644
|
+
const mode = ((entry.mode ?? 420) & 64) === 0 ? 420 : 493;
|
|
645
|
+
writes.push(pipeline(entry, createWriteStream(target, { mode })));
|
|
646
|
+
} catch (error) {
|
|
647
|
+
abort(error);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
const counter = new Transform({ transform(chunk, _encoding, callback) {
|
|
651
|
+
decompressed += chunk.length;
|
|
652
|
+
if (decompressed > limits.maxBytes) {
|
|
653
|
+
callback(unsafe(`more than ${limits.maxBytes} bytes after decompression`));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
callback(null, chunk);
|
|
657
|
+
} });
|
|
658
|
+
const done = new Promise((resolve, reject) => {
|
|
659
|
+
parser.on("end", () => resolve());
|
|
660
|
+
parser.on("error", (error) => reject(error));
|
|
661
|
+
});
|
|
662
|
+
done.catch(() => void 0);
|
|
663
|
+
try {
|
|
664
|
+
await pipeline(createReadStream(tarball), createGunzip(), counter, parser);
|
|
665
|
+
await done;
|
|
666
|
+
await Promise.all(writes);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
await Promise.allSettled(writes);
|
|
669
|
+
throw aborted ?? (error instanceof NpmVmError ? error : unsafe(`cannot read the tarball: ${error.message}`));
|
|
670
|
+
}
|
|
671
|
+
if (aborted !== null) throw aborted;
|
|
672
|
+
}
|
|
673
|
+
const VERIFIED_FILE = ".verified";
|
|
674
|
+
function writeVerified(dir, verified) {
|
|
675
|
+
writeFileSync(join(dir, VERIFIED_FILE), JSON.stringify(verified), { mode: 384 });
|
|
676
|
+
}
|
|
677
|
+
function readVerified(dir) {
|
|
678
|
+
try {
|
|
679
|
+
const parsed = JSON.parse(readFileSync(join(dir, VERIFIED_FILE), "utf8"));
|
|
680
|
+
if (typeof parsed.version !== "string" || typeof parsed.integrity !== "string") return null;
|
|
681
|
+
if (typeof parsed.sha256 !== "string" || typeof parsed.sha224 !== "string") return null;
|
|
682
|
+
return {
|
|
683
|
+
version: parsed.version,
|
|
684
|
+
integrity: parsed.integrity,
|
|
685
|
+
keyid: typeof parsed.keyid === "string" ? parsed.keyid : null,
|
|
686
|
+
sha256: parsed.sha256,
|
|
687
|
+
sha224: parsed.sha224,
|
|
688
|
+
verifiedAt: typeof parsed.verifiedAt === "string" ? parsed.verifiedAt : ""
|
|
689
|
+
};
|
|
690
|
+
} catch {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/** `<version>-<16 hex of the integrity>`: two registries serving different bytes get two entries (spec SEC-4). */
|
|
695
|
+
function entryName(version, integrity) {
|
|
696
|
+
const hex = sriHex(integrity);
|
|
697
|
+
if (hex === null) throw new Error(`not a sha512 integrity: ${integrity}`);
|
|
698
|
+
return `${version}-${hex.slice(0, 16)}`;
|
|
699
|
+
}
|
|
700
|
+
/** Where verified entries are staged before the rename into `npm/`: same filesystem, so the rename stays atomic. */
|
|
701
|
+
function stagingDir(cacheRoot) {
|
|
702
|
+
return join(cacheRoot, "staging");
|
|
703
|
+
}
|
|
704
|
+
function canonicalEntry(cacheRoot, version, integrity) {
|
|
705
|
+
return join(cacheRoot, "npm", entryName(version, integrity));
|
|
706
|
+
}
|
|
707
|
+
function privateEntry(canonical, token) {
|
|
708
|
+
return `${canonical}.${token}`;
|
|
709
|
+
}
|
|
710
|
+
/** Whether a directory name under `npm/` is an entry rather than a lease dir, a gate or a deletion in progress. */
|
|
711
|
+
function isEntryName(name) {
|
|
712
|
+
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?-[0-9a-f]{16}(?:\.[0-9a-f]{16})?$/.test(name);
|
|
713
|
+
}
|
|
714
|
+
function readManifest$1(dir) {
|
|
715
|
+
try {
|
|
716
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
717
|
+
const engines = pkg["engines"];
|
|
718
|
+
const node = typeof engines === "object" && engines !== null ? engines["node"] : void 0;
|
|
719
|
+
return {
|
|
720
|
+
name: pkg["name"],
|
|
721
|
+
version: pkg["version"],
|
|
722
|
+
enginesNode: typeof node === "string" ? node : null
|
|
723
|
+
};
|
|
724
|
+
} catch {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Whether an entry may run (spec SEC-5): verified for this integrity, owned by the current user,
|
|
730
|
+
* and holding the npm it claims. Never deletes anything.
|
|
731
|
+
*/
|
|
732
|
+
function validateEntry(entryPath, integrity) {
|
|
733
|
+
try {
|
|
734
|
+
const stat = statSync(entryPath);
|
|
735
|
+
if (!stat.isDirectory() || process.getuid !== void 0 && stat.uid !== process.getuid()) return false;
|
|
736
|
+
} catch {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
const verified = readVerified(entryPath);
|
|
740
|
+
if (verified === null || verified.integrity !== integrity) return false;
|
|
741
|
+
const manifest = readManifest$1(entryPath);
|
|
742
|
+
return manifest !== null && manifest.name === "npm" && manifest.version === verified.version;
|
|
743
|
+
}
|
|
744
|
+
/** Every verified entry in the cache as a candidate, whatever its validity; use-time validation decides. */
|
|
745
|
+
function listEntries(cacheRoot, keys = []) {
|
|
746
|
+
let names;
|
|
747
|
+
try {
|
|
748
|
+
names = readdirSync(join(cacheRoot, "npm"));
|
|
749
|
+
} catch {
|
|
750
|
+
return [];
|
|
751
|
+
}
|
|
752
|
+
const candidates = [];
|
|
753
|
+
for (const name of names) {
|
|
754
|
+
if (!isEntryName(name)) continue;
|
|
755
|
+
const entryPath = join(cacheRoot, "npm", name);
|
|
756
|
+
const verified = readVerified(entryPath);
|
|
757
|
+
const manifest = readManifest$1(entryPath);
|
|
758
|
+
if (verified === null || manifest === null || manifest.version !== verified.version) continue;
|
|
759
|
+
if (verified.keyid !== null && keys.some((key) => key.keyid === verified.keyid && key.revoked === true)) continue;
|
|
760
|
+
candidates.push({
|
|
761
|
+
version: verified.version,
|
|
762
|
+
enginesNode: manifest.enginesNode,
|
|
763
|
+
integrity: verified.integrity,
|
|
764
|
+
entryPath,
|
|
765
|
+
signatures: null,
|
|
766
|
+
keyid: verified.keyid,
|
|
767
|
+
digests: {
|
|
768
|
+
sha256: verified.sha256,
|
|
769
|
+
sha224: verified.sha224
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
return candidates;
|
|
774
|
+
}
|
|
775
|
+
function isCollision(error) {
|
|
776
|
+
const code = error.code;
|
|
777
|
+
return code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM" || code === "EISDIR";
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Moves a verified temp dir into the cache under a lease (spec SEC-4). The lease exists before the
|
|
781
|
+
* rename, so a prune never sees the entry without it; a lost race reuses or sidesteps the winner's entry.
|
|
782
|
+
*/
|
|
783
|
+
function publishEntry(tempDir, canonical, integrity, owner) {
|
|
784
|
+
const lease = createLease(canonical, owner);
|
|
785
|
+
if (existsSync(gatePath(canonical)) && ownerLive(gatePath(canonical))) {
|
|
786
|
+
lease.release();
|
|
787
|
+
return publishPrivately(tempDir, canonical, owner);
|
|
788
|
+
}
|
|
789
|
+
if (!leaseSurvived(lease)) return publishEntry(tempDir, canonical, integrity, owner);
|
|
790
|
+
try {
|
|
791
|
+
renameSync(tempDir, canonical);
|
|
792
|
+
return {
|
|
793
|
+
entryPath: canonical,
|
|
794
|
+
lease
|
|
795
|
+
};
|
|
796
|
+
} catch (error) {
|
|
797
|
+
if (!isCollision(error)) {
|
|
798
|
+
lease.release();
|
|
799
|
+
throw error;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (validateEntry(canonical, integrity)) {
|
|
803
|
+
rmSync(tempDir, {
|
|
804
|
+
recursive: true,
|
|
805
|
+
force: true
|
|
806
|
+
});
|
|
807
|
+
return {
|
|
808
|
+
entryPath: canonical,
|
|
809
|
+
lease
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
lease.release();
|
|
813
|
+
return publishPrivately(tempDir, canonical, owner);
|
|
814
|
+
}
|
|
815
|
+
function publishPrivately(tempDir, canonical, owner) {
|
|
816
|
+
for (;;) {
|
|
817
|
+
const entryPath = privateEntry(canonical, newToken());
|
|
818
|
+
const privateLease = createLease(entryPath, owner);
|
|
819
|
+
if (existsSync(gatePath(entryPath)) && ownerLive(gatePath(entryPath))) {
|
|
820
|
+
privateLease.release();
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (!leaseSurvived(privateLease)) continue;
|
|
824
|
+
try {
|
|
825
|
+
renameSync(tempDir, entryPath);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
privateLease.release();
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
return {
|
|
831
|
+
entryPath,
|
|
832
|
+
lease: privateLease
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/state/atomic.ts
|
|
838
|
+
const RETRY_CODES = /* @__PURE__ */ new Set([
|
|
839
|
+
"EBUSY",
|
|
840
|
+
"EPERM",
|
|
841
|
+
"EACCES"
|
|
842
|
+
]);
|
|
843
|
+
const RETRY_LIMIT_MS = 5e3;
|
|
844
|
+
function sleepSync(ms) {
|
|
845
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Runs a file operation, retrying Windows sharing violations with backoff for up to 5 s (spec MUT-3).
|
|
849
|
+
* Elsewhere the same codes are real permission errors and surface at once.
|
|
850
|
+
*/
|
|
851
|
+
function withSharingRetry(operation, platform = process.platform, limitMs = RETRY_LIMIT_MS) {
|
|
852
|
+
const started = Date.now();
|
|
853
|
+
let delay = 20;
|
|
854
|
+
for (;;) try {
|
|
855
|
+
return operation();
|
|
856
|
+
} catch (error) {
|
|
857
|
+
const code = error.code ?? "";
|
|
858
|
+
if (platform !== "win32" || !RETRY_CODES.has(code) || Date.now() - started + delay > limitMs) throw error;
|
|
859
|
+
sleepSync(delay);
|
|
860
|
+
delay = Math.min(delay * 2, 500);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** Writes `content` to `path` through a temp file in the same dir, fsynced and renamed into place. */
|
|
864
|
+
function atomicWrite(path, content, mode = 420) {
|
|
865
|
+
const dir = dirname(path);
|
|
866
|
+
mkdirSync(dir, { recursive: true });
|
|
867
|
+
const temp = join(dir, `.${newToken()}.tmp`);
|
|
868
|
+
try {
|
|
869
|
+
const fd = openSync(temp, "wx", mode);
|
|
870
|
+
try {
|
|
871
|
+
const buffer = typeof content === "string" ? Buffer.from(content, "utf8") : content;
|
|
872
|
+
for (let offset = 0; offset < buffer.length;) {
|
|
873
|
+
const written = writeSync(fd, buffer, offset, buffer.length - offset);
|
|
874
|
+
if (written <= 0) throw new Error(`short write to ${temp}: ${offset} of ${buffer.length} bytes`);
|
|
875
|
+
offset += written;
|
|
876
|
+
}
|
|
877
|
+
fsyncSync(fd);
|
|
878
|
+
} finally {
|
|
879
|
+
closeSync(fd);
|
|
880
|
+
}
|
|
881
|
+
withSharingRetry(() => renameSync(temp, path));
|
|
882
|
+
} catch (error) {
|
|
883
|
+
rmSync(temp, { force: true });
|
|
884
|
+
throw error;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/** Renames with the Windows retry; a cross-device rename is not retried, it is a layout error. */
|
|
888
|
+
function atomicRename(from, to) {
|
|
889
|
+
withSharingRetry(() => renameSync(from, to));
|
|
890
|
+
}
|
|
891
|
+
function removeWithRetry(path) {
|
|
892
|
+
withSharingRetry(() => rmSync(path, {
|
|
893
|
+
recursive: true,
|
|
894
|
+
force: true
|
|
895
|
+
}));
|
|
896
|
+
}
|
|
897
|
+
//#endregion
|
|
898
|
+
//#region src/state/prune.ts
|
|
899
|
+
const USED_SUFFIX = ".used";
|
|
900
|
+
/** Records a resolution of `entry` for the prune age, in a sidecar so the entry itself stays untouched. */
|
|
901
|
+
function touchUsed(entry) {
|
|
902
|
+
const marker = `${entry}${USED_SUFFIX}`;
|
|
903
|
+
try {
|
|
904
|
+
const now = /* @__PURE__ */ new Date();
|
|
905
|
+
utimesSync(marker, now, now);
|
|
906
|
+
} catch {
|
|
907
|
+
writeFileSync(marker, "", { flag: "a" });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
function lastUsed(entry) {
|
|
911
|
+
try {
|
|
912
|
+
return statSync(`${entry}${USED_SUFFIX}`).mtimeMs;
|
|
913
|
+
} catch {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/** Why one entry would go, or null to keep it. */
|
|
918
|
+
function pruneReason(cacheRoot, name, options) {
|
|
919
|
+
const entry = join(cacheRoot, "npm", name);
|
|
920
|
+
if (name.includes(".deleting.")) return "leftover";
|
|
921
|
+
if (!isEntryName(name)) return null;
|
|
922
|
+
const verified = readVerified(entry);
|
|
923
|
+
if (verified === null || !validateEntry(entry, verified.integrity)) return "invalid";
|
|
924
|
+
if (/\.[0-9a-f]{16}$/.test(name) && !listLeases(entry).some((lease) => ownerLive(lease))) return "orphan-private";
|
|
925
|
+
const used = lastUsed(entry);
|
|
926
|
+
return (options.now ?? Date.now()) - (used ?? statSync(entry).mtimeMs) > options.maxAgeMs ? "stale" : null;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Removes one entry the exclusive way (spec LEASE-1): gate, drop dead leases, bail on a live one, rename aside, drop the gate, delete.
|
|
930
|
+
* After the gate is gone nothing here touches the entry's paths again.
|
|
931
|
+
*/
|
|
932
|
+
function pruneEntry(entry, owner) {
|
|
933
|
+
if (existsSync(gatePath(entry)) && !ownerLive(gatePath(entry))) removeGate(entry);
|
|
934
|
+
if (!createGate(entry, owner)) return false;
|
|
935
|
+
for (const lease of listLeases(entry)) if (!ownerLive(lease)) rmSync(lease, { force: true });
|
|
936
|
+
if (listLeases(entry).length > 0) {
|
|
937
|
+
removeGate(entry);
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
const token = newToken();
|
|
941
|
+
const deletingEntry = `${entry}.deleting.${token}`;
|
|
942
|
+
const deletingLeases = `${entry}.leases.deleting.${token}`;
|
|
943
|
+
if (existsSync(entry)) atomicRename(entry, deletingEntry);
|
|
944
|
+
if (existsSync(leasesDir(entry))) atomicRename(leasesDir(entry), deletingLeases);
|
|
945
|
+
removeGate(entry);
|
|
946
|
+
removeWithRetry(deletingEntry);
|
|
947
|
+
removeWithRetry(deletingLeases);
|
|
948
|
+
rmSync(`${entry}${USED_SUFFIX}`, { force: true });
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
const STAGING_MAX_AGE_MS = 864e5;
|
|
952
|
+
/** Work dirs a crashed extraction left under `staging/`; a live one is never a day old. */
|
|
953
|
+
function pruneStaging(cacheRoot, now) {
|
|
954
|
+
let names;
|
|
955
|
+
try {
|
|
956
|
+
names = readdirSync(stagingDir(cacheRoot));
|
|
957
|
+
} catch {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
for (const name of names) {
|
|
961
|
+
const dir = join(stagingDir(cacheRoot), name);
|
|
962
|
+
try {
|
|
963
|
+
if (now - statSync(dir).mtimeMs > STAGING_MAX_AGE_MS) removeWithRetry(dir);
|
|
964
|
+
} catch {}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** Prunes the whole cache; the caller holds the exclusive lock (MUT-1) so only one prune ever runs. */
|
|
968
|
+
function pruneCache(cacheRoot, owner, options) {
|
|
969
|
+
const report = {
|
|
970
|
+
removed: [],
|
|
971
|
+
kept: [],
|
|
972
|
+
skipped: []
|
|
973
|
+
};
|
|
974
|
+
pruneStaging(cacheRoot, options.now ?? Date.now());
|
|
975
|
+
let names;
|
|
976
|
+
try {
|
|
977
|
+
names = readdirSync(join(cacheRoot, "npm"));
|
|
978
|
+
} catch {
|
|
979
|
+
return report;
|
|
980
|
+
}
|
|
981
|
+
for (const name of names) {
|
|
982
|
+
const entry = join(cacheRoot, "npm", name);
|
|
983
|
+
if (name.endsWith(".leases") && existsSync(entry) && !existsSync(entry.slice(0, -7))) {
|
|
984
|
+
(pruneEntry(entry.slice(0, -7), owner) ? report.removed : report.skipped).push(name);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
if (name.endsWith(".prune") && !existsSync(entry.slice(0, -6)) && !ownerLive(entry)) {
|
|
988
|
+
rmSync(entry, { force: true });
|
|
989
|
+
report.removed.push(name);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const reason = pruneReason(cacheRoot, name, options);
|
|
993
|
+
if (reason === null) {
|
|
994
|
+
if (isEntryName(name)) report.kept.push(name);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
if (reason === "leftover") {
|
|
998
|
+
removeWithRetry(entry);
|
|
999
|
+
report.removed.push(name);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (pruneEntry(entry, owner)) report.removed.push(name);
|
|
1003
|
+
else report.skipped.push(name);
|
|
1004
|
+
}
|
|
1005
|
+
return report;
|
|
1006
|
+
}
|
|
1007
|
+
//#endregion
|
|
1008
|
+
//#region src/setup/home.ts
|
|
1009
|
+
function localAppData(env) {
|
|
1010
|
+
return env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local");
|
|
1011
|
+
}
|
|
1012
|
+
function defaultHome(env, platform = process.platform) {
|
|
1013
|
+
if (platform === "win32") return join(localAppData(env), "npm-vm");
|
|
1014
|
+
return join(env["XDG_DATA_HOME"] || join(homedir(), ".local", "share"), "npm-vm");
|
|
1015
|
+
}
|
|
1016
|
+
function cacheBase(env, platform = process.platform) {
|
|
1017
|
+
if (platform === "win32") return join(localAppData(env), "npm-vm-cache");
|
|
1018
|
+
return join(env["XDG_CACHE_HOME"] || join(homedir(), ".cache"), "npm-vm");
|
|
1019
|
+
}
|
|
1020
|
+
function platformId(platform, env) {
|
|
1021
|
+
try {
|
|
1022
|
+
if (platform === "linux") {
|
|
1023
|
+
for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) try {
|
|
1024
|
+
const id = readFileSync(file, "utf8").trim();
|
|
1025
|
+
if (id !== "") return id;
|
|
1026
|
+
} catch {}
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
if (platform === "darwin") {
|
|
1030
|
+
const out = execFileSync("/usr/sbin/ioreg", [
|
|
1031
|
+
"-rd1",
|
|
1032
|
+
"-c",
|
|
1033
|
+
"IOPlatformExpertDevice"
|
|
1034
|
+
], {
|
|
1035
|
+
encoding: "utf8",
|
|
1036
|
+
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }
|
|
1037
|
+
});
|
|
1038
|
+
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1] ?? null;
|
|
1039
|
+
}
|
|
1040
|
+
if (platform === "win32") {
|
|
1041
|
+
const reg = join(env["SystemRoot"] ?? "C:\\Windows", "System32", "reg.exe");
|
|
1042
|
+
const out = execFileSync(reg, [
|
|
1043
|
+
"query",
|
|
1044
|
+
"HKLM\\SOFTWARE\\Microsoft\\Cryptography",
|
|
1045
|
+
"/v",
|
|
1046
|
+
"MachineGuid"
|
|
1047
|
+
], { encoding: "utf8" });
|
|
1048
|
+
return /MachineGuid\s+REG_SZ\s+(\S+)/.exec(out)?.[1] ?? null;
|
|
1049
|
+
}
|
|
1050
|
+
} catch {}
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
/** The hashed machine id, or a hashed hostname when the platform id is unreadable. */
|
|
1054
|
+
function readMachineId(env = process.env, platform = process.platform) {
|
|
1055
|
+
const raw = platformId(platform, env);
|
|
1056
|
+
const source = raw ?? hostname();
|
|
1057
|
+
return {
|
|
1058
|
+
id: createHash("sha256").update(source).digest("hex").slice(0, 16),
|
|
1059
|
+
fallback: raw === null
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function resolveDirs(env = process.env, home = env["NPM_VM_HOME"] || defaultHome(env), platform = process.platform, machine = readMachineId(env, platform)) {
|
|
1063
|
+
const absoluteHome = resolve(home);
|
|
1064
|
+
const state = join(absoluteHome, "state");
|
|
1065
|
+
return {
|
|
1066
|
+
home: absoluteHome,
|
|
1067
|
+
cache: join(cacheBase(env, platform), machine.id),
|
|
1068
|
+
state,
|
|
1069
|
+
tmp: join(state, "tmp"),
|
|
1070
|
+
journal: join(state, "journal"),
|
|
1071
|
+
machine
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
//#endregion
|
|
1075
|
+
//#region src/install/config.ts
|
|
1076
|
+
const DROPPED = /* @__PURE__ */ new Set(["NODE_OPTIONS", "NODE_EXTRA_CA_CERTS"]);
|
|
1077
|
+
/** Proxy settings reach the transport only from the user and global npm config (spec DL-2), never the env. */
|
|
1078
|
+
const PROXY_VARS = /^(https?_proxy|all_proxy|no_proxy|proxy)$/i;
|
|
1079
|
+
/** The env without anything that would steer npm or Node: `npm_config_*`, proxies, `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`. */
|
|
1080
|
+
function sanitizeEnv(env) {
|
|
1081
|
+
const clean = {};
|
|
1082
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1083
|
+
if (/^npm_config_/i.test(key) || DROPPED.has(key.toUpperCase()) || PROXY_VARS.test(key)) continue;
|
|
1084
|
+
clean[key] = value;
|
|
1085
|
+
}
|
|
1086
|
+
return clean;
|
|
1087
|
+
}
|
|
1088
|
+
function optionalString(value) {
|
|
1089
|
+
const text = Array.isArray(value) ? value.map(String).filter((item) => item !== "").join(",") : value;
|
|
1090
|
+
return typeof text === "string" && text !== "" ? text : null;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Loads npm's config the way npm does, minus the project: `cwd` is an empty dir under the home,
|
|
1094
|
+
* the env is sanitized and nothing is printed. Credentials come only from `getCredentialsByURI`.
|
|
1095
|
+
*/
|
|
1096
|
+
async function loadNpmConfig(own, env, cwd, nodePath = process.execPath) {
|
|
1097
|
+
const { definitions, shorthands, flatten, nerfDarts } = npmDefinitions;
|
|
1098
|
+
const config = new Config({
|
|
1099
|
+
definitions,
|
|
1100
|
+
shorthands,
|
|
1101
|
+
flatten,
|
|
1102
|
+
nerfDarts,
|
|
1103
|
+
npmPath: own.root,
|
|
1104
|
+
argv: [
|
|
1105
|
+
nodePath,
|
|
1106
|
+
own.cliPath,
|
|
1107
|
+
"--location=global"
|
|
1108
|
+
],
|
|
1109
|
+
execPath: nodePath,
|
|
1110
|
+
env: { ...sanitizeEnv(env) },
|
|
1111
|
+
cwd,
|
|
1112
|
+
excludeNpmCwd: true
|
|
1113
|
+
});
|
|
1114
|
+
await config.load();
|
|
1115
|
+
const registry = String(config.get("registry"));
|
|
1116
|
+
const ca = config.get("ca");
|
|
1117
|
+
return {
|
|
1118
|
+
registry,
|
|
1119
|
+
userconfig: String(config.get("userconfig")),
|
|
1120
|
+
globalconfig: String(config.get("globalconfig")),
|
|
1121
|
+
cache: String(config.get("cache")),
|
|
1122
|
+
credentials: config.getCredentialsByURI(registry),
|
|
1123
|
+
httpsProxy: optionalString(config.get("https-proxy")),
|
|
1124
|
+
proxy: optionalString(config.get("proxy")),
|
|
1125
|
+
noproxy: optionalString(config.get("noproxy")),
|
|
1126
|
+
ca: Array.isArray(ca) ? ca.map(String) : typeof ca === "string" ? [ca] : null,
|
|
1127
|
+
cafile: optionalString(config.get("cafile")),
|
|
1128
|
+
strictSsl: config.get("strict-ssl") !== false
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
//#endregion
|
|
1132
|
+
//#region src/install/fetch.ts
|
|
1133
|
+
const MIN_TRANSPORT = "8.0.0";
|
|
1134
|
+
const MAX_OUTPUT = 268435456;
|
|
1135
|
+
/** Whether Node's own npm can serve as the transport (spec DL-1). */
|
|
1136
|
+
function transportUsable(own) {
|
|
1137
|
+
return own !== null && semver.gte(own.version, MIN_TRANSPORT);
|
|
1138
|
+
}
|
|
1139
|
+
/** A fresh private working dir under `state/tmp/`, never the shared system temp dir (spec DL-2). */
|
|
1140
|
+
function newWorkDir(tmpRoot) {
|
|
1141
|
+
mkdirSync(tmpRoot, {
|
|
1142
|
+
recursive: true,
|
|
1143
|
+
mode: 448
|
|
1144
|
+
});
|
|
1145
|
+
return mkdtempSync(join(tmpRoot, "work-"));
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Runs Node's own npm as the download transport, isolated from the project (spec DL-1, DL-2).
|
|
1149
|
+
* Every call runs in its own empty working dir, with that dir as the prefix and the user and global config named explicitly.
|
|
1150
|
+
*/
|
|
1151
|
+
var Transport = class {
|
|
1152
|
+
#own;
|
|
1153
|
+
#config;
|
|
1154
|
+
#env;
|
|
1155
|
+
#tmpRoot;
|
|
1156
|
+
#nodePath;
|
|
1157
|
+
constructor(own, config, env, tmpRoot, nodePath = process.execPath) {
|
|
1158
|
+
this.#own = own;
|
|
1159
|
+
this.#config = config;
|
|
1160
|
+
this.#env = sanitizeEnv(env);
|
|
1161
|
+
this.#tmpRoot = tmpRoot;
|
|
1162
|
+
this.#nodePath = nodePath;
|
|
1163
|
+
}
|
|
1164
|
+
/** Versions of npm matching `range`, newest last as npm lists them; an unmatched range is an empty list. */
|
|
1165
|
+
view(range) {
|
|
1166
|
+
return this.viewPackage("npm", range);
|
|
1167
|
+
}
|
|
1168
|
+
/** The same for any package, used by self-update for its own package. */
|
|
1169
|
+
async viewPackage(name, range) {
|
|
1170
|
+
const output = await this.#run([
|
|
1171
|
+
"view",
|
|
1172
|
+
`${name}@${range}`,
|
|
1173
|
+
"version",
|
|
1174
|
+
"engines",
|
|
1175
|
+
"dist"
|
|
1176
|
+
], true);
|
|
1177
|
+
if (output === null) return [];
|
|
1178
|
+
return asList(output).flatMap((item) => {
|
|
1179
|
+
const version = readString(item, "version");
|
|
1180
|
+
const dist = readObject(item, "dist");
|
|
1181
|
+
const integrity = dist === null ? null : readString(dist, "integrity");
|
|
1182
|
+
const tarball = dist === null ? null : readString(dist, "tarball");
|
|
1183
|
+
if (version === null || integrity === null || tarball === null) return [];
|
|
1184
|
+
const engines = readObject(item, "engines");
|
|
1185
|
+
const signatures = (dist === null ? null : dist["signatures"]) ?? [];
|
|
1186
|
+
const attestations = dist === null ? null : readObject(dist, "attestations");
|
|
1187
|
+
return [{
|
|
1188
|
+
version,
|
|
1189
|
+
enginesNode: engines === null ? null : readString(engines, "node"),
|
|
1190
|
+
integrity,
|
|
1191
|
+
tarball,
|
|
1192
|
+
signatures: Array.isArray(signatures) ? signatures.filter(isSignature) : [],
|
|
1193
|
+
attestationsUrl: attestations === null ? null : readString(attestations, "url")
|
|
1194
|
+
}];
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
/** Publication time per version, used only to judge signatures by expired keys. */
|
|
1198
|
+
async times() {
|
|
1199
|
+
const output = await this.#run([
|
|
1200
|
+
"view",
|
|
1201
|
+
"npm",
|
|
1202
|
+
"time"
|
|
1203
|
+
], true);
|
|
1204
|
+
const map = output === null ? null : asList(output)[0] ?? null;
|
|
1205
|
+
const times = {};
|
|
1206
|
+
if (map !== null && typeof map === "object") {
|
|
1207
|
+
for (const [version, time] of Object.entries(map)) if (typeof time === "string") times[version] = time;
|
|
1208
|
+
}
|
|
1209
|
+
return times;
|
|
1210
|
+
}
|
|
1211
|
+
/** Downloads one npm version's tarball into `dest` through `npm pack`. */
|
|
1212
|
+
pack(version, dest) {
|
|
1213
|
+
return this.packPackage("npm", version, dest);
|
|
1214
|
+
}
|
|
1215
|
+
async packPackage(name, version, dest) {
|
|
1216
|
+
const item = asList(await this.#run([
|
|
1217
|
+
"pack",
|
|
1218
|
+
`${name}@${version}`,
|
|
1219
|
+
`--pack-destination=${dest}`
|
|
1220
|
+
], false)).flatMap((item) => readString(item, "filename") === null ? asList(Object.values(item)) : [item]).find((candidate) => readString(candidate, "filename") !== null) ?? null;
|
|
1221
|
+
const filename = item === null ? null : readString(item, "filename");
|
|
1222
|
+
const integrity = item === null ? null : readString(item, "integrity");
|
|
1223
|
+
if (filename === null || integrity === null) throw this.#failure(`npm pack returned no tarball for ${name}@${version}`);
|
|
1224
|
+
return {
|
|
1225
|
+
file: join(dest, filename),
|
|
1226
|
+
integrity
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
#failure(detail) {
|
|
1230
|
+
return new NpmVmError("M-TRANSPORT", EXIT.transport, transportMessage(this.#own.version, detail));
|
|
1231
|
+
}
|
|
1232
|
+
/** Runs npm with the isolation flags; returns parsed JSON, or null for a 404 when `missingIsEmpty`. */
|
|
1233
|
+
async #run(args, missingIsEmpty) {
|
|
1234
|
+
const cwd = newWorkDir(this.#tmpRoot);
|
|
1235
|
+
const argv = [
|
|
1236
|
+
this.#own.cliPath,
|
|
1237
|
+
...args,
|
|
1238
|
+
"--json",
|
|
1239
|
+
"--ignore-scripts",
|
|
1240
|
+
"--loglevel=error",
|
|
1241
|
+
"--replace-registry-host=always",
|
|
1242
|
+
...proxyFlags(this.#config),
|
|
1243
|
+
"--fetch-retry-mintimeout=1000",
|
|
1244
|
+
"--fetch-retry-maxtimeout=10000",
|
|
1245
|
+
`--prefix=${cwd}`,
|
|
1246
|
+
`--cache=${this.#config.cache}`,
|
|
1247
|
+
`--userconfig=${this.#config.userconfig}`,
|
|
1248
|
+
`--globalconfig=${this.#config.globalconfig}`
|
|
1249
|
+
];
|
|
1250
|
+
try {
|
|
1251
|
+
const { stdout: output } = await new Promise((resolve, reject) => {
|
|
1252
|
+
execFile(this.#nodePath, argv, {
|
|
1253
|
+
cwd,
|
|
1254
|
+
env: this.#env,
|
|
1255
|
+
maxBuffer: MAX_OUTPUT,
|
|
1256
|
+
timeout: 3e5,
|
|
1257
|
+
windowsHide: true
|
|
1258
|
+
}, (error, stdout, stderr) => {
|
|
1259
|
+
if (error === null) {
|
|
1260
|
+
resolve({ stdout });
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
reject(Object.assign(error, {
|
|
1264
|
+
stdout,
|
|
1265
|
+
stderr
|
|
1266
|
+
}));
|
|
1267
|
+
});
|
|
1268
|
+
});
|
|
1269
|
+
return parseJson(output);
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
const stderr = String(error.stderr ?? "");
|
|
1272
|
+
if (missingIsEmpty && /\bE404\b/.test(stderr)) return null;
|
|
1273
|
+
const redacted = redactUrls((stderr.split("\n").find((text) => /^npm (ERR!|error)/.test(text)) ?? error.message).replace(/^npm (ERR!|error) /, "").trim());
|
|
1274
|
+
if (/\bEINTEGRITY\b/.test(stderr)) throw new NpmVmError("M-INTEGRITY", EXIT.verification, integrityMessage("the registry integrity", "other bytes", redacted));
|
|
1275
|
+
throw this.#failure(redacted);
|
|
1276
|
+
} finally {
|
|
1277
|
+
rmSync(cwd, {
|
|
1278
|
+
recursive: true,
|
|
1279
|
+
force: true
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
/** Proxy settings as flags, since the env the transport sees carries none (spec DL-2). */
|
|
1285
|
+
function proxyFlags(config) {
|
|
1286
|
+
const flags = [];
|
|
1287
|
+
if (config.httpsProxy !== null) flags.push(`--https-proxy=${config.httpsProxy}`);
|
|
1288
|
+
if (config.proxy !== null) flags.push(`--proxy=${config.proxy}`);
|
|
1289
|
+
if (config.noproxy !== null) flags.push(`--noproxy=${config.noproxy}`);
|
|
1290
|
+
return flags;
|
|
1291
|
+
}
|
|
1292
|
+
function parseJson(text) {
|
|
1293
|
+
const trimmed = text.trim();
|
|
1294
|
+
if (trimmed === "") return null;
|
|
1295
|
+
return JSON.parse(trimmed);
|
|
1296
|
+
}
|
|
1297
|
+
function asList(value) {
|
|
1298
|
+
return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "object" && item !== null);
|
|
1299
|
+
}
|
|
1300
|
+
function readString(record, key) {
|
|
1301
|
+
const value = record[key];
|
|
1302
|
+
return typeof value === "string" ? value : null;
|
|
1303
|
+
}
|
|
1304
|
+
function readObject(record, key) {
|
|
1305
|
+
const value = record[key];
|
|
1306
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
1307
|
+
}
|
|
1308
|
+
function isSignature(value) {
|
|
1309
|
+
return typeof value === "object" && value !== null && typeof value.keyid === "string" && typeof value.sig === "string";
|
|
1310
|
+
}
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region src/install/keys.ts
|
|
1313
|
+
const REGISTRY_KEYS = [{
|
|
1314
|
+
keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
|
|
1315
|
+
key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==",
|
|
1316
|
+
expires: "2025-01-29T00:00:00.000Z"
|
|
1317
|
+
}, {
|
|
1318
|
+
keyid: "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
|
|
1319
|
+
key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==",
|
|
1320
|
+
expires: null
|
|
1321
|
+
}];
|
|
1322
|
+
const EXTRA_KEYS_FILE = "keys.json";
|
|
1323
|
+
/**
|
|
1324
|
+
* Keys added beside the built-in ones from `<home>/state/keys.json`, for registries that sign with their own keys.
|
|
1325
|
+
* Anything able to write there could replace the runtime itself, so this adds no trust the home does not already hold.
|
|
1326
|
+
*/
|
|
1327
|
+
function loadKeyring(stateDir) {
|
|
1328
|
+
let extra;
|
|
1329
|
+
try {
|
|
1330
|
+
extra = JSON.parse(readFileSync(join(stateDir, EXTRA_KEYS_FILE), "utf8"));
|
|
1331
|
+
} catch {
|
|
1332
|
+
return [...REGISTRY_KEYS];
|
|
1333
|
+
}
|
|
1334
|
+
const keys = Array.isArray(extra) ? extra : [];
|
|
1335
|
+
return [...REGISTRY_KEYS, ...keys.filter((key) => typeof key === "object" && key !== null && typeof key.keyid === "string" && typeof key.key === "string" && (key.expires === null || typeof key.expires === "string"))];
|
|
1336
|
+
}
|
|
1337
|
+
//#endregion
|
|
1338
|
+
//#region src/install/index.ts
|
|
1339
|
+
function createContext(env = process.env, home, node = {
|
|
1340
|
+
execPath: process.execPath,
|
|
1341
|
+
version: process.version
|
|
1342
|
+
}) {
|
|
1343
|
+
const dirs = resolveDirs(env, home);
|
|
1344
|
+
return {
|
|
1345
|
+
dirs,
|
|
1346
|
+
own: findOwnNpm(node.execPath),
|
|
1347
|
+
env,
|
|
1348
|
+
nodeVersion: node.version,
|
|
1349
|
+
nodePath: node.execPath,
|
|
1350
|
+
keys: loadKeyring(dirs.state)
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
function toCandidate(version) {
|
|
1354
|
+
return {
|
|
1355
|
+
version: version.version,
|
|
1356
|
+
enginesNode: version.enginesNode,
|
|
1357
|
+
integrity: version.integrity,
|
|
1358
|
+
entryPath: null,
|
|
1359
|
+
signatures: version.signatures
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
/** Lazily loads the npm config and builds the transport once per process. */
|
|
1363
|
+
var TransportFactory = class {
|
|
1364
|
+
#context;
|
|
1365
|
+
#ready = null;
|
|
1366
|
+
constructor(context) {
|
|
1367
|
+
this.#context = context;
|
|
1368
|
+
}
|
|
1369
|
+
get() {
|
|
1370
|
+
this.#ready ??= this.#create();
|
|
1371
|
+
return this.#ready;
|
|
1372
|
+
}
|
|
1373
|
+
async #create() {
|
|
1374
|
+
const { own, env, dirs } = this.#context;
|
|
1375
|
+
const found = own?.version ?? null;
|
|
1376
|
+
if (!transportUsable(own)) throw new NpmVmError("M-TRANSPORT", EXIT.transport, transportMessage(found, "too old or missing"));
|
|
1377
|
+
const cwd = newWorkDir(dirs.tmp);
|
|
1378
|
+
try {
|
|
1379
|
+
const config = await loadNpmConfig(own, env, cwd, this.#context.nodePath);
|
|
1380
|
+
return {
|
|
1381
|
+
transport: new Transport(own, config, env, dirs.tmp, this.#context.nodePath),
|
|
1382
|
+
config
|
|
1383
|
+
};
|
|
1384
|
+
} finally {
|
|
1385
|
+
rmSync(cwd, {
|
|
1386
|
+
recursive: true,
|
|
1387
|
+
force: true
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
function createInstaller(context) {
|
|
1393
|
+
const factory = new TransportFactory(context);
|
|
1394
|
+
async function registryVersions(specs) {
|
|
1395
|
+
const { transport } = await factory.get();
|
|
1396
|
+
const byVersion = /* @__PURE__ */ new Map();
|
|
1397
|
+
for (const spec of specs) for (const version of await transport.view(spec.exact ?? spec.range)) byVersion.set(version.version, version);
|
|
1398
|
+
return [...byVersion.values()];
|
|
1399
|
+
}
|
|
1400
|
+
async function installTarball(selection, source, onPacked) {
|
|
1401
|
+
const { candidate } = selection;
|
|
1402
|
+
const owner = ownerRecord(context.dirs.machine.id);
|
|
1403
|
+
if (source.published.version !== candidate.version || source.published.integrity !== candidate.integrity) throw new NpmVmError("M-INTEGRITY", EXIT.verification, integrityMessage(candidate.integrity, source.published.integrity, `the registry no longer serves npm ${candidate.version} with this integrity`));
|
|
1404
|
+
const digests = await fileDigests(source.file);
|
|
1405
|
+
checkIntegrity(digests, candidate.integrity, selection.hashes);
|
|
1406
|
+
const keyid = await verifySignatures({
|
|
1407
|
+
name: "npm",
|
|
1408
|
+
version: candidate.version,
|
|
1409
|
+
integrity: candidate.integrity,
|
|
1410
|
+
signatures: source.published.signatures,
|
|
1411
|
+
publishedAt: source.publishedAt,
|
|
1412
|
+
keys: context.keys,
|
|
1413
|
+
registry: source.registry,
|
|
1414
|
+
pinned: selection.hashes.length > 0
|
|
1415
|
+
});
|
|
1416
|
+
if (onPacked !== void 0) await onPacked(source, digests);
|
|
1417
|
+
const work = newWorkDir(stagingDir(context.dirs.cache));
|
|
1418
|
+
try {
|
|
1419
|
+
const extracted = join(work, "package");
|
|
1420
|
+
mkdirSync(extracted, { mode: 448 });
|
|
1421
|
+
await safeExtract(source.file, extracted);
|
|
1422
|
+
checkExtracted(extracted, candidate, context.nodeVersion);
|
|
1423
|
+
writeVerified(extracted, {
|
|
1424
|
+
version: candidate.version,
|
|
1425
|
+
integrity: candidate.integrity,
|
|
1426
|
+
keyid,
|
|
1427
|
+
sha256: digests.sha256hex,
|
|
1428
|
+
sha224: digests.sha224hex,
|
|
1429
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1430
|
+
});
|
|
1431
|
+
const canonical = canonicalEntry(context.dirs.cache, candidate.version, candidate.integrity);
|
|
1432
|
+
mkdirSync(join(canonical, ".."), {
|
|
1433
|
+
recursive: true,
|
|
1434
|
+
mode: 448
|
|
1435
|
+
});
|
|
1436
|
+
const published = publishEntry(extracted, canonical, candidate.integrity, owner);
|
|
1437
|
+
touchUsed(published.entryPath);
|
|
1438
|
+
return published;
|
|
1439
|
+
} finally {
|
|
1440
|
+
rmSync(work, {
|
|
1441
|
+
recursive: true,
|
|
1442
|
+
force: true
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return {
|
|
1447
|
+
nodeVersion: context.nodeVersion,
|
|
1448
|
+
ownNpm: () => context.own,
|
|
1449
|
+
cached: async () => listEntries(context.dirs.cache, context.keys),
|
|
1450
|
+
registry: async (specs) => (await registryVersions(specs)).map(toCandidate),
|
|
1451
|
+
entryPathFor: (candidate) => canonicalEntry(context.dirs.cache, candidate.version, candidate.integrity),
|
|
1452
|
+
async registryIdentity() {
|
|
1453
|
+
const { config } = await factory.get();
|
|
1454
|
+
return createHash("sha256").update(config.registry).digest("hex").slice(0, 12);
|
|
1455
|
+
},
|
|
1456
|
+
installTarball: (selection, source) => installTarball(selection, source),
|
|
1457
|
+
async ensure(selection, options = {}) {
|
|
1458
|
+
const { candidate } = selection;
|
|
1459
|
+
const owner = ownerRecord(context.dirs.machine.id);
|
|
1460
|
+
if (candidate.entryPath !== null && validateEntry(candidate.entryPath, candidate.integrity)) {
|
|
1461
|
+
const lease = await acquireShared(candidate.entryPath, owner);
|
|
1462
|
+
if (lease !== null && validateEntry(candidate.entryPath, candidate.integrity)) {
|
|
1463
|
+
touchUsed(candidate.entryPath);
|
|
1464
|
+
return {
|
|
1465
|
+
entryPath: candidate.entryPath,
|
|
1466
|
+
lease
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
lease?.release();
|
|
1470
|
+
}
|
|
1471
|
+
if (options.offline ?? (context.env["NPM_VM_OFFLINE"] ?? "") !== "") throw new NpmVmError("M-NOMATCH", EXIT.resolution, `npm ${candidate.version} is not usable from the cache and NPM_VM_OFFLINE forbids the registry.`);
|
|
1472
|
+
const { transport, config } = await factory.get();
|
|
1473
|
+
const published = (await transport.view(candidate.version)).find((item) => item.version === candidate.version);
|
|
1474
|
+
if (published === void 0) throw new NpmVmError("M-INTEGRITY", EXIT.verification, integrityMessage(candidate.integrity, "nothing", `the registry no longer serves npm ${candidate.version}`));
|
|
1475
|
+
const work = newWorkDir(context.dirs.tmp);
|
|
1476
|
+
try {
|
|
1477
|
+
return await installTarball(selection, {
|
|
1478
|
+
file: (await transport.pack(candidate.version, work)).file,
|
|
1479
|
+
published,
|
|
1480
|
+
publishedAt: async () => (await transport.times())[candidate.version] ?? null,
|
|
1481
|
+
registry: config.registry
|
|
1482
|
+
}, options.onPacked);
|
|
1483
|
+
} finally {
|
|
1484
|
+
rmSync(work, {
|
|
1485
|
+
recursive: true,
|
|
1486
|
+
force: true
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
/** The extracted manifest is authoritative for name, version and engines (spec SEC-5). */
|
|
1493
|
+
function checkExtracted(dir, candidate, nodeVersion) {
|
|
1494
|
+
let pkg;
|
|
1495
|
+
try {
|
|
1496
|
+
pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
throw new NpmVmError("M-INTEGRITY", EXIT.verification, integrityMessage("npm package.json", "unreadable", error.message));
|
|
1499
|
+
}
|
|
1500
|
+
if (pkg["name"] !== "npm" || pkg["version"] !== candidate.version) throw new NpmVmError("M-INTEGRITY", EXIT.verification, integrityMessage(`npm@${candidate.version}`, `${String(pkg["name"])}@${String(pkg["version"])}`, "the tarball holds a different package"));
|
|
1501
|
+
const engines = pkg["engines"];
|
|
1502
|
+
const node = typeof engines === "object" && engines !== null ? engines["node"] : void 0;
|
|
1503
|
+
if (typeof node === "string" && !semver.satisfies(nodeVersion, node, { includePrerelease: true })) throw new NpmVmError("M-ENGINE", EXIT.resolution, engineMessage(candidate.version, node, nodeVersion));
|
|
1504
|
+
}
|
|
1505
|
+
//#endregion
|
|
1506
|
+
//#region src/project.ts
|
|
1507
|
+
const BOOLEANS = /* @__PURE__ */ new Set(["global", "workspaces"]);
|
|
1508
|
+
const SHORTHANDS = {
|
|
1509
|
+
C: "prefix",
|
|
1510
|
+
g: "global",
|
|
1511
|
+
L: "location",
|
|
1512
|
+
ws: "workspaces"
|
|
1513
|
+
};
|
|
1514
|
+
/**
|
|
1515
|
+
* The subset of nopt that decides the prefix: `--flag value`, `--flag=value`, `--no-flag`, `--flag true|false` for booleans, npm's shorthands and `--`.
|
|
1516
|
+
* Later flags win, as in nopt.
|
|
1517
|
+
*/
|
|
1518
|
+
function parsePrefixFlags(argv, cwd) {
|
|
1519
|
+
const flags = {
|
|
1520
|
+
prefix: null,
|
|
1521
|
+
global: null,
|
|
1522
|
+
location: null,
|
|
1523
|
+
workspaces: null
|
|
1524
|
+
};
|
|
1525
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1526
|
+
const arg = argv[i];
|
|
1527
|
+
if (arg === "--") break;
|
|
1528
|
+
let name;
|
|
1529
|
+
let value = null;
|
|
1530
|
+
if (arg.startsWith("--")) {
|
|
1531
|
+
const eq = arg.indexOf("=");
|
|
1532
|
+
name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
1533
|
+
value = eq === -1 ? null : arg.slice(eq + 1);
|
|
1534
|
+
name = SHORTHANDS[name] ?? name;
|
|
1535
|
+
} else if (arg.startsWith("-") && arg.length > 1 && SHORTHANDS[arg.slice(1)] !== void 0) name = SHORTHANDS[arg.slice(1)];
|
|
1536
|
+
else continue;
|
|
1537
|
+
let negated = false;
|
|
1538
|
+
if (name.startsWith("no-") && BOOLEANS.has(name.slice(3))) {
|
|
1539
|
+
negated = true;
|
|
1540
|
+
name = name.slice(3);
|
|
1541
|
+
}
|
|
1542
|
+
if (BOOLEANS.has(name)) {
|
|
1543
|
+
let bool = !negated;
|
|
1544
|
+
if (value !== null) bool = value !== "false";
|
|
1545
|
+
else if (!negated && (argv[i + 1] === "true" || argv[i + 1] === "false")) {
|
|
1546
|
+
bool = argv[i + 1] === "true";
|
|
1547
|
+
i += 1;
|
|
1548
|
+
}
|
|
1549
|
+
if (name === "global") flags.global = bool;
|
|
1550
|
+
else flags.workspaces = bool;
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if (name !== "prefix" && name !== "location") continue;
|
|
1554
|
+
if (value === null) {
|
|
1555
|
+
if (i + 1 >= argv.length) continue;
|
|
1556
|
+
value = argv[i + 1];
|
|
1557
|
+
i += 1;
|
|
1558
|
+
}
|
|
1559
|
+
if (name === "prefix") flags.prefix = resolve(cwd, value);
|
|
1560
|
+
else flags.location = value;
|
|
1561
|
+
}
|
|
1562
|
+
return flags;
|
|
1563
|
+
}
|
|
1564
|
+
/** Global mode from the command line and the npm env, CLI winning (spec RES-3). */
|
|
1565
|
+
function isGlobalMode(flags, env) {
|
|
1566
|
+
const fromEnv = env["npm_config_global"];
|
|
1567
|
+
const global = flags.global ?? (fromEnv === void 0 || fromEnv === "" ? null : ![
|
|
1568
|
+
"false",
|
|
1569
|
+
"0",
|
|
1570
|
+
"null"
|
|
1571
|
+
].includes(fromEnv));
|
|
1572
|
+
const location = flags.location ?? env["npm_config_location"] ?? null;
|
|
1573
|
+
return global === true || location === "global";
|
|
1574
|
+
}
|
|
1575
|
+
function hasPackageJson(dir) {
|
|
1576
|
+
try {
|
|
1577
|
+
return statSync(join(dir, "package.json")).isFile();
|
|
1578
|
+
} catch {
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
function hasNodeModules(dir) {
|
|
1583
|
+
try {
|
|
1584
|
+
return statSync(join(dir, "node_modules")).isDirectory();
|
|
1585
|
+
} catch {
|
|
1586
|
+
return false;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function* walkUp(start) {
|
|
1590
|
+
let dir = resolve(start);
|
|
1591
|
+
for (;;) {
|
|
1592
|
+
yield dir;
|
|
1593
|
+
const parent = dirname(dir);
|
|
1594
|
+
if (parent === dir) return;
|
|
1595
|
+
dir = parent;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
/** The `workspaces` patterns of a manifest: the array form or `{ packages: [] }`, else none. */
|
|
1599
|
+
function workspacePatterns(pkg) {
|
|
1600
|
+
if (typeof pkg !== "object" || pkg === null) return [];
|
|
1601
|
+
let raw = pkg.workspaces;
|
|
1602
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) raw = raw.packages;
|
|
1603
|
+
if (!Array.isArray(raw)) return [];
|
|
1604
|
+
return raw.filter((entry) => typeof entry === "string");
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Whether `candidate` is one of the workspaces `root` declares, matched like `@npmcli/map-workspaces`.
|
|
1608
|
+
* A pattern selects directories holding a `package.json`, `!pattern` removes matches, and `node_modules` never matches.
|
|
1609
|
+
*/
|
|
1610
|
+
function isWorkspaceOf(root, candidate, patterns) {
|
|
1611
|
+
const rel = relative(root, candidate);
|
|
1612
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel) || !hasPackageJson(candidate)) return false;
|
|
1613
|
+
if (rel.split(sep).includes("node_modules")) return false;
|
|
1614
|
+
const posix = rel.split(sep).join("/");
|
|
1615
|
+
const { positive, negative } = splitPatterns(patterns);
|
|
1616
|
+
return positive.some((pattern) => matchesGlob(posix, pattern)) && !negative.some((pattern) => matchesGlob(posix, pattern));
|
|
1617
|
+
}
|
|
1618
|
+
function splitPatterns(patterns) {
|
|
1619
|
+
const positive = [];
|
|
1620
|
+
const negative = [];
|
|
1621
|
+
for (const raw of patterns) {
|
|
1622
|
+
const bangs = /^!+/.exec(raw)?.[0].length ?? 0;
|
|
1623
|
+
const pattern = normalizePattern(raw.slice(bangs));
|
|
1624
|
+
if (bangs % 2 === 1) {
|
|
1625
|
+
negative.push(pattern);
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
for (let i = negative.length - 1; i >= 0; i -= 1) if (matchesGlob(pattern, negative[i])) negative.splice(i, 1);
|
|
1629
|
+
positive.push(pattern);
|
|
1630
|
+
}
|
|
1631
|
+
return {
|
|
1632
|
+
positive,
|
|
1633
|
+
negative
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
function normalizePattern(pattern) {
|
|
1637
|
+
return pattern.replace(/\\/g, "/").replace(/^\.?\/+/, "").replace(/\/+$/, "");
|
|
1638
|
+
}
|
|
1639
|
+
function readManifest(dir) {
|
|
1640
|
+
try {
|
|
1641
|
+
return JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
1642
|
+
} catch {
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Mirrors `@npmcli/config` `loadLocalPrefix` (spec RES-2): a CLI prefix is final.
|
|
1648
|
+
* Otherwise the nearest ancestor with `package.json` or `node_modules` wins, promoted to a parent whose `workspaces` include it unless `--no-workspaces` or global mode.
|
|
1649
|
+
*/
|
|
1650
|
+
function findProject(argv, cwd, env = process.env) {
|
|
1651
|
+
const flags = parsePrefixFlags(argv, cwd);
|
|
1652
|
+
const global = isGlobalMode(flags, env);
|
|
1653
|
+
if (flags.prefix !== null) return {
|
|
1654
|
+
root: flags.prefix,
|
|
1655
|
+
global,
|
|
1656
|
+
cliPrefix: flags.prefix
|
|
1657
|
+
};
|
|
1658
|
+
let localPrefix = null;
|
|
1659
|
+
for (const dir of walkUp(cwd)) {
|
|
1660
|
+
const manifest = hasPackageJson(dir);
|
|
1661
|
+
if (localPrefix === null) {
|
|
1662
|
+
if (manifest || hasNodeModules(dir)) {
|
|
1663
|
+
localPrefix = dir;
|
|
1664
|
+
if (flags.workspaces === false || global) break;
|
|
1665
|
+
}
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
if (!manifest) continue;
|
|
1669
|
+
const pkg = readManifest(dir);
|
|
1670
|
+
if (pkg !== null && isWorkspaceOf(dir, localPrefix, workspacePatterns(pkg))) {
|
|
1671
|
+
localPrefix = dir;
|
|
1672
|
+
break;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return {
|
|
1676
|
+
root: localPrefix ?? resolve(cwd),
|
|
1677
|
+
global,
|
|
1678
|
+
cliPrefix: null
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
//#endregion
|
|
1682
|
+
//#region src/index.ts
|
|
1683
|
+
/** Resolves the npm to run for `argv` in `cwd` (spec section 5); writes only under the cache, spawns npm only as the transport. */
|
|
1684
|
+
function resolveNpm(argv, cwd, options = {}) {
|
|
1685
|
+
return resolveNpmWith(argv, cwd, options, createInstaller(createContext(options.env ?? process.env, options.home)));
|
|
1686
|
+
}
|
|
1687
|
+
async function resolveNpmWith(argv, cwd, options, providers) {
|
|
1688
|
+
const env = options.env ?? process.env;
|
|
1689
|
+
const own = providers.ownNpm();
|
|
1690
|
+
const ownResult = (projectRoot, source, warning) => {
|
|
1691
|
+
if (own === null) throw new NpmVmError("M-TRANSPORT", EXIT.transport, "No npm found beside the active Node; reinstall Node.");
|
|
1692
|
+
return {
|
|
1693
|
+
kind: "own",
|
|
1694
|
+
version: own.version,
|
|
1695
|
+
cliPath: own.cliPath,
|
|
1696
|
+
source,
|
|
1697
|
+
projectRoot,
|
|
1698
|
+
selection: null,
|
|
1699
|
+
specs: null,
|
|
1700
|
+
warning
|
|
1701
|
+
};
|
|
1702
|
+
};
|
|
1703
|
+
if (nonEmpty(env["NPM_VM_DISABLE"])) return ownResult(null, null, null);
|
|
1704
|
+
const project = findProject(argv, cwd, env);
|
|
1705
|
+
if (project.global) return ownResult(project.root, null, null);
|
|
1706
|
+
let parsed;
|
|
1707
|
+
let source;
|
|
1708
|
+
if (nonEmpty(env["NPM_VM_VERSION"])) {
|
|
1709
|
+
parsed = {
|
|
1710
|
+
specs: [parseVersionSpec(env["NPM_VM_VERSION"], "NPM_VM_VERSION")],
|
|
1711
|
+
onFail: "error",
|
|
1712
|
+
field: "devEngines"
|
|
1713
|
+
};
|
|
1714
|
+
source = {
|
|
1715
|
+
file: "NPM_VM_VERSION",
|
|
1716
|
+
field: "env"
|
|
1717
|
+
};
|
|
1718
|
+
} else {
|
|
1719
|
+
const file = join(project.root, "package.json");
|
|
1720
|
+
let read;
|
|
1721
|
+
try {
|
|
1722
|
+
read = readSpecs(file);
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
if (!(error instanceof SpecError)) throw error;
|
|
1725
|
+
const message = `${file}: ${error.message}`;
|
|
1726
|
+
const failure = new NpmVmError("M-NOMATCH", EXIT.resolution, message);
|
|
1727
|
+
return applyPolicy(error.onFail, failure, () => ownResult(project.root, null, message));
|
|
1728
|
+
}
|
|
1729
|
+
if (read === null) return ownResult(project.root, null, null);
|
|
1730
|
+
parsed = read;
|
|
1731
|
+
source = {
|
|
1732
|
+
file,
|
|
1733
|
+
field: read.field
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
if (own !== null && ownNpmSatisfies(own.version, parsed.specs)) return ownResult(project.root, source, null);
|
|
1737
|
+
const cached = await providers.cached(parsed.specs);
|
|
1738
|
+
let result = selectCandidate(parsed.specs, cached, providers.nodeVersion);
|
|
1739
|
+
if (result.kind === "failed" && !options.offline && !nonEmpty(env["NPM_VM_OFFLINE"])) {
|
|
1740
|
+
let fromRegistry;
|
|
1741
|
+
try {
|
|
1742
|
+
fromRegistry = await providers.registry(parsed.specs);
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
if (!(error instanceof NpmVmError) || error.id !== "M-TRANSPORT") throw error;
|
|
1745
|
+
return applyPolicy(parsed.onFail, error, () => ownResult(project.root, source, error.message));
|
|
1746
|
+
}
|
|
1747
|
+
result = selectCandidate(parsed.specs, fromRegistry, providers.nodeVersion);
|
|
1748
|
+
}
|
|
1749
|
+
if (result.kind === "selected") {
|
|
1750
|
+
const { candidate } = result.selection;
|
|
1751
|
+
const entry = candidate.entryPath ?? providers.entryPathFor(candidate);
|
|
1752
|
+
return {
|
|
1753
|
+
kind: candidate.entryPath === null ? "download" : "cached",
|
|
1754
|
+
version: candidate.version,
|
|
1755
|
+
cliPath: join(entry, "bin", "npm-cli.js"),
|
|
1756
|
+
source,
|
|
1757
|
+
projectRoot: project.root,
|
|
1758
|
+
selection: result.selection,
|
|
1759
|
+
specs: parsed.specs,
|
|
1760
|
+
warning: null
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
const message = result.failure.kind === "engine" ? engineMessage(result.failure.version, result.failure.enginesNode, providers.nodeVersion) : noMatchMessage(parsed.specs, source.file, result.failure.reason);
|
|
1764
|
+
const failure = new NpmVmError(result.failure.kind === "engine" ? "M-ENGINE" : "M-NOMATCH", EXIT.resolution, message);
|
|
1765
|
+
return applyPolicy(parsed.onFail, failure, () => ownResult(project.root, source, message));
|
|
1766
|
+
}
|
|
1767
|
+
/** `onFail` makes a failed resolution fatal, a warning before Node's own npm, or silent (spec RES-7). */
|
|
1768
|
+
function applyPolicy(onFail, failure, fallback) {
|
|
1769
|
+
if (onFail === "ignore") return {
|
|
1770
|
+
...fallback(),
|
|
1771
|
+
warning: null
|
|
1772
|
+
};
|
|
1773
|
+
if (onFail === "warn") return fallback();
|
|
1774
|
+
throw failure;
|
|
1775
|
+
}
|
|
1776
|
+
/** Reads and validates the project's specs; a broken file or range follows its own `onFail` when readable, else errors. */
|
|
1777
|
+
function readSpecs(file) {
|
|
1778
|
+
let text;
|
|
1779
|
+
try {
|
|
1780
|
+
text = readFileSync(file, "utf8");
|
|
1781
|
+
} catch {
|
|
1782
|
+
return null;
|
|
1783
|
+
}
|
|
1784
|
+
let pkg;
|
|
1785
|
+
try {
|
|
1786
|
+
pkg = JSON.parse(text);
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
throw new NpmVmError("M-NOMATCH", EXIT.resolution, `${file} is not valid JSON: ${error.message}`);
|
|
1789
|
+
}
|
|
1790
|
+
return parseSpecs(pkg);
|
|
1791
|
+
}
|
|
1792
|
+
function nonEmpty(value) {
|
|
1793
|
+
return value !== void 0 && value !== "";
|
|
1794
|
+
}
|
|
1795
|
+
//#endregion
|
|
1796
|
+
export { newToken as A, lockedMessage as B, verifySignatures as C, parseSpecs as D, SpecError as E, shellBinary as F, EXIT as G, redactUrls as H, findOwnNpm as I, NpmVmError as K, abiMessage as L, ownerRecord as M, isLive as N, parseVersionSpec as O, powershellPath as P, changedMessage as R, safeExtract as S, sriHex as T, restartMessage as U, provenanceMessage as V, transportMessage as W, withSharingRetry as _, createInstaller as a, fileDigests as b, newWorkDir as c, resolveDirs as d, lastUsed as f, removeWithRetry as g, atomicWrite as h, createContext as i, ownerLive as j, listLeases as k, transportUsable as l, atomicRename as m, resolveNpmWith as n, loadKeyring as o, pruneCache as p, isNpmVmError as q, findProject as r, Transport as s, resolveNpm as t, loadNpmConfig as u, listEntries as v, selectCandidate as w, readVerified as x, stagingDir as y, dirPermsMessage as z };
|
|
1797
|
+
|
|
1798
|
+
//# sourceMappingURL=src-NhNG2mnQ.js.map
|