@gjsify/cli 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/cli.gjs.mjs +3 -3
  2. package/lib/bundler-pick.js +61 -87
  3. package/lib/cli-fail.d.ts +4 -7
  4. package/lib/cli-fail.js +29 -55
  5. package/lib/commands/affected.js +161 -286
  6. package/lib/commands/install.d.ts +63 -66
  7. package/lib/commands/install.js +372 -536
  8. package/lib/templates/install.mjs.tmpl +38 -68
  9. package/lib/types/cli-build-options.d.ts +33 -67
  10. package/lib/types/command.d.ts +9 -12
  11. package/lib/types/config-data.d.ts +125 -221
  12. package/lib/utils/auth-npmrc.d.ts +5 -10
  13. package/lib/utils/auth-npmrc.js +11 -21
  14. package/lib/utils/clear-targets.d.ts +8 -11
  15. package/lib/utils/clear-targets.js +22 -29
  16. package/lib/utils/copy-targets.d.ts +13 -19
  17. package/lib/utils/copy-targets.js +31 -44
  18. package/lib/utils/dep-aggregation.d.ts +7 -15
  19. package/lib/utils/dep-aggregation.js +9 -22
  20. package/lib/utils/detect-native-packages.d.ts +83 -116
  21. package/lib/utils/detect-native-packages.js +111 -149
  22. package/lib/utils/dir-link.d.ts +29 -48
  23. package/lib/utils/dir-link.js +33 -56
  24. package/lib/utils/discover-showcases.d.ts +6 -7
  25. package/lib/utils/discover-showcases.js +11 -15
  26. package/lib/utils/dlx-cache.d.ts +29 -38
  27. package/lib/utils/dlx-cache.js +53 -63
  28. package/lib/utils/force-exit.d.ts +0 -3
  29. package/lib/utils/force-exit.js +13 -24
  30. package/lib/utils/gi-search-path.d.ts +16 -24
  31. package/lib/utils/gi-search-path.js +54 -82
  32. package/lib/utils/gjs-bundle-guard.d.ts +5 -13
  33. package/lib/utils/gjs-bundle-guard.js +35 -57
  34. package/lib/utils/gjs-source-escape.d.ts +2 -2
  35. package/lib/utils/gjs-source-escape.js +21 -39
  36. package/lib/utils/gjsify-shim.d.ts +17 -20
  37. package/lib/utils/gjsify-shim.js +80 -120
  38. package/lib/utils/html-entry.d.ts +8 -12
  39. package/lib/utils/html-entry.js +19 -28
  40. package/lib/utils/install-backend-native.d.ts +123 -161
  41. package/lib/utils/install-backend-native.js +536 -737
  42. package/lib/utils/install-backend.d.ts +42 -57
  43. package/lib/utils/install-backend.js +16 -21
  44. package/lib/utils/install-cache-fs.d.ts +10 -15
  45. package/lib/utils/install-cache-fs.js +18 -26
  46. package/lib/utils/install-lock.d.ts +5 -6
  47. package/lib/utils/install-lock.js +46 -65
  48. package/lib/utils/install-packument-cache.d.ts +27 -33
  49. package/lib/utils/install-packument-cache.js +44 -57
  50. package/lib/utils/install-progress.d.ts +2 -9
  51. package/lib/utils/install-progress.js +14 -39
  52. package/lib/utils/install-tarball-cache.d.ts +14 -20
  53. package/lib/utils/install-tarball-cache.js +41 -59
  54. package/lib/utils/library-output.d.ts +9 -22
  55. package/lib/utils/library-output.js +19 -37
  56. package/lib/utils/load-npmrc.d.ts +5 -6
  57. package/lib/utils/load-npmrc.js +18 -27
  58. package/lib/utils/node-bundle-guard.d.ts +4 -8
  59. package/lib/utils/node-bundle-guard.js +21 -35
  60. package/lib/utils/node-script.js +33 -54
  61. package/lib/utils/node-version.d.ts +5 -14
  62. package/lib/utils/node-version.js +15 -24
  63. package/lib/utils/normalize-bundler-options.d.ts +7 -11
  64. package/lib/utils/normalize-bundler-options.js +24 -42
  65. package/lib/utils/npm-oidc.d.ts +23 -37
  66. package/lib/utils/npm-oidc.js +28 -53
  67. package/lib/utils/npm-otp-cache.d.ts +4 -4
  68. package/lib/utils/npm-otp-cache.js +20 -25
  69. package/lib/utils/npm-otp.d.ts +20 -30
  70. package/lib/utils/npm-otp.js +32 -51
  71. package/lib/utils/onboard-probe.d.ts +7 -13
  72. package/lib/utils/onboard-probe.js +16 -27
  73. package/lib/utils/parse-spec.d.ts +3 -3
  74. package/lib/utils/parse-spec.js +4 -5
  75. package/lib/utils/pkg-json-edit.d.ts +7 -12
  76. package/lib/utils/pkg-json-edit.js +13 -26
  77. package/lib/utils/pkg-json.d.ts +11 -18
  78. package/lib/utils/pkg-json.js +19 -28
  79. package/lib/utils/prefixed-output.d.ts +3 -4
  80. package/lib/utils/prefixed-output.js +13 -19
  81. package/lib/utils/prompt.d.ts +3 -4
  82. package/lib/utils/prompt.js +14 -18
  83. package/lib/utils/publish-diagnose.d.ts +11 -16
  84. package/lib/utils/publish-diagnose.js +15 -28
  85. package/lib/utils/publish-headers.d.ts +7 -11
  86. package/lib/utils/publish-headers.js +36 -65
  87. package/lib/utils/publishable-packages.d.ts +4 -6
  88. package/lib/utils/publishable-packages.js +14 -18
  89. package/lib/utils/resolve-gjs-entry.d.ts +5 -8
  90. package/lib/utils/resolve-gjs-entry.js +10 -27
  91. package/lib/utils/resolve-npm-package.d.ts +6 -11
  92. package/lib/utils/resolve-npm-package.js +29 -63
  93. package/lib/utils/resolve-plugin-by-name.d.ts +11 -19
  94. package/lib/utils/resolve-plugin-by-name.js +9 -14
  95. package/lib/utils/run-gjs.d.ts +16 -21
  96. package/lib/utils/run-gjs.js +31 -56
  97. package/lib/utils/run-lifecycle-script.d.ts +5 -9
  98. package/lib/utils/run-lifecycle-script.js +23 -42
  99. package/lib/utils/run-node.d.ts +21 -33
  100. package/lib/utils/run-node.js +43 -64
  101. package/lib/utils/runtimes.d.ts +25 -35
  102. package/lib/utils/runtimes.js +34 -46
  103. package/lib/utils/spawn.d.ts +32 -48
  104. package/lib/utils/spawn.js +51 -84
  105. package/lib/utils/stdin.d.ts +2 -2
  106. package/lib/utils/stdin.js +10 -25
  107. package/lib/utils/suggest.d.ts +6 -7
  108. package/lib/utils/suggest.js +8 -10
  109. package/lib/utils/system-gi.d.ts +18 -27
  110. package/lib/utils/system-gi.js +60 -114
  111. package/lib/utils/trust-registry.d.ts +4 -8
  112. package/lib/utils/trust-registry.js +7 -15
  113. package/lib/utils/win32-command.d.ts +8 -14
  114. package/lib/utils/win32-command.js +29 -43
  115. package/lib/utils/workspace-root.js +7 -12
  116. package/package.json +18 -18
@@ -1,32 +1,21 @@
1
- // `gjsify install [pkg...]` — install packages with gjsify-aware post-checks.
1
+ // `gjsify install [pkg...]` — project install, `<pkg>` add, or `-g` user-global
2
+ // install (XDG prefix + GJS-runnable bin), plus gjsify-aware post-checks.
2
3
  //
3
- // Modes:
4
- // gjsify install → project install (native, reads pkg.json)
5
- // gjsify install <pkg> [<pkg>...] → add package(s) to project (native)
6
- // gjsify install -g <pkg> [...] → user-global install (XDG, GJS-runnable bin)
4
+ // All three modes route through `installPackagesNative`
5
+ // (`@gjsify/{semver,npm-registry,tar}`) — no Node/npm at runtime.
6
+ // `--backend=npm` / `GJSIFY_INSTALL_BACKEND=npm` is the escape hatch for what
7
+ // the native backend does not model (Yarn PnP, lifecycle scripts).
7
8
  //
8
- // All three modes route through `@gjsify/{semver,npm-registry,tar}` via
9
- // `installPackagesNative` — no Node/npm required at runtime. Pass
10
- // `--backend=npm` (or set the legacy `GJSIFY_INSTALL_BACKEND=npm` env var)
11
- // to opt back into the `npm install` subprocess flow — useful as an
12
- // escape hatch for projects that hit a missing native-backend feature
13
- // (Yarn PnP repos, lifecycle scripts, npm's `overrides` quirks).
9
+ // A workspace install hoists every member's externals into the root
10
+ // `node_modules/` and symlinks `workspace:` refs to their source. Members with
11
+ // conflicting ranges of the same external still share ONE hoisted copy; the
12
+ // resolver warns naming both ranges, both requesters and the winner (per-member
13
+ // dedup is the open Phase D.8 item in status/open-todos.md).
14
14
  //
15
- // Workspace install (`gjsify install` in a monorepo root with a
16
- // `"workspaces"` field) hoists every workspace's externals into the root
17
- // `node_modules/` and symlinks `workspace:*` / `workspace:^` / `workspace:~`
18
- // refs to their target source. Conflicting version ranges of the same
19
- // external dep from different workspaces still share ONE hoisted root copy
20
- // (a real per-workspace dedup pass is Phase D.8, see status/open-todos.md), but since
21
- // ADR 0001 step 3 the resolver surfaces every such conflict loudly —
22
- // `[gjsify] warning: version conflict for <pkg> …` names both ranges, the
23
- // workspaces that requested them, and the version that actually won.
24
- //
25
- // Concurrency (ADR 0001 step 2): every mutation of an install prefix —
26
- // project node_modules/, gjsify-lock.json, the user-global prefix — runs
27
- // under a per-prefix cross-process lock (utils/install-lock.ts). Installs
28
- // into different prefixes stay parallel; the shared XDG tarball/packument
29
- // caches need no lock because their writes are atomic tmp+rename.
15
+ // Concurrency (ADR 0001): every prefix mutation runs under a per-prefix
16
+ // cross-process lock (utils/install-lock.ts), so installs into different
17
+ // prefixes stay parallel. The shared XDG tarball/packument caches need no lock
18
+ // because their writes are atomic tmp+rename.
30
19
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
31
20
  import { dirname, join, relative, resolve } from 'node:path';
32
21
  import { fileURLToPath } from 'node:url';
@@ -47,30 +36,25 @@ import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFro
47
36
  /**
48
37
  * Link type for the workspace↔workspace directory links.
49
38
  *
50
- * Windows cannot create a *directory symlink* without elevation or Developer
51
- * Mode (`EPERM`), but any user can create an NTFS **junction** — which is why
52
- * npm and yarn both use junctions for workspace links. These two call sites had
53
- * no try/catch, so a non-elevated Windows workspace install hard-failed here.
39
+ * Windows cannot create a directory *symlink* without elevation or Developer
40
+ * Mode (`EPERM`), but any user can create an NTFS junction — which is why npm
41
+ * and yarn both use junctions for workspace links.
54
42
  */
55
43
  const WORKSPACE_LINK_TYPE = process.platform === 'win32' ? 'junction' : undefined;
56
44
  /**
57
45
  * The `target` argument for a workspace link.
58
46
  *
59
- * Node normalises a `'junction'` target with `path.resolve()` — i.e. against
60
- * the process CWD, NOT the link's own directory — so a junction MUST be given
61
- * an absolute path. POSIX symlinks stay relative so the tree survives being
62
- * moved.
47
+ * Node normalises a `'junction'` target with `path.resolve()` — against the
48
+ * process CWD, not the link's own directory — so a junction must be given an
49
+ * absolute path. POSIX symlinks stay relative so the tree survives a move.
63
50
  */
64
51
  function workspaceLinkTarget(linkPath, absTarget) {
65
52
  return WORKSPACE_LINK_TYPE === 'junction' ? resolve(absTarget) : relative(dirname(linkPath), absTarget);
66
53
  }
