@gjsify/cli 0.51.1 → 0.53.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 (126) hide show
  1. package/dist/cli.gjs.mjs +539 -493
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/affected-classify.d.ts +7 -1
  8. package/lib/commands/affected-classify.js +66 -9
  9. package/lib/commands/affected-script-refs.d.ts +20 -0
  10. package/lib/commands/affected-script-refs.js +218 -0
  11. package/lib/commands/affected.js +24 -2
  12. package/lib/commands/env.d.ts +31 -0
  13. package/lib/commands/env.js +114 -0
  14. package/lib/commands/exec.d.ts +10 -0
  15. package/lib/commands/exec.js +208 -0
  16. package/lib/commands/flatpak/diff.js +8 -2
  17. package/lib/commands/flatpak/init.js +4 -1
  18. package/lib/commands/flatpak/sources.js +4 -3
  19. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  20. package/lib/commands/flatpak/sync-flathub.js +149 -7
  21. package/lib/commands/flatpak/utils.d.ts +10 -0
  22. package/lib/commands/flatpak/utils.js +10 -0
  23. package/lib/commands/foreach.js +5 -97
  24. package/lib/commands/format.js +26 -6
  25. package/lib/commands/index.d.ts +4 -0
  26. package/lib/commands/index.js +4 -0
  27. package/lib/commands/install.d.ts +2 -2
  28. package/lib/commands/install.js +81 -5
  29. package/lib/commands/link.d.ts +13 -0
  30. package/lib/commands/link.js +243 -0
  31. package/lib/commands/onboard.js +18 -2
  32. package/lib/commands/publish.d.ts +34 -0
  33. package/lib/commands/publish.js +153 -8
  34. package/lib/commands/self-update.js +2 -1
  35. package/lib/commands/ship.js +19 -1
  36. package/lib/commands/test.d.ts +18 -9
  37. package/lib/commands/test.js +56 -11
  38. package/lib/commands/tsc.js +4 -4
  39. package/lib/commands/upgrade.d.ts +15 -0
  40. package/lib/commands/upgrade.js +76 -2
  41. package/lib/commands/webext/index.d.ts +2 -0
  42. package/lib/commands/webext/index.js +15 -0
  43. package/lib/commands/webext/subcommands.d.ts +16 -0
  44. package/lib/commands/webext/subcommands.js +204 -0
  45. package/lib/types/config-data.d.ts +2 -2
  46. package/lib/utils/bin-shim.d.ts +8 -2
  47. package/lib/utils/bin-shim.js +6 -5
  48. package/lib/utils/bundle-inputs.d.ts +84 -0
  49. package/lib/utils/bundle-inputs.js +268 -0
  50. package/lib/utils/check-system-deps.d.ts +5 -19
  51. package/lib/utils/check-system-deps.js +31 -94
  52. package/lib/utils/dev-link.d.ts +225 -0
  53. package/lib/utils/dev-link.js +684 -0
  54. package/lib/utils/exec-bin.d.ts +137 -0
  55. package/lib/utils/exec-bin.js +321 -0
  56. package/lib/utils/gi-runtime-paths.js +3 -2
  57. package/lib/utils/gitignore.d.ts +15 -0
  58. package/lib/utils/gitignore.js +178 -0
  59. package/lib/utils/gjsify-shim.d.ts +47 -0
  60. package/lib/utils/gjsify-shim.js +97 -8
  61. package/lib/utils/hang-watchdog.d.ts +95 -0
  62. package/lib/utils/hang-watchdog.js +194 -0
  63. package/lib/utils/install-backend-native.d.ts +33 -1
  64. package/lib/utils/install-backend-native.js +184 -20
  65. package/lib/utils/install-extraneous.d.ts +90 -0
  66. package/lib/utils/install-extraneous.js +120 -0
  67. package/lib/utils/install-lock.js +4 -25
  68. package/lib/utils/install-tarball-cache.d.ts +37 -9
  69. package/lib/utils/install-tarball-cache.js +59 -14
  70. package/lib/utils/native-skip-scan.d.ts +20 -0
  71. package/lib/utils/native-skip-scan.js +343 -0
  72. package/lib/utils/node-script.js +1 -1
  73. package/lib/utils/normalize-bundler-options.js +11 -0
  74. package/lib/utils/oxc-resolve.js +42 -3
  75. package/lib/utils/process-table.d.ts +22 -0
  76. package/lib/utils/process-table.js +188 -0
  77. package/lib/utils/prune-prefix.js +1 -1
  78. package/lib/utils/publish-pins.d.ts +38 -0
  79. package/lib/utils/publish-pins.js +47 -0
  80. package/lib/utils/publish-readback.d.ts +103 -17
  81. package/lib/utils/publish-readback.js +326 -47
  82. package/lib/utils/run-gjs.d.ts +15 -1
  83. package/lib/utils/run-gjs.js +98 -13
  84. package/lib/utils/run-node.d.ts +2 -0
  85. package/lib/utils/run-node.js +2 -1
  86. package/lib/utils/runtimes.js +14 -13
  87. package/lib/utils/ship/gzip.js +1 -1
  88. package/lib/utils/ship/icns.d.ts +14 -0
  89. package/lib/utils/ship/icns.js +83 -0
  90. package/lib/utils/ship/ico.d.ts +25 -0
  91. package/lib/utils/ship/ico.js +120 -0
  92. package/lib/utils/ship/icons.d.ts +197 -0
  93. package/lib/utils/ship/icons.js +399 -0
  94. package/lib/utils/ship/launcher.js +16 -1
  95. package/lib/utils/ship/layout.d.ts +32 -0
  96. package/lib/utils/ship/layout.js +112 -38
  97. package/lib/utils/ship/msi.d.ts +1 -1
  98. package/lib/utils/ship/msi.js +30 -5
  99. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  100. package/lib/utils/ship/pe-launcher.js +125 -4
  101. package/lib/utils/ship/plan.d.ts +1 -34
  102. package/lib/utils/ship/plan.js +7 -56
  103. package/lib/utils/ship/plist.d.ts +20 -10
  104. package/lib/utils/ship/plist.js +29 -12
  105. package/lib/utils/ship/settings.js +3 -2
  106. package/lib/utils/spawn.d.ts +13 -0
  107. package/lib/utils/spawn.js +10 -5
  108. package/lib/utils/watch-loop.d.ts +15 -1
  109. package/lib/utils/watch-loop.js +9 -5
  110. package/lib/utils/webext/build.d.ts +42 -0
  111. package/lib/utils/webext/build.js +120 -0
  112. package/lib/utils/webext/config.d.ts +72 -0
  113. package/lib/utils/webext/config.js +234 -0
  114. package/lib/utils/webext/icons.d.ts +24 -0
  115. package/lib/utils/webext/icons.js +83 -0
  116. package/lib/utils/webext/launch.d.ts +32 -0
  117. package/lib/utils/webext/launch.js +77 -0
  118. package/lib/utils/webext/manifest.d.ts +53 -0
  119. package/lib/utils/webext/manifest.js +219 -0
  120. package/lib/utils/webext/pages.d.ts +26 -0
  121. package/lib/utils/webext/pages.js +61 -0
  122. package/lib/utils/webext/targets.d.ts +13 -0
  123. package/lib/utils/webext/targets.js +36 -0
  124. package/lib/utils/win32-command.d.ts +17 -0
  125. package/lib/utils/win32-command.js +21 -6
  126. package/package.json +20 -20
