@gjsify/cli 0.25.1 → 0.26.1

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 (41) hide show
  1. package/dist/cli.gjs.mjs +171 -171
  2. package/lib/cli-app.js +7 -10
  3. package/lib/commands/affected.js +160 -17
  4. package/lib/commands/check.js +25 -8
  5. package/lib/commands/create.js +3 -1
  6. package/lib/commands/debug.js +3 -1
  7. package/lib/commands/flatpak/check.js +4 -3
  8. package/lib/commands/flatpak/diff.js +3 -1
  9. package/lib/commands/flatpak/sources.js +4 -7
  10. package/lib/commands/foreach.js +25 -7
  11. package/lib/commands/generate-installer.js +2 -4
  12. package/lib/commands/install.js +15 -7
  13. package/lib/commands/login.js +3 -1
  14. package/lib/commands/onboard.js +3 -6
  15. package/lib/commands/publish.js +8 -11
  16. package/lib/commands/self-update.js +4 -8
  17. package/lib/commands/showcase.d.ts +16 -0
  18. package/lib/commands/showcase.js +44 -24
  19. package/lib/commands/storybook.js +16 -2
  20. package/lib/commands/system-check.js +1 -2
  21. package/lib/commands/test.js +6 -2
  22. package/lib/commands/trust.js +12 -7
  23. package/lib/commands/tsc.js +3 -1
  24. package/lib/commands/uninstall.js +1 -2
  25. package/lib/commands/whoami.js +6 -3
  26. package/lib/utils/build-cache.js +7 -2
  27. package/lib/utils/dir-link.d.ts +58 -0
  28. package/lib/utils/dir-link.js +126 -0
  29. package/lib/utils/dlx-cache.d.ts +30 -3
  30. package/lib/utils/dlx-cache.js +63 -10
  31. package/lib/utils/install-backend-native.js +1 -1
  32. package/lib/utils/install-lock.js +5 -7
  33. package/lib/utils/install-packument-cache.d.ts +23 -0
  34. package/lib/utils/install-packument-cache.js +45 -10
  35. package/lib/utils/install-tarball-cache.js +5 -8
  36. package/lib/utils/oxc-resolve.js +1 -1
  37. package/lib/utils/prompt.js +6 -1
  38. package/lib/utils/runtimes.d.ts +18 -0
  39. package/lib/utils/runtimes.js +20 -0
  40. package/lib/utils/stdin.js +6 -7
  41. package/package.json +17 -17
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;
@@ -7,15 +7,30 @@
7
7
  // up running the touched workspace + its downstream consumers instead of
8
8
  // the whole monorepo.
9
9
  //
10
+ // EVERY VERDICT THIS FILE EMITS IS SCOPED TO ONE WORKFLOW: `main.yml`. That
11
+ // scoping is the single most misread thing here, so it is stated before the
12
+ // tables rather than after. The classifier is booted only by `main.yml`'s
13
+ // `changes` job, and `main.yml` is the only workflow whose jobs consume its
14
+ // outputs. So "ignored" NEVER means "irrelevant to the repository" — it means
15
+ // "cannot change what `main.yml` builds or tests". Sibling workflows
16
+ // (`prebuilds.yml`, `audit-runtimes.yml`, `node-gi.yml`, `napi.yml`,
17
+ // `deploy-docs.yml`, …) gate themselves with their OWN `paths:` filters and are
18
+ // unaffected by anything decided here. `IGNORE` has always worked this way —
19
+ // `packages/node-gi/**` and `packages/napi/**` are load-bearing build inputs to
20
+ // their own workflows and ignored here — but the table read as a global claim,
21
+ // which is how a directory that is genuinely irrelevant to `main.yml` still
22
+ // looks like something that must not be ignored.
23
+ //
10
24
  // Classifier table (first-match wins) handles the cases that aren't a
11
25
  // straightforward "file lives in workspace X":
12
26
  //
13
- // 1. IGNORE — pure-docs / *.md / website / refs/ submodule / unrelated
14
- // workflow files / LICENSE / .gitignore. Discarded FIRST, before every
15
- // other rule: an ignored file is never build-relevant, not even inside
16
- // a GLOBAL_TRIGGERS directory (e.g. `packages/infra/cli/README.md` must
17
- // not force a full run just because `packages/infra/cli/` is a global
18
- // trigger). Ignore wins over global.
27
+ // 1. IGNORE — cannot change what `main.yml` builds or tests: pure-docs /
28
+ // *.md / website / refs/ submodule / other workflows' files and their
29
+ // inputs / LICENSE / .gitignore. Discarded FIRST, before every other
30
+ // rule: an ignored file is never build-relevant to this workflow, not
31
+ // even inside a GLOBAL_TRIGGERS directory (e.g.
32
+ // `packages/infra/cli/README.md` must not force a full run just because
33
+ // `packages/infra/cli/` is a global trigger). Ignore wins over global.
19
34
  //
