@gjsify/cli 0.51.1 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.gjs.mjs +505 -490
- package/lib/cli-app.js +3 -1
- package/lib/commands/affected-classify.d.ts +7 -1
- package/lib/commands/affected-classify.js +66 -9
- package/lib/commands/affected-script-refs.d.ts +20 -0
- package/lib/commands/affected-script-refs.js +218 -0
- package/lib/commands/affected.js +24 -2
- package/lib/commands/flatpak/diff.js +8 -2
- package/lib/commands/flatpak/init.js +4 -1
- package/lib/commands/flatpak/sources.js +4 -3
- package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
- package/lib/commands/flatpak/sync-flathub.js +149 -7
- package/lib/commands/flatpak/utils.d.ts +10 -0
- package/lib/commands/flatpak/utils.js +10 -0
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/install.js +76 -0
- package/lib/commands/link.d.ts +13 -0
- package/lib/commands/link.js +243 -0
- package/lib/commands/onboard.js +18 -2
- package/lib/commands/publish.d.ts +34 -0
- package/lib/commands/publish.js +153 -8
- package/lib/commands/ship.js +19 -1
- package/lib/types/config-data.d.ts +1 -1
- package/lib/utils/check-system-deps.d.ts +1 -17
- package/lib/utils/check-system-deps.js +10 -68
- package/lib/utils/dev-link.d.ts +222 -0
- package/lib/utils/dev-link.js +632 -0
- package/lib/utils/hang-watchdog.d.ts +95 -0
- package/lib/utils/hang-watchdog.js +194 -0
- package/lib/utils/install-backend-native.d.ts +14 -0
- package/lib/utils/install-backend-native.js +91 -13
- package/lib/utils/install-extraneous.d.ts +90 -0
- package/lib/utils/install-extraneous.js +120 -0
- package/lib/utils/install-tarball-cache.d.ts +37 -9
- package/lib/utils/install-tarball-cache.js +59 -14
- package/lib/utils/normalize-bundler-options.js +11 -0
- package/lib/utils/publish-pins.d.ts +38 -0
- package/lib/utils/publish-pins.js +47 -0
- package/lib/utils/publish-readback.d.ts +103 -17
- package/lib/utils/publish-readback.js +326 -47
- package/lib/utils/run-gjs.js +82 -6
- package/lib/utils/ship/icns.d.ts +14 -0
- package/lib/utils/ship/icns.js +83 -0
- package/lib/utils/ship/ico.d.ts +25 -0
- package/lib/utils/ship/ico.js +120 -0
- package/lib/utils/ship/icons.d.ts +191 -0
- package/lib/utils/ship/icons.js +398 -0
- package/lib/utils/ship/launcher.js +16 -1
- package/lib/utils/ship/layout.d.ts +32 -0
- package/lib/utils/ship/layout.js +112 -38
- package/lib/utils/ship/msi.d.ts +1 -1
- package/lib/utils/ship/msi.js +30 -5
- package/lib/utils/ship/pe-launcher.d.ts +18 -0
- package/lib/utils/ship/pe-launcher.js +125 -4
- package/lib/utils/ship/plan.d.ts +1 -34
- package/lib/utils/ship/plan.js +7 -56
- package/lib/utils/ship/plist.d.ts +20 -10
- package/lib/utils/ship/plist.js +29 -12
- package/lib/utils/ship/settings.js +3 -2
- package/package.json +20 -20
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** One line of the heartbeat file. */
|
|
2
|
+
export interface Heartbeat {
|
|
3
|
+
/** Epoch ms the in-flight work is due by; `0` means the harness set no deadline. */
|
|
4
|
+
deadlineMs: number;
|
|
5
|
+
/** `<suite> › <test>`, or `<test run>` between tests. */
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
/** Grace on top of the harness's own deadline before a late test is called a hung one. */
|
|
9
|
+
export declare const DEFAULT_GRACE_MS = 30000;
|
|
10
|
+
/** How often the file is read. A hang is a minutes-scale event; a second of latency is free. */
|
|
11
|
+
export declare const DEFAULT_POLL_MS = 1000;
|
|
12
|
+
/**
|
|
13
|
+
* Grace on top of the harness's own deadline, read from `GJSIFY_HANG_GRACE_MS`. `0` turns the
|
|
14
|
+
* watchdog off — the escape hatch for a debugger parked on a breakpoint, which looks exactly
|
|
15
|
+
* like a hang from out here.
|
|
16
|
+
*
|
|
17
|
+
* A BLANK value is the DEFAULT, not `0`. `Number('')` is `0`, so a matrix leg that sets the
|
|
18
|
+
* variable to an empty string (the shape every `${{ }}` expansion takes when its input is
|
|
19
|
+
* missing) would have switched the guard off without anyone writing a zero — a guard silently
|
|
20
|
+
* disarmed by an empty string is the failure mode this whole PR exists to stop.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hangGraceMs(env: Record<string, string | undefined>): number;
|
|
23
|
+
/**
|
|
24
|
+
* The child's environment with `GJSIFY_UNIT_HEARTBEAT` pointing at `path` — or REMOVED when
|
|
25
|
+
* there is none.
|
|
26
|
+
*
|
|
27
|
+
* Removing it is the load-bearing half. The variable is INHERITED, so a spawn that mints no
|
|
28
|
+
* file of its own (`GJSIFY_HANG_GRACE_MS=0`, or a `/tmp` that refused) used to hand the child
|
|
29
|
+
* an OUTER run's heartbeat path. The inner run then wrote its own per-test deadlines into the
|
|
30
|
+
* outer run's file, and the outer watchdog judged them against the outer child's pid: a short
|
|
31
|
+
* inner deadline kills a healthy outer child, and the inner run's closing `0\t<run finished>`
|
|
32
|
+
* disarms the outer guard outright. Measured before the fix — with the variable preset and the
|
|
33
|
+
* grace at 0, the child wrote `0\t<run finished>` into the inherited path.
|
|
34
|
+
*/
|
|
35
|
+
export declare function heartbeatEnv(base: Record<string, string | undefined>, path: string | undefined): Record<string, string | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* Parse one heartbeat line. Returns `null` for anything that is not one — an absent file, a
|
|
38
|
+
* half-written line, a bundle that is not a test run — because every one of those means "no
|
|
39
|
+
* claim was made", and a watchdog that guesses when nobody claimed anything is a watchdog that
|
|
40
|
+
* kills healthy processes.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseHeartbeat(text: string | null | undefined): Heartbeat | null;
|
|
43
|
+
/**
|
|
44
|
+
* The message a CI reader gets instead of 86 minutes of silence. Names the test, says why the
|
|
45
|
+
* run could not say so itself, and carries whatever the backtrace probe found — the native
|
|
46
|
+
* frame is the part that points at the actual wedge (a `gst_pad_*` call, a `pthread_cond_wait`)
|
|
47
|
+
* and is the one thing no amount of re-running recovers once the process is gone.
|
|
48
|
+
*/
|
|
49
|
+
export declare function formatHangReport(input: {
|
|
50
|
+
label: string;
|
|
51
|
+
overdueMs: number;
|
|
52
|
+
pid: number;
|
|
53
|
+
graceMs?: number;
|
|
54
|
+
backtrace?: string;
|
|
55
|
+
}): string;
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort native backtrace of a wedged process, capped at `maxLines`.
|
|
58
|
+
*
|
|
59
|
+
* `eu-stack` (elfutils) needs no debuginfo to name the frames. It is NOT in the base image of
|
|
60
|
+
* every runner — `.docker/ci-fedora.Dockerfile` installs it on purpose, and macOS and Windows
|
|
61
|
+
* runners have no such tool at all, so this returning `undefined` is a normal outcome. On
|
|
62
|
+
* the measured webrtc shape it prints the answer outright — `g_main_loop_run` under
|
|
63
|
+
* `ffi_call`, i.e. a GI call that entered a loop it never left — which is what no amount of
|
|
64
|
+
* re-running recovers once the process is gone.
|
|
65
|
+
*
|
|
66
|
+
* WHATEVER it produced is kept, even on a non-zero exit: unwinding a live process is partial
|
|
67
|
+
* by nature (a thread that moves mid-walk fails the walk), and the frames it did get are
|
|
68
|
+
* exactly the ones worth having. Absent, unpermitted (`ptrace_scope`) or slow, this returns
|
|
69
|
+
* `undefined` and the report is printed without it — diagnostics must never be the reason a
|
|
70
|
+
* hang report fails to appear, and 5 s is the most this may add to a report someone is waiting
|
|
71
|
+
* for.
|
|
72
|
+
*/
|
|
73
|
+
export declare function captureNativeBacktrace(pid: number, maxLines?: number): string | undefined;
|
|
74
|
+
export interface HangWatchdogDeps {
|
|
75
|
+
/** Read the heartbeat file; `null` when it is absent or unreadable. */
|
|
76
|
+
read: () => string | null;
|
|
77
|
+
/** Injected for the spec, which drives a fake clock rather than waiting 30 s. */
|
|
78
|
+
now?: () => number;
|
|
79
|
+
graceMs?: number;
|
|
80
|
+
/** Called once, with the in-flight breadcrumb and how far past its deadline it is. */
|
|
81
|
+
onHang: (heartbeat: Heartbeat, overdueMs: number) => void;
|
|
82
|
+
}
|
|
83
|
+
export interface HangWatchdog {
|
|
84
|
+
/** One poll. Exposed so the spec needs neither real timers nor real files. */
|
|
85
|
+
tick: () => void;
|
|
86
|
+
start: (pollMs?: number) => void;
|
|
87
|
+
stop: () => void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Poll the heartbeat and fire `onHang` at most once.
|
|
91
|
+
*
|
|
92
|
+
* Latched deliberately: the child is still wedged on the next tick, and a second report would
|
|
93
|
+
* bury the first under repeats of itself.
|
|
94
|
+
*/
|
|
95
|
+
export declare function createHangWatchdog(deps: HangWatchdogDeps): HangWatchdog;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The parent half of `@gjsify/unit`'s hang breadcrumb: a deadline enforced from OUTSIDE the
|
|
2
|
+
// process that missed it.
|
|
3
|
+
//
|
|
4
|
+
// A GJS test bundle that blocks its own main loop — a spin, a nested `GLib.MainLoop.run()`, a
|
|
5
|
+
// blocking GI call into GStreamer — cannot fail itself. `@gjsify/unit`'s per-test, per-suite
|
|
6
|
+
// and per-run timeouts are all `setTimeout` races, and both halves of a race need the loop, so
|
|
7
|
+
// the run simply goes quiet and keeps the job's stdio open until CI's own cap kills it: run
|
|
8
|
+
// 34945600666 burned 90 minutes that way and arrived as an unexplained red on an unrelated PR.
|
|
9
|
+
// The full measurement is in `packages/gjs/unit/src/heartbeat.ts`.
|
|
10
|
+
//
|
|
11
|
+
// So the child writes `<deadline-epoch-ms>\t<label>` before each test, and this polls that
|
|
12
|
+
// file. Past the deadline plus a grace margin this names the test, grabs a native backtrace
|
|
13
|
+
// while the wedge is still standing, and kills the child.
|
|
14
|
+
//
|
|
15
|
+
// The grace is a POLICY, not an inference from the harness, and the difference is measured. A
|
|
16
|
+
// body that blocks the loop past its own timeout and then RETURNS passes today: `withTimeout`
|
|
17
|
+
// arms its timer before calling `fn()`, but the expired timer is cleared in `finally` before
|
|
18
|
+
// the loop ever turns again, so the already-resolved body wins the race. A 12 s synchronous
|
|
19
|
+
// body under a 5 s timeout exits 0 on `main`; with a 3 s grace this guard kills it. The grace
|
|
20
|
+
// is therefore "how long a body may legitimately block the loop beyond its own budget" — at
|
|
21
|
+
// the 30 s default, 7x the 5 s test timeout — which is why the report names
|
|
22
|
+
// `GJSIFY_HANG_GRACE_MS`: a false positive has to be fixable by whoever hits it.
|
|
23
|
+
//
|
|
24
|
+
// Inert by construction: a bundle that is not an `@gjsify/unit` run writes no heartbeat, so
|
|
25
|
+
// `parseHeartbeat` returns `null` forever and a GUI launched through `gjsify run` is never
|
|
26
|
+
// touched. That is why this needs no flag to be safe.
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
/** Grace on top of the harness's own deadline before a late test is called a hung one. */
|
|
29
|
+
export const DEFAULT_GRACE_MS = 30_000;
|
|
30
|
+
/** How often the file is read. A hang is a minutes-scale event; a second of latency is free. */
|
|
31
|
+
export const DEFAULT_POLL_MS = 1_000;
|
|
32
|
+
/**
|
|
33
|
+
* Grace on top of the harness's own deadline, read from `GJSIFY_HANG_GRACE_MS`. `0` turns the
|
|
34
|
+
* watchdog off — the escape hatch for a debugger parked on a breakpoint, which looks exactly
|
|
35
|
+
* like a hang from out here.
|
|
36
|
+
*
|
|
37
|
+
* A BLANK value is the DEFAULT, not `0`. `Number('')` is `0`, so a matrix leg that sets the
|
|
38
|
+
* variable to an empty string (the shape every `${{ }}` expansion takes when its input is
|
|
39
|
+
* missing) would have switched the guard off without anyone writing a zero — a guard silently
|
|
40
|
+
* disarmed by an empty string is the failure mode this whole PR exists to stop.
|
|
41
|
+
*/
|
|
42
|
+
export function hangGraceMs(env) {
|
|
43
|
+
const raw = env.GJSIFY_HANG_GRACE_MS;
|
|
44
|
+
if (raw === undefined || raw.trim() === '')
|
|
45
|
+
return DEFAULT_GRACE_MS;
|
|
46
|
+
const n = Number(raw);
|
|
47
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_GRACE_MS;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The child's environment with `GJSIFY_UNIT_HEARTBEAT` pointing at `path` — or REMOVED when
|
|
51
|
+
* there is none.
|
|
52
|
+
*
|
|
53
|
+
* Removing it is the load-bearing half. The variable is INHERITED, so a spawn that mints no
|
|
54
|
+
* file of its own (`GJSIFY_HANG_GRACE_MS=0`, or a `/tmp` that refused) used to hand the child
|
|
55
|
+
* an OUTER run's heartbeat path. The inner run then wrote its own per-test deadlines into the
|
|
56
|
+
* outer run's file, and the outer watchdog judged them against the outer child's pid: a short
|
|
57
|
+
* inner deadline kills a healthy outer child, and the inner run's closing `0\t<run finished>`
|
|
58
|
+
* disarms the outer guard outright. Measured before the fix — with the variable preset and the
|
|
59
|
+
* grace at 0, the child wrote `0\t<run finished>` into the inherited path.
|
|
60
|
+
*/
|
|
61
|
+
export function heartbeatEnv(base, path) {
|
|
62
|
+
const env = { ...base };
|
|
63
|
+
if (path)
|
|
64
|
+
env.GJSIFY_UNIT_HEARTBEAT = path;
|
|
65
|
+
else
|
|
66
|
+
delete env.GJSIFY_UNIT_HEARTBEAT;
|
|
67
|
+
return env;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parse one heartbeat line. Returns `null` for anything that is not one — an absent file, a
|
|
71
|
+
* half-written line, a bundle that is not a test run — because every one of those means "no
|
|
72
|
+
* claim was made", and a watchdog that guesses when nobody claimed anything is a watchdog that
|
|
73
|
+
* kills healthy processes.
|
|
74
|
+
*/
|
|
75
|
+
export function parseHeartbeat(text) {
|
|
76
|
+
if (!text)
|
|
77
|
+
return null;
|
|
78
|
+
const line = text.split('\n', 1)[0] ?? '';
|
|
79
|
+
const tab = line.indexOf('\t');
|
|
80
|
+
if (tab < 0)
|
|
81
|
+
return null;
|
|
82
|
+
const deadlineMs = Number(line.slice(0, tab));
|
|
83
|
+
if (!Number.isFinite(deadlineMs) || deadlineMs < 0)
|
|
84
|
+
return null;
|
|
85
|
+
const label = line.slice(tab + 1).trim();
|
|
86
|
+
if (label.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
return { deadlineMs, label };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The message a CI reader gets instead of 86 minutes of silence. Names the test, says why the
|
|
92
|
+
* run could not say so itself, and carries whatever the backtrace probe found — the native
|
|
93
|
+
* frame is the part that points at the actual wedge (a `gst_pad_*` call, a `pthread_cond_wait`)
|
|
94
|
+
* and is the one thing no amount of re-running recovers once the process is gone.
|
|
95
|
+
*/
|
|
96
|
+
export function formatHangReport(input) {
|
|
97
|
+
const lines = [
|
|
98
|
+
`⏱ hang: "${input.label}" is ${Math.round(input.overdueMs / 1000)}s past its own deadline`,
|
|
99
|
+
` gjs pid ${input.pid} stopped turning its main loop, so @gjsify/unit's timeout cannot fire`,
|
|
100
|
+
` (see packages/gjs/unit/src/heartbeat.ts). Killing it rather than holding the job open.`,
|
|
101
|
+
];
|
|
102
|
+
if (input.backtrace) {
|
|
103
|
+
lines.push(' native backtrace at the moment of the hang:');
|
|
104
|
+
for (const l of input.backtrace.split('\n'))
|
|
105
|
+
lines.push(` ${l}`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// Said out loud. Silence here reads as "the guard had nothing to add", when what it
|
|
109
|
+
// means is that the one artefact a dead process cannot be asked for again was missed.
|
|
110
|
+
lines.push(' no native backtrace: eu-stack (elfutils) absent, refused the attach, or found nothing');
|
|
111
|
+
}
|
|
112
|
+
if (input.graceMs !== undefined) {
|
|
113
|
+
// The knob travels WITH the accusation: whoever reads this is the one person who can
|
|
114
|
+
// tell a wedge from a body that legitimately blocks the loop this long.
|
|
115
|
+
lines.push(` a test that legitimately blocks the loop this long: raise GJSIFY_HANG_GRACE_MS`);
|
|
116
|
+
lines.push(` (now ${input.graceMs} ms, 0 disables this guard).`);
|
|
117
|
+
}
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Best-effort native backtrace of a wedged process, capped at `maxLines`.
|
|
122
|
+
*
|
|
123
|
+
* `eu-stack` (elfutils) needs no debuginfo to name the frames. It is NOT in the base image of
|
|
124
|
+
* every runner — `.docker/ci-fedora.Dockerfile` installs it on purpose, and macOS and Windows
|
|
125
|
+
* runners have no such tool at all, so this returning `undefined` is a normal outcome. On
|
|
126
|
+
* the measured webrtc shape it prints the answer outright — `g_main_loop_run` under
|
|
127
|
+
* `ffi_call`, i.e. a GI call that entered a loop it never left — which is what no amount of
|
|
128
|
+
* re-running recovers once the process is gone.
|
|
129
|
+
*
|
|
130
|
+
* WHATEVER it produced is kept, even on a non-zero exit: unwinding a live process is partial
|
|
131
|
+
* by nature (a thread that moves mid-walk fails the walk), and the frames it did get are
|
|
132
|
+
* exactly the ones worth having. Absent, unpermitted (`ptrace_scope`) or slow, this returns
|
|
133
|
+
* `undefined` and the report is printed without it — diagnostics must never be the reason a
|
|
134
|
+
* hang report fails to appear, and 5 s is the most this may add to a report someone is waiting
|
|
135
|
+
* for.
|
|
136
|
+
*/
|
|
137
|
+
export function captureNativeBacktrace(pid, maxLines = 40) {
|
|
138
|
+
try {
|
|
139
|
+
const r = spawnSync('eu-stack', ['-p', String(pid)], {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
timeout: 5_000,
|
|
142
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
143
|
+
});
|
|
144
|
+
const out = typeof r.stdout === 'string' ? r.stdout.trim() : '';
|
|
145
|
+
if (out.length === 0)
|
|
146
|
+
return undefined;
|
|
147
|
+
return out.split('\n').slice(0, maxLines).join('\n');
|
|
148
|
+
}
|
|
149
|
+
catch (_e) {
|
|
150
|
+
/* no eu-stack, or no permission to attach — the report stands without it */
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Poll the heartbeat and fire `onHang` at most once.
|
|
156
|
+
*
|
|
157
|
+
* Latched deliberately: the child is still wedged on the next tick, and a second report would
|
|
158
|
+
* bury the first under repeats of itself.
|
|
159
|
+
*/
|
|
160
|
+
export function createHangWatchdog(deps) {
|
|
161
|
+
const now = deps.now ?? Date.now;
|
|
162
|
+
const graceMs = deps.graceMs ?? DEFAULT_GRACE_MS;
|
|
163
|
+
let timer;
|
|
164
|
+
let fired = false;
|
|
165
|
+
const tick = () => {
|
|
166
|
+
if (fired)
|
|
167
|
+
return;
|
|
168
|
+
const hb = parseHeartbeat(deps.read());
|
|
169
|
+
// `deadlineMs === 0` is the harness saying "no deadline here" (`{ timeout: 0 }`, or
|
|
170
|
+
// the window after the run finished). Enforcing one it declined to set is exactly the
|
|
171
|
+
// false positive that would make this guard get turned off.
|
|
172
|
+
if (!hb || hb.deadlineMs <= 0)
|
|
173
|
+
return;
|
|
174
|
+
const overdueMs = now() - (hb.deadlineMs + graceMs);
|
|
175
|
+
if (overdueMs < 0)
|
|
176
|
+
return;
|
|
177
|
+
fired = true;
|
|
178
|
+
stop();
|
|
179
|
+
deps.onHang(hb, now() - hb.deadlineMs);
|
|
180
|
+
};
|
|
181
|
+
const stop = () => {
|
|
182
|
+
if (timer !== undefined)
|
|
183
|
+
clearInterval(timer);
|
|
184
|
+
timer = undefined;
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
tick,
|
|
188
|
+
start: (pollMs = DEFAULT_POLL_MS) => {
|
|
189
|
+
if (timer === undefined)
|
|
190
|
+
timer = setInterval(tick, pollMs);
|
|
191
|
+
},
|
|
192
|
+
stop,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -88,6 +88,20 @@ export interface NativeInstallOptions extends InstallOptions {
|
|
|
88
88
|
* required, the pre-platform-filter behaviour.
|
|
89
89
|
*/
|
|
90
90
|
optionalSpecs?: Set<string>;
|
|
91
|
+
/**
|
|
92
|
+
* Names provided by a `gjsify link` development override (`utils/dev-link.ts`):
|
|
93
|
+
* `node_modules/<name>` is a symlink into a local checkout, so the registry copy
|
|
94
|
+
* must not be fetched over it — `assertNodeModulesDest` refuses that extract and
|
|
95
|
+
* aborts the whole install, correctly, since it would delete the checkout.
|
|
96
|
+
*
|
|
97
|
+
* DELIBERATELY NOT `workspaceNames`, the set it is otherwise shaped like. That
|
|
98
|
+
* one also reaches `resolveDeps` and removes the subtree from the RESOLVE — and
|
|
99
|
+
* the resolve is what writes `gjsify-lock.json`. A link is a local development
|
|
100
|
+
* decision that must leave the consumer's committed lockfile byte-identical, so
|
|
101
|
+
* these names are dropped only from what gets DOWNLOADED, after the lockfile has
|
|
102
|
+
* been written from the full resolved tree.
|
|
103
|
+
*/
|
|
104
|
+
linkedNames?: Set<string>;
|
|
91
105
|
}
|
|
92
106
|
export declare function installPackagesNative(opts: NativeInstallOptions): Promise<InstalledTopLevel[]>;
|
|
93
107
|
/**
|
|
@@ -60,6 +60,9 @@ import { getCachedPackument, putCachedPackument } from './install-packument-cach
|
|
|
60
60
|
import { assertNativeBackendNodeVersion } from './node-version.js';
|
|
61
61
|
import { buildCmdShim, buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, isGjsBundlePath, parseShebang, pickBinMap, } from './bin-shim.js';
|
|
62
62
|
import { detectNativePackages } from './detect-native-packages.js';
|
|
63
|
+
import { findExtraneous, formatExtraneousError } from './install-extraneous.js';
|
|
64
|
+
import { devLinkPath, devLinkRoot } from './dev-link.js';
|
|
65
|
+
import { scanPrefix } from './prune-prefix.js';
|
|
63
66
|
import { badPlatformError, checkPlatform, declaresPlatform, describePlatformTarget, readPlatformForce, resolveHostPlatform, } from './platform-check.js';
|
|
64
67
|
// 16-wide download pool, matched to the shared Soup.Session's lifted
|
|
65
68
|
// `max-conns-per-host` (@gjsify/fetch `getSharedSession`) — without that lift
|
|
@@ -157,6 +160,14 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
157
160
|
}
|
|
158
161
|
log('install: --immutable, using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
|
|
159
162
|
nodes = lockfileToNodes(existingLock);
|
|
163
|
+
// ...and the tree must hold NOTHING ELSE. The installer only adds, so without
|
|
164
|
+
// this an undescribed package survives the install and exits 0 — the CI cache
|
|
165
|
+
// poisoning of gjsify#1683 (install-extraneous.ts carries the incident).
|
|
166
|
+
// Before the download so a wrong tree costs seconds, not the extract phase,
|
|
167
|
+
// and with the FULL lockfile set: the workspace filter below removes nodes
|
|
168
|
+
// that ARE described, and judging against the smaller set would call them
|
|
169
|
+
// strangers.
|
|
170
|
+
assertNoExtraneous(opts.prefix, nodes);
|
|
160
171
|
}
|
|
161
172
|
else if (!opts.refreshLockfile &&
|
|
162
173
|
existingLock &&
|
|
@@ -209,6 +220,16 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
209
220
|
writeLockfile(lockfilePath, opts.specs, nodes);
|
|
210
221
|
log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
|
|
211
222
|
}
|
|
223
|
+
// TWO NAMES FROM HERE ON, and the split is the fix for a measured defect.
|
|
224
|
+
// `nodes` stays the FULL resolved tree — what the lockfile describes and what
|
|
225
|
+
// `topLevelResolutions` answers from. `fetchable` is the subset that still has
|
|
226
|
+
// to come off the network. A package can be present without being fetched, and
|
|
227
|
+
// collapsing the two made the installer forget it existed: with a `gjsify link`
|
|
228
|
+
// active, `gjsify install is-odd` read the FILTERED array, found no `is-odd`
|
|
229
|
+
// resolution, and wrote `"is-odd": "latest"` into package.json and
|
|
230
|
+
// `is-odd@latest` into the lockfile's `requested` — over the `^3.0.1` the
|
|
231
|
+
// control run without the link produced. Never re-merge these two names.
|
|
232
|
+
let fetchable = nodes;
|
|
212
233
|
// A package named after one of the monorepo's own workspaces is provided by a
|
|
213
234
|
// workspace symlink (wired by `workspaceInstall`), NOT by a registry tarball —
|
|
214
235
|
// even when the lockfile or a transitive edge pins a same-named published
|
|
@@ -217,26 +238,55 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
217
238
|
// `--immutable` robust against a committed lockfile that still carries such
|
|
218
239
|
// entries, since that path never runs `resolveDeps`.
|
|
219
240
|
if (opts.workspaceNames && opts.workspaceNames.size > 0) {
|
|
220
|
-
const before =
|
|
221
|
-
|
|
222
|
-
const dropped = before -
|
|
241
|
+
const before = fetchable.length;
|
|
242
|
+
fetchable = fetchable.filter((n) => !opts.workspaceNames.has(n.name));
|
|
243
|
+
const dropped = before - fetchable.length;
|
|
223
244
|
if (dropped > 0) {
|
|
224
245
|
log('install: %d workspace-provided package(s) symlinked, not fetched', dropped);
|
|
225
246
|
}
|
|
226
247
|
}
|
|
248
|
+
// Same exclusion, different reason and a different position in the pipeline: a
|
|
249
|
+
// `gjsify link` override provides these from a local checkout. It runs AFTER the
|
|
250
|
+
// lockfile write above on purpose — the consumer's committed lockfile must stay
|
|
251
|
+
// byte-identical whether or not a link is active (utils/dev-link.ts).
|
|
252
|
+
if (opts.linkedNames && opts.linkedNames.size > 0) {
|
|
253
|
+
const before = fetchable.length;
|
|
254
|
+
fetchable = fetchable.filter((n) => !opts.linkedNames.has(n.name));
|
|
255
|
+
const dropped = before - fetchable.length;
|
|
256
|
+
if (dropped > 0) {
|
|
257
|
+
log('install: %d dev-linked package(s) provided by a local checkout, not fetched', dropped);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
227
260
|
// os/cpu/libc: throws for an incompatible REQUIRED dep, marks incompatible
|
|
228
261
|
// OPTIONAL ones inert. Runs on BOTH paths (fresh resolve and lockfile) —
|
|
229
262
|
// that is what makes one committed lockfile install a per-host subset.
|
|
230
|
-
const installable = applyPlatformFilter(
|
|
263
|
+
const installable = applyPlatformFilter(fetchable, target, force, log);
|
|
231
264
|
log('install: downloading %d tarball(s)', installable.length);
|
|
232
265
|
await downloadAndExtractAll(installable, opts.prefix, npmrc, log, opts.signal, progress);
|
|
233
266
|
await linkBins(installable, opts.prefix, log);
|
|
234
267
|
warnMissingNativeBuilds(installable, opts.prefix, log);
|
|
235
268
|
log('install: done');
|
|
236
269
|
// Top-level requested packages only, so callers can write the resolved version
|
|
237
|
-
// back into package.json (`npm install --save`).
|
|
270
|
+
// back into package.json (`npm install --save`). From `nodes`, NEVER from
|
|
271
|
+
// `fetchable`: "what is installed at node_modules/<name>" is not "what was
|
|
272
|
+
// downloaded", and a provided package is installed.
|
|
238
273
|
return topLevelResolutions(opts.specs, nodes);
|
|
239
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* `--immutable` only: refuse a `node_modules` holding packages the lockfile does not
|
|
277
|
+
* describe, naming every one of them.
|
|
278
|
+
*
|
|
279
|
+
* Deliberately NOT a prune — the reasoning, and the incident it comes from, are the
|
|
280
|
+
* header note of `install-extraneous.ts`. Scoped to the frozen path: a plain install
|
|
281
|
+
* resolves a NEW tree and may legitimately leave an older placement behind for its
|
|
282
|
+
* own prune pass to judge, so this rule would be wrong there.
|
|
283
|
+
*/
|
|
284
|
+
function assertNoExtraneous(prefix, nodes) {
|
|
285
|
+
const extraneous = findExtraneous({ prefix, installed: scanPrefix(prefix), expected: nodes });
|
|
286
|
+
if (extraneous.length === 0)
|
|
287
|
+
return;
|
|
288
|
+
throw new Error(formatExtraneousError(extraneous, prefix));
|
|
289
|
+
}
|
|
240
290
|
function errMsg(err) {
|
|
241
291
|
if (err instanceof Error)
|
|
242
292
|
return err.message;
|
|
@@ -1378,7 +1428,7 @@ async function extractOne(node, prefix, npmrc, log, signal) {
|
|
|
1378
1428
|
// fail if a workspace package leaked into the fetch/extract queue (the root cause
|
|
1379
1429
|
// fixed in `workspaceInstall`), and refusing here means a resolver regression can
|
|
1380
1430
|
// never again `rmSync` a working-tree source dir.
|
|
1381
|
-
assertNodeModulesDest(dest, node);
|
|
1431
|
+
assertNodeModulesDest(dest, node, prefix);
|
|
1382
1432
|
// Idempotent fast-path — skip the cache read + rm + re-extract for a node already
|
|
1383
1433
|
// extracted at the resolved version. The npm/yarn/pnpm default (only added/changed
|
|
1384
1434
|
// nodes are written) and the dominant cost on a warm re-install.
|
|
@@ -1387,14 +1437,15 @@ async function extractOne(node, prefix, npmrc, log, signal) {
|
|
|
1387
1437
|
log('up-to-date: %s@%s (already extracted at %s)', node.name, node.version, node.installPath);
|
|
1388
1438
|
return true;
|
|
1389
1439
|
}
|
|
1390
|
-
// Content-addressable cache before the network
|
|
1391
|
-
// integrity, so a
|
|
1392
|
-
//
|
|
1393
|
-
|
|
1440
|
+
// Content-addressable cache before the network. Both readers RE-HASH the bytes
|
|
1441
|
+
// against the integrity that named them, so a hit is byte-identical to what the
|
|
1442
|
+
// registry would return because it was checked, not because the path says so —
|
|
1443
|
+
// the store is a restorable CI artifact now, not just a local directory.
|
|
1444
|
+
let bytes = await getCachedTarball(node.integrity);
|
|
1394
1445
|
if (bytes) {
|
|
1395
1446
|
log('cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
|
|
1396
1447
|
}
|
|
1397
|
-
else if ((bytes = getForeignCachedTarball(node.integrity))) {
|
|
1448
|
+
else if ((bytes = await getForeignCachedTarball(node.integrity))) {
|
|
1398
1449
|
// Second chance: npm's cacache content store, same SRI key — anyone who has run
|
|
1399
1450
|
// `npm install` already has the tarball on disk. Written through to OUR store so
|
|
1400
1451
|
// the next `gjsify install` hits first-class even if npm prunes its cache.
|
|
@@ -1514,7 +1565,7 @@ function abortError(signal) {
|
|
|
1514
1565
|
* a workspace source tree, where `rmSync(dest, { recursive: true })` deletes the
|
|
1515
1566
|
* link's target contents.
|
|
1516
1567
|
*/
|
|
1517
|
-
function assertNodeModulesDest(dest, node) {
|
|
1568
|
+
function assertNodeModulesDest(dest, node, prefix) {
|
|
1518
1569
|
const segments = dest.split(path.sep);
|
|
1519
1570
|
if (!segments.includes('node_modules')) {
|
|
1520
1571
|
throw new Error(`gjsify install: refusing to extract ${node.name}@${node.version} into ${dest} — ` +
|
|
@@ -1533,9 +1584,36 @@ function assertNodeModulesDest(dest, node) {
|
|
|
1533
1584
|
if (!realSegments.includes('node_modules')) {
|
|
1534
1585
|
throw new Error(`gjsify install: refusing to extract ${node.name}@${node.version} — ${dest} resolves to ${real}, ` +
|
|
1535
1586
|
`which is outside any node_modules/ directory (likely a symlink to a workspace source tree). ` +
|
|
1536
|
-
`Extracting here would delete working-tree source files
|
|
1587
|
+
`Extracting here would delete working-tree source files.${devLinkRemedy(prefix, dest)}`);
|
|
1537
1588
|
}
|
|
1538
1589
|
}
|
|
1590
|
+
/**
|
|
1591
|
+
* The extra sentence for the ONE cause this refusal has that a developer can undo
|
|
1592
|
+
* with a command: a `gjsify link` development link.
|
|
1593
|
+
*
|
|
1594
|
+
* Measured: relinking with a narrower `--packages` left `node_modules/is-number`
|
|
1595
|
+
* pointing into the checkout while the new override no longer named it, and every
|
|
1596
|
+
* later `gjsify install` died on the refusal above — which spoke only of "a
|
|
1597
|
+
* workspace source tree" and named no command at all. `gjsify unlink` did not
|
|
1598
|
+
* rescue it either (it removed the override first, destroying the escape route),
|
|
1599
|
+
* so the only way out was a hand-written `rm`. Both halves are fixed at the
|
|
1600
|
+
* source; this message is what tells someone already wedged by an older CLI.
|
|
1601
|
+
*
|
|
1602
|
+
* The override file may be GONE by the time this fires, so its absence is not
|
|
1603
|
+
* evidence: the remedy is named whenever a `node_modules` entry points outside,
|
|
1604
|
+
* and it names the file only when it is actually there.
|
|
1605
|
+
*/
|
|
1606
|
+
function devLinkRemedy(prefix, dest) {
|
|
1607
|
+
// Same walk the guard and the installer do, so the path this message tells
|
|
1608
|
+
// someone to run `gjsify unlink` in is the one that holds the override.
|
|
1609
|
+
const root = devLinkRoot(prefix);
|
|
1610
|
+
const override = devLinkPath(root);
|
|
1611
|
+
const active = fs.existsSync(override);
|
|
1612
|
+
return (`\n If a \`gjsify link\` development link put it there${active ? ` — ${override} is present` : ''}: ` +
|
|
1613
|
+
`run \`gjsify unlink\` in ${root} to remove the links and restore the registry copies, ` +
|
|
1614
|
+
`or \`gjsify link <checkout> --packages …\` to re-select. ` +
|
|
1615
|
+
`If no override is left, delete ${dest} by hand and re-run.`);
|
|
1616
|
+
}
|
|
1539
1617
|
function depth(installPath) {
|
|
1540
1618
|
// `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, …
|
|
1541
1619
|
return installPath.split('/node_modules/').length;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half of `--immutable` that was missing: every locked package is present AND
|
|
3
|
+
* NOTHING ELSE IS.
|
|
4
|
+
*
|
|
5
|
+
* The installer only ever ADDED. Handed a `node_modules` carrying a package the
|
|
6
|
+
* lockfile never mentions, `--immutable` extracted whatever was missing, left the
|
|
7
|
+
* stranger in place and exited 0 — a green install over a tree its own lockfile does
|
|
8
|
+
* not describe.
|
|
9
|
+
*
|
|
10
|
+
* THE INCIDENT (gjsify#1683, 2026-09-15). CI caches `node_modules` keyed on
|
|
11
|
+
* `hashFiles('gjsify-lock.json')` with a prefix fallback `restore-keys:
|
|
12
|
+
* node-modules-v1-`. An earlier run of a `@girs` bump genuinely nested
|
|
13
|
+
* `node_modules/@girs/gtk-4.0/node_modules/@girs/graphene-1.0` and saved that tree.
|
|
14
|
+
* The fix push changed the lockfile → new key → miss → the FALLBACK restored the
|
|
15
|
+
* nested tree. `--immutable` added what was missing, pruned nothing, and `tsc` then
|
|
16
|
+
* saw one type under two identities: TS2883. Worse, the still-nested tree was saved
|
|
17
|
+
* under the NEW key, so no plain re-run could go green — the failure reproduced
|
|
18
|
+
* itself until the caches were deleted by hand. Nothing about the PR's code was
|
|
19
|
+
* wrong, and any lockfile-changing PR could poison itself the same way.
|
|
20
|
+
*
|
|
21
|
+
* WHY NAMING, NOT DELETING. The peers split: `npm ci` removes `node_modules` first,
|
|
22
|
+
* yarn's `--immutable` fails on a mismatch. This follows yarn, for three reasons.
|
|
23
|
+
* The flag's own name promises not to mutate, and this repo already refuses
|
|
24
|
+
* automatic deletion under it ({@link automaticPruneRefusal} returns `'--immutable'`).
|
|
25
|
+
* A verifier that deletes can delete the wrong thing — a `link`ed dep, a package
|
|
26
|
+
* some other tool put there — while a verifier that reports cannot. And the failure
|
|
27
|
+
* mode is asymmetric: a wrong deletion loses work, a wrong refusal costs one red run
|
|
28
|
+
* carrying the exact paths and the fix.
|
|
29
|
+
*
|
|
30
|
+
* That choice has a price, paid in `.github/actions/gjsify-setup/action.yml`: with
|
|
31
|
+
* this check fatal, a restored FOREIGN tree stops being silent corruption and
|
|
32
|
+
* becomes a hard failure on every lockfile-changing PR. So the `restore-keys`
|
|
33
|
+
* fallback is gone — see the comment there. The two decisions are one decision.
|
|
34
|
+
*/
|
|
35
|
+
import type { InstalledPackage } from './prune-prefix.js';
|
|
36
|
+
/** A package directory on disk that the lockfile does not describe. */
|
|
37
|
+
export interface ExtraneousPackage {
|
|
38
|
+
/**
|
|
39
|
+
* Path relative to the install prefix, always `/`-separated — the exact shape of
|
|
40
|
+
* a `gjsify-lock.json` `packages` key, so "not in the lockfile" is checkable by
|
|
41
|
+
* grepping the reported string against the file.
|
|
42
|
+
*/
|
|
43
|
+
installPath: string;
|
|
44
|
+
/** From the on-disk manifest; the directory path when it was unreadable. */
|
|
45
|
+
name: string;
|
|
46
|
+
version: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Package directories under `prefix` that no entry of `expected` accounts for.
|
|
50
|
+
*
|
|
51
|
+
* Pure: it takes the scan rows ({@link scanPrefix}) and the resolved set rather than
|
|
52
|
+
* reading either, so every branch — nested placements, symlinks, subtree collapsing —
|
|
53
|
+
* is decidable without a filesystem.
|
|
54
|
+
*
|
|
55
|
+
* Three things are deliberately NOT extraneous:
|
|
56
|
+
* - a SYMLINK, which is a workspace source tree linked in by `workspaceInstall`
|
|
57
|
+
* and by construction absent from the lockfile (`scanPrefix` does not descend
|
|
58
|
+
* into one either, so the user's own sources are never walked);
|
|
59
|
+
* - a dot-entry like `.bin` or `.cache`, already dropped by the scan;
|
|
60
|
+
* - a package the lockfile describes but the PLATFORM filter skipped on this host.
|
|
61
|
+
* It is described, so it is not a stranger; removing the foreign-platform residue
|
|
62
|
+
* is `prune-prefix.ts`'s separate job under a separate rule.
|
|
63
|
+
*
|
|
64
|
+
* Descendants of an extraneous directory are collapsed away: reporting the parent
|
|
65
|
+
* says everything, and the nested `@girs` tree of the incident would otherwise list
|
|
66
|
+
* its whole subtree.
|
|
67
|
+
*/
|
|
68
|
+
export declare function findExtraneous(opts: {
|
|
69
|
+
prefix: string;
|
|
70
|
+
installed: readonly InstalledPackage[];
|
|
71
|
+
expected: readonly {
|
|
72
|
+
installPath: string;
|
|
73
|
+
}[];
|
|
74
|
+
}): ExtraneousPackage[];
|
|
75
|
+
/** How many paths the message names before it starts counting. */
|
|
76
|
+
export declare const EXTRANEOUS_REPORT_LIMIT = 20;
|
|
77
|
+
/**
|
|
78
|
+
* The refusal, built to be actionable from the CI log alone: what is wrong, which
|
|
79
|
+
* paths, and the one thing that fixes it. Truncated past
|
|
80
|
+
* {@link EXTRANEOUS_REPORT_LIMIT} so a wholly foreign tree does not bury the advice
|
|
81
|
+
* under a thousand lines.
|
|
82
|
+
*
|
|
83
|
+
* It says DELETE, and says so as the only remedy, because that is what was measured:
|
|
84
|
+
* a plain `gjsify install` — and an explicit `--prune` — leave both a top-level and a
|
|
85
|
+
* nested stranger exactly where they were. The installer only adds, and `prune-prefix`
|
|
86
|
+
* judges one thing, npm's `os`/`cpu`/`libc`, so a package that is merely UNDESCRIBED
|
|
87
|
+
* matches no rule either owns. Sending a developer to `gjsify install` instead would
|
|
88
|
+
* hand them a command that changes nothing and a refusal that repeats verbatim.
|
|
89
|
+
*/
|
|
90
|
+
export declare function formatExtraneousError(entries: readonly ExtraneousPackage[], prefix: string): string;
|