@gjsify/cli 0.24.2 → 0.26.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 (39) hide show
  1. package/dist/cli.gjs.mjs +189 -187
  2. package/lib/actions/build.d.ts +13 -0
  3. package/lib/actions/build.js +57 -11
  4. package/lib/cli-app.js +7 -10
  5. package/lib/commands/affected.js +14 -2
  6. package/lib/commands/check.js +25 -8
  7. package/lib/commands/create.js +3 -1
  8. package/lib/commands/debug.js +3 -1
  9. package/lib/commands/flatpak/check.js +4 -3
  10. package/lib/commands/flatpak/diff.js +3 -1
  11. package/lib/commands/flatpak/sources.js +4 -7
  12. package/lib/commands/foreach.js +25 -7
  13. package/lib/commands/generate-installer.js +2 -4
  14. package/lib/commands/install.js +15 -7
  15. package/lib/commands/login.js +3 -1
  16. package/lib/commands/onboard.js +3 -6
  17. package/lib/commands/publish.js +8 -11
  18. package/lib/commands/self-update.js +4 -8
  19. package/lib/commands/showcase.d.ts +16 -0
  20. package/lib/commands/showcase.js +57 -26
  21. package/lib/commands/storybook.js +16 -2
  22. package/lib/commands/system-check.js +1 -2
  23. package/lib/commands/test.js +6 -2
  24. package/lib/commands/trust.js +12 -7
  25. package/lib/commands/tsc.js +3 -1
  26. package/lib/commands/uninstall.js +1 -2
  27. package/lib/commands/whoami.js +6 -3
  28. package/lib/utils/dlx-cache.js +4 -5
  29. package/lib/utils/install-backend-native.js +1 -1
  30. package/lib/utils/install-lock.js +5 -7
  31. package/lib/utils/install-tarball-cache.js +5 -8
  32. package/lib/utils/node-bundle-guard.d.ts +16 -0
  33. package/lib/utils/node-bundle-guard.js +69 -0
  34. package/lib/utils/oxc-resolve.js +1 -1
  35. package/lib/utils/prompt.js +6 -1
  36. package/lib/utils/runtimes.d.ts +18 -0
  37. package/lib/utils/runtimes.js +20 -0
  38. package/lib/utils/stdin.js +6 -7
  39. package/package.json +17 -17
