@gjsify/cli 0.41.0 → 0.42.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 (58) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1040 -405
  3. package/lib/actions/build.d.ts +14 -0
  4. package/lib/actions/build.js +54 -7
  5. package/lib/bundler-pick.js +10 -2
  6. package/lib/cli-app.js +2 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/debug.js +1 -1
  9. package/lib/commands/dev.d.ts +13 -0
  10. package/lib/commands/dev.js +132 -0
  11. package/lib/commands/dlx.js +1 -1
  12. package/lib/commands/index.d.ts +1 -0
  13. package/lib/commands/index.js +1 -0
  14. package/lib/commands/run.js +38 -85
  15. package/lib/commands/ship.js +6 -1
  16. package/lib/commands/showcase.js +1 -1
  17. package/lib/commands/storybook.js +17 -74
  18. package/lib/commands/test.js +12 -16
  19. package/lib/commands/tsc.js +96 -45
  20. package/lib/config.js +4 -0
  21. package/lib/types/config-data.d.ts +27 -0
  22. package/lib/utils/app-metadata.js +27 -6
  23. package/lib/utils/check-system-deps.js +49 -7
  24. package/lib/utils/daemon-command.d.ts +7 -0
  25. package/lib/utils/daemon-command.js +30 -0
  26. package/lib/utils/dev-plan.d.ts +52 -0
  27. package/lib/utils/dev-plan.js +117 -0
  28. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  29. package/lib/utils/gi-runtime-paths.js +84 -0
  30. package/lib/utils/gi-typelib.d.ts +49 -0
  31. package/lib/utils/gi-typelib.js +160 -0
  32. package/lib/utils/node-script.d.ts +4 -1
  33. package/lib/utils/node-script.js +27 -3
  34. package/lib/utils/npm-oidc.js +8 -5
  35. package/lib/utils/run-gjs.d.ts +12 -1
  36. package/lib/utils/run-gjs.js +24 -31
  37. package/lib/utils/run-node.d.ts +9 -4
  38. package/lib/utils/run-node.js +34 -41
  39. package/lib/utils/ship/depends.d.ts +5 -0
  40. package/lib/utils/ship/depends.js +20 -1
  41. package/lib/utils/ship/discover.js +106 -1
  42. package/lib/utils/ship/launcher.js +27 -4
  43. package/lib/utils/ship/payload.d.ts +35 -0
  44. package/lib/utils/ship/payload.js +84 -0
  45. package/lib/utils/ship/plan.js +20 -0
  46. package/lib/utils/ship/settings.d.ts +8 -1
  47. package/lib/utils/ship/settings.js +13 -1
  48. package/lib/utils/ship/types.d.ts +19 -0
  49. package/lib/utils/simple-command.d.ts +16 -0
  50. package/lib/utils/simple-command.js +77 -0
  51. package/lib/utils/spawn.d.ts +7 -1
  52. package/lib/utils/spawn.js +34 -7
  53. package/lib/utils/system-gi.d.ts +24 -0
  54. package/lib/utils/system-gi.js +14 -3
  55. package/lib/utils/watch-loop.d.ts +91 -0
  56. package/lib/utils/watch-loop.js +207 -0
  57. package/package.json +19 -18
  58. package/lib/templates/app/desktop.tmpl +0 -10