@@ -45,8 +45,20 @@
45
45
  // (v4); without it the fresh-resolve and lockfile paths disagree about one
46
46
  // tree.
47
47
  //
48
- // Out of scope (still deferred): peerDependencies validation, lifecycle scripts,
49
- // git/file specs.
48
+ // PEER DEPENDENCIES follow npm ≥ 7: every peer NOT marked optional in
49
+ // `peerDependenciesMeta` is installed where the dependent resolves it — as its
50
+ // SIBLING (queued from the dependent's parent, so the dependent and whatever
51
+ // requires it share one instance), reusing any visible copy whose version
52
+ // satisfies the range. Optional peers are never installed, exactly as npm skips
53
+ // them; ADR 0020's `@gjsify/rolldown-native` depends on that. The defect this
54
+ // closes: `wxt` declares `vite` as a required peer, `gjsify install` exited 0
55
+ // without it, and `wxt prepare` died with "Builder not found". A peer edge is a
56
+ // REQUIRED edge for the optionality fixpoint ({@link requiredPeerEntries}), so
57
+ // the lockfile records the peer maps and marks a peer-aware resolve
58
+ // (`Lockfile.peersResolved` says why that is a marker and not a version bump).
59
+ //
60
+ // Out of scope (still deferred): lifecycle scripts, git/file specs, and npm's
61
+ // strict-peer-deps ERESOLVE (a conflicting peer warns, see `resolveDeps`).
50
62
  import * as fs from 'node:fs';
51
63
  import * as path from 'node:path';
52
64
  import * as os from 'node:os';
@@ -60,6 +72,9 @@ import { getCachedPackument, putCachedPackument } from './install-packument-cach
60
72
  import { assertNativeBackendNodeVersion } from './node-version.js';
61
73
  import { buildCmdShim, buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, isGjsBundlePath, parseShebang, pickBinMap, } from './bin-shim.js';
62
74
  import { detectNativePackages } from './detect-native-packages.js';
75
+ import { findExtraneous, formatExtraneousError } from './install-extraneous.js';
76
+ import { devLinkPath, devLinkRoot } from './dev-link.js';
77
+ import { scanPrefix } from './prune-prefix.js';
63
78
  import { badPlatformError, checkPlatform, declaresPlatform, describePlatformTarget, readPlatformForce, resolveHostPlatform, } from './platform-check.js';
64
79
  // 16-wide download pool, matched to the shared Soup.Session's lifted
65
80
  // `max-conns-per-host` (@gjsify/fetch `getSharedSession`) — without that lift
@@ -157,10 +172,19 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
157
172
  }
158
173
  log('install: --immutable, using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
159
174
  nodes = lockfileToNodes(existingLock);
175
+ // ...and the tree must hold NOTHING ELSE. The installer only adds, so without
176
+ // this an undescribed package survives the install and exits 0 — the CI cache
177
+ // poisoning of gjsify#1683 (install-extraneous.ts carries the incident).
178
+ // Before the download so a wrong tree costs seconds, not the extract phase,
179
+ // and with the FULL lockfile set: the workspace filter below removes nodes
180
+ // that ARE described, and judging against the smaller set would call them
181
+ // strangers.
182
+ assertNoExtraneous(opts.prefix, nodes);
160
183
  }