20
35
  // 2. GLOBAL_TRIGGERS — a non-ignored change touching infra the classifier
21
36
  // itself depends on (`@gjsify/workspace`, `@gjsify/cli`, the bundler
@@ -24,12 +39,17 @@
24
39
  // can't trust the closure when the algorithm that computes it just
25
40
  // changed.
26
41
  //
27
- // 3. TEST_ONLY — every changed file under ONE workspace is a spec
42
+ // 3. SCRIPT_COUPLINGS — a build input that reaches its consumer through a
43
+ // BUILD SCRIPT instead of a dependency, so the closure walk has no edge
44
+ // to follow. Contributes EXTRA seeds (and may force a test tier). See
45
+ // the table for why this cannot be inferred and must be declared.
46
+ //
47
+ // 4. TEST_ONLY — every changed file under ONE workspace is a spec
28
48
  // file, e2e fixture, or integration test. Seed = that workspace
29
49
  // but SKIP the closure expansion (downstream consumers don't care
30
50
  // about test code changes).
31
51
  //
32
- // 4. CODE (default) — `workspacesForChangedFiles` maps file → ws,
52
+ // 5. CODE (default) — `workspacesForChangedFiles` maps file → ws,
33
53
  // then `affectedClosure` walks reverse-dep edges.
34
54
  //
35
55
  // Integration tests are gated separately: they run when any workspace
@@ -162,13 +182,55 @@ const GLOBAL_TRIGGERS = [
162
182
  /^\.github\/workflows\/main\.yml$/,
163
183
  /^scripts\/audit-runtimes\.mjs$/,
164
184
  ];
185
+ /**
186
+ * Inputs OWNED BY ANOTHER WORKFLOW — build-relevant, just not to `main.yml`.
187
+ *
188
+ * This is the sub-table where the workflow scoping stated at the top of the
189
+ * file is load-bearing, so each entry NAMES the workflow that does gate it.
190
+ * The test for membership is not "is this file unimportant" (none of these
191
+ * are) but "can `main.yml` build or test differently because of it" — and for
192
+ * every entry the answer is no, while the owning workflow has the path in its
193
+ * own `paths:` filter and would go red on a break.
194
+ *
195
+ * Adding a regex here is therefore a claim with two halves, and BOTH must
196
+ * hold: `main.yml` must not read the path, and some other workflow must.
197
+ */
198
+ const OTHER_WORKFLOW_INPUTS = [
199
+ // Each sibling workflow's own definition. `main.yml` is deliberately
200
+ // absent — it is a GLOBAL_TRIGGER, since a job-shape change there is
201
+ // invisible until the workflow re-runs.
202
+ /^\.github\/workflows\/(deploy-docs|commitlint|release|release-cut|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image)\.yml$/,
203
+ // `prebuilds.yml`'s toolchain (#838): the QEMU-emulated build script and
204
+ // the `changed-packages` classifier that decides which native packages a
205
+ // prebuild run rebuilds. `prebuilds.yml` lists `.github/prebuild-toolchain/**`
206
+ // in BOTH its `push` and `pull_request` `paths:` filters, so a break here
207
+ // reds that workflow on the same PR. `main.yml` never executes any of it —
208
+ // it builds no prebuild and runs no emulated leg.
209
+ /^\.github\/prebuild-toolchain\//,
210
+ // The REPO-SCOPED manifest-conformance rules (#847) — `tier`, `refs-pin`,
211
+ // `platforms-ci` plus the `unchecked-fields` ledger. Their gate is
212
+ // `audit-runtimes.yml`, which runs `node scripts/audit-runtimes.mjs
213
+ // --check --strict` on EVERY pull_request with no `paths:` filter at all,
214
+ // so this content is verified on every PR regardless of what is ignored
215
+ // here; `prebuilds.yml` additionally declares it a shared input (#843) and
216
+ // rebuilds every native package when it changes.
217
+ //
218
+ // The distinction that makes this safe is a real one, not a technicality:
219
+ // `main.yml` DOES run manifest-conformance code, but it reaches it through
220
+ // `scripts/verify-package-outputs.mjs` importing
221
+ // `packages/infra/manifest-conformance/lib/**` — the PACKAGE, a normal
222
+ // workspace under the `packages/infra/*` glob that maps and seeds like any
223
+ // other. Only the `scripts/`-side repo-scoped half is ignored, and that
224
+ // half is precisely the half `main.yml` does not load.
225
+ /^scripts\/manifest-conformance\//,
226
+ ];
165
227
  /** Patterns that contribute no seed and don't force a full run. */
