@mawesome/npm-vm 0.0.0-stage → 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 +51 -2
- 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 +85 -3
package/dist/internal.js
ADDED
|
@@ -0,0 +1,2706 @@
|
|
|
1
|
+
import { A as newToken, B as lockedMessage, C as verifySignatures, D as parseSpecs, E as SpecError, F as shellBinary, G as EXIT, H as redactUrls, I as findOwnNpm, K as NpmVmError, L as abiMessage, M as ownerRecord, N as isLive, O as parseVersionSpec, P as powershellPath, R as changedMessage, S as safeExtract, T as sriHex, U as restartMessage, V as provenanceMessage, W as transportMessage, _ as withSharingRetry, a as createInstaller, b as fileDigests, c as newWorkDir, d as resolveDirs, f as lastUsed, g as removeWithRetry, h as atomicWrite, i as createContext, j as ownerLive, k as listLeases, l as transportUsable, m as atomicRename, n as resolveNpmWith, o as loadKeyring, p as pruneCache, q as isNpmVmError, r as findProject, s as Transport, t as resolveNpm, u as loadNpmConfig, v as listEntries, w as selectCandidate, x as readVerified, y as stagingDir, z as dirPermsMessage } from "./src-NhNG2mnQ.js";
|
|
2
|
+
import { accessSync, chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, posix, win32 } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import semver from "semver";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { createInterface } from "node:readline/promises";
|
|
10
|
+
import { Agent, ProxyAgent, request } from "undici";
|
|
11
|
+
//#region \0rolldown/runtime.js
|
|
12
|
+
var __defProp = Object.defineProperty;
|
|
13
|
+
var __exportAll = (all, no_symbols) => {
|
|
14
|
+
let target = {};
|
|
15
|
+
for (var name in all) __defProp(target, name, {
|
|
16
|
+
get: all[name],
|
|
17
|
+
enumerable: true
|
|
18
|
+
});
|
|
19
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
20
|
+
return target;
|
|
21
|
+
};
|
|
22
|
+
const SHIM_DIR = "shims";
|
|
23
|
+
const LAUNCHERS_DIR = "launchers";
|
|
24
|
+
const SELECTORS = [
|
|
25
|
+
"npm",
|
|
26
|
+
"npx",
|
|
27
|
+
"npm-vm"
|
|
28
|
+
];
|
|
29
|
+
const UNSAFE = /[\n\r"'`$%!]/;
|
|
30
|
+
/** Refuses a home no quoting makes safe in all four shells and cmd (spec SET-2, M-HOMEPATH). */
|
|
31
|
+
function assertSafeHome(home) {
|
|
32
|
+
const match = UNSAFE.exec(home);
|
|
33
|
+
if (match !== null) {
|
|
34
|
+
const shown = match[0] === "\n" || match[0] === "\r" ? "a line break" : `"${match[0]}"`;
|
|
35
|
+
throw new NpmVmError("M-HOMEPATH", EXIT.usage, `The npm-vm home ${home} contains ${shown}, which cannot be quoted safely in every shell. Set NPM_VM_HOME to a plain path and run setup again.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function selectorOf(name) {
|
|
39
|
+
return name === "npm-vm" ? "cli" : name;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The launcher templates shipped beside this code: `src/launchers/` from source, `dist/runtime/launchers/`
|
|
43
|
+
* from the bin, and `launchers/` beside an installed `runtime.cjs`.
|
|
44
|
+
*/
|
|
45
|
+
function templatesDir() {
|
|
46
|
+
const here = import.meta.dirname;
|
|
47
|
+
for (const candidate of [
|
|
48
|
+
join(here, LAUNCHERS_DIR),
|
|
49
|
+
join(here, "..", LAUNCHERS_DIR),
|
|
50
|
+
join(here, "runtime", LAUNCHERS_DIR)
|
|
51
|
+
]) if (existsSync(join(candidate, "sh.tmpl"))) return candidate;
|
|
52
|
+
throw new Error(`npm-vm launcher templates not found near ${here}`);
|
|
53
|
+
}
|
|
54
|
+
function readTemplate(dir, kind) {
|
|
55
|
+
const text = readFileSync(join(dir, `${kind}.tmpl`), "utf8");
|
|
56
|
+
return kind === "sh" ? text.replace(/\r\n/g, "\n") : text;
|
|
57
|
+
}
|
|
58
|
+
/** Fills one launcher template; the ABI comes from the launcher generation the templates belong to. */
|
|
59
|
+
function renderTemplate(template, values) {
|
|
60
|
+
return template.replace(/\{\{ABI\}\}/g, String(values.abi)).replace(/\{\{BOOTSTRAP\}\}/g, values.bootstrap).replace(/\{\{SELECTOR\}\}/g, values.selector);
|
|
61
|
+
}
|
|
62
|
+
/** The POSIX shim: `sh` on every OS, so Git Bash and MSYS on Windows get one too. */
|
|
63
|
+
function posixShim(home, name, templates = templatesDir(), abi = 1, platform = process.platform) {
|
|
64
|
+
const { join: joinPath } = platform === "win32" ? win32 : posix;
|
|
65
|
+
return renderTemplate(readTemplate(templates, "sh"), {
|
|
66
|
+
bootstrap: joinPath(home, "bootstrap.cjs"),
|
|
67
|
+
selector: selectorOf(name),
|
|
68
|
+
abi
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The cmd shim resolves node.exe through PATH only (`%~$PATH:i`): cmd would otherwise run a `node.exe`
|
|
73
|
+
* committed to the current directory. One command line, so `%*` keeps its quoting.
|
|
74
|
+
*/
|
|
75
|
+
function cmdShim(home, name, templates = templatesDir(), abi = 1) {
|
|
76
|
+
return renderTemplate(readTemplate(templates, "cmd"), {
|
|
77
|
+
bootstrap: win32.join(home, "bootstrap.cjs"),
|
|
78
|
+
selector: selectorOf(name),
|
|
79
|
+
abi
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** PowerShell never searches the current directory for a command, so `Get-Command` is enough. */
|
|
83
|
+
function powershellShim(home, name, templates = templatesDir(), abi = 1) {
|
|
84
|
+
return renderTemplate(readTemplate(templates, "ps1"), {
|
|
85
|
+
bootstrap: win32.join(home, "bootstrap.cjs"),
|
|
86
|
+
selector: selectorOf(name),
|
|
87
|
+
abi
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Every shim file for the platform (spec SET-2); `.ps1` files are included only when the policy allows them (WIN-2). */
|
|
91
|
+
function renderShims(home, platform, options = {}) {
|
|
92
|
+
assertSafeHome(home);
|
|
93
|
+
const templates = options.templates ?? templatesDir();
|
|
94
|
+
const abi = options.abi ?? 1;
|
|
95
|
+
const files = [];
|
|
96
|
+
for (const name of SELECTORS) {
|
|
97
|
+
files.push({
|
|
98
|
+
name,
|
|
99
|
+
content: posixShim(home, name, templates, abi, platform),
|
|
100
|
+
mode: 493
|
|
101
|
+
});
|
|
102
|
+
if (platform === "win32") {
|
|
103
|
+
files.push({
|
|
104
|
+
name: `${name}.cmd`,
|
|
105
|
+
content: cmdShim(home, name, templates, abi),
|
|
106
|
+
mode: 420
|
|
107
|
+
});
|
|
108
|
+
if (options.powershell !== false) files.push({
|
|
109
|
+
name: `${name}.ps1`,
|
|
110
|
+
content: powershellShim(home, name, templates, abi),
|
|
111
|
+
mode: 420
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return files;
|
|
116
|
+
}
|
|
117
|
+
/** The ABI a shim or bootstrap file declares, or null when the file is not ours. */
|
|
118
|
+
function shimAbi(content) {
|
|
119
|
+
const match = /npm-vm launcher abi=(\d+)|LAUNCHER_ABI = (\d+)/.exec(content);
|
|
120
|
+
return match === null ? null : Number(match[1] ?? match[2]);
|
|
121
|
+
}
|
|
122
|
+
function shimDir(home) {
|
|
123
|
+
return join(home, SHIM_DIR);
|
|
124
|
+
}
|
|
125
|
+
/** Our `.ps1` shims left from a permissive policy; PowerShell would still prefer them over `.cmd` (WIN-2). */
|
|
126
|
+
function stalePowershellShims(home) {
|
|
127
|
+
return SELECTORS.map((name) => join(shimDir(home), `${name}.ps1`)).filter((file) => {
|
|
128
|
+
try {
|
|
129
|
+
return shimAbi(readFileSync(file, "utf8")) !== null;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/setup/shell.ts
|
|
137
|
+
const SHELLS = [
|
|
138
|
+
"zsh",
|
|
139
|
+
"bash",
|
|
140
|
+
"fish",
|
|
141
|
+
"powershell"
|
|
142
|
+
];
|
|
143
|
+
function isShell(value) {
|
|
144
|
+
return SHELLS.includes(value);
|
|
145
|
+
}
|
|
146
|
+
const BLOCK_START = "# >>> npm-vm >>>";
|
|
147
|
+
const BLOCK_END = "# <<< npm-vm <<<";
|
|
148
|
+
/** Escapes a path for use inside a bash or zsh `${var//pattern/}` pattern, where `/` and glob characters are special. */
|
|
149
|
+
function patternOf(path) {
|
|
150
|
+
return path.replace(/[\\/*?[\]]/g, (char) => `\\${char}`);
|
|
151
|
+
}
|
|
152
|
+
/** Adds `shims/` to the front of PATH when absent; sourcing it twice changes nothing (spec SH-1). */
|
|
153
|
+
function shimsFor(shell, home) {
|
|
154
|
+
if (shell === "powershell") return win32.join(home, SHIM_DIR);
|
|
155
|
+
return process.platform === "win32" ? shimDir(home).replace(/\\/g, "/") : shimDir(home);
|
|
156
|
+
}
|
|
157
|
+
function pathSnippet(shell, home) {
|
|
158
|
+
assertSafeHome(home);
|
|
159
|
+
const shims = shimsFor(shell, home);
|
|
160
|
+
switch (shell) {
|
|
161
|
+
case "zsh":
|
|
162
|
+
case "bash": return [
|
|
163
|
+
`case ":$PATH:" in`,
|
|
164
|
+
` *":${shims}:"*) ;;`,
|
|
165
|
+
` *) export PATH="${shims}:$PATH" ;;`,
|
|
166
|
+
"esac",
|
|
167
|
+
""
|
|
168
|
+
].join("\n");
|
|
169
|
+
case "fish": return [
|
|
170
|
+
`if not contains -- "${shims}" $PATH`,
|
|
171
|
+
` set -gx PATH "${shims}" $PATH`,
|
|
172
|
+
"end",
|
|
173
|
+
""
|
|
174
|
+
].join("\n");
|
|
175
|
+
case "powershell": return [
|
|
176
|
+
`if (($env:Path -split [IO.Path]::PathSeparator) -notcontains '${shims}') {`,
|
|
177
|
+
` $env:Path = '${shims}' + [IO.Path]::PathSeparator + $env:Path`,
|
|
178
|
+
"}",
|
|
179
|
+
""
|
|
180
|
+
].join("\n");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The prompt hook (spec SH-2): moves `shims/` back to the front when something prepended over it,
|
|
185
|
+
* with parameter expansion only, keeping `$?` and chaining whatever hook was there.
|
|
186
|
+
*/
|
|
187
|
+
function hookSnippet(shell, home) {
|
|
188
|
+
assertSafeHome(home);
|
|
189
|
+
const shims = shimsFor(shell, home);
|
|
190
|
+
switch (shell) {
|
|
191
|
+
case "zsh": return [
|
|
192
|
+
"_npm_vm_guard() {",
|
|
193
|
+
" local ret=$?",
|
|
194
|
+
` if [[ "$path[1]" != "${shims}" ]]; then`,
|
|
195
|
+
` path=("${shims}" \${path:#${shims}})`,
|
|
196
|
+
" fi",
|
|
197
|
+
" return $ret",
|
|
198
|
+
"}",
|
|
199
|
+
"if [[ -z \"${precmd_functions[(r)_npm_vm_guard]}\" ]]; then",
|
|
200
|
+
" precmd_functions+=(_npm_vm_guard)",
|
|
201
|
+
"fi",
|
|
202
|
+
""
|
|
203
|
+
].join("\n");
|
|
204
|
+
case "bash": return [
|
|
205
|
+
"_npm_vm_guard() {",
|
|
206
|
+
" local ret=$?",
|
|
207
|
+
` case ":$PATH:" in`,
|
|
208
|
+
` ":${shims}:"*) ;;`,
|
|
209
|
+
" *)",
|
|
210
|
+
" local rest=\":$PATH:\"",
|
|
211
|
+
` rest="\${rest//:${patternOf(shims)}:/:}"`,
|
|
212
|
+
" rest=\"${rest#:}\"",
|
|
213
|
+
" rest=\"${rest%:}\"",
|
|
214
|
+
` PATH="${shims}\${rest:+:$rest}"`,
|
|
215
|
+
" ;;",
|
|
216
|
+
" esac",
|
|
217
|
+
" return $ret",
|
|
218
|
+
"}",
|
|
219
|
+
"case \";${PROMPT_COMMAND};\" in",
|
|
220
|
+
" *\";_npm_vm_guard;\"*) ;;",
|
|
221
|
+
" *) PROMPT_COMMAND=\"_npm_vm_guard${PROMPT_COMMAND:+;$PROMPT_COMMAND}\" ;;",
|
|
222
|
+
"esac",
|
|
223
|
+
""
|
|
224
|
+
].join("\n");
|
|
225
|
+
case "fish": return [
|
|
226
|
+
"if status is-interactive",
|
|
227
|
+
" function _npm_vm_guard --on-event fish_prompt",
|
|
228
|
+
` if test "$PATH[1]" != "${shims}"`,
|
|
229
|
+
` set -l found (contains -i -- "${shims}" $PATH)`,
|
|
230
|
+
" if test -n \"$found\"",
|
|
231
|
+
" set -e PATH[$found]",
|
|
232
|
+
" end",
|
|
233
|
+
` set -gx PATH "${shims}" $PATH`,
|
|
234
|
+
" end",
|
|
235
|
+
" end",
|
|
236
|
+
"end",
|
|
237
|
+
""
|
|
238
|
+
].join("\n");
|
|
239
|
+
case "powershell": return [
|
|
240
|
+
"if (-not (Test-Path Function:\\global:_npm_vm_original_prompt)) {",
|
|
241
|
+
" $prev = Get-Item Function:\\prompt -ErrorAction SilentlyContinue",
|
|
242
|
+
" if ($prev) { Set-Item Function:\\global:_npm_vm_original_prompt $prev.ScriptBlock }",
|
|
243
|
+
" else { function global:_npm_vm_original_prompt { \"PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) \" } }",
|
|
244
|
+
" function global:prompt {",
|
|
245
|
+
" $ok = $?",
|
|
246
|
+
" $exit = $global:LASTEXITCODE",
|
|
247
|
+
` $shims = '${shims}'`,
|
|
248
|
+
" $parts = @($env:Path -split [IO.Path]::PathSeparator)",
|
|
249
|
+
" if ($parts.Length -eq 0 -or $parts[0] -ne $shims) {",
|
|
250
|
+
" $env:Path = (@($shims) + @($parts | Where-Object { $_ -ne $shims })) -join [IO.Path]::PathSeparator",
|
|
251
|
+
" }",
|
|
252
|
+
" $out = & $function:global:_npm_vm_original_prompt",
|
|
253
|
+
" $global:LASTEXITCODE = $exit",
|
|
254
|
+
" $out",
|
|
255
|
+
" if (-not $ok) { Write-Error 'npm-vm: preserving $?' -ErrorAction SilentlyContinue }",
|
|
256
|
+
" }",
|
|
257
|
+
"}",
|
|
258
|
+
""
|
|
259
|
+
].join("\n");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Both snippets inside the marked block `setup` writes and `uninstall` removes (spec SH-4). */
|
|
263
|
+
function profileBlock(shell, home, parts) {
|
|
264
|
+
const body = [parts.path ? pathSnippet(shell, home) : "", parts.hook ? hookSnippet(shell, home) : ""].filter((text) => text !== "").join("");
|
|
265
|
+
return `${BLOCK_START}\n${body}${BLOCK_END}\n`;
|
|
266
|
+
}
|
|
267
|
+
//#endregion
|
|
268
|
+
//#region src/state/meta.ts
|
|
269
|
+
function sha256(content) {
|
|
270
|
+
return createHash("sha256").update(content).digest("hex");
|
|
271
|
+
}
|
|
272
|
+
function isUtf8(content) {
|
|
273
|
+
return !content.includes(0) && Buffer.from(content.toString("utf8"), "utf8").equals(content);
|
|
274
|
+
}
|
|
275
|
+
function lineEndingsOf(text) {
|
|
276
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
277
|
+
const lf = (text.match(/\n/g) ?? []).length - crlf;
|
|
278
|
+
if (crlf === 0 && lf === 0) return "none";
|
|
279
|
+
if (crlf > 0 && lf > 0) return "mixed";
|
|
280
|
+
return crlf > 0 ? "crlf" : "lf";
|
|
281
|
+
}
|
|
282
|
+
const MISSING = {
|
|
283
|
+
type: "missing",
|
|
284
|
+
mode: 0,
|
|
285
|
+
sha256: null,
|
|
286
|
+
encoding: null,
|
|
287
|
+
lineEndings: null,
|
|
288
|
+
symlinkTarget: null
|
|
289
|
+
};
|
|
290
|
+
/** Captures `path` without following symlinks; a missing path is a valid state, not an error. */
|
|
291
|
+
function captureMeta(path) {
|
|
292
|
+
let stat;
|
|
293
|
+
try {
|
|
294
|
+
stat = lstatSync(path);
|
|
295
|
+
} catch {
|
|
296
|
+
return MISSING;
|
|
297
|
+
}
|
|
298
|
+
const mode = process.platform === "win32" ? 0 : stat.mode & 4095;
|
|
299
|
+
if (stat.isSymbolicLink()) return {
|
|
300
|
+
type: "symlink",
|
|
301
|
+
mode,
|
|
302
|
+
sha256: null,
|
|
303
|
+
encoding: null,
|
|
304
|
+
lineEndings: null,
|
|
305
|
+
symlinkTarget: readlinkSync(path)
|
|
306
|
+
};
|
|
307
|
+
if (stat.isDirectory()) return {
|
|
308
|
+
type: "directory",
|
|
309
|
+
mode,
|
|
310
|
+
sha256: null,
|
|
311
|
+
encoding: null,
|
|
312
|
+
lineEndings: null,
|
|
313
|
+
symlinkTarget: null
|
|
314
|
+
};
|
|
315
|
+
if (!stat.isFile()) return {
|
|
316
|
+
type: "other",
|
|
317
|
+
mode,
|
|
318
|
+
sha256: null,
|
|
319
|
+
encoding: null,
|
|
320
|
+
lineEndings: null,
|
|
321
|
+
symlinkTarget: null
|
|
322
|
+
};
|
|
323
|
+
const content = readFileSync(path);
|
|
324
|
+
const utf8 = isUtf8(content);
|
|
325
|
+
return {
|
|
326
|
+
type: "file",
|
|
327
|
+
mode,
|
|
328
|
+
sha256: sha256(content),
|
|
329
|
+
encoding: utf8 ? "utf8" : "binary",
|
|
330
|
+
lineEndings: utf8 ? lineEndingsOf(content.toString("utf8")) : null,
|
|
331
|
+
symlinkTarget: null
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** Whether the path still holds exactly what `meta` recorded (spec MUT-5): same type, same bytes or link target. */
|
|
335
|
+
function metaMatches(path, meta) {
|
|
336
|
+
const now = captureMeta(path);
|
|
337
|
+
return now.type === meta.type && now.sha256 === meta.sha256 && now.symlinkTarget === meta.symlinkTarget;
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/state/journal.ts
|
|
341
|
+
function journalPath(journalDir, id) {
|
|
342
|
+
return join(journalDir, `${id}.json`);
|
|
343
|
+
}
|
|
344
|
+
function readJournal(file) {
|
|
345
|
+
try {
|
|
346
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
347
|
+
return typeof parsed.id === "string" && typeof parsed.target === "string" ? parsed : null;
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function listJournals(journalDir) {
|
|
353
|
+
let names;
|
|
354
|
+
try {
|
|
355
|
+
names = readdirSync(journalDir);
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
return names.filter((name) => name.endsWith(".json")).map((name) => readJournal(join(journalDir, name))).filter((journal) => journal !== null);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Applies one file change under a journal: backup and staged copy first, the journal, then the write.
|
|
363
|
+
* The journal and its sidecars disappear only after the target holds the intended bytes.
|
|
364
|
+
*/
|
|
365
|
+
function mutateFile(journalDir, kind, owner, target, content, mode = 420) {
|
|
366
|
+
mkdirSync(journalDir, {
|
|
367
|
+
recursive: true,
|
|
368
|
+
mode: 448
|
|
369
|
+
});
|
|
370
|
+
const id = `${kind}-${newToken()}`;
|
|
371
|
+
const original = captureMeta(target);
|
|
372
|
+
let backup = null;
|
|
373
|
+
if (original.type === "file") {
|
|
374
|
+
backup = join(journalDir, `${id}.bak`);
|
|
375
|
+
copyFileSync(target, backup);
|
|
376
|
+
}
|
|
377
|
+
let staged = null;
|
|
378
|
+
if (content !== null) {
|
|
379
|
+
staged = join(journalDir, `${id}.new`);
|
|
380
|
+
atomicWrite(staged, content, 384);
|
|
381
|
+
}
|
|
382
|
+
const journal = {
|
|
383
|
+
id,
|
|
384
|
+
kind,
|
|
385
|
+
owner,
|
|
386
|
+
target,
|
|
387
|
+
original,
|
|
388
|
+
intended: content === null ? null : sha256(content),
|
|
389
|
+
staged,
|
|
390
|
+
backup,
|
|
391
|
+
mode,
|
|
392
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
393
|
+
};
|
|
394
|
+
atomicWrite(journalPath(journalDir, id), JSON.stringify(journal), 384);
|
|
395
|
+
apply(journal, content);
|
|
396
|
+
discard(journalDir, journal);
|
|
397
|
+
}
|
|
398
|
+
function apply(journal, content) {
|
|
399
|
+
if (content === null) {
|
|
400
|
+
withSharingRetry(() => {
|
|
401
|
+
if (existsSync(journal.target)) unlinkSync(journal.target);
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
atomicWrite(journal.target, content, journal.mode);
|
|
406
|
+
}
|
|
407
|
+
function discard(journalDir, journal) {
|
|
408
|
+
for (const sidecar of [journal.backup, journal.staged]) if (sidecar !== null) rmSync(sidecar, { force: true });
|
|
409
|
+
rmSync(journalPath(journalDir, journal.id), { force: true });
|
|
410
|
+
}
|
|
411
|
+
function targetSha(target) {
|
|
412
|
+
const meta = captureMeta(target);
|
|
413
|
+
return meta.type === "file" ? meta.sha256 : null;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Finishes one journal (spec MUT-2): a target already at the intended bytes is done; one still at the
|
|
417
|
+
* original is replayed from the staged copy; anything else is left alone and reported as changed (MUT-5).
|
|
418
|
+
*/
|
|
419
|
+
function recoverJournal(journalDir, journal, report) {
|
|
420
|
+
const current = targetSha(journal.target);
|
|
421
|
+
const exists = captureMeta(journal.target).type !== "missing";
|
|
422
|
+
if (journal.intended === null ? !exists : current === journal.intended) {
|
|
423
|
+
discard(journalDir, journal);
|
|
424
|
+
report.finished.push(journal.id);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (metaMatches(journal.target, journal.original)) {
|
|
428
|
+
if (journal.intended === null) {
|
|
429
|
+
apply(journal, null);
|
|
430
|
+
report.replayed.push(journal.id);
|
|
431
|
+
} else if (journal.staged !== null && existsSync(journal.staged) && sha256(readFileSync(journal.staged)) === journal.intended) {
|
|
432
|
+
apply(journal, readFileSync(journal.staged));
|
|
433
|
+
report.replayed.push(journal.id);
|
|
434
|
+
} else report.rolledBack.push(journal.id);
|
|
435
|
+
discard(journalDir, journal);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
report.changed.push(journal.target);
|
|
439
|
+
discard(journalDir, journal);
|
|
440
|
+
}
|
|
441
|
+
/** Recovers every journal whose owner is dead on this machine; foreign and live ones are skipped. */
|
|
442
|
+
function recoverDeadJournals(journalDir, machineId, kinds) {
|
|
443
|
+
const report = {
|
|
444
|
+
finished: [],
|
|
445
|
+
replayed: [],
|
|
446
|
+
rolledBack: [],
|
|
447
|
+
changed: [],
|
|
448
|
+
skipped: []
|
|
449
|
+
};
|
|
450
|
+
for (const journal of listJournals(journalDir)) {
|
|
451
|
+
if (kinds !== void 0 && !kinds.includes(journal.kind)) continue;
|
|
452
|
+
if (journal.owner.machineId !== machineId || isLive(journal.owner)) {
|
|
453
|
+
report.skipped.push(journal.id);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
recoverJournal(journalDir, journal, report);
|
|
457
|
+
}
|
|
458
|
+
return report;
|
|
459
|
+
}
|
|
460
|
+
/** Recovers one journal the operator vouched for, whatever machine owns it (the `recover` command). */
|
|
461
|
+
function recoverForeignJournal(journalDir, id, confirmedMachineId) {
|
|
462
|
+
const report = {
|
|
463
|
+
finished: [],
|
|
464
|
+
replayed: [],
|
|
465
|
+
rolledBack: [],
|
|
466
|
+
changed: [],
|
|
467
|
+
skipped: []
|
|
468
|
+
};
|
|
469
|
+
const journal = readJournal(journalPath(journalDir, id));
|
|
470
|
+
if (journal === null) throw new Error(`no journal ${id} in ${journalDir}`);
|
|
471
|
+
if (journal.owner.machineId !== confirmedMachineId) throw new Error(`journal ${id} belongs to machine ${journal.owner.machineId}, not ${confirmedMachineId}`);
|
|
472
|
+
recoverJournal(journalDir, journal, report);
|
|
473
|
+
return report;
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/state/lock.ts
|
|
477
|
+
const LOCK_FILE = "lock";
|
|
478
|
+
const WAIT_LIMIT_MS = 1e4;
|
|
479
|
+
function lockPath(stateDir) {
|
|
480
|
+
return join(stateDir, LOCK_FILE);
|
|
481
|
+
}
|
|
482
|
+
function readLock(stateDir) {
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(readFileSync(lockPath(stateDir), "utf8"));
|
|
485
|
+
if (typeof parsed.pid !== "number" || typeof parsed.token !== "string") return null;
|
|
486
|
+
return {
|
|
487
|
+
pid: parsed.pid,
|
|
488
|
+
start: typeof parsed.start === "string" ? parsed.start : null,
|
|
489
|
+
host: typeof parsed.host === "string" ? parsed.host : "?",
|
|
490
|
+
machineId: typeof parsed.machineId === "string" ? parsed.machineId : "?",
|
|
491
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
|
|
492
|
+
token: parsed.token
|
|
493
|
+
};
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** Whether the lock owner can be shown to be dead: only on this machine, and only by PROC-1. */
|
|
499
|
+
function lockOwnerLive(record, machineId) {
|
|
500
|
+
if (record === null) return true;
|
|
501
|
+
if (record.machineId !== machineId) return true;
|
|
502
|
+
return isLive(record);
|
|
503
|
+
}
|
|
504
|
+
function tryCreate(path, record) {
|
|
505
|
+
try {
|
|
506
|
+
writeFileSync(path, JSON.stringify(record), {
|
|
507
|
+
flag: "wx",
|
|
508
|
+
mode: 384
|
|
509
|
+
});
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (error.code === "EEXIST") return false;
|
|
512
|
+
rmSync(path, { force: true });
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
function sleep(ms) {
|
|
518
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Takes the exclusive lock (spec MUT-1): created with O_EXCL, released only by its owner, never reclaimed.
|
|
522
|
+
* Waits with backoff for up to 10 s, then fails with M-LOCKED naming the file and whether the owner is live.
|
|
523
|
+
*/
|
|
524
|
+
async function acquireLock(stateDir, owner, options = {}) {
|
|
525
|
+
mkdirSync(stateDir, {
|
|
526
|
+
recursive: true,
|
|
527
|
+
mode: 448
|
|
528
|
+
});
|
|
529
|
+
const path = lockPath(stateDir);
|
|
530
|
+
const record = {
|
|
531
|
+
...owner,
|
|
532
|
+
token: newToken()
|
|
533
|
+
};
|
|
534
|
+
const deadline = Date.now() + (options.waitMs ?? WAIT_LIMIT_MS);
|
|
535
|
+
let delay = 50;
|
|
536
|
+
for (;;) {
|
|
537
|
+
if (tryCreate(path, record)) return {
|
|
538
|
+
path,
|
|
539
|
+
release() {
|
|
540
|
+
if (readLock(stateDir)?.token === record.token) rmSync(path, { force: true });
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
if (Date.now() + delay > deadline) {
|
|
544
|
+
const holder = readLock(stateDir);
|
|
545
|
+
const foreign = holder !== null && holder.machineId !== owner.machineId;
|
|
546
|
+
const live = lockOwnerLive(holder, owner.machineId);
|
|
547
|
+
throw new NpmVmError("M-LOCKED", EXIT.problems, lockedMessage(path, holder, live, foreign));
|
|
548
|
+
}
|
|
549
|
+
await sleep(delay);
|
|
550
|
+
delay = Math.min(delay * 2, 1e3);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/commands/cache.ts
|
|
555
|
+
const DAY_MS = 864e5;
|
|
556
|
+
/** `install [<spec>]`: fetches and verifies the project's npm, or the given spec, into the cache. */
|
|
557
|
+
async function install(context, spec, cwd = process.cwd()) {
|
|
558
|
+
const env = spec === void 0 ? context.env : {
|
|
559
|
+
...context.env,
|
|
560
|
+
NPM_VM_VERSION: spec
|
|
561
|
+
};
|
|
562
|
+
const installer = createInstaller(createContext(env, context.dirs.home));
|
|
563
|
+
const result = await resolveNpmWith([], cwd, { env }, installer);
|
|
564
|
+
if (result.selection === null) {
|
|
565
|
+
context.io.out(context.json ? JSON.stringify(result) : `Node's own npm ${result.version} satisfies the requirement; nothing to download.`);
|
|
566
|
+
return EXIT.ok;
|
|
567
|
+
}
|
|
568
|
+
const ensured = await installer.ensure(result.selection);
|
|
569
|
+
ensured.lease.release();
|
|
570
|
+
context.io.out(context.json ? JSON.stringify({
|
|
571
|
+
...result,
|
|
572
|
+
entryPath: ensured.entryPath
|
|
573
|
+
}) : `npm ${result.version} is ready in ${ensured.entryPath}`);
|
|
574
|
+
return EXIT.ok;
|
|
575
|
+
}
|
|
576
|
+
function cacheList(context) {
|
|
577
|
+
const entries = listEntries(context.dirs.cache).map((entry) => {
|
|
578
|
+
const used = lastUsed(entry.entryPath);
|
|
579
|
+
return {
|
|
580
|
+
version: entry.version,
|
|
581
|
+
integrity: entry.integrity,
|
|
582
|
+
path: entry.entryPath,
|
|
583
|
+
lastUsed: used === null ? null : new Date(used).toISOString(),
|
|
584
|
+
liveLeases: listLeases(entry.entryPath).filter((lease) => ownerLive(lease)).length,
|
|
585
|
+
verifiedAt: readVerified(entry.entryPath)?.verifiedAt ?? ""
|
|
586
|
+
};
|
|
587
|
+
});
|
|
588
|
+
if (context.json) context.io.out(JSON.stringify({
|
|
589
|
+
cache: context.dirs.cache,
|
|
590
|
+
entries
|
|
591
|
+
}, null, 2));
|
|
592
|
+
else {
|
|
593
|
+
const lines = [`${context.dirs.cache}`];
|
|
594
|
+
for (const entry of entries) lines.push(` npm ${entry.version} ${entry.path} last used ${entry.lastUsed ?? "never"}${entry.liveLeases > 0 ? ` in use by ${entry.liveLeases}` : ""}`);
|
|
595
|
+
if (entries.length === 0) lines.push(" (empty)");
|
|
596
|
+
context.io.out(lines.join("\n"));
|
|
597
|
+
}
|
|
598
|
+
return EXIT.ok;
|
|
599
|
+
}
|
|
600
|
+
async function cachePrune(context, olderThanDays = 90) {
|
|
601
|
+
if (!Number.isFinite(olderThanDays) || olderThanDays < 0) throw new NpmVmError("M-NOMATCH", EXIT.usage, "--older-than needs a non-negative number of days.");
|
|
602
|
+
const lock = await acquireLock(context.dirs.state, context.owner);
|
|
603
|
+
try {
|
|
604
|
+
recoverDeadJournals(context.dirs.journal, context.dirs.machine.id);
|
|
605
|
+
const cacheLock = await acquireLock(context.dirs.cache, context.owner);
|
|
606
|
+
let report;
|
|
607
|
+
try {
|
|
608
|
+
report = pruneCache(context.dirs.cache, context.owner, { maxAgeMs: olderThanDays * DAY_MS });
|
|
609
|
+
} finally {
|
|
610
|
+
cacheLock.release();
|
|
611
|
+
}
|
|
612
|
+
context.io.out(context.json ? JSON.stringify(report, null, 2) : `Removed ${report.removed.length}, kept ${report.kept.length}, skipped ${report.skipped.length} (in use).`);
|
|
613
|
+
} finally {
|
|
614
|
+
lock.release();
|
|
615
|
+
}
|
|
616
|
+
return EXIT.ok;
|
|
617
|
+
}
|
|
618
|
+
/** `recover --journal <id> --confirm-dead-machine <machine-id>` (spec PROC-1, CLI). */
|
|
619
|
+
async function recover(context, journalId, confirmedMachine) {
|
|
620
|
+
const journal = listJournals(context.dirs.journal).find((entry) => entry.id === journalId);
|
|
621
|
+
if (journal === void 0) throw new NpmVmError("M-NOMATCH", EXIT.usage, `No journal ${journalId} under ${context.dirs.journal}.`);
|
|
622
|
+
if (journal.owner.machineId !== confirmedMachine) throw new NpmVmError("M-NOMATCH", EXIT.usage, `Journal ${journalId} belongs to machine ${journal.owner.machineId}, not ${confirmedMachine}.`);
|
|
623
|
+
if (journal.owner.machineId === context.dirs.machine.id && isLive(journal.owner)) throw new NpmVmError("M-LOCKED", EXIT.problems, `Journal ${journalId} belongs to PID ${journal.owner.pid} on this machine, which is still running.`);
|
|
624
|
+
const lock = await acquireLock(context.dirs.state, context.owner);
|
|
625
|
+
try {
|
|
626
|
+
const report = recoverForeignJournal(context.dirs.journal, journalId, confirmedMachine);
|
|
627
|
+
context.io.out(context.json ? JSON.stringify(report) : `Recovered ${journalId}: ${describeReport(report)}`);
|
|
628
|
+
} finally {
|
|
629
|
+
lock.release();
|
|
630
|
+
}
|
|
631
|
+
return EXIT.ok;
|
|
632
|
+
}
|
|
633
|
+
function describeReport(report) {
|
|
634
|
+
if (report.replayed.length > 0) return "replayed";
|
|
635
|
+
if (report.rolledBack.length > 0) return "rolled back";
|
|
636
|
+
if (report.changed.length > 0) return "target changed since, left untouched";
|
|
637
|
+
return "already finished";
|
|
638
|
+
}
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region src/commands/context.ts
|
|
641
|
+
function terminalIo() {
|
|
642
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
643
|
+
return {
|
|
644
|
+
out: (text) => process.stdout.write(`${text}\n`),
|
|
645
|
+
err: (text) => process.stderr.write(`${text}\n`),
|
|
646
|
+
interactive,
|
|
647
|
+
async confirm(question) {
|
|
648
|
+
if (!interactive) return false;
|
|
649
|
+
const rl = createInterface({
|
|
650
|
+
input: process.stdin,
|
|
651
|
+
output: process.stdout
|
|
652
|
+
});
|
|
653
|
+
try {
|
|
654
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
655
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
656
|
+
} finally {
|
|
657
|
+
rl.close();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
function createCommandContext(options) {
|
|
663
|
+
const env = options.env ?? process.env;
|
|
664
|
+
const dirs = resolveDirs(env, options.home);
|
|
665
|
+
return {
|
|
666
|
+
dirs,
|
|
667
|
+
env,
|
|
668
|
+
platform: process.platform,
|
|
669
|
+
owner: ownerRecord(dirs.machine.id),
|
|
670
|
+
io: options.io ?? terminalIo(),
|
|
671
|
+
json: options.json ?? false,
|
|
672
|
+
version: options.version
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/setup/link.ts
|
|
677
|
+
const ORIGINAL_SUFFIX = ".npm-vm-original";
|
|
678
|
+
const LINKS_FILE = "links.json";
|
|
679
|
+
function versionsUnder(dir) {
|
|
680
|
+
try {
|
|
681
|
+
return readdirSync(dir).filter((name) => /^v?\d+\.\d+\.\d+$/.test(name));
|
|
682
|
+
} catch {
|
|
683
|
+
return [];
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function fnmDir(env, platform, home) {
|
|
687
|
+
if (env["FNM_DIR"]) return env["FNM_DIR"];
|
|
688
|
+
if (platform === "win32") return join(env["APPDATA"] ?? join(home, "AppData", "Roaming"), "fnm");
|
|
689
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", "fnm");
|
|
690
|
+
return join(env["XDG_DATA_HOME"] || join(home, ".local", "share"), "fnm");
|
|
691
|
+
}
|
|
692
|
+
/** The root nvm-windows keeps its versions in, from its settings.txt (spec LNK-1). */
|
|
693
|
+
function nvmWindowsRoot(env) {
|
|
694
|
+
const nvmHome = env["NVM_HOME"];
|
|
695
|
+
if (!nvmHome) return null;
|
|
696
|
+
try {
|
|
697
|
+
const settings = readFileSync(join(nvmHome, "settings.txt"), "utf8");
|
|
698
|
+
return /^root:\s*(.+)$/m.exec(settings)?.[1]?.trim() ?? nvmHome;
|
|
699
|
+
} catch {
|
|
700
|
+
return nvmHome;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/** Windows paths resolved through junctions and compared without case, so one install is never linked twice (LNK-4). */
|
|
704
|
+
function installKey(binDir, platform) {
|
|
705
|
+
let real = binDir;
|
|
706
|
+
try {
|
|
707
|
+
real = realpathSync(binDir);
|
|
708
|
+
} catch {}
|
|
709
|
+
return platform === "win32" ? real.toLowerCase() : real;
|
|
710
|
+
}
|
|
711
|
+
function findNodeInstalls(env = process.env, platform = process.platform, home = env["HOME"] || homedir()) {
|
|
712
|
+
const installs = [];
|
|
713
|
+
const seen = /* @__PURE__ */ new Set();
|
|
714
|
+
const add = (install) => {
|
|
715
|
+
const key = installKey(install.binDir, platform);
|
|
716
|
+
if (!seen.has(key) && existsSync(install.binDir)) {
|
|
717
|
+
seen.add(key);
|
|
718
|
+
installs.push(install);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
const nvmDir = env["NVM_DIR"] || join(home, ".nvm");
|
|
722
|
+
for (const version of versionsUnder(join(nvmDir, "versions", "node"))) add({
|
|
723
|
+
manager: "nvm",
|
|
724
|
+
version: version.replace(/^v/, ""),
|
|
725
|
+
binDir: join(nvmDir, "versions", "node", version, "bin")
|
|
726
|
+
});
|
|
727
|
+
const fnm = join(fnmDir(env, platform, home), "node-versions");
|
|
728
|
+
for (const version of versionsUnder(fnm)) {
|
|
729
|
+
const installation = join(fnm, version, "installation");
|
|
730
|
+
add({
|
|
731
|
+
manager: "fnm",
|
|
732
|
+
version: version.replace(/^v/, ""),
|
|
733
|
+
binDir: platform === "win32" ? installation : join(installation, "bin")
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
if (platform === "win32") {
|
|
737
|
+
const root = nvmWindowsRoot(env);
|
|
738
|
+
if (root !== null) for (const version of versionsUnder(root)) add({
|
|
739
|
+
manager: "nvm-windows",
|
|
740
|
+
version: version.replace(/^v/, ""),
|
|
741
|
+
binDir: join(root, version)
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
return installs;
|
|
745
|
+
}
|
|
746
|
+
/** The files `--link` replaces in one install; blocked `.ps1` files are moved aside so `.cmd` wins. */
|
|
747
|
+
function linkTargets(home, platform, powershell) {
|
|
748
|
+
const targets = [];
|
|
749
|
+
for (const name of ["npm", "npx"]) {
|
|
750
|
+
targets.push({
|
|
751
|
+
name,
|
|
752
|
+
content: posixShim(home, name),
|
|
753
|
+
mode: 493
|
|
754
|
+
});
|
|
755
|
+
if (platform === "win32") {
|
|
756
|
+
targets.push({
|
|
757
|
+
name: `${name}.cmd`,
|
|
758
|
+
content: cmdShim(home, name),
|
|
759
|
+
mode: 420
|
|
760
|
+
});
|
|
761
|
+
targets.push({
|
|
762
|
+
name: `${name}.ps1`,
|
|
763
|
+
content: powershell ? powershellShim(home, name) : null,
|
|
764
|
+
mode: 420
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return targets;
|
|
769
|
+
}
|
|
770
|
+
/** Keeps a copy of the original beside the target; a symlink is copied as a symlink. */
|
|
771
|
+
function preserveOriginal(path, originalPath, meta) {
|
|
772
|
+
if (meta.type === "symlink") symlinkSync(readlinkSync(path), originalPath);
|
|
773
|
+
else copyFileSync(path, originalPath);
|
|
774
|
+
}
|
|
775
|
+
/** Puts the preserved original back in place of whatever is (or is not) at the target. */
|
|
776
|
+
function restoreOriginal(dirs, owner, entry) {
|
|
777
|
+
const originalPath = entry.originalPath;
|
|
778
|
+
if (entry.original.type === "symlink") {
|
|
779
|
+
mutateFile(dirs.journal, "link", owner, entry.target, null);
|
|
780
|
+
withSharingRetry(() => renameSync(originalPath, entry.target));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
mutateFile(dirs.journal, "link", owner, entry.target, readFileSync(originalPath), entry.original.mode || 493);
|
|
784
|
+
rmSync(originalPath, { force: true });
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Replaces one entry point with a shim (spec LNK-2, LNK-3). The original is copied beside the target and
|
|
788
|
+
* recorded before the journaled write, so a crash at any step leaves either a restorable original or the shim.
|
|
789
|
+
* A file matching neither our shim nor the recorded original is reported and left alone.
|
|
790
|
+
*/
|
|
791
|
+
function linkOne(dirs, owner, binDir, target) {
|
|
792
|
+
const path = join(binDir, target.name);
|
|
793
|
+
const originalPath = `${path}${ORIGINAL_SUFFIX}`;
|
|
794
|
+
const known = readLinks(dirs).entries.find((entry) => entry.target === path);
|
|
795
|
+
let current = captureMeta(path);
|
|
796
|
+
if (current.type === "missing" && known !== void 0 && known.shim.type !== "missing" && known.originalPath !== null && existsSync(known.originalPath)) {
|
|
797
|
+
restoreOriginal(dirs, owner, known);
|
|
798
|
+
forget$1(dirs, known);
|
|
799
|
+
current = captureMeta(path);
|
|
800
|
+
}
|
|
801
|
+
if (target.content === null) return moveAside(dirs, owner, binDir, target.name, known);
|
|
802
|
+
if (current.type === "missing" && known !== void 0 && known.shim.type === "missing" && known.originalPath !== null && metaMatches(known.originalPath, known.original)) {
|
|
803
|
+
mutateFile(dirs.journal, "link", owner, path, Buffer.from(target.content, "utf8"), target.mode);
|
|
804
|
+
record(dirs, {
|
|
805
|
+
...known,
|
|
806
|
+
shim: captureMeta(path),
|
|
807
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
808
|
+
});
|
|
809
|
+
return {
|
|
810
|
+
target: path,
|
|
811
|
+
outcome: "linked"
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
if (current.type === "missing") return {
|
|
815
|
+
target: path,
|
|
816
|
+
outcome: "missing"
|
|
817
|
+
};
|
|
818
|
+
if (current.type === "file" && readFileSync(path, "utf8") === target.content) {
|
|
819
|
+
if (known === void 0) record(dirs, {
|
|
820
|
+
binDir,
|
|
821
|
+
name: target.name,
|
|
822
|
+
target: path,
|
|
823
|
+
originalPath: existsSync(originalPath) ? originalPath : null,
|
|
824
|
+
original: captureMeta(originalPath),
|
|
825
|
+
shim: current,
|
|
826
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
827
|
+
});
|
|
828
|
+
else if (known.shim.type === "missing") record(dirs, {
|
|
829
|
+
...known,
|
|
830
|
+
shim: current,
|
|
831
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
832
|
+
});
|
|
833
|
+
return {
|
|
834
|
+
target: path,
|
|
835
|
+
outcome: "already"
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
if (known !== void 0 && metaMatches(path, known.shim)) {
|
|
839
|
+
mutateFile(dirs.journal, "link", owner, path, Buffer.from(target.content, "utf8"), target.mode);
|
|
840
|
+
record(dirs, {
|
|
841
|
+
...known,
|
|
842
|
+
shim: captureMeta(path),
|
|
843
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
844
|
+
});
|
|
845
|
+
return {
|
|
846
|
+
target: path,
|
|
847
|
+
outcome: "linked"
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
if (known !== void 0 && !metaMatches(path, known.original)) return {
|
|
851
|
+
target: path,
|
|
852
|
+
outcome: "foreign",
|
|
853
|
+
detail: "changed since npm-vm linked it"
|
|
854
|
+
};
|
|
855
|
+
if (existsSync(originalPath) && !metaMatches(originalPath, current)) return {
|
|
856
|
+
target: path,
|
|
857
|
+
outcome: "foreign",
|
|
858
|
+
detail: `${originalPath} already exists`
|
|
859
|
+
};
|
|
860
|
+
try {
|
|
861
|
+
if (!existsSync(originalPath)) preserveOriginal(path, originalPath, current);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
return {
|
|
864
|
+
target: path,
|
|
865
|
+
outcome: "unwritable",
|
|
866
|
+
detail: error.message
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
const entry = {
|
|
870
|
+
binDir,
|
|
871
|
+
name: target.name,
|
|
872
|
+
target: path,
|
|
873
|
+
originalPath,
|
|
874
|
+
original: current,
|
|
875
|
+
shim: MISSING,
|
|
876
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
877
|
+
};
|
|
878
|
+
record(dirs, entry);
|
|
879
|
+
try {
|
|
880
|
+
mutateFile(dirs.journal, "link", owner, path, Buffer.from(target.content, "utf8"), target.mode);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
return {
|
|
883
|
+
target: path,
|
|
884
|
+
outcome: "unwritable",
|
|
885
|
+
detail: error.message
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
record(dirs, {
|
|
889
|
+
...entry,
|
|
890
|
+
shim: captureMeta(path)
|
|
891
|
+
});
|
|
892
|
+
return {
|
|
893
|
+
target: path,
|
|
894
|
+
outcome: "linked"
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
/** Moves a file aside without a replacement, for Node's `.ps1` entry points when scripts are blocked. */
|
|
898
|
+
function moveAside(dirs, owner, binDir, name, known) {
|
|
899
|
+
const path = join(binDir, name);
|
|
900
|
+
const originalPath = `${path}${ORIGINAL_SUFFIX}`;
|
|
901
|
+
const current = captureMeta(path);
|
|
902
|
+
if (known !== void 0 && known.shim.type !== "missing" && metaMatches(path, known.shim)) {
|
|
903
|
+
mutateFile(dirs.journal, "link", owner, path, null);
|
|
904
|
+
record(dirs, {
|
|
905
|
+
...known,
|
|
906
|
+
shim: MISSING,
|
|
907
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
908
|
+
});
|
|
909
|
+
return {
|
|
910
|
+
target: path,
|
|
911
|
+
outcome: "linked"
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
if (current.type === "missing") {
|
|
915
|
+
if (known === void 0 && existsSync(originalPath)) {
|
|
916
|
+
record(dirs, {
|
|
917
|
+
binDir,
|
|
918
|
+
name,
|
|
919
|
+
target: path,
|
|
920
|
+
originalPath,
|
|
921
|
+
original: captureMeta(originalPath),
|
|
922
|
+
shim: MISSING,
|
|
923
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
924
|
+
});
|
|
925
|
+
return {
|
|
926
|
+
target: path,
|
|
927
|
+
outcome: "already"
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
target: path,
|
|
932
|
+
outcome: known !== void 0 && existsSync(originalPath) ? "already" : "missing"
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
if (known !== void 0 && !metaMatches(path, known.original)) return {
|
|
936
|
+
target: path,
|
|
937
|
+
outcome: "foreign",
|
|
938
|
+
detail: "changed since npm-vm linked it"
|
|
939
|
+
};
|
|
940
|
+
if (existsSync(originalPath) && !metaMatches(originalPath, current)) return {
|
|
941
|
+
target: path,
|
|
942
|
+
outcome: "foreign",
|
|
943
|
+
detail: `${originalPath} already exists`
|
|
944
|
+
};
|
|
945
|
+
try {
|
|
946
|
+
withSharingRetry(() => renameSync(path, originalPath));
|
|
947
|
+
} catch (error) {
|
|
948
|
+
return {
|
|
949
|
+
target: path,
|
|
950
|
+
outcome: "unwritable",
|
|
951
|
+
detail: error.message
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
record(dirs, {
|
|
955
|
+
binDir,
|
|
956
|
+
name,
|
|
957
|
+
target: path,
|
|
958
|
+
originalPath,
|
|
959
|
+
original: current,
|
|
960
|
+
shim: MISSING,
|
|
961
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
962
|
+
});
|
|
963
|
+
return {
|
|
964
|
+
target: path,
|
|
965
|
+
outcome: "linked"
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
/** Puts the original back only when the shim is still ours (spec MUT-5); a replaced file is left as is. */
|
|
969
|
+
function unlinkOne(dirs, owner, entry) {
|
|
970
|
+
const current = captureMeta(entry.target);
|
|
971
|
+
const hasOriginal = entry.originalPath !== null && existsSync(entry.originalPath);
|
|
972
|
+
if (current.type === "missing") {
|
|
973
|
+
if (hasOriginal && metaMatches(entry.originalPath, entry.original)) {
|
|
974
|
+
restoreOriginal(dirs, owner, entry);
|
|
975
|
+
forget$1(dirs, entry);
|
|
976
|
+
return "restored";
|
|
977
|
+
}
|
|
978
|
+
if (hasOriginal) return "changed";
|
|
979
|
+
forget$1(dirs, entry);
|
|
980
|
+
return "missing";
|
|
981
|
+
}
|
|
982
|
+
if (entry.shim.type === "missing") return "changed";
|
|
983
|
+
if (!metaMatches(entry.target, entry.shim)) return "changed";
|
|
984
|
+
if (hasOriginal) restoreOriginal(dirs, owner, entry);
|
|
985
|
+
else mutateFile(dirs.journal, "link", owner, entry.target, null);
|
|
986
|
+
forget$1(dirs, entry);
|
|
987
|
+
return "restored";
|
|
988
|
+
}
|
|
989
|
+
function record(dirs, entry) {
|
|
990
|
+
const links = readLinks(dirs);
|
|
991
|
+
links.entries = [...links.entries.filter((item) => item.target !== entry.target), entry];
|
|
992
|
+
writeLinks(dirs, links);
|
|
993
|
+
}
|
|
994
|
+
function forget$1(dirs, entry) {
|
|
995
|
+
const links = readLinks(dirs);
|
|
996
|
+
links.entries = links.entries.filter((item) => item.target !== entry.target);
|
|
997
|
+
writeLinks(dirs, links);
|
|
998
|
+
}
|
|
999
|
+
function readLinks(dirs) {
|
|
1000
|
+
try {
|
|
1001
|
+
const parsed = JSON.parse(readFileSync(join(dirs.state, LINKS_FILE), "utf8"));
|
|
1002
|
+
return Array.isArray(parsed.entries) ? parsed : {
|
|
1003
|
+
version: 1,
|
|
1004
|
+
entries: []
|
|
1005
|
+
};
|
|
1006
|
+
} catch {
|
|
1007
|
+
return {
|
|
1008
|
+
version: 1,
|
|
1009
|
+
entries: []
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
function writeLinks(dirs, links) {
|
|
1014
|
+
atomicWrite(join(dirs.state, LINKS_FILE), JSON.stringify(links, null, " "), 384);
|
|
1015
|
+
}
|
|
1016
|
+
/** Whether a recorded link still holds our shim, for `doctor` (spec CLI). */
|
|
1017
|
+
function linkState(entry) {
|
|
1018
|
+
const type = captureMeta(entry.target).type;
|
|
1019
|
+
if (entry.shim.type === "missing") {
|
|
1020
|
+
const preserved = entry.originalPath !== null && metaMatches(entry.originalPath, entry.original);
|
|
1021
|
+
return type === "missing" ? preserved ? "intact" : "missing" : "changed";
|
|
1022
|
+
}
|
|
1023
|
+
if (type === "missing") return "missing";
|
|
1024
|
+
return metaMatches(entry.target, entry.shim) ? "intact" : "changed";
|
|
1025
|
+
}
|
|
1026
|
+
//#endregion
|
|
1027
|
+
//#region src/setup/layout.ts
|
|
1028
|
+
const BOOTSTRAP_FILE = "bootstrap.cjs";
|
|
1029
|
+
const CURRENT_FILE = "current";
|
|
1030
|
+
const PREVIOUS_FILE = "previous";
|
|
1031
|
+
const MANIFEST_FILE = "manifest.json";
|
|
1032
|
+
const VERSIONS_DIR = "versions";
|
|
1033
|
+
/**
|
|
1034
|
+
* Where this code's runtime build lives: beside an installed `runtime.cjs`, under `dist/runtime/` for the
|
|
1035
|
+
* bin, or `../../dist/runtime/` when running from source. Null when nothing has been built.
|
|
1036
|
+
*/
|
|
1037
|
+
function runtimeDistDir() {
|
|
1038
|
+
const here = import.meta.dirname;
|
|
1039
|
+
const candidates = [
|
|
1040
|
+
here,
|
|
1041
|
+
join(here, "runtime"),
|
|
1042
|
+
join(here, "..", "dist", "runtime"),
|
|
1043
|
+
join(here, "..", "..", "dist", "runtime")
|
|
1044
|
+
];
|
|
1045
|
+
for (const candidate of candidates) if (existsSync(join(candidate, "runtime.cjs")) && existsSync(join(candidate, "bootstrap.cjs"))) return candidate;
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
function versionDir(home, version) {
|
|
1049
|
+
return join(home, VERSIONS_DIR, version);
|
|
1050
|
+
}
|
|
1051
|
+
function copyTree(from, to, files, prefix = "") {
|
|
1052
|
+
mkdirSync(to, {
|
|
1053
|
+
recursive: true,
|
|
1054
|
+
mode: 493
|
|
1055
|
+
});
|
|
1056
|
+
for (const name of readdirSync(from)) {
|
|
1057
|
+
const source = join(from, name);
|
|
1058
|
+
const target = join(to, name);
|
|
1059
|
+
if (statSync(source).isDirectory()) copyTree(source, target, files, `${prefix}${name}/`);
|
|
1060
|
+
else {
|
|
1061
|
+
copyFileSync(source, target);
|
|
1062
|
+
files.push(`${prefix}${name}`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Installs one runtime version under `versions/<version>/` atomically (spec SET-1): copied into a
|
|
1068
|
+
* temp dir beside it and renamed into place, so a crash never leaves a half-written version.
|
|
1069
|
+
*/
|
|
1070
|
+
function installVersion(home, version, distDir, abi) {
|
|
1071
|
+
const versions = join(home, VERSIONS_DIR);
|
|
1072
|
+
mkdirSync(versions, {
|
|
1073
|
+
recursive: true,
|
|
1074
|
+
mode: 493
|
|
1075
|
+
});
|
|
1076
|
+
const target = versionDir(home, version);
|
|
1077
|
+
const staging = join(versions, `.${version}.${process.pid}.${Date.now()}`);
|
|
1078
|
+
rmSync(staging, {
|
|
1079
|
+
recursive: true,
|
|
1080
|
+
force: true
|
|
1081
|
+
});
|
|
1082
|
+
const files = [];
|
|
1083
|
+
copyTree(distDir, staging, files);
|
|
1084
|
+
const manifest = {
|
|
1085
|
+
version,
|
|
1086
|
+
abi,
|
|
1087
|
+
files: files.toSorted()
|
|
1088
|
+
};
|
|
1089
|
+
writeFileSync(join(staging, MANIFEST_FILE), JSON.stringify(manifest, null, " "));
|
|
1090
|
+
const replaced = join(versions, `.${version}.replaced.${process.pid}.${Date.now()}`);
|
|
1091
|
+
if (existsSync(target)) atomicRename(target, replaced);
|
|
1092
|
+
try {
|
|
1093
|
+
atomicRename(staging, target);
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
if (existsSync(replaced)) atomicRename(replaced, target);
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
removeWithRetry(replaced);
|
|
1099
|
+
return target;
|
|
1100
|
+
}
|
|
1101
|
+
function readCurrent(home) {
|
|
1102
|
+
try {
|
|
1103
|
+
return readFileSync(join(home, CURRENT_FILE), "utf8").trim();
|
|
1104
|
+
} catch {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
function readPrevious(home) {
|
|
1109
|
+
try {
|
|
1110
|
+
return readFileSync(join(home, PREVIOUS_FILE), "utf8").trim();
|
|
1111
|
+
} catch {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
function readManifest(home, version) {
|
|
1116
|
+
try {
|
|
1117
|
+
return JSON.parse(readFileSync(join(versionDir(home, version), MANIFEST_FILE), "utf8"));
|
|
1118
|
+
} catch {
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/** Removes copies a crashed reinstall moved aside; the launcher only reads them while `versions/<v>` is missing. */
|
|
1123
|
+
function sweepReplaced(home) {
|
|
1124
|
+
let names;
|
|
1125
|
+
try {
|
|
1126
|
+
names = readdirSync(join(home, VERSIONS_DIR));
|
|
1127
|
+
} catch {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
for (const name of names) {
|
|
1131
|
+
const match = /^\.(.+)\.replaced\./.exec(name);
|
|
1132
|
+
if (match !== null && existsSync(join(home, "versions", match[1], "runtime.cjs"))) removeWithRetry(join(home, VERSIONS_DIR, name));
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/** The ABI an installed runtime declares, read from its manifest. */
|
|
1136
|
+
function runtimeAbi(home, version) {
|
|
1137
|
+
return readManifest(home, version)?.abi ?? null;
|
|
1138
|
+
}
|
|
1139
|
+
//#endregion
|
|
1140
|
+
//#region src/setup/profile.ts
|
|
1141
|
+
const PROFILES_FILE = "profiles.json";
|
|
1142
|
+
const BLOCK_RE = /^# >>> npm-vm >>>\n[\s\S]*?^# <<< npm-vm <<<\n?/m;
|
|
1143
|
+
function bashLoginFile(home) {
|
|
1144
|
+
for (const name of [
|
|
1145
|
+
".bash_profile",
|
|
1146
|
+
".bash_login",
|
|
1147
|
+
".profile"
|
|
1148
|
+
]) if (existsSync(join(home, name))) return join(home, name);
|
|
1149
|
+
return join(home, ".bash_profile");
|
|
1150
|
+
}
|
|
1151
|
+
/** The user profile of one PowerShell host, asked with `-NoProfile` so nothing of the user's runs (spec SEC-8). */
|
|
1152
|
+
function powershellProfile(host, env, platform = process.platform) {
|
|
1153
|
+
const bin = host === "powershell" ? powershellPath(env) : shellBinary("powershell", env, platform);
|
|
1154
|
+
if (bin === null) return null;
|
|
1155
|
+
try {
|
|
1156
|
+
const out = execFileSync(bin, [
|
|
1157
|
+
"-NoProfile",
|
|
1158
|
+
"-NonInteractive",
|
|
1159
|
+
"-Command",
|
|
1160
|
+
"$PROFILE.CurrentUserAllHosts"
|
|
1161
|
+
], {
|
|
1162
|
+
encoding: "utf8",
|
|
1163
|
+
env,
|
|
1164
|
+
windowsHide: true,
|
|
1165
|
+
stdio: [
|
|
1166
|
+
"ignore",
|
|
1167
|
+
"pipe",
|
|
1168
|
+
"ignore"
|
|
1169
|
+
]
|
|
1170
|
+
}).trim();
|
|
1171
|
+
return out === "" ? null : out;
|
|
1172
|
+
} catch {
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
/** The files to edit for `shells` (spec SH-3); PowerShell hosts that are not installed yield nothing. */
|
|
1177
|
+
function profileTargets(shells, env = process.env, platform = process.platform, home = env["HOME"] || homedir()) {
|
|
1178
|
+
const targets = [];
|
|
1179
|
+
for (const shell of shells) switch (shell) {
|
|
1180
|
+
case "zsh": {
|
|
1181
|
+
const zdot = env["ZDOTDIR"] || home;
|
|
1182
|
+
targets.push({
|
|
1183
|
+
shell,
|
|
1184
|
+
file: join(zdot, ".zshenv"),
|
|
1185
|
+
parts: {
|
|
1186
|
+
path: true,
|
|
1187
|
+
hook: false
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
targets.push({
|
|
1191
|
+
shell,
|
|
1192
|
+
file: join(zdot, ".zshrc"),
|
|
1193
|
+
parts: {
|
|
1194
|
+
path: false,
|
|
1195
|
+
hook: true
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1200
|
+
case "bash": {
|
|
1201
|
+
targets.push({
|
|
1202
|
+
shell,
|
|
1203
|
+
file: join(home, ".bashrc"),
|
|
1204
|
+
parts: {
|
|
1205
|
+
path: true,
|
|
1206
|
+
hook: true
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
const login = bashLoginFile(home);
|
|
1210
|
+
if (login !== join(home, ".bashrc")) targets.push({
|
|
1211
|
+
shell,
|
|
1212
|
+
file: login,
|
|
1213
|
+
parts: {
|
|
1214
|
+
path: true,
|
|
1215
|
+
hook: false
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1220
|
+
case "fish": {
|
|
1221
|
+
const config = env["XDG_CONFIG_HOME"] || join(home, ".config");
|
|
1222
|
+
targets.push({
|
|
1223
|
+
shell,
|
|
1224
|
+
file: join(config, "fish", "conf.d", "npm-vm.fish"),
|
|
1225
|
+
parts: {
|
|
1226
|
+
path: true,
|
|
1227
|
+
hook: true
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
1232
|
+
case "powershell": {
|
|
1233
|
+
const hosts = platform === "win32" ? ["powershell", "pwsh"] : ["pwsh"];
|
|
1234
|
+
for (const host of hosts) {
|
|
1235
|
+
const file = powershellProfile(host, env, platform);
|
|
1236
|
+
if (file !== null && !targets.some((target) => target.file === file)) targets.push({
|
|
1237
|
+
shell,
|
|
1238
|
+
file,
|
|
1239
|
+
parts: {
|
|
1240
|
+
path: true,
|
|
1241
|
+
hook: true
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
break;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return targets;
|
|
1249
|
+
}
|
|
1250
|
+
/** Why a profile is printed instead of edited (spec SH-5), or null when editing it is fine. */
|
|
1251
|
+
function profileGuard(file, env = process.env, platform = process.platform) {
|
|
1252
|
+
let stat;
|
|
1253
|
+
try {
|
|
1254
|
+
stat = lstatSync(file);
|
|
1255
|
+
} catch {
|
|
1256
|
+
stat = null;
|
|
1257
|
+
}
|
|
1258
|
+
if (stat !== null && stat.isSymbolicLink()) return "it is a symlink, so a dotfile manager probably owns it";
|
|
1259
|
+
let probe = stat === null ? dirname(file) : file;
|
|
1260
|
+
while (!existsSync(probe) && dirname(probe) !== probe) probe = dirname(probe);
|
|
1261
|
+
try {
|
|
1262
|
+
accessSync(probe, constants.W_OK);
|
|
1263
|
+
} catch {
|
|
1264
|
+
return "it is not writable";
|
|
1265
|
+
}
|
|
1266
|
+
try {
|
|
1267
|
+
if (realpathSync(probe).startsWith("/nix/store")) return "it lives in the Nix store (home-manager)";
|
|
1268
|
+
} catch {}
|
|
1269
|
+
const chezmoi = join(env["HOME"] || homedir(), ".local", "share", "chezmoi");
|
|
1270
|
+
if (existsSync(join(chezmoi, `dot_${basename(file).replace(/^\./, "")}`))) return "chezmoi manages it";
|
|
1271
|
+
if (platform === "win32" && stat !== null && file.endsWith(".ps1")) {
|
|
1272
|
+
if (readFileSync(file, "utf8").includes("# SIG # Begin signature block") && executionPolicy(env) === "AllSigned") return "it is signed and the execution policy is AllSigned";
|
|
1273
|
+
}
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
/** The effective PowerShell execution policy, asked without a profile (spec WIN-2). */
|
|
1277
|
+
function executionPolicy(env = process.env) {
|
|
1278
|
+
try {
|
|
1279
|
+
return execFileSync(powershellPath(env), [
|
|
1280
|
+
"-NoProfile",
|
|
1281
|
+
"-NonInteractive",
|
|
1282
|
+
"-Command",
|
|
1283
|
+
"Get-ExecutionPolicy"
|
|
1284
|
+
], {
|
|
1285
|
+
encoding: "utf8",
|
|
1286
|
+
windowsHide: true,
|
|
1287
|
+
stdio: [
|
|
1288
|
+
"ignore",
|
|
1289
|
+
"pipe",
|
|
1290
|
+
"ignore"
|
|
1291
|
+
]
|
|
1292
|
+
}).trim();
|
|
1293
|
+
} catch {
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
/** The content with our block replaced or appended; a trailing newline is guaranteed before the block. */
|
|
1298
|
+
function withBlock(content, block) {
|
|
1299
|
+
const text = content ?? "";
|
|
1300
|
+
if (BLOCK_RE.test(text)) return text.replace(BLOCK_RE, block);
|
|
1301
|
+
return `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}${text === "" ? "" : "\n"}${block}`;
|
|
1302
|
+
}
|
|
1303
|
+
/** The content without our block and the blank line that separated it; nothing else moves. */
|
|
1304
|
+
function withoutBlock(content) {
|
|
1305
|
+
const match = BLOCK_RE.exec(content);
|
|
1306
|
+
if (match === null) return content;
|
|
1307
|
+
let before = content.slice(0, match.index).replace(/\n+$/, "\n");
|
|
1308
|
+
if (before === "\n") before = "";
|
|
1309
|
+
const after = content.slice(match.index + match[0].length);
|
|
1310
|
+
return before !== "" && after !== "" ? `${before}\n${after}` : `${before}${after}`;
|
|
1311
|
+
}
|
|
1312
|
+
function currentBlock(content) {
|
|
1313
|
+
const match = BLOCK_RE.exec(content);
|
|
1314
|
+
return match === null ? null : match[0];
|
|
1315
|
+
}
|
|
1316
|
+
function renderDiff(file, before, after) {
|
|
1317
|
+
const oldBlock = before === null ? null : currentBlock(before);
|
|
1318
|
+
const newBlock = currentBlock(after) ?? "";
|
|
1319
|
+
const lines = [`--- ${file}`, `+++ ${file}`];
|
|
1320
|
+
if (oldBlock !== null) lines.push(...oldBlock.trimEnd().split("\n").map((line) => `-${line}`));
|
|
1321
|
+
lines.push(...newBlock.trimEnd().split("\n").map((line) => `+${line}`));
|
|
1322
|
+
return `${lines.join("\n")}\n`;
|
|
1323
|
+
}
|
|
1324
|
+
/** What `setup` would write for `target`, or null when the file already holds exactly this block. */
|
|
1325
|
+
function planProfileEdit(target, home) {
|
|
1326
|
+
const before = existsSync(target.file) ? readFileSync(target.file, "utf8") : null;
|
|
1327
|
+
const after = withBlock(before, profileBlock(target.shell, home, target.parts));
|
|
1328
|
+
if (before === after) return null;
|
|
1329
|
+
return {
|
|
1330
|
+
target,
|
|
1331
|
+
before,
|
|
1332
|
+
after,
|
|
1333
|
+
diff: renderDiff(target.file, before, after)
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
function uniqueBackup(file) {
|
|
1337
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1338
|
+
let candidate = `${file}.npm-vm-backup-${stamp}`;
|
|
1339
|
+
let counter = 1;
|
|
1340
|
+
while (existsSync(candidate)) {
|
|
1341
|
+
candidate = `${file}.npm-vm-backup-${stamp}-${counter}`;
|
|
1342
|
+
counter += 1;
|
|
1343
|
+
}
|
|
1344
|
+
return candidate;
|
|
1345
|
+
}
|
|
1346
|
+
/** Applies one consented edit under a journal and a unique backup, and records it (spec SH-4). */
|
|
1347
|
+
function applyProfileEdit(dirs, owner, edit) {
|
|
1348
|
+
let backup = null;
|
|
1349
|
+
if (edit.before !== null) {
|
|
1350
|
+
backup = uniqueBackup(edit.target.file);
|
|
1351
|
+
atomicWrite(backup, edit.before, 384);
|
|
1352
|
+
}
|
|
1353
|
+
const mode = edit.before === null ? 420 : captureMeta(edit.target.file).mode || 420;
|
|
1354
|
+
mutateFile(dirs.journal, "profile", owner, edit.target.file, Buffer.from(edit.after, "utf8"), mode);
|
|
1355
|
+
const record = {
|
|
1356
|
+
file: edit.target.file,
|
|
1357
|
+
shell: edit.target.shell,
|
|
1358
|
+
parts: edit.target.parts,
|
|
1359
|
+
writtenSha256: sha256(edit.after),
|
|
1360
|
+
backup,
|
|
1361
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1362
|
+
};
|
|
1363
|
+
const profiles = readProfiles(dirs);
|
|
1364
|
+
profiles.entries = [...profiles.entries.filter((entry) => entry.file !== record.file), record];
|
|
1365
|
+
writeProfiles(dirs, profiles);
|
|
1366
|
+
return record;
|
|
1367
|
+
}
|
|
1368
|
+
/** Removes our block only when the file is still what we wrote (spec MUT-5); otherwise it is left alone. */
|
|
1369
|
+
function removeProfileBlock(dirs, owner, record) {
|
|
1370
|
+
if (!existsSync(record.file)) {
|
|
1371
|
+
forget(dirs, record);
|
|
1372
|
+
return "missing";
|
|
1373
|
+
}
|
|
1374
|
+
const content = readFileSync(record.file, "utf8");
|
|
1375
|
+
if (sha256(content) !== record.writtenSha256) return "changed";
|
|
1376
|
+
const after = withoutBlock(content);
|
|
1377
|
+
mutateFile(dirs.journal, "profile", owner, record.file, after.trim() === "" ? null : Buffer.from(after, "utf8"), captureMeta(record.file).mode || 420);
|
|
1378
|
+
forget(dirs, record);
|
|
1379
|
+
return "removed";
|
|
1380
|
+
}
|
|
1381
|
+
function forget(dirs, record) {
|
|
1382
|
+
const profiles = readProfiles(dirs);
|
|
1383
|
+
profiles.entries = profiles.entries.filter((entry) => entry.file !== record.file);
|
|
1384
|
+
writeProfiles(dirs, profiles);
|
|
1385
|
+
}
|
|
1386
|
+
function readProfiles(dirs) {
|
|
1387
|
+
try {
|
|
1388
|
+
const parsed = JSON.parse(readFileSync(join(dirs.state, PROFILES_FILE), "utf8"));
|
|
1389
|
+
return Array.isArray(parsed.entries) ? parsed : {
|
|
1390
|
+
version: 1,
|
|
1391
|
+
entries: []
|
|
1392
|
+
};
|
|
1393
|
+
} catch {
|
|
1394
|
+
return {
|
|
1395
|
+
version: 1,
|
|
1396
|
+
entries: []
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
function writeProfiles(dirs, profiles) {
|
|
1401
|
+
atomicWrite(join(dirs.state, PROFILES_FILE), JSON.stringify(profiles, null, " "), 384);
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region src/setup/windows.ts
|
|
1405
|
+
function powershell(env, command) {
|
|
1406
|
+
return execFileSync(powershellPath(env), [
|
|
1407
|
+
"-NoProfile",
|
|
1408
|
+
"-NonInteractive",
|
|
1409
|
+
"-Command",
|
|
1410
|
+
command
|
|
1411
|
+
], {
|
|
1412
|
+
encoding: "utf8",
|
|
1413
|
+
env,
|
|
1414
|
+
windowsHide: true,
|
|
1415
|
+
stdio: [
|
|
1416
|
+
"ignore",
|
|
1417
|
+
"pipe",
|
|
1418
|
+
"pipe"
|
|
1419
|
+
]
|
|
1420
|
+
}).trim();
|
|
1421
|
+
}
|
|
1422
|
+
function regExe(env) {
|
|
1423
|
+
return join(env["SystemRoot"] ?? "C:\\Windows", "System32", "reg.exe");
|
|
1424
|
+
}
|
|
1425
|
+
function quote(value) {
|
|
1426
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
1427
|
+
}
|
|
1428
|
+
function readUserPath(env) {
|
|
1429
|
+
return powershell(env, "[Environment]::GetEnvironmentVariable('Path', 'User')");
|
|
1430
|
+
}
|
|
1431
|
+
function writeUserPath(env, value) {
|
|
1432
|
+
powershell(env, `[Environment]::SetEnvironmentVariable('Path', ${quote(value)}, 'User')`);
|
|
1433
|
+
}
|
|
1434
|
+
function sameDir(a, b) {
|
|
1435
|
+
return a.replace(/[\\/]+$/, "").toLowerCase() === b.replace(/[\\/]+$/, "").toLowerCase();
|
|
1436
|
+
}
|
|
1437
|
+
/** Prepends `dir` to the user PATH unless present; returns whether anything changed. */
|
|
1438
|
+
function addToUserPath(env, dir) {
|
|
1439
|
+
const parts = readUserPath(env).split(";").filter((part) => part !== "");
|
|
1440
|
+
if (parts.some((part) => sameDir(part, dir))) return false;
|
|
1441
|
+
writeUserPath(env, [dir, ...parts].join(";"));
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
function removeFromUserPath(env, dir) {
|
|
1445
|
+
const parts = readUserPath(env).split(";").filter((part) => part !== "");
|
|
1446
|
+
const kept = parts.filter((part) => !sameDir(part, dir));
|
|
1447
|
+
if (kept.length === parts.length) return false;
|
|
1448
|
+
writeUserPath(env, kept.join(";"));
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
const AUTORUN_KEY = "HKCU\\Software\\Microsoft\\Command Processor";
|
|
1452
|
+
const AUTORUN_MARK = "npm-vm";
|
|
1453
|
+
function readCmdAutoRun(env) {
|
|
1454
|
+
try {
|
|
1455
|
+
const out = execFileSync(regExe(env), [
|
|
1456
|
+
"query",
|
|
1457
|
+
AUTORUN_KEY,
|
|
1458
|
+
"/v",
|
|
1459
|
+
"AutoRun"
|
|
1460
|
+
], {
|
|
1461
|
+
encoding: "utf8",
|
|
1462
|
+
windowsHide: true,
|
|
1463
|
+
stdio: [
|
|
1464
|
+
"ignore",
|
|
1465
|
+
"pipe",
|
|
1466
|
+
"ignore"
|
|
1467
|
+
]
|
|
1468
|
+
});
|
|
1469
|
+
return /AutoRun\s+REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(out)?.[1]?.trim() ?? null;
|
|
1470
|
+
} catch {
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
/** The cmd AutoRun entry that puts shims first in every cmd session; only with `--cmd-autorun` (WIN-3). */
|
|
1475
|
+
function autoRunCommand(shimsDir) {
|
|
1476
|
+
return `set "PATH=${shimsDir};%PATH%" & rem ${AUTORUN_MARK}`;
|
|
1477
|
+
}
|
|
1478
|
+
/** `value` without the fragment `autoRunCommand` generated, whatever home it named; the user's own commands stay. */
|
|
1479
|
+
function withoutAutoRunCommand(value) {
|
|
1480
|
+
if (value === null) return "";
|
|
1481
|
+
return value.replace(/^set "PATH=[^"]*;%PATH%" & rem npm-vm(?: & )?/, "").trim();
|
|
1482
|
+
}
|
|
1483
|
+
function setCmdAutoRun(env, shimsDir) {
|
|
1484
|
+
const ours = autoRunCommand(shimsDir);
|
|
1485
|
+
const kept = withoutAutoRunCommand(readCmdAutoRun(env));
|
|
1486
|
+
const value = kept === "" ? ours : `${ours} & ${kept}`;
|
|
1487
|
+
execFileSync(regExe(env), [
|
|
1488
|
+
"add",
|
|
1489
|
+
AUTORUN_KEY,
|
|
1490
|
+
"/v",
|
|
1491
|
+
"AutoRun",
|
|
1492
|
+
"/t",
|
|
1493
|
+
"REG_SZ",
|
|
1494
|
+
"/d",
|
|
1495
|
+
value,
|
|
1496
|
+
"/f"
|
|
1497
|
+
], {
|
|
1498
|
+
windowsHide: true,
|
|
1499
|
+
stdio: "ignore"
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
function clearCmdAutoRun(env) {
|
|
1503
|
+
const existing = readCmdAutoRun(env);
|
|
1504
|
+
if (existing === null || !existing.includes(`rem ${AUTORUN_MARK}`)) return false;
|
|
1505
|
+
const rest = withoutAutoRunCommand(existing);
|
|
1506
|
+
if (rest === "") execFileSync(regExe(env), [
|
|
1507
|
+
"delete",
|
|
1508
|
+
AUTORUN_KEY,
|
|
1509
|
+
"/v",
|
|
1510
|
+
"AutoRun",
|
|
1511
|
+
"/f"
|
|
1512
|
+
], {
|
|
1513
|
+
windowsHide: true,
|
|
1514
|
+
stdio: "ignore"
|
|
1515
|
+
});
|
|
1516
|
+
else execFileSync(regExe(env), [
|
|
1517
|
+
"add",
|
|
1518
|
+
AUTORUN_KEY,
|
|
1519
|
+
"/v",
|
|
1520
|
+
"AutoRun",
|
|
1521
|
+
"/t",
|
|
1522
|
+
"REG_SZ",
|
|
1523
|
+
"/d",
|
|
1524
|
+
rest,
|
|
1525
|
+
"/f"
|
|
1526
|
+
], {
|
|
1527
|
+
windowsHide: true,
|
|
1528
|
+
stdio: "ignore"
|
|
1529
|
+
});
|
|
1530
|
+
return true;
|
|
1531
|
+
}
|
|
1532
|
+
/** Whether the effective execution policy lets our unsigned `.ps1` shims run (spec WIN-2). */
|
|
1533
|
+
function powershellAllowsScripts(policy) {
|
|
1534
|
+
return policy !== null && ![
|
|
1535
|
+
"Restricted",
|
|
1536
|
+
"AllSigned",
|
|
1537
|
+
"Undefined"
|
|
1538
|
+
].includes(policy);
|
|
1539
|
+
}
|
|
1540
|
+
/** Which `npm` each Windows context resolves first, for `doctor`. */
|
|
1541
|
+
function windowsWinners(env) {
|
|
1542
|
+
const results = [];
|
|
1543
|
+
try {
|
|
1544
|
+
const out = execFileSync(join(env["SystemRoot"] ?? "C:\\Windows", "System32", "where.exe"), ["npm"], {
|
|
1545
|
+
encoding: "utf8",
|
|
1546
|
+
env,
|
|
1547
|
+
windowsHide: true,
|
|
1548
|
+
stdio: [
|
|
1549
|
+
"ignore",
|
|
1550
|
+
"pipe",
|
|
1551
|
+
"ignore"
|
|
1552
|
+
]
|
|
1553
|
+
});
|
|
1554
|
+
results.push({
|
|
1555
|
+
context: "cmd",
|
|
1556
|
+
path: out.split(/\r?\n/)[0] ?? null
|
|
1557
|
+
});
|
|
1558
|
+
} catch {
|
|
1559
|
+
results.push({
|
|
1560
|
+
context: "cmd",
|
|
1561
|
+
path: null
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
try {
|
|
1565
|
+
results.push({
|
|
1566
|
+
context: "PowerShell",
|
|
1567
|
+
path: powershell(env, "(Get-Command npm -All | Select-Object -First 1).Source") || null
|
|
1568
|
+
});
|
|
1569
|
+
} catch {
|
|
1570
|
+
results.push({
|
|
1571
|
+
context: "PowerShell",
|
|
1572
|
+
path: null
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
return results;
|
|
1576
|
+
}
|
|
1577
|
+
//#endregion
|
|
1578
|
+
//#region src/commands/doctor.ts
|
|
1579
|
+
const PROBE_TIMEOUT_MS = 5e3;
|
|
1580
|
+
/** The `npm` an interactive login shell resolves, by asking that shell (its profiles decide the PATH). */
|
|
1581
|
+
function shellWinner(shell, env) {
|
|
1582
|
+
const bin = shellBinary(shell, env);
|
|
1583
|
+
if (bin === null) return null;
|
|
1584
|
+
const args = shell === "fish" ? [
|
|
1585
|
+
"-l",
|
|
1586
|
+
"-i",
|
|
1587
|
+
"-c",
|
|
1588
|
+
"emit fish_prompt; command -v npm"
|
|
1589
|
+
] : shell === "powershell" ? [
|
|
1590
|
+
"-NoProfile",
|
|
1591
|
+
"-Command",
|
|
1592
|
+
"(Get-Command npm).Source"
|
|
1593
|
+
] : shell === "zsh" ? [
|
|
1594
|
+
"-l",
|
|
1595
|
+
"-i",
|
|
1596
|
+
"-c",
|
|
1597
|
+
"for f in $precmd_functions; do \"$f\"; done; command -v npm"
|
|
1598
|
+
] : [
|
|
1599
|
+
"-l",
|
|
1600
|
+
"-i",
|
|
1601
|
+
"-c",
|
|
1602
|
+
"eval \"$PROMPT_COMMAND\"; command -v npm"
|
|
1603
|
+
];
|
|
1604
|
+
try {
|
|
1605
|
+
const out = execFileSync(bin, args, {
|
|
1606
|
+
encoding: "utf8",
|
|
1607
|
+
env,
|
|
1608
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
1609
|
+
stdio: [
|
|
1610
|
+
"ignore",
|
|
1611
|
+
"pipe",
|
|
1612
|
+
"ignore"
|
|
1613
|
+
]
|
|
1614
|
+
}).trim().split("\n").pop();
|
|
1615
|
+
return out === void 0 || out === "" ? null : out;
|
|
1616
|
+
} catch {
|
|
1617
|
+
return null;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
/** A Windows path for comparison: `where` answers with the long name, the home may carry an 8.3 short name. */
|
|
1621
|
+
function canonical(path) {
|
|
1622
|
+
try {
|
|
1623
|
+
return realpathSync.native(path).toLowerCase();
|
|
1624
|
+
} catch {
|
|
1625
|
+
return path.toLowerCase();
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
/** What `command -v npm` printed: an alias or a function wins over PATH, so no PATH fix can help. */
|
|
1629
|
+
function winnerKind(winner) {
|
|
1630
|
+
if (winner.startsWith("alias ")) return "alias";
|
|
1631
|
+
return winner === "npm" ? "function" : "path";
|
|
1632
|
+
}
|
|
1633
|
+
function samePath(a, b, platform) {
|
|
1634
|
+
return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
1635
|
+
}
|
|
1636
|
+
async function collect(context) {
|
|
1637
|
+
const { dirs, env, platform } = context;
|
|
1638
|
+
const report = {
|
|
1639
|
+
home: dirs.home,
|
|
1640
|
+
version: readCurrent(dirs.home),
|
|
1641
|
+
problems: [],
|
|
1642
|
+
notes: []
|
|
1643
|
+
};
|
|
1644
|
+
const setupHint = "Run `npx --prefix ~ -y @mawesome/npm-vm setup`.";
|
|
1645
|
+
const problem = (check, detail, fix) => {
|
|
1646
|
+
report.problems.push({
|
|
1647
|
+
check,
|
|
1648
|
+
detail,
|
|
1649
|
+
fix
|
|
1650
|
+
});
|
|
1651
|
+
};
|
|
1652
|
+
if (report.version === null || readManifest(dirs.home, report.version) === null) problem("runtime", `no runtime is installed under ${dirs.home}`, setupHint);
|
|
1653
|
+
else {
|
|
1654
|
+
const root = join(dirs.home, BOOTSTRAP_FILE);
|
|
1655
|
+
const shipped = join(versionDir(dirs.home, report.version), BOOTSTRAP_FILE);
|
|
1656
|
+
if (!existsSync(root) || !readFileSync(root).equals(readFileSync(shipped))) problem("launcher", `${root} differs from the installed version's launcher`, setupHint);
|
|
1657
|
+
}
|
|
1658
|
+
for (const name of SELECTORS) {
|
|
1659
|
+
const file = join(shimDir(dirs.home), name);
|
|
1660
|
+
const abi = existsSync(file) ? shimAbi(readFileSync(file, "utf8")) : null;
|
|
1661
|
+
if (abi === null) problem("shims", `${file} is missing or not an npm-vm shim`, setupHint);
|
|
1662
|
+
else if (abi !== 1) problem("shims", `${file} declares launcher ABI ${abi}, this version needs 1`, setupHint);
|
|
1663
|
+
}
|
|
1664
|
+
if (dirs.machine.fallback) report.notes.push("The machine id falls back to the hostname; cloned machines or containers sharing this home could collide in the cache.");
|
|
1665
|
+
const lock = readLock(dirs.state);
|
|
1666
|
+
if (lock !== null && !lockOwnerLive(lock, dirs.machine.id)) problem("lock", `${lockPath(dirs.state)} is held by PID ${lock.pid}, which no longer runs`, `Delete ${lockPath(dirs.state)}.`);
|
|
1667
|
+
for (const journal of listJournals(dirs.journal)) {
|
|
1668
|
+
const foreign = journal.owner.machineId !== dirs.machine.id;
|
|
1669
|
+
problem("journal", `${journal.id} (${journal.kind}) for ${journal.target} was left by ${foreign ? `another machine (${journal.owner.host})` : `PID ${journal.owner.pid}`}`, foreign ? `Confirm no npm-vm runs on ${journal.owner.host}, then run \`npm-vm recover --journal ${journal.id} --confirm-dead-machine ${journal.owner.machineId}\`.` : "Run any npm-vm command; dead owners are recovered automatically.");
|
|
1670
|
+
}
|
|
1671
|
+
const profiles = readProfiles(dirs);
|
|
1672
|
+
for (const entry of profiles.entries) if (!existsSync(entry.file) || !readFileSync(entry.file, "utf8").includes("# >>> npm-vm >>>")) problem("profile", `${entry.file} no longer holds the npm-vm block`, `Run \`npm-vm init ${entry.shell}\` and add the block, or run setup again.`);
|
|
1673
|
+
const shimsDir = shimDir(dirs.home);
|
|
1674
|
+
if (platform === "win32") {
|
|
1675
|
+
const shimsCanonical = canonical(shimsDir);
|
|
1676
|
+
for (const winner of windowsWinners(env)) if (winner.path === null || !canonical(winner.path).startsWith(shimsCanonical)) problem("path", `${winner.context} resolves npm to ${winner.path ?? "nothing"}`, "Run setup again, then restart the terminal (M-RESTART).");
|
|
1677
|
+
} else {
|
|
1678
|
+
const login = basename(env["SHELL"] ?? "");
|
|
1679
|
+
const shells = /* @__PURE__ */ new Set([...profiles.entries.map((entry) => entry.shell), ...isShell(login) ? [login] : []]);
|
|
1680
|
+
for (const shell of shells) {
|
|
1681
|
+
if (shell === "powershell") continue;
|
|
1682
|
+
const winner = shellWinner(shell, env);
|
|
1683
|
+
if (winner === null) continue;
|
|
1684
|
+
const kind = winnerKind(winner);
|
|
1685
|
+
if (kind !== "path") problem("path", `${shell} resolves npm to a shell ${kind}: ${winner}`, `A shell ${kind} wins over PATH. Remove it from your ${shell} profile, or make it run ${join(shimsDir, "npm")}.`);
|
|
1686
|
+
else if (!samePath(winner, join(shimsDir, "npm"), platform)) problem("path", `${shell} resolves npm to ${winner}${winner.includes("corepack") ? " (a Corepack shim)" : ""}`, `Add the snippet from \`npm-vm init ${shell}\` after your Node manager's init line, or run setup with --link.`);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
const links = readLinks(dirs);
|
|
1690
|
+
if (links.entries.length > 0) {
|
|
1691
|
+
for (const entry of links.entries) {
|
|
1692
|
+
const state = linkState(entry);
|
|
1693
|
+
if (state !== "intact") problem("link", `${entry.target} is ${state === "missing" ? "missing" : "no longer the npm-vm shim"}`, "Run `npm-vm setup --link` to relink, or `npm-vm uninstall` to stop linking.");
|
|
1694
|
+
}
|
|
1695
|
+
const linked = new Set(links.entries.map((entry) => entry.binDir));
|
|
1696
|
+
for (const install of findNodeInstalls(env, platform)) if (!linked.has(install.binDir)) problem("link", `${install.manager} ${install.version} at ${install.binDir} is not linked`, "Run `npm-vm setup --link`.");
|
|
1697
|
+
}
|
|
1698
|
+
report.notes.push(...findNodeInstalls(env, platform).map((install) => `Found ${install.manager} ${install.version} at ${install.binDir}`));
|
|
1699
|
+
const project = findProject([], process.cwd(), env);
|
|
1700
|
+
if (existsSync(join(project.root, "node_modules", ".bin", "npm"))) report.notes.push(`${project.root}/node_modules/.bin/npm exists; npm scripts in this project run it instead of the shim.`);
|
|
1701
|
+
return report;
|
|
1702
|
+
}
|
|
1703
|
+
async function doctor(context, options = {}) {
|
|
1704
|
+
const report = await collect(context);
|
|
1705
|
+
if (options.quiet) return report.problems.length === 0 ? EXIT.ok : EXIT.problems;
|
|
1706
|
+
if (context.json) context.io.out(JSON.stringify(report, null, 2));
|
|
1707
|
+
else {
|
|
1708
|
+
const lines = [`npm-vm ${report.version ?? "(not installed)"} in ${report.home}`];
|
|
1709
|
+
for (const problem of report.problems) lines.push(` problem [${problem.check}]: ${problem.detail}`, ` fix: ${problem.fix}`);
|
|
1710
|
+
for (const note of report.notes) lines.push(` note: ${note}`);
|
|
1711
|
+
lines.push(report.problems.length === 0 ? " healthy" : ` ${report.problems.length} problem(s)`);
|
|
1712
|
+
context.io.out(lines.join("\n"));
|
|
1713
|
+
}
|
|
1714
|
+
return report.problems.length === 0 ? EXIT.ok : EXIT.problems;
|
|
1715
|
+
}
|
|
1716
|
+
//#endregion
|
|
1717
|
+
//#region src/commands/pin.ts
|
|
1718
|
+
/** The indentation and newline a JSON file uses, so the rewrite keeps its shape. */
|
|
1719
|
+
function detectFormat(text) {
|
|
1720
|
+
return {
|
|
1721
|
+
indent: /^[ \t]+(?=")/m.exec(text)?.[0] ?? " ",
|
|
1722
|
+
newline: text.includes("\r\n") ? "\r\n" : "\n",
|
|
1723
|
+
trailingNewline: text.endsWith("\n")
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
/** `devEngines.packageManager` with one npm entry, keeping other managers and the field's shape. */
|
|
1727
|
+
function pinnedManifest(pkg, version) {
|
|
1728
|
+
const entry = {
|
|
1729
|
+
name: "npm",
|
|
1730
|
+
version,
|
|
1731
|
+
onFail: "error"
|
|
1732
|
+
};
|
|
1733
|
+
const devEngines = typeof pkg["devEngines"] === "object" && pkg["devEngines"] !== null ? { ...pkg["devEngines"] } : {};
|
|
1734
|
+
const existing = devEngines["packageManager"];
|
|
1735
|
+
if (Array.isArray(existing)) devEngines["packageManager"] = [...existing.filter((item) => !(typeof item === "object" && item !== null && item.name === "npm")), entry];
|
|
1736
|
+
else if (typeof existing === "object" && existing !== null && existing.name !== "npm") devEngines["packageManager"] = [existing, entry];
|
|
1737
|
+
else devEngines["packageManager"] = entry;
|
|
1738
|
+
return {
|
|
1739
|
+
...pkg,
|
|
1740
|
+
devEngines
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
function nearestManifestDir(cwd) {
|
|
1744
|
+
let dir = cwd;
|
|
1745
|
+
for (;;) {
|
|
1746
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
1747
|
+
const parent = dirname(dir);
|
|
1748
|
+
if (parent === dir) return null;
|
|
1749
|
+
dir = parent;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
/** `^<latest npm whose engines accept the active Node>`, asked from the registry. */
|
|
1753
|
+
async function defaultRange(context) {
|
|
1754
|
+
const latest = (await createInstaller(createContext(context.env, context.dirs.home)).registry([parseVersionSpec(">=8", "pin")])).filter((candidate) => candidate.enginesNode === null || semver.satisfies(process.version, candidate.enginesNode, { includePrerelease: true })).map((candidate) => candidate.version).toSorted(semver.rcompare)[0];
|
|
1755
|
+
if (latest === void 0) throw new NpmVmError("M-NOMATCH", EXIT.resolution, `No npm release supports Node ${process.version}.`);
|
|
1756
|
+
return `^${latest}`;
|
|
1757
|
+
}
|
|
1758
|
+
async function pin(context, range, cwd = process.cwd()) {
|
|
1759
|
+
const project = findProject([], cwd, context.env);
|
|
1760
|
+
const nearest = nearestManifestDir(cwd);
|
|
1761
|
+
if (nearest !== null && nearest !== project.root) throw new NpmVmError("M-NOMATCH", EXIT.usage, `${nearest} is a workspace of ${project.root}; npm reads devEngines from the root. Run pin from ${project.root} to pin it there.`);
|
|
1762
|
+
const file = join(project.root, "package.json");
|
|
1763
|
+
if (!existsSync(file)) throw new NpmVmError("M-NOMATCH", EXIT.usage, `No package.json in ${project.root}.`);
|
|
1764
|
+
const version = range ?? await defaultRange(context);
|
|
1765
|
+
try {
|
|
1766
|
+
parseVersionSpec(version, "pin");
|
|
1767
|
+
} catch (error) {
|
|
1768
|
+
throw new NpmVmError("M-NOMATCH", EXIT.usage, error.message);
|
|
1769
|
+
}
|
|
1770
|
+
const lock = await acquireLock(context.dirs.state, context.owner);
|
|
1771
|
+
try {
|
|
1772
|
+
recoverDeadJournals(context.dirs.journal, context.dirs.machine.id);
|
|
1773
|
+
const text = readFileSync(file, "utf8");
|
|
1774
|
+
let pkg;
|
|
1775
|
+
try {
|
|
1776
|
+
pkg = JSON.parse(text);
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
throw new NpmVmError("M-NOMATCH", EXIT.usage, `${file} is not valid JSON: ${error.message}`);
|
|
1779
|
+
}
|
|
1780
|
+
const format = detectFormat(text);
|
|
1781
|
+
const output = `${JSON.stringify(pinnedManifest(pkg, version), null, format.indent).replace(/\n/g, format.newline)}${format.trailingNewline ? format.newline : ""}`;
|
|
1782
|
+
if (output !== text) {
|
|
1783
|
+
const mode = process.platform === "win32" ? 420 : statSync(file).mode & 4095;
|
|
1784
|
+
mutateFile(context.dirs.journal, "project", context.owner, file, Buffer.from(output, "utf8"), mode);
|
|
1785
|
+
}
|
|
1786
|
+
} finally {
|
|
1787
|
+
lock.release();
|
|
1788
|
+
}
|
|
1789
|
+
context.io.out(context.json ? JSON.stringify({
|
|
1790
|
+
file,
|
|
1791
|
+
version
|
|
1792
|
+
}) : `Pinned npm ${version} in ${file}`);
|
|
1793
|
+
return EXIT.ok;
|
|
1794
|
+
}
|
|
1795
|
+
//#endregion
|
|
1796
|
+
//#region src/install/provenance.ts
|
|
1797
|
+
var provenance_exports = /* @__PURE__ */ __exportAll({
|
|
1798
|
+
PROVENANCE_IDENTITY: () => PROVENANCE_IDENTITY,
|
|
1799
|
+
PROVENANCE_ISSUER: () => PROVENANCE_ISSUER,
|
|
1800
|
+
SLSA_PREDICATE: () => SLSA_PREDICATE,
|
|
1801
|
+
attestationUrl: () => attestationUrl,
|
|
1802
|
+
childEnv: () => childEnv,
|
|
1803
|
+
fetchAttestations: () => fetchAttestations,
|
|
1804
|
+
identityPattern: () => identityPattern,
|
|
1805
|
+
noProxyMatches: () => noProxyMatches,
|
|
1806
|
+
selfCommand: () => selfCommand,
|
|
1807
|
+
tlsOptions: () => tlsOptions,
|
|
1808
|
+
verifierLaunch: () => verifierLaunch,
|
|
1809
|
+
verifyBundle: () => verifyBundle,
|
|
1810
|
+
verifyBundleHere: () => verifyBundleHere,
|
|
1811
|
+
verifyProvenance: () => verifyProvenance
|
|
1812
|
+
});
|
|
1813
|
+
/** What the attestation must say about this repository's release workflow (spec SUP-2). */
|
|
1814
|
+
const PROVENANCE_ISSUER = "https://token.actions.githubusercontent.com";
|
|
1815
|
+
const PROVENANCE_IDENTITY = "https://github.com/mawesomedev/mawesome/.github/workflows/release.yml@refs/heads/main";
|
|
1816
|
+
const SLSA_PREDICATE = "https://slsa.dev/provenance/v1";
|
|
1817
|
+
function failure(name, version, detail) {
|
|
1818
|
+
return new NpmVmError("M-PROVENANCE", EXIT.verification, provenanceMessage(name, version, detail));
|
|
1819
|
+
}
|
|
1820
|
+
/** Resolves the attestation path against the configured registry: never another origin (spec SUP-2). */
|
|
1821
|
+
function attestationUrl(input, registry) {
|
|
1822
|
+
const base = new URL(registry);
|
|
1823
|
+
const url = new URL(base);
|
|
1824
|
+
if (input.attestationsUrl !== null) {
|
|
1825
|
+
const given = new URL(input.attestationsUrl, base);
|
|
1826
|
+
url.pathname = given.pathname;
|
|
1827
|
+
url.search = given.search;
|
|
1828
|
+
} else {
|
|
1829
|
+
url.pathname = `${base.pathname.replace(/\/$/, "")}/-/npm/v1/attestations/${encodeURIComponent(input.name)}@${input.version}`;
|
|
1830
|
+
url.search = "";
|
|
1831
|
+
}
|
|
1832
|
+
url.hash = "";
|
|
1833
|
+
url.username = "";
|
|
1834
|
+
url.password = "";
|
|
1835
|
+
if (url.origin !== base.origin) throw new Error(`attestation URL ${redactUrls(url.href)} leaves the registry origin ${base.origin}`);
|
|
1836
|
+
return url;
|
|
1837
|
+
}
|
|
1838
|
+
/** TLS settings npm itself would use: `ca` and `cafile`, `strict-ssl`, and the registry's client certificate (spec DL-2). */
|
|
1839
|
+
function tlsOptions(config) {
|
|
1840
|
+
const ca = [...config.ca ?? []];
|
|
1841
|
+
if (config.cafile !== null) ca.push(readFileSync(config.cafile, "utf8"));
|
|
1842
|
+
const { certfile, keyfile } = config.credentials;
|
|
1843
|
+
return {
|
|
1844
|
+
rejectUnauthorized: config.strictSsl,
|
|
1845
|
+
...ca.length === 0 ? {} : { ca },
|
|
1846
|
+
...certfile !== void 0 && keyfile !== void 0 ? {
|
|
1847
|
+
cert: readFileSync(certfile, "utf8"),
|
|
1848
|
+
key: readFileSync(keyfile, "utf8")
|
|
1849
|
+
} : {}
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
/** `noproxy` the way @npmcli/agent reads it: comma or space separated, `*` for all, `host[:port]`, leading dots and `*.`. */
|
|
1853
|
+
function noProxyMatches(noproxy, url) {
|
|
1854
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
1855
|
+
return (noproxy ?? "").split(/[\s,]+/).map((item) => item.trim().toLowerCase()).filter((item) => item !== "").some((item) => {
|
|
1856
|
+
if (item === "*") return true;
|
|
1857
|
+
const [host, itemPort] = item.split(":");
|
|
1858
|
+
if (itemPort !== void 0 && itemPort !== port) return false;
|
|
1859
|
+
const bare = host.replace(/^\*?\./, "");
|
|
1860
|
+
return url.hostname === bare || url.hostname.endsWith(`.${bare}`);
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
function dispatcherFor(config, url) {
|
|
1864
|
+
const proxy = url.protocol === "https:" ? config.httpsProxy ?? config.proxy : config.proxy;
|
|
1865
|
+
const bypass = noProxyMatches(config.noproxy, url);
|
|
1866
|
+
const tls = tlsOptions(config);
|
|
1867
|
+
if (proxy !== null && !bypass) return new ProxyAgent({
|
|
1868
|
+
uri: proxy,
|
|
1869
|
+
requestTls: tls
|
|
1870
|
+
});
|
|
1871
|
+
return new Agent({ connect: tls });
|
|
1872
|
+
}
|
|
1873
|
+
function authHeader(config) {
|
|
1874
|
+
const { token, username, password, auth } = config.credentials;
|
|
1875
|
+
if (token !== void 0) return { authorization: `Bearer ${token}` };
|
|
1876
|
+
if (auth !== void 0) return { authorization: `Basic ${auth}` };
|
|
1877
|
+
if (username !== void 0 && password !== void 0) return { authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` };
|
|
1878
|
+
return {};
|
|
1879
|
+
}
|
|
1880
|
+
/** Fetches the attestations with the user's registry credentials, sent to that origin only (spec DL-2). */
|
|
1881
|
+
const fetchAttestations = async (url, config) => {
|
|
1882
|
+
const dispatcher = dispatcherFor(config, url);
|
|
1883
|
+
try {
|
|
1884
|
+
const response = await request(url, {
|
|
1885
|
+
dispatcher,
|
|
1886
|
+
headers: {
|
|
1887
|
+
accept: "application/json",
|
|
1888
|
+
...authHeader(config)
|
|
1889
|
+
}
|
|
1890
|
+
});
|
|
1891
|
+
if (response.statusCode !== 200) {
|
|
1892
|
+
await response.body.dump();
|
|
1893
|
+
throw new Error(`registry answered ${response.statusCode} for ${url.pathname}`);
|
|
1894
|
+
}
|
|
1895
|
+
const body = await response.body.json();
|
|
1896
|
+
return (Array.isArray(body.attestations) ? body.attestations : []).filter((item) => typeof item === "object" && item !== null && typeof item.predicateType === "string" && "bundle" in item);
|
|
1897
|
+
} finally {
|
|
1898
|
+
await dispatcher.close();
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
/** sigstore's verifier with this repository's identity pinned; the TUF cache lives under the state dir. */
|
|
1902
|
+
/** sigstore matches the identity as an unanchored regular expression, so the exact SAN is escaped and anchored. */
|
|
1903
|
+
function identityPattern(identity = PROVENANCE_IDENTITY) {
|
|
1904
|
+
return `^${identity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`;
|
|
1905
|
+
}
|
|
1906
|
+
/** The in-process verifier, run by the `verify-attestation` child command. */
|
|
1907
|
+
async function verifyBundleHere(bundle, tufCachePath) {
|
|
1908
|
+
await (await import("sigstore")).verify(bundle, {
|
|
1909
|
+
certificateIssuer: PROVENANCE_ISSUER,
|
|
1910
|
+
certificateIdentityURI: identityPattern(),
|
|
1911
|
+
tufCachePath
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
/** How to reach this program again from a child process: the entry argv plus the `cli` selector under the launcher. */
|
|
1915
|
+
function selfCommand() {
|
|
1916
|
+
const entry = process.argv[1] ?? "";
|
|
1917
|
+
return entry.endsWith("bootstrap.cjs") ? [entry, "cli"] : [entry];
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Runs the verifier in a child Node (spec DL-2): sigstore reaches TUF through make-fetch-happen, which reads
|
|
1921
|
+
* proxies from the env, and Node reads NODE_EXTRA_CA_CERTS only at start. The child's env carries nothing else.
|
|
1922
|
+
*/
|
|
1923
|
+
const verifyBundle = async (bundle, options) => {
|
|
1924
|
+
const dir = mkdtempSync(join(options.tufCachePath, "bundle-"));
|
|
1925
|
+
const file = join(dir, "bundle.json");
|
|
1926
|
+
writeFileSync(file, JSON.stringify(bundle), { mode: 384 });
|
|
1927
|
+
const { args, env } = verifierLaunch(file, options, dir);
|
|
1928
|
+
try {
|
|
1929
|
+
await new Promise((resolve, reject) => {
|
|
1930
|
+
execFile(process.execPath, args, {
|
|
1931
|
+
env,
|
|
1932
|
+
encoding: "utf8",
|
|
1933
|
+
timeout: 3e5,
|
|
1934
|
+
windowsHide: true
|
|
1935
|
+
}, (error, stdout, stderr) => {
|
|
1936
|
+
if (error === null) {
|
|
1937
|
+
resolve();
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
reject(new Error(`${stderr.trim() || stdout.trim() || error.message}`.replace(/^npm-vm: /, "")));
|
|
1941
|
+
});
|
|
1942
|
+
});
|
|
1943
|
+
} finally {
|
|
1944
|
+
rmSync(dir, {
|
|
1945
|
+
recursive: true,
|
|
1946
|
+
force: true
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
/** The verifier child's argv and env for one bundle file; `dir` is private to this run and holds any CA bundle. */
|
|
1951
|
+
function verifierLaunch(file, options, dir) {
|
|
1952
|
+
const [entry, ...prefix] = selfCommand();
|
|
1953
|
+
return {
|
|
1954
|
+
args: [
|
|
1955
|
+
entry,
|
|
1956
|
+
...prefix,
|
|
1957
|
+
"verify-attestation",
|
|
1958
|
+
"--bundle",
|
|
1959
|
+
file,
|
|
1960
|
+
"--tuf-cache",
|
|
1961
|
+
options.tufCachePath
|
|
1962
|
+
],
|
|
1963
|
+
env: childEnv(options.config, options.env, dir)
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
const PROXY_KEYS = /^(https?_proxy|all_proxy|no_proxy|proxy|node_extra_ca_certs|node_options)$/i;
|
|
1967
|
+
/** The child's env: the parent's minus every proxy and Node override, plus only what the npm config says. */
|
|
1968
|
+
function childEnv(config, env, bundleDir = null) {
|
|
1969
|
+
const child = {};
|
|
1970
|
+
for (const [key, value] of Object.entries(env)) if (!PROXY_KEYS.test(key) && !/^npm_config_/i.test(key)) child[key] = value;
|
|
1971
|
+
const httpsProxy = config.httpsProxy ?? config.proxy;
|
|
1972
|
+
if (httpsProxy !== null) child["https_proxy"] = httpsProxy;
|
|
1973
|
+
if (config.proxy !== null) child["http_proxy"] = config.proxy;
|
|
1974
|
+
if (config.noproxy !== null) child["no_proxy"] = config.noproxy;
|
|
1975
|
+
if (config.ca !== null && config.ca.length > 0 && bundleDir !== null) {
|
|
1976
|
+
const bundle = join(bundleDir, "ca-bundle.pem");
|
|
1977
|
+
const parts = [...config.ca];
|
|
1978
|
+
if (config.cafile !== null) parts.push(readFileSync(config.cafile, "utf8"));
|
|
1979
|
+
writeFileSync(bundle, `${parts.map((part) => part.trim()).join("\n")}\n`, { mode: 384 });
|
|
1980
|
+
child["NODE_EXTRA_CA_CERTS"] = bundle;
|
|
1981
|
+
} else if (config.cafile !== null) child["NODE_EXTRA_CA_CERTS"] = config.cafile;
|
|
1982
|
+
return child;
|
|
1983
|
+
}
|
|
1984
|
+
function isSubject(value) {
|
|
1985
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1986
|
+
const { name, digest } = value;
|
|
1987
|
+
return typeof name === "string" && typeof digest === "object" && digest !== null && !Array.isArray(digest);
|
|
1988
|
+
}
|
|
1989
|
+
function statementOf(bundle) {
|
|
1990
|
+
const payload = bundle.dsseEnvelope?.payload;
|
|
1991
|
+
if (typeof payload !== "string") return null;
|
|
1992
|
+
try {
|
|
1993
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
|
1994
|
+
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.subject)) return null;
|
|
1995
|
+
return {
|
|
1996
|
+
predicateType: typeof parsed.predicateType === "string" ? parsed.predicateType : "",
|
|
1997
|
+
subject: parsed.subject.filter(isSubject).map((subject) => ({
|
|
1998
|
+
name: subject.name,
|
|
1999
|
+
digest: Object.fromEntries(Object.entries(subject.digest).filter(([, value]) => typeof value === "string"))
|
|
2000
|
+
}))
|
|
2001
|
+
};
|
|
2002
|
+
} catch {
|
|
2003
|
+
return null;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Verifies the SLSA provenance of a release (spec SUP-2): fetched from the registry origin, signed by this
|
|
2008
|
+
* repository's release workflow on main, and naming exactly this package version and tarball digest.
|
|
2009
|
+
*/
|
|
2010
|
+
async function verifyProvenance(input, deps = {}) {
|
|
2011
|
+
const { name, version } = input;
|
|
2012
|
+
let url;
|
|
2013
|
+
try {
|
|
2014
|
+
url = attestationUrl(input, input.config.registry);
|
|
2015
|
+
} catch (error) {
|
|
2016
|
+
throw failure(name, version, `bad attestation URL: ${redactUrls(error.message)}`);
|
|
2017
|
+
}
|
|
2018
|
+
let attestations;
|
|
2019
|
+
try {
|
|
2020
|
+
attestations = await (deps.fetch ?? fetchAttestations)(url, input.config);
|
|
2021
|
+
} catch (error) {
|
|
2022
|
+
throw failure(name, version, `cannot fetch attestations: ${redactUrls(error.message)}`);
|
|
2023
|
+
}
|
|
2024
|
+
const provenance = attestations.find((item) => item.predicateType === SLSA_PREDICATE);
|
|
2025
|
+
if (provenance === void 0) throw failure(name, version, "the registry holds no SLSA provenance attestation for it");
|
|
2026
|
+
const statement = statementOf(provenance.bundle);
|
|
2027
|
+
if (statement === null) throw failure(name, version, "the attestation bundle carries no readable in-toto statement");
|
|
2028
|
+
if (statement.predicateType !== "https://slsa.dev/provenance/v1") throw failure(name, version, `the signed statement is ${statement.predicateType}, not SLSA provenance`);
|
|
2029
|
+
const expectedSubject = `pkg:npm/${name.replace(/^@/, "%40")}@${version}`;
|
|
2030
|
+
const subject = statement.subject.find((item) => item.name === expectedSubject);
|
|
2031
|
+
if (subject === void 0) throw failure(name, version, `the attestation names ${statement.subject.map((item) => item.name).join(", ") || "nothing"}, not ${expectedSubject}`);
|
|
2032
|
+
if (subject.digest["sha512"] !== input.sha512hex) throw failure(name, version, "the attested tarball digest differs from the downloaded tarball");
|
|
2033
|
+
const tufCachePath = join(input.stateDir, "sigstore");
|
|
2034
|
+
mkdirSync(tufCachePath, {
|
|
2035
|
+
recursive: true,
|
|
2036
|
+
mode: 448
|
|
2037
|
+
});
|
|
2038
|
+
try {
|
|
2039
|
+
await (deps.verify ?? verifyBundle)(provenance.bundle, {
|
|
2040
|
+
tufCachePath,
|
|
2041
|
+
config: input.config,
|
|
2042
|
+
env: deps.env ?? process.env
|
|
2043
|
+
});
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
throw failure(name, version, `signature verification failed: ${redactUrls(error.message)}`);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
//#endregion
|
|
2049
|
+
//#region src/commands/self-update.ts
|
|
2050
|
+
const PACKAGE_NAME = "@mawesome/npm-vm";
|
|
2051
|
+
function run$1(file, args) {
|
|
2052
|
+
return new Promise((resolve) => {
|
|
2053
|
+
execFile(process.execPath, [file, ...args], {
|
|
2054
|
+
encoding: "utf8",
|
|
2055
|
+
timeout: 6e4,
|
|
2056
|
+
windowsHide: true
|
|
2057
|
+
}, (error, stdout, stderr) => {
|
|
2058
|
+
resolve({
|
|
2059
|
+
code: error === null ? 0 : error.code ?? 1,
|
|
2060
|
+
stdout,
|
|
2061
|
+
stderr
|
|
2062
|
+
});
|
|
2063
|
+
});
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
/** The launcher generation a bootstrap file belongs to, read from its `LAUNCHER_ABI` constant. */
|
|
2067
|
+
function launcherAbiOf(bootstrapFile) {
|
|
2068
|
+
const abi = shimAbi(readFileSync(bootstrapFile, "utf8"));
|
|
2069
|
+
if (abi === null) throw new NpmVmError("M-ABI", EXIT.abi, abiMessage(`${bootstrapFile} declares no launcher ABI`));
|
|
2070
|
+
return abi;
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* Replaces the home's launcher files with those of `version` (spec SUP-3): the bootstrap at the root and
|
|
2074
|
+
* every shim, rendered from that version's templates, each under a journal.
|
|
2075
|
+
*/
|
|
2076
|
+
function installLaunchers(context, version) {
|
|
2077
|
+
const { dirs, platform, env } = context;
|
|
2078
|
+
const dir = versionDir(dirs.home, version);
|
|
2079
|
+
const bootstrap = join(dir, BOOTSTRAP_FILE);
|
|
2080
|
+
const abi = launcherAbiOf(bootstrap);
|
|
2081
|
+
mutateFile(dirs.journal, "launcher", context.owner, join(dirs.home, BOOTSTRAP_FILE), readFileSync(bootstrap), 420);
|
|
2082
|
+
const powershell = platform !== "win32" || powershellAllowsScripts(executionPolicy(env));
|
|
2083
|
+
mkdirSync(shimDir(dirs.home), {
|
|
2084
|
+
recursive: true,
|
|
2085
|
+
mode: 493
|
|
2086
|
+
});
|
|
2087
|
+
for (const file of renderShims(dirs.home, platform, {
|
|
2088
|
+
powershell,
|
|
2089
|
+
templates: join(dir, LAUNCHERS_DIR),
|
|
2090
|
+
abi
|
|
2091
|
+
})) mutateFile(dirs.journal, "launcher", context.owner, join(shimDir(dirs.home), file.name), Buffer.from(file.content), file.mode);
|
|
2092
|
+
if (platform === "win32" && !powershell) for (const stale of stalePowershellShims(dirs.home)) mutateFile(dirs.journal, "launcher", context.owner, stale, null);
|
|
2093
|
+
}
|
|
2094
|
+
function currentLauncherAbi(context) {
|
|
2095
|
+
const file = join(context.dirs.home, BOOTSTRAP_FILE);
|
|
2096
|
+
return existsSync(file) ? shimAbi(readFileSync(file, "utf8")) : null;
|
|
2097
|
+
}
|
|
2098
|
+
async function selfUpdate(context, options, deps = {}) {
|
|
2099
|
+
const lock = await acquireLock(context.dirs.state, context.owner);
|
|
2100
|
+
try {
|
|
2101
|
+
recoverDeadJournals(context.dirs.journal, context.dirs.machine.id);
|
|
2102
|
+
return options.rollback ? rollback(context) : await update(context, deps);
|
|
2103
|
+
} finally {
|
|
2104
|
+
lock.release();
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
/** `self-update --rollback` (spec SUP-4): offline, swaps `current` back and restores the previous launchers if the ABI differs. */
|
|
2108
|
+
function rollback(context) {
|
|
2109
|
+
const { dirs, io } = context;
|
|
2110
|
+
const current = readCurrent(dirs.home);
|
|
2111
|
+
const previous = readPrevious(dirs.home);
|
|
2112
|
+
if (previous === null || !existsSync(versionDir(dirs.home, previous)) || current === null) throw new NpmVmError("M-ABI", EXIT.usage, "No previous version to roll back to.");
|
|
2113
|
+
mutateFile(dirs.journal, "launcher", context.owner, join(dirs.home, "current"), Buffer.from(`${previous}\n`));
|
|
2114
|
+
if (launcherAbiOf(join(versionDir(dirs.home, previous), "bootstrap.cjs")) !== currentLauncherAbi(context)) installLaunchers(context, previous);
|
|
2115
|
+
atomicWrite(join(dirs.home, PREVIOUS_FILE), `${current}\n`);
|
|
2116
|
+
io.out(context.json ? JSON.stringify({
|
|
2117
|
+
current: previous,
|
|
2118
|
+
previous: current
|
|
2119
|
+
}) : `Rolled back to npm-vm ${previous} (from ${current}).`);
|
|
2120
|
+
return EXIT.ok;
|
|
2121
|
+
}
|
|
2122
|
+
async function update(context, deps) {
|
|
2123
|
+
const { dirs, env, io } = context;
|
|
2124
|
+
const installed = readCurrent(dirs.home) ?? context.version;
|
|
2125
|
+
const own = findOwnNpm();
|
|
2126
|
+
const found = own?.version ?? null;
|
|
2127
|
+
if (!transportUsable(own)) throw new NpmVmError("M-TRANSPORT", EXIT.transport, transportMessage(found, "too old or missing"));
|
|
2128
|
+
const work = newWorkDir(dirs.tmp);
|
|
2129
|
+
try {
|
|
2130
|
+
const config = await loadNpmConfig(own, env, work);
|
|
2131
|
+
const transport = new Transport(own, config, env, dirs.tmp);
|
|
2132
|
+
const latest = (await transport.viewPackage(PACKAGE_NAME, "latest"))[0];
|
|
2133
|
+
if (latest === void 0) throw new NpmVmError("M-TRANSPORT", EXIT.transport, transportMessage(own.version, `${PACKAGE_NAME}@latest is not published`));
|
|
2134
|
+
if (!semver.gt(latest.version, installed)) {
|
|
2135
|
+
io.out(context.json ? JSON.stringify({
|
|
2136
|
+
current: installed,
|
|
2137
|
+
latest: latest.version,
|
|
2138
|
+
updated: false
|
|
2139
|
+
}) : `npm-vm ${installed} is up to date (latest is ${latest.version}).`);
|
|
2140
|
+
return EXIT.ok;
|
|
2141
|
+
}
|
|
2142
|
+
const packed = await transport.packPackage(PACKAGE_NAME, latest.version, work);
|
|
2143
|
+
const digests = await fileDigests(packed.file);
|
|
2144
|
+
if (digests.sha512 !== latest.integrity) throw new NpmVmError("M-INTEGRITY", EXIT.verification, `${PACKAGE_NAME}@${latest.version}: tarball does not match the registry integrity.`);
|
|
2145
|
+
await verifySignatures({
|
|
2146
|
+
name: PACKAGE_NAME,
|
|
2147
|
+
version: latest.version,
|
|
2148
|
+
integrity: latest.integrity,
|
|
2149
|
+
signatures: latest.signatures,
|
|
2150
|
+
publishedAt: async () => null,
|
|
2151
|
+
keys: loadKeyring(dirs.state),
|
|
2152
|
+
registry: config.registry,
|
|
2153
|
+
pinned: false
|
|
2154
|
+
});
|
|
2155
|
+
await verifyProvenance({
|
|
2156
|
+
name: PACKAGE_NAME,
|
|
2157
|
+
version: latest.version,
|
|
2158
|
+
sha512hex: digests.sha512hex,
|
|
2159
|
+
attestationsUrl: latest.attestationsUrl,
|
|
2160
|
+
config,
|
|
2161
|
+
stateDir: dirs.state
|
|
2162
|
+
}, {
|
|
2163
|
+
...deps.fetchAttestations === void 0 ? {} : { fetch: deps.fetchAttestations },
|
|
2164
|
+
...deps.verifyBundle === void 0 ? {} : { verify: deps.verifyBundle },
|
|
2165
|
+
env
|
|
2166
|
+
});
|
|
2167
|
+
const extracted = join(work, "package");
|
|
2168
|
+
mkdirSync(extracted, { mode: 448 });
|
|
2169
|
+
await safeExtract(packed.file, extracted);
|
|
2170
|
+
const runtimeSource = join(extracted, "dist", "runtime");
|
|
2171
|
+
if (!existsSync(join(runtimeSource, "runtime.cjs")) || !existsSync(join(runtimeSource, "bootstrap.cjs"))) throw new NpmVmError("M-INTEGRITY", EXIT.verification, `${PACKAGE_NAME}@${latest.version} ships no runtime build.`);
|
|
2172
|
+
const candidateAbi = launcherAbiOf(join(runtimeSource, BOOTSTRAP_FILE));
|
|
2173
|
+
const installedRuntimeAbi = runtimeAbi(dirs.home, installed) ?? currentLauncherAbi(context) ?? candidateAbi;
|
|
2174
|
+
if (installedRuntimeAbi < candidateAbi - 1) throw new NpmVmError("M-ABI", EXIT.abi, abiMessage(`npm-vm ${latest.version} skips launcher generations (${installedRuntimeAbi} to ${candidateAbi})`));
|
|
2175
|
+
if (candidateAbi < installedRuntimeAbi) throw new NpmVmError("M-ABI", EXIT.abi, abiMessage(`npm-vm ${latest.version} ships launcher ABI ${candidateAbi}, older than the installed runtime's ${installedRuntimeAbi}`));
|
|
2176
|
+
const target = installVersion(dirs.home, latest.version, runtimeSource, candidateAbi);
|
|
2177
|
+
const health = await run$1(join(target, BOOTSTRAP_FILE), [
|
|
2178
|
+
"--candidate",
|
|
2179
|
+
"cli",
|
|
2180
|
+
"--version"
|
|
2181
|
+
]);
|
|
2182
|
+
if (health.code !== 0 || health.stdout.trim() !== latest.version) {
|
|
2183
|
+
rmSync(target, {
|
|
2184
|
+
recursive: true,
|
|
2185
|
+
force: true
|
|
2186
|
+
});
|
|
2187
|
+
throw new NpmVmError("M-ABI", EXIT.abi, abiMessage(`the downloaded runtime failed its health check: ${health.stderr.trim() || health.stdout.trim() || `exit ${health.code}`}`));
|
|
2188
|
+
}
|
|
2189
|
+
if (candidateAbi !== currentLauncherAbi(context)) installLaunchers(context, latest.version);
|
|
2190
|
+
atomicWrite(join(dirs.home, PREVIOUS_FILE), `${installed}\n`);
|
|
2191
|
+
mutateFile(dirs.journal, "launcher", context.owner, join(dirs.home, "current"), Buffer.from(`${latest.version}\n`));
|
|
2192
|
+
io.out(context.json ? JSON.stringify({
|
|
2193
|
+
current: latest.version,
|
|
2194
|
+
previous: installed,
|
|
2195
|
+
updated: true
|
|
2196
|
+
}) : `Updated npm-vm ${installed} to ${latest.version}.`);
|
|
2197
|
+
return EXIT.ok;
|
|
2198
|
+
} finally {
|
|
2199
|
+
rmSync(work, {
|
|
2200
|
+
recursive: true,
|
|
2201
|
+
force: true
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
//#endregion
|
|
2206
|
+
//#region src/commands/setup.ts
|
|
2207
|
+
/** Refuses a home or cache another user could write to (spec SEC-9). */
|
|
2208
|
+
function assertPrivateDir(dir, platform) {
|
|
2209
|
+
if (platform === "win32") return;
|
|
2210
|
+
const stat = statSync(dir);
|
|
2211
|
+
if (process.getuid !== void 0 && stat.uid !== process.getuid()) throw new NpmVmError("M-DIRPERMS", EXIT.problems, dirPermsMessage(dir, "is not owned by you", `chown $(id -u) ${dir}`));
|
|
2212
|
+
if ((stat.mode & 18) !== 0) throw new NpmVmError("M-DIRPERMS", EXIT.problems, dirPermsMessage(dir, "is writable by other users", `chmod go-w ${dir}`));
|
|
2213
|
+
}
|
|
2214
|
+
function ensurePrivateDir(dir, platform) {
|
|
2215
|
+
if (!existsSync(dir)) {
|
|
2216
|
+
mkdirSync(dir, {
|
|
2217
|
+
recursive: true,
|
|
2218
|
+
mode: 448
|
|
2219
|
+
});
|
|
2220
|
+
if (platform !== "win32") chmodSync(dir, 448);
|
|
2221
|
+
}
|
|
2222
|
+
assertPrivateDir(dir, platform);
|
|
2223
|
+
}
|
|
2224
|
+
/** The shells to configure: `--shell` values, else the login shell, plus PowerShell on Windows. */
|
|
2225
|
+
function defaultShells(env, platform) {
|
|
2226
|
+
if (platform === "win32") return ["powershell"];
|
|
2227
|
+
const login = basename(env["SHELL"] ?? "");
|
|
2228
|
+
return isShell(login) ? [login] : ["bash"];
|
|
2229
|
+
}
|
|
2230
|
+
function sameFiles(home, version, distDir) {
|
|
2231
|
+
const manifest = readManifest(home, version);
|
|
2232
|
+
if (manifest === null) return false;
|
|
2233
|
+
return manifest.files.every((file) => {
|
|
2234
|
+
try {
|
|
2235
|
+
return readFileSync(join(distDir, file)).equals(readFileSync(join(versionDir(home, version), file)));
|
|
2236
|
+
} catch {
|
|
2237
|
+
return false;
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
async function setup(context, options) {
|
|
2242
|
+
const { dirs, env, platform, io } = context;
|
|
2243
|
+
assertSafeHome(dirs.home);
|
|
2244
|
+
const distDir = runtimeDistDir();
|
|
2245
|
+
if (distDir === null) throw new NpmVmError("M-ABI", EXIT.problems, "This npm-vm installation ships no runtime build; reinstall the package.");
|
|
2246
|
+
for (const dir of [
|
|
2247
|
+
dirs.home,
|
|
2248
|
+
dirs.state,
|
|
2249
|
+
dirs.cache
|
|
2250
|
+
]) if (options.dryRun) {
|
|
2251
|
+
if (existsSync(dir)) assertPrivateDir(dir, platform);
|
|
2252
|
+
} else ensurePrivateDir(dir, platform);
|
|
2253
|
+
const report = {
|
|
2254
|
+
home: dirs.home,
|
|
2255
|
+
version: context.version,
|
|
2256
|
+
profiles: [],
|
|
2257
|
+
links: [],
|
|
2258
|
+
skipped: 0
|
|
2259
|
+
};
|
|
2260
|
+
const shells = options.shells ?? defaultShells(env, platform);
|
|
2261
|
+
const targets = options.modifyProfile ? profileTargets(shells, env, platform) : [];
|
|
2262
|
+
const policy = platform === "win32" ? executionPolicy(env) : null;
|
|
2263
|
+
const powershell = platform !== "win32" || powershellAllowsScripts(policy);
|
|
2264
|
+
const plans = targets.map((target) => ({
|
|
2265
|
+
target,
|
|
2266
|
+
guard: profileGuard(target.file, env, platform),
|
|
2267
|
+
edit: planProfileEdit(target, dirs.home)
|
|
2268
|
+
}));
|
|
2269
|
+
const lock = options.dryRun ? null : await acquireLock(dirs.state, context.owner);
|
|
2270
|
+
try {
|
|
2271
|
+
if (lock !== null) recoverDeadJournals(dirs.journal, dirs.machine.id);
|
|
2272
|
+
if (!options.dryRun) {
|
|
2273
|
+
installRuntime(context, distDir);
|
|
2274
|
+
mkdirSync(shimDir(dirs.home), {
|
|
2275
|
+
recursive: true,
|
|
2276
|
+
mode: 493
|
|
2277
|
+
});
|
|
2278
|
+
for (const file of renderShims(dirs.home, platform, { powershell })) mutateFile(dirs.journal, "launcher", context.owner, join(shimDir(dirs.home), file.name), Buffer.from(file.content), file.mode);
|
|
2279
|
+
if (platform === "win32" && !powershell) for (const stale of stalePowershellShims(dirs.home)) mutateFile(dirs.journal, "launcher", context.owner, stale, null);
|
|
2280
|
+
}
|
|
2281
|
+
for (const plan of plans) report.profiles.push(await applyPlan(context, options, plan));
|
|
2282
|
+
if (options.link) for (const install of findNodeInstalls(env, platform)) for (const target of linkTargets(dirs.home, platform, powershell)) if (options.dryRun) report.links.push({
|
|
2283
|
+
target: join(install.binDir, target.name),
|
|
2284
|
+
outcome: "linked",
|
|
2285
|
+
detail: "dry run"
|
|
2286
|
+
});
|
|
2287
|
+
else report.links.push(linkOne(dirs, context.owner, install.binDir, target));
|
|
2288
|
+
if (platform === "win32" && !options.dryRun && options.userPath) {
|
|
2289
|
+
if (addToUserPath(env, shimDir(dirs.home))) {
|
|
2290
|
+
env["Path"] = `${shimDir(dirs.home)};${env["Path"] ?? ""}`;
|
|
2291
|
+
io.err(restartMessage());
|
|
2292
|
+
}
|
|
2293
|
+
if (options.cmdAutorun) setCmdAutoRun(env, shimDir(dirs.home));
|
|
2294
|
+
if (!powershell) io.err(`PowerShell's execution policy (${policy ?? "unknown"}) blocks unsigned scripts, so no .ps1 shims were installed; cmd shims take over.`);
|
|
2295
|
+
}
|
|
2296
|
+
} finally {
|
|
2297
|
+
lock?.release();
|
|
2298
|
+
}
|
|
2299
|
+
report.skipped = report.profiles.filter((entry) => entry.outcome === "printed" || entry.outcome === "declined" || entry.outcome === "changed").length + report.links.filter((link) => [
|
|
2300
|
+
"foreign",
|
|
2301
|
+
"unwritable",
|
|
2302
|
+
"missing"
|
|
2303
|
+
].includes(link.outcome)).length;
|
|
2304
|
+
if (context.json) io.out(JSON.stringify(report, null, 2));
|
|
2305
|
+
else io.out(describe$1(report));
|
|
2306
|
+
if (!options.dryRun) {
|
|
2307
|
+
if (await doctor(context, { quiet: context.json }) !== EXIT.ok && options.strict) return EXIT.partial;
|
|
2308
|
+
}
|
|
2309
|
+
return options.strict && report.skipped > 0 ? EXIT.partial : EXIT.ok;
|
|
2310
|
+
}
|
|
2311
|
+
/** Installs this version's runtime and launcher unless the identical files are already in place. */
|
|
2312
|
+
function installRuntime(context, distDir) {
|
|
2313
|
+
const { dirs, version } = context;
|
|
2314
|
+
if (!sameFiles(dirs.home, version, distDir)) installVersion(dirs.home, version, distDir, 1);
|
|
2315
|
+
sweepReplaced(dirs.home);
|
|
2316
|
+
if (readCurrent(dirs.home) !== version) mutateFile(dirs.journal, "launcher", context.owner, join(dirs.home, "current"), Buffer.from(`${version}\n`));
|
|
2317
|
+
const bootstrap = readFileSync(join(versionDir(dirs.home, version), BOOTSTRAP_FILE));
|
|
2318
|
+
const installed = existsSync(join(dirs.home, "bootstrap.cjs")) ? readFileSync(join(dirs.home, BOOTSTRAP_FILE)) : null;
|
|
2319
|
+
if (installed === null || !installed.equals(bootstrap)) mutateFile(dirs.journal, "launcher", context.owner, join(dirs.home, BOOTSTRAP_FILE), bootstrap, 420);
|
|
2320
|
+
}
|
|
2321
|
+
async function applyPlan(context, options, plan) {
|
|
2322
|
+
const { io, dirs } = context;
|
|
2323
|
+
const { target } = plan;
|
|
2324
|
+
if (plan.guard !== null) {
|
|
2325
|
+
io.out(`Not editing ${target.file}: ${plan.guard}. Add this yourself:\n${profileBlock(target.shell, dirs.home, target.parts)}`);
|
|
2326
|
+
return {
|
|
2327
|
+
file: target.file,
|
|
2328
|
+
outcome: "printed",
|
|
2329
|
+
reason: plan.guard
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
if (plan.edit === null) return {
|
|
2333
|
+
file: target.file,
|
|
2334
|
+
outcome: "unchanged"
|
|
2335
|
+
};
|
|
2336
|
+
if (options.dryRun) {
|
|
2337
|
+
io.out(`Would edit ${target.file}:\n${plan.edit.diff}`);
|
|
2338
|
+
return {
|
|
2339
|
+
file: target.file,
|
|
2340
|
+
outcome: "dry-run"
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
if (!options.yes) {
|
|
2344
|
+
io.out(plan.edit.diff);
|
|
2345
|
+
if (!await io.confirm(`Apply this change to ${target.file}?`)) {
|
|
2346
|
+
io.out(`Skipped ${target.file}. Add this yourself:\n${profileBlock(target.shell, dirs.home, target.parts)}`);
|
|
2347
|
+
return {
|
|
2348
|
+
file: target.file,
|
|
2349
|
+
outcome: "declined"
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
const guard = profileGuard(target.file, context.env, context.platform);
|
|
2354
|
+
const fresh = planProfileEdit(target, dirs.home);
|
|
2355
|
+
if (fresh === null) return {
|
|
2356
|
+
file: target.file,
|
|
2357
|
+
outcome: "unchanged"
|
|
2358
|
+
};
|
|
2359
|
+
if (guard !== null || fresh.before !== plan.edit.before && !options.yes) {
|
|
2360
|
+
io.out(`${target.file} changed while the prompt was open; run setup again to review the new change.`);
|
|
2361
|
+
return {
|
|
2362
|
+
file: target.file,
|
|
2363
|
+
outcome: "changed"
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
applyProfileEdit(dirs, context.owner, fresh);
|
|
2367
|
+
return {
|
|
2368
|
+
file: target.file,
|
|
2369
|
+
outcome: "written"
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
function describe$1(report) {
|
|
2373
|
+
const lines = [`npm-vm ${report.version} installed in ${report.home}`];
|
|
2374
|
+
for (const profile of report.profiles) lines.push(` ${profile.file}: ${profile.outcome}${profile.reason ? ` (${profile.reason})` : ""}`);
|
|
2375
|
+
for (const link of report.links) lines.push(` ${link.target}: ${link.outcome}${link.detail ? ` (${link.detail})` : ""}`);
|
|
2376
|
+
return lines.join("\n");
|
|
2377
|
+
}
|
|
2378
|
+
//#endregion
|
|
2379
|
+
//#region src/commands/uninstall.ts
|
|
2380
|
+
/** Removes the cache's directories only while empty: an npm publishing right now keeps its entry. */
|
|
2381
|
+
function removeEmptyTree(cache) {
|
|
2382
|
+
for (const dir of [
|
|
2383
|
+
join(cache, "npm"),
|
|
2384
|
+
stagingDir(cache),
|
|
2385
|
+
cache
|
|
2386
|
+
]) try {
|
|
2387
|
+
rmdirSync(dir);
|
|
2388
|
+
} catch {}
|
|
2389
|
+
}
|
|
2390
|
+
async function uninstall(context, options) {
|
|
2391
|
+
const { dirs, env, platform, io } = context;
|
|
2392
|
+
if (!options.yes) {
|
|
2393
|
+
if (!await io.confirm(`Remove npm-vm from ${dirs.home}, its profile blocks and links${options.purgeCache ? ", and the cache" : ""}?`)) {
|
|
2394
|
+
io.out("Nothing removed.");
|
|
2395
|
+
return EXIT.ok;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
const report = {
|
|
2399
|
+
profiles: [],
|
|
2400
|
+
links: [],
|
|
2401
|
+
homeRemoved: false,
|
|
2402
|
+
cacheRemoved: false,
|
|
2403
|
+
cacheKept: []
|
|
2404
|
+
};
|
|
2405
|
+
const lock = await acquireLock(dirs.state, context.owner);
|
|
2406
|
+
let clean = true;
|
|
2407
|
+
try {
|
|
2408
|
+
recoverDeadJournals(dirs.journal, dirs.machine.id);
|
|
2409
|
+
for (const entry of readProfiles(dirs).entries) {
|
|
2410
|
+
const outcome = removeProfileBlock(dirs, context.owner, entry);
|
|
2411
|
+
report.profiles.push({
|
|
2412
|
+
file: entry.file,
|
|
2413
|
+
outcome
|
|
2414
|
+
});
|
|
2415
|
+
if (outcome === "changed") {
|
|
2416
|
+
clean = false;
|
|
2417
|
+
io.err(changedMessage(entry.file, "npm-vm block"));
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
for (const entry of readLinks(dirs).entries) {
|
|
2421
|
+
const outcome = unlinkOne(dirs, context.owner, entry);
|
|
2422
|
+
report.links.push({
|
|
2423
|
+
target: entry.target,
|
|
2424
|
+
outcome
|
|
2425
|
+
});
|
|
2426
|
+
if (outcome === "changed") {
|
|
2427
|
+
clean = false;
|
|
2428
|
+
io.err(changedMessage(entry.target, "shim"));
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
if (platform === "win32") {
|
|
2432
|
+
removeFromUserPath(env, shimDir(dirs.home));
|
|
2433
|
+
clearCmdAutoRun(env);
|
|
2434
|
+
}
|
|
2435
|
+
if (options.purgeCache) {
|
|
2436
|
+
const cacheLock = await acquireLock(dirs.cache, context.owner);
|
|
2437
|
+
try {
|
|
2438
|
+
const pruned = pruneCache(dirs.cache, context.owner, { maxAgeMs: 0 });
|
|
2439
|
+
report.cacheKept = pruned.kept.concat(pruned.skipped);
|
|
2440
|
+
if (report.cacheKept.length === 0) {
|
|
2441
|
+
removeEmptyTree(dirs.cache);
|
|
2442
|
+
report.cacheRemoved = true;
|
|
2443
|
+
}
|
|
2444
|
+
} finally {
|
|
2445
|
+
cacheLock.release();
|
|
2446
|
+
}
|
|
2447
|
+
if (report.cacheRemoved) removeEmptyTree(dirs.cache);
|
|
2448
|
+
if (report.cacheKept.length > 0) {
|
|
2449
|
+
clean = false;
|
|
2450
|
+
io.err(`Cache kept: ${report.cacheKept.join(", ")} ${report.cacheKept.length === 1 ? "is" : "are"} in use by a running npm.`);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
const tombstone = `${dirs.home}.removing.${process.pid}.${Date.now()}`;
|
|
2454
|
+
atomicRename(dirs.home, tombstone);
|
|
2455
|
+
removeWithRetry(tombstone);
|
|
2456
|
+
report.homeRemoved = true;
|
|
2457
|
+
} finally {
|
|
2458
|
+
lock.release();
|
|
2459
|
+
}
|
|
2460
|
+
io.out(context.json ? JSON.stringify(report, null, 2) : describe(report, dirs.cache));
|
|
2461
|
+
return clean ? EXIT.ok : EXIT.problems;
|
|
2462
|
+
}
|
|
2463
|
+
function describe(report, cache) {
|
|
2464
|
+
const lines = ["npm-vm removed."];
|
|
2465
|
+
for (const profile of report.profiles) lines.push(` ${profile.file}: ${profile.outcome}`);
|
|
2466
|
+
for (const link of report.links) lines.push(` ${link.target}: ${link.outcome}`);
|
|
2467
|
+
if (report.cacheRemoved) lines.push(` cache removed (${cache})`);
|
|
2468
|
+
else if (report.cacheKept.length > 0) lines.push(` cache kept at ${cache}: ${report.cacheKept.join(", ")} in use by a running npm`);
|
|
2469
|
+
else lines.push(` cache kept at ${cache}; add --purge-cache to remove it`);
|
|
2470
|
+
return lines.join("\n");
|
|
2471
|
+
}
|
|
2472
|
+
//#endregion
|
|
2473
|
+
//#region src/commands/dispatch.ts
|
|
2474
|
+
const VERSION = readSelfVersion();
|
|
2475
|
+
const USAGE = `npm-vm v${VERSION}: run the npm version a project declares in devEngines
|
|
2476
|
+
|
|
2477
|
+
Usage:
|
|
2478
|
+
npm-vm <command> [options]
|
|
2479
|
+
|
|
2480
|
+
Commands:
|
|
2481
|
+
setup Install the launcher, shims and shell snippets; --link also replaces npm
|
|
2482
|
+
inside every nvm/fnm Node. --shell <s> (repeatable), --yes, --dry-run,
|
|
2483
|
+
--no-modify-profile, --cmd-autorun and --no-user-path (Windows), --strict (exit 2 on skips).
|
|
2484
|
+
init <shell> Print the snippet for zsh, bash, fish or powershell (--path, --hook; default both).
|
|
2485
|
+
doctor Check the installation; exit 1 when something needs fixing.
|
|
2486
|
+
which Print the npm that would run for the current directory.
|
|
2487
|
+
pin [<range>] Write devEngines.packageManager for npm into the project's package.json.
|
|
2488
|
+
install [<spec>] Download and verify the project's npm, or <spec>, into the cache.
|
|
2489
|
+
cache list List cached npm versions.
|
|
2490
|
+
cache prune Remove unused entries; --older-than <days> (default 90).
|
|
2491
|
+
self-update Update npm-vm with provenance verification; --rollback restores the previous version.
|
|
2492
|
+
recover Finish a journal left by another machine: --journal <id> --confirm-dead-machine <id>.
|
|
2493
|
+
uninstall Remove npm-vm (--yes to skip the prompt, --purge-cache to drop the cache too).
|
|
2494
|
+
|
|
2495
|
+
Global options:
|
|
2496
|
+
--json Machine-readable output where a command offers it.
|
|
2497
|
+
--version, -v Print the version.
|
|
2498
|
+
--help, -h Print this help.
|
|
2499
|
+
|
|
2500
|
+
Environment:
|
|
2501
|
+
NPM_VM_VERSION Use this range or version instead of the project's.
|
|
2502
|
+
NPM_VM_DISABLE Any value: always run Node's own npm.
|
|
2503
|
+
NPM_VM_OFFLINE Any value: never contact the registry.
|
|
2504
|
+
NPM_VM_HOME The home directory (setup only).
|
|
2505
|
+
|
|
2506
|
+
Exit codes: 0 ok, 1 problems found, 2 partial setup under --strict, 3 resolution failed, 4 transport
|
|
2507
|
+
unavailable, 5 verification failed, 6 launcher mismatch, 64 usage error.`;
|
|
2508
|
+
function parse(argv) {
|
|
2509
|
+
return parseArgs({
|
|
2510
|
+
args: argv,
|
|
2511
|
+
allowPositionals: true,
|
|
2512
|
+
allowNegative: true,
|
|
2513
|
+
options: {
|
|
2514
|
+
json: { type: "boolean" },
|
|
2515
|
+
path: { type: "boolean" },
|
|
2516
|
+
hook: { type: "boolean" },
|
|
2517
|
+
link: { type: "boolean" },
|
|
2518
|
+
shell: {
|
|
2519
|
+
type: "string",
|
|
2520
|
+
multiple: true
|
|
2521
|
+
},
|
|
2522
|
+
yes: { type: "boolean" },
|
|
2523
|
+
"dry-run": { type: "boolean" },
|
|
2524
|
+
"modify-profile": { type: "boolean" },
|
|
2525
|
+
"cmd-autorun": { type: "boolean" },
|
|
2526
|
+
"user-path": { type: "boolean" },
|
|
2527
|
+
strict: { type: "boolean" },
|
|
2528
|
+
"older-than": { type: "string" },
|
|
2529
|
+
rollback: { type: "boolean" },
|
|
2530
|
+
journal: { type: "string" },
|
|
2531
|
+
"confirm-dead-machine": { type: "string" },
|
|
2532
|
+
"purge-cache": { type: "boolean" },
|
|
2533
|
+
bundle: { type: "string" },
|
|
2534
|
+
"tuf-cache": { type: "string" },
|
|
2535
|
+
version: {
|
|
2536
|
+
type: "boolean",
|
|
2537
|
+
short: "v"
|
|
2538
|
+
},
|
|
2539
|
+
help: {
|
|
2540
|
+
type: "boolean",
|
|
2541
|
+
short: "h"
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
/** Command-specific options, so a flag meant for another command is an error rather than silently ignored. */
|
|
2547
|
+
const COMMAND_OPTIONS = {
|
|
2548
|
+
setup: [
|
|
2549
|
+
"link",
|
|
2550
|
+
"shell",
|
|
2551
|
+
"yes",
|
|
2552
|
+
"dry-run",
|
|
2553
|
+
"modify-profile",
|
|
2554
|
+
"cmd-autorun",
|
|
2555
|
+
"user-path",
|
|
2556
|
+
"strict"
|
|
2557
|
+
],
|
|
2558
|
+
init: ["path", "hook"],
|
|
2559
|
+
doctor: [],
|
|
2560
|
+
which: [],
|
|
2561
|
+
pin: [],
|
|
2562
|
+
install: [],
|
|
2563
|
+
cache: ["older-than"],
|
|
2564
|
+
"self-update": ["rollback"],
|
|
2565
|
+
recover: ["journal", "confirm-dead-machine"],
|
|
2566
|
+
uninstall: ["yes", "purge-cache"],
|
|
2567
|
+
"verify-attestation": ["bundle", "tuf-cache"]
|
|
2568
|
+
};
|
|
2569
|
+
function misplacedOptions(command, values) {
|
|
2570
|
+
const allowed = new Set(COMMAND_OPTIONS[command] ?? []);
|
|
2571
|
+
return Object.values(COMMAND_OPTIONS).flat().filter((option) => !allowed.has(option) && values[option] !== void 0).map((option) => `--${option}`);
|
|
2572
|
+
}
|
|
2573
|
+
async function run(argv, options) {
|
|
2574
|
+
let parsed;
|
|
2575
|
+
try {
|
|
2576
|
+
parsed = parse(argv);
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
console.error(error.message);
|
|
2579
|
+
console.error(USAGE);
|
|
2580
|
+
return EXIT.usage;
|
|
2581
|
+
}
|
|
2582
|
+
const { values, positionals } = parsed;
|
|
2583
|
+
if (values.version) {
|
|
2584
|
+
console.log(VERSION);
|
|
2585
|
+
return EXIT.ok;
|
|
2586
|
+
}
|
|
2587
|
+
const command = positionals[0];
|
|
2588
|
+
if (values.help || command === void 0) {
|
|
2589
|
+
console.log(USAGE);
|
|
2590
|
+
return command === void 0 && !values.help ? EXIT.usage : EXIT.ok;
|
|
2591
|
+
}
|
|
2592
|
+
if (COMMAND_OPTIONS[command] === void 0) {
|
|
2593
|
+
console.error(`Unknown command: ${command}`);
|
|
2594
|
+
console.error(USAGE);
|
|
2595
|
+
return EXIT.usage;
|
|
2596
|
+
}
|
|
2597
|
+
const misplaced = misplacedOptions(command, values);
|
|
2598
|
+
if (misplaced.length > 0) {
|
|
2599
|
+
console.error(`${misplaced.join(", ")} cannot be used with ${command}`);
|
|
2600
|
+
return EXIT.usage;
|
|
2601
|
+
}
|
|
2602
|
+
const context = createCommandContext({
|
|
2603
|
+
...options.home === void 0 ? {} : { home: options.home },
|
|
2604
|
+
...options.env === void 0 ? {} : { env: options.env },
|
|
2605
|
+
...options.io === void 0 ? {} : { io: options.io },
|
|
2606
|
+
json: values.json ?? false,
|
|
2607
|
+
version: VERSION
|
|
2608
|
+
});
|
|
2609
|
+
try {
|
|
2610
|
+
return await dispatch(command, positionals.slice(1), values, context, options);
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
return reportError(error, context);
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
async function dispatch(command, rest, values, context, options) {
|
|
2616
|
+
switch (command) {
|
|
2617
|
+
case "setup": {
|
|
2618
|
+
const shells = values.shell ?? null;
|
|
2619
|
+
const bad = shells?.filter((shell) => !isShell(shell)) ?? [];
|
|
2620
|
+
if (bad.length > 0) {
|
|
2621
|
+
context.io.err(`--shell needs one of: ${SHELLS.join(", ")} (got ${bad.join(", ")})`);
|
|
2622
|
+
return EXIT.usage;
|
|
2623
|
+
}
|
|
2624
|
+
return setup(context, {
|
|
2625
|
+
link: values.link ?? false,
|
|
2626
|
+
shells,
|
|
2627
|
+
yes: values.yes ?? false,
|
|
2628
|
+
dryRun: values["dry-run"] ?? false,
|
|
2629
|
+
modifyProfile: values["modify-profile"] ?? true,
|
|
2630
|
+
cmdAutorun: values["cmd-autorun"] ?? false,
|
|
2631
|
+
strict: values.strict ?? false,
|
|
2632
|
+
userPath: values["user-path"] ?? true
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
case "init": {
|
|
2636
|
+
const shell = rest[0];
|
|
2637
|
+
if (shell === void 0 || !isShell(shell)) {
|
|
2638
|
+
context.io.err(`init needs one of: ${SHELLS.join(", ")}`);
|
|
2639
|
+
return EXIT.usage;
|
|
2640
|
+
}
|
|
2641
|
+
const both = values.path !== true && values.hook !== true;
|
|
2642
|
+
process.stdout.write(profileBlock(shell, context.dirs.home, {
|
|
2643
|
+
path: both || values.path === true,
|
|
2644
|
+
hook: both || values.hook === true
|
|
2645
|
+
}));
|
|
2646
|
+
return EXIT.ok;
|
|
2647
|
+
}
|
|
2648
|
+
case "doctor": return doctor(context);
|
|
2649
|
+
case "which": {
|
|
2650
|
+
const result = await resolveNpm([], process.cwd(), {
|
|
2651
|
+
home: context.dirs.home,
|
|
2652
|
+
...options.env === void 0 ? {} : { env: options.env }
|
|
2653
|
+
});
|
|
2654
|
+
context.io.out(context.json ? JSON.stringify(result, null, 2) : describeWhich(result));
|
|
2655
|
+
return EXIT.ok;
|
|
2656
|
+
}
|
|
2657
|
+
case "pin": return pin(context, rest[0]);
|
|
2658
|
+
case "install": return install(context, rest[0]);
|
|
2659
|
+
case "cache":
|
|
2660
|
+
if (rest[0] === "list") return cacheList(context);
|
|
2661
|
+
if (rest[0] === "prune") return cachePrune(context, values["older-than"] === void 0 ? 90 : Number(values["older-than"]));
|
|
2662
|
+
context.io.err("cache needs list or prune");
|
|
2663
|
+
return EXIT.usage;
|
|
2664
|
+
case "self-update": return selfUpdate(context, { rollback: values.rollback ?? false });
|
|
2665
|
+
case "recover":
|
|
2666
|
+
if (values.journal === void 0 || values["confirm-dead-machine"] === void 0) {
|
|
2667
|
+
context.io.err("recover needs --journal <id> and --confirm-dead-machine <machine-id>");
|
|
2668
|
+
return EXIT.usage;
|
|
2669
|
+
}
|
|
2670
|
+
return recover(context, values.journal, values["confirm-dead-machine"]);
|
|
2671
|
+
case "verify-attestation": {
|
|
2672
|
+
if (values.bundle === void 0 || values["tuf-cache"] === void 0) return EXIT.usage;
|
|
2673
|
+
const { verifyBundleHere } = await Promise.resolve().then(() => provenance_exports);
|
|
2674
|
+
await verifyBundleHere(JSON.parse(readFileSync(values.bundle, "utf8")), values["tuf-cache"]);
|
|
2675
|
+
return EXIT.ok;
|
|
2676
|
+
}
|
|
2677
|
+
case "uninstall": return uninstall(context, {
|
|
2678
|
+
yes: values.yes ?? false,
|
|
2679
|
+
purgeCache: values["purge-cache"] ?? false
|
|
2680
|
+
});
|
|
2681
|
+
default: return EXIT.usage;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
function describeWhich(result) {
|
|
2685
|
+
const where = result.kind === "own" ? "Node's own npm" : result.kind === "cached" ? "cached" : "to download";
|
|
2686
|
+
const from = result.source === null ? "no project requirement" : `${result.source.field} in ${result.source.file}`;
|
|
2687
|
+
const lines = [`npm ${result.version} (${where}): ${result.cliPath}`, `Source: ${from}`];
|
|
2688
|
+
if (result.warning !== null) lines.push(`Warning: ${result.warning}`);
|
|
2689
|
+
return lines.join("\n");
|
|
2690
|
+
}
|
|
2691
|
+
function reportError(error, context) {
|
|
2692
|
+
if (isNpmVmError(error)) {
|
|
2693
|
+
context.io.err(`npm-vm: ${error.message}`);
|
|
2694
|
+
return error.exitCode;
|
|
2695
|
+
}
|
|
2696
|
+
context.io.err(`npm-vm: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
2697
|
+
return EXIT.problems;
|
|
2698
|
+
}
|
|
2699
|
+
/** The build injects the package version; running from source reads the manifest instead. */
|
|
2700
|
+
function readSelfVersion() {
|
|
2701
|
+
return "0.1.0";
|
|
2702
|
+
}
|
|
2703
|
+
//#endregion
|
|
2704
|
+
export { EXIT, NpmVmError, SpecError, createContext, createInstaller, findProject, isNpmVmError, parseSpecs, parseVersionSpec, resolveNpmWith, run as runCommand, selectCandidate, sriHex };
|
|
2705
|
+
|
|
2706
|
+
//# sourceMappingURL=internal.js.map
|