@gjsify/cli 0.51.1 → 0.53.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 (126) hide show
  1. package/dist/cli.gjs.mjs +539 -493
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/affected-classify.d.ts +7 -1
  8. package/lib/commands/affected-classify.js +66 -9
  9. package/lib/commands/affected-script-refs.d.ts +20 -0
  10. package/lib/commands/affected-script-refs.js +218 -0
  11. package/lib/commands/affected.js +24 -2
  12. package/lib/commands/env.d.ts +31 -0
  13. package/lib/commands/env.js +114 -0
  14. package/lib/commands/exec.d.ts +10 -0
  15. package/lib/commands/exec.js +208 -0
  16. package/lib/commands/flatpak/diff.js +8 -2
  17. package/lib/commands/flatpak/init.js +4 -1
  18. package/lib/commands/flatpak/sources.js +4 -3
  19. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  20. package/lib/commands/flatpak/sync-flathub.js +149 -7
  21. package/lib/commands/flatpak/utils.d.ts +10 -0
  22. package/lib/commands/flatpak/utils.js +10 -0
  23. package/lib/commands/foreach.js +5 -97
  24. package/lib/commands/format.js +26 -6
  25. package/lib/commands/index.d.ts +4 -0
  26. package/lib/commands/index.js +4 -0
  27. package/lib/commands/install.d.ts +2 -2
  28. package/lib/commands/install.js +81 -5
  29. package/lib/commands/link.d.ts +13 -0
  30. package/lib/commands/link.js +243 -0
  31. package/lib/commands/onboard.js +18 -2
  32. package/lib/commands/publish.d.ts +34 -0
  33. package/lib/commands/publish.js +153 -8
  34. package/lib/commands/self-update.js +2 -1
  35. package/lib/commands/ship.js +19 -1
  36. package/lib/commands/test.d.ts +18 -9
  37. package/lib/commands/test.js +56 -11
  38. package/lib/commands/tsc.js +4 -4
  39. package/lib/commands/upgrade.d.ts +15 -0
  40. package/lib/commands/upgrade.js +76 -2
  41. package/lib/commands/webext/index.d.ts +2 -0
  42. package/lib/commands/webext/index.js +15 -0
  43. package/lib/commands/webext/subcommands.d.ts +16 -0
  44. package/lib/commands/webext/subcommands.js +204 -0
  45. package/lib/types/config-data.d.ts +2 -2
  46. package/lib/utils/bin-shim.d.ts +8 -2
  47. package/lib/utils/bin-shim.js +6 -5
  48. package/lib/utils/bundle-inputs.d.ts +84 -0
  49. package/lib/utils/bundle-inputs.js +268 -0
  50. package/lib/utils/check-system-deps.d.ts +5 -19
  51. package/lib/utils/check-system-deps.js +31 -94
  52. package/lib/utils/dev-link.d.ts +225 -0
  53. package/lib/utils/dev-link.js +684 -0
  54. package/lib/utils/exec-bin.d.ts +137 -0
  55. package/lib/utils/exec-bin.js +321 -0
  56. package/lib/utils/gi-runtime-paths.js +3 -2
  57. package/lib/utils/gitignore.d.ts +15 -0
  58. package/lib/utils/gitignore.js +178 -0
  59. package/lib/utils/gjsify-shim.d.ts +47 -0
  60. package/lib/utils/gjsify-shim.js +97 -8
  61. package/lib/utils/hang-watchdog.d.ts +95 -0
  62. package/lib/utils/hang-watchdog.js +194 -0
  63. package/lib/utils/install-backend-native.d.ts +33 -1
  64. package/lib/utils/install-backend-native.js +184 -20
  65. package/lib/utils/install-extraneous.d.ts +90 -0
  66. package/lib/utils/install-extraneous.js +120 -0
  67. package/lib/utils/install-lock.js +4 -25
  68. package/lib/utils/install-tarball-cache.d.ts +37 -9
  69. package/lib/utils/install-tarball-cache.js +59 -14
  70. package/lib/utils/native-skip-scan.d.ts +20 -0
  71. package/lib/utils/native-skip-scan.js +343 -0
  72. package/lib/utils/node-script.js +1 -1
  73. package/lib/utils/normalize-bundler-options.js +11 -0
  74. package/lib/utils/oxc-resolve.js +42 -3
  75. package/lib/utils/process-table.d.ts +22 -0
  76. package/lib/utils/process-table.js +188 -0
  77. package/lib/utils/prune-prefix.js +1 -1
  78. package/lib/utils/publish-pins.d.ts +38 -0
  79. package/lib/utils/publish-pins.js +47 -0
  80. package/lib/utils/publish-readback.d.ts +103 -17
  81. package/lib/utils/publish-readback.js +326 -47
  82. package/lib/utils/run-gjs.d.ts +15 -1
  83. package/lib/utils/run-gjs.js +98 -13
  84. package/lib/utils/run-node.d.ts +2 -0
  85. package/lib/utils/run-node.js +2 -1
  86. package/lib/utils/runtimes.js +14 -13
  87. package/lib/utils/ship/gzip.js +1 -1
  88. package/lib/utils/ship/icns.d.ts +14 -0
  89. package/lib/utils/ship/icns.js +83 -0
  90. package/lib/utils/ship/ico.d.ts +25 -0
  91. package/lib/utils/ship/ico.js +120 -0
  92. package/lib/utils/ship/icons.d.ts +197 -0
  93. package/lib/utils/ship/icons.js +399 -0
  94. package/lib/utils/ship/launcher.js +16 -1
  95. package/lib/utils/ship/layout.d.ts +32 -0
  96. package/lib/utils/ship/layout.js +112 -38
  97. package/lib/utils/ship/msi.d.ts +1 -1
  98. package/lib/utils/ship/msi.js +30 -5
  99. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  100. package/lib/utils/ship/pe-launcher.js +125 -4
  101. package/lib/utils/ship/plan.d.ts +1 -34
  102. package/lib/utils/ship/plan.js +7 -56
  103. package/lib/utils/ship/plist.d.ts +20 -10
  104. package/lib/utils/ship/plist.js +29 -12
  105. package/lib/utils/ship/settings.js +3 -2
  106. package/lib/utils/spawn.d.ts +13 -0
  107. package/lib/utils/spawn.js +10 -5
  108. package/lib/utils/watch-loop.d.ts +15 -1
  109. package/lib/utils/watch-loop.js +9 -5
  110. package/lib/utils/webext/build.d.ts +42 -0
  111. package/lib/utils/webext/build.js +120 -0
  112. package/lib/utils/webext/config.d.ts +72 -0
  113. package/lib/utils/webext/config.js +234 -0
  114. package/lib/utils/webext/icons.d.ts +24 -0
  115. package/lib/utils/webext/icons.js +83 -0
  116. package/lib/utils/webext/launch.d.ts +32 -0
  117. package/lib/utils/webext/launch.js +77 -0
  118. package/lib/utils/webext/manifest.d.ts +53 -0
  119. package/lib/utils/webext/manifest.js +219 -0
  120. package/lib/utils/webext/pages.d.ts +26 -0
  121. package/lib/utils/webext/pages.js +61 -0
  122. package/lib/utils/webext/targets.d.ts +13 -0
  123. package/lib/utils/webext/targets.js +36 -0
  124. package/lib/utils/win32-command.d.ts +17 -0
  125. package/lib/utils/win32-command.js +21 -6
  126. package/package.json +20 -20