@@ -60,6 +60,19 @@ export declare class BuildAction {
60
60
  * text scan cannot tell from a statement.
61
61
  */
62
62
  private assertGjsOutputLoadable;
63
+ /**
64
+ * Post-bundle gate for `--app node`: throw when the emitted bundle still
65
+ * references GJS ambient globals (`print`/`imports`/…) although the
66
+ * pre-build detection decided no `@gjsify/node-gi/globals` shim was needed
67
+ * — see `utils/node-bundle-guard.ts` for the full rationale.
68
+ *
69
+ * Unlike the `--app gjs` gate, the oracle here CANNOT be the module graph:
70
+ * an ambient global is a free identifier, not an import, so nothing about it
71
+ * appears in a chunk's `imports` list. It is the same acorn pass the
72
+ * detector runs, re-applied to what was actually written — a post-condition
73
+ * on the prediction, not a second opinion.
74
+ */
75
+ private assertNodeOutputGlobals;
63
76
  /**
64
77
  * Post-bundle step for a `--app browser` HTML entry: write the processed
65
78
  * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
@@ -11,6 +11,7 @@ import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize
11
11
  import { inputSourceDirs, isOutdirInsideSource, libraryOutputLeakError } from '../utils/library-output.js';
12
12
  import { detectHtmlEntry, parseHtmlEntry, emitBrowserHtml, htmlOutPathFor } from '../utils/html-entry.js';
13
13
  import { assertGjsBundleLoadable } from '../utils/gjs-bundle-guard.js';
14
+ import { assertNodeBundleGlobalsShimmed } from '../utils/node-bundle-guard.js';
14
15
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
15
16
  /**
16
17
  * `true` when `path` points at a location that's unsafe to use as a build
@@ -291,6 +292,31 @@ export class BuildAction {
291
292
  const chunks = (result.output ?? []).filter((item) => item.type === 'chunk');
292
293
  assertGjsBundleLoadable(chunks, outfile ?? outdir ?? 'the GJS bundle');
293
294
  }
295
+ /**
296
+ * Post-bundle gate for `--app node`: throw when the emitted bundle still
297
+ * references GJS ambient globals (`print`/`imports`/…) although the
298
+ * pre-build detection decided no `@gjsify/node-gi/globals` shim was needed
299
+ * — see `utils/node-bundle-guard.ts` for the full rationale.
300
+ *
301
+ * Unlike the `--app gjs` gate, the oracle here CANNOT be the module graph:
302
+ * an ambient global is a free identifier, not an import, so nothing about it
303
+ * appears in a chunk's `imports` list. It is the same acorn pass the
304
+ * detector runs, re-applied to what was actually written — a post-condition
305
+ * on the prediction, not a second opinion.
306
+ */
307
+ assertNodeOutputGlobals(result, outfile, outdir) {
308
+ const label = outfile ?? outdir ?? 'the node bundle';
309
+ for (const item of result.output ?? []) {
310
+ if (item.type !== 'chunk')
311
+ continue;
312
+ // `code` is present on a written chunk for both engines; skip
313
+ // defensively rather than fail a build over a missing field.
314
+ const code = item.code;
315
+ if (typeof code !== 'string' || code.length === 0)
316
+ continue;
317
+ assertNodeBundleGlobalsShimmed(code, item.fileName ? `${label} (${item.fileName})` : label);
318
+ }
319
+ }
294
320
  /**
295
321
  * Post-bundle step for a `--app browser` HTML entry: write the processed
296
322
  * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
@@ -530,6 +556,30 @@ export class BuildAction {
530
556
  // detection must run BEFORE the final build so the entry can be
531
557
  // wrapped; nothing is injected when no globals are referenced (the
532
558
  // eager-native-load regression guard).
559
+ //
560
+ // Explicit extras on a node build (`--globals auto,dom,…`) are the
561
+ // reverse bridge's DOM-surface request: inject the SAME register
562
+ // modules the gjs target would for those identifiers, so a genuine
563
+ // GJS source (an Excalibur/WebGLBridge app) gets document /
564
+ // HTMLCanvasElement / matchMedia / … on Node via node-gi. The
565
+ // orchestrator lifts the `@gjsify/empty` register routing only when
566
+ // this inject stub is present (see app/node.ts) — plain `auto`
567
+ // node builds are byte-unchanged.
568
+ //
569
+ // The stub is resolved BEFORE the detection pass, and that ordering
570
+ // is load-bearing: it is one of the two signals `isGjsSourceBuild`
571
+ // reads, so it decides whether `/register` subpaths resolve for real
572
+ // or route to `@gjsify/empty` and whether `@girs/*` bodies survive.
573
+ // Resolving it afterwards made the analysis bundle a DIFFERENT
574
+ // module graph from the one actually emitted — every GJS ambient
575
+ // global reachable only THROUGH a register module was invisible, so
576
+ // the shim was not injected and the emitted bundle threw
577
+ // `ReferenceError: imports is not defined` at runtime (the
578
+ // excalibur-jelly-jumper showcase on node/bun, via
579
+ // `@gjsify/canvas2d-core`'s `_toDataURL` and `@gjsify/utils`).
580
+ if (extras) {
581
+ pluginOpts.autoGlobalsInject = await this.resolveGlobalsInject(app, extras, verbose);
582
+ }
533
583
  const gjsifyPluginFactory = async (opts) => {
534
584
  const cfg = await gjsifyPlugin({
535
585
  input: userBundler.input,
@@ -550,17 +600,6 @@ export class BuildAction {
550
600
  }, pluginOpts, gjsifyPluginFactory, bundleToChunks);
551
601
  if (needsGiGlobals)
552
602
  pluginOpts.nodeGiGlobalsInject = true;
553
- // Explicit extras on a node build (`--globals auto,dom,…`) are the
554
- // reverse bridge's DOM-surface request: inject the SAME register
555
- // modules the gjs target would for those identifiers, so a genuine
556
- // GJS source (an Excalibur/WebGLBridge app) gets document /
557
- // HTMLCanvasElement / matchMedia / … on Node via node-gi. The
558
- // orchestrator lifts the `@gjsify/empty` register routing only when
559
- // this inject stub is present (see app/node.ts) — plain `auto`
560
- // node builds are byte-unchanged.
561
- if (extras) {
562
- pluginOpts.autoGlobalsInject = await this.resolveGlobalsInject(app, extras, verbose);
563
- }
564
603
  }
565
604
  else if (extras) {
566
605
  pluginOpts.autoGlobalsInject = await this.resolveGlobalsInject(app, extras, verbose);
@@ -596,6 +635,13 @@ export class BuildAction {
596
635
  if (app === 'gjs') {
597
636
  this.assertGjsOutputLoadable(writeResult, outfile, outdir);
598
637
  }
638
+ // Refuse to hand back a `--app node` bundle that reaches for GJS ambient
639
+ // globals nothing seeds. Only meaningful when the shim was NOT injected:
640
+ // with it, the globals are installed and the references are correct.
641
+ // `--globals none` opts out of the mechanism, so it opts out of the gate.
642
+ if (app === 'node' && autoMode && !pluginOpts.nodeGiGlobalsInject) {
643
+ this.assertNodeOutputGlobals(writeResult, outfile, outdir);
644
+ }
599
645
  // Browser HTML entry: emit the processed `index.html` beside the bundle
600
646
  // (engine-portable post-step — the native rolldown engine has no
601
647
  // `emitFile`, so this can't live in a plugin). `outfile` is guaranteed
package/lib/cli-app.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // build`, and `gjsify run build:types → gjsify tsc → gjs tsc.gjs.mjs`), so on
11
11
  // CI's few cores the multiplier oversubscribes and the build thrashes. Letting
12
12
  // `gjsify run` dispatch the inner `gjsify <cmd>` in-process collapses two gjs
13
- // into one. See STATUS.md.
13
+ // into one. See status/open-todos.md.
14
14
  //
15
15
  // This module MUST NOT execute anything at load time (no top-level
16
16
  // `parseAsync`) — `run.ts` imports it, and a top-level run would re-dispatch
@@ -28,15 +28,12 @@ import { ensureGjsifyShimOnPath } from './utils/gjsify-shim.js';
28
28
  // GJS's @gjsify/process shim — fake `process.versions.node` for npm
29
29
  // compatibility, so a plain Node probe is a false positive on either.
30
30
  function runtimeLabel() {
31
- try {
32
- const sysVersion = gjsSystemVersion();
33
- if (sysVersion !== undefined) {
34
- const v = Number(sysVersion);
35
- return `GJS ${Math.floor(v / 10000)}.${Math.floor((v % 10000) / 100)}.${v % 100} (SpiderMonkey)`;
36
- }
37
- }
38
- catch {
39
- /* not GJS */
31
+ // Off GJS `gjsSystemVersion()` RETURNS undefined (an optional-chained
32
+ // `globalThis.imports` read, same probe as `isGjs()`) — it never throws.
33
+ const sysVersion = gjsSystemVersion();
34
+ if (sysVersion !== undefined) {
35
+ const v = Number(sysVersion);
36
+ return `GJS ${Math.floor(v / 10000)}.${Math.floor((v % 10000) / 100)}.${v % 100} (SpiderMonkey)`;
40
37
  }
41
38
  if (isBun()) {
42
39
  const version = globalThis.Bun?.version ?? process.versions.bun;
@@ -343,7 +343,16 @@ function runGitDiff(cwd, base, head) {
343
343
  // Surface a clear error — caller (CI) will fall back to full run
344
344
  // via `continue-on-error: true` on the classify step.
345
345
  process.stderr.write(`gjsify affected: git diff failed (${r.status}): ${r.stderr.trim()}\n`);
346
+ // Under GJS `process.exit()` is DEFERRED (no atexit — the call
347
+ // returns), so in this synchronous, value-returning helper a bare
348
+ // exit fell through to the `return` below and handed the caller a
349
+ // diff parsed from a failed git run. `return process.exit(…)` cannot
350
+ // help here either — it would hand the caller the pending exit
351
+ // promise as a `string[]`. The throw is what actually stops the
352
+ // caller; on Node the exit(2) halts first and the throw is dead.
353
+ // oxlint-disable-next-line gjsify/deferred-process-exit -- the throw below IS the halt for the GJS path; see the comment above.
346
354
  process.exit(2);
355
+ throw new Error(`gjsify affected: git diff failed (${r.status})`);
347
356
  }
348
357
  return r.stdout.split('\n').filter(Boolean);
349
358
  }
@@ -409,8 +418,11 @@ function assertShellSafeWorkspaceName(name) {
409
418
  ` \`include-args\` token (the value is consumed by an UNQUOTED $INCLUDE_ARGS expansion — see the\n` +
410
419
  ` contract at the top of commands/affected.ts). Rename the workspace, or teach this command a\n` +
411
420
  ` transport the consumers can unquote. Refusing to emit a filter that would silently mis-match.\n`);
421
+ // NOT unreachable under GJS: `process.exit()` is DEFERRED there (no atexit
422
+ // — the call returns), so the throw below is what actually stops an unsafe
423
+ // name from reaching the emit site; the exit code rides the scheduled
424
+ // teardown. On Node the exit(2) halts first and the throw is dead code.
425
+ // oxlint-disable-next-line gjsify/deferred-process-exit -- the throw below IS the halt for the GJS path; see the comment above.
412
426
  process.exit(2);
413
- // Unreachable — `process.exit` terminates. Present so this function has no
414
- // fall-through path that could ever return an unsafe name.
415
427
  throw new Error(`gjsify affected: unsafe workspace name ${name}`);
416
428
  }
