@gjsify/cli 0.4.44 → 0.5.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.
@@ -9,9 +9,166 @@
9
9
  // matching yarn's interactive flow. Exit code is non-zero if any child
10
10
  // process failed; first failure's stderr is forwarded.
11
11
  import { spawn } from 'node:child_process';
12
+ import { readFileSync, readdirSync } from 'node:fs';
12
13
  import { cpus } from 'node:os';
13
14
  import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
14
15
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
16
+ import { prefixLines } from '../utils/prefixed-output.js';
17
+ // Every child spawned by spawnPrefixed registers here so fail-fast can
18
+ // terminate the whole in-flight set instead of waiting on it. On CI a
19
+ // single failed package used to leave foreach awaiting its still-running
20
+ // siblings — whose nested gjs chains can stall for hours (issue #497).
21
+ const activeChildren = new Set();
22
+ // Grace window between SIGTERM and the SIGKILL escalation.
23
+ const KILL_GRACE_MS = 5_000;
24
+ // Hard deadline for the in-flight set to drain after a fail-fast kill. A
25
+ // killed child whose grandchildren inherit (and keep open) the stdio pipe
26
+ // never emits 'close', so we must not wait on it unboundedly.
27
+ const DRAIN_DEADLINE_MS = 15_000;
28
+ // Read the direct child PIDs of `pid` from /proc/<pid>/task/*/children
29
+ // (Linux, CONFIG_PROC_CHILDREN — standard on every mainstream kernel). When
30
+ // the per-task children files are unavailable, fall back to one full
31
+ // /proc/*/stat scan building a ppid→children map. Both paths use only
32
+ // `node:fs` reads, so they behave identically under Node and GJS
33
+ // (@gjsify/fs) — unlike a process-group kill, which GJS cannot issue
34
+ // (`@gjsify/process.kill` shells out `kill <sig> <pid>` where a negative
35
+ // PID parses as an option, and Gio.Subprocess has no group-signal API).
36
+ function readDirectChildren(pid) {
37
+ let taskIds;
38
+ try {
39
+ taskIds = readdirSync(`/proc/${pid}/task`);
40
+ }
41
+ catch {
42
+ // Process already gone (common) or /proc unavailable.
43
+ return null;
44
+ }
45
+ const kids = [];
46
+ let readAny = false;
47
+ for (const tid of taskIds) {
48
+ try {
49
+ const data = readFileSync(`/proc/${pid}/task/${tid}/children`, 'utf-8');
50
+ readAny = true;
51
+ for (const tok of data.trim().split(/\s+/)) {
52
+ const n = Number(tok);
53
+ if (Number.isInteger(n) && n > 0)
54
+ kids.push(n);
55
+ }
56
+ }
57
+ catch {
58
+ // Thread vanished mid-walk, or kernel lacks CONFIG_PROC_CHILDREN.
59
+ }
60
+ }
61
+ return readAny ? kids : null;
62
+ }
63
+ // Fallback: scan every /proc/<pid>/stat once and build ppid → children.
64
+ // O(#processes) small reads — fine for the rare kill path.
65
+ function buildPpidMap() {
66
+ const map = new Map();
67
+ let entries = [];
68
+ try {
69
+ entries = readdirSync('/proc');
70
+ }
71
+ catch {
72
+ return map;
73
+ }
74
+ for (const entry of entries) {
75
+ const pid = Number(entry);
76
+ if (!Number.isInteger(pid) || pid <= 0)
77
+ continue;
78
+ try {
79
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
80
+ // comm (field 2) may contain spaces/parens — parse after last ')'.
81
+ const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
82
+ const ppid = Number(rest[1]);
83
+ if (!Number.isInteger(ppid) || ppid <= 0)
84
+ continue;
85
+ const siblings = map.get(ppid);
86
+ if (siblings)
87
+ siblings.push(pid);
88
+ else
89
+ map.set(ppid, [pid]);
90
+ }
91
+ catch {
92
+ // Process exited between readdir and read — fine.
93
+ }
94
+ }
95
+ return map;
96
+ }
97
+ // Snapshot every transitive descendant of `pid`, breadth-first (so
98
+ // reversing the list yields deepest-first order for signalling).
99
+ function collectDescendants(pid) {
100
+ const result = [];
101
+ const seen = new Set([pid]);
102
+ const queue = [pid];
103
+ let ppidMap = null;
104
+ while (queue.length > 0) {
105
+ const cur = queue.shift();
106
+ let kids = readDirectChildren(cur);
107
+ if (kids === null) {
108
+ // children files unreadable — use (and lazily build) the
109
+ // full-scan fallback for this and subsequent levels.
110
+ ppidMap ??= buildPpidMap();
111
+ kids = ppidMap.get(cur) ?? [];
112
+ }
113
+ for (const kid of kids) {
114
+ if (seen.has(kid))
115
+ continue;
116
+ seen.add(kid);
117
+ result.push(kid);
118
+ queue.push(kid);
119
+ }
120
+ }
121
+ return result;
122
+ }
123
+ // Signal an already-collected descendant set deepest-first, then the direct
124
+ // child itself. Snapshot-then-signal (not signal-while-walking) so killing
125
+ // the parent can't orphan grandchildren before we enumerated them. The
126
+ // snapshot has the usual tree-kill PID-reuse race (a descendant exits and
127
+ // the kernel recycles its PID before our signal lands) — accepted, same as
128
+ // Node's tree-kill packages; the window is milliseconds.
129
+ function signalTree(child, descendants, signal) {
130
+ for (let i = descendants.length - 1; i >= 0; i--) {
131
+ try {
132
+ process.kill(descendants[i], signal);
133
+ }
134
+ catch {
135
+ // already gone — fine
136
+ }
137
+ }
138
+ try {
139
+ child.kill(signal);
140
+ }
141
+ catch {
142
+ // already gone — fine
143
+ }
144
+ }
145
+ function killActiveChildren() {
146
+ // The direct child is `npm run <script>` — killing only it leaves the
147
+ // npm-spawned shell → gjs/node build grandchildren alive (observed: an
148
+ // orphaned gjs bundler at 100% CPU for 19+ min after a fail-fast kill).
149
+ // So terminate the whole process TREE of each child via a /proc walk.
150
+ const termSnapshots = new Map();
151
+ for (const child of activeChildren) {
152
+ const descendants = child.pid ? collectDescendants(child.pid) : [];
153
+ termSnapshots.set(child, descendants);
154
+ signalTree(child, descendants, 'SIGTERM');
155
+ }
156
+ const escalate = setTimeout(() => {
157
+ for (const child of activeChildren) {
158
+ // Union a FRESH walk (catches processes spawned after the TERM)
159
+ // with the TERM-time snapshot (catches survivors that were
160
+ // reparented to init when their parent died on SIGTERM — a
161
+ // fresh walk from child.pid can no longer see those).
162
+ const union = new Set(child.pid ? collectDescendants(child.pid) : []);
163
+ for (const pid of termSnapshots.get(child) ?? [])
164
+ union.add(pid);
165
+ signalTree(child, [...union], 'SIGKILL');
166
+ }
167
+ }, KILL_GRACE_MS);
168
+ // Don't let the escalation timer keep the process alive (Node returns a
169
+ // Timeout with unref(); GJS returns a plain handle without it).
170
+ escalate.unref?.();
171
+ }
15
172
  export const foreachCommand = {
16
173
  command: 'foreach [script] [args..]',
17
174
  description: 'Run a workspace script across all (or filtered) workspaces. Drop-in for `yarn workspaces foreach`: -A/--all, -p/--parallel, -t/--topological, --include, --exclude, --no-private. Pass --exec to run an arbitrary command instead of a script.',
@@ -101,6 +258,14 @@ export const foreachCommand = {
101
258
  // --tag/--access/etc. as its own options.
102
259
  let cmd = args.script;
103
260
  let cmdArgs = args.args ?? [];
261
+ if (!exec) {
262
+ // Script mode silently DROPPED everything after `--` (only --exec
263
+ // consumed args['--']). Forward them to each child invocation,
264
+ // matching `yarn workspaces foreach run <script> -- <flags>`.
265
+ const fromDoubleDash = (args['--'] ?? []).filter((v) => typeof v === 'string');
266
+ if (fromDoubleDash.length > 0)
267
+ cmdArgs = [...cmdArgs, ...fromDoubleDash];
268
+ }
104
269
  if (exec) {
105
270
  // With populate--:true, anything after the literal `--`
106
271
  // separator lands in top-level args['--']. yargs DOES NOT
@@ -172,6 +337,10 @@ export const foreachCommand = {
172
337
  }
173
338
  }
174
339
  catch (err) {
340
+ // Plain --parallel rejects on the FIRST failure (Promise.all) while
341
+ // sibling children are still running — terminate them so we don't
342
+ // leave orphans holding the CI step's stdio open.
343
+ killActiveChildren();
175
344
  console.error(err.message);
176
345
  process.exit(1);
177
346
  }
@@ -220,10 +389,57 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
220
389
  remaining.set(ws.name, wsDeps);
221
390
  }
222
391
  const byName = new Map(workspaces.map((w) => [w.name, w]));
392
+ const total = workspaces.length;
223
393
  const done = new Set();
224
394
  let inflight = 0;
395
+ // Stall observability + self-protection. With prefixed output BUFFERED on
396
+ // non-tty sinks (flush-on-close), a CI log shows only COMPLETED children —
397
+ // when the run stalls, nothing identifies who is stuck (issue #497's
398
+ // 3h45-silence-to-the-cap pattern). So on non-tty (or --verbose) every
399
+ // start is echoed live on foreach's own stderr, a watchdog WARNS once no
400
+ // workspace has completed for a while, and after a hard stall budget the
401
+ // run aborts with the in-flight set named instead of riding the CI job cap.
402
+ const liveProgress = !process.stdout.isTTY || verbose;
403
+ const inflightStarts = new Map();
404
+ const STALL_WARN_MS = 5 * 60_000;
405
+ const stallAbortMinutes = Number(process.env['GJSIFY_FOREACH_STALL_MINUTES'] ?? '20');
406
+ const STALL_ABORT_MS = (Number.isFinite(stallAbortMinutes) && stallAbortMinutes > 0 ? stallAbortMinutes : 20) * 60_000;
225
407
  return new Promise((resolve, reject) => {
226
408
  let error = null;
409
+ let lastActivity = Date.now();
410
+ const describeInflight = () => [...inflightStarts.entries()]
411
+ .map(([n, t]) => `${n} (${Math.round((Date.now() - t) / 1000)}s)`)
412
+ .join(', ');
413
+ const failFast = (e) => {
414
+ // First error wins. KILL the in-flight siblings instead of
415
+ // waiting for them (yarn waits — but a sibling's nested build
416
+ // chain can stall for hours on CI, see issue #497), and bound
417
+ // the drain with a hard deadline in case a killed child's pipe
418
+ // never closes.
419
+ if (error)
420
+ return;
421
+ error = e;
422
+ killActiveChildren();
423
+ const deadline = setTimeout(() => settle(), DRAIN_DEADLINE_MS);
424
+ deadline.unref?.();
425
+ };
426
+ const watchdog = setInterval(() => {
427
+ const idle = Date.now() - lastActivity;
428
+ if (idle < STALL_WARN_MS || inflightStarts.size === 0)
429
+ return;
430
+ console.error(`[gjsify foreach] WARNING: no workspace completed for ${Math.round(idle / 60_000)}min — in-flight: ${describeInflight()}`);
431
+ if (idle >= STALL_ABORT_MS) {
432
+ failFast(new Error(`gjsify foreach: stalled — no workspace completed for ${Math.round(idle / 60_000)}min (override via GJSIFY_FOREACH_STALL_MINUTES); killed in-flight: ${describeInflight()}`));
433
+ }
434
+ }, 60_000);
435
+ watchdog.unref?.();
436
+ const settle = () => {
437
+ clearInterval(watchdog);
438
+ if (error)
439
+ reject(error);
440
+ else
441
+ resolve();
442
+ };
227
443
  const pump = () => {
228
444
  if (error)
229
445
  return;
@@ -236,26 +452,44 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
236
452
  const next = ready.sort()[0];
237
453
  remaining.delete(next);
238
454
  inflight++;
455
+ inflightStarts.set(next, Date.now());
456
+ if (liveProgress) {
457
+ console.error(`[gjsify foreach] start ${next} (${done.size}/${total} done, ${inflight} in flight)`);
458
+ }
239
459
  runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose, exec)
240
460
  .then(() => {
241
- inflight--;
242
461
  done.add(next);
243
- if (remaining.size === 0 && inflight === 0) {
244
- resolve();
245
- return;
246
- }
247
- pump();
248
462
  })
249
463
  .catch((e) => {
250
- error = e instanceof Error ? e : new Error(String(e));
251
- // Wait for in-flight tasks to finish (yarn does the
252
- // same — surfaces all errors instead of abruptly
253
- // killing siblings).
254
- if (inflight === 0)
255
- reject(error);
464
+ failFast(e instanceof Error ? e : new Error(String(e)));
465
+ })
466
+ .finally(() => {
467
+ // Release the slot on BOTH success AND failure. The old
468
+ // code decremented `inflight` only in `.then`, so a
469
+ // FAILED task left `inflight` stuck above 0 → the
470
+ // `inflight === 0` guard never held → the promise never
471
+ // settled → hang. Under Node the empty event loop bails
472
+ // it out (exiting 0, silently masking the failure);
473
+ // under GJS `ensureMainLoop()` keeps the process alive
474
+ // forever (the 6-hour CI timeout). Settling here fixes
475
+ // both: fail-fast with the real error, on either runtime.
476
+ inflight--;
477
+ inflightStarts.delete(next);
478
+ lastActivity = Date.now();
479
+ if (error) {
480
+ if (inflight === 0)
481
+ settle();
482
+ }
483
+ else if (remaining.size === 0 && inflight === 0) {
484
+ settle();
485
+ }
486
+ else {
487
+ pump();
488
+ }
256
489
  });
257
490
  }
258
491
  if (remaining.size > 0 && inflight === 0 && !error) {
492
+ clearInterval(watchdog);
259
493
  reject(new Error(`gjsify foreach --topological: stuck — workspaces ${[...remaining.keys()].join(', ')} have unsatisfied deps in the selected set`));
260
494
  }
261
495
  };
@@ -307,32 +541,36 @@ function spawnPrefixed(cmd, args, cwd, prefix) {
307
541
  stdio: prefix ? ['ignore', 'pipe', 'pipe'] : 'inherit',
308
542
  env: { ...process.env, ...colorEnv },
309
543
  });