166
228
  const IGNORE = [
167
229
  /\.md$/i,
168
230
  /^refs\//,
169
231
  /^website\//,
170
232
  /^docs\//,
171
- /^\.github\/workflows\/(deploy-docs|commitlint|release|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image)\.yml$/,
233
+ ...OTHER_WORKFLOW_INPUTS,
172
234
  /^\.githooks\//,
173
235
  // @gjsify/node-gi (the Node-native GObject-Introspection engine, Axis 5) is
174
236
  // NOT a gjsify workspace member — the GJS-first install/foreach tooling can't
@@ -200,6 +262,31 @@ const IGNORE = [
200
262
  ];
201
263
  /** Patterns that suggest a test-only change. */
202
264
  const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
265
+ const SCRIPT_COUPLINGS = [
266
+ {
267
+ // `templates/*` ARE workspaces (root `package.json#workspaces` carries
268
+ // a `templates/*` glob), so a template change already seeds its own
269
+ // `@gjsify/template-<name>`. That is not the consumer that matters.
270
+ //
271
+ // The consumer is `@gjsify/create-app`, and it is unreachable twice
272
+ // over: it lives at `packages/infra/create-gjsify/`, so no
273
+ // directory-name search finds it, and it takes the templates in
274
+ // through `node scripts/process-template.mjs` in its `build` script
275
+ // (which resolves each template's `workspace:^` specifiers and copies
276
+ // the result into `dist-templates/`), so there is no dependency edge
277
+ // either. `dist-templates/` is a build-output cache candidate, so once
278
+ // it is stale it stays stale.
279
+ match: /^templates\//,
280
+ seeds: ['@gjsify/create-app'],
281
+ // …and `tests/e2e/create-app` is the ONLY thing that would notice: it
282
+ // scaffolds a project out of `dist-templates/` and builds it. Without
283
+ // this a templates-only PR got neither the regenerated templates nor
284
+ // the suite that exercises them. PR #853 passed only by accident of
285
+ // also touching root `package.json` and tripping a global trigger.
286
+ runE2E: true,
287
+ why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
288
+ },
289
+ ];
203
290
  function classifyAndExpand(workspaces, changedFiles) {
204
291
  const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
205
292
  if (files.length === 0) {
@@ -250,23 +337,64 @@ function classifyAndExpand(workspaces, changedFiles) {
250
337
  }
251
338
  // Map files → workspaces. Files outside any workspace stay in `unmatched`.
252
339
  const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
340
+ // SCRIPT_COUPLINGS: add the seeds no graph edge can reach. Applied BEFORE
341
+ // the `unmatched` bail-out so the table works for a coupled directory that
342
+ // is NOT itself a workspace — such a file would otherwise force a full run
343
+ // and the declared seeds would never be consulted.
344
+ const knownNames = new Set(workspaces.map((w) => w.name));
345
+ const couplingSeeds = new Set();
346
+ const couplingAccounted = new Set();
347
+ let couplingRunE2E = false;
348
+ for (const c of SCRIPT_COUPLINGS) {
349
+ const hits = remaining.filter((f) => c.match.test(f));
350
+ if (hits.length === 0)
351
+ continue;
352
+ for (const h of hits)
353
+ couplingAccounted.add(h);
354
+ for (const s of c.seeds) {
355
+ // A declared seed that is not a workspace means the table has
356
+ // drifted from the tree (package renamed, moved, removed). Fail
357
+ // towards the full run and SAY SO — the whole point of this table
358
+ // is that a missing rebuild here is otherwise invisible, so it must
359
+ // never degrade quietly back into the bug it exists to prevent.
360
+ if (!knownNames.has(s)) {
361
+ return {
362
+ global: true,
363
+ reason: `script-coupling seed ${s} is not a workspace (${c.match.source} → ${c.why}); ` +
364
+ `SCRIPT_COUPLINGS in commands/affected.ts is stale`,
365
+ workspaces: workspaces.map((w) => w.name),
366
+ runE2E: true,
367
+ runIntegration: true,
368
+ skipAll: false,
369
+ };
370
+ }
371
+ couplingSeeds.add(s);
372
+ }
373
+ if (c.runE2E)
374
+ couplingRunE2E = true;
375
+ }
253
376
  // Unmatched-but-not-ignored files are suspicious enough to fall back to
254
377
  // the conservative "full run" path. Examples: a new top-level dotfile,
255
378
  // a script in `scripts/` we haven't carved out, a refs/-adjacent file.
256
- if (unmatched.length > 0) {
379
+ // A file a coupling claimed is accounted for and does not count here.
380
+ const stillUnmatched = unmatched.filter((f) => !couplingAccounted.has(f));
381
+ if (stillUnmatched.length > 0) {
257
382
  return {
258
383
  global: true,
259
- reason: `unmatched files (${unmatched.length}): ${unmatched.slice(0, 3).join(', ')}${unmatched.length > 3 ? '…' : ''}`,
384
+ reason: `unmatched files (${stillUnmatched.length}): ${stillUnmatched.slice(0, 3).join(', ')}${stillUnmatched.length > 3 ? '…' : ''}`,
260
385
  workspaces: workspaces.map((w) => w.name),
261
386
  runE2E: true,
262
387
  runIntegration: true,
263
388
  skipAll: false,
264
389
  };
265
390
  }
391
+ for (const s of couplingSeeds)
392
+ matched.add(s);
266
393
  // TEST_ONLY shortcut: every remaining file is a spec / e2e / integration
267
394
  // path, all under ONE workspace. Skip the closure expansion — test code
268
- // has no downstream consumers.
269
- const testOnly = remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
395
+ // has no downstream consumers. A coupling deliberately disables it: the
396
+ // extra seeds exist precisely because a downstream consumer DOES care.
397
+ const testOnly = couplingSeeds.size === 0 && remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
270
398
  if (testOnly && matched.size === 1) {
271
399
  const only = [...matched][0];
272
400
  // E2E or integration test-only changes still need their own job.
@@ -317,10 +445,13 @@ function classifyAndExpand(workspaces, changedFiles) {
317
445
  }
318
446
  }
319
447
  }
320
- const runE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
448
+ // A `tests/e2e/**` touch turns the tier on, and so does any coupling that
449
+ // declares e2e as its only real coverage (see SCRIPT_COUPLINGS).
450
+ const runE2E = remaining.some((f) => f.startsWith('tests/e2e/')) || couplingRunE2E;
321
451
  return {
322
452
  global: false,
323
- reason: `closure (${closure.size} ws from ${matched.size} seed(s))`,
453
+ reason: `closure (${closure.size} ws from ${matched.size} seed(s))` +
454
+ (couplingSeeds.size > 0 ? `, ${couplingSeeds.size} via script-coupling` : ''),
324
455
  workspaces: [...closure].sort(),
325
456
  runE2E,
326
457
  runIntegration,
@@ -343,7 +474,16 @@ function runGitDiff(cwd, base, head) {
343
474
  // Surface a clear error — caller (CI) will fall back to full run
344
475
  // via `continue-on-error: true` on the classify step.
345
476
  process.stderr.write(`gjsify affected: git diff failed (${r.status}): ${r.stderr.trim()}\n`);
477
+ // Under GJS `process.exit()` is DEFERRED (no atexit — the call
478
+ // returns), so in this synchronous, value-returning helper a bare
479
+ // exit fell through to the `return` below and handed the caller a
480
+ // diff parsed from a failed git run. `return process.exit(…)` cannot
481
+ // help here either — it would hand the caller the pending exit
482
+ // promise as a `string[]`. The throw is what actually stops the
483
+ // caller; on Node the exit(2) halts first and the throw is dead.
484
+ // oxlint-disable-next-line gjsify/deferred-process-exit -- the throw below IS the halt for the GJS path; see the comment above.
346
485
  process.exit(2);
486
+ throw new Error(`gjsify affected: git diff failed (${r.status})`);
347
487
  }
348
488
  return r.stdout.split('\n').filter(Boolean);
349
489
  }
@@ -409,8 +549,11 @@ function assertShellSafeWorkspaceName(name) {
409
549
  ` \`include-args\` token (the value is consumed by an UNQUOTED $INCLUDE_ARGS expansion — see the\n` +
410
550
  ` contract at the top of commands/affected.ts). Rename the workspace, or teach this command a\n` +
411
551
  ` transport the consumers can unquote. Refusing to emit a filter that would silently mis-match.\n`);
552
+ // NOT unreachable under GJS: `process.exit()` is DEFERRED there (no atexit
553
+ // — the call returns), so the throw below is what actually stops an unsafe
554
+ // name from reaching the emit site; the exit code rides the scheduled
555
+ // teardown. On Node the exit(2) halts first and the throw is dead code.
556
+ // oxlint-disable-next-line gjsify/deferred-process-exit -- the throw below IS the halt for the GJS path; see the comment above.
412
557
  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
558
  throw new Error(`gjsify affected: unsafe workspace name ${name}`);
416
559
  }
@@ -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
  }