161
184
  else if (!opts.refreshLockfile &&
162
185
  existingLock &&
163
186
  existingLock.lockfileVersion === LOCKFILE_VERSION &&
187
+ existingLock.peersResolved === true &&
164
188
  lockfileMatchesRequest(existingLock, opts.specs)) {
165
189
  log('install: using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
166
190
  nodes = lockfileToNodes(existingLock);
@@ -168,6 +192,9 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
168
192
  else {
169
193
  // A resolve has to run (new/changed/removed dep, or no lockfile yet).
170
194
  //
195
+ // So does a lockfile without `peersResolved`: it may lack a required peer
196
+ // (Lockfile.peersResolved).
197
+ //
171
198
  // A pre-v4 lockfile lands here too even when it matches the request: each
172
199
  // bump added something the verdict is recomputed FROM, so a file lacking it
173
200
  // cannot be consumed verbatim — pre-v3 entries carry no platform
@@ -209,6 +236,16 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
209
236
  writeLockfile(lockfilePath, opts.specs, nodes);
210
237
  log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
211
238
  }
239
+ // TWO NAMES FROM HERE ON, and the split is the fix for a measured defect.
240
+ // `nodes` stays the FULL resolved tree — what the lockfile describes and what
241
+ // `topLevelResolutions` answers from. `fetchable` is the subset that still has
242
+ // to come off the network. A package can be present without being fetched, and
243
+ // collapsing the two made the installer forget it existed: with a `gjsify link`
244
+ // active, `gjsify install is-odd` read the FILTERED array, found no `is-odd`
245
+ // resolution, and wrote `"is-odd": "latest"` into package.json and
246
+ // `is-odd@latest` into the lockfile's `requested` — over the `^3.0.1` the
247
+ // control run without the link produced. Never re-merge these two names.
248
+ let fetchable = nodes;
212
249
  // A package named after one of the monorepo's own workspaces is provided by a
213
250
  // workspace symlink (wired by `workspaceInstall`), NOT by a registry tarball —
214
251
  // even when the lockfile or a transitive edge pins a same-named published
@@ -217,26 +254,55 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
217
254
  // `--immutable` robust against a committed lockfile that still carries such
218
255
  // entries, since that path never runs `resolveDeps`.
219
256
  if (opts.workspaceNames && opts.workspaceNames.size > 0) {
220
- const before = nodes.length;
221
- nodes = nodes.filter((n) => !opts.workspaceNames.has(n.name));
222
- const dropped = before - nodes.length;
257
+ const before = fetchable.length;
258
+ fetchable = fetchable.filter((n) => !opts.workspaceNames.has(n.name));
259
+ const dropped = before - fetchable.length;
223
260
  if (dropped > 0) {
224
261
  log('install: %d workspace-provided package(s) symlinked, not fetched', dropped);
225
262
  }
226
263
  }
264
+ // Same exclusion, different reason and a different position in the pipeline: a
265
+ // `gjsify link` override provides these from a local checkout. It runs AFTER the
266
+ // lockfile write above on purpose — the consumer's committed lockfile must stay
267
+ // byte-identical whether or not a link is active (utils/dev-link.ts).
268
+ if (opts.linkedNames && opts.linkedNames.size > 0) {
269
+ const before = fetchable.length;
270
+ fetchable = fetchable.filter((n) => !opts.linkedNames.has(n.name));
271
+ const dropped = before - fetchable.length;
272
+ if (dropped > 0) {
273
+ log('install: %d dev-linked package(s) provided by a local checkout, not fetched', dropped);
274
+ }
275
+ }
227
276
  // os/cpu/libc: throws for an incompatible REQUIRED dep, marks incompatible
228
277
  // OPTIONAL ones inert. Runs on BOTH paths (fresh resolve and lockfile) —
229
278
  // that is what makes one committed lockfile install a per-host subset.
230
- const installable = applyPlatformFilter(nodes, target, force, log);
279
+ const installable = applyPlatformFilter(fetchable, target, force, log);
231
280
  log('install: downloading %d tarball(s)', installable.length);
232
281
  await downloadAndExtractAll(installable, opts.prefix, npmrc, log, opts.signal, progress);
233
282
  await linkBins(installable, opts.prefix, log);
234
283
  warnMissingNativeBuilds(installable, opts.prefix, log);
235
284
  log('install: done');
236
285
  // Top-level requested packages only, so callers can write the resolved version
237
- // back into package.json (`npm install --save`).
286
+ // back into package.json (`npm install --save`). From `nodes`, NEVER from
287
+ // `fetchable`: "what is installed at node_modules/<name>" is not "what was
288
+ // downloaded", and a provided package is installed.
238
289
  return topLevelResolutions(opts.specs, nodes);
239
290
  }
291
+ /**
292
+ * `--immutable` only: refuse a `node_modules` holding packages the lockfile does not
293
+ * describe, naming every one of them.
294
+ *
295
+ * Deliberately NOT a prune — the reasoning, and the incident it comes from, are the
296
+ * header note of `install-extraneous.ts`. Scoped to the frozen path: a plain install
297
+ * resolves a NEW tree and may legitimately leave an older placement behind for its
298
+ * own prune pass to judge, so this rule would be wrong there.
299
+ */
300
+ function assertNoExtraneous(prefix, nodes) {
301
+ const extraneous = findExtraneous({ prefix, installed: scanPrefix(prefix), expected: nodes });
302
+ if (extraneous.length === 0)
303
+ return;
304
+ throw new Error(formatExtraneousError(extraneous, prefix));
305
+ }
240
306
  function errMsg(err) {
241
307
  if (err instanceof Error)
242
308
  return err.message;
@@ -483,7 +549,7 @@ optionalSpecs) {
483
549
  // `workspaceInstall` produces when two workspaces declare incompatible ranges of
484
550
  // the same external dep) all compete for the single root slot and the resolver
485
551
  // silently keeps one version for everyone. Per-workspace dedup is Phase D.8
486
- // (status/open-todos.md); until it lands the conflict is surfaced loudly after
552
+ // (status/open-todos/README.md); until it lands the conflict is surfaced loudly after
487
553
  // the resolve (see emitTopLevelConflictWarnings).
488
554
  const topLevelRanges = new Map();
489
555
  const queue = specs.map(parseSpec).map((s) => {
@@ -551,6 +617,23 @@ optionalSpecs) {
551
617
  // {@link computeOptionalFlags} is the only writer of the final flag.
552
618
  continue;
553
619
  }
620
+ // A peer must live in ONE directory — beside its dependent. If that slot
621
+ // already holds a version outside the range, a private copy would give the
622
+ // dependent a different instance from its requester, which is the thing a
623
+ // peer exists to prevent. npm ≥ 7 raises ERESOLVE here in strict mode and,
624
+ // non-strict (`--force`, or a conflict below the root), warns "overriding
625
+ // peer dependency" and keeps the existing copy. We take the lenient answer:
626
+ // warn, keep it, install nothing.
627
+ if (edge.peerOf) {
628
+ const slot = edge.from === null ? `node_modules/${edge.name}` : `${edge.from}/node_modules/${edge.name}`;
629
+ const occupant = byPath.get(slot);
630
+ if (occupant) {
631
+ console.warn(`[gjsify] warning: peer dependency conflict: ${edge.peerOf.name}@${edge.peerOf.version} ` +
632
+ `wants ${edge.name}@${edge.range}, but ${occupant.version} is installed at ` +
633
+ `${occupant.installPath} — keeping it (npm's non-strict behaviour).`);
634
+ continue;
635
+ }
636
+ }
554
637
  // No compatible placement. Resolve a version, preferring one already
555
638
  // pinned when it satisfies the range so an add does not bump unchanged
556
639
  // deps.
@@ -579,6 +662,8 @@ optionalSpecs) {
579
662
  installPath,
580
663
  dependencies: v.dependencies ?? {},
581
664
  optionalDependencies: v.optionalDependencies ?? {},
665
+ peerDependencies: nonEmpty(v.peerDependencies),
666
+ peerDependenciesMeta: nonEmpty(v.peerDependenciesMeta),
582
667
  bin: v.bin,
583
668
  // Complete as published: `fetchPkg` reads the FULL document, so all
584
669
  // three fields come from one body — no second fetch, no heuristic.
@@ -603,6 +688,20 @@ optionalSpecs) {
603
688
  name: `${edge.name}@${version}`,
604
689
  });
605
690
  if (!skipDeps) {
691
+ // Required peers FIRST, so the peer claims its slot beside the
692
+ // dependent before the dependent's own subtree can hoist an
693
+ // incompatible copy of the same name into it. Queued from the PARENT
694
+ // (`Edge.peerOf`); optional peers are never queued, npm ≥ 7 skips them.
695
+ const parent = parentInstallPath(installPath);
696
+ for (const [peerName, peerRange] of requiredPeerEntries(node)) {
697
+ queue.push({
698
+ from: parent,
699
+ name: peerName,
700
+ range: applyOverride(peerName, peerRange),
701
+ required: !node.optional,
702
+ peerOf: node,
703
+ });
704
+ }
606
705
  // REQUIRED edges only — a name this package also lists in
607
706
  // `optionalDependencies` is queued once by the loop below, as
608
707
  // optional (header note, invariant 3). Queuing it here too would
@@ -643,7 +742,9 @@ optionalSpecs) {
643
742
  // `assertRequiredEdgesResolved` re-judges every entry against the
644
743
  // fixpoint flags, so a genuinely required dependency cannot go missing
645
744
  // just because BFS reached it through an optional edge first.
646
- skippedEdges.add(edgeKey(edge.from, edge.name));
745
+ // A peer edge is keyed by its DEPENDENT, not by `from`: the dependent
746
+ // is the requester whose final optionality decides the verdict.
747
+ skippedEdges.add(edgeKey(edge.peerOf?.installPath ?? edge.from, edge.name));
647
748
  log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, errMsg(e));
648
749
  continue;
649
750
  }