544
+ activeChildren.add(child);
545
+ // Under GJS, `process.stdout.write` is a BLOCKING `Gio.write_all`.
546
+ // Writing each prefixed line LIVE during a PARALLEL foreach to a
547
+ // backpressuring pipe (a CI log collector that drains slowly) stalls
548
+ // the single GLib main loop on a full pipe → every parallel child's
549
+ // pipe backs up → their reads stall → the whole run HANGS. (A tty or a
550
+ // file sink never backpressures, which is why it only bites in CI.)
551
+ // On a NON-tty sink we therefore BUFFER each child's prefixed output
552
+ // and flush it as ONE write when that child closes: the child is
553
+ // already done by then, so its own read can't stall, and concurrent
554
+ // flushes serialize into brief loop stalls instead of a deadlock. On a
555
+ // tty (interactive) we keep live line-prefixing for responsive output.
556
+ const buffered = !process.stdout.isTTY;
557
+ const flushers = [];
310
558
  if (prefix && child.stdout && child.stderr) {
311
- prefixLines(child.stdout, process.stdout, prefix);
312
- prefixLines(child.stderr, process.stderr, prefix);
559
+ flushers.push(prefixLines(child.stdout, process.stdout, prefix, buffered));
560
+ flushers.push(prefixLines(child.stderr, process.stderr, prefix, buffered));
313
561
  }