@@ -8,6 +8,16 @@
8
8
  * undoing the repair one level down.
9
9
  */
10
10
  export declare function usingSelfShim(): boolean;
11
+ /**
12
+ * `PATH` as the user's shell sees it: `process.env.PATH` without the self-shim dir.
13
+ *
14
+ * The shim is this PROCESS TREE's `gjsify`, never the one the user's next command
15
+ * runs. `self-update` asked `resolveBinOnPath` who wins PATH and got its own shim
16
+ * back, the temp copy of the version it had just replaced, so a successful update
17
+ * inside a workspace reported "`gjsify` on PATH resolves to /tmp/gjsify-shim-…
18
+ * (v0.51.1)" and exited 1.
19
+ */
20
+ export declare function pathWithoutSelfShim(pathValue?: string, shimDir?: string | undefined, sep?: string): string;
11
21
  /**
12
22
  * Does this invocation need its own `gjsify` on PATH, rather than the workspace's
13
23
  * npm bin?
@@ -31,6 +41,43 @@ export declare function needsSelfShim(opts: {
31
41
  * reuse it instead of creating their own.
32
42
  */
33
43
  export declare function ensureGjsifyShimOnPath(): void;
44
+ /**
45
+ * The tree whose `node_modules` holds the running CLI and its prebuilds: the
46
+ * install prefix above the first `node_modules` segment, or the workspace root
47
+ * for an in-repo CLI (`packages/infra/cli/dist/cli.gjs.mjs`).
48
+ */
49
+ export declare function selfShimScanRoot(selfEntry: string): string;
50
+ /**
51
+ * The env preamble for a shim re-invoking `selfEntry` — {@link buildNativeEnvPreamble}
52
+ * with the inputs `gjsify install -g` gives it: the prefix to glob at launch, and
53
+ * the prebuild dirs the walk finds above it (a hoisted ancestor's `node_modules`).
54
+ */
55
+ export declare function selfShimPreamble(selfEntry: string): string;
56
+ /**
57
+ * The POSIX `sh` body of the self-shim: re-invoke `target` under `interpreter`.
58
+ *
59
+ * On darwin it RE-EXPORTS the running CLI's `DYLD_*` values first. SIP strips every
60
+ * `DYLD_*` variable when a protected binary is exec'd, and `/bin/sh` — this shim —
61
+ * is one, so the child `gjs` otherwise starts without the loader path the install
62
+ * launcher gave the parent (see `buildNativeEnvPreamble`). `GI_TYPELIB_PATH` is not a
63
+ * `DYLD_*` name and survives, so the child finds a prebuild's typelib and then fails
64
+ * the bare-leaf `dlopen` it names: `gjsify workspace <pkg> build` died with
65
+ * `Failed to load shared library 'libgjsifyterminal.dylib'` while the same script run
66
+ * through the tree's own launcher worked.
67
+ *
68
+ * Baking the values is not the snapshot `buildNativeEnvPreamble` warns about: this
69
+ * shim lives in a temp dir for ONE process tree and carries exactly the environment
70
+ * that tree's root was started with.
71
+ */
72
+ export declare function buildSelfShimScript(opts: {
73
+ interpreter: string;
74
+ interpreterArgs: readonly string[];
75
+ target: string;
76
+ platform?: string;
77
+ env?: Record<string, string | undefined>;
78
+ }): string;
79
+ /** `export` lines restoring each set `DYLD_*` variable on darwin; `''` elsewhere. */
80
+ export declare function dyldCarryPreamble(platform: string, env: Record<string, string | undefined>): string;
34
81
  /**
35
82
  * Directory carrying a `node` that runs the file through
36
83
  * `gjsify run --node-script`, or null when this host needs none.
@@ -41,10 +41,11 @@
41
41
  // nested builds off the GJS bundle.
42
42
  import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
43
43
  import { tmpdir } from 'node:os';
44
- import { join, delimiter, relative, isAbsolute } from 'node:path';
44
+ import { join, delimiter, relative, isAbsolute, dirname } from 'node:path';
45
45
  import { fileURLToPath } from 'node:url';
46
46
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
47
- import { buildLauncherShims } from './bin-shim.js';
47
+ import { buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, shQuoteArg } from './bin-shim.js';
48
+ import { detectNativePackages } from './detect-native-packages.js';
48
49
  import { resolveBinOnPath } from './install-global.js';
49
50
  import { findWorkspaceRoot } from './workspace-root.js';
50
51
  let _ensured = false;
@@ -61,6 +62,23 @@ let _selfShimActive = false;
61
62
  export function usingSelfShim() {
62
63
  return _selfShimActive;
63
64
  }
65
+ /**
66
+ * `PATH` as the user's shell sees it: `process.env.PATH` without the self-shim dir.
67
+ *
68
+ * The shim is this PROCESS TREE's `gjsify`, never the one the user's next command
69
+ * runs. `self-update` asked `resolveBinOnPath` who wins PATH and got its own shim
70
+ * back, the temp copy of the version it had just replaced, so a successful update
71
+ * inside a workspace reported "`gjsify` on PATH resolves to /tmp/gjsify-shim-…
72
+ * (v0.51.1)" and exited 1.
73
+ */
74
+ export function pathWithoutSelfShim(pathValue = process.env.PATH ?? '', shimDir = process.env.GJSIFY_SHIM_DIR, sep = delimiter) {
75
+ if (!shimDir)
76
+ return pathValue;
77
+ return pathValue
78
+ .split(sep)
79
+ .filter((entry) => entry !== shimDir)
80
+ .join(sep);
81
+ }
64
82
  /**
65
83
  * Does this invocation need its own `gjsify` on PATH, rather than the workspace's
66
84
  * npm bin?
@@ -117,23 +135,90 @@ export function ensureGjsifyShimOnPath() {
117
135
  // `process.execPath`: the batch member cannot quote an interpreter argument
118
136
  // (`<interpreter> "<target>" %*`), so `C:\Program Files\nodejs\node.exe` splits.
119
137
  const interpreter = gjs ? process.env.GJS_CONSOLE || 'gjs' : 'node';
120
- const interpreterArgs = gjs ? ['-m'] : [];
121
- const argv = interpreterArgs.length > 0 ? `${interpreterArgs.join(' ')} ` : '';
122
- writeFileSync(shim, `#!/bin/sh\nexec "${interpreter}" ${argv}"${selfEntry}" "$@"\n`, { mode: 0o755 });
138
+ // Under GJS the shim is the SAME launcher `gjsify install` writes, preamble
139
+ // included (`selfShimPreamble`). A bare `exec gjs -m` is not enough on a Mac:
140
+ // it runs behind `/bin/sh`, and SIP strips every `DYLD_*` from a protected
141
+ // binary's environment, so a nested `gjsify` found its typelibs through the
142
+ // surviving GI_TYPELIB_PATH and none of their libraries. Reported by the
143
+ // beifahrer consumer on macOS: `gjsify workspace <pkg> build` died in
144
+ // `get columns` with "Unsupported type void".
145
+ const envPreamble = gjs ? selfShimPreamble(selfEntry) : '';
146
+ writeFileSync(shim, gjs
147
+ ? buildShLauncher(selfEntry, { isGjsBundle: true, envPreamble, gjs: interpreter })
148
+ : `#!/bin/sh\nexec "${interpreter}" "${selfEntry}" "$@"\n`, { mode: 0o755 });
123
149
  chmodSync(shim, 0o755);
124
150
  // cmd.exe and pwsh cannot run the extension-less member: not on PATHEXT, and
125
151
  // Windows has no shebang handling. `buildLauncherShims` ports npm's
126
152
  // three-sibling answer, including the IF/ELSE ERRORLEVEL handling that
127
153
  // `&&`/`||` chaining gets wrong.
128
154
  if (!gjs && process.platform === 'win32') {
129
- const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs, target: selfEntry });
155
+ const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs: [], target: selfEntry });
130
156
  writeFileSync(`${shim}.cmd`, cmd);
131
157
  writeFileSync(`${shim}.ps1`, ps1);
132
158
  }
133
159
  _selfShimActive = true;
134
160
  process.env.GJSIFY_SHIM_DIR = dir;
135
161
  process.env.PATH = dir + delimiter + (process.env.PATH ?? '');
136
- writeNodeShim(dir, gjs, selfEntry);
162
+ writeNodeShim(dir, gjs, selfEntry, envPreamble);
163
+ }
164
+ /**
165
+ * The tree whose `node_modules` holds the running CLI and its prebuilds: the
166
+ * install prefix above the first `node_modules` segment, or the workspace root
167
+ * for an in-repo CLI (`packages/infra/cli/dist/cli.gjs.mjs`).
168
+ */
169
+ export function selfShimScanRoot(selfEntry) {
170
+ // Both separators: the entry is a HOST path, and win32 spells it with a backslash.
171
+ const at = selfEntry.search(/[\\/]node_modules[\\/]/);
172
+ if (at > 0)
173
+ return selfEntry.slice(0, at);
174
+ return findWorkspaceRoot(dirname(selfEntry)) ?? dirname(selfEntry);
175
+ }
176
+ /**
177
+ * The env preamble for a shim re-invoking `selfEntry` — {@link buildNativeEnvPreamble}
178
+ * with the inputs `gjsify install -g` gives it: the prefix to glob at launch, and
179
+ * the prebuild dirs the walk finds above it (a hoisted ancestor's `node_modules`).
180
+ */
181
+ export function selfShimPreamble(selfEntry) {
182
+ let baked = [];
183
+ try {
184
+ baked = detectNativePackages(dirname(selfEntry)).map((p) => p.prebuildsDir);
185
+ }
186
+ catch {
187
+ // The walk is filesystem I/O (EACCES on an unreadable node_modules). The
188
+ // launch-time glob of the scan root still runs; only ancestors go unseen.
189
+ }
190
+ return buildNativeEnvPreamble(selfShimScanRoot(selfEntry), baked);
191
+ }
192
+ /**
193
+ * The POSIX `sh` body of the self-shim: re-invoke `target` under `interpreter`.
194
+ *
195
+ * On darwin it RE-EXPORTS the running CLI's `DYLD_*` values first. SIP strips every
196
+ * `DYLD_*` variable when a protected binary is exec'd, and `/bin/sh` — this shim —
197
+ * is one, so the child `gjs` otherwise starts without the loader path the install
198
+ * launcher gave the parent (see `buildNativeEnvPreamble`). `GI_TYPELIB_PATH` is not a
199
+ * `DYLD_*` name and survives, so the child finds a prebuild's typelib and then fails
200
+ * the bare-leaf `dlopen` it names: `gjsify workspace <pkg> build` died with
201
+ * `Failed to load shared library 'libgjsifyterminal.dylib'` while the same script run
202
+ * through the tree's own launcher worked.
203
+ *
204
+ * Baking the values is not the snapshot `buildNativeEnvPreamble` warns about: this
205
+ * shim lives in a temp dir for ONE process tree and carries exactly the environment
206
+ * that tree's root was started with.
207
+ */
208
+ export function buildSelfShimScript(opts) {
209
+ const argv = opts.interpreterArgs.length > 0 ? `${opts.interpreterArgs.join(' ')} ` : '';
210
+ const preamble = dyldCarryPreamble(opts.platform ?? process.platform, opts.env ?? process.env);
211
+ return `#!/bin/sh\n${preamble}exec "${opts.interpreter}" ${argv}"${opts.target}" "$@"\n`;
212
+ }
213
+ /** `export` lines restoring each set `DYLD_*` variable on darwin; `''` elsewhere. */
214
+ export function dyldCarryPreamble(platform, env) {
215
+ if (platform !== 'darwin')
216
+ return '';
217
+ return Object.keys(env)
218
+ .filter((name) => /^DYLD_[A-Z_]+$/.test(name) && env[name])
219
+ .sort()
220
+ .map((name) => `${name}=${shQuoteArg(env[name])}\nexport ${name}\n`)
221
+ .join('');
137
222
  }
