@gjsify/cli 0.51.0 → 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.
Files changed (66) hide show
  1. package/dist/cli.gjs.mjs +505 -490
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected-classify.d.ts +7 -1
  4. package/lib/commands/affected-classify.js +66 -9
  5. package/lib/commands/affected-script-refs.d.ts +20 -0
  6. package/lib/commands/affected-script-refs.js +218 -0
  7. package/lib/commands/affected.js +24 -2
  8. package/lib/commands/flatpak/diff.js +8 -2
  9. package/lib/commands/flatpak/init.js +4 -1
  10. package/lib/commands/flatpak/sources.js +4 -3
  11. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  12. package/lib/commands/flatpak/sync-flathub.js +149 -7
  13. package/lib/commands/flatpak/utils.d.ts +10 -0
  14. package/lib/commands/flatpak/utils.js +10 -0
  15. package/lib/commands/index.d.ts +1 -0
  16. package/lib/commands/index.js +1 -0
  17. package/lib/commands/install.js +76 -0
  18. package/lib/commands/link.d.ts +13 -0
  19. package/lib/commands/link.js +243 -0
  20. package/lib/commands/onboard.js +18 -2
  21. package/lib/commands/publish.d.ts +34 -0
  22. package/lib/commands/publish.js +153 -8
  23. package/lib/commands/ship.js +46 -4
  24. package/lib/types/config-data.d.ts +1 -1
  25. package/lib/utils/check-system-deps.d.ts +1 -17
  26. package/lib/utils/check-system-deps.js +10 -68
  27. package/lib/utils/dev-link.d.ts +222 -0
  28. package/lib/utils/dev-link.js +632 -0
  29. package/lib/utils/hang-watchdog.d.ts +95 -0
  30. package/lib/utils/hang-watchdog.js +194 -0
  31. package/lib/utils/install-backend-native.d.ts +14 -0
  32. package/lib/utils/install-backend-native.js +91 -13
  33. package/lib/utils/install-extraneous.d.ts +90 -0
  34. package/lib/utils/install-extraneous.js +120 -0
  35. package/lib/utils/install-tarball-cache.d.ts +37 -9
  36. package/lib/utils/install-tarball-cache.js +59 -14
  37. package/lib/utils/normalize-bundler-options.js +11 -0
  38. package/lib/utils/publish-pins.d.ts +38 -0
  39. package/lib/utils/publish-pins.js +47 -0
  40. package/lib/utils/publish-readback.d.ts +103 -17
  41. package/lib/utils/publish-readback.js +326 -47
  42. package/lib/utils/run-gjs.js +82 -6
  43. package/lib/utils/ship/appimage.d.ts +24 -4
  44. package/lib/utils/ship/appimage.js +51 -6
  45. package/lib/utils/ship/formats.js +44 -11
  46. package/lib/utils/ship/icns.d.ts +14 -0
  47. package/lib/utils/ship/icns.js +83 -0
  48. package/lib/utils/ship/ico.d.ts +25 -0
  49. package/lib/utils/ship/ico.js +120 -0
  50. package/lib/utils/ship/icons.d.ts +191 -0
  51. package/lib/utils/ship/icons.js +398 -0
  52. package/lib/utils/ship/launcher.js +16 -1
  53. package/lib/utils/ship/layout.d.ts +32 -0
  54. package/lib/utils/ship/layout.js +112 -38
  55. package/lib/utils/ship/msi.d.ts +1 -1
  56. package/lib/utils/ship/msi.js +30 -5
  57. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  58. package/lib/utils/ship/pe-launcher.js +125 -4
  59. package/lib/utils/ship/plan.d.ts +1 -34
  60. package/lib/utils/ship/plan.js +7 -56
  61. package/lib/utils/ship/plist.d.ts +20 -10
  62. package/lib/utils/ship/plist.js +29 -12
  63. package/lib/utils/ship/schemas.d.ts +46 -0
  64. package/lib/utils/ship/schemas.js +92 -11
  65. package/lib/utils/ship/settings.js +3 -2
  66. 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 = nodes.length;
221
- nodes = nodes.filter((n) => !opts.workspaceNames.has(n.name));
222
- const dropped = before - nodes.length;
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(nodes, target, force, log);
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: tarballs are immutable per SRI
1391
- // integrity, so a hash hit is byte-identical to what the registry would return and
1392
- // needs no verifying re-download.
1393
- let bytes = getCachedTarball(node.integrity);
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;