314
562
  child.on('close', (code) => {
563
+ activeChildren.delete(child);
564
+ for (const flush of flushers)
565
+ flush();
315
566
  if (code === 0)
316
567
  resolve();
317
568
  else
318
569
  reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
319
570
  });
320
- child.on('error', (err) => reject(err));
321
- });
322
- }
323
- function prefixLines(src, sink, prefix) {
324
- let buf = '';
325
- src.setEncoding('utf-8');
326
- src.on('data', (chunk) => {
327
- buf += chunk;
328
- let idx;
329
- while ((idx = buf.indexOf('\n')) !== -1) {
330
- sink.write(prefix + buf.slice(0, idx + 1));
331
- buf = buf.slice(idx + 1);
332
- }
333
- });
334
- src.on('end', () => {
335
- if (buf.length > 0)
336
- sink.write(prefix + buf + '\n');
571
+ child.on('error', (err) => {
572
+ activeChildren.delete(child);
573
+ reject(err);
574
+ });
337
575
  });
338
576
  }
@@ -16,7 +16,7 @@
16
16
  // trailing-comma all, semicolons always, arrow parens always).
17
17
  import { existsSync, writeFileSync } from 'node:fs';
18
18
  import { resolve } from 'node:path';
19
- import { OxcNotFoundError, findOxfmtConfig, loadOxfmtTemplate, loadOxlintTemplate, printOxcNotFound, runOxfmt, } from '../utils/oxc-resolve.js';
19
+ import { OxcNotFoundError, findOxfmtConfig, loadOxfmtTemplate, loadOxlintTemplate, printOxcNotFound, runOxfmt, setOxcExitCode, } from '../utils/oxc-resolve.js';
20
20
  export const formatCommand = {
21
21
  command: 'format [paths..]',
22
22
  description: 'Format JS/TS source files via oxfmt (CSS/JSON formatting is not supported).',
@@ -83,12 +83,12 @@ export const formatCommand = {
83
83
  oxfmtArgs.push(...paths);
84
84
  try {
85
85
  const code = await runOxfmt(oxfmtArgs, { cwd, verbose: args.verbose });
86
- process.exitCode = code;
86
+ setOxcExitCode(code);
87
87
  }
88
88
  catch (err) {
89
89
  if (err instanceof OxcNotFoundError) {
90
90
  printOxcNotFound(err);
91
- process.exitCode = 1;
91
+ setOxcExitCode(1);
92
92
  return;
93
93
  }
94
94
  throw err;
@@ -385,6 +385,17 @@ async function workspaceInstall(cwd, args, signal) {
385
385
  }
386
386
  }
387
387
  console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
388
+ // Write workspace bin shims EARLY — before the download/extract phase.
389
+ // The shims are cheap derived artifacts (workspace manifests only), and
390
+ // the heavy phase that follows can fail or die mid-way (network errors,
391
+ // timeouts, runtime crashes). Writing them up-front guarantees the
392
+ // workspace never loses its runner shim (`node_modules/.bin/gjsify`) to
393
+ // a failed install, and an in-place re-install on an already-materialized
394
+ // tree refreshes a stale shim even when a later phase aborts. The same
395
+ // shims are re-written at the end of this function so the GJS-preamble
396
+ // (GI_TYPELIB_PATH for native prebuilds) reflects the freshly installed
397
+ // tree — see the final `writeWorkspaceBinShims` call below.
398
+ writeWorkspaceBinShims(cwd, workspaces);
388
399
  // Read top-level package.json's `overrides` (npm-native) or `resolutions`
389
400
  // (yarn-native, kept as the existing field name in pre-Phase-D.8 repos).
390
401
  // Flat `name → range` entries become global overrides applied to every
@@ -601,18 +612,42 @@ async function workspaceInstall(cwd, args, signal) {
601
612
  if (rootHoisted > 0) {
602
613
  console.log(`gjsify install: hoisted ${rootHoisted} workspace(s) to root node_modules/`);
603
614
  }
604
- // Link workspace bins into `node_modules/.bin/`. Without this,
605
- // `npm run <script>` (or any `node_modules/.bin`-PATH consumer)
606
- // cannot find the `gjsify` binary on a fresh checkout — yarn
607
- // creates these shims at install time; we need to match.
608
- //
609
- // Each workspace's `bin` entry maps `<binName>` → `<relative-target>`.
610
- // For GJS-runnable bins, `gjsify.bin` is preferred — its target is the
611
- // committed `dist/cli.gjs.mjs` bundle that exists on a fresh checkout,
612
- // versus the `bin` field which typically points at `lib/index.js`
613
- // (a build artifact that may not yet exist). The shim wraps the
614
- // target in a shell script that picks the right interpreter (`gjs -m`
615
- // for `.mjs` bundles, `node` for `.js` files).
615
+ // Re-write workspace bins into `node_modules/.bin/` now that the tree is
616
+ // fully materialized. The early pass at the top of this function already
617
+ // guaranteed the shims exist; this final pass refreshes their GJS-preamble
618
+ // with the native prebuild dirs that only became discoverable after the
619
+ // install (fresh checkout: `detectNativePackages` finds nothing before the
620
+ // packages are extracted). Running last also keeps workspace shims
621
+ // authoritative over any same-named external bin linked by the install
622
+ // backend (yarn semantics: workspace bins win).
623
+ const wsBinsCreated = writeWorkspaceBinShims(cwd, workspaces);
624
+ if (wsBinsCreated > 0) {
625
+ console.log(`gjsify install: linked ${wsBinsCreated} workspace bin(s) into node_modules/.bin/`);
626
+ }
627
+ }
628
+ /**
629
+ * (Re)write the shell shims for every workspace-declared bin into the root
630
+ * `node_modules/.bin/`. Without this, `npm run <script>` (or any
631
+ * `node_modules/.bin`-PATH consumer) cannot find the `gjsify` binary on a
632
+ * fresh checkout — yarn creates these shims at install time; we match.
633
+ *
634
+ * Each workspace's `bin` entry maps `<binName>` → `<relative-target>`.
635
+ * For GJS-runnable bins, `gjsify.bin` is preferred — its target is the
636
+ * committed `dist/cli.gjs.mjs` bundle that exists on a fresh checkout,
637
+ * versus the `bin` field which typically points at `lib/index.js`
638
+ * (a build artifact that may not yet exist). The shim wraps the
639
+ * target in a shell script that picks the right interpreter (`gjs -m`
640
+ * for `.mjs` bundles, `node` for `.js` files).
641
+ *
642
+ * IDEMPOTENT + UNCONDITIONAL: shims are derived artifacts and are always
643
+ * regenerated — never skipped because a file already exists, never a
644
+ * failure because one is missing. `workspaceInstall` calls this twice:
645
+ * once BEFORE the download/extract phase (so a failed/aborted install can
646
+ * never leave the workspace without its runner shim, and a stale shim is
647
+ * refreshed even when a later phase dies) and once after (to pick up
648
+ * native prebuild dirs that only exist post-extract).
649
+ */
650
+ function writeWorkspaceBinShims(cwd, workspaces) {
616
651
  const wsBinDir = join(cwd, 'node_modules', '.bin');
617
652
  // Discover native prebuilds reachable from the workspace cwd so the
618
653
  // workspace-local `node_modules/.bin/gjsify` shim sets GI_TYPELIB_PATH /
@@ -652,9 +687,7 @@ async function workspaceInstall(cwd, args, signal) {
652
687
  wsBinsCreated++;
653
688
  }
654
689
  }
655
- if (wsBinsCreated > 0) {
656
- console.log(`gjsify install: linked ${wsBinsCreated} workspace bin(s) into node_modules/.bin/`);
657
- }
690
+ return wsBinsCreated;
658
691
  }
659
692
  function extractOverrides(rootManifest) {
660
693
  if (!rootManifest)
@@ -741,7 +774,26 @@ function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []
741
774
  `export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
742
775
  })();
743
776
  if (nodeAbs && gjsAbs) {
744
- return `#!/bin/sh\nif [ -f "${nodeAbs}" ]; then\n exec node "${nodeAbs}" "$@"\nfi\n${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n`;
777
+ // GJS-FIRST: prefer the committed Node-free GJS bundle when `gjs` is on
778
+ // PATH AND the bundle exists; fall back to the Node entry otherwise (a
779
+ // Node-only machine, or before the bundle is built). gjsify is
780
+ // GJS-first but stays fully runnable under Node.
781
+ //
782
+ // Safe to default to GJS now that the blockers earlier flip attempts
783
+ // surfaced are all fixed: css-as-string file://-resolve, `foreach`
784
+ // fail-fast, the @gjsify/tsc lib-refresh race, native-rolldown
785
+ // node-target externals — and the make-or-break one, the nested-gjs
786
+ // build thrash: `gjsify run` now dispatches a single `gjsify <cmd>`
787
+ // script IN-PROCESS under GJS (see cli-app.ts) instead of spawning yet
788
+ // another gjs, so the build no longer chains ~5 heavyweight gjs per
789
+ // package and oversubscribes CI's few cores. Verified: the faithful
790
+ // `foreach build -tp --jobs 4` over @gjsify/* with a piped stdout
791
+ // completes in ~83s under GJS (was a multi-hour hang).
792
+ return (`#!/bin/sh\n` +
793
+ `if command -v gjs >/dev/null 2>&1 && [ -f "${gjsAbs}" ]; then\n` +
794
+ `${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n` +
795
+ `fi\n` +
796
+ `exec node "${nodeAbs}" "$@"\n`);
745
797
  }
746
798
  if (nodeAbs)
747
799
  return `#!/bin/sh\nexec node "${nodeAbs}" "$@"\n`;
@@ -7,7 +7,7 @@
7
7
  // report-only. Pass `--fix` for oxlint's safe-fix mode, or use `gjsify fix`
8
8
  // for the combined oxfmt + oxlint --fix surface.
9
9
  import { resolve } from 'node:path';
10
- import { OxcNotFoundError, findOxlintConfig, printOxcNotFound, runOxlint } from '../utils/oxc-resolve.js';
10
+ import { OxcNotFoundError, findOxlintConfig, printOxcNotFound, runOxlint, setOxcExitCode, } from '../utils/oxc-resolve.js';
11
11
  export const lintCommand = {
12
12
  command: 'lint [paths..]',
13
13
  description: 'Run oxlint diagnostics (spawned via its Node launcher to support JS plugins).',
@@ -46,12 +46,12 @@ export const lintCommand = {
46
46
  oxlintArgs.push(...paths);
47
47
  try {
48
48
  const code = await runOxlint(oxlintArgs, { cwd, verbose: args.verbose });
49
- process.exitCode = code;
49
+ setOxcExitCode(code);
50
50
  }
51
51
  catch (err) {
52
52
  if (err instanceof OxcNotFoundError) {
53
53
  printOxcNotFound(err);
54
- process.exitCode = 1;
54
+ setOxcExitCode(1);
55
55
  return;
56
56
  }
57
57
  throw err;
@@ -587,9 +587,25 @@ function handleOidcFailure(err, packageName, asJson) {
587
587
  ? `npm rejected the OIDC exchange (${err.status}) — check that ${packageName} has a Trusted Publisher configured at https://www.npmjs.com/package/${encodeURIComponent(packageName)}/access pointing at this workflow.`
588
588
  : err.message;
589
589
  if (asJson)
590
- process.stdout.write(`${JSON.stringify({ ok: false, name: packageName, error: 'oidc-exchange', status: err.status, body: err.body, message: err.message })}\n`);
591
- else
590
+ process.stdout.write(`${JSON.stringify({ ok: false, name: packageName, error: 'oidc-exchange', status: err.status, body: err.body, claims: err.claims, message: err.message })}\n`);
591
+ else {
592
592
  process.stderr.write(`✗ ${packageName}: ${friendly}\n`);
593
+ // Diagnostics: the raw npm body often names the rejected claim, and
594
+ // the decoded JWT claims are exactly what the TP config must match
595
+ // (repository + workflow_ref + environment). Print both so a 401 is
596
+ // actionable without re-running in --json/--verbose mode.
597
+ if (err.body)
598
+ process.stderr.write(` ↳ npm said: ${err.body.slice(0, 300)}\n`);
599
+ if (err.claims) {
600
+ const c = err.claims;
601
+ const shown = ['aud', 'iss', 'repository', 'repository_owner', 'workflow_ref', 'job_workflow_ref', 'ref', 'environment', 'sub']
602
+ .filter((k) => c[k] !== undefined)
603
+ .map((k) => `${k}=${JSON.stringify(c[k])}`)
604
+ .join(', ');
605
+ if (shown)
606
+ process.stderr.write(` ↳ JWT claims npm received: ${shown}\n`);
607
+ }
608
+ }
593
609
  return;
594
610
  }
595
611
  const msg = err instanceof Error ? err.message : String(err);