@@ -112,7 +112,10 @@ export const checkCommand = {
112
112
  console.log(`[check] cwd=${cwd} → npm run check`);
113
113
  }
114
114
  const r = spawnSync('npm', ['run', 'check'], { cwd, stdio: 'inherit' });
115
- process.exit(r.status ?? 1);
115
+ // `return` — a bare `process.exit()` is deferred under GJS (no
116
+ // atexit), so execution fell through into workspace mode and
117
+ // re-checked the whole monorepo after the single-package run.
118
+ return process.exit(r.status ?? 1);
116
119
  }
117
120
  // Fall through to workspace mode when the local package has no
118
121
  // check script (cd'd into a non-package dir under the root).
@@ -120,7 +123,9 @@ export const checkCommand = {
120
123
  // ---- Workspace mode ----
121
124
  if (!workspaceRoot) {
122
125
  console.error('gjsify check: no workspace root found from cwd. Run inside a workspace (or a package within one) with `npm run check` defined.');
123
- process.exit(1);
126
+ // `return` — the deferred GJS exit otherwise ran discoverWorkspaces
127
+ // on a null root.
128
+ return process.exit(1);
124
129
  }
125
130
  const allWorkspaces = discoverWorkspaces(workspaceRoot);
126
131
  // Always exclude @girs/* (type-only packages, no own tsc check).
@@ -137,7 +142,7 @@ export const checkCommand = {
137
142
  });