@@ -0,0 +1,117 @@
1
+ // What `gjsify dev` builds, launches and watches — resolved from the project's
2
+ // OWN declarations rather than from a second set of flags.
3
+ //
4
+ // Re-declaring `--globals auto,dom,PointerEvent,…` here, beside the `build:gjs`
5
+ // script that already says it, would be exactly the drifting second copy this
6
+ // repo keeps paying for: the day the build script gains a flag, the dev loop
7
+ // builds a different bundle than `gjsify run build` does, and the difference
8
+ // surfaces as a runtime error in the app rather than as a diff. So the plan is
9
+ // DERIVED from the script, with the CLI flags as overrides on top.
10
+ //
11
+ // Pure by design — no filesystem, no process — so precedence is unit-tested
12
+ // instead of inferred from a running watch loop.
13
+ import { dirname } from 'node:path';
14
+ import { gjsifyCommandArgv } from './simple-command.js';
15
+ /** The value of `--name value` / `--name=value` in `argv`, if present. */
16
+ export function readFlag(argv, name) {
17
+ const long = `--${name}`;
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const token = argv[i];
20
+ if (token === long)
21
+ return argv[i + 1];
22
+ if (token.startsWith(`${long}=`))
23
+ return token.slice(long.length + 1);
24
+ }
25
+ return undefined;
26
+ }
27
+ /** `argv` with `--name value` set — replacing any existing occurrence, else appended. */
28
+ export function withFlag(argv, name, value) {
29
+ const long = `--${name}`;
30
+ const out = [...argv];
31
+ for (let i = 0; i < out.length; i++) {
32
+ const token = out[i];
33
+ if (token === long) {
34
+ out[i + 1] = value;
35
+ return out;
36
+ }
37
+ if (token.startsWith(`${long}=`)) {
38
+ out[i] = `${long}=${value}`;
39
+ return out;
40
+ }
41
+ }
42
+ out.push(long, value);
43
+ return out;
44
+ }
45
+ /**
46
+ * The entry point a `gjsify build` argv names positionally. Only the token
47
+ * DIRECTLY after `build` counts: further positionals are indistinguishable from
48
+ * the value of a preceding flag without knowing every flag's arity, and a wrong
49
+ * guess here would silently build the wrong file.
50
+ */
51
+ export function buildEntryPoint(argv) {
52
+ const candidate = argv[1];
53
+ if (candidate === undefined || candidate.startsWith('-'))
54
+ return undefined;
55
+ return candidate;
56
+ }
57
+ /** Message shown when neither a usable build script nor an explicit entry exists. */
58
+ function noEntryError(scriptName, hasScript) {
59
+ const why = hasScript
60
+ ? `\`${scriptName}\` is not a plain \`gjsify build <entry> …\` command, so its entry point cannot be read`
61
+ : `this package declares no \`${scriptName}\` script`;
62
+ return new Error(`gjsify dev: nothing to build — ${why}.\n` +
63
+ ` Name the entry point directly: gjsify dev src/index.ts\n` +
64
+ ` or point at another script: gjsify dev --script <name>`);
65
+ }
66
+ /**
67
+ * Resolve the build argv, the bundle to launch and the directory to watch.
68
+ * Throws with both fixes named when the project declares neither.
69
+ */
70
+ export function planDev(req) {
71
+ const literal = req.scripts[req.scriptName];
72
+ const scriptArgv = typeof literal === 'string' ? gjsifyCommandArgv(literal) : null;
73
+ const derived = scriptArgv && scriptArgv[0] === 'build' ? scriptArgv : null;
74
+ const entry = req.entry ?? (derived ? buildEntryPoint(derived) : undefined);
75
+ if (entry === undefined)
76
+ throw noEntryError(req.scriptName, typeof literal === 'string');
77
+ // Start from the script's own argv so every flag it declares survives, then
78
+ // put the overrides on top. `--app` is SET rather than inherited: `--script`
79
+ // and `--runtime` can name different targets, and the runtime that will
80
+ // launch the bundle is the one that must decide what is built.
81
+ let argv;
82
+ if (derived) {
83
+ argv = [...derived];
84
+ // Replace the entry the script names, or splice one in when it names
85
+ // none (the script may take its entry from `gjsify.bundler.input`).
86
+ if (buildEntryPoint(derived) !== undefined)
87
+ argv[1] = entry;
88
+ else
89
+ argv.splice(1, 0, entry);
90
+ }
91
+ else {
92
+ argv = ['build', entry];
93
+ }
94
+ argv = withFlag(argv, 'app', req.app);
95
+ if (req.globals !== undefined)
96
+ argv = withFlag(argv, 'globals', req.globals);
97
+ if (req.outfile !== undefined)
98
+ argv = withFlag(argv, 'outfile', req.outfile);
99
+ return {
100
+ buildArgv: argv,
101
+ bundle: readFlag(argv, 'outfile') ?? req.declaredBundle,
102
+ watchDir: req.watchDir ?? dirname(entry),
103
+ };
104
+ }
105
+ /**
106
+ * Message for a launch that has no bundle to launch. Separate from `planDev`
107
+ * because only the LAUNCHING caller needs one: a build writing an `--outdir`
108
+ * legitimately has no single output file, and refusing inside the planner would
109
+ * fail `--build-only` for a reason that does not apply to it.
110
+ */
111
+ export function noBundleError(scriptName, app) {
112
+ const declaration = app === 'gjs' ? '`gjsify.main`' : '`gjsify.example.node`';
113
+ return new Error(`gjsify dev: nothing to launch — neither \`${scriptName}\` nor --outfile names an output file, ` +
114
+ `and this package declares no ${declaration}.\n` +
115
+ ` Pass one: gjsify dev --outfile dist/index.${app}.mjs\n` +
116
+ ` or rebuild without launching: gjsify dev --build-only`);
117
+ }
@@ -0,0 +1,27 @@
1
+ import type { GiSystemProbe } from '@gjsify/rolldown-plugin-gjsify';
2
+ /**
3
+ * The system libdir candidates a `--app gjs` bundle carries — every platform the
4
+ * table names, not the building host's.
5
+ *
6
+ * A build-time platform gate would be exactly the snapshot the note above exists to
7
+ * avoid: `--app gjs` emits platform-neutral JavaScript, so the bundle a Linux runner
8
+ * produces is the bundle a Mac runs. What travels instead is the RUNTIME gate, and it
9
+ * is two markers, not one, because the `girepository-1.0` marker alone answers the
10
+ * wrong question. It says "a GI stack is installed under this prefix" — true on a
11
+ * Linux box built with `meson setup --prefix=/usr/local` or jhbuild, a normal shape
12
+ * for this project's own audience — while the rule this mirrors is about LOADERS:
13
+ * `systemGiLibraryDirs()` is empty off darwin because `ld.so`'s system-wide cache
14
+ * already resolves these leaves. Without the host marker every such Linux host would
15
+ * get `/usr/local/lib` prepended ahead of the distro's typelibs AND libraries, which
16
+ * is the two-stacks precedence ADR 0023 § 4 / #910 paid for — measured against a
17
+ * stand-in host in `gi-runtime-paths-banner.spec.ts`.
18
+ *
19
+ * A platform in the table with no {@link HOST_MARKERS} entry THROWS rather than
20
+ * shipping an ungated candidate: an unknown-host default of "prepend" is the bug
21
+ * above, on every platform at once.
22
+ *
23
+ * SORTED and deduplicated: the result is emitted into a bundle two machines must be
24
+ * able to build byte-for-byte identically, and `Object.entries()` order is the
25
+ * table's, which is a source-edit away from changing.
26
+ */
27
+ export declare function giSystemProbes(): GiSystemProbe[];
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // WHAT a `--app gjs` bundle's byte-1 GI prologue is told. The prologue itself is
3
+ // `rolldown-plugin-gjsify`'s `giRuntimePathsStub`; this is the only production caller
4
+ // that fills it, and until it did, every bundle carried the empty string that stub
5
+ // returns for nothing-to-say.
6
+ //
7
+ // WHY A BUNDLE NEEDS IT. On macOS a typelib names its library by bare leaf
8
+ // (`libgtk-4.1.dylib`), and SIP strips an inherited `DYLD_*` at the `/bin/sh` exec a
9
+ // launcher goes through, so the only repair that survives is one the process makes to
10
+ // girepository's own search paths from the inside. Measurements, and what this reaches
11
+ // (loads AFTER module evaluation starts, not a static `gi://` import):
12
+ // `status/open-todos.md` § "A globally installed GJS launcher still cannot load a
13
+ // system GTK on macOS".
14
+ //
15
+ // A SHIPPED BUNDLE RUNS ON A MACHINE THE BUILD NEVER SAW, which rules out both
16
+ // tempting sources. `systemGiLibraryDirs()` MEASURES the build host and answers `[]`
17
+ // on the Linux runner that builds most releases — so what travels is the CANDIDATE
18
+ // table it probes, with the probe moved into the bundle. `detectNativePackages()`
19
+ // answers with the build tree's `node_modules/@gjsify/<pkg>-<target>/prebuilds/
20
+ // <target>`: the build host's target twice over (ADR 0017), at the build tree's depth,
21
+ // so it names nothing on an installed macOS host — and baking it would make
22
+ // `dist/affected.gjs.mjs`, a `--app gjs` bundle `scripts/verify-committed-bundles.mjs`
23
+ // compares byte for byte, encode which platform siblings the committing machine had.
24
+ // `activateNativePrebuilds()` already covers those from inside the running process.
25
+ //
26
+ // DELIBERATELY NOT INCLUDED: a `@gjsify/gtk-runtime-<target>` bundle's `gtk/lib`. That
27
+ // is node-gi's mechanism, for a host process that brings no GTK of its own; a `--app
28
+ // gjs` bundle runs under a `gjs` already linked against a system GLib, so a second GTK
29
+ // stack in that process is the two-registries failure #910 paid for (ADR 0023 § 4).
30
+ import { PROBED_GI_LIBDIRS, TYPELIB_SUBDIR } from './system-gi.js';
31
+ /**
32
+ * A path that proves the RUNNING host is the platform whose candidates it keys, for
33
+ * every platform `PROBED_GI_LIBDIRS` names.
34
+ *
35
+ * The bundle cannot read `process.platform` — it is platform-neutral JavaScript, built
36
+ * on a machine that is not the host — so the darwin scope `systemGiLibraryDirs()`
37
+ * enforces with `PROBED_GI_LIBDIRS[platform]` has to travel as a marker path.
38
+ * Same probe, same spelling as `@gjsify/child_process`'s `detectPlatform()` and
39
+ * `@gjsify/v8`'s `detectHeapPlatform()`: macOS has no procfs, and this plist is
40
+ * present on every install.
41
+ */
42
+ const HOST_MARKERS = {
43
+ darwin: '/System/Library/CoreServices/SystemVersion.plist',
44
+ };
45
+ /**
46
+ * The system libdir candidates a `--app gjs` bundle carries — every platform the
47
+ * table names, not the building host's.
48
+ *
49
+ * A build-time platform gate would be exactly the snapshot the note above exists to
50
+ * avoid: `--app gjs` emits platform-neutral JavaScript, so the bundle a Linux runner
51
+ * produces is the bundle a Mac runs. What travels instead is the RUNTIME gate, and it
52
+ * is two markers, not one, because the `girepository-1.0` marker alone answers the
53
+ * wrong question. It says "a GI stack is installed under this prefix" — true on a
54
+ * Linux box built with `meson setup --prefix=/usr/local` or jhbuild, a normal shape
55
+ * for this project's own audience — while the rule this mirrors is about LOADERS:
56
+ * `systemGiLibraryDirs()` is empty off darwin because `ld.so`'s system-wide cache
57
+ * already resolves these leaves. Without the host marker every such Linux host would
58
+ * get `/usr/local/lib` prepended ahead of the distro's typelibs AND libraries, which
59
+ * is the two-stacks precedence ADR 0023 § 4 / #910 paid for — measured against a
60
+ * stand-in host in `gi-runtime-paths-banner.spec.ts`.
61
+ *
62
+ * A platform in the table with no {@link HOST_MARKERS} entry THROWS rather than
63
+ * shipping an ungated candidate: an unknown-host default of "prepend" is the bug
64
+ * above, on every platform at once.
65
+ *
66
+ * SORTED and deduplicated: the result is emitted into a bundle two machines must be
67
+ * able to build byte-for-byte identically, and `Object.entries()` order is the
68
+ * table's, which is a source-edit away from changing.
69
+ */
70
+ export function giSystemProbes() {
71
+ const probes = new Map();
72
+ for (const [platform, dirs] of Object.entries(PROBED_GI_LIBDIRS)) {
73
+ const hostMarker = HOST_MARKERS[platform];
74
+ if (!hostMarker) {
75
+ throw new Error(`[gjsify] PROBED_GI_LIBDIRS names '${platform}' but HOST_MARKERS does not: ` +
76
+ 'a bundle cannot ask process.platform, so an ungated candidate would be ' +
77
+ 'prepended on every host that happens to have the directory.');
78
+ }
79
+ for (const dir of dirs) {
80
+ probes.set(`${dir}\u0000${hostMarker}`, [dir, `${dir}/${TYPELIB_SUBDIR}`, hostMarker]);
81
+ }
82
+ }
83
+ return [...probes.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, probe]) => probe);
84
+ }
@@ -0,0 +1,49 @@
1
+ /** Injectable host facts — every platform branch is testable from any host. */
2
+ export interface GiTypelibOptions {
3
+ /** `process.platform` value; defaults to the running process. */
4
+ platform?: string;
5
+ /** Environment to read `GI_TYPELIB_PATH` / `PATH` from; defaults to `process.env`. */
6
+ env?: Record<string, string | undefined>;
7
+ /** Directory-existence predicate; defaults to a `statSync` probe. */
8
+ existsDir?: (dir: string) => boolean;
9
+ /** File-existence predicate; defaults to `existsSync`. */
10
+ existsFile?: (file: string) => boolean;
11
+ /** Immediate subdirectory names of `dir`, or `[]`; defaults to a `readdirSync` probe. */
12
+ listDirs?: (dir: string) => string[];
13
+ /** `pkg-config`'s `.pc` search directories; defaults to {@link pkgConfigSearchDirs}. */
14
+ searchDirs?: (env: Record<string, string | undefined>) => string[];
15
+ }
16
+ /**
17
+ * Directories girepository looks for typelibs in, most specific first,
18
+ * deduplicated, each verified on disk.
19
+ *
20
+ * Sources, in precedence order:
21
+ * 1. `GI_TYPELIB_PATH` — the host stating outright where typelibs live.
22
+ * Believed on existence alone; an explicit statement is not second-guessed.
23
+ * 2. `pkg-config`'s `.pc` search path → `<libdir>/girepository-1.0`. The
24
+ * general mechanism: it finds a jhbuild or otherwise bespoke prefix that no
25
+ * list here could name. Present only when pkg-config is, which on a
26
+ * runtime-only host it is not — hence 3.
27
+ * 3. Platform defaults, each believed only on the marker directory existing.
28
+ *
29
+ * Source 3 is where the platforms differ, and each branch is a statement about
30
+ * that platform's install layout:
31
+ * * **linux** — {@link LINUX_LIBDIRS} plus the discovered multiarch triplets.
32
+ * * **darwin** — {@link systemGiLibraryDirs}, which already probes Homebrew,
33
+ * MacPorts and pkg-config prefixes for exactly this marker. Reused rather
34
+ * than re-derived: the two must not be able to disagree about where the GI
35
+ * stack is.
36
+ * * **win32** — a GTK distribution ships `<prefix>/bin` on `PATH` and its
37
+ * typelibs in the sibling `<prefix>/lib/girepository-1.0`, so PATH is the
38
+ * prefix oracle. (Untested against a real Windows GTK; it costs a `stat` per
39
+ * `bin` entry and cannot make any host worse than the empty list it replaces.)
40
+ */
41
+ export declare function systemGiTypelibDirs({ platform, env, existsDir, listDirs, searchDirs, }?: GiTypelibOptions): string[];
42
+ /**
43
+ * The installed `<namespace>-<version>.typelib`, or `null`.
44
+ *
45
+ * `version` is GI's API version as it appears in a `gi://` specifier (`4.0` for
46
+ * `gi://Gtk?version=4.0`, `1` for Adwaita) — the same string the filename
47
+ * carries, so the caller states it exactly once.
48
+ */
49
+ export declare function findSystemTypelib(namespace: string, version: string, { existsFile, ...options }?: GiTypelibOptions): string | null;
@@ -0,0 +1,160 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Is a GObject-Introspection namespace INSTALLED on this host — the question
3
+ // `gi_repository_require()` answers, asked without loading anything.
4
+ //
5
+ // WHY THIS EXISTS. `gjsify system-check` used to answer it with `pkg-config
6
+ // --modversion libadwaita-1`, which is a question about the DEVELOPMENT package.
7
+ // Measured on postmarketOS v26.06 / aarch64 (OnePlus 6T, gjs 1.88.1, no node):
8
+ // libadwaita 1.9.2 installed, `Adw-1.typelib` present, every Adwaita app on the
9
+ // device running — and `system-check` printing `✗ libadwaita` under **Required**
10
+ // and exiting 1, because `libadwaita-dev` (which ships the `.pc` file and
11
+ // nothing a running app loads) was not installed. A check that fails on a host
12
+ // where the thing it checks demonstrably works is worse than no check: it sends
13
+ // people to install packages they do not need, and it teaches them to ignore the
14
+ // output — the same failure mode `checkTypeSkew`'s comment in
15
+ // `check-system-deps.ts` describes from the other direction.
16
+ //
17
+ // THE ORACLE is the typelib FILE on girepository's search path:
18
+ // * it is what GI itself resolves, so it cannot disagree with what an app does;
19
+ // * it is identical under GJS and Node, so the CLI's two entry points give the
20
+ // same answer (`pkg-config` at least had that property; `imports.gi` would
21
+ // not);
22
+ // * it needs no `dlopen`, and a presence probe must not have the side effect of
23
+ // loading GTK into the process asking the question.
24
+ //
25
+ // It answers PRESENCE only. The version still comes from `pkg-config` where that
26
+ // is installed — see `checkGiLibrary` in `check-system-deps.ts`, which reports a
27
+ // version when it can and a bare `✓` when it cannot. Presence without a version
28
+ // is the honest answer on a runtime-only host; `✗` was not.
29
+ //
30
+ // PURE FUNCTION of injected host facts (platform / env / fs / pkg-config), like
31
+ // `systemGiLibraryDirs()` and `resolvePrebuildDirName()` beside it, so the
32
+ // darwin and win32 branches are unit-tested from Linux.
33
+ import { existsSync, readdirSync, statSync } from 'node:fs';
34
+ import { posix, win32 } from 'node:path';
35
+ import { pkgConfigSearchDirs, splitSearchPath, systemGiLibraryDirs, TYPELIB_SUBDIR } from './system-gi.js';
36
+ /**
37
+ * Library prefixes a Linux GI stack is installed under, before multiarch.
38
+ * `/usr/lib64` is Fedora/openSUSE, `/usr/lib` everything else, `/usr/local/lib`
39
+ * a source install. Each is accepted only when it actually holds the marker
40
+ * directory, so naming one that does not exist costs a `stat` and nothing else.
41
+ */
42
+ const LINUX_LIBDIRS = ['/usr/lib', '/usr/lib64', '/usr/local/lib'];
43
+ /**
44
+ * Where a Debian/Ubuntu multiarch layout hides the typelibs
45
+ * (`/usr/lib/x86_64-linux-gnu/girepository-1.0`). The triplet is DISCOVERED by
46
+ * listing the parent rather than derived from `process.arch`: the mapping from
47
+ * Node's arch names to Debian triplets is a table that would need a row per
48
+ * port, and one `readdir` of `/usr/lib` answers it for every architecture
49
+ * including the ones nobody has written down.
50
+ */
51
+ const MULTIARCH_PARENTS = ['/usr/lib'];
52
+ function isDirectory(dir) {
53
+ try {
54
+ return statSync(dir).isDirectory();
55
+ }
56
+ catch {
57
+ // ENOENT on an uninstalled prefix, EACCES on a path this process may not
58
+ // read: not a usable typelib directory either way.
59
+ return false;
60
+ }
61
+ }
62
+ function subdirectories(dir) {
63
+ try {
64
+ return readdirSync(dir, { withFileTypes: true })
65
+ .filter((entry) => entry.isDirectory())
66
+ .map((entry) => entry.name);
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ }
72
+ /**
73
+ * Directories girepository looks for typelibs in, most specific first,
74
+ * deduplicated, each verified on disk.
75
+ *
76
+ * Sources, in precedence order:
77
+ * 1. `GI_TYPELIB_PATH` — the host stating outright where typelibs live.
78
+ * Believed on existence alone; an explicit statement is not second-guessed.
79
+ * 2. `pkg-config`'s `.pc` search path → `<libdir>/girepository-1.0`. The
80
+ * general mechanism: it finds a jhbuild or otherwise bespoke prefix that no
81
+ * list here could name. Present only when pkg-config is, which on a
82
+ * runtime-only host it is not — hence 3.
83
+ * 3. Platform defaults, each believed only on the marker directory existing.
84
+ *
85
+ * Source 3 is where the platforms differ, and each branch is a statement about
86
+ * that platform's install layout:
87
+ * * **linux** — {@link LINUX_LIBDIRS} plus the discovered multiarch triplets.
88
+ * * **darwin** — {@link systemGiLibraryDirs}, which already probes Homebrew,
89
+ * MacPorts and pkg-config prefixes for exactly this marker. Reused rather
90
+ * than re-derived: the two must not be able to disagree about where the GI
91
+ * stack is.
92
+ * * **win32** — a GTK distribution ships `<prefix>/bin` on `PATH` and its
93
+ * typelibs in the sibling `<prefix>/lib/girepository-1.0`, so PATH is the
94
+ * prefix oracle. (Untested against a real Windows GTK; it costs a `stat` per
95
+ * `bin` entry and cannot make any host worse than the empty list it replaces.)
96
+ */
97
+ export function systemGiTypelibDirs({ platform = process.platform, env = process.env, existsDir = isDirectory, listDirs = subdirectories, searchDirs = pkgConfigSearchDirs, } = {}) {
98
+ // POSIX semantics unless the TARGET is win32 — `platform` is a parameter, so
99
+ // this answers a question about a foreign filesystem from any host. The same
100
+ // trap `system-gi.ts` documents: a host-following `join` turns
101
+ // `/usr/lib/girepository-1.0` into a backslash path on Windows and matches
102
+ // nothing the caller holds.
103
+ const { join, dirname, basename } = platform === 'win32' ? win32 : posix;
104
+ const separator = platform === 'win32' ? ';' : ':';
105
+ const out = [];
106
+ const add = (dir) => {
107
+ if (dir && !out.includes(dir))
108
+ out.push(dir);
109
+ };
110
+ // 1. Explicit.
111
+ for (const dir of splitSearchPath(env['GI_TYPELIB_PATH'], separator)) {
112
+ if (existsDir(dir))
113
+ add(dir);
114
+ }
115
+ // 2 + 3. Candidate libdirs, believed only on the marker.
116
+ const libDirs = [];
117
+ for (const pcDir of searchDirs(env)) {
118
+ if (basename(pcDir) === 'pkgconfig')
119
+ libDirs.push(dirname(pcDir));
120
+ }
121
+ if (platform === 'linux') {
122
+ libDirs.push(...LINUX_LIBDIRS);
123
+ for (const parent of MULTIARCH_PARENTS) {
124
+ for (const entry of listDirs(parent))
125
+ libDirs.push(join(parent, entry));
126
+ }
127
+ }
128
+ else if (platform === 'darwin') {
129
+ libDirs.push(...systemGiLibraryDirs({ platform, env, existsDir, searchDirs }));
130
+ }
131
+ else if (platform === 'win32') {
132
+ for (const pathEntry of splitSearchPath(env['PATH'], separator)) {
133
+ if (basename(pathEntry).toLowerCase() === 'bin')
134
+ libDirs.push(join(dirname(pathEntry), 'lib'));
135
+ }
136
+ }
137
+ for (const libDir of libDirs) {
138
+ const typelibDir = join(libDir, TYPELIB_SUBDIR);
139
+ if (existsDir(typelibDir))
140
+ add(typelibDir);
141
+ }
142
+ return out;
143
+ }
144
+ /**
145
+ * The installed `<namespace>-<version>.typelib`, or `null`.
146
+ *
147
+ * `version` is GI's API version as it appears in a `gi://` specifier (`4.0` for
148
+ * `gi://Gtk?version=4.0`, `1` for Adwaita) — the same string the filename
149
+ * carries, so the caller states it exactly once.
150
+ */
151
+ export function findSystemTypelib(namespace, version, { existsFile = existsSync, ...options } = {}) {
152
+ const { join } = options.platform === 'win32' ? win32 : posix;
153
+ const leaf = `${namespace}-${version}.typelib`;
154
+ for (const dir of systemGiTypelibDirs(options)) {
155
+ const candidate = join(dir, leaf);
156
+ if (existsFile(candidate))
157
+ return candidate;
158
+ }
159
+ return null;
160
+ }
@@ -1,4 +1,7 @@
1
+ import type { SpawnCompletionContract } from './spawn.js';
1
2
  export interface RunNodeScriptOptions {
3
+ /** The caller's teardown contract, forwarded verbatim — see `RunGjsBundleOptions.completion`. */
4
+ completion: SpawnCompletionContract;
2
5
  /** Exit the process when the script succeeds. See `RunGjsBundleOptions.exitOnSuccess`. */
3
6
  exitOnSuccess?: boolean;
4
7
  }
@@ -9,4 +12,4 @@ export interface RunNodeScriptOptions {
9
12
  * Terminal callers pass `exitOnSuccess` (a GJS main loop armed by the spawn
10
13
  * parks the process otherwise); see {@link runGjsBundle}.
11
14
  */
12
- export declare function runNodeScript(file: string, extraArgs?: string[], options?: RunNodeScriptOptions): Promise<void>;
15
+ export declare function runNodeScript(file: string, extraArgs: string[] | undefined, options: RunNodeScriptOptions): Promise<void>;
@@ -59,7 +59,7 @@ function scriptCacheEnabled() {
59
59
  * Terminal callers pass `exitOnSuccess` (a GJS main loop armed by the spawn
60
60
  * parks the process otherwise); see {@link runGjsBundle}.
61
61
  */
62
- export async function runNodeScript(file, extraArgs = [], options = {}) {
62
+ export async function runNodeScript(file, extraArgs = [], options) {
63
63
  const scriptPath = resolve(file);
64
64
  if (!existsSync(scriptPath) || !statSync(scriptPath).isFile()) {
65
65
  console.error(`gjsify run --node-script: no such file: ${scriptPath}`);
@@ -72,7 +72,11 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
72
72
  // differ from what the runtime would have done itself. `quiet` because the echo
73
73
  // would print `node <file>` — what the package.json script already reads — behind
74
74
  // ~2 kB of native-library env, a dozen times per build chain.
75
- await runRuntimeBundle(host, scriptPath, extraArgs, { exitOnSuccess: options.exitOnSuccess, quiet: true });
75
+ await runRuntimeBundle(host, scriptPath, extraArgs, {
76
+ completion: options.completion,
77
+ exitOnSuccess: options.exitOnSuccess,
78
+ quiet: true,
79
+ });
76
80
  return;
77
81
  }
78
82
  // Dynamic import keeps the bundler machinery out of the eager module graph
@@ -90,6 +94,22 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
90
94
  define: { 'import.meta.url': JSON.stringify(pathToFileURL(scriptPath).href) },
91
95
  globals,
92
96
  excludeGlobals,
97
+ // OPT-IN, and the widest of the three call sites — say what it does and
98
+ // does not mean. This function is reached through the `node` shim `gjsify
99
+ // run` puts on the PATH, so it also bundles a consumer's
100
+ // `"start": "node ./src/main.mjs"`, not only a repo build script. The
101
+ // discriminator is therefore "the CLI is bundling this on the project's
102
+ // behalf", NOT "this is toolchain".
103
+ //
104
+ // Narrowing it to the CLI's own tree was considered and does not work: in
105
+ // the ADR 0002 bootstrap the CLI is a GLOBAL install (`install.mjs` →
106
+ // `gjsify install -g`) and the script is in the user's clone, so no
107
+ // containment test relates them. What contains the hazard instead is
108
+ // PROJECT FIRST (a resolvable dependency is never shadowed) plus the
109
+ // `this.warn` the guard emits on every rescue, which names the package and
110
+ // the anchor — so a mixed artifact is visible in the build log rather than
111
+ // byte-identical to a healthy one.
112
+ resolveFromToolchain: true,
93
113
  });
94
114
  }
95
115
  catch (err) {
@@ -99,5 +119,9 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
99
119
  }
100
120
  // Quiet for the same reason as the node path above; the bundle's location is
101
121
  // deterministic (`node_modules/.cache/gjsify/node-scripts/<name>-<hash>/<name>.mjs`).
102
- await runGjsBundle(bundlePath, extraArgs, { exitOnSuccess: options.exitOnSuccess, quiet: true });
122
+ await runGjsBundle(bundlePath, extraArgs, {
123
+ completion: options.completion,
124
+ exitOnSuccess: options.exitOnSuccess,
125
+ quiet: true,
126
+ });
103
127
  }
@@ -80,11 +80,14 @@ export async function fetchGithubOidcToken(audience, log) {
80
80
  throw new OidcUnavailableError('GitHub Actions OIDC env vars (ACTIONS_ID_TOKEN_REQUEST_{URL,TOKEN}) not set. ' +
81
81
  'The calling workflow needs `permissions: id-token: write`.', 'no-env');
82
82
  }
83
- // Append the audience by STRING CONCATENATION, as @actions/core's getIDToken does. Do NOT
84
- // use `URL.searchParams.set` + `.href`: under GJS that serialization dropped the `audience`
85
- // param, so GitHub issued an id-token with the DEFAULT audience (the repo API URL) and npm
86
- // rejected an otherwise-valid token with 401 — repository/workflow_ref/sub all matched the
87
- // Trusted Publisher, only `aud` was wrong.
83
+ // Append the audience by STRING CONCATENATION, as @actions/core's getIDToken does.
84
+ //
85
+ // fixed upstream in gjsify: `URL.searchParams.set` + `.href` used to DROP the parameter under
86
+ // GJS — the mutation never reached the serialisation — so GitHub issued an id-token with the
87
+ // DEFAULT audience (the repo API URL) and npm rejected an otherwise-valid token with 401:
88
+ // repository/workflow_ref/sub all matched the Trusted Publisher, only `aud` was wrong. The
89
+ // URL live view now works, so the ban is lifted; the concatenation stays because it is what
90
+ // the reference implementation does, not because the alternative is broken.
88
91
  const sep = url.includes('?') ? '&' : '?';
89
92
  const requestUrl = `${url}${sep}audience=${encodeURIComponent(audience)}`;
90
93
  log?.(`gjsify oidc: GET ${requestUrl.replace(bearer, '<bearer>')}`);
@@ -1,4 +1,5 @@
1
1
  import { type NativeEnv } from './detect-native-packages.js';
2
+ import { type SpawnCompletionContract } from './spawn.js';
2
3
  /**
3
4
  * Pure env computation: the typelib + shared-library search paths {@link runGjsBundle} would
4
5
  * inject into the spawned `gjs`, plus the formatted prefix for the `$ …` echo. Which library
@@ -15,6 +16,16 @@ export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: stri
15
16
  * bundle's own node_modules tree.
16
17
  */
17
18
  export interface RunGjsBundleOptions {
19
+ /**
20
+ * The teardown contract of the command that will end this process — the same field,
21
+ * and the same reason, `spawnToCompletion` demands. Required rather than defaulted:
22
+ * absent, a caller silently inherited the side that ARMS the GJS main loop.
23
+ *
24
+ * Distinct from {@link exitOnSuccess}, which asks WHO performs the exit. `gjsify test`
25
+ * is `'exit'` — its handler exits at the end of `commands/test.ts` — while leaving
26
+ * `exitOnSuccess` off, because an exit HERE would truncate its multi-runtime loop.
27
+ */
28
+ completion: SpawnCompletionContract;
18
29
  /**
19
30
  * Exit this process with code 0 once the child succeeds. Under GJS, `ensureMainLoop()`
20
31
  * (armed by spawn) keeps the parent's GLib loop alive after the child exits, so a TERMINAL
@@ -31,4 +42,4 @@ export interface RunGjsBundleOptions {
31
42
  */
32
43
  quiet?: boolean;
33
44
  }
34
- export declare function runGjsBundle(bundlePath: string, extraArgs?: string[], options?: RunGjsBundleOptions): Promise<void>;
45
+ export declare function runGjsBundle(bundlePath: string, extraArgs: string[] | undefined, options: RunGjsBundleOptions): Promise<void>;
@@ -9,9 +9,9 @@
9
9
  //
10
10
  // Env composition is split out as the pure `computeNativeEnvForBundle()` so the e2e tests can
11
11
  // assert the env without spawning gjs.
12
- import { spawn } from 'node:child_process';
13
12
  import { dirname, resolve } from 'node:path';
14
13
  import { detectNativePackages, buildNativeEnv } from './detect-native-packages.js';
14
+ import { describeExit, spawnToCompletion } from './spawn.js';
15
15
  /**
16
16
  * Pure env computation: the typelib + shared-library search paths {@link runGjsBundle} would
17
17
  * inject into the spawned `gjs`, plus the formatted prefix for the `$ …` echo. Which library
@@ -40,7 +40,7 @@ export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inher
40
40
  .join(' ');
41
41
  return { env, envPrefix };
42
42
  }
43
- export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
43
+ export async function runGjsBundle(bundlePath, extraArgs = [], options) {
44
44
  const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath);
45
45
  const env = {
46
46
  ...process.env,
@@ -52,36 +52,29 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
52
52
  const gjsCommand = ['gjs', ...gjsArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
53
53
  if (!options.quiet)
54
54
  console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${gjsCommand}`);
55
- const child = spawn('gjs', gjsArgs, { env, stdio: 'inherit' });
56
- let failed = false;
57
- // The CHILD's own exit code, re-raised verbatim; a spawn error (no `gjs` on PATH) has none
58
- // of its own and falls back to 1. Collapsing every failure to 1 lost information callers
59
- // act on — a chain reading `$?` to tell "tool failed" from "tool says no" saw 1 for both.
60
- let childCode = 1;
61
- await new Promise((resolvePromise, reject) => {
62
- child.on('close', (code) => {
63
- if (code !== 0) {
64
- childCode = code ?? 1;
65
- reject(new Error(`gjs exited with code ${code}`));
66
- }
67
- else {
68
- resolvePromise();
69
- }
70
- });
71
- child.on('error', reject);
72
- }).catch((err) => {
55
+ // The CHILD's own exit code is re-raised verbatim; a spawn error (no `gjs` on PATH) has
56
+ // none of its own and falls back to 1. Collapsing every failure to 1 lost information
57
+ // callers act on — a chain reading `$?` to tell "tool failed" from "tool says no" saw 1
58
+ // for both.
59
+ const failed = (code) => {
60
+ // `process.exitCode` is set BEFORE exiting because `gjsify run <script>` dispatches a
61
+ // nested `gjsify run <bundle>` IN PROCESS: the outer command reads `process.exitCode`
62
+ // to propagate. Without it a failing `test:gjs` returned 0 through the `test` chain,
63
+ // and through `gjsify foreach test` in CI. The `return` guards a fall-through exit.
64
+ process.exitCode = code;
65
+ return process.exit(code);
66
+ };
67
+ let result;
68
+ try {
69
+ result = await spawnToCompletion('gjs', gjsArgs, { completion: options.completion, env });
70
+ }
71
+ catch (err) {
73
72
  console.error(err.message);
74
- failed = true;
75
- });
76
- if (failed) {
77
- // Set `process.exitCode` synchronously BEFORE exiting: under GJS `process.exit()` is
78
- // deferred (the GLib main loop is armed), so under run.ts's in-process script dispatch
79
- // execution returns to the caller, which propagates by reading `process.exitCode`.
80
- // Without it, `gjsify run <script>` wrapping `gjsify run <bundle>` swallowed a non-zero
81
- // gjs exit and returned 0 — masking a failing `test:gjs` in the `test` chain, and via
82
- // `gjsify foreach test` in CI. The `return` guards a fall-through second `exit`.
83
- process.exitCode = childCode;
84
- return process.exit(childCode);
73
+ return failed(1);
74
+ }
75
+ if (result.code !== 0) {
76
+ console.error(`gjs exited with ${describeExit(result)}`);
77
+ return failed(result.code ?? 1);
85
78
  }
86
79
  // See RunGjsBundleOptions.exitOnSuccess — terminal callers opt in,
87
80
  // mid-flow callers (test.ts's runtime loop) keep the process alive.