@gjsify/cli 0.36.0 → 0.37.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/dist/cli.gjs.mjs +3 -3
- package/lib/bundler-pick.js +61 -87
- package/lib/cli-fail.d.ts +4 -7
- package/lib/cli-fail.js +29 -55
- package/lib/commands/affected.js +161 -286
- package/lib/commands/install.d.ts +63 -66
- package/lib/commands/install.js +372 -536
- package/lib/templates/install.mjs.tmpl +38 -68
- package/lib/types/cli-build-options.d.ts +33 -67
- package/lib/types/command.d.ts +9 -12
- package/lib/types/config-data.d.ts +125 -221
- package/lib/utils/auth-npmrc.d.ts +5 -10
- package/lib/utils/auth-npmrc.js +11 -21
- package/lib/utils/clear-targets.d.ts +8 -11
- package/lib/utils/clear-targets.js +22 -29
- package/lib/utils/copy-targets.d.ts +13 -19
- package/lib/utils/copy-targets.js +31 -44
- package/lib/utils/dep-aggregation.d.ts +7 -15
- package/lib/utils/dep-aggregation.js +9 -22
- package/lib/utils/detect-native-packages.d.ts +83 -116
- package/lib/utils/detect-native-packages.js +111 -149
- package/lib/utils/dir-link.d.ts +29 -48
- package/lib/utils/dir-link.js +33 -56
- package/lib/utils/discover-showcases.d.ts +6 -7
- package/lib/utils/discover-showcases.js +11 -15
- package/lib/utils/dlx-cache.d.ts +29 -38
- package/lib/utils/dlx-cache.js +53 -63
- package/lib/utils/force-exit.d.ts +0 -3
- package/lib/utils/force-exit.js +13 -24
- package/lib/utils/gi-search-path.d.ts +16 -24
- package/lib/utils/gi-search-path.js +54 -82
- package/lib/utils/gjs-bundle-guard.d.ts +5 -13
- package/lib/utils/gjs-bundle-guard.js +35 -57
- package/lib/utils/gjs-source-escape.d.ts +2 -2
- package/lib/utils/gjs-source-escape.js +21 -39
- package/lib/utils/gjsify-shim.d.ts +17 -20
- package/lib/utils/gjsify-shim.js +80 -120
- package/lib/utils/html-entry.d.ts +8 -12
- package/lib/utils/html-entry.js +19 -28
- package/lib/utils/install-backend-native.d.ts +123 -161
- package/lib/utils/install-backend-native.js +536 -737
- package/lib/utils/install-backend.d.ts +42 -57
- package/lib/utils/install-backend.js +16 -21
- package/lib/utils/install-cache-fs.d.ts +10 -15
- package/lib/utils/install-cache-fs.js +18 -26
- package/lib/utils/install-lock.d.ts +5 -6
- package/lib/utils/install-lock.js +46 -65
- package/lib/utils/install-packument-cache.d.ts +27 -33
- package/lib/utils/install-packument-cache.js +44 -57
- package/lib/utils/install-progress.d.ts +2 -9
- package/lib/utils/install-progress.js +14 -39
- package/lib/utils/install-tarball-cache.d.ts +14 -20
- package/lib/utils/install-tarball-cache.js +41 -59
- package/lib/utils/library-output.d.ts +9 -22
- package/lib/utils/library-output.js +19 -37
- package/lib/utils/load-npmrc.d.ts +5 -6
- package/lib/utils/load-npmrc.js +18 -27
- package/lib/utils/node-bundle-guard.d.ts +4 -8
- package/lib/utils/node-bundle-guard.js +21 -35
- package/lib/utils/node-script.js +33 -54
- package/lib/utils/node-version.d.ts +5 -14
- package/lib/utils/node-version.js +15 -24
- package/lib/utils/normalize-bundler-options.d.ts +7 -11
- package/lib/utils/normalize-bundler-options.js +24 -42
- package/lib/utils/npm-oidc.d.ts +23 -37
- package/lib/utils/npm-oidc.js +28 -53
- package/lib/utils/npm-otp-cache.d.ts +4 -4
- package/lib/utils/npm-otp-cache.js +20 -25
- package/lib/utils/npm-otp.d.ts +20 -30
- package/lib/utils/npm-otp.js +32 -51
- package/lib/utils/onboard-probe.d.ts +7 -13
- package/lib/utils/onboard-probe.js +16 -27
- package/lib/utils/parse-spec.d.ts +3 -3
- package/lib/utils/parse-spec.js +4 -5
- package/lib/utils/pkg-json-edit.d.ts +7 -12
- package/lib/utils/pkg-json-edit.js +13 -26
- package/lib/utils/pkg-json.d.ts +11 -18
- package/lib/utils/pkg-json.js +19 -28
- package/lib/utils/prefixed-output.d.ts +3 -4
- package/lib/utils/prefixed-output.js +13 -19
- package/lib/utils/prompt.d.ts +3 -4
- package/lib/utils/prompt.js +14 -18
- package/lib/utils/publish-diagnose.d.ts +11 -16
- package/lib/utils/publish-diagnose.js +15 -28
- package/lib/utils/publish-headers.d.ts +7 -11
- package/lib/utils/publish-headers.js +36 -65
- package/lib/utils/publishable-packages.d.ts +4 -6
- package/lib/utils/publishable-packages.js +14 -18
- package/lib/utils/resolve-gjs-entry.d.ts +5 -8
- package/lib/utils/resolve-gjs-entry.js +10 -27
- package/lib/utils/resolve-npm-package.d.ts +6 -11
- package/lib/utils/resolve-npm-package.js +29 -63
- package/lib/utils/resolve-plugin-by-name.d.ts +11 -19
- package/lib/utils/resolve-plugin-by-name.js +9 -14
- package/lib/utils/run-gjs.d.ts +16 -21
- package/lib/utils/run-gjs.js +31 -56
- package/lib/utils/run-lifecycle-script.d.ts +5 -9
- package/lib/utils/run-lifecycle-script.js +23 -42
- package/lib/utils/run-node.d.ts +21 -33
- package/lib/utils/run-node.js +43 -64
- package/lib/utils/runtimes.d.ts +25 -35
- package/lib/utils/runtimes.js +34 -46
- package/lib/utils/spawn.d.ts +32 -48
- package/lib/utils/spawn.js +51 -84
- package/lib/utils/stdin.d.ts +2 -2
- package/lib/utils/stdin.js +10 -25
- package/lib/utils/suggest.d.ts +6 -7
- package/lib/utils/suggest.js +8 -10
- package/lib/utils/system-gi.d.ts +18 -27
- package/lib/utils/system-gi.js +60 -114
- package/lib/utils/trust-registry.d.ts +4 -8
- package/lib/utils/trust-registry.js +7 -15
- package/lib/utils/win32-command.d.ts +8 -14
- package/lib/utils/win32-command.js +29 -43
- package/lib/utils/workspace-root.js +7 -12
- package/package.json +18 -18
|
@@ -1,80 +1,52 @@
|
|
|
1
1
|
// Native install backend — GJS-runnable replacement for `npm install`.
|
|
2
2
|
//
|
|
3
3
|
// Pipeline: parse specs → resolve deps via @gjsify/npm-registry packuments and
|
|
4
|
-
// @gjsify/semver → download tarballs in parallel → extract into
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// @gjsify/semver → download tarballs in parallel → extract into node_modules/
|
|
5
|
+
// via @gjsify/tar. Output layout matches `npm install` so `runGjsBundle()`'s
|
|
6
|
+
// prebuild detection needs no branching. Hoisting is npm v3+: root placement
|
|
7
|
+
// unless the root holds an incompatible version, then nested under the requester.
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// PLATFORM FILTERING (os/cpu/libc) — the tree is RESOLVED for every platform and
|
|
10
|
+
// INSTALLED for one. A node the host cannot run is marked `inert` (npm's word):
|
|
11
|
+
// not downloaded, not extracted, still in `gjsify-lock.json` with its
|
|
12
|
+
// `os`/`cpu`/`libc`. A lockfile recording only what ONE host can install is a
|
|
13
|
+
// machine snapshot — commit it and a colleague on another OS gets a tree missing
|
|
14
|
+
// its binaries with no drift reported. So DECLARATIONS are locked and the VERDICT
|
|
15
|
+
// is recomputed per host (`applyPlatformFilter`).
|
|
13
16
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// which skips its download + extract, but it stays in `gjsify-lock.json` with
|
|
18
|
-
// its `os`/`cpu`/`libc` recorded. That split is deliberate and is the whole
|
|
19
|
-
// reason lockfiles are shareable: a lockfile containing only what ONE host can
|
|
20
|
-
// install is not a lockfile, it is a machine snapshot — commit it and every
|
|
21
|
-
// colleague on another OS gets a tree missing its binaries with no drift
|
|
22
|
-
// reported. So the DECLARATIONS are locked and the VERDICT is recomputed per
|
|
23
|
-
// host at install time (see applyPlatformFilter).
|
|
17
|
+
// Three invariants hold that up. Each is structural rather than documented,
|
|
18
|
+
// because the first draft broke the first two while reading as if it did not.
|
|
19
|
+
// The measured record is packages/infra/cli/AGENTS.md § `gjsify install`.
|
|
24
20
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// (`#fetchManifest` sets `fullMetadata: true`).
|
|
39
|
-
// 2. `optional` IS DERIVED, NEVER TRUSTED. It is recomputed as a FIXPOINT over
|
|
40
|
-
// the placed graph on BOTH paths (computeOptionalFlags), before it is
|
|
41
|
-
// persisted and before the platform verdict reads it. The draft promoted a
|
|
42
|
-
// node out of the optional set at the reuse site, one shot, which made the
|
|
43
|
-
// answer depend on BFS edge order: a genuinely required transitive dep
|
|
44
|
-
// reached first through an optional edge stayed flagged optional, was
|
|
45
|
-
// persisted that way, and was then silently skipped on every machine
|
|
46
|
-
// instead of raising EBADPLATFORM. npm computes the same flags as a
|
|
21
|
+
// 1. THE RESOLVE IS TARGET-BLIND — `resolveDeps` takes no target, so it cannot
|
|
22
|
+
// record a target-dependent declaration. The draft gated `libc` escalation
|
|
23
|
+
// on `target.os === 'linux'`, so a macOS-authored lockfile carried no `libc`
|
|
24
|
+
// for anything and a Linux colleague's `--immutable` got no libc filtering
|
|
25
|
+
// at all. The resolve now reads the FULL document for every package, as
|
|
26
|
+
// arborist does (`#fetchManifest` sets `fullMetadata: true`).
|
|
27
|
+
// 2. `optional` IS DERIVED, NEVER TRUSTED — a fixpoint over the placed graph on
|
|
28
|
+
// BOTH paths (`computeOptionalFlags`), before it is persisted and before the
|
|
29
|
+
// platform verdict reads it. The draft promoted at the reuse site, one shot,
|
|
30
|
+
// making the answer depend on BFS edge order: a genuinely required
|
|
31
|
+
// transitive dep reached first through an optional edge stayed flagged
|
|
32
|
+
// optional, was persisted that way, and was then silently skipped on every
|
|
33
|
+
// machine instead of raising EBADPLATFORM. npm computes the same flags as a
|
|
47
34
|
// fixpoint for the same reason (refs/npm-cli/workspaces/arborist/lib/
|
|
48
|
-
// calc-dep-flags.js
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// manifest exits 0, installs
|
|
61
|
-
// `@parcel/rust-linux-x64-gnu`, and records the other seven as
|
|
62
|
-
// `"optional": true` in its own lockfile. The rule was already applied to
|
|
63
|
-
// swallowed resolve failures (`assertRequiredEdgesResolved`: "optionality
|
|
64
|
-
// lives on the edge, not on its endpoints") and missing from the two walks
|
|
65
|
-
// that decide fatal-vs-inert, which is the worst place to have half of it.
|
|
66
|
-
// It costs a lockfile field (`optionalDependencies` per entry, v4): without
|
|
67
|
-
// it the fresh-resolve path and the lockfile path would disagree about the
|
|
68
|
-
// same tree, which is the split invariant 2 exists to close.
|
|
69
|
-
// WHY THE DEFECT LOOKED LIKE IT DID NOT EXIST: the same napi shape is
|
|
70
|
-
// overwhelmingly published in ONE block — measured on the latest of `oxlint`,
|
|
71
|
-
// `esbuild` and `rollup`, all three list their per-platform bindings in
|
|
72
|
-
// `optionalDependencies` only (zero overlap), so they installed correctly
|
|
73
|
-
// throughout and every mock corpus reaches for that shape too. The block a
|
|
74
|
-
// publisher chooses is not a property this installer may assume.
|
|
35
|
+
// calc-dep-flags.js).
|
|
36
|
+
// 3. OPTIONALITY BELONGS TO THE EDGE, not to the block the name came from. A
|
|
37
|
+
// name in BOTH `dependencies` and `optionalDependencies` is OPTIONAL (npm's
|
|
38
|
+
// documented override) — hence {@link requiredDepEntries} in every dep walk.
|
|
39
|
+
// `@parcel/rust@2.16.4` lists all eight of its napi platform packages in
|
|
40
|
+
// both blocks, so a `dependencies`-only walk made `@parcel/rust-darwin-x64`
|
|
41
|
+
// REQUIRED and every Linux install of a tree containing parcel died with
|
|
42
|
+
// EBADPLATFORM where `npm install` exits 0. It hid because that napi shape
|
|
43
|
+
// is overwhelmingly published in ONE block (`oxlint`, `esbuild`, `rollup`),
|
|
44
|
+
// which is what every mock corpus reaches for too. Costs a lockfile field
|
|
45
|
+
// (v4); without it the fresh-resolve and lockfile paths disagree about one
|
|
46
|
+
// tree.
|
|
75
47
|
//
|
|
76
|
-
// Out of scope (still deferred): peerDependencies validation,
|
|
77
|
-
//
|
|
48
|
+
// Out of scope (still deferred): peerDependencies validation, lifecycle scripts,
|
|
49
|
+
// git/file specs.
|
|
78
50
|
import * as fs from 'node:fs';
|
|
79
51
|
import * as path from 'node:path';
|
|
80
52
|
import * as os from 'node:os';
|
|
@@ -89,40 +61,38 @@ import { assertNativeBackendNodeVersion } from './node-version.js';
|
|
|
89
61
|
import { buildCmdShim, buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, isGjsBundlePath, parseShebang, pickBinMap, } from './bin-shim.js';
|
|
90
62
|
import { detectNativePackages } from './detect-native-packages.js';
|
|
91
63
|
import { badPlatformError, checkPlatform, declaresPlatform, describePlatformTarget, readPlatformForce, resolveHostPlatform, } from './platform-check.js';
|
|
92
|
-
// 16-wide download pool
|
|
93
|
-
// `max-conns-per-host` (
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// 16) use the same order of magnitude. Override with GJSIFY_INSTALL_CONCURRENCY.
|
|
64
|
+
// 16-wide download pool, matched to the shared Soup.Session's lifted
|
|
65
|
+
// `max-conns-per-host` (@gjsify/fetch `getSharedSession`) — without that lift
|
|
66
|
+
// libsoup's default of 2 caps real concurrency however wide the pool is. Same
|
|
67
|
+
// order of magnitude as npm (`maxsockets` 15) and pnpm (`network-concurrency` 16).
|
|
97
68
|
const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '16') || 16;
|
|
98
69
|
const LOCKFILE_NAME = 'gjsify-lock.json';
|
|
99
70
|
/**
|
|
100
|
-
* v3 adds the per-entry platform declaration (`os`/`cpu`/`libc`) plus
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
71
|
+
* v3 adds the per-entry platform declaration (`os`/`cpu`/`libc`) plus `optional`,
|
|
72
|
+
* which together let a foreign-platform package be recorded without being
|
|
73
|
+
* installed. v4 adds the per-entry `optionalDependencies` map, without which the
|
|
74
|
+
* optionality fixpoint cannot see WHICH of an entry's edges are optional and a
|
|
75
|
+
* name declared in both blocks comes out required (header note, invariant 3).
|
|
76
|
+
* Older lockfiles are still READ — see {@link readLockfile}.
|
|
106
77
|
*/
|
|
107
78
|
const LOCKFILE_VERSION = 4;
|
|
108
79
|
/**
|
|
109
|
-
* Lockfile versions this CLI can read. A v2
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
80
|
+
* Lockfile versions this CLI can read. A v2 file is a valid pin set that merely
|
|
81
|
+
* predates the platform fields; rejecting it (the old `!== LOCKFILE_VERSION →
|
|
82
|
+
* null` behaviour) discards the whole file and lets the following fresh resolve
|
|
83
|
+
* bump every `^`-range to the newest in-range version — the silent churn lockfile
|
|
84
|
+
* preservation exists to prevent, on every user's first install after a CLI
|
|
85
|
+
* upgrade.
|
|
115
86
|
*
|
|
116
|
-
* READING an older file is not
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* (still v2).
|
|
87
|
+
* READING an older file is not TRUSTING it for everything: only the current
|
|
88
|
+
* version short-circuits the resolve (see the branch below), so a v2/v3 file
|
|
89
|
+
* seeds version preservation and is then rewritten. `--immutable` must consume
|
|
90
|
+
* what it was handed verbatim, so there alone a pre-v4 file keeps its pre-v4 edge
|
|
91
|
+
* fidelity — a both-blocks optional dep is judged required, exactly as before
|
|
92
|
+
* this fix, which is an unchanged outcome rather than a new failure, and one
|
|
93
|
+
* plain `gjsify install` upgrades the file. Failing `--immutable` on an
|
|
94
|
+
* old-but-readable lockfile would instead break every CI whose committed file
|
|
95
|
+
* predates this CLI.
|
|
126
96
|
*/
|
|
127
97
|
const READABLE_LOCKFILE_VERSIONS = new Set([2, 3, LOCKFILE_VERSION]);
|
|
128
98
|
export async function installPackagesNative(opts) {
|
|
@@ -136,12 +106,12 @@ export async function installPackagesNative(opts) {
|
|
|
136
106
|
const npmrc = await loadNpmrc(opts);
|
|
137
107
|
const log = makeLogger(opts.verbose ?? false);
|
|
138
108
|
// Serialize every mutation of THIS prefix across processes (ADR 0001):
|
|
139
|
-
// concurrent installs into the same node_modules used to interleave
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
109
|
+
// concurrent installs into the same node_modules used to interleave `rmSync` +
|
|
110
|
+
// extract on shared destination dirs and tear the lockfile. Re-entrant within
|
|
111
|
+
// the process (a workspace install already holds the root-prefix lock, so this
|
|
112
|
+
// only bumps a refcount). Other prefixes proceed concurrently; the shared XDG
|
|
113
|
+
// caches stay lock-free because their tmp+rename writes are atomic
|
|
114
|
+
// (install-cache-fs.ts).
|
|
145
115
|
const lock = await acquireInstallLock(opts.prefix, { signal: opts.signal });
|
|
146
116
|
try {
|
|
147
117
|
return await installPackagesNativeLocked(opts, npmrc, log);
|
|
@@ -156,9 +126,9 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
156
126
|
const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
|
|
157
127
|
const existingLock = readLockfile(lockfilePath);
|
|
158
128
|
// The triple this install materialises FOR. Normally the running host;
|
|
159
|
-
// `--os/--cpu/--libc` (npm's config keys
|
|
160
|
-
//
|
|
161
|
-
//
|
|
129
|
+
// `--os/--cpu/--libc` (npm's config keys, read from the environment by
|
|
130
|
+
// `resolveHostPlatform`) override it, which is what makes the darwin/win32/musl
|
|
131
|
+
// branches reviewable from one machine.
|
|
162
132
|
const target = resolveHostPlatform();
|
|
163
133
|
const force = readPlatformForce(process.env);
|
|
164
134
|
log('install: platform target %s%s', describePlatformTarget(target), force ? ' (--force: checks bypassed)' : '');
|
|
@@ -166,18 +136,16 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
166
136
|
/** Did a resolve actually run? Only then is there something new to persist. */
|
|
167
137
|
let resolved = false;
|
|
168
138
|
/**
|
|
169
|
-
* Edges whose resolve FAILED and was tolerated because the edge looked
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
139
|
+
* Edges whose resolve FAILED and was tolerated because the edge looked optional
|
|
140
|
+
* when visited; re-judged against the fixpoint optionality below (see
|
|
141
|
+
* {@link assertRequiredEdgesResolved}). Empty on the lockfile paths, which
|
|
142
|
+
* resolve nothing.
|
|
173
143
|
*/
|
|
174
144
|
let skippedEdges = new Set();
|
|
175
145
|
if (opts.frozen) {
|
|
176
|
-
// --immutable / --frozen: lockfile is
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// honoring a stale lockfile would mask real dep churn (the original
|
|
180
|
-
// bug --immutable exists to catch).
|
|
146
|
+
// --immutable / --frozen: the lockfile is authoritative. Reject a missing,
|
|
147
|
+
// version-mismatched or drifted file — silently honouring a stale lockfile
|
|
148
|
+
// masks the real dep churn `--immutable` exists to catch.
|
|
181
149
|
if (!existingLock) {
|
|
182
150
|
throw new Error(`install: --immutable requires ${LOCKFILE_NAME} at ${opts.prefix} — none found. ` +
|
|
183
151
|
`Run \`gjsify install\` (without --immutable) to generate one and commit it.`);
|
|
@@ -200,43 +168,39 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
200
168
|
else {
|
|
201
169
|
// A resolve has to run (new/changed/removed dep, or no lockfile yet).
|
|
202
170
|
//
|
|
203
|
-
// A pre-v4 lockfile lands here too
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// install left a v3 file behind that would otherwise reproduce its own
|
|
214
|
-
// EBADPLATFORM on every later run without ever resolving again.
|
|
171
|
+
// A pre-v4 lockfile lands here too even when it matches the request: each
|
|
172
|
+
// bump added something the verdict is recomputed FROM, so a file lacking it
|
|
173
|
+
// cannot be consumed verbatim — pre-v3 entries carry no platform
|
|
174
|
+
// declaration (the foreign-platform tree would keep installing forever),
|
|
175
|
+
// pre-v4 entries no `optionalDependencies` map (the `@parcel/rust` shape
|
|
176
|
+
// fails the install; header note, invariant 3). One version-preserving
|
|
177
|
+
// resolve upgrades the file, and that matters beyond tidiness: the lockfile
|
|
178
|
+
// is written BEFORE the platform filter runs, so a failing install left a
|
|
179
|
+
// v3 file behind that would otherwise reproduce its own EBADPLATFORM
|
|
180
|
+
// forever without ever resolving again.
|
|
215
181
|
//
|
|
216
|
-
// Unless --refresh-lockfile was passed, seed
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// (and silently bumping transitive deps) on a one-package add.
|
|
182
|
+
// Unless --refresh-lockfile was passed, seed from the versions already
|
|
183
|
+
// pinned so unchanged deps keep them and only genuinely new/changed deps
|
|
184
|
+
// move — the npm/yarn/pnpm `install` default. Without it every `^`-range
|
|
185
|
+
// re-resolves to the newest registry version, churning the whole tree (and
|
|
186
|
+
// silently bumping transitives) on a one-package add.
|
|
222
187
|
const preferred = !opts.refreshLockfile && existingLock ? buildPreferredVersions(existingLock) : undefined;
|
|
223
188
|
log('install: resolving %d top-level spec(s) → %s%s', opts.specs.length, opts.prefix, preferred ? ` (preserving ${preferred.size} locked name(s))` : '');
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
189
|
+
// `target` is deliberately NOT passed: the resolve must produce the same
|
|
190
|
+
// tree and the same recorded declarations on every machine (header note,
|
|
191
|
+
// invariant 1), and giving it nothing target-shaped to read is what keeps
|
|
192
|
+
// that true.
|
|
228
193
|
const resolveResult = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress, preferred, opts.workspaceNames, opts.specOrigins, opts.optionalSpecs);
|
|
229
194
|
nodes = resolveResult.nodes;
|
|
230
195
|
skippedEdges = resolveResult.skippedEdges;
|
|
231
196
|
resolved = true;
|
|
232
197
|
}
|
|
233
|
-
// `optional` is DERIVED here
|
|
234
|
-
// `applyPlatformFilter` is: the answer must not depend on
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// the walk).
|
|
198
|
+
// `optional` is DERIVED here on BOTH paths for the same reason
|
|
199
|
+
// `applyPlatformFilter` is: the answer must not depend on how the tree arrived,
|
|
200
|
+
// and a flag read back from a file is an INPUT nothing checked. Order matters —
|
|
201
|
+
// before `writeLockfile` (so the persisted flag is final), before the platform
|
|
202
|
+
// verdict (which reads it for fatal vs. inert), and before the workspace filter
|
|
203
|
+
// below (which removes nodes and would truncate the walk).
|
|
240
204
|
computeOptionalFlags(nodes, requiredTopLevelNames(opts.specs, opts.optionalSpecs), log);
|
|
241
205
|
assertRequiredEdgesResolved(nodes, skippedEdges);
|
|
242
206
|
if (resolved && opts.lockfile) {
|
|
@@ -245,15 +209,13 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
245
209
|
writeLockfile(lockfilePath, opts.specs, nodes);
|
|
246
210
|
log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
|
|
247
211
|
}
|
|
248
|
-
// A package
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// data-loss guard would otherwise abort the whole install). This keeps
|
|
212
|
+
// A package named after one of the monorepo's own workspaces is provided by a
|
|
213
|
+
// workspace symlink (wired by `workspaceInstall`), NOT by a registry tarball —
|
|
214
|
+
// even when the lockfile or a transitive edge pins a same-named published
|
|
215
|
+
// version. Dropping those nodes keeps `extractOne` from `rm`ing the workspace
|
|
216
|
+
// source symlink (its data-loss guard would abort the whole install), and keeps
|
|
254
217
|
// `--immutable` robust against a committed lockfile that still carries such
|
|
255
|
-
//
|
|
256
|
-
// `resolveDeps`); a fresh resolve additionally skips them at the source.
|
|
218
|
+
// entries, since that path never runs `resolveDeps`.
|
|
257
219
|
if (opts.workspaceNames && opts.workspaceNames.size > 0) {
|
|
258
220
|
const before = nodes.length;
|
|
259
221
|
nodes = nodes.filter((n) => !opts.workspaceNames.has(n.name));
|
|
@@ -271,9 +233,8 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
271
233
|
await linkBins(installable, opts.prefix, log);
|
|
272
234
|
warnMissingNativeBuilds(installable, opts.prefix, log);
|
|
273
235
|
log('install: done');
|
|
274
|
-
//
|
|
275
|
-
// package.json
|
|
276
|
-
// behavior). Sub-deps are not included.
|
|
236
|
+
// Top-level requested packages only, so callers can write the resolved version
|
|
237
|
+
// back into package.json (`npm install --save`).
|
|
277
238
|
return topLevelResolutions(opts.specs, nodes);
|
|
278
239
|
}
|
|
279
240
|
function errMsg(err) {
|
|
@@ -282,29 +243,26 @@ function errMsg(err) {
|
|
|
282
243
|
return String(err);
|
|
283
244
|
}
|
|
284
245
|
/**
|
|
285
|
-
* Split a resolved tree into what THIS target can install, marking the rest
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* final once every edge that could reach it has been visited.
|
|
246
|
+
* Split a resolved tree into what THIS target can install, marking the rest inert.
|
|
247
|
+
* A post-pass rather than a filter during the walk (as in arborist's
|
|
248
|
+
* `refs/npm-cli/workspaces/arborist/lib/arborist/build-ideal-tree.js`) for two
|
|
249
|
+
* reasons: the verdict must be identical whether the tree came from a fresh
|
|
250
|
+
* resolve or from `gjsify-lock.json`, and a node's optionality is only final once
|
|
251
|
+
* every edge that could reach it has been visited.
|
|
292
252
|
*
|
|
293
253
|
* The two outcomes are npm's, including the asymmetry around `force`:
|
|
294
|
-
* - REQUIRED + incompatible → EBADPLATFORM. A required
|
|
295
|
-
*
|
|
296
|
-
* `pkgid`/`current`/`required`
|
|
297
|
-
*
|
|
298
|
-
* - OPTIONAL + incompatible → inert: not downloaded, not extracted, still in
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* else.
|
|
254
|
+
* - REQUIRED + incompatible → EBADPLATFORM. A required dep the host cannot run
|
|
255
|
+
* is a broken install, not a smaller one, so it fails loudly with
|
|
256
|
+
* `pkgid`/`current`/`required`. `--force` suppresses it (the user is asserting
|
|
257
|
+
* something about their own machine).
|
|
258
|
+
* - OPTIONAL + incompatible → inert: not downloaded, not extracted, still in the
|
|
259
|
+
* lockfile. `--force` does NOT lift this — npm says so in as many words ("We
|
|
260
|
+
* ignore the --force and --engine-strict flags"), and forcing an optional
|
|
261
|
+
* binary that cannot load buys a download and nothing else.
|
|
303
262
|
*
|
|
304
|
-
* The skip line
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* GB arrived with no line saying it should not have.
|
|
263
|
+
* The skip line carries npm's `current`/`required` payload so `--verbose` recovers
|
|
264
|
+
* WHY a package is absent. Silence here is what made the original defect invisible
|
|
265
|
+
* in the other direction: 3.67 GB arrived with no line saying it should not have.
|
|
308
266
|
*/
|
|
309
267
|
export function applyPlatformFilter(nodes, target, force, log) {
|
|
310
268
|
const installable = [];
|
|
@@ -343,15 +301,12 @@ export function applyPlatformFilter(nodes, target, force, log) {
|
|
|
343
301
|
/**
|
|
344
302
|
* Warn about a native package that installed WITHOUT a usable binary.
|
|
345
303
|
*
|
|
346
|
-
*
|
|
347
|
-
* `
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* 0.21.0` case: installed, but `node_gi.node` absent → the consumer hit a runtime
|
|
353
|
-
* failure with no hint). Surface it at install time with an actionable line. Pure
|
|
354
|
-
* detection — no script is run, so node-free-ness is preserved.
|
|
304
|
+
* This install is node-free and does NOT run `install`/`postinstall` scripts —
|
|
305
|
+
* `node-gyp rebuild` would need Node plus a C++ toolchain. Such a package works
|
|
306
|
+
* only if it SHIPS a prebuild (`prebuilds/<platform>-<arch>/…`) or is built by
|
|
307
|
+
* hand; with neither it silently loads no binary, as `@gjsify/node-gi 0.21.0` did
|
|
308
|
+
* (installed, `node_gi.node` absent, consumer hit a runtime failure with no hint).
|
|
309
|
+
* Pure detection — no script is run, so node-free-ness is preserved.
|
|
355
310
|
*/
|
|
356
311
|
export function warnMissingNativeBuilds(nodes, prefix, log) {
|
|
357
312
|
const plat = process.platform;
|
|
@@ -404,8 +359,7 @@ export function warnMissingNativeBuilds(nodes, prefix, log) {
|
|
|
404
359
|
}
|
|
405
360
|
}
|
|
406
361
|
function topLevelResolutions(specs, nodes) {
|
|
407
|
-
// Top-level installs live at `node_modules/<name
|
|
408
|
-
// a name → root-node lookup limited to the top-level set.
|
|
362
|
+
// Top-level installs live at `node_modules/<name>`, never nested.
|
|
409
363
|
const byName = new Map();
|
|
410
364
|
for (const n of nodes) {
|
|
411
365
|
if (n.installPath === `node_modules/${n.name}`)
|
|
@@ -441,49 +395,39 @@ function parseSpecName(spec) {
|
|
|
441
395
|
* the root has an incompatible version. Subsequent dependents of the
|
|
442
396
|
* same conflicting version reuse the nested placement.
|
|
443
397
|
*
|
|
444
|
-
* The walk is BFS over (requester, depName, depRange) edges. Top-level
|
|
445
|
-
*
|
|
446
|
-
* the root. Each placement returns a `ResolvedNode` whose `installPath`
|
|
447
|
-
* captures where it lives in the tree.
|
|
398
|
+
* The walk is BFS over (requester, depName, depRange) edges. Top-level specs are
|
|
399
|
+
* seeded with a synthetic `null` requester so they hoist to the root.
|
|
448
400
|
*
|
|
449
|
-
* TAKES NO PLATFORM TARGET,
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
* gated on `target.os === 'linux'` — and a macOS-authored lockfile ended up with
|
|
455
|
-
* no `libc` recorded anywhere, i.e. unusable for filtering on the Linux hosts it
|
|
456
|
-
* was committed for. Do not reintroduce the parameter: the resolve now reads the
|
|
457
|
-
* full document unconditionally, which needs no target and no heuristic.
|
|
401
|
+
* TAKES NO PLATFORM TARGET, a load-bearing absence (header note, invariant 1): the
|
|
402
|
+
* tree and the `os`/`cpu`/`libc` it records must be identical on every machine, so the
|
|
403
|
+
* platform verdict is a separate post-pass this function cannot influence. Do not
|
|
404
|
+
* reintroduce the parameter — reading the full document unconditionally needs neither
|
|
405
|
+
* a target nor a heuristic.
|
|
458
406
|
*/
|
|
459
407
|
async function resolveDeps(specs, npmrc, log, overrides, skipDeps, signal, progress,
|
|
460
408
|
/**
|
|
461
|
-
* Lockfile-preservation oracle: `name → versions already pinned
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* (first install, or an explicit `--refresh-lockfile`).
|
|
409
|
+
* Lockfile-preservation oracle: `name → versions already pinned`. A pinned
|
|
410
|
+
* version satisfying the edge's range is reused instead of the newest registry
|
|
411
|
+
* match. Undefined ⇒ always pick the newest in-range version (first install, or
|
|
412
|
+
* `--refresh-lockfile`).
|
|
466
413
|
*/
|
|
467
414
|
preferredVersions,
|
|
468
415
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
* the lockfile.
|
|
416
|
+
* The monorepo's own workspace packages. Such an edge is satisfied by the
|
|
417
|
+
* workspace symlink, so it is skipped here — the published version and its
|
|
418
|
+
* subtree never enter the resolved tree or the lockfile.
|
|
473
419
|
*/
|
|
474
420
|
workspaceNames,
|
|
475
421
|
/**
|
|
476
|
-
* Requester labels for top-level specs (`"<name>@<range>"` → workspace
|
|
477
|
-
*
|
|
478
|
-
* version-conflict warning.
|
|
422
|
+
* Requester labels for top-level specs (`"<name>@<range>"` → workspace names),
|
|
423
|
+
* see `InstallOptions.specOrigins`. Only attributes the conflict warning.
|
|
479
424
|
*/
|
|
480
425
|
specOrigins,
|
|
481
426
|
/**
|
|
482
427
|
* Names of top-level specs declared as `optionalDependencies` — see
|
|
483
|
-
* {@link NativeInstallOptions.optionalSpecs}.
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* {@link computeOptionalFlags}, not from this walk.
|
|
428
|
+
* {@link NativeInstallOptions.optionalSpecs}. Seeds the edge `required: false`,
|
|
429
|
+
* so a resolve failure is skipped rather than fatal. The FINAL flag still comes
|
|
430
|
+
* from {@link computeOptionalFlags}, not from this walk.
|
|
487
431
|
*/
|
|
488
432
|
optionalSpecs) {
|
|
489
433
|
progress?.beginPhase('resolve', specs.length);
|
|
@@ -503,31 +447,26 @@ optionalSpecs) {
|
|
|
503
447
|
const cached = packumentCache.get(name);
|
|
504
448
|
if (cached)
|
|
505
449
|
return cached;
|
|
506
|
-
// FULL document (`accept: application/json`), not the abbreviated
|
|
507
|
-
//
|
|
508
|
-
//
|
|
450
|
+
// FULL document (`accept: application/json`), not the abbreviated "corgi"
|
|
451
|
+
// install document — the same choice arborist makes for every dependency it
|
|
452
|
+
// resolves (`#fetchManifest` sets `fullMetadata: true`,
|
|
509
453
|
// refs/npm-cli/workspaces/arborist/lib/arborist/build-ideal-tree.js).
|
|
510
454
|
//
|
|
511
|
-
// WHY, and why no cheaper trigger exists: the registry omits `libc` from
|
|
512
|
-
//
|
|
455
|
+
// WHY, and why no cheaper trigger exists: the registry omits `libc` from the
|
|
456
|
+
// abbreviated body (measured — `@rollup/rollup-linux-x64-musl` returns
|
|
513
457
|
// `{os,cpu}` under the corgi accept header and `{os,cpu,libc}` under
|
|
514
|
-
// `application/json`), so
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
// install time.
|
|
458
|
+
// `application/json`), so that body can never PROVE a version has no libc
|
|
459
|
+
// restriction, and every "escalate only where it can matter" rule has a
|
|
460
|
+
// hole. The draft's rule — escalate when the corgi entry declares `os` or
|
|
461
|
+
// `cpu` — had exactly the hole that matters: the nine `@gjsify/*` bridges
|
|
462
|
+
// declare `libc: ["glibc"]` with NO `os`/`cpu`, so on Alpine the installer
|
|
463
|
+
// would have handed a glibc-only prebuild to a musl host and failed at
|
|
464
|
+
// `dlopen` instead of at install time.
|
|
522
465
|
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
// was ~1.15× and 1.12× the requests, i.e. this change costs ~10% more
|
|
528
|
-
// metadata bytes and ~11% FEWER round-trips than the shape it replaces.
|
|
529
|
-
// That is the whole trade: one authoritative document per package instead
|
|
530
|
-
// of two documents and a heuristic that cannot be made sound.
|
|
466
|
+
// The trade is one authoritative document per package instead of two plus an
|
|
467
|
+
// unsound heuristic; measured over 15 representative deps of this workspace,
|
|
468
|
+
// the full document costs 1.26× the abbreviated one in transfer bytes and
|
|
469
|
+
// ~11% FEWER round-trips than corgi-plus-escalate.
|
|
531
470
|
const fresh = fetchPackumentWithDiskCache(name, npmrc, log, signal, 'full');
|
|
532
471
|
packumentCache.set(name, fresh);
|
|
533
472
|
return fresh;
|
|
@@ -538,15 +477,14 @@ optionalSpecs) {
|
|
|
538
477
|
const root = new Map();
|
|
539
478
|
/** See {@link ResolveResult.skippedEdges}. Keyed by {@link edgeKey}. */
|
|
540
479
|
const skippedEdges = new Set();
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
// surfaced loudly after the resolve (see emitTopLevelConflictWarnings).
|
|
480
|
+
// Bookkeeping for the version-conflict warning: `name → (applied range →
|
|
481
|
+
// requester labels)`. Only TOP-LEVEL specs participate — conflicting transitive
|
|
482
|
+
// edges are resolved correctly by nesting, but conflicting top-level specs (what
|
|
483
|
+
// `workspaceInstall` produces when two workspaces declare incompatible ranges of
|
|
484
|
+
// the same external dep) all compete for the single root slot and the resolver
|
|
485
|
+
// silently keeps one version for everyone. Per-workspace dedup is Phase D.8
|
|
486
|
+
// (status/open-todos.md); until it lands the conflict is surfaced loudly after
|
|
487
|
+
// the resolve (see emitTopLevelConflictWarnings).
|
|
550
488
|
const topLevelRanges = new Map();
|
|
551
489
|
const queue = specs.map(parseSpec).map((s) => {
|
|
552
490
|
const range = applyOverride(s.name, s.range);
|
|
@@ -569,26 +507,19 @@ optionalSpecs) {
|
|
|
569
507
|
required: !optionalSpecs?.has(s.name),
|
|
570
508
|
};
|
|
571
509
|
});
|
|
572
|
-
// Wave-based BFS
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
// lift in `@gjsify/fetch`. `packumentCache` still guarantees ≤1 fetch per
|
|
584
|
-
// unique name across the whole resolve, so prefetching every wave name
|
|
585
|
-
// fetches exactly the same SET as before, just batched.
|
|
510
|
+
// Wave-based BFS: drain the current queue level, prefetch its not-yet-cached
|
|
511
|
+
// packuments in bounded parallel, then place SERIALLY in FIFO order. Newly
|
|
512
|
+
// discovered children always append, so "the current queue contents" is exactly
|
|
513
|
+
// one BFS level and the serial pass visits edges in the same order a single-edge
|
|
514
|
+
// loop would — which is what keeps `decidePlacement`'s order-dependent hoist/nest
|
|
515
|
+
// decisions, and hence the lockfile, unchanged. Only the network moved: N
|
|
516
|
+
// sequential ~RTT packument fetches per level collapse into one batch, the
|
|
517
|
+
// dominant cold-install cost. `packumentCache` still guarantees ≤1 fetch per
|
|
518
|
+
// unique name, so the same SET is fetched, just batched. Pointless before the
|
|
519
|
+
// `max-conns-per-host` lift in `@gjsify/fetch` — libsoup's default of 2 would
|
|
520
|
+
// have throttled it anyway.
|
|
586
521
|
while (queue.length > 0) {
|
|
587
522
|
const wave = queue.splice(0, queue.length);
|
|
588
|
-
// Prefetch every not-yet-cached packument referenced in this level.
|
|
589
|
-
// Names already in `packumentCache` (resolved in an earlier wave, or a
|
|
590
|
-
// duplicate within this one) are skipped — the de-dup keeps the batch
|
|
591
|
-
// to genuinely-new names.
|
|
592
523
|
const toPrefetch = [];
|
|
593
524
|
const queuedForFetch = new Set();
|
|
594
525
|
for (const edge of wave) {
|
|
@@ -598,60 +529,47 @@ optionalSpecs) {
|
|
|
598
529
|
toPrefetch.push(edge.name);
|
|
599
530
|
}
|
|
600
531
|
await prefetchPackuments(toPrefetch, fetchPkg, signal);
|
|
601
|
-
// Serial placement pass
|
|
602
|
-
//
|
|
603
|
-
// cache instead of blocking on a fresh round-trip.
|
|
532
|
+
// Serial placement pass; every `fetchPkg` here resolves from the warmed cache
|
|
533
|
+
// instead of blocking on a fresh round-trip.
|
|
604
534
|
for (let wi = 0; wi < wave.length; wi++) {
|
|
605
535
|
const edge = wave[wi];
|
|
606
|
-
// A workspace member is satisfied by its
|
|
607
|
-
//
|
|
608
|
-
//
|
|
536
|
+
// A workspace member is satisfied by its symlink, never by a registry
|
|
537
|
+
// tarball — skip so the published version and its subtree never enter the
|
|
538
|
+
// resolved tree or the lockfile.
|
|
609
539
|
if (workspaceNames?.has(edge.name))
|
|
610
540
|
continue;
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
541
|
+
// Is a satisfying placement already visible from the requester's
|
|
542
|
+
// `node_modules` lookup? npm's resolver does the same — each level of
|
|
543
|
+
// nesting acts as a fallback.
|
|
614
544
|
const visible = findVisible(edge.from, edge.name, byPath);
|
|
615
545
|
if (visible && satisfiesRange(visible.version, edge.range)) {
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
// order-dependent answer (the reviewed defect). Optionality is
|
|
623
|
-
// recomputed as a fixpoint over the finished graph instead —
|
|
624
|
-
// {@link computeOptionalFlags}, which is order-independent by
|
|
625
|
-
// construction and is the only writer of the final flag.
|
|
546
|
+
// Reuse; no new install. NOTHING IS PROMOTED HERE: setting
|
|
547
|
+
// `visible.optional = false` on a required edge is the obvious move
|
|
548
|
+
// and it is wrong, because the node's own dep edges were already
|
|
549
|
+
// queued with the STALE flag, so its children stay optional and the
|
|
550
|
+
// one-shot promotion answers by edge order (the reviewed defect).
|
|
551
|
+
// {@link computeOptionalFlags} is the only writer of the final flag.
|
|
626
552
|
continue;
|
|
627
553
|
}
|
|
628
|
-
// No compatible
|
|
629
|
-
//
|
|
630
|
-
//
|
|
554
|
+
// No compatible placement. Resolve a version, preferring one already
|
|
555
|
+
// pinned when it satisfies the range so an add does not bump unchanged
|
|
556
|
+
// deps.
|
|
631
557
|
let version = null;
|
|
632
558
|
try {
|
|
633
559
|
const packument = await fetchPkg(edge.name);
|
|
634
560
|
const preferred = preferredVersionFor(preferredVersions?.get(edge.name), edge.range);
|
|
635
561
|
version = pickVersion(packument, edge.range, preferred);
|
|
636
562
|
if (!version) {
|
|
637
|
-
// Throw even for an optional edge and let the catch below
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
// silent copy of that decision — it skipped with no log line
|
|
642
|
-
// at all and left nothing for the post-pass to re-judge.
|
|
563
|
+
// Throw even for an optional edge and let the catch below decide:
|
|
564
|
+
// ONE place knows what a tolerated failure costs (a log line and a
|
|
565
|
+
// `skippedEdges` entry). A bare `if (!edge.required) continue` here
|
|
566
|
+
// skipped silently and left nothing for the post-pass to re-judge.
|
|
643
567
|
throw new Error(`No version of ${edge.name} satisfies ${edge.range}`);
|
|
644
568
|
}
|
|
645
569
|
const v = packument.versions[version];
|
|
646
570
|
if (!v) {
|
|
647
571
|
throw new Error(`Packument for ${edge.name} promised ${version} but no entry exists`);
|
|
648
572
|
}
|
|
649
|
-
// Decision: hoist to root, or nest under the requester?
|
|
650
|
-
// - Hoist iff the root has no conflicting placement (i.e. the
|
|
651
|
-
// root slot for `name` is empty OR holds the same version).
|
|
652
|
-
// - Otherwise nest. Top-level specs (from === null) always
|
|
653
|
-
// hoist; the resolver guarantees they never conflict with
|
|
654
|
-
// each other because the input set is checked once.
|
|
655
573
|
const installPath = decidePlacement(edge.from, edge.name, version, root);
|
|
656
574
|
const node = {
|
|
657
575
|
name: edge.name,
|
|
@@ -662,14 +580,13 @@ optionalSpecs) {
|
|
|
662
580
|
dependencies: v.dependencies ?? {},
|
|
663
581
|
optionalDependencies: v.optionalDependencies ?? {},
|
|
664
582
|
bin: v.bin,
|
|
665
|
-
// Complete as published
|
|
666
|
-
//
|
|
667
|
-
// second fetch or per-package heuristic is involved.
|
|
583
|
+
// Complete as published: `fetchPkg` reads the FULL document, so all
|
|
584
|
+
// three fields come from one body — no second fetch, no heuristic.
|
|
668
585
|
platform: platformDeclarationOf(v),
|
|
669
|
-
// Forward guess only; `computeOptionalFlags` overwrites it.
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
586
|
+
// Forward guess only; `computeOptionalFlags` overwrites it. Still
|
|
587
|
+
// worth setting, because the child edges queued below read it, which
|
|
588
|
+
// is what keeps a failure under a plainly-optional subtree non-fatal
|
|
589
|
+
// at the point of failure.
|
|
673
590
|
optional: !edge.required,
|
|
674
591
|
};
|
|
675
592
|
byPath.set(installPath, node);
|
|
@@ -677,9 +594,8 @@ optionalSpecs) {
|
|
|
677
594
|
root.set(edge.name, node);
|
|
678
595
|
}
|
|
679
596
|
log('resolve: %s@%s ← %s (at %s)', edge.name, version, edge.range, installPath);
|
|
680
|
-
// Moving soft-total: resolved so far + edges
|
|
681
|
-
//
|
|
682
|
-
// converging-estimate pattern yarn/pnpm use.
|
|
597
|
+
// Moving soft-total: resolved so far + edges left in this wave +
|
|
598
|
+
// children queued for the next. The converging estimate yarn/pnpm use.
|
|
683
599
|
progress?.update({
|
|
684
600
|
phase: 'resolve',
|
|
685
601
|
current: byPath.size,
|
|
@@ -688,25 +604,22 @@ optionalSpecs) {
|
|
|
688
604
|
});
|
|
689
605
|
if (!skipDeps) {
|
|
690
606
|
// REQUIRED edges only — a name this package also lists in
|
|
691
|
-
// `optionalDependencies` is queued by the loop below
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
607
|
+
// `optionalDependencies` is queued once by the loop below, as
|
|
608
|
+
// optional (header note, invariant 3). Queuing it here too would
|
|
609
|
+
// place it with a `required: true` forward guess, making a resolve
|
|
610
|
+
// failure fatal and, via the fixpoint, its incompatible platform
|
|
611
|
+
// siblings fatal too.
|
|
696
612
|
for (const [depName, depRange] of requiredDepEntries(node)) {
|
|
697
613
|
queue.push({
|
|
698
614
|
from: installPath,
|
|
699
615
|
name: depName,
|
|
700
|
-
// A dependency of an OPTIONAL node inherits its
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
// would raise EBADPLATFORM for a package nothing on
|
|
708
|
-
// this host will ever load, i.e. fail every Linux
|
|
709
|
-
// install over a darwin-only optional dep.
|
|
616
|
+
// A dependency of an OPTIONAL node inherits its optionality
|
|
617
|
+
// (npm's `optionalSet`). Load-bearing for the platform check:
|
|
618
|
+
// foreign-platform optional packages are resolved on purpose
|
|
619
|
+
// to keep the lockfile portable, so their own required deps
|
|
620
|
+
// get visited too. Treating darwin-only `fsevents`' subtree as
|
|
621
|
+
// required would fail every Linux install with EBADPLATFORM
|
|
622
|
+
// over a package nothing on the host will ever load.
|
|
710
623
|
range: applyOverride(depName, depRange),
|
|
711
624
|
required: !node.optional,
|
|
712
625
|
});
|
|
@@ -722,15 +635,14 @@ optionalSpecs) {
|
|
|
722
635
|
}
|
|
723
636
|
}
|
|
724
637
|
catch (e) {
|
|
725
|
-
// Optional deps that fail to resolve are skipped
|
|
726
|
-
//
|
|
638
|
+
// Optional deps that fail to resolve are skipped (yarn/npm behaviour);
|
|
639
|
+
// required deps re-throw.
|
|
727
640
|
if (!edge.required) {
|
|
728
|
-
// RECORDED, not merely logged: `edge.required` is the walk's
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
// BFS order happened to reach it through an optional edge first.
|
|
641
|
+
// RECORDED, not merely logged: `edge.required` is the walk's forward
|
|
642
|
+
// guess and a later required edge can still make this node mandatory.
|
|
643
|
+
// `assertRequiredEdgesResolved` re-judges every entry against the
|
|
644
|
+
// fixpoint flags, so a genuinely required dependency cannot go missing
|
|
645
|
+
// just because BFS reached it through an optional edge first.
|
|
734
646
|
skippedEdges.add(edgeKey(edge.from, edge.name));
|
|
735
647
|
log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, errMsg(e));
|
|
736
648
|
continue;
|
|
@@ -741,24 +653,20 @@ optionalSpecs) {
|
|
|
741
653
|
}
|
|
742
654
|
progress?.endPhase('resolve');
|
|
743
655
|
emitTopLevelConflictWarnings(topLevelRanges, root);
|
|
744
|
-
// NO declaration post-pass here, deliberately
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// have (header note, invariant 1). Reintroducing the pass means
|
|
750
|
-
// reintroducing the parameter, which is how a macOS-authored lockfile ended
|
|
751
|
-
// up with no `libc` recorded for anyone.
|
|
656
|
+
// NO declaration post-pass here, deliberately: `fetchPkg` read the FULL document for
|
|
657
|
+
// every package, so each `platform` is complete as published. The draft's second
|
|
658
|
+
// `libc`-only fetch sat at exactly this line and needed the platform target to
|
|
659
|
+
// decide when to fire — reintroducing the pass reintroduces the one argument this
|
|
660
|
+
// function must never have (header note, invariant 1).
|
|
752
661
|
//
|
|
753
|
-
// `optional` is likewise NOT finalised here
|
|
754
|
-
//
|
|
755
|
-
// the call site, on this path and on the lockfile path alike.
|
|
662
|
+
// `optional` is likewise NOT finalised here; these flags are still the walk's
|
|
663
|
+
// forward guess, overwritten by `computeOptionalFlags` at the call site.
|
|
756
664
|
return { nodes: Array.from(byPath.values()), skippedEdges };
|
|
757
665
|
}
|
|
758
666
|
/**
|
|
759
667
|
* The platform declaration a packument version carries, or undefined when it
|
|
760
|
-
* declares none (the common case —
|
|
761
|
-
*
|
|
668
|
+
* declares none (the common case — undefined keeps the node and the lockfile entry
|
|
669
|
+
* minimal).
|
|
762
670
|
*/
|
|
763
671
|
function platformDeclarationOf(version) {
|
|
764
672
|
const declaration = { os: version.os, cpu: version.cpu, libc: version.libc };
|
|
@@ -768,19 +676,17 @@ function platformDeclarationOf(version) {
|
|
|
768
676
|
* Identity of one dependency EDGE: the requester's `installPath` (empty string
|
|
769
677
|
* for the project root) plus the dependency name.
|
|
770
678
|
*
|
|
771
|
-
* The PAIR is the identity, never the name alone
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
679
|
+
* The PAIR is the identity, never the name alone: the same unresolvable name can be
|
|
680
|
+
* legitimately absent below an optional subtree and a hard error below a required
|
|
681
|
+
* one, and only the requester says which — a `skippedEdges` keyed by name would
|
|
682
|
+
* collapse the two and answer whichever it saw last.
|
|
775
683
|
*
|
|
776
|
-
* `\n`
|
|
777
|
-
*
|
|
778
|
-
*
|
|
684
|
+
* `\n` separates because no npm package name and no install path can contain one
|
|
685
|
+
* (npm rejects control characters in names, and every path here is built out of
|
|
686
|
+
* names), so the key round-trips through {@link parseEdgeKey}
|
|
779
687
|
* unambiguously. Deliberately NOT `\u0000`, the other obvious choice: the GJS
|
|
780
|
-
* bundle minifier rewrites that escape into a raw NUL byte and GJS then refuses
|
|
781
|
-
*
|
|
782
|
-
* that only fails once this file is bundled for the runtime it ships on is not
|
|
783
|
-
* worth the theoretical tidiness.
|
|
688
|
+
* bundle minifier rewrites that escape into a raw NUL byte and GJS then refuses to
|
|
689
|
+
* parse the bundle at all ("template literal not terminated").
|
|
784
690
|
*/
|
|
785
691
|
function edgeKey(from, name) {
|
|
786
692
|
return `${from ?? ''}\n${name}`;
|
|
@@ -792,19 +698,19 @@ function parseEdgeKey(key) {
|
|
|
792
698
|
return { from: from === '' ? null : from, name: key.slice(sep + 1) };
|
|
793
699
|
}
|
|
794
700
|
/**
|
|
795
|
-
* Seed set for {@link computeOptionalFlags}: the NAMES of the top-level specs
|
|
796
|
-
*
|
|
797
|
-
*
|
|
701
|
+
* Seed set for {@link computeOptionalFlags}: the NAMES of the top-level specs the
|
|
702
|
+
* project did not declare optional. Everything else must EARN its required status
|
|
703
|
+
* by being reachable from one of these.
|
|
798
704
|
*
|
|
799
705
|
* The parse is load-bearing. `specs` are flat `"<name>@<range>"` strings while
|
|
800
706
|
* `optionalSpecs` holds bare NAMES (the range differs per requester — see
|
|
801
|
-
* {@link NativeInstallOptions.optionalSpecs}), so testing a spec string against
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
707
|
+
* {@link NativeInstallOptions.optionalSpecs}), so testing a spec string against the
|
|
708
|
+
* set is a lookup that can never hit: every top-level optionalDependency would be
|
|
709
|
+
* seeded REQUIRED and the `optionalDependencies: { fsevents }` shape would fail
|
|
710
|
+
* every Linux install with EBADPLATFORM instead of thinning. An earlier version
|
|
711
|
+
* compared the two directly and did exactly that; pinned by the "incompatible
|
|
712
|
+
* OPTIONAL top-level dep is skipped, not fatal" case in
|
|
713
|
+
* `tests/e2e/install-platform-filter`.
|
|
808
714
|
*/
|
|
809
715
|
function requiredTopLevelNames(specs, optionalSpecs) {
|
|
810
716
|
const names = new Set();
|
|
@@ -817,27 +723,21 @@ function requiredTopLevelNames(specs, optionalSpecs) {
|
|
|
817
723
|
return names;
|
|
818
724
|
}
|
|
819
725
|
/**
|
|
820
|
-
* A node's REQUIRED dependency edges: its `dependencies
|
|
821
|
-
* `optionalDependencies` also lists. The
|
|
822
|
-
*
|
|
823
|
-
* cannot drift.
|
|
726
|
+
* A node's REQUIRED dependency edges: its `dependencies` minus every name its
|
|
727
|
+
* `optionalDependencies` also lists. The ONE place that decides an edge's kind —
|
|
728
|
+
* both the resolve walk and the optionality fixpoint read it, so they cannot drift.
|
|
824
729
|
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
* it's usually best to only put in one place" (npm's package.json docs). Measured
|
|
828
|
-
* on `@parcel/rust@2.16.4`, which lists all eight per-platform napi packages in
|
|
829
|
-
* both blocks: `npm install` exits 0, installs `@parcel/rust-linux-x64-gnu` only,
|
|
830
|
-
* and writes `"optional": true` for all eight in its `package-lock.json`.
|
|
730
|
+
* The subtraction is npm's rule, not ours: "entries in optionalDependencies will
|
|
731
|
+
* override entries of the same name in dependencies" (npm's package.json docs).
|
|
831
732
|
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
733
|
+
* NOT MERELY A DEDUP: the kind decides what an incompatible `os`/`cpu`/`libc` MEANS
|
|
734
|
+
* (fatal vs. inert in `applyPlatformFilter`) and whether a failed resolve is
|
|
735
|
+
* tolerated. Reading `dependencies` alone does not just visit a name twice, it
|
|
736
|
+
* promotes a foreign-platform binary to a required dependency and fails installs npm
|
|
737
|
+
* completes — the header note's invariant 3.
|
|
837
738
|
*
|
|
838
|
-
*
|
|
839
|
-
* `
|
|
840
|
-
* which is what {@link resolveDeps}'s second queue loop covers.
|
|
739
|
+
* Deliberately NOT symmetric: a name in `optionalDependencies` alone is optional (no
|
|
740
|
+
* `dependencies` entry to override), covered by {@link resolveDeps}'s second queue loop.
|
|
841
741
|
*/
|
|
842
742
|
export function requiredDepEntries(node) {
|
|
843
743
|
const entries = [];
|
|
@@ -849,51 +749,38 @@ export function requiredDepEntries(node) {
|
|
|
849
749
|
return entries;
|
|
850
750
|
}
|
|
851
751
|
/**
|
|
852
|
-
* Recompute every node's `optional` flag as a FIXPOINT over the placed graph.
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
752
|
+
* Recompute every node's `optional` flag as a FIXPOINT over the placed graph. The
|
|
753
|
+
* only writer of the final flag; whatever the nodes arrive carrying is an input
|
|
754
|
+
* nothing checked (a BFS forward guess on the resolve path, a value read out of a
|
|
755
|
+
* file on the lockfile path) and is overwritten unconditionally.
|
|
856
756
|
*
|
|
857
|
-
* DEFINITION: a node is REQUIRED iff
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
* is still optional — which is what npm's `optionalSet` computes.
|
|
757
|
+
* DEFINITION: a node is REQUIRED iff reachable from `requiredNames` through
|
|
758
|
+
* REQUIRED edges alone ({@link requiredDepEntries}); every other node is optional.
|
|
759
|
+
* Optionality is therefore INHERITED — a plain dependency OF an optional package is
|
|
760
|
+
* still optional, which is what npm's `optionalSet` computes.
|
|
862
761
|
*
|
|
863
762
|
* WHY A FIXPOINT AND NOT A ONE-SHOT PROMOTION AT THE REUSE SITE (the reviewed
|
|
864
|
-
* defect): the walk queues a node's
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
* first
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
* order. npm reaches for the same shape and says so —
|
|
873
|
-
* refs/npm-cli/workspaces/arborist/lib/calc-dep-flags.js: "If a node is changed,
|
|
874
|
-
* we add to the queue and continue until no more changes."
|
|
763
|
+
* defect): the walk queues a node's dep edges when it is placed, carrying the
|
|
764
|
+
* optionality it had THEN, so promoting the node later leaves its children behind and
|
|
765
|
+
* the answer depends on which edge BFS traversed first. A required transitive dep
|
|
766
|
+
* reached first through an optional edge stayed flagged optional, was persisted that
|
|
767
|
+
* way, and was then silently skipped by the platform filter on every machine instead
|
|
768
|
+
* of raising EBADPLATFORM. A monotone worklist over the FINISHED graph cannot have
|
|
769
|
+
* that property: "required" only ever spreads, so the result is order-independent.
|
|
770
|
+
* Same shape as refs/npm-cli/workspaces/arborist/lib/calc-dep-flags.js.
|
|
875
771
|
*
|
|
876
|
-
* PATH-INDEPENDENCE
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
* every Linux install of any tree containing parcel failed with EBADPLATFORM on
|
|
885
|
-
* `@parcel/rust-darwin-x64` while `npm install` thinned the same tree and exited
|
|
886
|
-
* 0. "Erring toward REQUIRED errs toward the loud failure" is true and was the
|
|
887
|
-
* wrong trade: the loud failure was not a real one, and no `--force` lifts it
|
|
888
|
-
* (nor should it). So the declaration is persisted and both paths subtract it.
|
|
889
|
-
* Reading a pre-v4 lockfile leaves the map empty, which reproduces the old
|
|
890
|
-
* behaviour for that file alone — see READABLE_LOCKFILE_VERSIONS.
|
|
772
|
+
* PATH-INDEPENDENCE is the other requirement, and why the lockfile carries an
|
|
773
|
+
* `optionalDependencies` map per entry (v4). This walk USED to read `dependencies`
|
|
774
|
+
* only, because a lockfile entry recorded nothing else and consulting
|
|
775
|
+
* `node.optionalDependencies` would have made the two paths compute different flags
|
|
776
|
+
* for one tree. That premise was fixable and its consequence — a both-blocks name
|
|
777
|
+
* coming out REQUIRED — was not (header note, invariant 3). A pre-v4 lockfile leaves
|
|
778
|
+
* the map empty and reproduces the old behaviour for that file alone
|
|
779
|
+
* (READABLE_LOCKFILE_VERSIONS).
|
|
891
780
|
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
* fatal-vs-inert question is decided by these two functions in composition, and
|
|
896
|
-
* that is worth pinning without a registry.
|
|
781
|
+
* Exported so the unit spec can drive it with INJECTED graphs — the whole
|
|
782
|
+
* fatal-vs-inert question is decided by this and `applyPlatformFilter` in
|
|
783
|
+
* composition, worth pinning without a registry.
|
|
897
784
|
*/
|
|
898
785
|
export function computeOptionalFlags(nodes, requiredNames, log) {
|
|
899
786
|
const byPath = new Map();
|
|
@@ -909,9 +796,9 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
|
|
|
909
796
|
required.add(node.installPath);
|
|
910
797
|
worklist.push(node.installPath);
|
|
911
798
|
};
|
|
912
|
-
// Top-level specs always hoist (`decidePlacement` returns the root slot for
|
|
913
|
-
//
|
|
914
|
-
//
|
|
799
|
+
// Top-level specs always hoist (`decidePlacement` returns the root slot for a
|
|
800
|
+
// null requester), so the seed lookup is exact. A name with no placement is a
|
|
801
|
+
// workspace member satisfied by its symlink — nothing to flag.
|
|
915
802
|
for (const name of requiredNames) {
|
|
916
803
|
const seed = byPath.get(`node_modules/${name}`);
|
|
917
804
|
if (seed)
|
|
@@ -934,11 +821,10 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
|
|
|
934
821
|
enter(resolvedTo);
|
|
935
822
|
}
|
|
936
823
|
}
|
|
937
|
-
// Count the disagreements, don't just apply them
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
// exactly like a correct one.
|
|
824
|
+
// Count the disagreements, don't just apply them: a nonzero `corrected` is the
|
|
825
|
+
// signal that the incoming flags (the walk's guess, or a lockfile from an older
|
|
826
|
+
// CLI) were wrong for this tree. Otherwise invisible, because a corrected result
|
|
827
|
+
// looks exactly like a correct one.
|
|
942
828
|
let corrected = 0;
|
|
943
829
|
for (const node of nodes) {
|
|
944
830
|
const optional = !required.has(node.installPath);
|
|
@@ -949,26 +835,22 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
|
|
|
949
835
|
log('install: optionality fixpoint — %d of %d node(s) reachable only via optional edges%s', nodes.length - required.size, nodes.length, corrected > 0 ? `; corrected ${corrected} incoming flag(s)` : '');
|
|
950
836
|
}
|
|
951
837
|
/**
|
|
952
|
-
* Re-judge every failure the resolve swallowed against the FIXPOINT flags, and
|
|
953
|
-
*
|
|
838
|
+
* Re-judge every failure the resolve swallowed against the FIXPOINT flags, and throw
|
|
839
|
+
* if one turned out to be required after all.
|
|
954
840
|
*
|
|
955
|
-
* `resolveDeps` tolerates a failed edge on the strength of `edge.required`,
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
841
|
+
* `resolveDeps` tolerates a failed edge on the strength of `edge.required`, a
|
|
842
|
+
* forward guess made while the graph was incomplete. Without this second half of the
|
|
843
|
+
* bargain a genuinely required dependency disappears from the tree for no better
|
|
844
|
+
* reason than BFS reaching it through an optional edge first — an install that
|
|
845
|
+
* "succeeded" and produced a tree that cannot run, explained only by a `--verbose`
|
|
846
|
+
* line nobody read.
|
|
961
847
|
*
|
|
962
|
-
* Same shape as npm's `#pruneFailedOptional()` (
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
* point at the verbose line that carries the cause.
|
|
848
|
+
* Same shape and position as npm's `#pruneFailedOptional()` (after the flag pass).
|
|
849
|
+
* The difference is what we hold: npm placed a real broken Node and still has the
|
|
850
|
+
* original error, we recorded only the edge, so the message names the edge and points
|
|
851
|
+
* at the verbose line carrying the cause.
|
|
967
852
|
*
|
|
968
|
-
*
|
|
969
|
-
* a root edge the project itself declared optional, an edge under a subtree the
|
|
970
|
-
* fixpoint says is optional-only, and a required node's own
|
|
971
|
-
* `optionalDependencies` entry.
|
|
853
|
+
* The three tolerated cases below are each a property of the EDGE, not of the tree.
|
|
972
854
|
*/
|
|
973
855
|
function assertRequiredEdgesResolved(nodes, skippedEdges) {
|
|
974
856
|
if (skippedEdges.size === 0)
|
|
@@ -978,35 +860,28 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
|
|
|
978
860
|
byPath.set(node.installPath, node);
|
|
979
861
|
for (const key of skippedEdges) {
|
|
980
862
|
const { from, name } = parseEdgeKey(key);
|
|
981
|
-
// Top-level edge: tolerated only because the project's own manifest put
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
// is the whole truth for a root edge — no later edge can revise it.
|
|
863
|
+
// Top-level edge: tolerated only because the project's own manifest put the
|
|
864
|
+
// spec in `optionalDependencies` (the ONLY way `requiredTopLevelNames` leaves
|
|
865
|
+
// it out of the seed set), and no later edge can revise the manifest.
|
|
985
866
|
if (from === null)
|
|
986
867
|
continue;
|
|
987
868
|
const requester = byPath.get(from);
|
|
988
|
-
// Requester never made it into the tree
|
|
989
|
-
// any more, so nothing is missing.
|
|
869
|
+
// Requester never made it into the tree, so nothing depends on this edge.
|
|
990
870
|
if (!requester)
|
|
991
871
|
continue;
|
|
992
|
-
// The whole subtree is
|
|
993
|
-
// `optionalSet`, skipped in silence.
|
|
872
|
+
// The whole subtree is optional-only — npm's `optionalSet`, skipped silently.
|
|
994
873
|
if (requester.optional)
|
|
995
874
|
continue;
|
|
996
|
-
// A REQUIRED package's own optionalDependency stays optional whatever
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
// is what keeps a darwin-only `fsevents` under a required `chokidar` from
|
|
1001
|
-
// failing every Linux install.
|
|
1002
|
-
//
|
|
1003
|
-
// Membership, not `requiredDepEntries`: the failed edge may name something
|
|
1004
|
-
// no longer in `dependencies` at all, and what is being asked here is
|
|
1005
|
-
// whether the requester declared THIS name optional.
|
|
875
|
+
// A REQUIRED package's own optionalDependency stays optional whatever the
|
|
876
|
+
// package's flag says: optionality lives on the edge, not on its endpoints
|
|
877
|
+
// (header note, invariant 3). This branch is what keeps a darwin-only
|
|
878
|
+
// `fsevents` under a required `chokidar` from failing every Linux install.
|
|
1006
879
|
//
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
880
|
+
// Membership, not `requiredDepEntries`: the failed edge may name something no
|
|
881
|
+
// longer in `dependencies` at all, and the question is whether the requester
|
|
882
|
+
// declared THIS name optional. Safe on both paths — `skippedEdges` is only
|
|
883
|
+
// ever non-empty on the fresh-resolve path, where `optionalDependencies` is
|
|
884
|
+
// populated.
|
|
1010
885
|
if (name in requester.optionalDependencies)
|
|
1011
886
|
continue;
|
|
1012
887
|
throw new Error(`install: required dependency ${name} of ${requester.name}@${requester.version} ` +
|
|
@@ -1019,17 +894,15 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
|
|
|
1019
894
|
}
|
|
1020
895
|
}
|
|
1021
896
|
/**
|
|
1022
|
-
* Loud
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
1027
|
-
* (
|
|
1028
|
-
* not semver (dist-tags like `latest`) cannot be compared and are skipped.
|
|
897
|
+
* Loud one-line-per-package warning for top-level version-range conflicts (ADR
|
|
898
|
+
* 0001). Fires when two or more top-level specs requested DIFFERENT ranges of the
|
|
899
|
+
* same package and the version hoisted to the root does not satisfy all of them, so
|
|
900
|
+
* some requester silently got a version outside its declared range. Compatible
|
|
901
|
+
* ranges (`^1.2` + `^1.4` both satisfied by `1.9.0`) stay silent; non-semver ranges
|
|
902
|
+
* (dist-tags like `latest`) cannot be compared and are skipped.
|
|
1029
903
|
*
|
|
1030
|
-
* This makes the
|
|
1031
|
-
*
|
|
1032
|
-
* requester its own nested copy — is Phase D.8 (see status/open-todos.md).
|
|
904
|
+
* This makes the single-root-slot behaviour honest rather than silent; the real fix
|
|
905
|
+
* is a per-workspace dedup pass, Phase D.8 (status/open-todos.md).
|
|
1033
906
|
*/
|
|
1034
907
|
function emitTopLevelConflictWarnings(topLevelRanges, root) {
|
|
1035
908
|
for (const [name, ranges] of topLevelRanges) {
|
|
@@ -1060,27 +933,17 @@ function emitTopLevelConflictWarnings(topLevelRanges, root) {
|
|
|
1060
933
|
}
|
|
1061
934
|
}
|
|
1062
935
|
/**
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
* the
|
|
1069
|
-
* aborting the whole wave's batch.
|
|
1070
|
-
*/
|
|
1071
|
-
/**
|
|
1072
|
-
* Fetch a packument with on-disk ETag revalidation. Reads the cached
|
|
1073
|
-
* `{ etag, packument }` for `(registry, name)`, sends it as `If-None-Match`,
|
|
1074
|
-
* and on a `304 Not Modified` returns the cached body without re-downloading
|
|
1075
|
-
* it; on a `200` it stores the fresh body + ETag and returns it. The cache is
|
|
1076
|
-
* keyed by the registry the name resolves to, so scope-registry overrides never
|
|
1077
|
-
* cross-contaminate. Falls back to a plain fetch when there's no cached entry
|
|
1078
|
-
* or the registry doesn't send an ETag (the 304 fast-path simply never fires).
|
|
936
|
+
* Fetch a packument with on-disk ETag revalidation: the cached
|
|
937
|
+
* `{ etag, packument }` for `(registry, name)` goes out as `If-None-Match`, a `304`
|
|
938
|
+
* returns the cached body, a `200` stores the fresh body + ETag. Keyed by the
|
|
939
|
+
* registry the NAME resolves to, so scope-registry overrides never
|
|
940
|
+
* cross-contaminate. Falls back to a plain fetch with no cached entry or no ETag
|
|
941
|
+
* from the registry (the 304 fast-path then simply never fires).
|
|
1079
942
|
*
|
|
1080
|
-
* `shape` selects WHICH document
|
|
1081
|
-
*
|
|
1082
|
-
* through
|
|
1083
|
-
* different bodies with independently-versioned ETags for one URL
|
|
943
|
+
* `shape` selects WHICH document — `'corgi'` (abbreviated) or `'full'`, the only one
|
|
944
|
+
* this backend's resolve asks for because it is the only one carrying `libc`. It is
|
|
945
|
+
* threaded through request `accept`, cache read AND cache write, because the two are
|
|
946
|
+
* different bodies with independently-versioned ETags for one URL; mixing them
|
|
1084
947
|
* anywhere in that chain produces a `libc`-less body that looks like a hit.
|
|
1085
948
|
*/
|
|
1086
949
|
async function fetchPackumentWithDiskCache(name, npmrc, log, signal, shape = 'corgi') {
|
|
@@ -1106,10 +969,18 @@ async function fetchPackumentWithDiskCache(name, npmrc, log, signal, shape = 'co
|
|
|
1106
969
|
putCachedPackument(registry, name, result.etag, result.packument, shape);
|
|
1107
970
|
return result.packument;
|
|
1108
971
|
}
|
|
1109
|
-
// 304 with no cached body to satisfy it
|
|
1110
|
-
//
|
|
972
|
+
// 304 with no cached body to satisfy it — a stale `If-None-Match` raced a cache
|
|
973
|
+
// eviction. Re-fetch unconditionally so a body is always returned.
|
|
1111
974
|
return fetchPackument(name, { npmrc, signal, fullMetadata, onRetry });
|
|
1112
975
|
}
|
|
976
|
+
/**
|
|
977
|
+
* Warm `packumentCache` for a batch of names with bounded parallelism (the download
|
|
978
|
+
* pool's `DEFAULT_CONCURRENCY` width). Rejections — a 404 on an optional dep, say —
|
|
979
|
+
* are swallowed: the promise stays cached in its rejected state and the caller's
|
|
980
|
+
* per-edge `await fetchPkg(name)` re-surfaces it, so required deps still throw and
|
|
981
|
+
* optional ones are skipped exactly as on the single-edge path. Swallowing also stops
|
|
982
|
+
* one bad optional dependency from aborting the whole wave's batch.
|
|
983
|
+
*/
|
|
1113
984
|
async function prefetchPackuments(names, fetchPkg, signal) {
|
|
1114
985
|
if (names.length === 0)
|
|
1115
986
|
return;
|
|
@@ -1131,27 +1002,18 @@ async function prefetchPackuments(names, fetchPkg, signal) {
|
|
|
1131
1002
|
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
1132
1003
|
}
|
|
1133
1004
|
/**
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1005
|
+
* The placement of `name` that `requesterPath` would resolve through Node's
|
|
1006
|
+
* `node_modules` lookup — its own nested copy first (nested deps shadow ancestor
|
|
1007
|
+
* ones), then each ancestor, then the root. The first match is what the requester
|
|
1008
|
+
* actually sees at runtime.
|
|
1138
1009
|
*/
|
|
1139
1010
|
function findVisible(requesterPath, name, byPath) {
|
|
1140
|
-
// From the requester's directory, Node walks up node_modules dirs
|
|
1141
|
-
// looking for `<dir>/node_modules/<name>`. Translate that to lockfile
|
|
1142
|
-
// paths: any prefix of the requester's `installPath` that ends in a
|
|
1143
|
-
// package directory gives a candidate `<prefix>/node_modules/<name>`.
|
|
1144
|
-
//
|
|
1145
|
-
// The requester itself ALSO checks its OWN `node_modules` first
|
|
1146
|
-
// (i.e. `<requesterPath>/node_modules/<name>` — nested deps shadow
|
|
1147
|
-
// ancestor ones). Then it walks up.
|
|
1148
1011
|
const candidates = [];
|
|
1149
1012
|
if (requesterPath !== null) {
|
|
1150
1013
|
candidates.push(`${requesterPath}/node_modules/${name}`);
|
|
1151
|
-
// Walk up
|
|
1014
|
+
// Walk up by stripping the deepest `/node_modules/<pkg>` segment each time.
|
|
1152
1015
|
let p = requesterPath;
|
|
1153
1016
|
while (true) {
|
|
1154
|
-
// Find the deepest `/node_modules/<pkg>` in `p`, strip it.
|
|
1155
1017
|
const idx = p.lastIndexOf('/node_modules/');
|
|
1156
1018
|
if (idx < 0)
|
|
1157
1019
|
break;
|
|
@@ -1161,8 +1023,7 @@ function findVisible(requesterPath, name, byPath) {
|
|
|
1161
1023
|
break;
|
|
1162
1024
|
}
|
|
1163
1025
|
}
|
|
1164
|
-
//
|
|
1165
|
-
// `requesterPath === null` case too).
|
|
1026
|
+
// Also covers `requesterPath === null`.
|
|
1166
1027
|
candidates.push(`node_modules/${name}`);
|
|
1167
1028
|
for (const candidate of candidates) {
|
|
1168
1029
|
const hit = byPath.get(candidate);
|
|
@@ -1172,13 +1033,10 @@ function findVisible(requesterPath, name, byPath) {
|
|
|
1172
1033
|
return null;
|
|
1173
1034
|
}
|
|
1174
1035
|
/**
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
* - Root has a DIFFERENT version: nest under the requester.
|
|
1180
|
-
*
|
|
1181
|
-
* Top-level requesters (requesterPath === null) always hoist.
|
|
1036
|
+
* Where to install `name@version` for a request from `requesterPath`: hoist to
|
|
1037
|
+
* `node_modules/<name>` when the root slot is empty or already holds this version,
|
|
1038
|
+
* nest under the requester when the root holds a different one. Top-level requesters
|
|
1039
|
+
* (`requesterPath === null`) always hoist.
|
|
1182
1040
|
*/
|
|
1183
1041
|
function decidePlacement(requesterPath, name, version, root) {
|
|
1184
1042
|
const rootSlot = root.get(name);
|
|
@@ -1187,16 +1045,15 @@ function decidePlacement(requesterPath, name, version, root) {
|
|
|
1187
1045
|
if (rootSlot.version === version)
|
|
1188
1046
|
return `node_modules/${name}`;
|
|
1189
1047
|
if (requesterPath === null) {
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
// top-level spec with conflicting versions).
|
|
1048
|
+
// Defensive: the caller dedups top-level specs, so this only fires on a
|
|
1049
|
+
// duplicate top-level spec with conflicting versions.
|
|
1193
1050
|
return `node_modules/${name}`;
|
|
1194
1051
|
}
|
|
1195
1052
|
return `${requesterPath}/node_modules/${name}`;
|
|
1196
1053
|
}
|
|
1197
1054
|
function satisfiesRange(version, range) {
|
|
1198
|
-
// dist-tag (
|
|
1199
|
-
//
|
|
1055
|
+
// A dist-tag (`latest`) cannot be matched here; those are only meaningful at
|
|
1056
|
+
// fresh-resolve time.
|
|
1200
1057
|
try {
|
|
1201
1058
|
return satisfies(version, new Range(range));
|
|
1202
1059
|
}
|
|
@@ -1209,11 +1066,8 @@ function readLockfile(lockfilePath) {
|
|
|
1209
1066
|
return null;
|
|
1210
1067
|
try {
|
|
1211
1068
|
const parsed = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
// worse than reading it. The caller decides what it may be used FOR:
|
|
1215
|
-
// a v2 file seeds version preservation but never short-circuits the
|
|
1216
|
-
// resolve, because it carries no platform declarations.
|
|
1069
|
+
// Every readable version, not just the current one — READABLE_LOCKFILE_VERSIONS
|
|
1070
|
+
// has the reason. The caller decides what it may be used FOR.
|
|
1217
1071
|
if (!READABLE_LOCKFILE_VERSIONS.has(parsed.lockfileVersion))
|
|
1218
1072
|
return null;
|
|
1219
1073
|
if (!parsed.packages || typeof parsed.packages !== 'object')
|
|
@@ -1234,16 +1088,14 @@ function writeLockfile(lockfilePath, specs, nodes) {
|
|
|
1234
1088
|
resolved: node.tarballUrl,
|
|
1235
1089
|
integrity: node.integrity,
|
|
1236
1090
|
dependencies: Object.keys(node.dependencies).length > 0 ? node.dependencies : undefined,
|
|
1237
|
-
// The EDGE KINDS, for the same reason the platform declaration is
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
// invariant 3). Omitted when empty, which is most packages.
|
|
1091
|
+
// The EDGE KINDS, for the same reason the platform declaration is here:
|
|
1092
|
+
// `computeOptionalFlags` runs on the lockfile path too and cannot recompute
|
|
1093
|
+
// what the file does not carry (v4, header note invariant 3).
|
|
1241
1094
|
optionalDependencies: Object.keys(node.optionalDependencies).length > 0 ? node.optionalDependencies : undefined,
|
|
1242
1095
|
bin: node.bin,
|
|
1243
|
-
// The DECLARATION, never the verdict: `inert` is per-host and
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
// tree for everyone else — the failure this design exists to avoid).
|
|
1096
|
+
// The DECLARATION, never the verdict: `inert` is per-host and deliberately
|
|
1097
|
+
// absent from the file, since a lockfile recording which packages the
|
|
1098
|
+
// author's machine skipped installs a different tree for everyone else.
|
|
1247
1099
|
os: node.platform?.os,
|
|
1248
1100
|
cpu: node.platform?.cpu,
|
|
1249
1101
|
libc: node.platform?.libc,
|
|
@@ -1255,17 +1107,16 @@ function writeLockfile(lockfilePath, specs, nodes) {
|
|
|
1255
1107
|
requested: [...specs],
|
|
1256
1108
|
packages,
|
|
1257
1109
|
};
|
|
1258
|
-
// Atomic tmp+rename so a crash mid-write
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1110
|
+
// Atomic tmp+rename so a crash mid-write, or a reader racing the writer, can never
|
|
1111
|
+
// observe a torn gjsify-lock.json — `--immutable` would otherwise hard-fail on the
|
|
1112
|
+
// corrupt file with a misleading error.
|
|
1261
1113
|
atomicWriteStrict(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n');
|
|
1262
1114
|
}
|
|
1263
1115
|
/**
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
* in-range match.
|
|
1116
|
+
* The lockfile-preservation oracle: `name → every version currently pinned` (a name
|
|
1117
|
+
* can appear at more than one path/version when the tree nested a conflicting copy).
|
|
1118
|
+
* Consulted during a resolve so unchanged deps keep their pinned version instead of
|
|
1119
|
+
* bumping to the newest in-range match.
|
|
1269
1120
|
*/
|
|
1270
1121
|
function buildPreferredVersions(lockfile) {
|
|
1271
1122
|
const byName = new Map();
|
|
@@ -1302,35 +1153,30 @@ function lockfileToNodes(lockfile) {
|
|
|
1302
1153
|
return Object.entries(lockfile.packages).map(([installPath, entry]) => {
|
|
1303
1154
|
const platform = { os: entry.os, cpu: entry.cpu, libc: entry.libc };
|
|
1304
1155
|
return {
|
|
1305
|
-
// Recover the package name from the path: the last segment is
|
|
1306
|
-
// either `<name>` (unscoped) or `@scope/<name>` (scoped).
|
|
1307
1156
|
name: nameFromInstallPath(installPath),
|
|
1308
1157
|
version: entry.version,
|
|
1309
1158
|
tarballUrl: entry.resolved,
|
|
1310
1159
|
integrity: entry.integrity,
|
|
1311
1160
|
installPath,
|
|
1312
1161
|
dependencies: entry.dependencies ?? {},
|
|
1313
|
-
// Empty for a pre-v4 entry, which is why such a file never
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
// see READABLE_LOCKFILE_VERSIONS.
|
|
1162
|
+
// Empty for a pre-v4 entry, which is why such a file never short-circuits
|
|
1163
|
+
// the resolve: with no edge kinds recorded, a name the publisher put in both
|
|
1164
|
+
// blocks reads as required here. `--immutable` consumes it anyway — see
|
|
1165
|
+
// READABLE_LOCKFILE_VERSIONS.
|
|
1318
1166
|
optionalDependencies: entry.optionalDependencies ?? {},
|
|
1319
1167
|
bin: entry.bin,
|
|
1320
|
-
// Rebuilt from the recorded declaration so `applyPlatformFilter`
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
// filtered; that install behaves exactly as it did before the
|
|
1325
|
-
// feature (see READABLE_LOCKFILE_VERSIONS).
|
|
1168
|
+
// Rebuilt from the recorded declaration so `applyPlatformFilter` reaches the
|
|
1169
|
+
// SAME verdict here as on the fresh-resolve path, for THIS host — generally
|
|
1170
|
+
// not the host that wrote the file. A v2 entry has none of these fields, so
|
|
1171
|
+
// nothing is filtered and the install behaves as it did pre-feature.
|
|
1326
1172
|
platform: declaresPlatform(platform) ? platform : undefined,
|
|
1327
1173
|
optional: entry.optional === true,
|
|
1328
1174
|
};
|
|
1329
1175
|
});
|
|
1330
1176
|
}
|
|
1331
1177
|
function nameFromInstallPath(installPath) {
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1178
|
+
// Everything after the last `node_modules/` boundary is the package name — one
|
|
1179
|
+
// segment unscoped, `@scope/pkg` scoped.
|
|
1334
1180
|
const idx = installPath.lastIndexOf('/node_modules/');
|
|
1335
1181
|
const after = idx < 0 ? installPath.replace(/^node_modules\//, '') : installPath.slice(idx + '/node_modules/'.length);
|
|
1336
1182
|
return after;
|
|
@@ -1343,10 +1189,9 @@ function lockfileMatchesRequest(lockfile, specs) {
|
|
|
1343
1189
|
return a.every((v, i) => v === b[i]);
|
|
1344
1190
|
}
|
|
1345
1191
|
/**
|
|
1346
|
-
* Human-readable diff between `lockfile.requested` and the live request
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
* failures don't force the user to diff lockfile JSON by hand.
|
|
1192
|
+
* Human-readable diff between `lockfile.requested` and the live request, or null when
|
|
1193
|
+
* they are identical. `--immutable` uses it to name exactly which deps drifted, so a
|
|
1194
|
+
* CI failure does not force the user to diff lockfile JSON by hand.
|
|
1350
1195
|
*/
|
|
1351
1196
|
function describeLockfileDrift(lockfile, specs) {
|
|
1352
1197
|
const lockSet = new Set(lockfile.requested);
|
|
@@ -1368,20 +1213,14 @@ function describeLockfileDrift(lockfile, specs) {
|
|
|
1368
1213
|
lines.push(` - ${removed.sort().join('\n - ')}`);
|
|
1369
1214
|
return lines.join('\n');
|
|
1370
1215
|
}
|
|
1371
|
-
//
|
|
1372
|
-
// stable, the install-backend itself still calls it via the local
|
|
1373
|
-
// binding below. Internal API.
|
|
1216
|
+
// Internal API, exported for `install-backend-parse-spec.spec.ts`.
|
|
1374
1217
|
export function parseSpec(raw) {
|
|
1375
|
-
//
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
// precedence than the associated normal version"); ts-for-gir
|
|
1382
|
-
// shipped only prereleases (4.0.0-rc.17 is the `latest` tag, no
|
|
1383
|
-
// stable 4.x yet) and `*` was selecting the abandoned 3.3.0
|
|
1384
|
-
// instead.
|
|
1218
|
+
// A bare name resolves to the `latest` DIST-TAG, matching `npm install foo`, and
|
|
1219
|
+
// that choice is what picks up prereleases the publisher tagged `latest`. Semver
|
|
1220
|
+
// `*` would silently exclude every version with a `-` suffix (semver §9: "Pre-
|
|
1221
|
+
// release versions have a lower precedence than the associated normal version");
|
|
1222
|
+
// ts-for-gir shipped only prereleases (`latest` = 4.0.0-rc.17, no stable 4.x) and
|
|
1223
|
+
// `*` selected the abandoned 3.3.0 instead.
|
|
1385
1224
|
if (raw.startsWith('@')) {
|
|
1386
1225
|
const slash = raw.indexOf('/');
|
|
1387
1226
|
if (slash < 0)
|
|
@@ -1396,19 +1235,18 @@ export function parseSpec(raw) {
|
|
|
1396
1235
|
return { name: raw, range: 'latest' };
|
|
1397
1236
|
return { name: raw.slice(0, at), range: raw.slice(at + 1) || 'latest' };
|
|
1398
1237
|
}
|
|
1399
|
-
//
|
|
1238
|
+
// Internal API, exported for `install-backend-parse-spec.spec.ts`.
|
|
1400
1239
|
export function pickVersion(packument, range, preferred) {
|
|
1401
|
-
// Lockfile preservation:
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
// dist-tag), so this never hijacks a `latest`/`next` range.
|
|
1240
|
+
// Lockfile preservation: a pinned version that still satisfies the range and is
|
|
1241
|
+
// still published is reused rather than bumped. `preferred` is only ever a concrete
|
|
1242
|
+
// semver, never a dist-tag, so this cannot hijack a `latest`/`next` range.
|
|
1405
1243
|
if (preferred && packument.versions[preferred] && satisfiesRange(preferred, range)) {
|
|
1406
1244
|
return preferred;
|
|
1407
1245
|
}
|
|
1408
1246
|
// dist-tag fast path: `latest`, `next`, ...
|
|
1409
1247
|
if (packument['dist-tags'][range])
|
|
1410
1248
|
return packument['dist-tags'][range];
|
|
1411
|
-
// Validate range early so a typo fails loudly.
|
|
1249
|
+
// Validate the range early so a typo fails loudly.
|
|
1412
1250
|
let parsedRange;
|
|
1413
1251
|
try {
|
|
1414
1252
|
parsedRange = new Range(range);
|
|
@@ -1428,17 +1266,15 @@ export function pickVersion(packument, range, preferred) {
|
|
|
1428
1266
|
return maxSatisfying(versions, parsedRange);
|
|
1429
1267
|
}
|
|
1430
1268
|
async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress) {
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
// wipe out the child.
|
|
1269
|
+
// Depth ascending, so parents extract before children — extracting a parent on
|
|
1270
|
+
// top of an existing child wipes the child out.
|
|
1434
1271
|
const queue = [...nodes].sort((a, b) => depth(a.installPath) - depth(b.installPath) || (a.installPath < b.installPath ? -1 : 1));
|
|
1435
1272
|
const workers = [];
|
|
1436
1273
|
const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
|
|
1437
1274
|
progress?.beginPhase('download', queue.length);
|
|
1438
1275
|
let completed = 0;
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
// install makes it obvious why the phase finished in seconds.
|
|
1276
|
+
// Nodes already correctly materialised on disk (npm's "unchanged" set). Surfaced in
|
|
1277
|
+
// the summary log so a warm install explains why the phase finished in seconds.
|
|
1442
1278
|
let skipped = 0;
|
|
1443
1279
|
const tickProgress = (node, wasSkipped) => {
|
|
1444
1280
|
completed++;
|
|
@@ -1451,9 +1287,8 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
|
|
|
1451
1287
|
name: `${node.name}@${node.version}`,
|
|
1452
1288
|
});
|
|
1453
1289
|
};
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
// depths >=2 run with full concurrency.
|
|
1290
|
+
// Depth-1 parents extract serially first, to avoid `rm -rf` + extract racing their
|
|
1291
|
+
// own children; depths ≥2 then run at full concurrency.
|
|
1457
1292
|
let cursor = 0;
|
|
1458
1293
|
const depth1End = queue.findIndex((n) => depth(n.installPath) > 1);
|
|
1459
1294
|
const splitAt = depth1End < 0 ? queue.length : depth1End;
|
|
@@ -1471,13 +1306,11 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
|
|
|
1471
1306
|
for (let i = 0; i < concurrency; i++) {
|
|
1472
1307
|
workers.push((async () => {
|
|
1473
1308
|
while (true) {
|
|
1474
|
-
// Honour an aborted overall-install budget inside the pool.
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
// asked to (a contributor to the observed "it never
|
|
1480
|
-
// completed; I killed it" hang).
|
|
1309
|
+
// Honour an aborted overall-install budget inside the pool. Without
|
|
1310
|
+
// this a fired --timeout (or Ctrl-C) aborts only the NETWORK fetches,
|
|
1311
|
+
// so a mostly-cached tree churns the extract loop to completion and
|
|
1312
|
+
// the install never stops when asked — one contributor to the observed
|
|
1313
|
+
// "it never completed; I killed it" hang.
|
|
1481
1314
|
if (signal?.aborted)
|
|
1482
1315
|
throw abortError(signal);
|
|
1483
1316
|
const idx = cursor++;
|
|
@@ -1498,20 +1331,17 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
|
|
|
1498
1331
|
}
|
|
1499
1332
|
}
|
|
1500
1333
|
/**
|
|
1501
|
-
* Is `name@version` already correctly materialised at `dest`?
|
|
1502
|
-
*
|
|
1503
|
-
*
|
|
1504
|
-
* already-present, correct copy is skipped instead of being `rm`-ed and
|
|
1505
|
-
* re-extracted.
|
|
1334
|
+
* Is `name@version` already correctly materialised at `dest`? The npm/yarn/pnpm
|
|
1335
|
+
* "unchanged node" check: an already-present, correct copy is skipped instead of being
|
|
1336
|
+
* `rm`-ed and re-extracted.
|
|
1506
1337
|
*
|
|
1507
|
-
* Conservative by design
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
* Gio.ZlibDecompressor on the single GLib main loop) into a near-instant no-op.
|
|
1338
|
+
* Conservative by design — a missing/unreadable/unparseable package.json or ANY
|
|
1339
|
+
* name/version mismatch returns false, so the caller falls through to the full rm +
|
|
1340
|
+
* extract. Only the exact-match case is fast-pathed, which is the overwhelming
|
|
1341
|
+
* majority of nodes on a warm re-install, and that is what turns a warm `gjsify
|
|
1342
|
+
* install` on a 2000+-package workspace from a many-minute re-extract of every tarball
|
|
1343
|
+
* (each gunzip routed through GJS's Gio.ZlibDecompressor on the single GLib main loop)
|
|
1344
|
+
* into a near-instant no-op.
|
|
1515
1345
|
*/
|
|
1516
1346
|
function isAlreadyExtracted(dest, node) {
|
|
1517
1347
|
const manifestPath = path.join(dest, 'package.json');
|
|
@@ -1524,11 +1354,9 @@ function isAlreadyExtracted(dest, node) {
|
|
|
1524
1354
|
}
|
|
1525
1355
|
try {
|
|
1526
1356
|
const parsed = JSON.parse(raw);
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
// could coincidentally collide. A missing `name` (rare, malformed) is
|
|
1531
|
-
// tolerated as long as the version matches.
|
|
1357
|
+
// Name too, when present: a stale dir from a previous resolve can hold a
|
|
1358
|
+
// different package at the same path (a nested placement that moved), where the
|
|
1359
|
+
// version alone could coincidentally collide. A missing `name` is tolerated.
|
|
1532
1360
|
if (parsed.version !== node.version)
|
|
1533
1361
|
return false;
|
|
1534
1362
|
if (typeof parsed.name === 'string' && parsed.name !== node.name)
|
|
@@ -1546,38 +1374,30 @@ function isAlreadyExtracted(dest, node) {
|
|
|
1546
1374
|
*/
|
|
1547
1375
|
async function extractOne(node, prefix, npmrc, log, signal) {
|
|
1548
1376
|
const dest = path.join(prefix, node.installPath);
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1553
|
-
// queue (the root cause fixed in `workspaceInstall`). Refusing here means
|
|
1554
|
-
// a regression in the resolver can never again `rmSync` a working-tree
|
|
1555
|
-
// source dir — the realpath check additionally rejects a `dest` that
|
|
1556
|
-
// resolves THROUGH a symlink into a directory outside node_modules.
|
|
1377
|
+
// Defence-in-depth against the workspace-source-wipe data-loss bug: this can only
|
|
1378
|
+
// fail if a workspace package leaked into the fetch/extract queue (the root cause
|
|
1379
|
+
// fixed in `workspaceInstall`), and refusing here means a resolver regression can
|
|
1380
|
+
// never again `rmSync` a working-tree source dir.
|
|
1557
1381
|
assertNodeModulesDest(dest, node);
|
|
1558
|
-
// Idempotent fast-path
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
// GJSIFY_INSTALL_FORCE_EXTRACT=1 (debug / corrupted-tree recovery).
|
|
1382
|
+
// Idempotent fast-path — skip the cache read + rm + re-extract for a node already
|
|
1383
|
+
// extracted at the resolved version. The npm/yarn/pnpm default (only added/changed
|
|
1384
|
+
// nodes are written) and the dominant cost on a warm re-install.
|
|
1385
|
+
// GJSIFY_INSTALL_FORCE_EXTRACT=1 forces a full re-extract for corrupted-tree recovery.
|
|
1563
1386
|
if (process.env.GJSIFY_INSTALL_FORCE_EXTRACT !== '1' && isAlreadyExtracted(dest, node)) {
|
|
1564
1387
|
log('up-to-date: %s@%s (already extracted at %s)', node.name, node.version, node.installPath);
|
|
1565
1388
|
return true;
|
|
1566
1389
|
}
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
// return — no need to verify by re-download.
|
|
1390
|
+
// Content-addressable cache before the network: tarballs are immutable per SRI
|
|
1391
|
+
// integrity, so a hash hit is byte-identical to what the registry would return and
|
|
1392
|
+
// needs no verifying re-download.
|
|
1571
1393
|
let bytes = getCachedTarball(node.integrity);
|
|
1572
1394
|
if (bytes) {
|
|
1573
1395
|
log('cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
|
|
1574
1396
|
}
|
|
1575
1397
|
else if ((bytes = getForeignCachedTarball(node.integrity))) {
|
|
1576
|
-
// Second
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
// the next `gjsify install` is a first-class hit even if npm later
|
|
1580
|
-
// prunes its cache.
|
|
1398
|
+
// Second chance: npm's cacache content store, same SRI key — anyone who has run
|
|
1399
|
+
// `npm install` already has the tarball on disk. Written through to OUR store so
|
|
1400
|
+
// the next `gjsify install` hits first-class even if npm prunes its cache.
|
|
1581
1401
|
log('npm-cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
|
|
1582
1402
|
putCachedTarball(node.integrity, bytes);
|
|
1583
1403
|
}
|
|
@@ -1591,35 +1411,31 @@ async function extractOne(node, prefix, npmrc, log, signal) {
|
|
|
1591
1411
|
log('tarball %s@%s: retry %d after %dms (%s)', node.name, node.version, attempt, delayMs, errMsg(error));
|
|
1592
1412
|
},
|
|
1593
1413
|
});
|
|
1594
|
-
// Best-effort
|
|
1595
|
-
//
|
|
1414
|
+
// Best-effort: `putCachedTarball` swallows failures so a read-only HOME or an
|
|
1415
|
+
// out-of-disk cache volume does not break the install.
|
|
1596
1416
|
putCachedTarball(node.integrity, bytes);
|
|
1597
1417
|
}
|
|
1598
|
-
// `rmWithRetry
|
|
1599
|
-
//
|
|
1600
|
-
//
|
|
1601
|
-
// EPERM/ENOTEMPTY. No-op cost on POSIX. This runs for EVERY package.
|
|
1418
|
+
// `rmWithRetry`, not a bare rmSync: on Windows deleting a freshly extracted tree
|
|
1419
|
+
// fails EBUSY/EPERM while any handle is open and stays pending afterwards, so the
|
|
1420
|
+
// mkdirSync below would hit EPERM/ENOTEMPTY. No-op cost on POSIX.
|
|
1602
1421
|
rmWithRetry(dest);
|
|
1603
1422
|
fs.mkdirSync(dest, { recursive: true });
|
|
1604
1423
|
await extractWithStallGuard(bytes, dest, node, signal);
|
|
1605
1424
|
return false;
|
|
1606
1425
|
}
|
|
1607
1426
|
/**
|
|
1608
|
-
* Cap over `extractTarball`, which takes no AbortSignal and no timeout of its
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
1616
|
-
* the overall-install abort signal, so a wedge surfaces as an actionable
|
|
1617
|
-
* error and the install fails fast instead of hanging.
|
|
1427
|
+
* Cap over `extractTarball`, which takes no AbortSignal and no timeout of its own. One
|
|
1428
|
+
* extract completes in well under a second even for large packages, so anything past
|
|
1429
|
+
* `EXTRACT_STALL_MS` means the decompress/write wedged — the classic dropped Gio stream
|
|
1430
|
+
* close-event under GJS, a never-settling await nothing downstream can break. Unlike a
|
|
1431
|
+
* network fetch (30s per-request budget in @gjsify/npm-registry) extraction has no
|
|
1432
|
+
* self-healing path, so one stuck package used to hang the whole install at 0% CPU
|
|
1433
|
+
* indefinitely. Racing the extract against a stall timer AND the overall-install abort
|
|
1434
|
+
* signal turns that into an actionable error.
|
|
1618
1435
|
*
|
|
1619
|
-
* Rejecting the race does not tear down the
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1622
|
-
* signal race still applies).
|
|
1436
|
+
* Rejecting the race does not tear down the leaked extract promise; the process exit
|
|
1437
|
+
* that follows reclaims it. `GJSIFY_EXTRACT_STALL_MS=0` disables the timer, leaving the
|
|
1438
|
+
* signal race in place.
|
|
1623
1439
|
*/
|
|
1624
1440
|
const EXTRACT_STALL_MS = (() => {
|
|
1625
1441
|
const raw = process.env.GJSIFY_EXTRACT_STALL_MS;
|
|
@@ -1629,7 +1445,7 @@ const EXTRACT_STALL_MS = (() => {
|
|
|
1629
1445
|
return Number.isFinite(n) && n >= 0 ? n : 120_000;
|
|
1630
1446
|
})();
|
|
1631
1447
|
async function extractWithStallGuard(bytes, dest, node, signal) {
|
|
1632
|
-
//
|
|
1448
|
+
// Nothing to guard against: timer disabled and no signal.
|
|
1633
1449
|
if (EXTRACT_STALL_MS === 0 && !signal) {
|
|
1634
1450
|
await extractTarball(bytes, dest);
|
|
1635
1451
|
return;
|
|
@@ -1638,13 +1454,11 @@ async function extractWithStallGuard(bytes, dest, node, signal) {
|
|
|
1638
1454
|
let onAbort;
|
|
1639
1455
|
try {
|
|
1640
1456
|
await new Promise((resolve, reject) => {
|
|
1641
|
-
// Test seam
|
|
1642
|
-
//
|
|
1643
|
-
// + abort-signal race actually break the wedge. Never call
|
|
1644
|
-
// extractTarball — leave the inner promise pending forever.
|
|
1457
|
+
// Test seam: simulate an extract that never settles, so a suite can prove
|
|
1458
|
+
// the stall timer + abort-signal race actually break the wedge.
|
|
1645
1459
|
if (process.env.GJSIFY_TEST_HANG_EXTRACT !== '1') {
|
|
1646
|
-
// extractTarball keeps running if
|
|
1647
|
-
//
|
|
1460
|
+
// extractTarball keeps running if it loses the race; its settlement is
|
|
1461
|
+
// then a no-op on the already-settled promise.
|
|
1648
1462
|
extractTarball(bytes, dest).then(() => resolve(), reject);
|
|
1649
1463
|
}
|
|
1650
1464
|
if (EXTRACT_STALL_MS > 0) {
|
|
@@ -1689,19 +1503,16 @@ function abortError(signal) {
|
|
|
1689
1503
|
return err;
|
|
1690
1504
|
}
|
|
1691
1505
|
/**
|
|
1692
|
-
* Guard: a tarball may only be extracted into a `node_modules/` directory.
|
|
1506
|
+
* Guard: a tarball may only be extracted into a `node_modules/` directory. Two checks,
|
|
1507
|
+
* both against ever wiping a working-tree source dir (the
|
|
1508
|
+
* install-deletes-workspace-sources data-loss bug):
|
|
1693
1509
|
*
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
* 2. If `dest` already exists and resolves (via symlink) to a directory
|
|
1701
|
-
* whose REAL path is not under a `node_modules/` segment, refuse. This
|
|
1702
|
-
* catches the case where `node_modules/<name>` is a symlink to a
|
|
1703
|
-
* workspace's source tree — `rmSync(dest, { recursive: true })` would
|
|
1704
|
-
* then delete the link's target contents.
|
|
1510
|
+
* 1. The logical `installPath` must contain a `node_modules` segment. The resolver
|
|
1511
|
+
* always produces such paths; a workspace package that slipped in would not.
|
|
1512
|
+
* 2. An existing `dest` whose REAL path (after symlinks) is not under a
|
|
1513
|
+
* `node_modules/` segment is refused — that is `node_modules/<name>` symlinked to
|
|
1514
|
+
* a workspace source tree, where `rmSync(dest, { recursive: true })` deletes the
|
|
1515
|
+
* link's target contents.
|
|
1705
1516
|
*/
|
|
1706
1517
|
function assertNodeModulesDest(dest, node) {
|
|
1707
1518
|
const segments = dest.split(path.sep);
|
|
@@ -1715,8 +1526,7 @@ function assertNodeModulesDest(dest, node) {
|
|
|
1715
1526
|
real = fs.realpathSync(dest);
|
|
1716
1527
|
}
|
|
1717
1528
|
catch {
|
|
1718
|
-
// `dest`
|
|
1719
|
-
// logical-path check above is sufficient.
|
|
1529
|
+
// `dest` does not exist yet (fresh install); the logical-path check suffices.
|
|
1720
1530
|
return;
|
|
1721
1531
|
}
|
|
1722
1532
|
const realSegments = real.split(path.sep);
|
|
@@ -1727,8 +1537,7 @@ function assertNodeModulesDest(dest, node) {
|
|
|
1727
1537
|
}
|
|
1728
1538
|
}
|
|
1729
1539
|
function depth(installPath) {
|
|
1730
|
-
//
|
|
1731
|
-
// `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, etc.
|
|
1540
|
+
// `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, …
|
|
1732
1541
|
return installPath.split('/node_modules/').length;
|
|
1733
1542
|
}
|
|
1734
1543
|
/**
|
|
@@ -1750,20 +1559,18 @@ function readShebang(file) {
|
|
|
1750
1559
|
/**
|
|
1751
1560
|
* Materialise ONE `node_modules/.bin/<name>` entry for `targetAbs`.
|
|
1752
1561
|
*
|
|
1753
|
-
* Exported
|
|
1754
|
-
*
|
|
1755
|
-
* `symlinkSync`/`CreateSymbolicLink` syscall behaviour itself is OS-bound.
|
|
1562
|
+
* Exported and platform-injectable so the Windows branch is unit-tested from a Linux
|
|
1563
|
+
* host; only the `symlinkSync`/`CreateSymbolicLink` syscall itself is OS-bound.
|
|
1756
1564
|
*
|
|
1757
|
-
* POSIX: a relative symlink,
|
|
1758
|
-
*
|
|
1565
|
+
* POSIX: a relative symlink, falling back to a copy (some filesystems and container
|
|
1566
|
+
* overlays reject symlinks).
|
|
1759
1567
|
*
|
|
1760
|
-
* Windows: `.bin/<name>` is not on `PATHEXT` and Windows has no shebang
|
|
1761
|
-
*
|
|
1762
|
-
*
|
|
1763
|
-
*
|
|
1764
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
* the same set from the same templates. See `./bin-shim.ts`.
|
|
1568
|
+
* Windows: `.bin/<name>` is not on `PATHEXT` and Windows has no shebang handling, so a
|
|
1569
|
+
* symlink OR a copy of a `#!/usr/bin/env node` script is unrunnable — and a *file*
|
|
1570
|
+
* symlink additionally needs elevation or Developer Mode, so the previous code reliably
|
|
1571
|
+
* fell into its copy fallback and produced a dead entry. npm's answer is three sibling
|
|
1572
|
+
* files (`<name>` for git-bash/MSYS/WSL, `<name>.cmd` for cmd.exe, `<name>.ps1` for
|
|
1573
|
+
* pwsh); the same set is written from the same templates (`./bin-shim.ts`).
|
|
1767
1574
|
*/
|
|
1768
1575
|
export function writeBinEntry(opts) {
|
|
1769
1576
|
const { binDir, binName, targetAbs, gjs, nodeFallbackAbs } = opts;
|
|
@@ -1771,8 +1578,8 @@ export function writeBinEntry(opts) {
|
|
|
1771
1578
|
const linkPath = path.join(binDir, binName);
|
|
1772
1579
|
if (gjs) {
|
|
1773
1580
|
const isBundle = isGjsBundlePath(targetAbs);
|
|
1774
|
-
// Replace the whole sibling set
|
|
1775
|
-
//
|
|
1581
|
+
// Replace the whole sibling set — a previous install may have written a plain
|
|
1582
|
+
// symlink, or npm's three-file shim, under this very name.
|
|
1776
1583
|
for (const stale of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`])
|
|
1777
1584
|
rmWithRetry(stale);
|
|
1778
1585
|
fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble: gjs.envPreamble, isGjsBundle: isBundle, nodeFallbackAbs }));
|
|
@@ -1783,9 +1590,9 @@ export function writeBinEntry(opts) {
|
|
|
1783
1590
|
/* inert on Windows; harmless under WSL/MSYS */
|
|
1784
1591
|
}
|
|
1785
1592
|
if (platform === 'win32' && isBundle) {
|
|
1786
|
-
// No dedicated DLL-search variable on Windows: LoadLibrary searches
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1593
|
+
// No dedicated DLL-search variable on Windows: LoadLibrary searches PATH, and
|
|
1594
|
+
// GLib's search-path separator is `;` there too, so both variables take the
|
|
1595
|
+
// same `;`-joined value.
|
|
1789
1596
|
const joined = gjs.prebuildDirs.join(';');
|
|
1790
1597
|
const prependEnv = gjs.prebuildDirs.length > 0 ? { GI_TYPELIB_PATH: joined, PATH: joined } : {};
|
|
1791
1598
|
const shims = buildLauncherShims({
|
|
@@ -1830,15 +1637,14 @@ export function writeBinEntry(opts) {
|
|
|
1830
1637
|
}
|
|
1831
1638
|
}
|
|
1832
1639
|
async function linkBins(nodes, prefix, log) {
|
|
1833
|
-
// Only root-level packages publish bins into the top-level
|
|
1834
|
-
//
|
|
1835
|
-
//
|
|
1836
|
-
//
|
|
1837
|
-
// backend depends on it (gjsify's own use cases all hit root bins).
|
|
1640
|
+
// Only root-level packages publish bins into the top-level `node_modules/.bin/`.
|
|
1641
|
+
// Nested-bin linking is omitted because no consumer of this backend needs it — a
|
|
1642
|
+
// nested package's bins are addressable by its direct dependents through the nested
|
|
1643
|
+
// .bin, which is what npm does too.
|
|
1838
1644
|
const binDir = path.join(prefix, 'node_modules', '.bin');
|
|
1839
1645
|
let created = 0;
|
|
1840
|
-
// Computed on first use
|
|
1841
|
-
//
|
|
1646
|
+
// Computed on first use: the scan walks the whole prefix, and most installs contain
|
|
1647
|
+
// no package declaring `gjsify.bin` at all.
|
|
1842
1648
|
let gjsEnv;
|
|
1843
1649
|
const gjsEnvForPrefix = () => {
|
|
1844
1650
|
if (gjsEnv === undefined) {
|
|
@@ -1851,11 +1657,11 @@ async function linkBins(nodes, prefix, log) {
|
|
|
1851
1657
|
if (depth(node.installPath) !== 1)
|
|
1852
1658
|
continue;
|
|
1853
1659
|
const pkgDir = path.join(prefix, node.installPath);
|
|
1854
|
-
// The INSTALLED manifest is the source of truth here, not the packument
|
|
1855
|
-
//
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1660
|
+
// The INSTALLED manifest is the source of truth here, not the packument the
|
|
1661
|
+
// resolver carried: `gjsify.bin` is a gjsify-specific field npm's abbreviated
|
|
1662
|
+
// packument does not include, so a packument-only read structurally cannot see
|
|
1663
|
+
// it. Falls back to the resolved npm `bin` when the extracted manifest is
|
|
1664
|
+
// unreadable.
|
|
1859
1665
|
const manifest = readInstalledManifest(pkgDir) ?? (node.bin === undefined ? null : { bin: node.bin });
|
|
1860
1666
|
if (manifest === null)
|
|
1861
1667
|
continue;
|
|
@@ -1873,8 +1679,8 @@ async function linkBins(nodes, prefix, log) {
|
|
|
1873
1679
|
catch {
|
|
1874
1680
|
/* best effort */
|
|
1875
1681
|
}
|
|
1876
|
-
// Only a fallback that EXISTS is worth writing: a launcher naming a
|
|
1877
|
-
//
|
|
1682
|
+
// Only a fallback that EXISTS is worth writing: a launcher naming a missing
|
|
1683
|
+
// Node entry trades exit 127 for a different exit 127.
|
|
1878
1684
|
const fallbackRel = picked.nodeFallback?.get(binName);
|
|
1879
1685
|
const fallbackAbs = fallbackRel === undefined ? undefined : path.join(pkgDir, fallbackRel);
|
|
1880
1686
|
writeBinEntry({
|
|
@@ -1891,12 +1697,10 @@ async function linkBins(nodes, prefix, log) {
|
|
|
1891
1697
|
log('bin: linked %d entry(ies) under .bin/', created);
|
|
1892
1698
|
}
|
|
1893
1699
|
/**
|
|
1894
|
-
* Read an extracted package's `package.json
|
|
1895
|
-
*
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
* linking is not the place to fail an otherwise-complete install. The caller
|
|
1899
|
-
* falls back to the resolved npm `bin`.
|
|
1700
|
+
* Read an extracted package's `package.json`, returning `null` rather than throwing: a
|
|
1701
|
+
* node can legitimately have no manifest on disk (an optional dependency skipped for
|
|
1702
|
+
* this platform), and bin linking is not the place to fail an otherwise-complete
|
|
1703
|
+
* install. The caller falls back to the resolved npm `bin`.
|
|
1900
1704
|
*/
|
|
1901
1705
|
function readInstalledManifest(pkgDir) {
|
|
1902
1706
|
try {
|
|
@@ -1908,14 +1712,11 @@ function readInstalledManifest(pkgDir) {
|
|
|
1908
1712
|
}
|
|
1909
1713
|
}
|
|
1910
1714
|
/**
|
|
1911
|
-
* `fs.rmSync(..., { force: true })` with the Windows retry npm/rimraf apply
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
1915
|
-
*
|
|
1916
|
-
* following create can hit `EPERM`/`ENOTEMPTY`. Node implements the retry loop
|
|
1917
|
-
* for us behind `maxRetries`; it is a no-op on POSIX, where the first attempt
|
|
1918
|
-
* always succeeds.
|
|
1715
|
+
* `fs.rmSync(..., { force: true })` with the Windows retry npm/rimraf apply: there a
|
|
1716
|
+
* delete fails `EBUSY`/`EPERM` while any process holds a handle (antivirus scan, an
|
|
1717
|
+
* editor, a still-terminating child) and stays *pending* until the last handle closes,
|
|
1718
|
+
* so an immediately following create can hit `EPERM`/`ENOTEMPTY`. Node implements the
|
|
1719
|
+
* retry loop behind `maxRetries`; a no-op on POSIX, where the first attempt succeeds.
|
|
1919
1720
|
*/
|
|
1920
1721
|
function rmWithRetry(target) {
|
|
1921
1722
|
fs.rmSync(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
@@ -1928,11 +1729,9 @@ async function loadNpmrc(opts) {
|
|
|
1928
1729
|
authTokens: {},
|
|
1929
1730
|
basicAuth: {},
|
|
1930
1731
|
};
|
|
1931
|
-
// Layered .npmrc lookup
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
1934
|
-
// hit most often (mock-registry tests, scoped-registry overrides), so
|
|
1935
|
-
// we cover that explicitly.
|
|
1732
|
+
// Layered .npmrc lookup, most-specific wins: home → project (the prefix). npm also
|
|
1733
|
+
// merges `XDG_CONFIG_HOME/npm/npmrc` and a workspace-root file; the project-local
|
|
1734
|
+
// case is the one users hit (mock-registry tests, scoped-registry overrides).
|
|
1936
1735
|
for (const candidate of [path.join(home, '.npmrc'), path.join(opts.prefix, '.npmrc')]) {
|
|
1937
1736
|
if (!fs.existsSync(candidate))
|
|
1938
1737
|
continue;
|
|
@@ -1944,11 +1743,11 @@ async function loadNpmrc(opts) {
|
|
|
1944
1743
|
console.warn(`gjsify install: ignoring malformed ${candidate}: ${e.message}`);
|
|
1945
1744
|
}
|
|
1946
1745
|
}
|
|
1947
|
-
//
|
|
1746
|
+
// npm convention.
|
|
1948
1747
|
const envRegistry = process.env.npm_config_registry;
|
|
1949
1748
|
if (envRegistry)
|
|
1950
1749
|
parsed.registry = envRegistry;
|
|
1951
|
-
//
|
|
1750
|
+
// An explicit caller-provided registry trumps everything else.
|
|
1952
1751
|
if (opts.registry)
|
|
1953
1752
|
parsed.registry = opts.registry;
|
|
1954
1753
|
return parsed;
|