@gjsify/cli 0.26.1 → 0.27.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.
Files changed (55) hide show
  1. package/dist/cli.gjs.mjs +184 -182
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected.js +1 -1
  4. package/lib/commands/build.js +21 -14
  5. package/lib/commands/check.js +40 -4
  6. package/lib/commands/clear.d.ts +8 -0
  7. package/lib/commands/clear.js +90 -0
  8. package/lib/commands/copy.d.ts +8 -0
  9. package/lib/commands/copy.js +87 -0
  10. package/lib/commands/dlx.js +8 -1
  11. package/lib/commands/index.d.ts +2 -0
  12. package/lib/commands/index.js +2 -0
  13. package/lib/commands/install.d.ts +4 -0
  14. package/lib/commands/install.js +164 -15
  15. package/lib/commands/onboard.js +17 -14
  16. package/lib/commands/publish.js +18 -4
  17. package/lib/commands/storybook.js +5 -1
  18. package/lib/commands/test.js +29 -2
  19. package/lib/commands/uninstall.js +44 -2
  20. package/lib/commands/upgrade.js +1 -4
  21. package/lib/templates/install.mjs.tmpl +110 -19
  22. package/lib/utils/bin-shim.d.ts +73 -0
  23. package/lib/utils/bin-shim.js +97 -2
  24. package/lib/utils/build-cache.d.ts +7 -27
  25. package/lib/utils/build-cache.js +27 -2
  26. package/lib/utils/check-system-deps.d.ts +15 -1
  27. package/lib/utils/check-system-deps.js +126 -2
  28. package/lib/utils/clear-targets.d.ts +30 -0
  29. package/lib/utils/clear-targets.js +114 -0
  30. package/lib/utils/copy-targets.d.ts +41 -0
  31. package/lib/utils/copy-targets.js +145 -0
  32. package/lib/utils/detect-native-packages.d.ts +160 -5
  33. package/lib/utils/detect-native-packages.js +326 -50
  34. package/lib/utils/dir-link.d.ts +34 -6
  35. package/lib/utils/dir-link.js +23 -9
  36. package/lib/utils/install-backend-native.d.ts +109 -1
  37. package/lib/utils/install-backend-native.js +622 -59
  38. package/lib/utils/install-backend.js +17 -15
  39. package/lib/utils/install-global.js +6 -34
  40. package/lib/utils/install-lock.js +1 -1
  41. package/lib/utils/install-packument-cache.d.ts +36 -17
  42. package/lib/utils/install-packument-cache.js +59 -31
  43. package/lib/utils/oxc-resolve.d.ts +27 -1
  44. package/lib/utils/oxc-resolve.js +53 -26
  45. package/lib/utils/pkg-json.d.ts +24 -0
  46. package/lib/utils/pkg-json.js +27 -1
  47. package/lib/utils/platform-check.d.ts +215 -0
  48. package/lib/utils/platform-check.js +377 -0
  49. package/lib/utils/resolve-npm-package.js +9 -2
  50. package/lib/utils/run-gjs.d.ts +1 -1
  51. package/lib/utils/run-gjs.js +19 -3
  52. package/lib/utils/spawn.js +50 -2
  53. package/lib/utils/win32-command.d.ts +54 -0
  54. package/lib/utils/win32-command.js +140 -0
  55. 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
- const LOCKFILE_VERSION = 2;
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 && existingLock && lockfileMatchesRequest(existingLock, opts.specs)) {
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
- nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress, preferred, opts.workspaceNames, opts.specOrigins);
100
- if (opts.lockfile) {
101
- writeLockfile(lockfilePath, opts.specs, nodes);
102
- log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
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
- log('install: downloading %d tarball(s)', nodes.length);
123
- await downloadAndExtractAll(nodes, opts.prefix, npmrc, log, opts.signal, progress);
124
- await linkBins(nodes, opts.prefix, log);
125
- warnMissingNativeBuilds(nodes, opts.prefix, log);
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
- const fresh = fetchPackumentWithDiskCache(name, npmrc, log, signal);
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: true,
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
- if (!edge.required)
378
- continue;
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: true,
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
- log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, e.message);
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
- return Array.from(byPath.values());
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 disk = getCachedPackument(registry, name);
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
- if (parsed.lockfileVersion !== LOCKFILE_VERSION)
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
- // Recover the package name from the path: the last segment is
707
- // either `<name>` (unscoped) or `@scope/<name>` (scoped).
708
- name: nameFromInstallPath(installPath),
709
- version: entry.version,
710
- tarballUrl: entry.resolved,
711
- integrity: entry.integrity,
712
- installPath,
713
- dependencies: entry.dependencies ?? {},
714
- optionalDependencies: {},
715
- bin: entry.bin,
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 map = normalizeBin(node.name, node.bin);
1202
- if (map.size === 0)
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(prefix, node.installPath, binTarget);
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({ binDir, binName, targetAbs });
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 = {