@gjsify/cli 0.26.1 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.gjs.mjs +188 -183
- package/lib/bundler-pick.js +12 -4
- package/lib/cli-app.js +3 -1
- package/lib/commands/affected.js +1 -1
- package/lib/commands/build.js +21 -14
- package/lib/commands/check.js +40 -4
- package/lib/commands/clear.d.ts +8 -0
- package/lib/commands/clear.js +90 -0
- package/lib/commands/copy.d.ts +8 -0
- package/lib/commands/copy.js +87 -0
- package/lib/commands/dlx.js +8 -1
- package/lib/commands/index.d.ts +2 -0
- package/lib/commands/index.js +2 -0
- package/lib/commands/install.d.ts +4 -0
- package/lib/commands/install.js +164 -15
- package/lib/commands/onboard.js +17 -14
- package/lib/commands/pack.d.ts +32 -0
- package/lib/commands/pack.js +11 -2
- package/lib/commands/publish.js +18 -4
- package/lib/commands/storybook.js +5 -1
- package/lib/commands/test.js +29 -2
- package/lib/commands/uninstall.js +44 -2
- package/lib/commands/upgrade.js +1 -4
- package/lib/templates/install.mjs.tmpl +110 -19
- package/lib/utils/bin-shim.d.ts +73 -0
- package/lib/utils/bin-shim.js +97 -2
- package/lib/utils/build-cache.d.ts +7 -27
- package/lib/utils/build-cache.js +27 -2
- package/lib/utils/check-system-deps.d.ts +15 -1
- package/lib/utils/check-system-deps.js +126 -2
- package/lib/utils/clear-targets.d.ts +30 -0
- package/lib/utils/clear-targets.js +114 -0
- package/lib/utils/copy-targets.d.ts +41 -0
- package/lib/utils/copy-targets.js +145 -0
- package/lib/utils/detect-native-packages.d.ts +160 -5
- package/lib/utils/detect-native-packages.js +326 -50
- package/lib/utils/dir-link.d.ts +34 -6
- package/lib/utils/dir-link.js +23 -9
- package/lib/utils/install-backend-native.d.ts +109 -1
- package/lib/utils/install-backend-native.js +622 -59
- package/lib/utils/install-backend.js +17 -15
- package/lib/utils/install-global.js +6 -34
- package/lib/utils/install-lock.js +1 -1
- package/lib/utils/install-packument-cache.d.ts +36 -17
- package/lib/utils/install-packument-cache.js +59 -31
- package/lib/utils/oxc-resolve.d.ts +27 -1
- package/lib/utils/oxc-resolve.js +53 -26
- package/lib/utils/pkg-json.d.ts +24 -0
- package/lib/utils/pkg-json.js +27 -1
- package/lib/utils/platform-check.d.ts +215 -0
- package/lib/utils/platform-check.js +377 -0
- package/lib/utils/resolve-npm-package.js +9 -2
- package/lib/utils/run-gjs.d.ts +1 -1
- package/lib/utils/run-gjs.js +19 -3
- package/lib/utils/spawn.js +50 -2
- package/lib/utils/win32-command.d.ts +54 -0
- package/lib/utils/win32-command.js +140 -0
- package/package.json +19 -20
|
@@ -11,6 +11,43 @@
|
|
|
11
11
|
// when its required version is incompatible with what's already at
|
|
12
12
|
// the root. Mirrors npm v3+ behavior.
|
|
13
13
|
//
|
|
14
|
+
// PLATFORM FILTERING (os/cpu/libc) — the tree is RESOLVED for every platform
|
|
15
|
+
// and INSTALLED for one. A node whose declaration excludes the host is marked
|
|
16
|
+
// `inert` (npm's word — arborist build-ideal-tree.js's `node.inert = true`)
|
|
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).
|
|
24
|
+
//
|
|
25
|
+
// TWO INVARIANTS MAKE THAT CLAIM TRUE, and both are structural rather than
|
|
26
|
+
// documented, because the first draft of this feature broke both while reading
|
|
27
|
+
// as if it did not:
|
|
28
|
+
//
|
|
29
|
+
// 1. THE RESOLVE IS TARGET-BLIND. `resolveDeps` is not given the platform
|
|
30
|
+
// target at all — it cannot be, so it cannot record a target-dependent
|
|
31
|
+
// declaration. The draft passed the target in to decide when to escalate to
|
|
32
|
+
// a full packument for `libc`, gated on `target.os === 'linux'`; a lockfile
|
|
33
|
+
// authored on macOS therefore carried NO `libc` for anything, and a Linux
|
|
34
|
+
// colleague running `--immutable` off that file got no libc filtering at
|
|
35
|
+
// all — precisely the direction this header promises is portable. The
|
|
36
|
+
// resolve now reads the FULL document for every package (see resolveDeps),
|
|
37
|
+
// which is also what arborist does for every dependency it resolves
|
|
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
|
|
47
|
+
// fixpoint for the same reason (refs/npm-cli/workspaces/arborist/lib/
|
|
48
|
+
// calc-dep-flags.js: "If a node is changed, we add to the queue and
|
|
49
|
+
// continue until no more changes").
|
|
50
|
+
//
|
|
14
51
|
// Out of scope (still deferred): peerDependencies validation,
|
|
15
52
|
// lifecycle scripts, git/file specs.
|
|
16
53
|
import * as fs from 'node:fs';
|
|
@@ -24,7 +61,9 @@ import { acquireInstallLock } from './install-lock.js';
|
|
|
24
61
|
import { getCachedTarball, getForeignCachedTarball, putCachedTarball, } from './install-tarball-cache.js';
|
|
25
62
|
import { getCachedPackument, putCachedPackument } from './install-packument-cache.js';
|
|
26
63
|
import { assertNativeBackendNodeVersion } from './node-version.js';
|
|
27
|
-
import { buildCmdShim, parseShebang } from './bin-shim.js';
|
|
64
|
+
import { buildCmdShim, buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, isGjsBundlePath, parseShebang, pickBinMap, } from './bin-shim.js';
|
|
65
|
+
import { detectNativePackages } from './detect-native-packages.js';
|
|
66
|
+
import { badPlatformError, checkPlatform, declaresPlatform, describePlatformTarget, readPlatformForce, resolveHostPlatform, } from './platform-check.js';
|
|
28
67
|
// 16-wide download pool. Matched to the shared Soup.Session's lifted
|
|
29
68
|
// `max-conns-per-host` (see @gjsify/fetch `getSharedSession`) — a higher pool
|
|
30
69
|
// here only translates into real concurrency because that cap was raised from
|
|
@@ -32,7 +71,21 @@ import { buildCmdShim, parseShebang } from './bin-shim.js';
|
|
|
32
71
|
// 16) use the same order of magnitude. Override with GJSIFY_INSTALL_CONCURRENCY.
|
|
33
72
|
const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '16') || 16;
|
|
34
73
|
const LOCKFILE_NAME = 'gjsify-lock.json';
|
|
35
|
-
|
|
74
|
+
/**
|
|
75
|
+
* v3 adds the per-entry platform declaration (`os`/`cpu`/`libc`) plus
|
|
76
|
+
* `optional`, which together let a foreign-platform package be recorded without
|
|
77
|
+
* being installed. v2 lockfiles are still READ (see {@link readLockfile}).
|
|
78
|
+
*/
|
|
79
|
+
const LOCKFILE_VERSION = 3;
|
|
80
|
+
/**
|
|
81
|
+
* Lockfile versions this CLI can read. A v2 lockfile is a valid pin set that
|
|
82
|
+
* simply predates the platform fields; rejecting it (the old
|
|
83
|
+
* `!== LOCKFILE_VERSION → null` behaviour) would discard the whole file and let
|
|
84
|
+
* the following fresh resolve bump every `^`-range to the newest in-range
|
|
85
|
+
* version — the exact silent churn lockfile preservation exists to prevent, on
|
|
86
|
+
* every user's first install after upgrading the CLI.
|
|
87
|
+
*/
|
|
88
|
+
const READABLE_LOCKFILE_VERSIONS = new Set([2, LOCKFILE_VERSION]);
|
|
36
89
|
export async function installPackagesNative(opts) {
|
|
37
90
|
// Fail clearly on an unsupported Node major BEFORE touching the ABI-locked
|
|
38
91
|
// native deps — otherwise they SIGSEGV mid-extract with no actionable message.
|
|
@@ -63,7 +116,23 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
63
116
|
const progress = opts.progress;
|
|
64
117
|
const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
|
|
65
118
|
const existingLock = readLockfile(lockfilePath);
|
|
119
|
+
// The triple this install materialises FOR. Normally the running host;
|
|
120
|
+
// `--os/--cpu/--libc` (npm's config keys `os`/`cpu`/`libc`, read from the
|
|
121
|
+
// environment by resolveHostPlatform) override it, which is what makes the
|
|
122
|
+
// darwin/win32/musl selection testable — and reviewable — from one machine.
|
|
123
|
+
const target = resolveHostPlatform();
|
|
124
|
+
const force = readPlatformForce(process.env);
|
|
125
|
+
log('install: platform target %s%s', describePlatformTarget(target), force ? ' (--force: checks bypassed)' : '');
|
|
66
126
|
let nodes;
|
|
127
|
+
/** Did a resolve actually run? Only then is there something new to persist. */
|
|
128
|
+
let resolved = false;
|
|
129
|
+
/**
|
|
130
|
+
* Edges whose resolve FAILED and was tolerated because the edge looked
|
|
131
|
+
* optional at the time it was visited. Checked against the final (fixpoint)
|
|
132
|
+
* optionality below — see {@link assertRequiredEdgesResolved}. Empty on the
|
|
133
|
+
* lockfile paths, which resolve nothing.
|
|
134
|
+
*/
|
|
135
|
+
let skippedEdges = new Set();
|
|
67
136
|
if (opts.frozen) {
|
|
68
137
|
// --immutable / --frozen: lockfile is the authoritative source.
|
|
69
138
|
// Reject if the file is missing, version-mismatched, or its
|
|
@@ -82,12 +151,22 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
82
151
|
log('install: --immutable, using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
|
|
83
152
|
nodes = lockfileToNodes(existingLock);
|
|
84
153
|
}
|
|
85
|
-
else if (!opts.refreshLockfile &&
|
|
154
|
+
else if (!opts.refreshLockfile &&
|
|
155
|
+
existingLock &&
|
|
156
|
+
existingLock.lockfileVersion === LOCKFILE_VERSION &&
|
|
157
|
+
lockfileMatchesRequest(existingLock, opts.specs)) {
|
|
86
158
|
log('install: using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
|
|
87
159
|
nodes = lockfileToNodes(existingLock);
|
|
88
160
|
}
|
|
89
161
|
else {
|
|
90
162
|
// A resolve has to run (new/changed/removed dep, or no lockfile yet).
|
|
163
|
+
//
|
|
164
|
+
// A pre-v3 lockfile lands here too, even when it matches the request:
|
|
165
|
+
// its entries carry no platform declarations, so consuming it verbatim
|
|
166
|
+
// would keep installing the foreign-platform tree forever with nothing
|
|
167
|
+
// on disk to recompute the verdict from. One resolve — version-
|
|
168
|
+
// preserving, per the seeding below, so nothing bumps — upgrades it.
|
|
169
|
+
//
|
|
91
170
|
// Unless --refresh-lockfile was passed, seed it with the versions
|
|
92
171
|
// already pinned in the existing lockfile so unchanged deps keep their
|
|
93
172
|
// resolved version and only the genuinely new/changed deps move — the
|
|
@@ -96,11 +175,29 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
96
175
|
// (and silently bumping transitive deps) on a one-package add.
|
|
97
176
|
const preferred = !opts.refreshLockfile && existingLock ? buildPreferredVersions(existingLock) : undefined;
|
|
98
177
|
log('install: resolving %d top-level spec(s) → %s%s', opts.specs.length, opts.prefix, preferred ? ` (preserving ${preferred.size} locked name(s))` : '');
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
178
|
+
// NOTE the absent argument: `target` is deliberately NOT passed. The
|
|
179
|
+
// resolve must produce the same tree and the same recorded declarations
|
|
180
|
+
// on every machine (header note, invariant 1), and the cheapest way to
|
|
181
|
+
// keep that true is to give it nothing target-shaped to read.
|
|
182
|
+
const resolveResult = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress, preferred, opts.workspaceNames, opts.specOrigins, opts.optionalSpecs);
|
|
183
|
+
nodes = resolveResult.nodes;
|
|
184
|
+
skippedEdges = resolveResult.skippedEdges;
|
|
185
|
+
resolved = true;
|
|
186
|
+
}
|
|
187
|
+
// `optional` is DERIVED here, on BOTH paths, for the same reason
|
|
188
|
+
// `applyPlatformFilter` is: the answer must not depend on which path the tree
|
|
189
|
+
// arrived by, and a flag read back from a file is an INPUT nothing checked.
|
|
190
|
+
// Runs BEFORE writeLockfile (so the persisted flag is the final one) and
|
|
191
|
+
// before the platform verdict (which reads it to decide fatal vs. inert), and
|
|
192
|
+
// before the workspace filter below (which removes nodes and would truncate
|
|
193
|
+
// the walk).
|
|
194
|
+
computeOptionalFlags(nodes, requiredTopLevelNames(opts.specs, opts.optionalSpecs), log);
|
|
195
|
+
assertRequiredEdgesResolved(nodes, skippedEdges);
|
|
196
|
+
if (resolved && opts.lockfile) {
|
|
197
|
+
// The FULL resolved set, inert nodes included — see the header note:
|
|
198
|
+
// the lockfile is the portable artifact, the platform verdict is not.
|
|
199
|
+
writeLockfile(lockfilePath, opts.specs, nodes);
|
|
200
|
+
log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
|
|
104
201
|
}
|
|
105
202
|
// A package whose name is one of the monorepo's own workspaces is provided
|
|
106
203
|
// by a workspace symlink (wired by `workspaceInstall`), NOT by a registry
|
|
@@ -119,10 +216,14 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
119
216
|
log('install: %d workspace-provided package(s) symlinked, not fetched', dropped);
|
|
120
217
|
}
|
|
121
218
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
219
|
+
// os/cpu/libc: throws for an incompatible REQUIRED dep, marks incompatible
|
|
220
|
+
// OPTIONAL ones inert. Runs on BOTH paths (fresh resolve and lockfile) —
|
|
221
|
+
// that is what makes one committed lockfile install a per-host subset.
|
|
222
|
+
const installable = applyPlatformFilter(nodes, target, force, log);
|
|
223
|
+
log('install: downloading %d tarball(s)', installable.length);
|
|
224
|
+
await downloadAndExtractAll(installable, opts.prefix, npmrc, log, opts.signal, progress);
|
|
225
|
+
await linkBins(installable, opts.prefix, log);
|
|
226
|
+
warnMissingNativeBuilds(installable, opts.prefix, log);
|
|
126
227
|
log('install: done');
|
|
127
228
|
// Surface the top-level requested packages so callers can update
|
|
128
229
|
// package.json with the resolved version (mirrors `npm install --save`
|
|
@@ -134,6 +235,65 @@ function errMsg(err) {
|
|
|
134
235
|
return err.message;
|
|
135
236
|
return String(err);
|
|
136
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Split a resolved tree into what THIS target can install, marking the rest
|
|
240
|
+
* inert. Mirrors arborist's post-resolve platform pass
|
|
241
|
+
* (`refs/npm-cli/workspaces/arborist/lib/arborist/build-ideal-tree.js`, the
|
|
242
|
+
* `checkPlatform` loop around line 210) rather than filtering during the walk,
|
|
243
|
+
* for two reasons: the verdict must be identical whether the tree came from a
|
|
244
|
+
* fresh resolve or from `gjsify-lock.json`, and a node's optionality is only
|
|
245
|
+
* final once every edge that could reach it has been visited.
|
|
246
|
+
*
|
|
247
|
+
* The two outcomes are npm's, including the asymmetry around `force`:
|
|
248
|
+
* - REQUIRED + incompatible → EBADPLATFORM. A required dependency the host
|
|
249
|
+
* cannot run is a broken install, not a smaller one; failing loudly with
|
|
250
|
+
* `pkgid`/`current`/`required` is the only honest answer. `--force`
|
|
251
|
+
* suppresses it (the user is asserting something about their own machine).
|
|
252
|
+
* - OPTIONAL + incompatible → inert: not downloaded, not extracted, still in
|
|
253
|
+
* the lockfile. `--force` does NOT lift this — npm says so in as many words
|
|
254
|
+
* ("We ignore the --force and --engine-strict flags") and it is right:
|
|
255
|
+
* forcing an optional binary that cannot load buys a download and nothing
|
|
256
|
+
* else.
|
|
257
|
+
*
|
|
258
|
+
* The skip line goes through the debug logger with npm's `current`/`required`
|
|
259
|
+
* payload, so `--verbose` recovers exactly WHY a package is absent. Silence
|
|
260
|
+
* here is what made the original defect invisible in the other direction: 3.67
|
|
261
|
+
* GB arrived with no line saying it should not have.
|
|
262
|
+
*/
|
|
263
|
+
export function applyPlatformFilter(nodes, target, force, log) {
|
|
264
|
+
const installable = [];
|
|
265
|
+
let skipped = 0;
|
|
266
|
+
for (const node of nodes) {
|
|
267
|
+
const declaration = node.platform;
|
|
268
|
+
if (!declaration || !declaresPlatform(declaration)) {
|
|
269
|
+
installable.push(node);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const verdict = checkPlatform(declaration, target);
|
|
273
|
+
if (verdict.ok) {
|
|
274
|
+
installable.push(node);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const pkgid = `${node.name}@${node.version}`;
|
|
278
|
+
if (!node.optional) {
|
|
279
|
+
if (force) {
|
|
280
|
+
log('platform-forced: %s (required, incompatible, installed anyway via --force)', pkgid);
|
|
281
|
+
installable.push(node);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
throw badPlatformError(pkgid, verdict);
|
|
285
|
+
}
|
|
286
|
+
node.inert = true;
|
|
287
|
+
skipped++;
|
|
288
|
+
log('platform-skip: %s (%s) current=%s required=%s', pkgid, node.installPath, JSON.stringify(verdict.current), JSON.stringify(verdict.required));
|
|
289
|
+
}
|
|
290
|
+
if (skipped > 0) {
|
|
291
|
+
// Verbose-only, like every other per-install summary here — and like npm,
|
|
292
|
+
// which reports inert optional packages only through its final count.
|
|
293
|
+
log('install: %d optional package(s) inert — not installable on %s', skipped, describePlatformTarget(target));
|
|
294
|
+
}
|
|
295
|
+
return installable;
|
|
296
|
+
}
|
|
137
297
|
/**
|
|
138
298
|
* Warn about a native package that installed WITHOUT a usable binary.
|
|
139
299
|
*
|
|
@@ -239,6 +399,16 @@ function parseSpecName(spec) {
|
|
|
239
399
|
* specs are seeded with a synthetic `null` requester so they hoist to
|
|
240
400
|
* the root. Each placement returns a `ResolvedNode` whose `installPath`
|
|
241
401
|
* captures where it lives in the tree.
|
|
402
|
+
*
|
|
403
|
+
* TAKES NO PLATFORM TARGET, and that is a load-bearing absence (header note,
|
|
404
|
+
* invariant 1): the tree it produces and the `os`/`cpu`/`libc` it records must be
|
|
405
|
+
* identical on every machine, so the platform verdict is a separate post-pass
|
|
406
|
+
* (`applyPlatformFilter`) that this function cannot influence. An earlier draft
|
|
407
|
+
* did take one — to decide when to escalate to a full packument for `libc`,
|
|
408
|
+
* gated on `target.os === 'linux'` — and a macOS-authored lockfile ended up with
|
|
409
|
+
* no `libc` recorded anywhere, i.e. unusable for filtering on the Linux hosts it
|
|
410
|
+
* was committed for. Do not reintroduce the parameter: the resolve now reads the
|
|
411
|
+
* full document unconditionally, which needs no target and no heuristic.
|
|
242
412
|
*/
|
|
243
413
|
async function resolveDeps(specs, npmrc, log, overrides, skipDeps, signal, progress,
|
|
244
414
|
/**
|
|
@@ -261,7 +431,15 @@ workspaceNames,
|
|
|
261
431
|
* names) — see `InstallOptions.specOrigins`. Used only to attribute the
|
|
262
432
|
* version-conflict warning.
|
|
263
433
|
*/
|
|
264
|
-
specOrigins
|
|
434
|
+
specOrigins,
|
|
435
|
+
/**
|
|
436
|
+
* Names of top-level specs declared as `optionalDependencies` — see
|
|
437
|
+
* {@link NativeInstallOptions.optionalSpecs}. Such an edge is seeded
|
|
438
|
+
* `required: false`, so a resolve failure is skipped rather than fatal. It
|
|
439
|
+
* also seeds the optional-flag walk, but the FINAL flag comes from
|
|
440
|
+
* {@link computeOptionalFlags}, not from this walk.
|
|
441
|
+
*/
|
|
442
|
+
optionalSpecs) {
|
|
265
443
|
progress?.beginPhase('resolve', specs.length);
|
|
266
444
|
const applyOverride = (name, range) => {
|
|
267
445
|
if (!overrides)
|
|
@@ -279,7 +457,32 @@ specOrigins) {
|
|
|
279
457
|
const cached = packumentCache.get(name);
|
|
280
458
|
if (cached)
|
|
281
459
|
return cached;
|
|
282
|
-
|
|
460
|
+
// FULL document (`accept: application/json`), not the abbreviated
|
|
461
|
+
// "corgi" install document — the same choice arborist makes for every
|
|
462
|
+
// dependency it resolves (`#fetchManifest` sets `fullMetadata: true`,
|
|
463
|
+
// refs/npm-cli/workspaces/arborist/lib/arborist/build-ideal-tree.js).
|
|
464
|
+
//
|
|
465
|
+
// WHY, and why no cheaper trigger exists: the registry omits `libc` from
|
|
466
|
+
// the abbreviated body (measured — `@rollup/rollup-linux-x64-musl` returns
|
|
467
|
+
// `{os,cpu}` under the corgi accept header and `{os,cpu,libc}` under
|
|
468
|
+
// `application/json`), so the abbreviated body can never PROVE that a
|
|
469
|
+
// version has no libc restriction. Every "escalate only where it can
|
|
470
|
+
// matter" rule therefore has a hole, and the first draft's rule — escalate
|
|
471
|
+
// when the corgi entry declares `os` or `cpu` — had exactly the hole that
|
|
472
|
+
// matters here: the nine `@gjsify/*` bridges declare `libc: ["glibc"]`
|
|
473
|
+
// with NO `os`/`cpu`, so on Alpine the installer would have handed a
|
|
474
|
+
// glibc-only prebuild to a musl host and failed at `dlopen` instead of at
|
|
475
|
+
// install time.
|
|
476
|
+
//
|
|
477
|
+
// Cost, measured over 15 representative deps of this workspace (transfer
|
|
478
|
+
// bytes, largest packages dominating): the full document is 1.26× the
|
|
479
|
+
// abbreviated one. Corgi-plus-escalate-everything would be 2.26× and
|
|
480
|
+
// twice the requests; corgi-plus-escalate-the-declaring-~12% (the draft)
|
|
481
|
+
// was ~1.15× and 1.12× the requests, i.e. this change costs ~10% more
|
|
482
|
+
// metadata bytes and ~11% FEWER round-trips than the shape it replaces.
|
|
483
|
+
// That is the whole trade: one authoritative document per package instead
|
|
484
|
+
// of two documents and a heuristic that cannot be made sound.
|
|
485
|
+
const fresh = fetchPackumentWithDiskCache(name, npmrc, log, signal, 'full');
|
|
283
486
|
packumentCache.set(name, fresh);
|
|
284
487
|
return fresh;
|
|
285
488
|
};
|
|
@@ -287,6 +490,8 @@ specOrigins) {
|
|
|
287
490
|
const byPath = new Map();
|
|
288
491
|
/** Root placements indexed by name for the hoist-vs-nest decision. */
|
|
289
492
|
const root = new Map();
|
|
493
|
+
/** See {@link ResolveResult.skippedEdges}. Keyed by {@link edgeKey}. */
|
|
494
|
+
const skippedEdges = new Set();
|
|
290
495
|
// Top-level range bookkeeping for the version-conflict warning:
|
|
291
496
|
// `name → (applied range → requester labels)`. Only TOP-LEVEL specs
|
|
292
497
|
// participate: conflicting transitive edges are resolved correctly by
|
|
@@ -315,7 +520,7 @@ specOrigins) {
|
|
|
315
520
|
from: null,
|
|
316
521
|
name: s.name,
|
|
317
522
|
range,
|
|
318
|
-
required:
|
|
523
|
+
required: !optionalSpecs?.has(s.name),
|
|
319
524
|
};
|
|
320
525
|
});
|
|
321
526
|
// Wave-based BFS. Each iteration drains the current queue level, prefetches
|
|
@@ -363,6 +568,15 @@ specOrigins) {
|
|
|
363
568
|
const visible = findVisible(edge.from, edge.name, byPath);
|
|
364
569
|
if (visible && satisfiesRange(visible.version, edge.range)) {
|
|
365
570
|
// Compatible placement reachable; reuse, no new install.
|
|
571
|
+
//
|
|
572
|
+
// NOTHING IS PROMOTED HERE. A `visible.optional = false` on a
|
|
573
|
+
// required edge is the obvious move and it is wrong: the node's own
|
|
574
|
+
// dep edges were already queued with the STALE flag, so its
|
|
575
|
+
// children stayed optional and the one-shot promotion produced an
|
|
576
|
+
// order-dependent answer (the reviewed defect). Optionality is
|
|
577
|
+
// recomputed as a fixpoint over the finished graph instead —
|
|
578
|
+
// {@link computeOptionalFlags}, which is order-independent by
|
|
579
|
+
// construction and is the only writer of the final flag.
|
|
366
580
|
continue;
|
|
367
581
|
}
|
|
368
582
|
// No compatible existing placement. Resolve a version — preferring a
|
|
@@ -374,8 +588,12 @@ specOrigins) {
|
|
|
374
588
|
const preferred = preferredVersionFor(preferredVersions?.get(edge.name), edge.range);
|
|
375
589
|
version = pickVersion(packument, edge.range, preferred);
|
|
376
590
|
if (!version) {
|
|
377
|
-
|
|
378
|
-
|
|
591
|
+
// Throw even for an optional edge and let the catch below
|
|
592
|
+
// decide: ONE place that knows what a tolerated failure costs
|
|
593
|
+
// (a log line and a `skippedEdges` entry). The `if
|
|
594
|
+
// (!edge.required) continue` this replaces was a second,
|
|
595
|
+
// silent copy of that decision — it skipped with no log line
|
|
596
|
+
// at all and left nothing for the post-pass to re-judge.
|
|
379
597
|
throw new Error(`No version of ${edge.name} satisfies ${edge.range}`);
|
|
380
598
|
}
|
|
381
599
|
const v = packument.versions[version];
|
|
@@ -398,6 +616,15 @@ specOrigins) {
|
|
|
398
616
|
dependencies: v.dependencies ?? {},
|
|
399
617
|
optionalDependencies: v.optionalDependencies ?? {},
|
|
400
618
|
bin: v.bin,
|
|
619
|
+
// Complete as published — `fetchPkg` reads the FULL document,
|
|
620
|
+
// so all three fields come from one authoritative body and no
|
|
621
|
+
// second fetch or per-package heuristic is involved.
|
|
622
|
+
platform: platformDeclarationOf(v),
|
|
623
|
+
// Forward guess only; `computeOptionalFlags` overwrites it.
|
|
624
|
+
// It is still worth setting: the child edges queued below read
|
|
625
|
+
// it, which is what keeps a failure under a plainly-optional
|
|
626
|
+
// subtree non-fatal at the point of failure.
|
|
627
|
+
optional: !edge.required,
|
|
401
628
|
};
|
|
402
629
|
byPath.set(installPath, node);
|
|
403
630
|
if (installPath === `node_modules/${edge.name}`) {
|
|
@@ -418,8 +645,18 @@ specOrigins) {
|
|
|
418
645
|
queue.push({
|
|
419
646
|
from: installPath,
|
|
420
647
|
name: depName,
|
|
648
|
+
// A dependency of an OPTIONAL node inherits its
|
|
649
|
+
// optionality — npm's `optionalSet`. Load-bearing for
|
|
650
|
+
// the platform check, not a nicety: we resolve
|
|
651
|
+
// foreign-platform optional packages on purpose (the
|
|
652
|
+
// lockfile must stay portable), so their own required
|
|
653
|
+
// deps get visited too. `fsevents` (darwin-only) is
|
|
654
|
+
// the live shape — treating its subtree as required
|
|
655
|
+
// would raise EBADPLATFORM for a package nothing on
|
|
656
|
+
// this host will ever load, i.e. fail every Linux
|
|
657
|
+
// install over a darwin-only optional dep.
|
|
421
658
|
range: applyOverride(depName, depRange),
|
|
422
|
-
required:
|
|
659
|
+
required: !node.optional,
|
|
423
660
|
});
|
|
424
661
|
}
|
|
425
662
|
for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
|
|
@@ -436,7 +673,14 @@ specOrigins) {
|
|
|
436
673
|
// Optional deps that fail to resolve are skipped — yarn/npm
|
|
437
674
|
// behavior. Required deps re-throw.
|
|
438
675
|
if (!edge.required) {
|
|
439
|
-
|
|
676
|
+
// RECORDED, not merely logged: `edge.required` is the walk's
|
|
677
|
+
// forward guess, and a later required edge can still make this
|
|
678
|
+
// node mandatory. `assertRequiredEdgesResolved` re-judges every
|
|
679
|
+
// entry against the fixpoint flags, so a genuinely required
|
|
680
|
+
// dependency cannot end up missing from the tree just because
|
|
681
|
+
// BFS order happened to reach it through an optional edge first.
|
|
682
|
+
skippedEdges.add(edgeKey(edge.from, edge.name));
|
|
683
|
+
log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, errMsg(e));
|
|
440
684
|
continue;
|
|
441
685
|
}
|
|
442
686
|
throw e;
|
|
@@ -445,7 +689,237 @@ specOrigins) {
|
|
|
445
689
|
}
|
|
446
690
|
progress?.endPhase('resolve');
|
|
447
691
|
emitTopLevelConflictWarnings(topLevelRanges, root);
|
|
448
|
-
|
|
692
|
+
// NO declaration post-pass here, deliberately. `fetchPkg` already read the
|
|
693
|
+
// FULL document for every package, so each node's `platform` is complete as
|
|
694
|
+
// published and there is nothing left to escalate. The draft DID have a
|
|
695
|
+
// second `libc`-only fetch at exactly this line, and it took the platform
|
|
696
|
+
// target to decide when to fire — the one argument this function must never
|
|
697
|
+
// have (header note, invariant 1). Reintroducing the pass means
|
|
698
|
+
// reintroducing the parameter, which is how a macOS-authored lockfile ended
|
|
699
|
+
// up with no `libc` recorded for anyone.
|
|
700
|
+
//
|
|
701
|
+
// `optional` is likewise NOT finalised here: the flags on these nodes are
|
|
702
|
+
// still the walk's forward guess. `computeOptionalFlags` overwrites them at
|
|
703
|
+
// the call site, on this path and on the lockfile path alike.
|
|
704
|
+
return { nodes: Array.from(byPath.values()), skippedEdges };
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* The platform declaration a packument version carries, or undefined when it
|
|
708
|
+
* declares none (the common case — keeping it undefined keeps the node and the
|
|
709
|
+
* lockfile entry minimal). Pure read, no I/O.
|
|
710
|
+
*/
|
|
711
|
+
function platformDeclarationOf(version) {
|
|
712
|
+
const declaration = { os: version.os, cpu: version.cpu, libc: version.libc };
|
|
713
|
+
return declaresPlatform(declaration) ? declaration : undefined;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Identity of one dependency EDGE: the requester's `installPath` (empty string
|
|
717
|
+
* for the project root) plus the dependency name.
|
|
718
|
+
*
|
|
719
|
+
* The PAIR is the identity, never the name alone. The same unresolvable name can
|
|
720
|
+
* be legitimately absent below an optional subtree and a hard error below a
|
|
721
|
+
* required one — only the requester says which, so a `skippedEdges` keyed by
|
|
722
|
+
* name would collapse the two cases and answer whichever it saw last.
|
|
723
|
+
*
|
|
724
|
+
* `\n` is the separator because no npm package name and no install path can
|
|
725
|
+
* contain one (npm rejects control characters in names, and every path here is
|
|
726
|
+
* built out of names), so the key round-trips through {@link parseEdgeKey}
|
|
727
|
+
* unambiguously. Deliberately NOT `\u0000`, the other obvious choice: the GJS
|
|
728
|
+
* bundle minifier rewrites that escape into a raw NUL byte and GJS then refuses
|
|
729
|
+
* to parse the bundle at all ("template literal not terminated"). A separator
|
|
730
|
+
* that only fails once this file is bundled for the runtime it ships on is not
|
|
731
|
+
* worth the theoretical tidiness.
|
|
732
|
+
*/
|
|
733
|
+
function edgeKey(from, name) {
|
|
734
|
+
return `${from ?? ''}\n${name}`;
|
|
735
|
+
}
|
|
736
|
+
/** Inverse of {@link edgeKey}; `from` is null for a top-level (project) edge. */
|
|
737
|
+
function parseEdgeKey(key) {
|
|
738
|
+
const sep = key.indexOf('\n');
|
|
739
|
+
const from = key.slice(0, sep);
|
|
740
|
+
return { from: from === '' ? null : from, name: key.slice(sep + 1) };
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Seed set for {@link computeOptionalFlags}: the NAMES of the top-level specs
|
|
744
|
+
* the project did not declare optional. Everything else in the tree has to EARN
|
|
745
|
+
* its required status by being reachable from one of these.
|
|
746
|
+
*
|
|
747
|
+
* The parse is load-bearing. `specs` are flat `"<name>@<range>"` strings while
|
|
748
|
+
* `optionalSpecs` holds bare NAMES (the range differs per requester — see
|
|
749
|
+
* {@link NativeInstallOptions.optionalSpecs}), so testing a spec string against
|
|
750
|
+
* the set is a lookup that can never hit: every top-level optionalDependency
|
|
751
|
+
* would be seeded as REQUIRED, and the `optionalDependencies: { fsevents }`
|
|
752
|
+
* shape would fail every Linux install with EBADPLATFORM instead of thinning.
|
|
753
|
+
* An earlier version of this walk compared the two directly and did exactly
|
|
754
|
+
* that; the `(c) an incompatible OPTIONAL top-level dep is skipped, not fatal`
|
|
755
|
+
* case in `tests/e2e/install-platform-filter` is where it is pinned.
|
|
756
|
+
*/
|
|
757
|
+
function requiredTopLevelNames(specs, optionalSpecs) {
|
|
758
|
+
const names = new Set();
|
|
759
|
+
for (const spec of specs) {
|
|
760
|
+
const name = parseSpecName(spec);
|
|
761
|
+
if (optionalSpecs?.has(name))
|
|
762
|
+
continue;
|
|
763
|
+
names.add(name);
|
|
764
|
+
}
|
|
765
|
+
return names;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Recompute every node's `optional` flag as a FIXPOINT over the placed graph.
|
|
769
|
+
* The only writer of the final flag; whatever the nodes arrive carrying is an
|
|
770
|
+
* input nothing checked (a BFS forward guess on the resolve path, a value read
|
|
771
|
+
* back out of a file on the lockfile path) and is overwritten unconditionally.
|
|
772
|
+
*
|
|
773
|
+
* DEFINITION: a node is REQUIRED iff it is reachable from `requiredNames`
|
|
774
|
+
* through `dependencies` edges alone; every other node is optional. Optionality
|
|
775
|
+
* is therefore INHERITED — a plain dependency OF an optional package is still
|
|
776
|
+
* optional — which is what npm's `optionalSet` computes.
|
|
777
|
+
*
|
|
778
|
+
* WHY A FIXPOINT AND NOT A ONE-SHOT PROMOTION AT THE REUSE SITE (the reviewed
|
|
779
|
+
* defect): the walk queues a node's own dep edges at the moment it is placed,
|
|
780
|
+
* carrying the optionality it had THEN. Promoting the node later leaves its
|
|
781
|
+
* children behind, so the answer depends on which edge BFS happened to traverse
|
|
782
|
+
* first — a genuinely required transitive dep reached first through an optional
|
|
783
|
+
* edge stayed flagged optional, was persisted that way, and was then silently
|
|
784
|
+
* skipped by the platform filter on every machine instead of raising
|
|
785
|
+
* EBADPLATFORM. A monotone worklist over the FINISHED graph cannot have that
|
|
786
|
+
* property: "required" only ever spreads, so the result is independent of visit
|
|
787
|
+
* order. npm reaches for the same shape and says so —
|
|
788
|
+
* refs/npm-cli/workspaces/arborist/lib/calc-dep-flags.js: "If a node is changed,
|
|
789
|
+
* we add to the queue and continue until no more changes."
|
|
790
|
+
*
|
|
791
|
+
* PATH-INDEPENDENCE IS THE OTHER REQUIREMENT, and it is why the walk reads
|
|
792
|
+
* `dependencies` ONLY. A lockfile entry records `dependencies` and nothing else
|
|
793
|
+
* (an `optionalDependencies` map is not persisted), so consulting
|
|
794
|
+
* `node.optionalDependencies` here would make the fresh-resolve path and the
|
|
795
|
+
* lockfile path compute different flags for the same tree — precisely the split
|
|
796
|
+
* this pass exists to close. The cost is one npm quirk we do not reproduce: a
|
|
797
|
+
* publisher listing the same name in BOTH blocks means "optional" to npm, and
|
|
798
|
+
* comes out required here. That shape is vanishingly rare in practice (the
|
|
799
|
+
* platform-binary packages this feature is about — rollup, lightningcss,
|
|
800
|
+
* esbuild, fsevents — all list their optional deps in one block only), and
|
|
801
|
+
* erring toward REQUIRED errs toward the loud failure, not the silent one.
|
|
802
|
+
*
|
|
803
|
+
* Runs before `writeLockfile` so the persisted flag is the final one, and before
|
|
804
|
+
* `applyPlatformFilter` so the fatal-vs-inert decision reads it rather than the
|
|
805
|
+
* guess.
|
|
806
|
+
*/
|
|
807
|
+
function computeOptionalFlags(nodes, requiredNames, log) {
|
|
808
|
+
const byPath = new Map();
|
|
809
|
+
for (const node of nodes)
|
|
810
|
+
byPath.set(node.installPath, node);
|
|
811
|
+
/** `installPath`s proven reachable through required edges alone. */
|
|
812
|
+
const required = new Set();
|
|
813
|
+
/** Nodes newly proven required and not yet expanded. */
|
|
814
|
+
const worklist = [];
|
|
815
|
+
const enter = (node) => {
|
|
816
|
+
if (required.has(node.installPath))
|
|
817
|
+
return;
|
|
818
|
+
required.add(node.installPath);
|
|
819
|
+
worklist.push(node.installPath);
|
|
820
|
+
};
|
|
821
|
+
// Top-level specs always hoist (`decidePlacement` returns the root slot for
|
|
822
|
+
// a null requester), so the seed lookup is exact. A name with no placement
|
|
823
|
+
// is a workspace member satisfied by its symlink — nothing to flag.
|
|
824
|
+
for (const name of requiredNames) {
|
|
825
|
+
const seed = byPath.get(`node_modules/${name}`);
|
|
826
|
+
if (seed)
|
|
827
|
+
enter(seed);
|
|
828
|
+
}
|
|
829
|
+
while (worklist.length > 0) {
|
|
830
|
+
const installPath = worklist.pop();
|
|
831
|
+
if (installPath === undefined)
|
|
832
|
+
break;
|
|
833
|
+
const node = byPath.get(installPath);
|
|
834
|
+
if (!node)
|
|
835
|
+
continue;
|
|
836
|
+
for (const depName of Object.keys(node.dependencies)) {
|
|
837
|
+
// Resolve the edge the way the REQUESTER will at runtime — through
|
|
838
|
+
// the ancestor `node_modules` chain — so a nested copy is credited
|
|
839
|
+
// to the requester that nested it and the hoisted one is not
|
|
840
|
+
// accidentally kept alive by a dependent that cannot even see it.
|
|
841
|
+
const resolvedTo = findVisible(installPath, depName, byPath);
|
|
842
|
+
if (resolvedTo)
|
|
843
|
+
enter(resolvedTo);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Count the disagreements, don't just apply them. A nonzero `corrected` is
|
|
847
|
+
// the signal that the incoming flags (the walk's guess, or a lockfile
|
|
848
|
+
// written by an older CLI) were wrong for this tree — the class of bug this
|
|
849
|
+
// pass exists for, otherwise invisible because the corrected result looks
|
|
850
|
+
// exactly like a correct one.
|
|
851
|
+
let corrected = 0;
|
|
852
|
+
for (const node of nodes) {
|
|
853
|
+
const optional = !required.has(node.installPath);
|
|
854
|
+
if ((node.optional ?? false) !== optional)
|
|
855
|
+
corrected++;
|
|
856
|
+
node.optional = optional;
|
|
857
|
+
}
|
|
858
|
+
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)` : '');
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Re-judge every failure the resolve swallowed against the FIXPOINT flags, and
|
|
862
|
+
* throw if one of them turned out to be required after all.
|
|
863
|
+
*
|
|
864
|
+
* `resolveDeps` tolerates a failed edge on the strength of `edge.required`,
|
|
865
|
+
* which is a forward guess made while the graph was still incomplete. This is
|
|
866
|
+
* the second half of that bargain: without it, a genuinely required dependency
|
|
867
|
+
* disappears from the tree for no better reason than BFS reaching it through an
|
|
868
|
+
* optional edge first — an install that "succeeded" and produced a tree that
|
|
869
|
+
* cannot run, with the explanation confined to a `--verbose` line nobody read.
|
|
870
|
+
*
|
|
871
|
+
* Same shape as npm's `#pruneFailedOptional()` ("if (!node.optional) throw
|
|
872
|
+
* node.errors[0]"), including its position AFTER the flag pass. The difference
|
|
873
|
+
* is what we hold: npm placed a real (broken) Node and still has the original
|
|
874
|
+
* error, we recorded only the edge — so the message has to name the edge and
|
|
875
|
+
* point at the verbose line that carries the cause.
|
|
876
|
+
*
|
|
877
|
+
* Three tolerated cases, and each is a property of the EDGE, not of the tree:
|
|
878
|
+
* a root edge the project itself declared optional, an edge under a subtree the
|
|
879
|
+
* fixpoint says is optional-only, and a required node's own
|
|
880
|
+
* `optionalDependencies` entry.
|
|
881
|
+
*/
|
|
882
|
+
function assertRequiredEdgesResolved(nodes, skippedEdges) {
|
|
883
|
+
if (skippedEdges.size === 0)
|
|
884
|
+
return;
|
|
885
|
+
const byPath = new Map();
|
|
886
|
+
for (const node of nodes)
|
|
887
|
+
byPath.set(node.installPath, node);
|
|
888
|
+
for (const key of skippedEdges) {
|
|
889
|
+
const { from, name } = parseEdgeKey(key);
|
|
890
|
+
// Top-level edge: tolerated only because the project's own manifest put
|
|
891
|
+
// the spec in `optionalDependencies` (that is the ONLY way
|
|
892
|
+
// `requiredTopLevelNames` leaves it out of the seed set). The manifest
|
|
893
|
+
// is the whole truth for a root edge — no later edge can revise it.
|
|
894
|
+
if (from === null)
|
|
895
|
+
continue;
|
|
896
|
+
const requester = byPath.get(from);
|
|
897
|
+
// Requester never made it into the tree. Nothing depends on this edge
|
|
898
|
+
// any more, so nothing is missing.
|
|
899
|
+
if (!requester)
|
|
900
|
+
continue;
|
|
901
|
+
// The whole subtree is reachable only through optional edges — npm's
|
|
902
|
+
// `optionalSet`, skipped in silence.
|
|
903
|
+
if (requester.optional)
|
|
904
|
+
continue;
|
|
905
|
+
// A REQUIRED package's own optionalDependency stays optional whatever
|
|
906
|
+
// the package's flag says: optionality lives on the edge, not on its
|
|
907
|
+
// endpoints. This branch is what keeps a darwin-only `fsevents` under a
|
|
908
|
+
// required `chokidar` from failing every Linux install.
|
|
909
|
+
//
|
|
910
|
+
// Safe on both paths because `skippedEdges` is only ever non-empty on
|
|
911
|
+
// the fresh-resolve path, where `optionalDependencies` is populated; the
|
|
912
|
+
// lockfile paths resolve nothing and reach here with an empty set.
|
|
913
|
+
if (name in requester.optionalDependencies)
|
|
914
|
+
continue;
|
|
915
|
+
throw new Error(`install: required dependency ${name} of ${requester.name}@${requester.version} ` +
|
|
916
|
+
`(${requester.installPath}) could not be resolved.\n` +
|
|
917
|
+
`The failure was tolerated during the walk because ${requester.name} looked optional at that ` +
|
|
918
|
+
`point, but the final dependency graph makes it required — so ${name} is required too and the ` +
|
|
919
|
+
`tree would be incomplete.\n` +
|
|
920
|
+
`Re-run with --verbose and look for the "resolve: optional dep ${name}@… skipped (…)" line for ` +
|
|
921
|
+
`the underlying cause.`);
|
|
922
|
+
}
|
|
449
923
|
}
|
|
450
924
|
/**
|
|
451
925
|
* Loud, single-line-per-package warning for top-level version-range
|
|
@@ -505,31 +979,39 @@ function emitTopLevelConflictWarnings(topLevelRanges, root) {
|
|
|
505
979
|
* keyed by the registry the name resolves to, so scope-registry overrides never
|
|
506
980
|
* cross-contaminate. Falls back to a plain fetch when there's no cached entry
|
|
507
981
|
* or the registry doesn't send an ETag (the 304 fast-path simply never fires).
|
|
982
|
+
*
|
|
983
|
+
* `shape` selects WHICH document: `'corgi'` (abbreviated — every resolve) or
|
|
984
|
+
* `'full'` (the escalation that carries `libc`). It is threaded all the way
|
|
985
|
+
* through — request `accept`, cache read AND cache write — because the two are
|
|
986
|
+
* different bodies with independently-versioned ETags for one URL. Mixing them
|
|
987
|
+
* anywhere in that chain produces a `libc`-less body that looks like a hit.
|
|
508
988
|
*/
|
|
509
|
-
async function fetchPackumentWithDiskCache(name, npmrc, log, signal) {
|
|
989
|
+
async function fetchPackumentWithDiskCache(name, npmrc, log, signal, shape = 'corgi') {
|
|
510
990
|
const registry = registryFor(name, npmrc);
|
|
511
|
-
const
|
|
991
|
+
const fullMetadata = shape === 'full';
|
|
992
|
+
const disk = getCachedPackument(registry, name, shape);
|
|
512
993
|
const onRetry = ({ attempt, error, delayMs }) => {
|
|
513
|
-
log('packument %s: retry %d after %dms (%s)', name, attempt, delayMs, errMsg(error));
|
|
994
|
+
log('packument %s (%s): retry %d after %dms (%s)', name, shape, attempt, delayMs, errMsg(error));
|
|
514
995
|
};
|
|
515
996
|
const result = await fetchPackumentConditional(name, {
|
|
516
997
|
npmrc,
|
|
517
998
|
signal,
|
|
518
999
|
ifNoneMatch: disk?.etag,
|
|
1000
|
+
fullMetadata,
|
|
519
1001
|
onRetry,
|
|
520
1002
|
});
|
|
521
1003
|
if (result.status === 'not-modified' && disk) {
|
|
522
|
-
log('packument-cache-hit: %s (304, etag %s)', name, disk.etag);
|
|
1004
|
+
log('packument-cache-hit: %s (%s, 304, etag %s)', name, shape, disk.etag);
|
|
523
1005
|
return disk.packument;
|
|
524
1006
|
}
|
|
525
1007
|
if (result.status === 'fresh' && result.packument) {
|
|
526
1008
|
if (result.etag)
|
|
527
|
-
putCachedPackument(registry, name, result.etag, result.packument);
|
|
1009
|
+
putCachedPackument(registry, name, result.etag, result.packument, shape);
|
|
528
1010
|
return result.packument;
|
|
529
1011
|
}
|
|
530
1012
|
// 304 with no cached body to satisfy it (a stale `If-None-Match` raced a
|
|
531
1013
|
// cache eviction). Re-fetch unconditionally so we always return a body.
|
|
532
|
-
return fetchPackument(name, { npmrc, signal, onRetry });
|
|
1014
|
+
return fetchPackument(name, { npmrc, signal, fullMetadata, onRetry });
|
|
533
1015
|
}
|
|
534
1016
|
async function prefetchPackuments(names, fetchPkg, signal) {
|
|
535
1017
|
if (names.length === 0)
|
|
@@ -630,7 +1112,12 @@ function readLockfile(lockfilePath) {
|
|
|
630
1112
|
return null;
|
|
631
1113
|
try {
|
|
632
1114
|
const parsed = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
|
|
633
|
-
|
|
1115
|
+
// Accept every readable version, not just the current one — see
|
|
1116
|
+
// READABLE_LOCKFILE_VERSIONS for why returning null on a v2 file is
|
|
1117
|
+
// worse than reading it. The caller decides what it may be used FOR:
|
|
1118
|
+
// a v2 file seeds version preservation but never short-circuits the
|
|
1119
|
+
// resolve, because it carries no platform declarations.
|
|
1120
|
+
if (!READABLE_LOCKFILE_VERSIONS.has(parsed.lockfileVersion))
|
|
634
1121
|
return null;
|
|
635
1122
|
if (!parsed.packages || typeof parsed.packages !== 'object')
|
|
636
1123
|
return null;
|
|
@@ -651,6 +1138,14 @@ function writeLockfile(lockfilePath, specs, nodes) {
|
|
|
651
1138
|
integrity: node.integrity,
|
|
652
1139
|
dependencies: Object.keys(node.dependencies).length > 0 ? node.dependencies : undefined,
|
|
653
1140
|
bin: node.bin,
|
|
1141
|
+
// The DECLARATION, never the verdict: `inert` is per-host and
|
|
1142
|
+
// deliberately absent from the file (a lockfile that recorded which
|
|
1143
|
+
// packages the author's machine skipped would install a different
|
|
1144
|
+
// tree for everyone else — the failure this design exists to avoid).
|
|
1145
|
+
os: node.platform?.os,
|
|
1146
|
+
cpu: node.platform?.cpu,
|
|
1147
|
+
libc: node.platform?.libc,
|
|
1148
|
+
optional: node.optional ? true : undefined,
|
|
654
1149
|
};
|
|
655
1150
|
}
|
|
656
1151
|
const lockfile = {
|
|
@@ -702,18 +1197,29 @@ function preferredVersionFor(locked, range) {
|
|
|
702
1197
|
return best;
|
|
703
1198
|
}
|
|
704
1199
|
function lockfileToNodes(lockfile) {
|
|
705
|
-
return Object.entries(lockfile.packages).map(([installPath, entry]) =>
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1200
|
+
return Object.entries(lockfile.packages).map(([installPath, entry]) => {
|
|
1201
|
+
const platform = { os: entry.os, cpu: entry.cpu, libc: entry.libc };
|
|
1202
|
+
return {
|
|
1203
|
+
// Recover the package name from the path: the last segment is
|
|
1204
|
+
// either `<name>` (unscoped) or `@scope/<name>` (scoped).
|
|
1205
|
+
name: nameFromInstallPath(installPath),
|
|
1206
|
+
version: entry.version,
|
|
1207
|
+
tarballUrl: entry.resolved,
|
|
1208
|
+
integrity: entry.integrity,
|
|
1209
|
+
installPath,
|
|
1210
|
+
dependencies: entry.dependencies ?? {},
|
|
1211
|
+
optionalDependencies: {},
|
|
1212
|
+
bin: entry.bin,
|
|
1213
|
+
// Rebuilt from the recorded declaration so `applyPlatformFilter`
|
|
1214
|
+
// reaches the SAME verdict on this path as on the fresh-resolve
|
|
1215
|
+
// path — for THIS host, which is generally not the host that wrote
|
|
1216
|
+
// the file. A v2 entry has none of these fields, so nothing is
|
|
1217
|
+
// filtered; that install behaves exactly as it did before the
|
|
1218
|
+
// feature (see READABLE_LOCKFILE_VERSIONS).
|
|
1219
|
+
platform: declaresPlatform(platform) ? platform : undefined,
|
|
1220
|
+
optional: entry.optional === true,
|
|
1221
|
+
};
|
|
1222
|
+
});
|
|
717
1223
|
}
|
|
718
1224
|
function nameFromInstallPath(installPath) {
|
|
719
1225
|
// Last `node_modules/` boundary, then the rest is the package name
|
|
@@ -1153,9 +1659,39 @@ function readShebang(file) {
|
|
|
1153
1659
|
* the same set from the same templates. See `./bin-shim.ts`.
|
|
1154
1660
|
*/
|
|
1155
1661
|
export function writeBinEntry(opts) {
|
|
1156
|
-
const { binDir, binName, targetAbs } = opts;
|
|
1662
|
+
const { binDir, binName, targetAbs, gjs } = opts;
|
|
1157
1663
|
const platform = opts.platform ?? process.platform;
|
|
1158
1664
|
const linkPath = path.join(binDir, binName);
|
|
1665
|
+
if (gjs) {
|
|
1666
|
+
const isBundle = isGjsBundlePath(targetAbs);
|
|
1667
|
+
// Replace the whole sibling set: a previous install may have written a
|
|
1668
|
+
// plain symlink (or npm's three-file shim) under this very name.
|
|
1669
|
+
for (const stale of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`])
|
|
1670
|
+
rmWithRetry(stale);
|
|
1671
|
+
fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble: gjs.envPreamble, isGjsBundle: isBundle }));
|
|
1672
|
+
try {
|
|
1673
|
+
fs.chmodSync(linkPath, 0o755);
|
|
1674
|
+
}
|
|
1675
|
+
catch {
|
|
1676
|
+
/* inert on Windows; harmless under WSL/MSYS */
|
|
1677
|
+
}
|
|
1678
|
+
if (platform === 'win32' && isBundle) {
|
|
1679
|
+
// No dedicated DLL-search variable on Windows: LoadLibrary searches
|
|
1680
|
+
// PATH, and GLib's search-path separator is `;` there too — so both
|
|
1681
|
+
// variables take the same `;`-joined value.
|
|
1682
|
+
const joined = gjs.prebuildDirs.join(';');
|
|
1683
|
+
const prependEnv = gjs.prebuildDirs.length > 0 ? { GI_TYPELIB_PATH: joined, PATH: joined } : {};
|
|
1684
|
+
const shims = buildLauncherShims({
|
|
1685
|
+
interpreter: 'gjs',
|
|
1686
|
+
interpreterArgs: ['-m'],
|
|
1687
|
+
target: targetAbs,
|
|
1688
|
+
prependEnv,
|
|
1689
|
+
});
|
|
1690
|
+
fs.writeFileSync(`${linkPath}.cmd`, shims.cmd);
|
|
1691
|
+
fs.writeFileSync(`${linkPath}.ps1`, shims.ps1);
|
|
1692
|
+
}
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1159
1695
|
if (platform === 'win32') {
|
|
1160
1696
|
const relPosix = path.relative(binDir, targetAbs).split('\\').join('/');
|
|
1161
1697
|
const { sh, cmd, ps1 } = buildCmdShim(relPosix, readShebang(targetAbs));
|
|
@@ -1193,17 +1729,34 @@ async function linkBins(nodes, prefix, log) {
|
|
|
1193
1729
|
// backend depends on it (gjsify's own use cases all hit root bins).
|
|
1194
1730
|
const binDir = path.join(prefix, 'node_modules', '.bin');
|
|
1195
1731
|
let created = 0;
|
|
1732
|
+
// Computed on first use only: the scan walks the whole prefix, and most
|
|
1733
|
+
// installs contain no package declaring `gjsify.bin` at all.
|
|
1734
|
+
let gjsEnv;
|
|
1735
|
+
const gjsEnvForPrefix = () => {
|
|
1736
|
+
if (gjsEnv === undefined) {
|
|
1737
|
+
const prebuildDirs = detectNativePackages(prefix).map((p) => p.prebuildsDir);
|
|
1738
|
+
gjsEnv = { envPreamble: buildNativeEnvPreamble(prefix, prebuildDirs), prebuildDirs };
|
|
1739
|
+
}
|
|
1740
|
+
return gjsEnv;
|
|
1741
|
+
};
|
|
1196
1742
|
for (const node of nodes) {
|
|
1197
|
-
if (!node.bin)
|
|
1198
|
-
continue;
|
|
1199
1743
|
if (depth(node.installPath) !== 1)
|
|
1200
1744
|
continue;
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1745
|
+
const pkgDir = path.join(prefix, node.installPath);
|
|
1746
|
+
// The INSTALLED manifest is the source of truth here, not the packument
|
|
1747
|
+
// the resolver carried: `gjsify.bin` is a gjsify-specific field npm's
|
|
1748
|
+
// abbreviated packument does not include, so a packument-only read can
|
|
1749
|
+
// never see it. Falls back to the resolved npm `bin` when the extracted
|
|
1750
|
+
// manifest is unreadable, which keeps the previous behaviour intact.
|
|
1751
|
+
const manifest = readInstalledManifest(pkgDir) ?? (node.bin === undefined ? null : { bin: node.bin });
|
|
1752
|
+
if (manifest === null)
|
|
1753
|
+
continue;
|
|
1754
|
+
const picked = pickBinMap(node.name, manifest);
|
|
1755
|
+
if (picked === null || picked.map.size === 0)
|
|
1203
1756
|
continue;
|
|
1204
1757
|
fs.mkdirSync(binDir, { recursive: true });
|
|
1205
|
-
for (const [binName, binTarget] of map) {
|
|
1206
|
-
const targetAbs = path.join(
|
|
1758
|
+
for (const [binName, binTarget] of picked.map) {
|
|
1759
|
+
const targetAbs = path.join(pkgDir, binTarget);
|
|
1207
1760
|
if (!fs.existsSync(targetAbs))
|
|
1208
1761
|
continue;
|
|
1209
1762
|
try {
|
|
@@ -1212,13 +1765,35 @@ async function linkBins(nodes, prefix, log) {
|
|
|
1212
1765
|
catch {
|
|
1213
1766
|
/* best effort */
|
|
1214
1767
|
}
|
|
1215
|
-
writeBinEntry({
|
|
1768
|
+
writeBinEntry({
|
|
1769
|
+
binDir,
|
|
1770
|
+
binName,
|
|
1771
|
+
targetAbs,
|
|
1772
|
+
gjs: picked.isGjsBin ? gjsEnvForPrefix() : undefined,
|
|
1773
|
+
});
|
|
1216
1774
|
created++;
|
|
1217
1775
|
}
|
|
1218
1776
|
}
|
|
1219
1777
|
if (created > 0)
|
|
1220
1778
|
log('bin: linked %d entry(ies) under .bin/', created);
|
|
1221
1779
|
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Read an extracted package's `package.json`.
|
|
1782
|
+
*
|
|
1783
|
+
* Returns `null` rather than throwing: a node can legitimately have no manifest
|
|
1784
|
+
* on disk yet (an optional dependency skipped for this platform), and bin
|
|
1785
|
+
* linking is not the place to fail an otherwise-complete install. The caller
|
|
1786
|
+
* falls back to the resolved npm `bin`.
|
|
1787
|
+
*/
|
|
1788
|
+
function readInstalledManifest(pkgDir) {
|
|
1789
|
+
try {
|
|
1790
|
+
const raw = fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8');
|
|
1791
|
+
return JSON.parse(raw);
|
|
1792
|
+
}
|
|
1793
|
+
catch {
|
|
1794
|
+
return null;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1222
1797
|
/**
|
|
1223
1798
|
* `fs.rmSync(..., { force: true })` with the Windows retry npm/rimraf apply.
|
|
1224
1799
|
*
|
|
@@ -1232,18 +1807,6 @@ async function linkBins(nodes, prefix, log) {
|
|
|
1232
1807
|
function rmWithRetry(target) {
|
|
1233
1808
|
fs.rmSync(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
1234
1809
|
}
|
|
1235
|
-
function normalizeBin(pkgName, bin) {
|
|
1236
|
-
const out = new Map();
|
|
1237
|
-
if (typeof bin === 'string') {
|
|
1238
|
-
// String form is shorthand for `{ <last-segment-of-pkgName>: <bin> }`.
|
|
1239
|
-
const baseName = pkgName.startsWith('@') ? pkgName.slice(pkgName.indexOf('/') + 1) : pkgName;
|
|
1240
|
-
out.set(baseName, bin);
|
|
1241
|
-
return out;
|
|
1242
|
-
}
|
|
1243
|
-
for (const [k, v] of Object.entries(bin))
|
|
1244
|
-
out.set(k, v);
|
|
1245
|
-
return out;
|
|
1246
|
-
}
|
|
1247
1810
|
async function loadNpmrc(opts) {
|
|
1248
1811
|
const home = os.homedir();
|
|
1249
1812
|
let parsed = {
|