@gjsify/cli 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.js CHANGED
@@ -223,7 +223,7 @@ export class Config {
223
223
  const pkg = (await this.readPackageJSON(configFilePath));
224
224
  const tsConfig = (await this.readTSConfig(configFilePath));
225
225
  tsConfig.reflection ||= cliArgs.reflection;
226
- // TODO replace with `cliArgs.logLevel`
226
+ // TODO(open-todos: 10 small API gaps): replace with `cliArgs.logLevel`
227
227
  configData.verbose = cliArgs.verbose || false;
228
228
  configData.exclude = cliArgs.exclude || [];
229
229
  if (cliArgs.consoleShim !== undefined)
@@ -61,6 +61,17 @@ export declare function buildLauncherShims(opts: {
61
61
  * path the prebuilds have to be reachable through.
62
62
  */
63
63
  prependEnv?: Readonly<Record<string, string>>;
64
+ /**
65
+ * Second interpreter + script to run when the primary interpreter is not on
66
+ * `PATH`. The batch/PowerShell counterpart of {@link buildShLauncher}'s
67
+ * `nodeFallbackAbs` — a Windows host with no `gjs` is the majority case, and
68
+ * the `.cmd`/`.ps1` members are the ones cmd.exe and pwsh actually reach.
69
+ */
70
+ fallback?: {
71
+ interpreter: string;
72
+ interpreterArgs?: readonly string[];
73
+ target: string;
74
+ };
64
75
  }): {
65
76
  cmd: string;
66
77
  ps1: string;
@@ -182,6 +193,26 @@ export declare function normalizeBinMap(pkgName: string, bin: string | Record<st
182
193
  *
183
194
  * `isGjsBin` lets the caller keep plain-Node packages on the plain symlink path
184
195
  * — this must not change how `lodash`'s bin is linked.
196
+ *
197
+ * WHY THE npm MAP COMES BACK TOO
198
+ *
199
+ * The reasoning above is sound and INCOMPLETE: it was written from the Node-less
200
+ * GJS host and is blind to the mirror case. `@gjsify/cli` declares BOTH fields,
201
+ * so a host with no `gjs` — macOS and Windows, the two active port targets —
202
+ * got `exec gjs -m <bundle>`, which is exit 127, while a working Node entry sat
203
+ * beside it in the same package. `cli-cross-platform.yml` recorded that on both
204
+ * OSes for three consecutive releases (v0.27.1, v0.28.0, v0.29.0); it is
205
+ * advisory, so it stayed red. The install even KNEW: its own post-install check
206
+ * printed `Missing required system dependencies: ✗ GJS` and then wrote a
207
+ * gjs-only launcher anyway.
208
+ *
209
+ * So `nodeFallback` carries the npm map alongside, and the LAUNCHER decides per
210
+ * invocation. Deliberately not an install-time host probe: an install-time
211
+ * decision is a SNAPSHOT, and this repo has already paid for that exact mistake
212
+ * once — `buildNativeEnvPreamble` used to bake the install-time prebuild scan,
213
+ * so a package installed later was invisible and v0.24.1 shipped a launcher with
214
+ * no preamble at all. A host probe here fails the same way in reverse: install
215
+ * without gjs, install gjs afterwards, shim still points at Node forever.
185
216
  */
186
217
  export declare function pickBinMap(pkgName: string, pkgJson: {
187
218
  bin?: string | Record<string, string>;
@@ -191,6 +222,7 @@ export declare function pickBinMap(pkgName: string, pkgJson: {
191
222
  }): {
192
223
  map: Map<string, string>;
193
224
  isGjsBin: boolean;
225
+ nodeFallback: Map<string, string> | null;
194
226
  } | null;
195
227
  /**
196
228
  * The POSIX `sh` launcher gjsify writes for a bin it owns.
@@ -205,10 +237,17 @@ export declare function pickBinMap(pkgName: string, pkgJson: {
205
237
  * because not every published bundle carries a `#!/usr/bin/env -S gjs -m` line,
206
238
  * and direct-exec'ing a shebang-less `.mjs` falls through to `/bin/sh`, which
207
239
  * then parses JavaScript as shell.
240
+ *
241
+ * `nodeFallbackAbs` makes the runtime choice per INVOCATION rather than per
242
+ * install — see {@link pickBinMap} for why a host probe at install time is the
243
+ * wrong shape. `gjs` stays first: that is today's behaviour on a host carrying
244
+ * both, and which runtime wins is a separate decision from making the shim
245
+ * runnable at all.
208
246
  */
209
247
  export declare function buildShLauncher(targetAbs: string, opts: {
210
248
  envPreamble?: string;
211
249
  isGjsBundle: boolean;
250
+ nodeFallbackAbs?: string;
212
251
  }): string;
213
252
  /** True when a bin target is a GJS-runnable bundle rather than a Node script. */
214
253
  export declare function isGjsBundlePath(target: string): boolean;
@@ -223,26 +223,63 @@ export function buildCmdShim(relTargetPosix, shebang) {
223
223
  * fails the same way on every OS.
224
224
  */
225
225
  export function buildLauncherShims(opts) {
226
- const { interpreter, interpreterArgs = [], target, prependEnv = {} } = opts;
226
+ const { interpreter, interpreterArgs = [], target, prependEnv = {}, fallback } = opts;
227
227
  const winTarget = target.split('/').join('\\');
228
228
  const argv = interpreterArgs.length > 0 ? `${interpreterArgs.join(' ')} ` : '';
229
229
  const entries = Object.entries(prependEnv);
230
+ const fbTarget = fallback === undefined ? '' : fallback.target.split('/').join('\\');
231
+ const fbArgv = fallback?.interpreterArgs !== undefined && fallback.interpreterArgs.length > 0
232
+ ? `${fallback.interpreterArgs.join(' ')} `
233
+ : '';
230
234
  let cmd = CMD_HEAD;
231
235
  for (const [key, value] of entries)
232
236
  cmd += `@SET "${key}=${value};%${key}%"\r\n`;
233
- cmd += `${interpreter} ${argv}"${winTarget}" %*\r\n`;
237
+ if (fallback === undefined) {
238
+ cmd += `${interpreter} ${argv}"${winTarget}" %*\r\n`;
239
+ }
240
+ else {
241
+ // `where` is the batch equivalent of `command -v`; it writes to stdout,
242
+ // so both streams are silenced. ERRORLEVEL is read via IF/ELSE rather
243
+ // than `&&`/`||` chaining, which would swallow the child's own exit code.
244
+ cmd +=
245
+ `@where ${interpreter} >NUL 2>NUL\r\n` +
246
+ `@IF %ERRORLEVEL% EQU 0 (\r\n` +
247
+ ` ${interpreter} ${argv}"${winTarget}" %*\r\n` +
248
+ `) ELSE (\r\n` +
249
+ ` ${fallback.interpreter} ${fbArgv}"${fbTarget}" %*\r\n` +
250
+ `)\r\n`;
251
+ }
234
252
  let ps1 = '#!/usr/bin/env pwsh\n';
235
253
  for (const [key, value] of entries)
236
254
  ps1 += `$env:${key} = "${value};" + $env:${key}\n`;
237
255
  const ps1Args = interpreterArgs.map((a) => `"${a}" `).join('');
238
- ps1 +=
239
- '# Support pipeline input\n' +
240
- 'if ($MyInvocation.ExpectingInput) {\n' +
241
- ` $input | & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
242
- '} else {\n' +
243
- ` & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
244
- '}\n' +
245
- 'exit $LASTEXITCODE\n';
256
+ if (fallback === undefined) {
257
+ ps1 +=
258
+ '# Support pipeline input\n' +
259
+ 'if ($MyInvocation.ExpectingInput) {\n' +
260
+ ` $input | & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
261
+ '} else {\n' +
262
+ ` & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
263
+ '}\n';
264
+ }
265
+ else {
266
+ // Bind the three parts once, then call once — the alternative (a full
267
+ // if/else around two near-identical invocation blocks, each of which
268
+ // already branches on pipeline input) is four copies of the same call.
269
+ const psList = (a) => a.length === 0 ? '@()' : `@(${a.map((x) => `"${x}"`).join(', ')})`;
270
+ ps1 +=
271
+ `$exe = "${interpreter}"; $exeArgs = ${psList(interpreterArgs)}; $script = "${winTarget}"\n` +
272
+ `if (-not (Get-Command "${interpreter}" -ErrorAction SilentlyContinue)) {\n` +
273
+ ` $exe = "${fallback.interpreter}"; $exeArgs = ${psList(fallback.interpreterArgs ?? [])}; $script = "${fbTarget}"\n` +
274
+ '}\n' +
275
+ '# Support pipeline input\n' +
276
+ 'if ($MyInvocation.ExpectingInput) {\n' +
277
+ ' $input | & $exe @exeArgs $script $args\n' +
278
+ '} else {\n' +
279
+ ' & $exe @exeArgs $script $args\n' +
280
+ '}\n';
281
+ }
282
+ ps1 += 'exit $LASTEXITCODE\n';
246
283
  return { cmd, ps1 };
247
284
  }
248
285
  /** POSIX `sh` single-quoting — the one place the escape lives. */
@@ -437,14 +474,39 @@ export function normalizeBinMap(pkgName, bin) {
437
474
  *
438
475
  * `isGjsBin` lets the caller keep plain-Node packages on the plain symlink path
439
476
  * — this must not change how `lodash`'s bin is linked.
477
+ *
478
+ * WHY THE npm MAP COMES BACK TOO
479
+ *
480
+ * The reasoning above is sound and INCOMPLETE: it was written from the Node-less
481
+ * GJS host and is blind to the mirror case. `@gjsify/cli` declares BOTH fields,
482
+ * so a host with no `gjs` — macOS and Windows, the two active port targets —
483
+ * got `exec gjs -m <bundle>`, which is exit 127, while a working Node entry sat
484
+ * beside it in the same package. `cli-cross-platform.yml` recorded that on both
485
+ * OSes for three consecutive releases (v0.27.1, v0.28.0, v0.29.0); it is
486
+ * advisory, so it stayed red. The install even KNEW: its own post-install check
487
+ * printed `Missing required system dependencies: ✗ GJS` and then wrote a
488
+ * gjs-only launcher anyway.
489
+ *
490
+ * So `nodeFallback` carries the npm map alongside, and the LAUNCHER decides per
491
+ * invocation. Deliberately not an install-time host probe: an install-time
492
+ * decision is a SNAPSHOT, and this repo has already paid for that exact mistake
493
+ * once — `buildNativeEnvPreamble` used to bake the install-time prebuild scan,
494
+ * so a package installed later was invisible and v0.24.1 shipped a launcher with
495
+ * no preamble at all. A host probe here fails the same way in reverse: install
496
+ * without gjs, install gjs afterwards, shim still points at Node forever.
440
497
  */
441
498
  export function pickBinMap(pkgName, pkgJson) {
442
499
  const gjsifyBin = pkgJson.gjsify?.bin;
443
- if (gjsifyBin !== undefined)
444
- return { map: normalizeBinMap(pkgName, gjsifyBin), isGjsBin: true };
445
500
  const npmBin = pkgJson.bin;
501
+ if (gjsifyBin !== undefined) {
502
+ return {
503
+ map: normalizeBinMap(pkgName, gjsifyBin),
504
+ isGjsBin: true,
505
+ nodeFallback: npmBin === undefined ? null : normalizeBinMap(pkgName, npmBin),
506
+ };
507
+ }
446
508
  if (npmBin !== undefined)
447
- return { map: normalizeBinMap(pkgName, npmBin), isGjsBin: false };
509
+ return { map: normalizeBinMap(pkgName, npmBin), isGjsBin: false, nodeFallback: null };
448
510
  return null;
449
511
  }
450
512
  /**
@@ -460,9 +522,23 @@ export function pickBinMap(pkgName, pkgJson) {
460
522
  * because not every published bundle carries a `#!/usr/bin/env -S gjs -m` line,
461
523
  * and direct-exec'ing a shebang-less `.mjs` falls through to `/bin/sh`, which
462
524
  * then parses JavaScript as shell.
525
+ *
526
+ * `nodeFallbackAbs` makes the runtime choice per INVOCATION rather than per
527
+ * install — see {@link pickBinMap} for why a host probe at install time is the
528
+ * wrong shape. `gjs` stays first: that is today's behaviour on a host carrying
529
+ * both, and which runtime wins is a separate decision from making the shim
530
+ * runnable at all.
463
531
  */
464
532
  export function buildShLauncher(targetAbs, opts) {
465
533
  const preamble = opts.isGjsBundle ? (opts.envPreamble ?? '') : '';
534
+ if (opts.isGjsBundle && opts.nodeFallbackAbs !== undefined) {
535
+ // The preamble only ever configures the GI search path, so it stays
536
+ // inside the gjs branch — exporting it around a Node exec would be
537
+ // inert at best and misleading to anyone reading the shim.
538
+ return (`#!/bin/sh\nif command -v gjs >/dev/null 2>&1; then\n${preamble}` +
539
+ `exec gjs -m ${shQuote(targetAbs)} "$@"\nfi\n` +
540
+ `exec node ${shQuote(opts.nodeFallbackAbs)} "$@"\n`);
541
+ }
466
542
  const exec = opts.isGjsBundle ? `exec gjs -m ${shQuote(targetAbs)} "$@"` : `exec ${shQuote(targetAbs)} "$@"`;
467
543
  return `#!/bin/sh\n${preamble}${exec}\n`;
468
544
  }
@@ -28,6 +28,27 @@ export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew
28
28
  * MacPorts or Nix host, and it is why this must never fall back to "probably brew".
29
29
  */
30
30
  export declare function detectPackageManager(): PackageManager;
31
+ interface OptionalDep {
32
+ /** DepCheck id */
33
+ id: string;
34
+ /** Display name */
35
+ name: string;
36
+ /** pkg-config library name */
37
+ pkgName: string;
38
+ }
39
+ /** Optional system deps keyed by their DepCheck id. Exported for the spec's declaration invariant. */
40
+ export declare const OPTIONAL_DEPS: Record<string, OptionalDep>;
41
+ /**
42
+ * Map of @gjsify/* package name → ids of OPTIONAL_DEPS this package needs.
43
+ * Generated by walking each package's `gi://` imports (excluding the always-
44
+ * available trio GLib/GObject/Gio).
45
+ *
46
+ * Used to compute the set of optional deps to check for a given project.
47
+ *
48
+ * Exported for the spec's declaration invariant: every id named here must exist in
49
+ * OPTIONAL_DEPS, or the dep is silently never checked.
50
+ */
51
+ export declare const PACKAGE_DEPS: Record<string, string[]>;
31
52
  /**
32
53
  * Run all dependency checks. Used by `gjsify check` to show full system status.
33
54
  *
@@ -86,3 +107,4 @@ export interface TypeSkewReaders {
86
107
  * ignore the output.
87
108
  */
88
109
  export declare function checkTypeSkew(cwd: string, readers?: TypeSkewReaders): TypeSkew[];
110
+ export {};
@@ -87,8 +87,8 @@ export function detectPackageManager() {
87
87
  }
88
88
  return 'unknown';
89
89
  }
90
- /** Optional system deps keyed by their DepCheck id. */
91
- const OPTIONAL_DEPS = {
90
+ /** Optional system deps keyed by their DepCheck id. Exported for the spec's declaration invariant. */
91
+ export const OPTIONAL_DEPS = {
92
92
  manette: { id: 'manette', name: 'libmanette', pkgName: 'manette-0.2' },
93
93
  gstreamer: { id: 'gstreamer', name: 'GStreamer', pkgName: 'gstreamer-1.0' },
94
94
  'gst-app': { id: 'gst-app', name: 'GStreamer App', pkgName: 'gstreamer-app-1.0' },
@@ -105,6 +105,21 @@ const OPTIONAL_DEPS = {
105
105
  // `Run-time dependency X found: NO` error mid-build.
106
106
  gnutls: { id: 'gnutls', name: 'GnuTLS', pkgName: 'gnutls' },
107
107
  nghttp2: { id: 'nghttp2', name: 'libnghttp2', pkgName: 'libnghttp2' },
108
+ // RUNTIME deps of the shipped prebuilds, not build-time ones — the distinction
109
+ // the two entries above make does not hold for these. Measured on fedora:43
110
+ // provisioned like a CONSUMER (gjs + libsoup3 only), `ldd` over every committed
111
+ // linux-x64 prebuild: three sonames do not resolve, and each makes its typelib
112
+ // UNLOADABLE, which surfaces as `Unsupported type void, deriving from
113
+ // fundamental void` at first construction — a message that names nothing.
114
+ // @gjsify/rolldown-native libjson-glib-1.0.so.0
115
+ // @gjsify/webgl libepoxy.so.0 (gdk-pixbuf already listed)
116
+ // @gjsify/webrtc-native libgstwebrtc-1.0.so.0 (gstreamer already listed)
117
+ // json-glib is the severe one: rolldown-native IS the GJS bundler engine, so a
118
+ // host without it cannot `gjsify build` at all, while `gjsify install` printed
119
+ // "System dependencies OK." — a check passing for the wrong reason.
120
+ 'json-glib': { id: 'json-glib', name: 'JSON-GLib', pkgName: 'json-glib-1.0' },
121
+ epoxy: { id: 'epoxy', name: 'libepoxy', pkgName: 'epoxy' },
122
+ 'gst-webrtc': { id: 'gst-webrtc', name: 'GStreamer WebRTC', pkgName: 'gstreamer-webrtc-1.0' },
108
123
  };
109
124
  /**
110
125
  * Map of @gjsify/* package name → ids of OPTIONAL_DEPS this package needs.
@@ -112,8 +127,11 @@ const OPTIONAL_DEPS = {
112
127
  * available trio GLib/GObject/Gio).
113
128
  *
114
129
  * Used to compute the set of optional deps to check for a given project.
130
+ *
131
+ * Exported for the spec's declaration invariant: every id named here must exist in
132
+ * OPTIONAL_DEPS, or the dep is silently never checked.
115
133
  */
116
- const PACKAGE_DEPS = {
134
+ export const PACKAGE_DEPS = {
117
135
  '@gjsify/gamepad': ['manette'],
118
136
  '@gjsify/webaudio': ['gstreamer', 'gst-app'],
119
137
  '@gjsify/iframe': ['webkitgtk'],
@@ -124,13 +142,17 @@ const PACKAGE_DEPS = {
124
142
  // as a special-case checkNpmPackage rather than checkPkgConfig in
125
143
  // runOptionalChecks. Mapping it here so its presence in the project's
126
144
  // dep tree triggers the check.
127
- '@gjsify/webgl': ['gwebgl'],
145
+ '@gjsify/webgl': ['gwebgl', 'gdk-pixbuf', 'epoxy'],
128
146
  // Native Vala bridges with `dependency('gnutls')` / `dependency('libnghttp2')`
129
147
  // in their meson.build. Optional because the shipped prebuild covers the
130
148
  // common-arch user path; only contributors rebuilding from source hit the
131
149
  // build-time dep.
132
150
  '@gjsify/tls-native': ['gnutls'],
133
151
  '@gjsify/http2-native': ['nghttp2'],
152
+ // Unlike the two above, these are LOAD-time: the prebuild is dynamically linked
153
+ // and GIRepository cannot open its backing library without them.
154
+ '@gjsify/rolldown-native': ['json-glib'],
155
+ '@gjsify/webrtc-native': ['gstreamer', 'gst-webrtc'],
134
156
  // @gjsify/event-bridge only needs gtk4/gdk which are already in the
135
157
  // required set, so it doesn't need an optional entry.
136
158
  };
@@ -367,6 +389,8 @@ const PM_PACKAGES = {
367
389
  cairo: 'libcairo2-dev',
368
390
  gnutls: 'libgnutls28-dev',
369
391
  nghttp2: 'libnghttp2-dev',
392
+ 'json-glib': 'libjson-glib-dev',
393
+ epoxy: 'libepoxy-dev',
370
394
  },
371
395
  dnf: {
372
396
  gjs: 'gjs',
@@ -390,6 +414,8 @@ const PM_PACKAGES = {
390
414
  cairo: 'cairo-devel',
391
415
  gnutls: 'gnutls-devel',
392
416
  nghttp2: 'libnghttp2-devel',
417
+ 'json-glib': 'json-glib-devel',
418
+ epoxy: 'libepoxy-devel',
393
419
  },
394
420
  pacman: {
395
421
  gjs: 'gjs',
@@ -413,6 +439,8 @@ const PM_PACKAGES = {
413
439
  cairo: 'cairo',
414
440
  gnutls: 'gnutls',
415
441
  nghttp2: 'libnghttp2',
442
+ 'json-glib': 'json-glib',
443
+ epoxy: 'libepoxy',
416
444
  },
417
445
  zypper: {
418
446
  gjs: 'gjs',
@@ -436,6 +464,8 @@ const PM_PACKAGES = {
436
464
  cairo: 'cairo-devel',
437
465
  gnutls: 'libgnutls-devel',
438
466
  nghttp2: 'libnghttp2-devel',
467
+ 'json-glib': 'json-glib-devel',
468
+ epoxy: 'libepoxy-devel',
439
469
  },
440
470
  apk: {
441
471
  gjs: 'gjs',
@@ -459,6 +489,8 @@ const PM_PACKAGES = {
459
489
  cairo: 'cairo-dev',
460
490
  gnutls: 'gnutls-dev',
461
491
  nghttp2: 'nghttp2-dev',
492
+ 'json-glib': 'json-glib-dev',
493
+ epoxy: 'libepoxy-dev',
462
494
  },
463
495
  // macOS. Names verified against homebrew/core rather than inferred from the
464
496
  // Linux rows — the mapping is NOT mechanical: Homebrew ships no `-dev`/`-devel`
@@ -502,6 +534,8 @@ const PM_PACKAGES = {
502
534
  cairo: 'cairo',
503
535
  gnutls: 'gnutls',
504
536
  nghttp2: 'nghttp2',
537
+ 'json-glib': 'json-glib',
538
+ epoxy: 'libepoxy',
505
539
  },
506
540
  unknown: {},
507
541
  };
@@ -158,7 +158,7 @@ export function symlinkSwap(cacheDir, prepareDir, opts = {}) {
158
158
  }
159
159
  /** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
160
160
  export function cleanupStalePrepareDirs(cacheDir, _maxAgeMinutes = DEFAULT_TTL_MIN) {
161
- // Out of scope for Phase 1 — pnpm has the same TODO. Leaving a stub so
161
+ // TODO(open-todos: 10 small API gaps): out of scope for Phase 1 — pnpm has the same stub. Kept so
162
162
  // call sites already exist when we do implement it.
163
163
  void cacheDir;
164
164
  }
@@ -250,6 +250,14 @@ export declare function writeBinEntry(opts: {
250
250
  envPreamble: string;
251
251
  prebuildDirs: readonly string[];
252
252
  };
253
+ /**
254
+ * The package's npm `bin` target, when it declares one ALONGSIDE
255
+ * `gjsify.bin`. The launcher then picks a runtime per invocation instead of
256
+ * hard-coding `gjs` — see {@link pickBinMap}. Absent for a package that
257
+ * declares only one of the two, which is every package but `@gjsify/cli`
258
+ * today.
259
+ */
260
+ nodeFallbackAbs?: string;
253
261
  }): void;
254
262
  type Logger = (fmt: string, ...args: unknown[]) => void;
255
263
  export {};
@@ -1766,7 +1766,7 @@ function readShebang(file) {
1766
1766
  * the same set from the same templates. See `./bin-shim.ts`.
1767
1767
  */
1768
1768
  export function writeBinEntry(opts) {
1769
- const { binDir, binName, targetAbs, gjs } = opts;
1769
+ const { binDir, binName, targetAbs, gjs, nodeFallbackAbs } = opts;
1770
1770
  const platform = opts.platform ?? process.platform;
1771
1771
  const linkPath = path.join(binDir, binName);
1772
1772
  if (gjs) {
@@ -1775,7 +1775,7 @@ export function writeBinEntry(opts) {
1775
1775
  // plain symlink (or npm's three-file shim) under this very name.
1776
1776
  for (const stale of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`])
1777
1777
  rmWithRetry(stale);
1778
- fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble: gjs.envPreamble, isGjsBundle: isBundle }));
1778
+ fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble: gjs.envPreamble, isGjsBundle: isBundle, nodeFallbackAbs }));
1779
1779
  try {
1780
1780
  fs.chmodSync(linkPath, 0o755);
1781
1781
  }
@@ -1793,6 +1793,7 @@ export function writeBinEntry(opts) {
1793
1793
  interpreterArgs: ['-m'],
1794
1794
  target: targetAbs,
1795
1795
  prependEnv,
1796
+ fallback: nodeFallbackAbs === undefined ? undefined : { interpreter: 'node', target: nodeFallbackAbs },
1796
1797
  });
1797
1798
  fs.writeFileSync(`${linkPath}.cmd`, shims.cmd);
1798
1799
  fs.writeFileSync(`${linkPath}.ps1`, shims.ps1);
@@ -1872,11 +1873,16 @@ async function linkBins(nodes, prefix, log) {
1872
1873
  catch {
1873
1874
  /* best effort */
1874
1875
  }
1876
+ // Only a fallback that EXISTS is worth writing: a launcher naming a
1877
+ // missing Node entry would trade exit 127 for a different exit 127.
1878
+ const fallbackRel = picked.nodeFallback?.get(binName);
1879
+ const fallbackAbs = fallbackRel === undefined ? undefined : path.join(pkgDir, fallbackRel);
1875
1880
  writeBinEntry({
1876
1881
  binDir,
1877
1882
  binName,
1878
1883
  targetAbs,
1879
1884
  gjs: picked.isGjsBin ? gjsEnvForPrefix() : undefined,
1885
+ nodeFallbackAbs: fallbackAbs !== undefined && fs.existsSync(fallbackAbs) ? fallbackAbs : undefined,
1880
1886
  });
1881
1887
  created++;
1882
1888
  }
@@ -198,10 +198,19 @@ export function linkGlobalBins(packageNames, layout) {
198
198
  // then tries to parse JavaScript as shell. Plain Node scripts
199
199
  // with shebangs (lib/index.js) keep the direct-exec path.
200
200
  const isGjsBundle = isGjsBundlePath(targetAbs);
201
+ // A global install extracts the WHOLE package, so when it declares
202
+ // an npm `bin` beside its `gjsify.bin` that entry is on disk here
203
+ // too and the launcher can fall back to it on a host without `gjs`
204
+ // — the same fix as the project-local backend. Guarded on existence
205
+ // rather than assumed: naming a missing Node entry would only trade
206
+ // one exit 127 for another. See `pickBinMap`.
207
+ const fallbackRel = picked.nodeFallback?.get(binName);
208
+ const fallbackCandidate = fallbackRel === undefined ? undefined : path.join(pkgDir, fallbackRel);
209
+ const nodeFallbackAbs = fallbackCandidate !== undefined && fs.existsSync(fallbackCandidate) ? fallbackCandidate : undefined;
201
210
  // Only GJS bundles need the GI typelib search path. Plain Node
202
211
  // scripts ignore GI_TYPELIB_PATH, so skipping the preamble there
203
212
  // keeps the launcher minimal.
204
- fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble, isGjsBundle }));
213
+ fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble, isGjsBundle, nodeFallbackAbs }));
205
214
  fs.chmodSync(linkPath, 0o755);
206
215
  // Windows runs neither an extension-less file nor a `#!` line, so
207
216
  // the `sh` launcher above is only reachable from git-bash/MSYS/WSL.
@@ -224,6 +233,9 @@ export function linkGlobalBins(packageNames, layout) {
224
233
  interpreterArgs: ['-m'],
225
234
  target: targetAbs,
226
235
  prependEnv,
236
+ fallback: nodeFallbackAbs === undefined
237
+ ? undefined
238
+ : { interpreter: 'node', target: nodeFallbackAbs },
227
239
  })
228
240
  : buildLauncherShims({
229
241
  interpreter: shebang?.prog || 'node',
@@ -7,8 +7,28 @@
7
7
  // behaves like `yarn run` / `npm run`. That's wrong for the embedded use
8
8
  // case where pack/publish needs the lifecycle script to finish, then keep
9
9
  // running its own logic. This helper resolves to a Promise that settles
10
- // when the child exits — no process.exit, no GLib-mainloop intermingling
11
- // from `ensureMainLoop`.
10
+ // when the child exits.
11
+ //
12
+ // ## Not calling `process.exit()` is what ARMED the hang, not what avoided it
13
+ //
14
+ // This header used to end "no process.exit, no GLib-mainloop intermingling
15
+ // from `ensureMainLoop`". The second half was exactly backwards, and it is why
16
+ // nobody looked here for #1010.
17
+ //
18
+ // `@gjsify/child_process`'s async `spawn()` calls `ensureMainLoop()`
19
+ // unconditionally — the caller has no say. That arms a GJS main-loop hook whose
20
+ // blocking `g_main_loop_run()` only `process.exit()` tears down. So a helper
21
+ // that spawns asynchronously and deliberately does NOT exit leaves the loop
22
+ // armed forever: `gjsify pack` on a package with a `prepack` wrote its tarball,
23
+ // printed its complete JSON, and then parked at 0% CPU until killed at 5m30s.
24
+ // A package with no lifecycle script exited in under a second, and the same
25
+ // pack under Node exited in 0.68s.
26
+ //
27
+ // `utils/spawn.ts` already owns that contract and makes every call site DECLARE
28
+ // which side of it they are on. This helper was the one spawn in the CLI that
29
+ // bypassed it — the seventh of the "six near-identical wrappers" that file was
30
+ // written to consolidate. It now goes through it with `completion: 'return'`,
31
+ // which is what "settle, then keep running our own logic" means.
12
32
  //
13
33
  // Matches yarn / npm script semantics:
14
34
  // - `shell: true` so `&&` / `|` / env-var refs work
@@ -16,8 +36,8 @@
16
36
  // - `npm_lifecycle_event` / `npm_package_name` / `npm_package_version`
17
37
  // env vars set
18
38
  // - FORCE_COLOR=1 default unless caller overrides
19
- import { spawn } from 'node:child_process';
20
39
  import { delimiter, join } from 'node:path';
40
+ import { describeExit, spawnToCompletion } from './spawn.js';
21
41
  import { findWorkspaceRoot } from './workspace-root.js';
22
42
  /**
23
43
  * Run a lifecycle script defined in `pkg.scripts[name]` from `wsDir`.
@@ -52,28 +72,24 @@ export async function runLifecycleScript(wsDir, pkg, name, opts = {}) {
52
72
  npm_package_version: pkg.version ?? '',
53
73
  ...opts.env,
54
74
  };
55
- // `'inherit-stderr'` is our extension on top of node's stdio modes —
56
- // child stdin inherits, child stdout → parent's stderr (fd 2), child
57
- // stderr → parent's stderr. Used by `gjsify pack --json` so the
58
- // prepack's log lines don't get interleaved with the JSON we emit on
59
- // parent stdout. `spawn`'s `stdio` accepts numeric fds in array form
60
- // and routes the child's matching stream to that fd.
61
- const stdioConfig = opts.stdio === 'inherit-stderr' ? ['inherit', 2, 2] : (opts.stdio ?? 'inherit');
62
- await new Promise((resolveOk, reject) => {
63
- const child = spawn(literal, [], {
64
- cwd: wsDir,
65
- env: env,
66
- stdio: stdioConfig,
67
- shell: true,
68
- });
69
- child.on('close', (code) => {
70
- if (code === 0)
71
- resolveOk();
72
- else {
73
- reject(new Error(`gjsify lifecycle-script: "${name}" in ${wsDir} exited with code ${code}`));
74
- }
75
- });
76
- child.on('error', reject);
75
+ // `'ignore'` has no `spawnToCompletion` equivalent and no call site in the
76
+ // tree; folding it onto `'inherit'` would silently start printing where a
77
+ // caller asked for silence, so it is rejected rather than approximated.
78
+ if (opts.stdio === 'ignore') {
79
+ throw new Error('gjsify lifecycle-script: stdio "ignore" is not supported — see utils/spawn.ts');
80
+ }
81
+ const result = await spawnToCompletion(literal, [], {
82
+ // The whole point of this helper: pack/publish waits for the script and
83
+ // then keeps running. Under GJS that is the blocking path, which is what
84
+ // keeps the armed main loop from outliving us — see the header.
85
+ completion: 'return',
86
+ cwd: wsDir,
87
+ env: env,
88
+ shell: true,
89
+ stdio: opts.stdio === 'inherit-stderr' ? 'inherit-stderr' : opts.stdio === 'pipe' ? 'pipe' : 'inherit',
77
90
  });
91
+ if (result.code !== 0) {
92
+ throw new Error(`gjsify lifecycle-script: "${name}" in ${wsDir} exited with ${describeExit(result)}`);
93
+ }
78
94
  return true;
79
95
  }
@@ -33,8 +33,24 @@ export interface SpawnToCompletionOptions {
33
33
  * `'inherit'` (default) forwards the parent's stdio. `'pipe'` gives the
34
34
  * caller `child.stdout` / `child.stderr` via {@link onSpawn} — only
35
35
  * meaningful together with `completion: 'exit'` (see {@link onSpawn}).
36
+ *
37
+ * `'inherit-stderr'` inherits, but routes the child's **stdout to the
38
+ * parent's stderr**. For a parent whose own stdout is a machine-readable
39
+ * stream: `gjsify pack --json` emits JSON there, and a lifecycle script's
40
+ * log lines interleaved into it make the result unparseable for every
41
+ * caller that pipes it into `JSON.parse`.
42
+ */
43
+ stdio?: 'inherit' | 'pipe' | 'inherit-stderr';
44
+ /**
45
+ * Run `cmd` through the platform shell, so `&&`, `|` and env-var
46
+ * references work. `cmd` is then a whole command LINE and `args` must be
47
+ * empty — that is npm/yarn script semantics.
48
+ *
49
+ * Skips the win32 command rewrite by design, per this file's own rule:
50
+ * Node routes a `shell: true` spawn through `%COMSPEC%`, which finds
51
+ * `.cmd` shims on its own, so rewriting the string would corrupt it.
36
52
  */
37
- stdio?: 'inherit' | 'pipe';
53
+ shell?: boolean;
38
54
  /**
39
55
  * Invoked synchronously with the live `ChildProcess` right after a
40
56
  * successful async spawn — the seam for kill-tracking registries and for
@@ -133,34 +133,51 @@ function mustBlock(opts) {
133
133
  */
134
134
  export function spawnToCompletion(cmd, args, opts) {
135
135
  const env = resolveEnv(opts);
136
+ // A shell invocation is a command LINE, not an argv, so the win32 rewrite
137
+ // must not touch it — see `SpawnToCompletionOptions.shell`.
138
+ const invocation = opts.shell
139
+ ? { cmd, args: [...args], windowsVerbatimArguments: false }
140
+ : forWin32(cmd, args, env);
136
141
  if (mustBlock(opts)) {
137
142
  // `'inherit'` is NOT forwarded through `@gjsify/child_process`'s GJS
138
143
  // spawnSync path (it drives a private main context via
139
144
  // `communicateWithTimeout`), so the child's diagnostics would be
140
145
  // silently dropped. Capture them and re-emit after the child exits.
141
146
  // stdin stays inherited so an interactive child still reads the tty.
142
- const win = forWin32(cmd, args, env);
143
- const r = spawnSync(win.cmd, win.args, {
147
+ const r = spawnSync(invocation.cmd, invocation.args, {
144
148
  stdio: ['inherit', 'pipe', 'pipe'],
145
149
  cwd: opts.cwd,
146
150
  env,
151
+ shell: opts.shell,
147
152
  maxBuffer: opts.maxBuffer ?? DEFAULT_MAX_BUFFER,
148
- windowsVerbatimArguments: win.windowsVerbatimArguments,
153
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
149
154
  });
150
155
  if (r.error)
151
156
  return Promise.reject(mapSpawnError(r.error, opts));
157
+ // The captured stdout goes to the parent's STDERR under
158
+ // `'inherit-stderr'` — same destination the streaming path routes it to,
159
+ // so the two agree on where a lifecycle script's chatter lands.
160
+ const out = opts.stdio === 'inherit-stderr' ? process.stderr : process.stdout;
152
161
  if (r.stdout && r.stdout.length > 0)
153
- process.stdout.write(r.stdout);
162
+ out.write(r.stdout);
154
163
  if (r.stderr && r.stderr.length > 0)
155
164
  process.stderr.write(r.stderr);
156
165
  return Promise.resolve({ code: r.status, signal: r.signal ?? null });
157
166
  }
158
167
  return new Promise((resolvePromise, reject) => {
159
- const win = forWin32(cmd, args, env);
168
+ const win = invocation;
160
169
  const spawnOpts = {
161
170
  cwd: opts.cwd,
162
- stdio: opts.stdio === 'pipe' ? ['ignore', 'pipe', 'pipe'] : 'inherit',
171
+ // Numeric fds route the child's matching stream to that fd, so
172
+ // `['inherit', 2, 2]` is stdin inherited, stdout AND stderr → fd 2.
173
+ stdio: opts.stdio === 'pipe'
174
+ ? ['ignore', 'pipe', 'pipe']
175
+ : opts.stdio === 'inherit-stderr'
176
+ ? ['inherit', 2, 2]
177
+ : 'inherit',
163
178
  };
179
+ if (opts.shell)
180
+ spawnOpts.shell = true;
164
181
  if (env)
165
182
  spawnOpts.env = env;
166
183
  if (win.windowsVerbatimArguments)