@@ -748,6 +849,27 @@ export function requiredDepEntries(node) {
748
849
  }
749
850
  return entries;
750
851
  }
852
+ /**
853
+ * A node's REQUIRED peer edges: its `peerDependencies` minus those
854
+ * `peerDependenciesMeta` marks `optional` (npm ≥ 7 installs the rest and skips
855
+ * these) and minus any name it also lists as a dependency — that edge is walked
856
+ * from the node itself by {@link requiredDepEntries}, or is optional because the
857
+ * publisher said so in `optionalDependencies`.
858
+ *
859
+ * Shared by the resolve walk and the optionality fixpoint, for the reason
860
+ * {@link requiredDepEntries} is: two readings of one manifest are two answers.
861
+ */
862
+ export function requiredPeerEntries(node) {
863
+ const entries = [];
864
+ for (const [name, range] of Object.entries(node.peerDependencies ?? {})) {
865
+ if (node.peerDependenciesMeta?.[name]?.optional === true)
866
+ continue;
867
+ if (name in node.dependencies || name in node.optionalDependencies)
868
+ continue;
869
+ entries.push([name, range]);
870
+ }
871
+ return entries;
872
+ }
751
873
  /**
752
874
  * Recompute every node's `optional` flag as a FIXPOINT over the placed graph. The
753
875
  * only writer of the final flag; whatever the nodes arrive carrying is an input
@@ -755,7 +877,8 @@ export function requiredDepEntries(node) {
755
877
  * file on the lockfile path) and is overwritten unconditionally.
756
878
  *
757
879
  * DEFINITION: a node is REQUIRED iff reachable from `requiredNames` through
758
- * REQUIRED edges alone ({@link requiredDepEntries}); every other node is optional.
880
+ * REQUIRED edges alone ({@link requiredDepEntries} plus {@link requiredPeerEntries} —
881
+ * a peer npm installs is as mandatory as a dependency); every other node is optional.
759
882
  * Optionality is therefore INHERITED — a plain dependency OF an optional package is
760
883
  * still optional, which is what npm's `optionalSet` computes.
761
884
  *
@@ -811,7 +934,7 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
811
934
  const node = byPath.get(installPath);
812
935
  if (!node)
813
936
  continue;
814
- for (const [depName] of requiredDepEntries(node)) {
937
+ for (const [depName] of [...requiredDepEntries(node), ...requiredPeerEntries(node)]) {
815
938
  // Resolve the edge the way the REQUESTER will at runtime — through
816
939
  // the ancestor `node_modules` chain — so a nested copy is credited
817
940
  // to the requester that nested it and the hoisted one is not
@@ -902,7 +1025,7 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
902
1025
  * (dist-tags like `latest`) cannot be compared and are skipped.
903
1026
  *
904
1027
  * This makes the single-root-slot behaviour honest rather than silent; the real fix
905
- * is a per-workspace dedup pass, Phase D.8 (status/open-todos.md).
1028
+ * is a per-workspace dedup pass, Phase D.8 (status/open-todos/README.md).
906
1029
  */