138
223
  /** Deliberately NOT the shim dir itself — see {@link nodeShimDir}. */
139
224
  const NODE_SHIM_SUBDIR = 'node-shim';
@@ -183,7 +268,7 @@ export function nodeShimDir() {
183
268
  * resolves NOWHERE, and it is reachable only from a package script's PATH (see
184
269
  * {@link nodeShimDir}).
185
270
  */
186
- function writeNodeShim(dir, gjs, selfEntry) {
271
+ function writeNodeShim(dir, gjs, selfEntry, envPreamble) {
187
272
  if (!gjs)
188
273
  return;
189
274
  const sub = join(dir, NODE_SHIM_SUBDIR);
@@ -199,11 +284,15 @@ function writeNodeShim(dir, gjs, selfEntry) {
199
284
  // `--node-script` can honour, and yargs would take `--test` FOR the script
200
285
  // path (`unknown-options-as-args` turns an unknown flag into a positional).
201
286
  writeFileSync(shim, '#!/bin/sh\n' +
287
+ // Same SIP hop as the self-shim — see `buildSelfShimScript`.
288
+ dyldCarryPreamble(process.platform, process.env) +
202
289
  'case "$1" in\n' +
203
290
  ' -*) echo "gjsify: this host has no node; the gjsify shim runs a SCRIPT FILE only" >&2\n' +
204
291
  ' echo "gjsify: got: node $*" >&2\n' +
205
292
  ' exit 127 ;;\n' +
206
293
  'esac\n' +
294
+ // The same hop as the `gjsify` shim, so the same preamble.
295
+ envPreamble +
207
296
  `exec "${interpreter}" -m "${selfEntry}" run --node-script "$@"\n`, { mode: 0o755 });
208
297
  chmodSync(shim, 0o755);
209
298
  }
@@ -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
+ }
@@ -22,6 +22,12 @@ export interface ResolvedNode {
22
22
  /** Ranges keyed by name, as published. */
23
23
  dependencies: Record<string, string>;
24
24
  optionalDependencies: Record<string, string>;
25
+ /** As published; absent when the version declares none. Read through {@link requiredPeerEntries}. */
26
+ peerDependencies?: Record<string, string>;
27
+ /** As published — `optional: true` here is what keeps a peer from being installed. */
28
+ peerDependenciesMeta?: Record<string, {
29
+ optional?: boolean;
30
+ }>;
25
31
  bin?: string | Record<string, string>;
26
32
  /**
27
33
  * The version's `os`/`cpu`/`libc` declaration, as published. Recorded here
@@ -88,6 +94,20 @@ export interface NativeInstallOptions extends InstallOptions {
88
94
  * required, the pre-platform-filter behaviour.
89
95
  */
90
96
  optionalSpecs?: Set<string>;
97
+ /**
98
+ * Names provided by a `gjsify link` development override (`utils/dev-link.ts`):
99
+ * `node_modules/<name>` is a symlink into a local checkout, so the registry copy
100
+ * must not be fetched over it — `assertNodeModulesDest` refuses that extract and
101
+ * aborts the whole install, correctly, since it would delete the checkout.
102
+ *
103
+ * DELIBERATELY NOT `workspaceNames`, the set it is otherwise shaped like. That
104
+ * one also reaches `resolveDeps` and removes the subtree from the RESOLVE — and
105
+ * the resolve is what writes `gjsify-lock.json`. A link is a local development
106
+ * decision that must leave the consumer's committed lockfile byte-identical, so
107
+ * these names are dropped only from what gets DOWNLOADED, after the lockfile has
108
+ * been written from the full resolved tree.
109
+ */
110
+ linkedNames?: Set<string>;
91
111
  }
92
112
  export declare function installPackagesNative(opts: NativeInstallOptions): Promise<InstalledTopLevel[]>;
93
113
  /**
@@ -142,6 +162,17 @@ export declare function warnMissingNativeBuilds(nodes: ResolvedNode[], prefix: s
142
162
  * `dependencies` entry to override), covered by {@link resolveDeps}'s second queue loop.
143
163
  */
144
164
  export declare function requiredDepEntries(node: Pick<ResolvedNode, 'dependencies' | 'optionalDependencies'>): [string, string][];
165
+ /**
166
+ * A node's REQUIRED peer edges: its `peerDependencies` minus those
167
+ * `peerDependenciesMeta` marks `optional` (npm ≥ 7 installs the rest and skips
168
+ * these) and minus any name it also lists as a dependency — that edge is walked
169
+ * from the node itself by {@link requiredDepEntries}, or is optional because the
170
+ * publisher said so in `optionalDependencies`.
171
+ *
172
+ * Shared by the resolve walk and the optionality fixpoint, for the reason
173
+ * {@link requiredDepEntries} is: two readings of one manifest are two answers.
174
+ */
175
+ export declare function requiredPeerEntries(node: Pick<ResolvedNode, 'dependencies' | 'optionalDependencies' | 'peerDependencies' | 'peerDependenciesMeta'>): [string, string][];
145
176
  /**
146
177
  * Recompute every node's `optional` flag as a FIXPOINT over the placed graph. The
147
178
  * only writer of the final flag; whatever the nodes arrive carrying is an input
@@ -149,7 +180,8 @@ export declare function requiredDepEntries(node: Pick<ResolvedNode, 'dependencie
149
180
  * file on the lockfile path) and is overwritten unconditionally.
150
181
  *
151
182
  * DEFINITION: a node is REQUIRED iff reachable from `requiredNames` through
152
- * REQUIRED edges alone ({@link requiredDepEntries}); every other node is optional.
183
+ * REQUIRED edges alone ({@link requiredDepEntries} plus {@link requiredPeerEntries} —
184
+ * a peer npm installs is as mandatory as a dependency); every other node is optional.
153
185
  * Optionality is therefore INHERITED — a plain dependency OF an optional package is
154
186
  * still optional, which is what npm's `optionalSet` computes.
155
187
  *