@gjsify/cli 0.4.44 → 0.5.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.
- package/dist/cli.gjs.mjs +214 -215
- package/lib/actions/build.js +9 -1
- package/lib/bundler-pick.d.ts +56 -0
- package/lib/bundler-pick.js +153 -16
- package/lib/cli-app.d.ts +10 -0
- package/lib/cli-app.js +112 -0
- package/lib/commands/affected.js +4 -0
- package/lib/commands/build.js +5 -1
- package/lib/commands/check.js +21 -13
- package/lib/commands/dlx.js +3 -1
- package/lib/commands/fix.js +3 -3
- package/lib/commands/foreach.js +269 -31
- package/lib/commands/format.js +3 -3
- package/lib/commands/install.js +68 -16
- package/lib/commands/lint.js +3 -3
- package/lib/commands/publish.js +37 -24
- package/lib/commands/run.js +132 -2
- package/lib/commands/test.js +5 -0
- package/lib/commands/tsc.js +78 -24
- package/lib/index.js +7 -96
- package/lib/utils/npm-oidc.d.ts +23 -1
- package/lib/utils/npm-oidc.js +44 -6
- package/lib/utils/oxc-resolve.d.ts +36 -5
- package/lib/utils/oxc-resolve.js +132 -6
- package/lib/utils/prefixed-output.d.ts +7 -0
- package/lib/utils/prefixed-output.js +52 -0
- package/lib/utils/publish-headers.d.ts +45 -0
- package/lib/utils/publish-headers.js +99 -0
- package/lib/utils/run-gjs.d.ts +15 -1
- package/lib/utils/run-gjs.js +8 -7
- package/package.json +22 -18
package/lib/commands/foreach.js
CHANGED
|
@@ -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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
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) =>
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
}
|
package/lib/commands/format.js
CHANGED
|
@@ -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
|
-
|
|
86
|
+
setOxcExitCode(code);
|
|
87
87
|
}
|
|
88
88
|
catch (err) {
|
|
89
89
|
if (err instanceof OxcNotFoundError) {
|
|
90
90
|
printOxcNotFound(err);
|
|
91
|
-
|
|
91
|
+
setOxcExitCode(1);
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
throw err;
|
package/lib/commands/install.js
CHANGED
|
@@ -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
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`;
|
package/lib/commands/lint.js
CHANGED
|
@@ -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
|
-
|
|
49
|
+
setOxcExitCode(code);
|
|
50
50
|
}
|
|
51
51
|
catch (err) {
|
|
52
52
|
if (err instanceof OxcNotFoundError) {
|
|
53
53
|
printOxcNotFound(err);
|
|
54
|
-
|
|
54
|
+
setOxcExitCode(1);
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
57
|
throw err;
|
package/lib/commands/publish.js
CHANGED
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
// in-the-wild publish payloads.
|
|
36
36
|
import { readFileSync } from 'node:fs';
|
|
37
37
|
import { join, resolve } from 'node:path';
|
|
38
|
-
import { DEFAULT_REGISTRY, registryFor
|
|
38
|
+
import { DEFAULT_REGISTRY, registryFor } from '@gjsify/npm-registry';
|
|
39
|
+
import { buildPublishHeaders, escapePackageName } from '../utils/publish-headers.js';
|
|
39
40
|
import { packWorkspace } from './pack.js';
|
|
40
41
|
import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
|
|
41
42
|
import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
|
|
@@ -208,18 +209,12 @@ export const publishCommand = {
|
|
|
208
209
|
const npmrc = await loadNpmrc(wsDir);
|
|
209
210
|
const registry = process.env.npm_config_registry ?? registryFor(packed.name, npmrc) ?? DEFAULT_REGISTRY;
|
|
210
211
|
const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
|
|
211
|
-
// npm-package-arg's escapedName convention
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const slash = packed.name.indexOf('/');
|
|
218
|
-
const scope = packed.name.slice(1, slash);
|
|
219
|
-
const base = packed.name.slice(slash + 1);
|
|
220
|
-
return `@${encodeURIComponent(scope)}%2f${encodeURIComponent(base)}`;
|
|
221
|
-
})()
|
|
222
|
-
: encodeURIComponent(packed.name);
|
|
212
|
+
// npm-package-arg's escapedName convention (npa.js:188):
|
|
213
|
+
// `name.replace('/', '%2f')` — scoped `@scope/name` → `@scope%2fname`
|
|
214
|
+
// (literal @, lowercase %2f, segments NOT otherwise re-encoded);
|
|
215
|
+
// unscoped names pass through. Match it exactly — the npm registry is
|
|
216
|
+
// picky about the publish PUT URL shape.
|
|
217
|
+
const escapedName = escapePackageName(packed.name);
|
|
223
218
|
const url = `${registryClean}/${escapedName}`;
|
|
224
219
|
// npm publish convention: the dist.tarball URL + _attachments key both
|
|
225
220
|
// use the UNSCOPED basename — `cli-0.4.5.tgz`, not
|
|
@@ -241,14 +236,15 @@ export const publishCommand = {
|
|
|
241
236
|
packed: { ...packed, wireFilename },
|
|
242
237
|
provenance,
|
|
243
238
|
});
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
239
|
+
// Build the publish-PUT headers to match what `npm publish` sends via
|
|
240
|
+
// npm-registry-fetch's `getHeaders` — critically `npm-command: publish`
|
|
241
|
+
// (the routing header npm's create-package frontdoor keys on; without it
|
|
242
|
+
// a first-publish of a brand-new scoped package returns a bare 404
|
|
243
|
+
// `Not Found`), plus `npm-auth-type` (legacy when --otp, else web), a
|
|
244
|
+
// real `user-agent`, and `npm-otp` when an OTP is supplied. See
|
|
245
|
+
// `utils/publish-headers.ts` for the field-by-field refs/npm-cli
|
|
246
|
+
// evidence.
|
|
247
|
+
const headers = buildPublishHeaders(url, { npmrc, otp });
|
|
252
248
|
// Trusted Publishing path. `--trusted` forces OIDC (errors if env
|
|
253
249
|
// vars are missing); the default `undefined` triggers auto-detect:
|
|
254
250
|
// OIDC is used iff GitHub OIDC env vars are present AND no
|
|
@@ -301,7 +297,8 @@ export const publishCommand = {
|
|
|
301
297
|
handleOidcFailure(err, packed.name, args.json === true);
|
|
302
298
|
process.exit(1);
|
|
303
299
|
}
|
|
304
|
-
// Auto-detect: fall back to
|
|
300
|
+
// Auto-detect: fall back to the npmrc token auth that
|
|
301
|
+
// buildPublishHeaders already resolved into `headers`.
|
|
305
302
|
if (verbose) {
|
|
306
303
|
const msg = err instanceof Error ? err.message : String(err);
|
|
307
304
|
console.error(`gjsify publish: OIDC auto-detect failed (${msg}) — falling back to token auth`);
|
|
@@ -587,9 +584,25 @@ function handleOidcFailure(err, packageName, asJson) {
|
|
|
587
584
|
? `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
585
|
: err.message;
|
|
589
586
|
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
|
|
587
|
+
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`);
|
|
588
|
+
else {
|
|
592
589
|
process.stderr.write(`✗ ${packageName}: ${friendly}\n`);
|
|
590
|
+
// Diagnostics: the raw npm body often names the rejected claim, and
|
|
591
|
+
// the decoded JWT claims are exactly what the TP config must match
|
|
592
|
+
// (repository + workflow_ref + environment). Print both so a 401 is
|
|
593
|
+
// actionable without re-running in --json/--verbose mode.
|
|
594
|
+
if (err.body)
|
|
595
|
+
process.stderr.write(` ↳ npm said: ${err.body.slice(0, 300)}\n`);
|
|
596
|
+
if (err.claims) {
|
|
597
|
+
const c = err.claims;
|
|
598
|
+
const shown = ['aud', 'iss', 'repository', 'repository_owner', 'workflow_ref', 'job_workflow_ref', 'ref', 'environment', 'sub']
|
|
599
|
+
.filter((k) => c[k] !== undefined)
|
|
600
|
+
.map((k) => `${k}=${JSON.stringify(c[k])}`)
|
|
601
|
+
.join(', ');
|
|
602
|
+
if (shown)
|
|
603
|
+
process.stderr.write(` ↳ JWT claims npm received: ${shown}\n`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
593
606
|
return;
|
|
594
607
|
}
|
|
595
608
|
const msg = err instanceof Error ? err.message : String(err);
|