67
- // Default 30min wall-clock budget for the full install. Big workspaces
68
- // (212+ sub-packages × 600+ external deps in the gjsify monorepo itself)
69
- // can legitimately take 10-20 min on a fresh CI install when the npm CDN
70
- // is slow — a 5-min default would false-positive on those legitimate
71
- // flows. Per-fetch timeout (30s, retried) catches the truly stuck case
72
- // inside this budget. Set --timeout 0 to disable the wall-clock guard
73
- // entirely.
54
+ // 30 min, not minutes: this monorepo (212+ members × 600+ externals) legitimately
55
+ // takes 10-20 min on a fresh CI install behind a slow npm CDN, so a tighter
56
+ // default would false-positive. The per-fetch timeout (30s, retried) catches the
57
+ // truly stuck case inside this budget.
74
58
  const DEFAULT_INSTALL_TIMEOUT_MS = 1_800_000;
75
59
  export const installCommand = {
76
60
  command: 'install [packages..]',
@@ -143,37 +127,33 @@ export const installCommand = {
143
127
  default: false,
144
128
  }),
145
129
  handler: async (args) => {
146
- // Publish `--os/--cpu/--libc/--force` as npm CONFIG KEYS before anything
147
- // resolves — see applyPlatformConfigFromFlags for why the environment is
148
- // the right channel for them rather than a parameter.
130
+ // Must run before anything resolves — `--os/--cpu/--libc/--force` are npm
131
+ // config keys, see applyPlatformConfigFromFlags for why via the environment.
149
132
  applyPlatformConfigFromFlags(args);
150
- // --immutable is incompatible with explicit `<pkg>` adds and with
151
- // `--global` (which has no lockfile concept). Matches yarn's
152
- // behavior: `yarn add --immutable` is a hard error.
133
+ // --immutable rejects `<pkg>` adds and `--global` (no lockfile concept),
134
+ // matching yarn, where `yarn add --immutable` is a hard error.
153
135
  if (args.immutable) {
154
136
  if (args.packages && args.packages.length > 0) {
155
137
  console.error('gjsify install --immutable does not accept package arguments. ' +
156
138
  'Remove the package names or drop --immutable.');
157
- // `return` — a bare `process.exit()` is deferred under GJS and
158
- // the install would proceed past a refused flag combination.
139
+ // `return` — a bare `process.exit()` is deferred under GJS, and the
140
+ // install would proceed past a refused flag combination. Same for
141
+ // every `return process.exit()` below.
159
142
  return process.exit(1);
160
143
  }
161
144
  if (args.global) {
162
145
  console.error('gjsify install --immutable is incompatible with --global.');
163
- // `return` — see the deferred-exit note above.
164
146
  return process.exit(1);
165
147
  }
166
148
  if (args['refresh-lockfile']) {
167
149
  console.error('gjsify install --immutable is incompatible with --refresh-lockfile ' +
168
150
  '(--immutable forbids rewriting the lockfile). Drop one.');
169
- // `return` — see the deferred-exit note above.
170
151
  return process.exit(1);
171
152
  }
172
153
  }
173
154
  if (args.global) {
174
155
  if (!args.packages || args.packages.length === 0) {
175
156
  console.error('gjsify install --global requires at least one <pkg> argument.');
176
- // `return` — see the deferred-exit note above.
177
157
  return process.exit(1);
178
158
  }
179
159
  for (const flag of ['save-dev', 'save-peer', 'save-optional']) {
@@ -184,68 +164,43 @@ export const installCommand = {
184
164
  await installGlobalAndLink(args.packages, { verbose: args.verbose });
185
165
  return;
186
166
  }
187
- // Surface a CLI/workspace version skew BEFORE the heavy install, so
188
- // it's visible even if a later phase is slow. A gjsify CLI that is out
189
- // of step with the @gjsify/cli the workspace pins is the root cause of
190
- // both the mysterious install stall (a mismatched CLI resolves a
191
- // differently-shaped lock → full fresh resolve → a wedge-prone extract)
192
- // and silent-wrong-builds (the stale CLI builds with stale semantics).
167
+ // Before the heavy install, so the skew is visible even if a later phase
168
+ // is slow — it is the root cause the slow phase would otherwise mask.
193
169
  warnOnCliVersionSkew(process.cwd());
194
- // Backend selection (in precedence order):
195
- // 1. --backend flag (explicit user choice)
196
- // 2. GJSIFY_INSTALL_BACKEND env (back-compat shape from pre-flag era)
197
- // 3. native (default)
198
170
  const backend = args.backend ?? process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
199
171
  if (backend === 'npm') {
200
172
  await projectInstallViaNpm(args);
201
173
  await runPostInstallChecks(args);
202
174
  return;
203
175
  }
204
- // Overall wall-clock budget for the install (default 30 min — big
205
- // workspaces on slow networks legitimately take a while). On timeout
206
- // we abort every in-flight registry fetch via this controller so a
207
- // signal-aware await rejects and the process exits cleanly with an
208
- // actionable message. Per-request timeouts inside @gjsify/npm-registry
209
- // (default 30s, retried) still apply within this budget.
176
+ // On timeout this controller aborts every in-flight registry fetch, so a
177
+ // signal-aware await rejects and the process exits with an actionable
178
+ // message.
210
179
  //
211
- // The abort only rescues awaits that OBSERVE the signal. Some awaits on
212
- // the fresh-resolve path do not — most notably tarball extraction (a
213
- // Gio-backed decompress whose stream close-event can be dropped under
214
- // GJS) and the workspace symlink fs-pool — so an aborted controller
215
- // alone cannot guarantee the process ever stops. `hardExitTimerId`
216
- // below is the backstop: a grace period after the abort, if the install
217
- // STILL hasn't returned, force a non-zero exit. This converts any
218
- // residual never-settling await into a clean failure instead of a
219
- // silent 0%-CPU hang (the observed "it never completed; I killed it"
220
- // pathology). `extractOne` additionally caps each extract with its own
221
- // stall timeout so the common case fails fast, long before this.
180
+ // But the abort only rescues awaits that OBSERVE the signal, and some on
181
+ // the fresh-resolve path do not — notably tarball extraction (a Gio-backed
182
+ // decompress whose stream close-event can be dropped under GJS) and the
183
+ // workspace symlink fs-pool. `hardExitTimerId` below is the backstop that
184
+ // turns any residual never-settling await into a non-zero exit instead of a
185
+ // silent 0%-CPU hang. `extractOne` also caps each extract with its own
186
+ // stall timeout, so the common case fails long before either.
222
187
  const overallTimeoutMs = args.timeout > 0 ? args.timeout : 0;
223
188
  const overallController = overallTimeoutMs > 0 ? new AbortController() : null;
224
189
  const overallTimerId = overallController !== null
225
190
  ? setTimeout(() => overallController.abort(new Error('install-overall-timeout')), overallTimeoutMs)
226
191
  : null;
227
- // Grace after the abort before we pull the plug. Long enough for a
228
- // signal-aware reject to propagate through the catch (the clean path),
229
- // short enough that a genuinely-wedged await doesn't hang for minutes.
192
+ // Long enough for a signal-aware reject to propagate through the catch (the
193
+ // clean path), short enough that a wedged await does not hang for minutes.
230
194
  const HARD_EXIT_GRACE_MS = 10_000;
231
195
  const hardExitTimerId = overallTimeoutMs > 0
232
196
  ? setTimeout(() => {
233
197
  const secs = Math.round(overallTimeoutMs / 100) / 10;
234
- // Do NOT assert a cause here — this timer cannot tell the
235
- // two shapes apart, and claiming the rarer one sends the
236
- // next reader after a bug that isn't there. Measured on a
237
- // cold tree of this workspace WHEN THIS TIMER WAS WRITTEN:
238
- // 1597 packages / ~4.8 GB extracted, of which ~3.4 GB were
239
- // foreign-platform optional deps (`os`/`cpu` excluding the
240
- // host) that npm would never place. Those are no longer
241
- // downloaded — the resolver now records `os`/`cpu`/`libc`
242
- // and marks incompatible optional nodes inert
243
- // (`utils/platform-check.ts` + `applyPlatformFilter`), which
244
- // leaves ~1.4 GB of that same tree to extract. The budget
245
- // still exists for the same reason: extraction has
246
- // multi-second SYNCHRONOUS stretches (tar parse + file
247
- // writes) and 16 run concurrently, so the clean abort path
248
- // can easily miss the grace window with nothing wedged.
198
+ // Do NOT assert a cause here: this timer cannot tell the two
199
+ // shapes apart, and naming the rarer one sends the next reader
200
+ // after a bug that is not there. Extraction has multi-second
201
+ // SYNCHRONOUS stretches (tar parse + file writes) and 16 run
202
+ // concurrently, so a healthy install can miss the grace window
203
+ // with nothing wedged — measured: ~1.4 GB on a cold tree here.
249
204
  console.error(`gjsify install: the ${secs}s budget elapsed and the abort did not unwind within ` +
250
205
  `${Math.round(HARD_EXIT_GRACE_MS / 1000)}s — forcing exit. That means one of two ` +
251
206
  `things, and this timer cannot distinguish them: the install was still WORKING ` +
@@ -257,9 +212,8 @@ export const installCommand = {
257
212
  forceExit(1);
258
213
  }, overallTimeoutMs + HARD_EXIT_GRACE_MS)
259
214
  : null;
260
- // Don't let the backstop timer keep an otherwise-finished process alive
261
- // (Node keeps the loop up while a timer is armed). `unref` lets the
262
- // process end the instant the install returns; `finally` clears it too.
215
+ // Node keeps the loop up while a timer is armed, so `unref` lets the process
216
+ // end the instant the install returns.
263
217
  hardExitTimerId?.unref?.();
264
218
  try {
265
219
  await projectInstallNative(args, overallController?.signal);
@@ -270,9 +224,8 @@ export const installCommand = {
270
224
  const secs = Math.round(overallTimeoutMs / 100) / 10;
271
225
  console.error(`gjsify install: timed out after ${secs}s — likely a registry slowdown or a wedged extract.\n` +
272
226
  `Re-run, or override with --timeout <ms> (set --timeout 0 to disable the overall budget).`);
273
- // `return` — the deferred GJS exit otherwise fell through into
274
- // the rethrow below and reported the timeout twice. The
275
- // `finally` still runs on the way out and clears the timers.
227
+ // `return` — the deferred GJS exit otherwise fell through into the
228
+ // rethrow below and reported the timeout twice.
276
229
  return process.exit(1);
277
230
  }
278
231
  throw err;
@@ -288,20 +241,16 @@ export const installCommand = {
288
241
  /**
289
242
  * Hand the platform target + force bypass to the install backend.
290
243
  *
291
- * `os`, `cpu`, `libc` and `force` are npm CONFIG KEYS, and npm's own transport
292
- * for a config key is the environment: `--libc=musl` and `npm_config_libc=musl`
293
- * are the same input, which is why supporting the flags gives users the env
294
- * spelling for free. That is also why this is not threaded through as a
295
- * parameter: the config channel is already what this installer reads
296
- * (`loadNpmrc` honours `npm_config_registry` exactly so), one write reaches
297
- * EVERY prefix an invocation may install into (project, workspace root, `-g`,
298
- * the `dlx` cache) instead of one call site each, and a child process inherits
299
- * it — so a nested install can't quietly resolve for a different machine than
300
- * the run that spawned it.
244
+ * `os`, `cpu`, `libc` and `force` are npm CONFIG KEYS, and npm's transport for a
245
+ * config key is the environment (`--libc=musl` ≡ `npm_config_libc=musl`), so the
246
+ * flags come with the env spelling for free. Not threaded as a parameter because
247
+ * one env write reaches EVERY prefix an invocation may install into (project,
248
+ * workspace root, `-g`, the `dlx` cache) and is inherited by children, so a
249
+ * nested install cannot quietly resolve for a different machine than its parent.
301
250
  *
302
- * Empty/whitespace values are ignored rather than written as the empty string:
303
- * `--os=` must not mean "the nameless platform" (`readPlatformOverrides` drops
304
- * them on the read side too, so the two ends agree).
251
+ * Empty/whitespace values are ignored rather than written as the empty string —
252
+ * `--os=` must not mean "the nameless platform", and `readPlatformOverrides`
253
+ * drops them on the read side too.
305
254
  */
306
255
  function applyPlatformConfigFromFlags(args) {
307
256
  const keys = [
@@ -323,12 +272,10 @@ function applyPlatformConfigFromFlags(args) {
323
272
  process.env.npm_config_force = 'true';
324
273
  }
325
274
  /**
326
- * Heuristic: was this error raised because the overall-install AbortSignal
327
- * fired? The signal's `reason` is the sentinel `Error('install-overall-timeout')`
328
- * we installed above; the abort surfaces either as that exact reason or as
329
- * any AbortError thrown by a downstream fetch / setTimeout-on-abort path.
330
- * We match permissively because intermediate layers (fetch, GJS Soup, our
331
- * own delay()) re-wrap the reason in their own AbortError instances.
275
+ * Was this error raised because the overall-install AbortSignal fired? Matched
276
+ * permissively on purpose: intermediate layers (fetch, GJS Soup, our own
277
+ * `delay()`) re-wrap the `install-overall-timeout` reason in their own
278
+ * AbortError instances.
332
279
  */
333
280
  function isAbortedFromOverallTimeout(err) {
334
281
  if (!err || typeof err !== 'object')
@@ -361,9 +308,9 @@ function readCliVersionFrom(pkgJsonPath) {
361
308
  return null;
362
309
  }
363
310
  /**
364
- * Version of the @gjsify/cli that is ACTUALLY running (this bundle / lib entry).
365
- * Walks up from `import.meta.url` to the nearest `@gjsify/cli` package.json —
366
- * same discovery as `self-update`'s `readCurrentVersion`.
311
+ * Version of the `@gjsify/cli` that is ACTUALLY running: walks up from
312
+ * `import.meta.url` to the nearest `@gjsify/cli` package.json, the same
313
+ * discovery as `self-update`'s `readCurrentVersion`.
367
314
  */
368
315
  function readRunningCliVersion() {
369
316
  try {
@@ -381,24 +328,20 @@ function readRunningCliVersion() {
381
328
  return null;
382
329
  }
383
330
  /**
384
- * Version of the @gjsify/cli the workspace at `cwd` pins/ships. Prefers the
385
- * resolved install (`node_modules/@gjsify/cli`, a hoisted symlink in the
386
- * gjsify monorepo or a real install in a consumer), falling back to the
387
- * in-repo source so the check also fires on a fresh, not-yet-installed
388
- * checkout of the gjsify monorepo itself.
331
+ * Version of the `@gjsify/cli` the workspace at `cwd` pins. Prefers the resolved
332
+ * install, falling back to the in-repo source so the check also fires on a fresh,
333
+ * not-yet-installed checkout of the gjsify monorepo itself.
389
334
  */
390
335
  function readWorkspaceCliVersion(cwd) {
391
336
  return (readCliVersionFrom(join(cwd, 'node_modules', CLI_PACKAGE_NAME, 'package.json')) ??
392
337
  readCliVersionFrom(join(cwd, 'packages', 'infra', 'cli', 'package.json')));
393
338
  }
394
339
  /**
395
- * Warn (never block) when the running gjsify CLI is a different version than
396
- * the @gjsify/cli the workspace pins. Running a mismatched CLI is the root of
397
- * two hard-to-diagnose failures documented on real sessions: install stalls (a
398
- * mismatched CLI discards the lock → full fresh resolve → a wedge-prone
399
- * extract) and silent-wrong-builds (stale CLI, stale bundle semantics). A
400
- * single actionable line pointing at `gjsify self-update` beats both silently.
401
- * Suppress with GJSIFY_NO_VERSION_SKEW_WARNING=1.
340
+ * Warn (never block) when the running gjsify CLI differs from the `@gjsify/cli`
341
+ * the workspace pins. A mismatched CLI is the measured root of two
342
+ * hard-to-diagnose failures: install stalls (the lock is discarded → full fresh
343
+ * resolve → wedge-prone extract) and silent-wrong-builds (stale bundle
344
+ * semantics). Suppress with `GJSIFY_NO_VERSION_SKEW_WARNING=1`.
402
345
  */
403
346
  function warnOnCliVersionSkew(cwd) {
404
347
  if (process.env.GJSIFY_NO_VERSION_SKEW_WARNING === '1')
@@ -425,26 +368,23 @@ function isWorkspaceRoot(cwd) {
425
368
  /**
426
369
  * Which dependency NAMES are optional across the given manifests.
427
370
  *
428
- * The specs handed to the install backend are flat `"<name>@<range>"` strings —
429
- * `projectSpecsFromPackageJson` merges the three dependency blocks — so the kind
430
- * has to travel beside them. It matters for exactly one decision: an
431
- * incompatible `os`/`cpu`/`libc` skips an OPTIONAL dependency and fails a
432
- * REQUIRED one (see `NativeInstallOptions.optionalSpecs`).
371
+ * The specs handed to the backend are flat `"<name>@<range>"` strings, so the
372
+ * kind has to travel beside them. It decides exactly one thing: an incompatible
373
+ * `os`/`cpu`/`libc` skips an OPTIONAL dependency and fails a REQUIRED one (see
374
+ * `NativeInstallOptions.optionalSpecs`).
433
375
  *
434
- * REQUIRED WINS ACROSS MANIFESTS, OPTIONAL WINS WITHIN ONE — and the two halves
435
- * are different rules for different reasons, so neither is a special case of the
436
- * other:
437
- * - Across manifests: in a workspace the same package can be a plain dependency
438
- * of one member and an optionalDependency of another. Those are two real
439
- * edges; if one of them is not allowed to be missing, the install is not
440
- * allowed to silently miss it. Subtracting rather than "last block seen" also
441
- * makes the answer independent of manifest iteration order.
442
- * - Within one manifest: a name in BOTH blocks is OPTIONAL, because npm says so
443
- * ("entries in optionalDependencies will override entries of the same name in
444
- * dependencies") — there is only ONE edge and the optional block names its
445
- * kind. Treating it as required is the root-edge twin of the transitive defect
446
- * `requiredDepEntries` fixes: `optionalDependencies: { fsevents }` alongside a
447
- * `dependencies: { fsevents }` line would fail every Linux install with
376
+ * REQUIRED WINS ACROSS MANIFESTS, OPTIONAL WINS WITHIN ONE — two rules with two
377
+ * reasons, neither a special case of the other:
378
+ * - Across manifests: one package can be a plain dependency of one member and
379
+ * an optionalDependency of another. Those are two real edges, and an edge
380
+ * that may not be missing must not be silently missed. Subtracting rather
381
+ * than "last block seen" also makes the answer order-independent.
382
+ * - Within one manifest: a name in BOTH blocks is OPTIONAL because npm says so
383
+ * ("entries in optionalDependencies will override entries of the same name
384
+ * in dependencies") — one edge, and the optional block names its kind.
385
+ * Treating it as required is the root-edge twin of the defect
386
+ * `requiredDepEntries` fixes: `optionalDependencies: { fsevents }` beside a
387
+ * `dependencies: { fsevents }` line failed every Linux install with
448
388
  * EBADPLATFORM over a package npm never installs there.
449
389
  */
450
390
  export function optionalDependencyNames(manifests) {
@@ -456,9 +396,8 @@ export function optionalDependencyNames(manifests) {
456
396
  optional.add(name);
457
397
  for (const kind of ['dependencies', 'devDependencies']) {
458
398
  for (const name of Object.keys(manifest[kind] ?? {})) {
459
- // Overridden by THIS manifest's own optional block — not a
460
- // required edge, so it must not veto another manifest's optional
461
- // declaration either.
399
+ // Overridden by THIS manifest's own optional block — not a required
400
+ // edge, so it must not veto another manifest's optional declaration.
462
401
  if (optionalHere.has(name))
463
402
  continue;
464
403
  required.add(name);
@@ -481,23 +420,18 @@ function depKindFromArgs(args) {
481
420
  async function projectInstallNative(args, signal) {
482
421
  const cwd = process.cwd();
483
422
  const pkgPath = join(cwd, 'package.json');
484
- // gjsify install is a node_modules-linker installer (like `npm install`
485
- // or `yarn --nodeLinker node-modules`): it materialises a node_modules/
486
- // tree + gjsify-lock.json. It deliberately does NOT produce a Yarn PnP
487
- // install, and the two resolution strategies cannot coexist — a leftover
488
- // `.pnp.cjs` makes Node ignore the node_modules/ tree we write, leaving a
489
- // silently broken project.
423
+ // This is a node_modules-linker installer, and the two resolution strategies
424
+ // cannot coexist: a leftover `.pnp.cjs` makes Node ignore the node_modules/
425
+ // tree we write, leaving a silently broken project.
490
426
  //
491
- // Native PnP *generation* (a byte-identical `.pnp.cjs` + `.yarn/cache/*.zip`
492
- // that yarn itself would accept) is intentionally out of scope: it would
493
- // mean replicating yarn's libzip-deterministic archives, SHA-512 cache
494
- // keys, cache-version tracking and virtual-locator synthesis, and would
495
- // only ever match a single yarn release. So we stop here with actionable
496
- // guidance instead of half-doing it.
427
+ // Native PnP *generation* is out of scope on purpose — it would mean
428
+ // replicating yarn's libzip-deterministic archives, SHA-512 cache keys,
429
+ // cache-version tracking and virtual-locator synthesis, and would only ever
430
+ // match one yarn release. Better to stop with actionable guidance.
497
431
  //
498
- // We do NOT point users at `GJSIFY_INSTALL_BACKEND=npm` from this error:
499
- // that backend chokes on `workspace:` specs (`EUNSUPPORTEDPROTOCOL`),
500
- // which is exactly what the workspace repos that reach this branch have.
432
+ // The error deliberately does NOT point at `GJSIFY_INSTALL_BACKEND=npm`: that
433
+ // backend rejects `workspace:` specs (`EUNSUPPORTEDPROTOCOL`), which is exactly
434
+ // what the repos reaching this branch have.
501
435
  const pnpResidue = ['.pnp.cjs', '.pnp.loader.mjs'].filter((f) => existsSync(join(cwd, f)));
502
436
  if (pnpResidue.length > 0) {
503
437
  throw new Error(`gjsify install uses the node_modules linker and cannot run in a Yarn PnP ` +
@@ -509,14 +443,10 @@ async function projectInstallNative(args, signal) {
509
443
  `• If instead you want Yarn to manage dependencies, set \`nodeLinker: node-modules\` ` +
510
444
  `in .yarnrc.yml and run \`yarn install\` (not \`gjsify install\`).`);
511
445
  }
512
- // Workspace install (no args). Runs from the root, OR from any workspace
513
- // child — npm/yarn/pnpm resolve the monorepo root when you `install`
514
- // inside a member, so `findWorkspaceRoot` walks up from cwd (sanity-checked
515
- // that cwd is actually one of that root's workspaces) and installs the whole
516
- // workspace there. Without this, a child install treats the child as the
517
- // root and tries to resolve its `workspace:`/sibling deps from the registry.
518
- // Project-local `gjsify install <pkg>` inside a child still adds the dep to
519
- // that child via the single-package path below (this branch is no-args only).
446
+ // Workspace install (no args), from the root OR from any member — npm/yarn/pnpm
447
+ // all resolve the monorepo root when you `install` inside a member. Without the
448
+ // walk up, a child install treats the child as the root and tries to resolve
449
+ // its `workspace:`/sibling deps from the registry.
520
450
  if (!args.packages || args.packages.length === 0) {
521
451
  const wsRoot = isWorkspaceRoot(cwd) ? cwd : findWorkspaceRoot(cwd);
522
452
  if (wsRoot) {
@@ -531,10 +461,9 @@ async function projectInstallNative(args, signal) {
531
461
  const pkg = readPackageJson(pkgPath);
532
462
  const existingSpecs = pkg ? projectSpecsFromPackageJson(pkg) : [];
533
463
  if (args.packages && args.packages.length > 0) {
534
- // Combine new specs with existing manifest deps so a single
535
- // `gjsify install <new>` doesn't churn the lockfile (would drop
536
- // every previously-pinned entry otherwise). New specs with the
537
- // same name as an existing dep override.
464
+ // Combine new specs with the existing manifest deps: resolving the new ones
465
+ // alone would drop every previously-pinned lockfile entry. A new spec
466
+ // overrides an existing dep of the same name.
538
467
  const newNames = new Set(args.packages.map((s) => parseSpec(s).name));
539
468
  const carryover = existingSpecs.filter((s) => !newNames.has(parseSpec(s).name));
540
469
  specs = [...carryover, ...args.packages];
@@ -550,28 +479,24 @@ async function projectInstallNative(args, signal) {
550
479
  }
551
480
  }
552
481
  mkdirSync(cwd, { recursive: true });
553
- // Progress bar is auto-enabled when stderr is a TTY (and `--verbose` /
554
- // `--quiet` / `--no-progress` aren't set). When piped to a log file the
555
- // reporter falls back to one line per phase begin/end.
556
482
  const progress = makeProgressReporter({
557
483
  enabled: !args.verbose && !args.quiet && args.progress !== false,
558
484
  });
559
- // Hold the per-prefix lock across the install AND the manifest/lockfile
560
- // sync that follows, so a concurrent `gjsify install <pkg>` in the same
561
- // project can't interleave its package.json / gjsify-lock.json writes
562
- // with ours. `installPackages` re-acquires the same lock re-entrantly.
485
+ // Held across the install AND the manifest/lockfile sync that follows, so a
486
+ // concurrent `gjsify install <pkg>` in the same project cannot interleave its
487
+ // package.json / gjsify-lock.json writes with ours. `installPackages`
488
+ // re-acquires the same lock re-entrantly.
563
489
  const lock = await acquireInstallLock(cwd, { signal });
564
490
  try {
565
- // Typed as NativeInstallOptions so the native-backend-only
566
- // `optionalSpecs` is CHECKED at this call site instead of being smuggled
567
- // past an object-literal excess-property check; `installPackages`
568
- // forwards the object to that backend unchanged.
491
+ // Typed as NativeInstallOptions so the native-backend-only `optionalSpecs`
492
+ // is CHECKED here instead of smuggled past an excess-property check;
493
+ // `installPackages` forwards the object to that backend unchanged.
569
494
  const nativeOpts = {
570
495
  prefix: cwd,
571
496
  specs,
572
497
  verbose: args.verbose,
573
- // --immutable consumes the lockfile verbatim and must NOT rewrite
574
- // it (the whole point is byte-stability under CI).
498
+ // --immutable consumes the lockfile verbatim and must NOT rewrite it:
499
+ // byte-stability under CI is the point.
575
500
  lockfile: !args.immutable,
576
501
  frozen: args.immutable,
577
502
  refreshLockfile: args['refresh-lockfile'],
@@ -586,9 +511,8 @@ async function projectInstallNative(args, signal) {
586
511
  ]),
587
512
  };
588
513
  const result = await installPackages(nativeOpts);
589
- // Update package.json only when the user passed explicit packages
590
- // (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
591
- // flow doesn't mutate manifest entries.
514
+ // Only the `gjsify install <pkg>...` add-a-dep flow mutates the manifest;
515
+ // the no-args refresh must not.
592
516
  if (args.packages && args.packages.length > 0 && pkg) {
593
517
  const kind = depKindFromArgs(args);
594
518
  for (const spec of args.packages) {
@@ -598,12 +522,10 @@ async function projectInstallNative(args, signal) {
598
522
  addDependencyEntry(pkg, name, finalRange, kind);
599
523
  }
600
524
  writePackageJson(pkgPath, pkg);
601
- // Re-sync the lockfile's `requested` field with what
602
- // `projectSpecsFromPackageJson()` will return on the next
603
- // invocation. Without this, a `gjsify install foo` (bare name,
604
- // lockfile records `"foo"`) followed by `gjsify install
605
- // --immutable` (reads package.json → spec `"foo@^1.2.3"`) would
606
- // surface a spurious drift error.
525
+ // Re-sync `requested` with what `projectSpecsFromPackageJson()` returns
526
+ // next time. Without this, `gjsify install foo` (bare name, lockfile
527
+ // records `"foo"`) followed by `gjsify install --immutable` (reads
528
+ // package.json → `"foo@^1.2.3"`) reports a spurious drift error.
607
529
  if (!args.immutable) {
608
530
  syncLockfileRequested(cwd, projectSpecsFromPackageJson(pkg));
609
531
  }
@@ -625,40 +547,31 @@ function syncLockfileRequested(cwd, specs) {
625
547
  return; // Already in sync; preserve byte-stability.
626
548
  }
627
549
  lock.requested = specs;
628
- // Atomic tmp+rename — same rationale as the backend's lockfile
629
- // writer: a torn gjsify-lock.json breaks the next `--immutable`.
550
+ // Atomic tmp+rename: a torn gjsify-lock.json breaks the next `--immutable`.
630
551
  atomicWriteStrict(lockPath, JSON.stringify(lock, null, 2) + '\n');
631
552
  }
632
553
  catch {
633
- // Best-effort sync; if the lockfile is malformed, the next
634
- // non-immutable install will rewrite it from scratch.
554
+ // Best-effort; a malformed lockfile is rewritten from scratch by the next
555
+ // non-immutable install.
635
556
  }
636
557
  }
637
558
  /**
638
- * Workspace-aware install. Mirrors what `yarn install` does at a monorepo root:
639
- * 1. Discover every workspace under the root.
640
- * 2. Aggregate the union of their external (non-`workspace:`) deps.
641
- * 3. Run the native install backend ONCE at the root prefix so all
642
- * externals land in a single `node_modules/` (poor-man's hoisting —
643
- * workspaces with conflicting ranges of the same dep share one hoisted
644
- * copy; the resolver emits a loud `[gjsify] warning: version conflict`
645
- * naming both sides and the version that won).
646
- * 4. For every `workspace:` reference, symlink the target workspace's
647
- * directory into the requesting workspace's `node_modules/<dep>`
648
- * so `import '@gjsify/utils'` resolves to the local source.
559
+ * Workspace-aware install, mirroring `yarn install` at a monorepo root: discover
560
+ * the members, aggregate their external (non-`workspace:`) deps, install them
561
+ * ONCE at the root prefix, and symlink every workspace-named dep to the local
562
+ * source so `import '@gjsify/utils'` resolves there.
649
563
  *
650
- * Hoisting strategy is intentionally minimal — per-workspace dedup +
651
- * nested `node_modules/` for cross-workspace version conflicts are the
652
- * Phase D.8 follow-up tracked in status/open-todos.md.
564
+ * Hoisting is deliberately minimal — members with conflicting ranges of the same
565
+ * dep share one hoisted copy and the resolver warns. Per-member dedup and nested
566
+ * `node_modules/` are the open Phase D.8 item in status/open-todos.md.
653
567
  */
654
568
  async function workspaceInstall(cwd, args, signal) {
655
- // Hold the root-prefix lock for the WHOLE workspace flow: bin shims,
656
- // workspace symlinks and the external install all mutate the root
657
- // node_modules/, and a second concurrent workspace install interleaving
658
- // its rm+symlink/extract steps with ours corrupts both. The inner
659
- // `installPackages` calls re-acquire the same lock re-entrantly (root
660
- // prefix) or take the child-workspace lock (scoped overrides) — always
661
- // in root→child order, so there is no lock-ordering cycle.
569
+ // Held for the WHOLE flow: bin shims, workspace symlinks and the external
570
+ // install all mutate the root node_modules/, and a second concurrent workspace
571
+ // install interleaving its rm+symlink/extract steps corrupts both. The inner
572
+ // `installPackages` calls re-acquire this lock re-entrantly, or take the
573
+ // child-workspace lock for scoped overrides — always root→child, so there is
574
+ // no lock-ordering cycle.
662
575
  const lock = await acquireInstallLock(cwd, { signal });
663
576
  try {
664
577
  await workspaceInstallLocked(cwd, args, signal);
@@ -675,9 +588,8 @@ async function workspaceInstallLocked(cwd, args, signal) {
675
588
  }
676
589
  const byName = new Map(workspaces.map((w) => [w.name, w]));
677
590
  const externalSpecs = new Set();
678
- // Which workspace(s) declared each external spec — `"<name>@<range>"` →
679
- // workspace names. Feeds the resolver's version-conflict warning so it
680
- // can attribute both sides of a cross-workspace range conflict.
591
+ // `"<name>@<range>"` → the members that declared it. Feeds the resolver's
592
+ // version-conflict warning so it can attribute both sides of a conflict.
681
593
  const specOrigins = new Map();
682
594
  const symlinks = [];
683
595
  for (const ws of workspaces) {
@@ -689,23 +601,16 @@ async function workspaceInstallLocked(cwd, args, signal) {
689
601
  for (const [depName, spec] of Object.entries(block)) {
690
602
  if (typeof spec !== 'string')
691
603
  continue;
692
- // A dependency whose NAME matches a local workspace is always
693
- // satisfied by that workspace — never by a same-named package on
694
- // npm. This holds regardless of the spec form: an explicit
695
- // `workspace:` protocol, a plain semver range (`^1.2.3`), or even
696
- // a dist-tag. Yarn/npm resolve a workspace-named dep to the local
697
- // package first; we MUST do the same. Routing such a dep into the
698
- // native fetch/extract queue is the data-loss bug this guards
699
- // against — `extractOne` would `rmSync` + drop the published
700
- // tarball over the workspace's OWN source tree (the published
701
- // tarball ships only `files`, so `src/**` gets wiped). Symlink
702
- // it instead, exactly like a `workspace:` ref.
604
+ // A dep whose NAME matches a local workspace is always satisfied by
605
+ // that workspace, never by a same-named package on npm — whatever
606
+ // the spec form (`workspace:`, `^1.2.3`, a dist-tag), because that
607
+ // is what yarn/npm do. Routing one into the fetch/extract queue is a
608
+ // DATA-LOSS bug: `extractOne` would `rmSync` the workspace's own
609
+ // source tree and unpack the published tarball over it, and that
610
+ // tarball ships only `files`, so `src/**` is wiped.
703
611
  const localWorkspace = byName.get(depName);
704
612
  if (localWorkspace) {
705
- // Only an EXPLICIT out-of-workspace protocol (link:/file:/
706
- // portal:/git+/http(s):) opts out of the local workspace. Any
707
- // other shape — `workspace:` or a plain semver range/dist-tag
708
- // (`^1.2.3`, `*`, `latest`) — resolves to the local package.
613
+ // Only an EXPLICIT out-of-workspace protocol opts out.
709
614
  const explicitOverride = /^(link|file|portal|git\+|https?):/.test(spec);
710
615
  if (!explicitOverride) {
711
616
  symlinks.push({
@@ -715,11 +620,10 @@ async function workspaceInstallLocked(cwd, args, signal) {
715
620
  });
716
621
  continue;
717
622
  }
718
- // explicit override → fall through to the existing handling.
719
623
  }
720
624
  if (spec.startsWith('workspace:')) {
721
- // `workspace:` against a name that is NOT a discovered
722
- // workspace is a hard error (typo / missing package).
625
+ // `workspace:` against a name that is not a discovered workspace
626
+ // is a hard error (typo / missing package).
723
627
  throw new Error(`gjsify install: ${ws.name} declares "${depName}: ${spec}" but ` +
724
628
  `no workspace with that name exists`);
725
629
  }
@@ -737,31 +641,21 @@ async function workspaceInstallLocked(cwd, args, signal) {
737
641
  }
738
642
  }
739
643
  console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
740
- // Write workspace bin shims EARLY — before the download/extract phase.
741
- // The shims are cheap derived artifacts (workspace manifests only), and
742
- // the heavy phase that follows can fail or die mid-way (network errors,
743
- // timeouts, runtime crashes). Writing them up-front guarantees the
744
- // workspace never loses its runner shim (`node_modules/.bin/gjsify`) to
745
- // a failed install, and an in-place re-install on an already-materialized
746
- // tree refreshes a stale shim even when a later phase aborts. The same
747
- // shims are re-written at the end of this function so the GJS-preamble
748
- // (GI_TYPELIB_PATH for native prebuilds) reflects the freshly installed
749
- // tree — see the final `writeWorkspaceBinShims` call below.
644
+ // EARLY, before the download/extract phase: the shims derive from workspace
645
+ // manifests alone, while the heavy phase that follows can die mid-way (network,
646
+ // timeout, crash). Writing them up front means a failed install can never leave
647
+ // the workspace without its runner shim (`node_modules/.bin/gjsify`), and a
648
+ // stale shim is refreshed even when a later phase aborts. Rewritten at the end
649
+ // of this function so the GJS preamble reflects the installed tree.
750
650
  writeWorkspaceBinShims(cwd, workspaces);
751
- // Materialise the workspace↔workspace symlinks: per-requester links
752
- // (`<requester>/node_modules/<dep>`) plus a root hoist of every workspace
753
- // into `node_modules/<name>` so transitive workspace deps resolve via
754
- // Node's parent-walk from any descendant. Idempotent (rm + symlink, and
755
- // the hoist skips entries that already exist), so it's safe to run more
756
- // than once.
651
+ // Per-requester links (`<requester>/node_modules/<dep>`) plus a root hoist of
652
+ // every workspace into `node_modules/<name>`, so transitive workspace deps
653
+ // resolve by Node's parent-walk from any descendant. Idempotent, hence callable
654
+ // more than once.
757
655
  const wireWorkspaceSymlinks = async () => {
758
- // Per-requester symlinks — pre-dedup the parent-dir mkdirs (every
759
- // symlink for the same workspace shares a single `node_modules`
760
- // parent), then run the per-link rm + symlink steps with bounded
761
- // concurrency. Pure sync loops here used to dominate the tail of large
762
- // installs (~793 symlinks × ~10ms each for mkdir+rm+symlink = ~24s of
763
- // serial syscalls). With async + a 32-wide pool the same set lands in
764
- // 1-2s.
656
+ // Bounded-concurrency async, because the sync version dominated the tail of
657
+ // large installs: ~793 symlinks × ~10 ms of mkdir+rm+symlink is ~24 s of
658
+ // serial syscalls, versus 1-2 s with a 32-wide pool.
765
659
  if (symlinks.length > 0) {
766
660
  const fsp = await import('node:fs/promises');
767
661
  const parentDirs = new Set();
@@ -775,25 +669,20 @@ async function workspaceInstallLocked(cwd, args, signal) {
775
669
  const relTarget = workspaceLinkTarget(linkPath, link.targetLocation);
776
670
  plans.push({ linkPath, relTarget });
777
671
  }
778
- // Phase 1: one mkdir per unique parent (max ~213 instead of ~793).
672
+ // One mkdir per unique parent — ~213 instead of ~793.
779
673
  await Promise.all([...parentDirs].map((dir) => fsp.mkdir(dir, { recursive: true })));
780
- // Phase 2: per-link rm + symlink, pooled. A semaphore-style cursor
781
- // keeps the concurrent in-flight count bounded so we don't blow up
782
- // the file-descriptor table on huge monorepos.
674
+ // Bounded so a huge monorepo cannot blow up the fd table.
783
675
  const SYMLINK_CONCURRENCY = 32;
784
676
  let cursor = 0;
785
677
  const workers = [];
786
678
  const wireOne = async (linkPath, relTarget) => {
787
- // Remove any prior entry — regular dir, broken symlink, file,
788
- // or a normal symlink left over from a previous install.
789
- // `{ recursive: true, force: true }` handles every shape (rm
790
- // no-ops on missing paths under force; recursive covers dirs).
679
+ // Clear any prior entry of any shape — dir, file, broken or live
680
+ // symlink; `{ recursive, force }` covers all of them.
791
681
  try {
792
682
  await fsp.rm(linkPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
793
683
  }
794
684
  catch {
795
- /* unexpected; symlink call below will surface the real
796
- cause if it persists. */
685
+ /* the symlink call below surfaces the real cause if it persists */
797
686
  }
798
687
  await fsp.symlink(relTarget, linkPath, WORKSPACE_LINK_TYPE);
799
688
  };
@@ -813,37 +702,27 @@ async function workspaceInstallLocked(cwd, args, signal) {
813
702
  await Promise.all(workers);
814
703
  console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
815
704
  }
816
- // Hoist EVERY workspace package to the repo root's `node_modules/` so
817
- // transitive workspace deps are reachable from any descendant via
818
- // standard Node parent-walk resolution. yarn's `nodeLinker:
819
- // node-modules` does the same thing — the entire workspace graph is
820
- // materialised at the root, which is how rolldown's resolver finds
821
- // e.g. `@gjsify/abort-controller/register` injected from a
822
- // deeply-nested package's `node_modules/.cache/gjsify/` cache file
823
- // when the consumer didn't declare a direct dep on it (auto-globals
824
- // injection at build time).
825
- //
826
- // Without this hoist, each workspace's `node_modules/` only contains
827
- // its direct declared deps, and any auto-injected register import for
828
- // a workspace package the consumer didn't list as a dep externalises
829
- // and the bundle fails at runtime with `Module not found`.
705
+ // Hoist EVERY workspace package to the root `node_modules/`, like yarn's
706
+ // `nodeLinker: node-modules`, so the whole graph is reachable from any
707
+ // descendant by parent-walk. Without it a member's `node_modules/` holds
708
+ // only its declared deps, and an auto-injected register import for an
709
+ // undeclared workspace package (auto-globals injection at build time, e.g.
710
+ // `@gjsify/abort-controller/register`) externalises and the bundle fails at
711
+ // runtime with `Module not found`.
830
712
  const rootBinDir = join(cwd, 'node_modules');
831
713
  let rootHoisted = 0;
832
714
  for (const ws of workspaces) {
833
- // Skip the root workspace itself (its location IS cwd; it can't
834
- // symlink itself into its own node_modules).
715
+ // The root workspace cannot symlink itself into its own node_modules.
835
716
  if (ws.location === cwd)
836
717
  continue;
837
718
  if (!ws.name)
838
719
  continue;
839
720
  const linkPath = join(rootBinDir, ws.name);
840
- // If a symlink already exists here (from the per-requester loop
841
- // above when the root workspace declared this dep directly), it
842
- // already points at the right place — skip. We don't try to
843
- // remove + recreate because under GJS's Gio-backed fs polyfill,
844
- // `rmSync` on a symlink can race with `symlinkSync` and surface
845
- // EEXIST. A real directory at this path is also left alone —
846
- // someone else (npm, yarn) seeded it and we shouldn't clobber.
721
+ // An existing symlink here (from the per-requester loop above) already
722
+ // points at the right place. Deliberately not remove+recreate: under
723
+ // GJS's Gio-backed fs polyfill `rmSync` on a symlink can race
724
+ // `symlinkSync` and surface EEXIST. A real directory is left alone too —
725
+ // npm or yarn seeded it and clobbering it is not ours to do.
847
726
  let existsHere = false;
848
727
  try {
849
728
  lstatSync(linkPath);
@@ -862,35 +741,23 @@ async function workspaceInstallLocked(cwd, args, signal) {
862
741
  console.log(`gjsify install: hoisted ${rootHoisted} workspace(s) to root node_modules/`);
863
742
  }
864
743
  };
865
- // Wire workspace↔workspace symlinks EARLY too — same rationale as the bin
866
- // shims. These symlinks point at local workspace source trees and never
867
- // depend on the external download, yet they USED to run only at the tail
868
- // of the install. An interrupted/timed-out external fetch then left the
869
- // tree with NO workspace symlinks at all, so cross-package imports
870
- // (`@gjsify/native-fs-bridge` → `@gjsify/native-platform`) failed to
871
- // resolve even though every package was on disk. Materialising them here
872
- // makes workspace resolution survive a failed external phase. Safe to run
873
- // before `installPackages`: that phase only fetches NON-workspace names
874
- // (workspace-named deps are routed to symlinks and excluded from
875
- // `externalSpecs`), and `extractOne` only `rm`s the individual external
876
- // package dest it is about to write — never a workspace symlink.
744
+ // EARLY too, for the same reason as the bin shims: these links point at local
745
+ // source and never depend on the external download, but when they ran only at
746
+ // the tail, an interrupted external fetch left the tree with NO workspace
747
+ // symlinks, so cross-package imports failed to resolve even with every package
748
+ // on disk. Safe here because `installPackages` only fetches NON-workspace names
749
+ // (workspace-named deps are excluded from `externalSpecs`), and `extractOne`
750
+ // only `rm`s the one external dest it is about to write.
877
751
  await wireWorkspaceSymlinks();
878
- // Read top-level package.json's `overrides` (npm-native) or `resolutions`
879
- // (yarn-native, kept as the existing field name in pre-Phase-D.8 repos).
880
- // Flat `name → range` entries become global overrides applied to every
881
- // workspace; nested `<workspace> → {dep → range}` entries become
882
- // workspace-local installs that place the overridden dep inside that
883
- // workspace's own `node_modules/`. Lets a monorepo pin one workspace to
884
- // an older `typescript` (e.g. a downstream integration test) without
885
- // forcing the rest of the tree to the same version.
752
+ // `overrides` (npm) / `resolutions` (yarn). Flat `name → range` entries apply to
753
+ // every member; nested `<workspace> → {dep → range}` entries become
754
+ // workspace-local installs, so a monorepo can pin one member to an older
755
+ // `typescript` without dragging the rest of the tree along.
886
756
  const rootManifest = workspaces.find((w) => w.location === cwd)?.manifest;
887
757
  const extracted = extractOverrides(rootManifest);
888
758
  const overrides = extracted?.global;
889
- // Second pass: pluck specs that have a workspace-scoped override out of
890
- // `externalSpecs` and re-collect them into a per-workspace map. Those
891
- // specs will be installed into the workspace's own `node_modules/` after
892
- // the root install completes, so the resolver in the root pass does NOT
893
- // see the conflicting versions.
759
+ // Move specs carrying a workspace-scoped override out of `externalSpecs` into a
760
+ // per-workspace map, so the root resolver never sees the conflicting versions.
894
761
  const wsLocalSpecs = new Map(); // wsLocation → name@range set
895
762
  const droppedFromExternal = new Set();
896
763
  if (extracted && extracted.scoped.size > 0) {
@@ -904,7 +771,6 @@ async function workspaceInstallLocked(cwd, args, signal) {
904
771
  const override = scopedOverrideFor(ws, depName, extracted);
905
772
  if (!override)
906
773
  continue;
907
- // Re-route this dep to the workspace's own install
908
774
  const targetRange = override;
909
775
  const wsKey = ws.location;
910
776
  let bucket = wsLocalSpecs.get(wsKey);
@@ -913,17 +779,14 @@ async function workspaceInstallLocked(cwd, args, signal) {
913
779
  wsLocalSpecs.set(wsKey, bucket);
914
780
  }
915
781
  bucket.add(`${depName}@${targetRange}`);
916
- // Drop the un-overridden version from the root spec set:
917
- // the workspace will see its scoped version via parent-walk
918
- // resolution. Note we only drop the EXACT `name@spec` the
919
- // workspace declared; other workspaces' instances of the
920
- // same name+spec stay in the root set.
782
+ // Only the EXACT `name@spec` this member declared is dropped from
783
+ // the root set; other members' instances of the same pair stay.
921
784
  droppedFromExternal.add(`${depName}@${spec}`);
922
785
  }
923
786
  }
924
787
  }
925
- // Apply the drops only when no OTHER workspace declared the same spec
926
- // — otherwise it has legitimate root requesters and must stay.
788
+ // Only drop a spec no OTHER member declared — otherwise it still has
789
+ // legitimate root requesters.
927
790
  const stillNeeded = new Set();
928
791
  for (const ws of workspaces) {
929
792
  for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
@@ -959,15 +822,14 @@ async function workspaceInstallLocked(cwd, args, signal) {
959
822
  overrides,
960
823
  signal,
961
824
  progress,
962
- // Workspace-named packages are symlinked above — the native backend
963
- // must never fetch a same-named published version over those
964
- // symlinks (e.g. a `types-dev/<lib>` workspace that also exists on
965
- // npm and is pinned transitively in the lockfile).
825
+ // Workspace-named packages are symlinked above; the backend must never
826
+ // fetch a same-named published version over those symlinks (e.g. a
827
+ // `types-dev/<lib>` workspace that also exists on npm and is pinned
828
+ // transitively in the lockfile).
966
829
  workspaceNames: new Set(byName.keys()),
967
830
  specOrigins: new Map([...specOrigins].map(([k, v]) => [k, [...v]])),
968
- // Aggregated over EVERY member: the specs are aggregated the same
969
- // way, so the optional-vs-required answer has to be too (a name that
970
- // any member declares as a plain dependency stays required).
831
+ // Aggregated over EVERY member, because the specs are: a name any member
832
+ // declares as a plain dependency stays required.
971
833
  optionalSpecs: optionalDependencyNames(workspaces.map((w) => w.manifest)),
972
834
  };
973
835
  await installPackages(rootOpts);
@@ -975,10 +837,8 @@ async function workspaceInstallLocked(cwd, args, signal) {
975
837
  else if (args.verbose) {
976
838
  console.log('gjsify install: no external deps to fetch');
977
839
  }
978
- // Workspace-local installs for scoped overrides. Each runs as its own
979
- // `installPackages` call inside the workspace location — the resulting
980
- // `node_modules/<dep>` shadows the root-hoisted version via standard
981
- // Node parent-walk resolution.
840
+ // Scoped overrides, one `installPackages` per member: the resulting
841
+ // `node_modules/<dep>` shadows the root-hoisted version by parent-walk.
982
842
  for (const [wsLocation, specSet] of wsLocalSpecs) {
983
843
  if (specSet.size === 0)
984
844
  continue;
@@ -990,34 +850,26 @@ async function workspaceInstallLocked(cwd, args, signal) {
990
850
  prefix: wsLocation,
991
851
  specs: [...specSet],
992
852
  verbose: args.verbose,
993
- // Per-workspace installs get a thin lockfile next to the workspace
994
- // package.json. Same `--immutable` semantics as the root install.
853
+ // A thin lockfile next to the member's package.json, same `--immutable`
854
+ // semantics as the root install.
995
855
  lockfile: !args.immutable,
996
856
  frozen: args.immutable,
997
857
  signal,
998
858
  workspaceNames: new Set(byName.keys()),
999
- // Same aggregated answer as the root install: these specs are a
1000
- // SUBSET of the root's, so classifying them differently here would
1001
- // make one dep optional at the root and required in a member.
859
+ // Same aggregated answer as the root install: these specs are a SUBSET of
860
+ // the root's, so classifying them differently would make one dep optional
861
+ // at the root and required in a member.
1002
862
  optionalSpecs: optionalDependencyNames(workspaces.map((w) => w.manifest)),
1003
863
  };
1004
864
  await installPackages(scopedOpts);
1005
865
  }
1006
- // Re-wire workspace symlinks once more now that the external + scoped
1007
- // installs are done. The EARLY pass above already created them so workspace
1008
- // resolution survives a failed external phase; this final idempotent pass
1009
- // is a cheap safety net in case any later step disturbed one (the per-link
1010
- // rm+symlink re-establishes them, the root hoist skips entries that already
1011
- // exist — so it's a near no-op when nothing changed).
866
+ // A near no-op safety net in case a later step disturbed a link.
1012
867
  await wireWorkspaceSymlinks();
1013
- // Re-write workspace bins into `node_modules/.bin/` now that the tree is
1014
- // fully materialized. The early pass at the top of this function already
1015
- // guaranteed the shims exist; this final pass refreshes their GJS-preamble
1016
- // with the native prebuild dirs that only became discoverable after the
1017
- // install (fresh checkout: `detectNativePackages` finds nothing before the
1018
- // packages are extracted). Running last also keeps workspace shims
1019
- // authoritative over any same-named external bin linked by the install
1020
- // backend (yarn semantics: workspace bins win).
868
+ // Now that the tree is materialised, refresh the shims' GJS preamble with the
869
+ // native prebuild dirs that only became discoverable after the install (on a
870
+ // fresh checkout `detectNativePackages` finds nothing before extraction).
871
+ // Running last also keeps workspace shims authoritative over any same-named
872
+ // external bin the backend linked — yarn semantics: workspace bins win.
1021
873
  const wsBinsCreated = writeWorkspaceBinShims(cwd, workspaces);
1022
874
  if (wsBinsCreated > 0) {
1023
875
  console.log(`gjsify install: linked ${wsBinsCreated} workspace bin(s) into node_modules/.bin/`);
@@ -1025,47 +877,32 @@ async function workspaceInstallLocked(cwd, args, signal) {
1025
877
  }
1026
878
  /**
1027
879
  * (Re)write the shell shims for every workspace-declared bin into the root
1028
- * `node_modules/.bin/`. Without this, `npm run <script>` (or any
1029
- * `node_modules/.bin`-PATH consumer) cannot find the `gjsify` binary on a
1030
- * fresh checkout — yarn creates these shims at install time; we match.
880
+ * `node_modules/.bin/`. Without them `npm run <script>` — or any
881
+ * `node_modules/.bin`-on-PATH consumer — cannot find `gjsify` on a fresh
882
+ * checkout; yarn writes these at install time and we match.
1031
883
  *
1032
- * Each workspace's `bin` entry maps `<binName>` → `<relative-target>`.
1033
- * For GJS-runnable bins, `gjsify.bin` is preferred — its target is the
1034
- * committed `dist/cli.gjs.mjs` bundle that exists on a fresh checkout,
1035
- * versus the `bin` field which typically points at `lib/index.js`
1036
- * (a build artifact that may not yet exist). The shim wraps the
1037
- * target in a shell script that picks the right interpreter (`gjs -m`
1038
- * for `.mjs` bundles, `node` for `.js` files).
884
+ * `gjsify.bin` is preferred for the GJS target because it names the committed
885
+ * `dist/cli.gjs.mjs` bundle, which exists on a fresh checkout, while `bin`
886
+ * typically names `lib/index.js`, a build artifact that may not.
1039
887
  *
1040
- * IDEMPOTENT + UNCONDITIONAL: shims are derived artifacts and are always
1041
- * regenerated — never skipped because a file already exists, never a
1042
- * failure because one is missing. `workspaceInstall` calls this twice:
1043
- * once BEFORE the download/extract phase (so a failed/aborted install can
1044
- * never leave the workspace without its runner shim, and a stale shim is
1045
- * refreshed even when a later phase dies) and once after (to pick up
1046
- * native prebuild dirs that only exist post-extract).
888
+ * IDEMPOTENT + UNCONDITIONAL: shims are derived artifacts, always regenerated,
889
+ * never skipped for existing and never fatal for missing. `workspaceInstall`
890
+ * calls this before AND after the download/extract phase.
1047
891
  */
1048
892
  function writeWorkspaceBinShims(cwd, workspaces) {
1049
893
  const wsBinDir = join(cwd, 'node_modules', '.bin');
1050
- // Discover native prebuilds reachable from the workspace cwd so the
1051
- // workspace-local `node_modules/.bin/gjsify` shim sets GI_TYPELIB_PATH /
1052
- // LD_LIBRARY_PATH for them. Same rationale as the global launcher in
1053
- // install-global.ts — the bin shim invokes the CLI bundle via `gjs -m`
1054
- // directly, with no chance to set env after the fact, so without this
1055
- // preamble `imports.gi.GjsifyTerminal` etc. fail and process.stdout
1056
- // collapses to no-color, 80-col defaults.
894
+ // The shim invokes the CLI bundle via `gjs -m` directly, with no chance to set
895
+ // env after the fact, so GI_TYPELIB_PATH / LD_LIBRARY_PATH have to be in its
896
+ // preamble — otherwise `imports.gi.GjsifyTerminal` and friends fail and
897
+ // process.stdout collapses to no-color, 80-col defaults.
1057
898
  const nativePrebuildDirs = detectNativePackages(cwd).map((p) => p.prebuildsDir);
1058
899
  let wsBinsCreated = 0;
1059
900
  for (const ws of workspaces) {
1060
901
  const m = ws.manifest;
1061
902
  const gjsifyBin = m.gjsify?.bin;
1062
903
  const nodeBin = m.bin;
1063
- // For each bin name, collect both the Node-target and GJS-target
1064
- // when they exist. The shim prefers Node at invocation time
1065
- // because Node's child_process is more reliable than GJS's
1066
- // Gio.Subprocess polyfill (parallel-spawn close-event delivery
1067
- // races under heavy concurrency); GJS is the fallback for fresh
1068
- // checkouts where the Node target hasn't been built yet.
904
+ // Collect both the Node and GJS target per bin name when they exist; which
905
+ // one the shim tries first is `buildBinShim`'s per-platform decision.
1069
906
  const merged = mergeWorkspaceBins(ws.name, gjsifyBin, nodeBin);
1070
907
  if (merged.size === 0)
1071
908
  continue;
@@ -1082,10 +919,10 @@ function writeWorkspaceBinShims(cwd, workspaces) {
1082
919
  }
1083
920
  writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs, process.platform, cwd), { mode: 0o755 });
1084
921
  chmodSync(linkPath, 0o755);
1085
- // Windows executes neither an extension-less file nor a `#!` line —
1086
- // the sh shim above is only reachable from git-bash/MSYS/WSL. Write
1087
- // the `.cmd`/`.ps1` companions cmd.exe and pwsh need, mirroring what
1088
- // npm's cmd-shim lays down next to every linked bin.
922
+ // Windows executes neither an extension-less file nor a `#!` line, so the
923
+ // sh shim above is reachable only from git-bash/MSYS/WSL. The
924
+ // `.cmd`/`.ps1` companions are what cmd.exe and pwsh need, mirroring
925
+ // npm's cmd-shim.
1089
926
  if (process.platform === 'win32') {
1090
927
  for (const [suffix, contents] of Object.entries(buildWorkspaceBinShimsForWindows(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs))) {
1091
928
  writeFileSync(`${linkPath}.${suffix}`, contents, { mode: 0o755 });
@@ -1096,6 +933,19 @@ function writeWorkspaceBinShims(cwd, workspaces) {
1096
933
  }
1097
934
  return wsBinsCreated;
1098
935
  }
936
+ /**
937
+ * Flatten npm `overrides` / yarn `resolutions` into a bare name → range map,
938
+ * accepting both `{ "typescript": "~5.9.2" }` and yarn's pattern form
939
+ * `{ "typescript@*": "~5.9.2" }`.
940
+ *
941
+ * Pattern keys with a version glob are normalised to the bare name because the
942
+ * resolver has no per-incoming-range scoping. npm's per-parent nested shape
943
+ * (`"foo": { ".": "1.0", "bar": "2.0" }`) is warned about rather than
944
+ * half-applied, since without per-parent support it would misbehave silently.
945
+ *
946
+ * Keys beginning with `_` are skipped — the in-the-wild convention for
947
+ * documentation entries like `"_comment_typescript"`.
948
+ */
1099
949
  function extractOverrides(rootManifest) {
1100
950
  if (!rootManifest)
1101
951
  return undefined;
@@ -1108,9 +958,8 @@ function extractOverrides(rootManifest) {
1108
958
  if (key.startsWith('_'))
1109
959
  continue;
1110
960
  if (typeof value === 'string') {
1111
- // Flat `name → range` entry. Normalise pattern keys (`name@*`,
1112
- // `name@^range`) → bare name. For scoped packages preserve the
1113
- // leading `@`.
961
+ // Normalise pattern keys (`name@*`, `name@^range`) to the bare name,
962
+ // preserving the leading `@` of a scoped package.
1114
963
  let name = key;
1115
964
  const atIdx = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@');
1116
965
  if (atIdx > 0)
@@ -1119,9 +968,8 @@ function extractOverrides(rootManifest) {
1119
968
  continue;
1120
969
  }
1121
970
  if (value && typeof value === 'object' && !Array.isArray(value)) {
1122
- // Scoped entry — `<workspace> → {dep → range}`. This is the
1123
- // npm-overrides nested shape and yarn's resolutions
1124
- // selectors collapsed to per-workspace level.
971
+ // `<workspace> → {dep → range}`: npm's nested overrides shape and
972
+ // yarn's resolutions selectors, collapsed to per-workspace level.
1125
973
  const sub = {};
1126
974
  for (const [depKey, depValue] of Object.entries(value)) {
1127
975
  if (depKey.startsWith('_'))
@@ -1151,10 +999,7 @@ function extractOverrides(rootManifest) {
1151
999
  return undefined;
1152
1000
  return { global, scoped };
1153
1001
  }
1154
- /**
1155
- * Look up the scoped override for a given workspace + dep. Matches by
1156
- * workspace name OR relative location (both accepted for readability).
1157
- */
1002
+ /** Scoped override for a workspace + dep, matched by name OR relative location. */
1158
1003
  function scopedOverrideFor(ws, depName, extracted) {
1159
1004
  if (!extracted || extracted.scoped.size === 0)
1160
1005
  return undefined;
@@ -1166,66 +1011,45 @@ function scopedOverrideFor(ws, depName, extracted) {
1166
1011
  }
1167
1012
  return undefined;
1168
1013
  }
1014
+ /**
1015
+ * Build the `sh` shim for one bin. Which interpreter it tries first is decided
1016
+ * per platform below; the probe is per-INVOCATION, not per-install, so the same
1017
+ * shim works before and after the workspace's `lib/` has been built.
1018
+ *
1019
+ * Both targets are absolute so the shim survives the different cwds consumers
1020
+ * (`yarn run`, `npm run`, a direct PATH invocation) call it from.
1021
+ */
1169
1022
  export function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = [], platform = process.platform, scanRoot = wsLocation) {
1170
1023
  const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
1171
1024
  const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
1172
- // GJS-only env preamble — Node ignores GI_TYPELIB_PATH so we scope the
1173
- // export to the gjs branch, keeping the shim minimal when no native pkgs
1174
- // exist or only the Node bin is in play.
1025
+ // Scoped to the gjs branch because Node ignores GI_TYPELIB_PATH.
1175
1026
  //
1176
- // `scanRoot` is the INSTALL prefix (the tree holding `node_modules/.bin`),
1177
- // not the workspace package dir: the preamble globs its `node_modules` at
1178
- // launch time instead of embedding what was installed the day the shim was
1179
- // written, so `gjsify install <some>-native` afterwards is picked up without
1180
- // a re-link. See `buildNativeEnvPreamble`.
1027
+ // `scanRoot` is the INSTALL prefix (the tree holding `node_modules/.bin`), not
1028
+ // the workspace package dir: `buildNativeEnvPreamble` globs its `node_modules`
1029
+ // at LAUNCH time rather than embedding what was installed the day the shim was
1030
+ // written, so a later `gjsify install <x>-native` is picked up without a re-link.
1181
1031
  const gjsPreamble = buildNativeEnvPreamble(scanRoot, nativePrebuildDirs, { platform });
1182
1032
  if (nodeAbs && gjsAbs) {
1183
- // GJS-FIRST: prefer the committed Node-free GJS bundle when `gjs` is on
1184
- // PATH AND the bundle exists; fall back to the Node entry otherwise (a
1185
- // Node-only machine, or before the bundle is built). gjsify is
1186
- // GJS-first but stays fully runnable under Node.
1033
+ // GJS-FIRST by default: gjsify is a GJS-first toolchain that stays fully
1034
+ // runnable under Node. This is only safe because `gjsify run` dispatches a
1035
+ // single `gjsify <cmd>` IN-PROCESS under GJS (see cli-app.ts) instead of
1036
+ // spawning another gjs per script — the earlier gjs-first attempt chained ~5
1037
+ // heavyweight gjs per package and turned a ~83s build into a multi-hour hang
1038
+ // on CI's few cores.
1187
1039
  //
1188
- // Safe to default to GJS now that the blockers earlier flip attempts
1189
- // surfaced are all fixed: css-as-string file://-resolve, `foreach`
1190
- // fail-fast, the @gjsify/tsc lib-refresh race, native-rolldown
1191
- // node-target externals — and the make-or-break one, the nested-gjs
1192
- // build thrash: `gjsify run` now dispatches a single `gjsify <cmd>`
1193
- // script IN-PROCESS under GJS (see cli-app.ts) instead of spawning yet
1194
- // another gjs, so the build no longer chains ~5 heavyweight gjs per
1195
- // package and oversubscribes CI's few cores. Verified: the faithful
1196
- // `foreach build -tp --jobs 4` over @gjsify/* with a piped stdout
1197
- // completes in ~83s under GJS (was a multi-hour hang).
1198
- // …EXCEPT on macOS and Windows, where the order is inverted — and not
1199
- // as a preference but as a capability. GJS is a GNOME-desktop host: on
1200
- // those two platforms it is at best a Homebrew/MSYS afterthought, while
1201
- // Node is what a developer already has. The tooling agrees:
1040
+ // macOS and Windows invert the order. Windows is a capability limit: there is
1041
+ // no prebuilt `libgjs` at all (website/src/content/docs/platform-support.md),
1042
+ // so the gjs probe can never succeed, and leading with Node also makes the
1043
+ // `sh` shim agree with the Node-only `.cmd`/`.ps1` companions
1044
+ // {@link buildLauncherShims} writes. macOS is NOT: `@gjsify/rolldown-native`
1045
+ // now ships darwin-arm64 + darwin-x64 prebuilds, so the original reason (no
1046
+ // bundler engine under GJS there, npm `rolldown` being a Rust napi crate GJS
1047
+ // cannot load) has expired, and only the "Homebrew gjs is an afterthought"
1048
+ // preference is left holding `darwin` in this branch.
1202
1049
  //
1203
- // - macOS: the only bundler engine under GJS is
1204
- // `@gjsify/rolldown-native` (npm `rolldown` is a Rust napi crate
1205
- // GJS cannot load), and it declares `linux-x64`/`linux-arm64` only.
1206
- // So the gjs branch cannot run `gjsify build`, nor any script that
1207
- // ends in one — it dies with "no usable bundler engine under GJS",
1208
- // whose own closing line is "Running the build under Node also
1209
- // works". Preferring gjs routed the user into a dead end the error
1210
- // message tells them to leave while the shim offered no way out.
1211
- // Measured on a cold macOS tree: root `build:infra` fails at the
1212
- // first `gjsify build --library` (`@gjsify/utils`) with the
1213
- // gjs-first shim and completes with this one.
1214
- // - Windows: there is no prebuilt `libgjs` at all (see
1215
- // `website/src/content/docs/platform-support.md`), so the gjs probe
1216
- // can never succeed. This also makes the `sh` shim — the git-bash /
1217
- // MSYS entry point — agree with the `.cmd`/`.ps1` companions
1218
- // {@link buildLauncherShims} already writes as Node-only, instead
1219
- // of leading with a branch that is dead by construction.
1220
- //
1221
- // The probe stays symmetric with the gjs one — `command -v node`, not a
1222
- // bare file test — so a host carrying the bundle but no Node still
1223
- // falls through to gjs and keeps every non-build command working. That
1224
- // is what keeps GJS on these platforms supported-and-tested rather than
1225
- // merely absent. Revert a platform to the shared gjs-first form once it
1226
- // has a working engine (macOS: status/open-todos.md,
1227
- // "@gjsify/rolldown-native's darwin-arm64 leg is PROVEN but not
1228
- // promoted").
1050
+ // The Node probe stays symmetric with the gjs one — `command -v node`, not a
1051
+ // bare file test — so a host carrying the bundle but no Node still falls
1052
+ // through to gjs and keeps every non-build command working.
1229
1053
  const nodeFirstPlatform = platform === 'darwin' || platform === 'win32';
1230
1054
  const gjsFirst = `if command -v gjs >/dev/null 2>&1 && [ -f "${gjsAbs}" ]; then\n` +
1231
1055
  `${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n` +
@@ -1246,10 +1070,9 @@ export function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDi
1246
1070
  /**
1247
1071
  * `.cmd` / `.ps1` companions for {@link buildBinShim}.
1248
1072
  *
1249
- * The `sh` shim's gjs-first / node-fallback probe has no Windows counterpart to
1250
- * express: there is no GJS host on Windows at all (no prebuilt `libgjs` — see
1251
- * `website/src/content/docs/platform-support.md`), so a workspace that ships
1252
- * both targets resolves to Node here, and a GJS-only workspace gets an honest
1073
+ * There is no GJS host on Windows at all (no prebuilt `libgjs` — see
1074
+ * `website/src/content/docs/platform-support.md`), so a workspace shipping both
1075
+ * targets resolves to Node here, and a GJS-only workspace gets an honest
1253
1076
  * `gjs`-invoking shim that fails exactly the way the `sh` one does.
1254
1077
  *
1255
1078
  * Exported + platform-free so it is unit-tested from Linux.
@@ -1260,10 +1083,9 @@ export function buildWorkspaceBinShimsForWindows(wsLocation, nodeTarget, gjsTarg
1260
1083
  if (nodeAbs)
1261
1084
  return buildLauncherShims({ interpreter: 'node', target: nodeAbs });
1262
1085
  if (gjsAbs) {
1263
- // Windows has no dedicated library-path variable — LoadLibrary searches
1264
- // PATH — so the prebuild dirs go there, `;`-separated (which is also
1265
- // GLib's G_SEARCHPATH_SEPARATOR on Windows, hence the same join for
1266
- // GI_TYPELIB_PATH).
1086
+ // Windows has no dedicated library-path variable — LoadLibrary searches PATH
1087
+ // — so the prebuild dirs go there, `;`-separated, which is also GLib's
1088
+ // G_SEARCHPATH_SEPARATOR on Windows, hence the same join for GI_TYPELIB_PATH.
1267
1089
  const joined = nativePrebuildDirs.join(';');
1268
1090
  return buildLauncherShims({
1269
1091
  interpreter: 'gjs',
@@ -1275,10 +1097,8 @@ export function buildWorkspaceBinShimsForWindows(wsLocation, nodeTarget, gjsTarg
1275
1097
  throw new Error('buildWorkspaceBinShimsForWindows: either nodeTarget or gjsTarget must be provided');
1276
1098
  }
1277
1099
  /**
1278
- * Walk a workspace's `bin` (Node) + `gjsify.bin` (GJS) declarations
1279
- * into a unified `<binName> → {nodeTarget?, gjsTarget?}` map. The
1280
- * shim built from this picks Node at runtime when its target exists,
1281
- * GJS otherwise.
1100
+ * Merge a workspace's `bin` (Node) and `gjsify.bin` (GJS) declarations into one
1101
+ * `<binName> → {nodeTarget?, gjsTarget?}` map for {@link buildBinShim}.
1282
1102
  */
1283
1103
  function mergeWorkspaceBins(pkgName, gjsifyBin, nodeBin) {
1284
1104
  const out = new Map();
@@ -1327,11 +1147,10 @@ async function projectInstallViaNpm(args) {
1327
1147
  await spawnNpm(npmArgs);
1328
1148
  }
1329
1149
  async function spawnNpm(npmArgs) {
1330
- // `completion: 'return'` — the `--backend npm` branch of the handler
1331
- // RETURNS after `runPostInstallChecks()` instead of exiting, so under GJS
1332
- // this must not leave the main loop `spawn()` arms parked (see
1333
- // utils/spawn.ts). The npm backend needs a Node host anyway, so the
1334
- // blocking path costs nothing in practice.
1150
+ // `completion: 'return'` — the `--backend npm` branch RETURNS after
1151
+ // `runPostInstallChecks()` instead of exiting, so under GJS it must not leave the
1152
+ // main loop arms `spawn()` parks (see utils/spawn.ts). The npm backend needs a
1153
+ // Node host anyway, so the blocking path costs nothing here.
1335
1154
  return spawnToCompletion('npm', npmArgs, {
1336
1155
  completion: 'return',
1337
1156
  notFound: () => new Error('npm not found on PATH — install Node.js first.'),
@@ -1341,9 +1160,8 @@ async function spawnNpm(npmArgs) {
1341
1160
  throw new Error(`npm install exited with code ${code}`);
1342
1161
  })
1343
1162
  .catch((err) => {
1344
- // A raw spawn failure still carries an errno `code`; the mapped
1345
- // not-found hint and the non-zero-exit error do not, and already
1346
- // read as finished sentences.
1163
+ // A raw spawn failure still carries an errno `code`; the mapped not-found
1164
+ // hint and the non-zero-exit error do not, and already read as sentences.
1347
1165
  const errno = err.code;
1348
1166
  console.error(errno === undefined ? err.message : `npm install failed: ${err.message}`);
1349
1167
  process.exit(1);
@@ -1354,11 +1172,10 @@ async function installGlobalAndLink(specs, opts) {
1354
1172
  mkdirSync(layout.prefix, { recursive: true });
1355
1173
  console.log(`gjsify install --global → ${layout.prefix}`);
1356
1174
  console.log(` bins → ${layout.binDir}`);
1357
- // The global prefix is SHARED user-wide state — two concurrent
1358
- // `gjsify install -g` runs (e.g. parallel agent sessions bootstrapping
1359
- // tooling) used to interleave rm+extract on the same package dirs and
1360
- // race the launcher writes in ~/.local/bin. Hold the prefix lock across
1361
- // the install, the engine-package top-up AND the bin linking (ADR 0001).
1175
+ // The global prefix is SHARED user-wide state: two concurrent `gjsify install -g`
1176
+ // runs interleaved rm+extract on the same package dirs and raced the launcher
1177
+ // writes in ~/.local/bin. The lock spans the install, the engine top-up AND the
1178
+ // bin linking (ADR 0001).
1362
1179
  const lock = await acquireInstallLock(layout.prefix, {});
1363
1180
  try {
1364
1181
  const result = await installPackages({
@@ -1367,17 +1184,16 @@ async function installGlobalAndLink(specs, opts) {
1367
1184
  verbose: opts.verbose,
1368
1185
  });
1369
1186
  const packageNames = specs.map(specToPackageName);
1370
- // A global GJS install of `@gjsify/cli` must also lay down the GJS-native
1371
- // bundler engine (`@gjsify/rolldown-native`) + the sibling format/CSS
1372
- // bridges. They are declared OPTIONAL peers of `@gjsify/cli` (so plain
1373
- // `npm install @gjsify/cli` on Node doesn't force a linux prebuild), and
1374
- // the native backend doesn't resolve peerDependencies at all — so without
1375
- // this they'd never be installed, and `gjsify build` would hard-fail under
1376
- // GJS ("no usable bundler engine"). Installed best-effort at the cli's
1377
- // resolved version so they move in lockstep with the bundle; a platform
1378
- // with no published prebuild degrades to a warning. Must run BEFORE
1379
- // linkGlobalBins so the launcher's detectNativePackages() bakes the
1380
- // engine's prebuild dirs into the wrapper's GI_TYPELIB_PATH/LD_LIBRARY_PATH.
1187
+ // A global install of `@gjsify/cli` must also lay down the GJS bundler engine
1188
+ // + the sibling format/CSS bridges. They are OPTIONAL PEERS of the CLI (so a
1189
+ // plain `npm install @gjsify/cli` on Node does not force a prebuild) and the
1190
+ // native backend does not resolve peerDependencies at all, so without this
1191
+ // they never arrive and `gjsify build` hard-fails under GJS with "no usable
1192
+ // bundler engine". Pinned to the CLI's resolved version so they move in
1193
+ // lockstep with the bundle; a platform with no published prebuild warns.
1194
+ //
1195
+ // Must run BEFORE linkGlobalBins, so the launcher's detectNativePackages()
1196
+ // bakes the engine's prebuild dirs into GI_TYPELIB_PATH/LD_LIBRARY_PATH.
1381
1197
  if (packageNames.includes('@gjsify/cli')) {
1382
1198
  const cliVersion = result.installed.find((r) => r.name === '@gjsify/cli')?.version ?? 'latest';
1383
1199
  await installGjsEnginePackages(layout.prefix, cliVersion, { verbose: opts.verbose });
@@ -1404,6 +1220,42 @@ function reportLinkedBins(created, binDir) {
1404
1220
  `Add it to your shell rc file:\n export PATH="${binDir}:$PATH"`);
1405
1221
  }
1406
1222
  }
1223
+ /**
1224
+ * Lay the GJS bundler engine down after a PROJECT install, when the host will need
1225
+ * it and does not have it (#1005).
1226
+ *
1227
+ * The engine is an OPTIONAL PEER of `@gjsify/cli` on purpose — a plain
1228
+ * `npm install @gjsify/cli` on Node must not fetch prebuilds — but nobody resolves
1229
+ * it for a project: npm 7+ skips optional peers and the native backend does not
1230
+ * resolve `peerDependencies` at all. Under GJS there is no npm `rolldown`
1231
+ * fallback, so `gjsify build` had nothing to load.
1232
+ *
1233
+ * Four conditions, and (b) is the one worth reading:
1234
+ *
1235
+ * (a) `@gjsify/cli` is in this tree — otherwise the engine has no caller.
1236
+ * (b) THE HOST CAN RUN GJS, which is NOT the `isGjs()` question. `isGjs()` asks
1237
+ * which runtime is executing the installer, but the launcher prefers the GJS
1238
+ * bundle whenever `command -v gjs` succeeds, so a tree installed BY NODE is
1239
+ * routinely built BY GJS. Gating on the interpreter reproduces #1005 for
1240
+ * every contributor who installs on Node and builds through the shim.
1241
+ * `runMinimalChecks()` already probed for gjs, so this costs no extra spawn.
1242
+ * (c) No engine is already reachable — asked with the same walk the BUILD uses.
1243
+ * (d) Not `--immutable`: a frozen install cannot acquire what its lockfile does
1244
+ * not name, so that case warns and installs nothing.
1245
+ *
1246
+ * NOT gated on `gjsify.app` or on "declares a build script": neither failing
1247
+ * consumer declares the former, and the latter means pattern-matching script
1248
+ * bodies, the drifting hand-maintained judgement ADR 0017 rejects by name.
1249
+ * ADR 0020 records the simpler `optionalDependencies` shape that would retire
1250
+ * this policy entirely.
1251
+ *
1252
+ * NOT recorded in the lockfile — `installPackages` writes one only when both
1253
+ * `resolved` and `opts.lockfile` hold — so a consumer's `gjsify-lock.json` is
1254
+ * never rewritten by this; an e2e row pins it. Ordering is free here, unlike the
1255
+ * global path: `buildNativeEnvPreamble` scans the prefix at LAUNCH time, so a shim
1256
+ * written before the engine arrives still exports its dir
1257
+ * (`tests/e2e/global-install-engine/`).
1258
+ */
1407
1259
  export async function ensureProjectGjsEngine(args, gjsFound, deps = {}) {
1408
1260
  const cwd = deps.cwd ?? process.cwd();
1409
1261
  const hasEngine = deps.hasEngineFn ?? hasBundlerEngineInstalled;
@@ -1414,9 +1266,8 @@ export async function ensureProjectGjsEngine(args, gjsFound, deps = {}) {
1414
1266
  if (hasEngine(cwd))
1415
1267
  return;
1416
1268
  if (args.immutable) {
1417
- // Name the DURABLE fix, not a transient one: a frozen CI install cannot
1418
- // be repaired by anything that happens at this moment, so the answer is
1419
- // to put the engine in the lockfile.
1269
+ // Name the DURABLE fix: a frozen CI install cannot be repaired by anything
1270
+ // happening now, so the answer is to put the engine in the lockfile.
1420
1271
  console.warn(`\nWarning: \`gjsify build\` will fail under GJS — ${BUNDLER_ENGINE_PACKAGE} is not installed, and\n` +
1421
1272
  ' --immutable cannot add it (the lockfile does not name it). Declare it so the lockfile carries it:\n' +
1422
1273
  ` gjsify install ${BUNDLER_ENGINE_PACKAGE}\n` +
@@ -1432,7 +1283,6 @@ export async function ensureProjectGjsEngine(args, gjsFound, deps = {}) {
1432
1283
  }
1433
1284
  async function runPostInstallChecks(args) {
1434
1285
  console.log('\n--- gjsify post-install checks ---');
1435
- // 1. System deps that GJS apps typically need.
1436
1286
  const results = runMinimalChecks();
1437
1287
  const missing = results.filter((r) => !r.found && r.severity === 'required');
1438
1288
  if (missing.length > 0) {
@@ -1448,14 +1298,9 @@ async function runPostInstallChecks(args) {
1448
1298
  else {
1449
1299
  console.log('System dependencies OK.');
1450
1300
  }
1451
- // 2. Close #1005 before reporting the prebuild set, so the report reflects
1452
- // what the tree ACTUALLY has rather than what it had a moment ago.
1301
+ // Before the prebuild report, so the report reflects what the tree ACTUALLY has:
1302
+ // an engine installed a moment ago belongs in the "Detected N …" list.
1453
1303
  await ensureProjectGjsEngine(args, results.find((r) => r.id === 'gjs')?.found ?? false);
1454
- // 3. Surface @gjsify/* packages with native prebuilds — `gjsify run`
1455
- // will set LD_LIBRARY_PATH / GI_TYPELIB_PATH for these automatically.
1456
- // Runs AFTER step 2 on purpose: an engine installed a moment ago belongs
1457
- // in this list, and a consumer reading "Detected N …" needs it to be the
1458
- // tree's current state rather than its state on entry.
1459
1304
  const native = detectNativePackages(process.cwd());
1460
1305
  if (native.length > 0) {
1461
1306
  console.log(`\nDetected ${native.length} @gjsify/* package(s) with native prebuilds:`);
@@ -1464,37 +1309,28 @@ async function runPostInstallChecks(args) {
1464
1309
  }
1465
1310
  console.log('\nUse `gjsify run <bundle>` to launch with LD_LIBRARY_PATH/GI_TYPELIB_PATH set.');
1466
1311
  }
1467
- // 3. Install workspace git hooks (only fires inside the gjsify monorepo
1468
- // itself, NOT in consumer projects that depend on @gjsify/cli — gated
1469
- // by the presence of `scripts/install-git-hooks.mjs` + a `.git`
1470
- // checkout). Idempotent; safe to re-run on every install.
1471
1312
  maybeInstallGitHooks();
1472
1313
  }
1473
1314
  /**
1474
- * Wire `core.hooksPath = .githooks` when running `gjsify install` inside a
1475
- * git checkout that ships `scripts/install-git-hooks.mjs` (i.e. the gjsify
1476
- * monorepo). Consumer projects that don't ship the script are skipped
1477
- * silently — they wouldn't have hooks to install.
1478
- *
1479
- * The script itself handles its own no-op cases (extracted tarball, already
1480
- * configured, SKIP_GJSIFY_HOOKS=1).
1315
+ * Wire `core.hooksPath = .githooks` when `gjsify install` runs inside a git
1316
+ * checkout that ships `scripts/install-git-hooks.mjs` — i.e. the gjsify monorepo
1317
+ * and not a consumer project, which has no hooks to install. Idempotent; the
1318
+ * script handles its own no-op cases (extracted tarball, already configured,
1319
+ * `SKIP_GJSIFY_HOOKS=1`).
1481
1320
  */
1482
1321
  function maybeInstallGitHooks() {
1483
1322
  const cwd = process.cwd();
1484
1323
  const scriptPath = join(cwd, 'scripts', 'install-git-hooks.mjs');
1485
1324
  if (!existsSync(scriptPath))
1486
1325
  return;
1487
- // Need a git checkout — the script also checks, but skipping here
1488
- // avoids spawning a process when we know the answer.
1326
+ // The script also checks, but skipping here avoids a spawn.
1489
1327
  if (!existsSync(join(cwd, '.git')))
1490
1328
  return;
1491
1329
  try {
1492
1330
  // `nodeBinary()`, NOT `process.execPath`: under the committed GJS bundle
1493
- // `process.execPath` is `gjs`, so `spawnSync(process.execPath, [script])`
1494
- // ran `gjs cli.gjs.mjs install-git-hooks.mjs` (wrong argv). The hook
1495
- // installer is a plain Node ESM script; run it with a real node binary
1496
- // (PATH `node` under GJS, the current node otherwise). Best-effort —
1497
- // wrapped in try/catch, so a missing node just warns and continues.
1331
+ // `process.execPath` is `gjs`, so `spawnSync(process.execPath, [script])` ran
1332
+ // `gjs cli.gjs.mjs install-git-hooks.mjs` — wrong argv. The hook installer is
1333
+ // a plain Node ESM script and needs a real node binary.
1498
1334
  const result = spawnSync(nodeBinary(), [scriptPath, '--quiet'], {
1499
1335
  cwd,
1500
1336
  stdio: 'inherit',
@@ -1505,8 +1341,8 @@ function maybeInstallGitHooks() {
1505
1341
  }
1506
1342
  }
1507
1343
  catch (err) {
1508
- // Hook installation is a quality-of-life touchup, not a hard install
1509
- // requirement. Never let it abort the surrounding install.
1344
+ // A quality-of-life touchup, not an install requirement — never let it abort
1345
+ // the surrounding install.
1510
1346
  console.warn(`[gjsify install] git hook installation skipped: ${err instanceof Error ? err.message : String(err)}`);
1511
1347
  }
1512
1348
  }