907
1030
  function emitTopLevelConflictWarnings(topLevelRanges, root) {
908
1031
  for (const [name, ranges] of topLevelRanges) {
@@ -1032,6 +1155,11 @@ function findVisible(requesterPath, name, byPath) {
1032
1155
  }
1033
1156
  return null;
1034
1157
  }
1158
+ /** The `installPath` whose `node_modules` holds this one; null for a root placement. */
1159
+ function parentInstallPath(installPath) {
1160
+ const idx = installPath.lastIndexOf('/node_modules/');
1161
+ return idx < 0 ? null : installPath.slice(0, idx);
1162
+ }
1035
1163
  /**
1036
1164
  * Where to install `name@version` for a request from `requesterPath`: hoist to
1037
1165
  * `node_modules/<name>` when the root slot is empty or already holds this version,
@@ -1092,6 +1220,8 @@ function writeLockfile(lockfilePath, specs, nodes) {
1092
1220
  // `computeOptionalFlags` runs on the lockfile path too and cannot recompute
1093
1221
  // what the file does not carry (v4, header note invariant 3).
1094
1222
  optionalDependencies: Object.keys(node.optionalDependencies).length > 0 ? node.optionalDependencies : undefined,
1223
+ peerDependencies: nonEmpty(node.peerDependencies),
1224
+ peerDependenciesMeta: nonEmpty(node.peerDependenciesMeta),
1095
1225
  bin: node.bin,
1096
1226
  // The DECLARATION, never the verdict: `inert` is per-host and deliberately
1097
1227
  // absent from the file, since a lockfile recording which packages the
@@ -1106,12 +1236,16 @@ function writeLockfile(lockfilePath, specs, nodes) {
1106
1236
  lockfileVersion: LOCKFILE_VERSION,
1107
1237
  requested: [...specs],
1108
1238
  packages,
1239
+ peersResolved: true,
1109
1240
  };
1110
1241
  // Atomic tmp+rename so a crash mid-write, or a reader racing the writer, can never
1111
1242
  // observe a torn gjsify-lock.json — `--immutable` would otherwise hard-fail on the
1112
1243
  // corrupt file with a misleading error.
1113
1244
  atomicWriteStrict(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n');
1114
1245
  }
1246
+ function nonEmpty(map) {
1247
+ return map && Object.keys(map).length > 0 ? map : undefined;
1248
+ }
1115
1249
  /**
1116
1250
  * The lockfile-preservation oracle: `name → every version currently pinned` (a name
1117
1251
  * can appear at more than one path/version when the tree nested a conflicting copy).
@@ -1164,6 +1298,8 @@ function lockfileToNodes(lockfile) {
1164
1298
  // blocks reads as required here. `--immutable` consumes it anyway — see
1165
1299
  // READABLE_LOCKFILE_VERSIONS.
1166
1300
  optionalDependencies: entry.optionalDependencies ?? {},
1301
+ peerDependencies: entry.peerDependencies,
1302
+ peerDependenciesMeta: entry.peerDependenciesMeta,
1167
1303
  bin: entry.bin,
1168
1304
  // Rebuilt from the recorded declaration so `applyPlatformFilter` reaches the
1169
1305
  // SAME verdict here as on the fresh-resolve path, for THIS host — generally
@@ -1378,7 +1514,7 @@ async function extractOne(node, prefix, npmrc, log, signal) {
1378
1514
  // fail if a workspace package leaked into the fetch/extract queue (the root cause
1379
1515
  // fixed in `workspaceInstall`), and refusing here means a resolver regression can
1380
1516
  // never again `rmSync` a working-tree source dir.
1381
- assertNodeModulesDest(dest, node);
1517
+ assertNodeModulesDest(dest, node, prefix);
1382
1518
  // Idempotent fast-path — skip the cache read + rm + re-extract for a node already
1383
1519
  // extracted at the resolved version. The npm/yarn/pnpm default (only added/changed
1384
1520
  // nodes are written) and the dominant cost on a warm re-install.
@@ -1387,14 +1523,15 @@ async function extractOne(node, prefix, npmrc, log, signal) {
1387
1523
  log('up-to-date: %s@%s (already extracted at %s)', node.name, node.version, node.installPath);
1388
1524
  return true;
1389
1525
  }
1390
- // Content-addressable cache before the network: tarballs are immutable per SRI
1391
- // integrity, so a hash hit is byte-identical to what the registry would return and
1392
- // needs no verifying re-download.
1393
- let bytes = getCachedTarball(node.integrity);
1526
+ // Content-addressable cache before the network. Both readers RE-HASH the bytes
1527
+ // against the integrity that named them, so a hit is byte-identical to what the
1528
+ // registry would return because it was checked, not because the path says so —
1529
+ // the store is a restorable CI artifact now, not just a local directory.
1530
+ let bytes = await getCachedTarball(node.integrity);
1394
1531
  if (bytes) {
1395
1532
  log('cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
1396
1533
  }
1397
- else if ((bytes = getForeignCachedTarball(node.integrity))) {
1534
+ else if ((bytes = await getForeignCachedTarball(node.integrity))) {
1398
1535
  // Second chance: npm's cacache content store, same SRI key — anyone who has run
1399
1536
  // `npm install` already has the tarball on disk. Written through to OUR store so
1400
1537
  // the next `gjsify install` hits first-class even if npm prunes its cache.
@@ -1514,7 +1651,7 @@ function abortError(signal) {
1514
1651
  * a workspace source tree, where `rmSync(dest, { recursive: true })` deletes the
1515
1652
  * link's target contents.
1516
1653
  */
1517
- function assertNodeModulesDest(dest, node) {
1654
+ function assertNodeModulesDest(dest, node, prefix) {
1518
1655
  const segments = dest.split(path.sep);
1519
1656
  if (!segments.includes('node_modules')) {
1520
1657
  throw new Error(`gjsify install: refusing to extract ${node.name}@${node.version} into ${dest} — ` +
@@ -1533,9 +1670,36 @@ function assertNodeModulesDest(dest, node) {
1533
1670
  if (!realSegments.includes('node_modules')) {
1534
1671
  throw new Error(`gjsify install: refusing to extract ${node.name}@${node.version} — ${dest} resolves to ${real}, ` +
1535
1672
  `which is outside any node_modules/ directory (likely a symlink to a workspace source tree). ` +
1536
- `Extracting here would delete working-tree source files.`);
1673
+ `Extracting here would delete working-tree source files.${devLinkRemedy(prefix, dest)}`);
1537
1674
  }
1538
1675
  }
1676
+ /**
1677
+ * The extra sentence for the ONE cause this refusal has that a developer can undo
1678
+ * with a command: a `gjsify link` development link.
1679
+ *
1680
+ * Measured: relinking with a narrower `--packages` left `node_modules/is-number`
1681
+ * pointing into the checkout while the new override no longer named it, and every
1682
+ * later `gjsify install` died on the refusal above — which spoke only of "a
1683
+ * workspace source tree" and named no command at all. `gjsify unlink` did not
1684
+ * rescue it either (it removed the override first, destroying the escape route),
1685
+ * so the only way out was a hand-written `rm`. Both halves are fixed at the
1686
+ * source; this message is what tells someone already wedged by an older CLI.
1687
+ *
1688
+ * The override file may be GONE by the time this fires, so its absence is not
1689
+ * evidence: the remedy is named whenever a `node_modules` entry points outside,
1690
+ * and it names the file only when it is actually there.
1691
+ */
1692
+ function devLinkRemedy(prefix, dest) {
1693
+ // Same walk the guard and the installer do, so the path this message tells
1694
+ // someone to run `gjsify unlink` in is the one that holds the override.
1695
+ const root = devLinkRoot(prefix);
1696
+ const override = devLinkPath(root);
1697
+ const active = fs.existsSync(override);
1698
+ return (`\n If a \`gjsify link\` development link put it there${active ? ` — ${override} is present` : ''}: ` +
1699
+ `run \`gjsify unlink\` in ${root} to remove the links and restore the registry copies, ` +
1700
+ `or \`gjsify link <checkout> --packages …\` to re-select. ` +
1701
+ `If no override is left, delete ${dest} by hand and re-run.`);
1702
+ }
1539
1703
  function depth(installPath) {
1540
1704
  // `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, …
1541
1705
  return installPath.split('/node_modules/').length;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The half of `--immutable` that was missing: every locked package is present AND
3
+ * NOTHING ELSE IS.
4
+ *
5
+ * The installer only ever ADDED. Handed a `node_modules` carrying a package the
6
+ * lockfile never mentions, `--immutable` extracted whatever was missing, left the
7
+ * stranger in place and exited 0 — a green install over a tree its own lockfile does
8
+ * not describe.
9
+ *
10
+ * THE INCIDENT (gjsify#1683, 2026-09-15). CI caches `node_modules` keyed on
11
+ * `hashFiles('gjsify-lock.json')` with a prefix fallback `restore-keys:
12
+ * node-modules-v1-`. An earlier run of a `@girs` bump genuinely nested
13
+ * `node_modules/@girs/gtk-4.0/node_modules/@girs/graphene-1.0` and saved that tree.
14
+ * The fix push changed the lockfile → new key → miss → the FALLBACK restored the
15
+ * nested tree. `--immutable` added what was missing, pruned nothing, and `tsc` then
16
+ * saw one type under two identities: TS2883. Worse, the still-nested tree was saved
17
+ * under the NEW key, so no plain re-run could go green — the failure reproduced
18
+ * itself until the caches were deleted by hand. Nothing about the PR's code was
19
+ * wrong, and any lockfile-changing PR could poison itself the same way.
20
+ *
21
+ * WHY NAMING, NOT DELETING. The peers split: `npm ci` removes `node_modules` first,
22
+ * yarn's `--immutable` fails on a mismatch. This follows yarn, for three reasons.
23
+ * The flag's own name promises not to mutate, and this repo already refuses
24
+ * automatic deletion under it ({@link automaticPruneRefusal} returns `'--immutable'`).
25
+ * A verifier that deletes can delete the wrong thing — a `link`ed dep, a package
26
+ * some other tool put there — while a verifier that reports cannot. And the failure
27
+ * mode is asymmetric: a wrong deletion loses work, a wrong refusal costs one red run
28
+ * carrying the exact paths and the fix.
29
+ *
30
+ * That choice has a price, paid in `.github/actions/gjsify-setup/action.yml`: with
31
+ * this check fatal, a restored FOREIGN tree stops being silent corruption and
32
+ * becomes a hard failure on every lockfile-changing PR. So the `restore-keys`
33
+ * fallback is gone — see the comment there. The two decisions are one decision.
34
+ */
35
+ import type { InstalledPackage } from './prune-prefix.js';
36
+ /** A package directory on disk that the lockfile does not describe. */
37
+ export interface ExtraneousPackage {
38
+ /**
39
+ * Path relative to the install prefix, always `/`-separated — the exact shape of
40
+ * a `gjsify-lock.json` `packages` key, so "not in the lockfile" is checkable by
41
+ * grepping the reported string against the file.
42
+ */
43
+ installPath: string;
44
+ /** From the on-disk manifest; the directory path when it was unreadable. */
45
+ name: string;
46
+ version: string;
47
+ }
48
+ /**
49
+ * Package directories under `prefix` that no entry of `expected` accounts for.
50
+ *
51
+ * Pure: it takes the scan rows ({@link scanPrefix}) and the resolved set rather than
52
+ * reading either, so every branch — nested placements, symlinks, subtree collapsing —
53
+ * is decidable without a filesystem.
54
+ *
55
+ * Three things are deliberately NOT extraneous:
56
+ * - a SYMLINK, which is a workspace source tree linked in by `workspaceInstall`
57
+ * and by construction absent from the lockfile (`scanPrefix` does not descend
58
+ * into one either, so the user's own sources are never walked);
59
+ * - a dot-entry like `.bin` or `.cache`, already dropped by the scan;
60
+ * - a package the lockfile describes but the PLATFORM filter skipped on this host.
61
+ * It is described, so it is not a stranger; removing the foreign-platform residue
62
+ * is `prune-prefix.ts`'s separate job under a separate rule.
63
+ *
64
+ * Descendants of an extraneous directory are collapsed away: reporting the parent
65
+ * says everything, and the nested `@girs` tree of the incident would otherwise list
66
+ * its whole subtree.
67
+ */
68
+ export declare function findExtraneous(opts: {
69
+ prefix: string;
70
+ installed: readonly InstalledPackage[];
71
+ expected: readonly {
72
+ installPath: string;
73
+ }[];
74
+ }): ExtraneousPackage[];
75
+ /** How many paths the message names before it starts counting. */
76
+ export declare const EXTRANEOUS_REPORT_LIMIT = 20;
77
+ /**
78
+ * The refusal, built to be actionable from the CI log alone: what is wrong, which
79
+ * paths, and the one thing that fixes it. Truncated past
80
+ * {@link EXTRANEOUS_REPORT_LIMIT} so a wholly foreign tree does not bury the advice
81
+ * under a thousand lines.
82
+ *
83
+ * It says DELETE, and says so as the only remedy, because that is what was measured:
84
+ * a plain `gjsify install` — and an explicit `--prune` — leave both a top-level and a
85
+ * nested stranger exactly where they were. The installer only adds, and `prune-prefix`
86
+ * judges one thing, npm's `os`/`cpu`/`libc`, so a package that is merely UNDESCRIBED
87
+ * matches no rule either owns. Sending a developer to `gjsify install` instead would
88
+ * hand them a command that changes nothing and a refusal that repeats verbatim.
89
+ */
90
+ export declare function formatExtraneousError(entries: readonly ExtraneousPackage[], prefix: string): string;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The half of `--immutable` that was missing: every locked package is present AND
3
+ * NOTHING ELSE IS.
4
+ *
5
+ * The installer only ever ADDED. Handed a `node_modules` carrying a package the
6
+ * lockfile never mentions, `--immutable` extracted whatever was missing, left the
7
+ * stranger in place and exited 0 — a green install over a tree its own lockfile does
8
+ * not describe.
9
+ *
10
+ * THE INCIDENT (gjsify#1683, 2026-09-15). CI caches `node_modules` keyed on
11
+ * `hashFiles('gjsify-lock.json')` with a prefix fallback `restore-keys:
12
+ * node-modules-v1-`. An earlier run of a `@girs` bump genuinely nested
13
+ * `node_modules/@girs/gtk-4.0/node_modules/@girs/graphene-1.0` and saved that tree.
14
+ * The fix push changed the lockfile → new key → miss → the FALLBACK restored the
15
+ * nested tree. `--immutable` added what was missing, pruned nothing, and `tsc` then
16
+ * saw one type under two identities: TS2883. Worse, the still-nested tree was saved
17
+ * under the NEW key, so no plain re-run could go green — the failure reproduced
18
+ * itself until the caches were deleted by hand. Nothing about the PR's code was
19
+ * wrong, and any lockfile-changing PR could poison itself the same way.
20
+ *
21
+ * WHY NAMING, NOT DELETING. The peers split: `npm ci` removes `node_modules` first,
22
+ * yarn's `--immutable` fails on a mismatch. This follows yarn, for three reasons.
23
+ * The flag's own name promises not to mutate, and this repo already refuses
24
+ * automatic deletion under it ({@link automaticPruneRefusal} returns `'--immutable'`).
25
+ * A verifier that deletes can delete the wrong thing — a `link`ed dep, a package
26
+ * some other tool put there — while a verifier that reports cannot. And the failure
27
+ * mode is asymmetric: a wrong deletion loses work, a wrong refusal costs one red run
28
+ * carrying the exact paths and the fix.
29
+ *
30
+ * That choice has a price, paid in `.github/actions/gjsify-setup/action.yml`: with
31
+ * this check fatal, a restored FOREIGN tree stops being silent corruption and
32
+ * becomes a hard failure on every lockfile-changing PR. So the `restore-keys`
33
+ * fallback is gone — see the comment there. The two decisions are one decision.
34
+ */
35
+ import { relative, sep } from 'node:path';
36
+ /** `/`-separated, no trailing slash — lockfile key shape on every host. */
37
+ function normalizePath(p) {
38
+ const slashed = sep === '/' ? p : p.split(sep).join('/');
39
+ return slashed.endsWith('/') ? slashed.slice(0, -1) : slashed;
40
+ }
41
+ /**
42
+ * Package directories under `prefix` that no entry of `expected` accounts for.
43
+ *
44
+ * Pure: it takes the scan rows ({@link scanPrefix}) and the resolved set rather than
45
+ * reading either, so every branch — nested placements, symlinks, subtree collapsing —
46
+ * is decidable without a filesystem.
47
+ *
48
+ * Three things are deliberately NOT extraneous:
49
+ * - a SYMLINK, which is a workspace source tree linked in by `workspaceInstall`
50
+ * and by construction absent from the lockfile (`scanPrefix` does not descend
51
+ * into one either, so the user's own sources are never walked);
52
+ * - a dot-entry like `.bin` or `.cache`, already dropped by the scan;
53
+ * - a package the lockfile describes but the PLATFORM filter skipped on this host.
54
+ * It is described, so it is not a stranger; removing the foreign-platform residue
55
+ * is `prune-prefix.ts`'s separate job under a separate rule.
56
+ *
57
+ * Descendants of an extraneous directory are collapsed away: reporting the parent
58
+ * says everything, and the nested `@girs` tree of the incident would otherwise list
59
+ * its whole subtree.
60
+ */
61
+ export function findExtraneous(opts) {
62
+ const described = new Set(opts.expected.map((n) => normalizePath(n.installPath)));
63
+ const found = [];
64
+ for (const pkg of opts.installed) {
65
+ if (pkg.linked)
66
+ continue;
67
+ const installPath = normalizePath(relative(opts.prefix, pkg.dir));
68
+ // Outside the prefix, or the prefix itself: not this install's to judge.
69
+ if (installPath === '' || installPath.startsWith('..'))
70
+ continue;
71
+ if (described.has(installPath))
72
+ continue;
73
+ found.push({ installPath, name: pkg.name, version: pkg.version });
74
+ }
75
+ found.sort((a, b) => (a.installPath < b.installPath ? -1 : a.installPath > b.installPath ? 1 : 0));
76
+ const roots = [];
77
+ for (const entry of found) {
78
+ const parent = roots[roots.length - 1];
79
+ // Sorted, so an enclosing extraneous directory is always the last one kept.
80
+ if (parent && entry.installPath.startsWith(`${parent.installPath}/`))
81
+ continue;
82
+ roots.push(entry);
83
+ }
84
+ return roots;
85
+ }
86
+ /** How many paths the message names before it starts counting. */
87
+ export const EXTRANEOUS_REPORT_LIMIT = 20;
88
+ /**
89
+ * The refusal, built to be actionable from the CI log alone: what is wrong, which
90
+ * paths, and the one thing that fixes it. Truncated past
91
+ * {@link EXTRANEOUS_REPORT_LIMIT} so a wholly foreign tree does not bury the advice
92
+ * under a thousand lines.
93
+ *
94
+ * It says DELETE, and says so as the only remedy, because that is what was measured:
95
+ * a plain `gjsify install` — and an explicit `--prune` — leave both a top-level and a
96
+ * nested stranger exactly where they were. The installer only adds, and `prune-prefix`
97
+ * judges one thing, npm's `os`/`cpu`/`libc`, so a package that is merely UNDESCRIBED
98
+ * matches no rule either owns. Sending a developer to `gjsify install` instead would
99
+ * hand them a command that changes nothing and a refusal that repeats verbatim.
100
+ */
101
+ export function formatExtraneousError(entries, prefix) {
102
+ const shown = entries.slice(0, EXTRANEOUS_REPORT_LIMIT);
103
+ const lines = shown.map((e) => ` ${e.installPath} (${e.name}@${e.version})`);
104
+ if (entries.length > shown.length)
105
+ lines.push(` … and ${entries.length - shown.length} more`);
106
+ return [
107
+ `install: --immutable, but ${prefix}/node_modules holds ${entries.length} package(s) ` +
108
+ 'that gjsify-lock.json does not describe.',
109
+ '--immutable installs the lockfile and removes nothing, so each of these would survive ' +
110
+ 'the install and be resolved, bundled and type-checked as if it belonged ' +
111
+ '(gjsify#1683: a nested copy of an already-hoisted package → TS2883, one type ' +
112
+ 'with two identities).',
113
+ ...lines,
114
+ 'Fix: delete those directories — or the whole node_modules — and re-run. A plain ' +
115
+ '`gjsify install` does NOT clear them: it only ever adds, and its prune pass judges ' +
116
+ 'foreign-PLATFORM packages alone (prune-prefix.ts), so an undescribed package ' +
117
+ 'survives both. Deleting is currently the only thing that removes one. ' +
118
+ 'In CI this usually means a cache entry was restored from a DIFFERENT lockfile.',
119
+ ].join('\n');
120
+ }
@@ -17,8 +17,8 @@
17
17
  // `owner.json` inside records pid + start time for staleness checks.
18
18
  //
19
19
  // Staleness — a crashed install cannot release its lock, so contenders steal when
20
- // (a) the owner pid is provably dead (`/proc/<pid>` on Linux, readable under Node and GJS
21
- // alike; a kill(0) probe covers non-/proc Nodes), or
20
+ // (a) the owner pid is provably dead (`isPidAlive` in `process-table.ts`: `/proc/<pid>` where
21
+ // procfs exists, a signal-0 probe elsewhere), or
22
22
  // (b) the lock outlives GJSIFY_INSTALL_LOCK_STALE_MS (default 35 min — just above the 30-min
23
23
  // install budget, so a budget-bound install always exits before its lock can be stolen out
24
24
  // from under it), or
@@ -31,8 +31,9 @@
31
31
  //
32
32
  // GJSIFY_INSTALL_LOCK=0 disables locking entirely — for a network filesystem with broken
33
33
  // rename/mkdir atomicity, or an outer tool that already serializes installs.
34
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
34
+ import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
35
35
  import { dirname, join, resolve } from 'node:path';
36
+ import { isPidAlive } from './process-table.js';
36
37
  const LOCK_DIR_NAME = '.gjsify-install-lock';
37
38
  const OWNER_FILE = 'owner.json';
38
39
  /** Default staleness budget — see the header comment for why 35 min. */
@@ -60,28 +61,6 @@ function lockingDisabled() {
60
61
  const trimmed = flag.trim();
61
62
  return trimmed === '0' || trimmed === 'false';
62
63
  }
63
- /**
64
- * Is `pid` a live process on this machine?
65
- *
66
- * `/proc/<pid>` is the reliable cross-runtime probe on Linux, identical under Node and GJS;
67
- * elsewhere a signal-0 probe (ESRCH dead, EPERM alive-but-foreign). The GJS `process.kill`
68
- * polyfill shells out and cannot report ESRCH, but GJS only runs on /proc platforms in practice,
69
- * so the fallback branch is Node's. Unknown outcomes count as ALIVE — never steal a lock that
70
- * cannot be proven abandoned; the mtime staleness budget still applies.
71
- */
72
- function isPidAlive(pid) {
73
- // `existsSync` never throws by contract: Node returns false on any error, and the GJS shim is
74
- // a typeof guard + Gio `query_exists`, neither with a throw path in the GIR.
75
- if (existsSync('/proc/self'))
76
- return existsSync(`/proc/${pid}`);
77
- try {
78
- process.kill(pid, 0);
79
- return true;
80
- }
81
- catch (err) {
82
- return err.code === 'EPERM';
83
- }
84
- }
85
64
  function mtimeMs(path) {
86
65
  try {
87
66
  return statSync(path).mtimeMs;