138
143
  if (targets.length === 0) {
139
144
  console.error('gjsify check: no workspaces with a `check` script found.');
140
- process.exit(1);
145
+ return process.exit(1);
141
146
  }
142
147
  if (args.verbose) {
143
148
  console.log(`[check] root=${workspaceRoot} workspaces=${targets.length} parallel=${args.parallel ? 'yes' : 'no'}`);
@@ -145,16 +150,26 @@ export const checkCommand = {
145
150
  // ---- Sequential mode ----
146
151
  if (!args.parallel) {
147
152
  let firstFail = 0;
153
+ let failCount = 0;
148
154
  for (const ws of targets) {
149
155
  if (args.verbose)
150
156
  console.log(`[check] → ${ws.name}`);
151
157
  const code = await runCheck(ws, null);
152
- if (code !== 0 && firstFail === 0)
153
- firstFail = code;
158
+ if (code !== 0) {
159
+ failCount++;
160
+ if (firstFail === 0)
161
+ firstFail = code;
162
+ }
154
163
  }
164
+ // Count failures from the loop above — the previous
165
+ // `targets.filter(async …)` spelling RE-SPAWNED every workspace's
166
+ // check (fire-and-forget) just to print a count, and its async
167
+ // predicate is always truthy, so it counted ALL targets.
155
168
  if (firstFail !== 0)
156
- console.error(`gjsify check: failures in ${targets.filter(async (ws) => (await runCheck(ws, null)) !== 0).length}+ workspaces`);
157
- process.exit(firstFail);
169
+ console.error(`gjsify check: failures in ${failCount} workspace(s)`);
170
+ // `return` — a bare exit is deferred under GJS and fell through
171
+ // into parallel mode, re-running every check.
172
+ return process.exit(firstFail);
158
173
  }
159
174
  // ---- Parallel mode (default) ----
160
175
  const concurrency = args.jobs ?? Math.max(1, cpus().length);
@@ -179,7 +194,9 @@ export const checkCommand = {
179
194
  console.error(`\ngjsify check: ${failures.length} of ${targets.length} workspace(s) failed:`);
180
195
  for (const f of failures)
181
196
  console.error(` ✗ ${f.name} (exit ${f.code})`);
182
- process.exit(1);
197
+ // `return` — the deferred GJS exit otherwise fell through to the
198
+ // success `process.exit(0)` below, clobbering the failure code.
199
+ return process.exit(1);
183
200
  }
184
201
  if (args.verbose)
185
202
  console.log(`\ngjsify check: ${targets.length} workspace(s) green.`);
@@ -37,7 +37,9 @@ export const createCommand = {
37
37
  if (!process.stdin.isTTY) {
38
38
  const list = templates.map((t) => t.name).join(', ');
39
39
  console.error(`Error: --template is required in non-interactive mode. Available templates: ${list || '(none)'}`);
40
- process.exit(1);
40
+ // `return` — the deferred GJS exit otherwise fell through into
41
+ // the interactive template prompt on a non-TTY.
42
+ return process.exit(1);
41
43
  }
42
44
  const picked = await promptTemplate(templates);
43
45
  template = picked.name;
@@ -122,7 +122,9 @@ export const debugCommand = {
122
122
  : undefined);
123
123
  if (!busNameBase) {
124
124
  console.error('gjsify debug: no bus name — set package.json#gjsify.devtools.busNameBase or pass --bus-name <org.example.App>');
125
- process.exit(1);
125
+ // `return` — a bare `process.exit()` is deferred under GJS and the
126
+ // handler would continue without a bus name.
127
+ return process.exit(1);
126
128
  }
127
129
  const globals = args.globals ?? config.globals ?? 'auto';
128
130
  const name = pkg.name ? `${pkg.name}-devtools` : 'gjsify-devtools';
@@ -73,8 +73,7 @@ export const flatpakCheckCommand = {
73
73
  if (args.builderLint !== false) {
74
74
  if (!existsSync(manifestPath)) {
75
75
  console.error(`[gjsify flatpak check] manifest not found: ${manifestPath}`);
76
- process.exit(1);
77
- return;
76
+ return process.exit(1);
78
77
  }
79
78
  const ok = await runLinter('flatpak-builder-lint', ['manifest', manifestPath], args.verbose ?? false);
80
79
  if (!ok)
@@ -88,7 +87,9 @@ export const flatpakCheckCommand = {
88
87
  }
89
88
  if (failures > 0) {
90
89
  console.error(`\n[gjsify flatpak check] ${failures} check(s) failed.`);
91
- process.exit(1);
90
+ // `return` — the deferred GJS exit otherwise fell through and ALSO
91
+ // printed "all checks passed" after reporting failures.
92
+ return process.exit(1);
92
93
  }
93
94
  console.log('[gjsify flatpak check] all checks passed.');
94
95
  },
@@ -87,7 +87,9 @@ export const flatpakDiffCommand = {
87
87
  }
88
88
  if (!remoteTag) {
89
89
  console.warn('[gjsify flatpak diff] flathub manifest has no `tag` field on the inspected source.');
90
- process.exit(1);
90
+ // `return` — the deferred GJS exit otherwise compared an undefined
91
+ // tag below and could report "in sync" on a broken manifest.
92
+ return process.exit(1);
91
93
  }
92
94
  if (remoteTag === localVersion) {
93
95
  console.log(`✅ in sync (${localVersion})`);
@@ -31,13 +31,10 @@ function parseSri(integrity) {
31
31
  return null;
32
32
  const algorithm = integrity.slice(0, dash);
33
33
  const b64 = integrity.slice(dash + 1).replace(/=+$/, '');
34
- let hex;
35
- try {
36
- hex = Buffer.from(b64, 'base64').toString('hex');
37
- }
38
- catch {
39
- return null;
40
- }
34
+ // Base64 decoding is lenient on every runtime (Node skips invalid chars,
35
+ // @gjsify/buffer's table decode maps them to 0): malformed input yields
36
+ // short/garbage output — caught by the length check below — never a throw.
37
+ const hex = Buffer.from(b64, 'base64').toString('hex');
41
38
  if (hex.length < 4)
42
39
  return null;
43
40
  return { algorithm, hex };
@@ -141,7 +141,9 @@ function signalTree(child, descendants, signal) {
141
141
  child.kill(signal);
142
142
  }
143
143
  catch {
144
- // already gone — fine
144
+ // NOT the already-dead case — `ChildProcess.kill()` swallows ESRCH
145
+ // itself and returns false. This guards Node's residual throw paths
146
+ // (EPERM surfacing via an unhandled 'error' emit, unknown signal).
145
147
  }
146
148
  }
147
149
  function killActiveChildren() {
@@ -292,7 +294,9 @@ export const foreachCommand = {
292
294
  }
293
295
  if (!cmd) {
294
296
  console.error('gjsify foreach --exec: missing command. Pass it after `--`, e.g. `gjsify foreach --exec -- npm publish --tag latest`.');
295
- process.exit(1);
297
+ // `return` — a bare `process.exit()` is deferred under GJS and
298
+ // the handler would keep running without a command.
299
+ return process.exit(1);
296
300
  }
297
301
  }
298
302
  assertEveryIncludeMatches(allWorkspaces, args.include);
@@ -309,7 +313,8 @@ export const foreachCommand = {
309
313
  if (!exec) {
310
314
  if (!cmd) {
311
315
  console.error('gjsify foreach: missing <script> positional. Pass --exec to run an arbitrary command instead.');
312
- process.exit(1);
316
+ // `return` — see the deferred-exit note on the --exec guard.
317
+ return process.exit(1);
313
318
  }
314
319
  const scriptName = cmd;
315
320
  selected = selected.filter((ws) => {
@@ -330,13 +335,16 @@ export const foreachCommand = {
330
335
  const m = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(args.shard);
331
336
  if (!m) {
332
337
  console.error(`gjsify foreach: invalid --shard "${args.shard}" — expected "<index>/<total>" (1-based), e.g. 2/4.`);
333
- process.exit(1);
338
+ // `return` — the deferred GJS exit otherwise ran the whole
339
+ // unsharded set.
340
+ return process.exit(1);
334
341
  }
335
342
  const index = Number(m[1]);
336
343
  const total = Number(m[2]);
337
344
  if (total < 1 || index < 1 || index > total) {
338
345
  console.error(`gjsify foreach: invalid --shard "${args.shard}" — need 1 <= index <= total and total >= 1.`);
339
- process.exit(1);
346
+ // `return` — see the deferred-exit note on the shard guard above.
347
+ return process.exit(1);
340
348
  }
341
349
  if (total > 1) {
342
350
  const byName = [...selected].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
@@ -376,7 +384,8 @@ export const foreachCommand = {
376
384
  // default covers whole script chains (CI sets it once for the
377
385
  // build job) where an --exec invocation is simply not cacheable.
378
386
  console.error('gjsify foreach: --cached only applies to script mode, not --exec.');
379
- process.exit(1);
387
+ // `return` — the deferred GJS exit otherwise ran the --exec anyway.
388
+ return process.exit(1);
380
389
  }
381
390
  const cache = cached && !exec
382
391
  ? new BuildCacheRunner({
@@ -409,7 +418,9 @@ export const foreachCommand = {
409
418
  // leave orphans holding the CI step's stdio open.
410
419
  killActiveChildren();
411
420
  console.error(err.message);
412
- process.exit(1);
421
+ // `return` — the deferred GJS exit otherwise fell through to the
422
+ // success `process.exit(0)` below, clobbering the failure code.
423
+ return process.exit(1);
413
424
  }
414
425
  // ensureMainLoop() (called inside spawn) keeps GJS alive after every
415
426
  // child exits — without an explicit process.exit() the success path
@@ -472,7 +483,14 @@ function assertEveryIncludeMatches(allWorkspaces, include) {
472
483
  }
473
484
  lines.push(` ${allWorkspaces.length} workspace(s) discovered. Refusing to run: a filter that selects nothing would`, ` otherwise exit 0 and look exactly like a successful run over the whole set.`);
474
485
  console.error(lines.join('\n'));
486
+ // Under GJS `process.exit()` is DEFERRED (no atexit — the call returns), so
487
+ // a bare exit here RETURNED to the handler, which kept going and could
488
+ // spawn children for the patterns that DID match before the scheduled exit
489
+ // fired. The throw is what actually stops the run on GJS; on Node the
490
+ // exit(1) halts first and the throw is dead code.
491
+ // oxlint-disable-next-line gjsify/deferred-process-exit -- the throw below IS the halt for the GJS path; see the comment above.
475
492
  process.exit(1);
493
+ throw new Error('gjsify foreach: --include matched no workspace');
476
494
  }
477
495
  /** Drop ONE matching pair of surrounding `'` or `"` — the quoting-bug probe. */
478
496
  function stripSurroundingQuotes(s) {
@@ -60,8 +60,7 @@ export const generateInstallerCommand = {
60
60
  const outputPath = resolve(process.cwd(), args.output);
61
61
  if (existsSync(outputPath) && !args.force) {
62
62
  console.error(`${args.output} already exists. Re-run with --force to overwrite.`);
63
- process.exit(1);
64
- return;
63
+ return process.exit(1);
65
64
  }
66
65
  const pkgJsonPath = resolve(process.cwd(), 'package.json');
67
66
  let pkgJson = null;
@@ -76,8 +75,7 @@ export const generateInstallerCommand = {
76
75
  const target = args.target ?? pkgJson?.name;
77
76
  if (!target) {
78
77
  console.error('No target package: pass `gjsify generate-installer <pkg>` or run inside a directory with a package.json.');
79
- process.exit(1);
80
- return;
78
+ return process.exit(1);
81
79
  }
82
80
  const binName = args['bin-name'] ?? pickDefaultBinName(pkgJson, target);
83
81
  const bootstrapUrl = args['bootstrap-url'] ?? DEFAULT_BOOTSTRAP_URL;
@@ -17,7 +17,7 @@
17
17
  // `node_modules/` and symlinks `workspace:*` / `workspace:^` / `workspace:~`
18
18
  // refs to their target source. Conflicting version ranges of the same
19
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.md), but since
20
+ // (a real per-workspace dedup pass is Phase D.8, see status/open-todos.md), but since
21
21
  // ADR 0001 step 3 the resolver surfaces every such conflict loudly —
22
22
  // `[gjsify] warning: version conflict for <pkg> …` names both ranges, the
23
23
  // workspaces that requested them, and the version that actually won.
@@ -133,22 +133,27 @@ export const installCommand = {
133
133
  if (args.packages && args.packages.length > 0) {
134
134
  console.error('gjsify install --immutable does not accept package arguments. ' +
135
135
  'Remove the package names or drop --immutable.');
136
- process.exit(1);
136
+ // `return` — a bare `process.exit()` is deferred under GJS and
137
+ // the install would proceed past a refused flag combination.
138
+ return process.exit(1);
137
139
  }
138
140
  if (args.global) {
139
141
  console.error('gjsify install --immutable is incompatible with --global.');
140
- process.exit(1);
142
+ // `return` — see the deferred-exit note above.
143
+ return process.exit(1);
141
144
  }
142
145
  if (args['refresh-lockfile']) {
143
146
  console.error('gjsify install --immutable is incompatible with --refresh-lockfile ' +
144
147
  '(--immutable forbids rewriting the lockfile). Drop one.');
145
- process.exit(1);
148
+ // `return` — see the deferred-exit note above.
149
+ return process.exit(1);
146
150
  }
147
151
  }
148
152
  if (args.global) {
149
153
  if (!args.packages || args.packages.length === 0) {
150
154
  console.error('gjsify install --global requires at least one <pkg> argument.');
151
- process.exit(1);
155
+ // `return` — see the deferred-exit note above.
156
+ return process.exit(1);
152
157
  }
153
158
  for (const flag of ['save-dev', 'save-peer', 'save-optional']) {
154
159
  if (args[flag]) {
@@ -241,7 +246,10 @@ export const installCommand = {
241
246
  const secs = Math.round(overallTimeoutMs / 100) / 10;
242
247
  console.error(`gjsify install: timed out after ${secs}s — likely a registry slowdown or a wedged extract.\n` +
243
248
  `Re-run, or override with --timeout <ms> (set --timeout 0 to disable the overall budget).`);
244
- process.exit(1);
249
+ // `return` — the deferred GJS exit otherwise fell through into
250
+ // the rethrow below and reported the timeout twice. The
251
+ // `finally` still runs on the way out and clears the timers.
252
+ return process.exit(1);
245
253
  }
246
254
  throw err;
247
255
  }
@@ -536,7 +544,7 @@ function syncLockfileRequested(cwd, specs) {
536
544
  *
537
545
  * Hoisting strategy is intentionally minimal — per-workspace dedup +
538
546
  * nested `node_modules/` for cross-workspace version conflicts are the
539
- * Phase D.8 follow-up tracked in STATUS.md.
547
+ * Phase D.8 follow-up tracked in status/open-todos.md.
540
548
  */
541
549
  async function workspaceInstall(cwd, args, signal) {
542
550
  // Hold the root-prefix lock for the WHOLE workspace flow: bin shims,
@@ -63,7 +63,9 @@ export const loginCommand = {
63
63
  catch (err) {
64
64
  if (err instanceof LoginError) {
65
65
  console.error(err.message);
66
- process.exit(1);
66
+ // `return` — the deferred GJS exit otherwise fell through into
67
+ // the rethrow below and reported the login error twice.
68
+ return process.exit(1);
67
69
  }
68
70
  throw err;
69
71
  }
@@ -116,8 +116,7 @@ export const onboardCommand = {
116
116
  process.stdout.write(`${JSON.stringify({ ok: false, error: 'auth', message: msg })}\n`);
117
117
  else
118
118
  console.error(msg);
119
- process.exit(1);
120
- return;
119
+ return process.exit(1);
121
120
  }
122
121
  // Login may have written a fresh token — reload so the probes/ops use it.
123
122
  npmrc = await loadNpmrc(cwd);
@@ -136,8 +135,7 @@ export const onboardCommand = {
136
135
  process.stdout.write(`${JSON.stringify({ ok: false, error: 'repository', message: msg })}\n`);
137
136
  else
138
137
  console.error(msg);
139
- process.exit(1);
140
- return;
138
+ return process.exit(1);
141
139
  }
142
140
  const workflow = normalizeWorkflowFile(args.workflow);
143
141
  const environment = args.environment;
@@ -155,8 +153,7 @@ export const onboardCommand = {
155
153
  process.stdout.write(`${JSON.stringify({ ok: false, error: 'no-packages', message: msg })}\n`);
156
154
  else
157
155
  console.error(msg);
158
- process.exit(1);
159
- return;
156
+ return process.exit(1);
160
157
  }
161
158
  log(`gjsify onboard: ${selected.length} package(s) | repo=${repository} workflow=${workflow}`);
162
159
  if (dryRun)
@@ -125,7 +125,9 @@ export const publishCommand = {
125
125
  const rawPkg = JSON.parse(readFileSync(rawPkgPath, 'utf-8'));
126
126
  if (typeof rawPkg.name !== 'string') {
127
127
  process.stderr.write(`gjsify publish --check-trusted: ${rawPkgPath} has no \`name\` field\n`);
128
- process.exit(2);
128
+ // `return` — a bare `process.exit()` is deferred under GJS and
129
+ // the check would continue on a nameless manifest.
130
+ return process.exit(2);
129
131
  }
130
132
  if (rawPkg.private === true) {
131
133
  const out = { ok: true, action: 'check-trusted', name: rawPkg.name, skipped: 'private' };
@@ -432,13 +434,11 @@ function reportPublishOutcome(outcome, asJson) {
432
434
  }
433
435
  case 'otp-required': {
434
436
  console.error(`gjsify publish: npm requires a 2FA one-time code — re-run with \`--otp <code>\``);
435
- process.exit(1);
436
- return;
437
+ return process.exit(1);
437
438
  }
438
439
  case 'oidc-failed': {
439
440
  handleOidcFailure(outcome.error, outcome.name, asJson);
440
- process.exit(1);
441
- return;
441
+ return process.exit(1);
442
442
  }
443
443
  case 'oidc-no-token': {
444
444
  const msg = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
@@ -446,8 +446,7 @@ function reportPublishOutcome(outcome, asJson) {
446
446
  if (asJson) {
447
447
  process.stdout.write(`${JSON.stringify({ ok: false, name: outcome.name, version: outcome.version, error: 'oidc-failed-no-token' }, null, 2)}\n`);
448
448
  }
449
- process.exit(1);
450
- return;
449
+ return process.exit(1);
451
450
  }
452
451
  case 'diagnostic': {
453
452
  if (asJson) {
@@ -463,14 +462,12 @@ function reportPublishOutcome(outcome, asJson) {
463
462
  else {
464
463
  process.stderr.write(`${outcome.diag.message}\n`);
465
464
  }
466
- process.exit(1);
467
- return;
465
+ return process.exit(1);
468
466
  }
469
467
  case 'error': {
470
468
  console.error(`gjsify publish: ${outcome.name}@${outcome.version} — ${outcome.status} ${outcome.statusText}`);
471
469
  console.error(outcome.text);
472
- process.exit(1);
473
- return;
470
+ return process.exit(1);
474
471
  }
475
472
  }
476
473
  }
@@ -65,15 +65,13 @@ export const selfUpdateCommand = {
65
65
  catch (err) {
66
66
  const msg = err instanceof Error ? err.message : String(err);
67
67
  console.error(`Failed to fetch packument: ${msg}`);
68
- process.exit(1);
69
- return;
68
+ return process.exit(1);
70
69
  }
71
70
  const target = resolveTag(packument, args.tag);
72
71
  if (!target) {
73
72
  console.error(`Unknown dist-tag '${args.tag}' on ${PACKAGE_NAME}. ` +
74
73
  `Known tags: ${Object.keys(packument['dist-tags'] ?? {}).join(', ') || '(none)'}`);
75
- process.exit(1);
76
- return;
74
+ return process.exit(1);
77
75
  }
78
76
  console.log(`Latest matching --tag ${args.tag}: v${target}`);
79
77
  // A version match is NOT sufficient to declare "up to date": a global
@@ -103,8 +101,7 @@ export const selfUpdateCommand = {
103
101
  }
104
102
  if (args.check) {
105
103
  console.log(currentVersion ? `Update available: v${currentVersion} → v${target}` : `Install required: → v${target}`);
106
- process.exit(1);
107
- return;
104
+ return process.exit(1);
108
105
  }
109
106
  // `@gjsify/cli` ships two runtimes from one package: the Node `bin`
110
107
  // (`lib/index.js`, which imports its deps from `node_modules`) and the
@@ -141,8 +138,7 @@ export const selfUpdateCommand = {
141
138
  const msg = err instanceof Error ? err.message : String(err);
142
139
  console.error(`self-update: install failed — ${msg}`);
143
140
  console.error(`Re-run \`gjsify self-update\` to retry.`);
144
- process.exit(1);
145
- return;
141
+ return process.exit(1);
146
142
  }
147
143
  // Lay down (or repair) the GJS-native engine bridges in lockstep with
148
144
  // the bundle — same set + best-effort handling as the global installer.