@gjsify/cli 0.21.0 → 0.23.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 (37) hide show
  1. package/README.md +24 -1
  2. package/dist/cli.gjs.mjs +257 -184
  3. package/lib/bundler-pick.js +12 -4
  4. package/lib/commands/affected.js +22 -10
  5. package/lib/commands/check.js +38 -29
  6. package/lib/commands/flatpak/build.js +19 -18
  7. package/lib/commands/foreach.js +48 -41
  8. package/lib/commands/info.js +11 -5
  9. package/lib/commands/install.d.ts +16 -0
  10. package/lib/commands/install.js +103 -32
  11. package/lib/commands/onboard.js +6 -1
  12. package/lib/commands/pack.js +69 -4
  13. package/lib/commands/run.js +26 -14
  14. package/lib/commands/trust.js +4 -62
  15. package/lib/commands/workspace.js +49 -30
  16. package/lib/utils/bin-shim.d.ts +66 -0
  17. package/lib/utils/bin-shim.js +246 -0
  18. package/lib/utils/build-cache.d.ts +42 -6
  19. package/lib/utils/build-cache.js +114 -42
  20. package/lib/utils/detect-native-packages.d.ts +112 -6
  21. package/lib/utils/detect-native-packages.js +201 -29
  22. package/lib/utils/install-backend-native.d.ts +55 -0
  23. package/lib/utils/install-backend-native.js +152 -13
  24. package/lib/utils/install-global.d.ts +16 -0
  25. package/lib/utils/install-global.js +58 -8
  26. package/lib/utils/oxc-resolve.js +18 -55
  27. package/lib/utils/publish-headers.d.ts +2 -3
  28. package/lib/utils/publish-headers.js +64 -3
  29. package/lib/utils/publishable-packages.d.ts +22 -0
  30. package/lib/utils/publishable-packages.js +93 -0
  31. package/lib/utils/run-gjs.d.ts +6 -7
  32. package/lib/utils/run-gjs.js +4 -3
  33. package/lib/utils/spawn.d.ts +82 -0
  34. package/lib/utils/spawn.js +139 -0
  35. package/lib/utils/stdin.d.ts +25 -0
  36. package/lib/utils/stdin.js +132 -0
  37. package/package.json +23 -18
@@ -23,7 +23,8 @@
23
23
  import * as fs from 'node:fs';
24
24
  import * as os from 'node:os';
25
25
  import * as path from 'node:path';
26
- import { detectNativePackages } from './detect-native-packages.js';
26
+ import { detectNativePackages, libraryPathVar } from './detect-native-packages.js';
27
+ import { buildLauncherShims, parseShebang } from './bin-shim.js';
27
28
  /**
28
29
  * Compute the canonical global install layout for the current user. Honours
29
30
  * `XDG_DATA_HOME` (per the XDG Base Directory Spec) plus
@@ -174,7 +175,9 @@ export function linkGlobalBins(packageNames, layout) {
174
175
  /* best effort */
175
176
  }
176
177
  const linkPath = path.join(layout.binDir, binName);
177
- fs.rmSync(linkPath, { force: true });
178
+ for (const stale of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`]) {
179
+ fs.rmSync(stale, { force: true, maxRetries: 10, retryDelay: 100 });
180
+ }
178
181
  // Inline `${target}` directly — this file is rewritten on every
179
182
  // install, paths are user-owned, and POSIX `sh` quoting via
180
183
  // single-quotes plus `'\''` for embedded quotes is well-defined.
@@ -196,6 +199,36 @@ export function linkGlobalBins(packageNames, layout) {
196
199
  : `#!/bin/sh\nexec ${shQuote(targetAbs)} "$@"\n`;
197
200
  fs.writeFileSync(linkPath, launcher);
198
201
  fs.chmodSync(linkPath, 0o755);
202
+ // Windows runs neither an extension-less file nor a `#!` line, so
203
+ // the `sh` launcher above is only reachable from git-bash/MSYS/WSL.
204
+ // Write the `.cmd`/`.ps1` companions cmd.exe and pwsh need — the
205
+ // same three-file layout npm's cmd-shim produces.
206
+ if (process.platform === 'win32') {
207
+ const joined = nativePrebuildDirs.join(';');
208
+ // No dedicated DLL-search variable on Windows: LoadLibrary
209
+ // searches PATH, and GLib's search-path separator is `;` there
210
+ // too — so both variables take the same `;`-joined value.
211
+ const prependEnv = isGjsBundle && nativePrebuildDirs.length > 0 ? { GI_TYPELIB_PATH: joined, PATH: joined } : {};
212
+ // A non-bundle bin is exec'd directly by the sh launcher, which
213
+ // relies on its `#!` line; batch/pwsh have no shebang support,
214
+ // so read the interpreter out of it the way cmd-shim does
215
+ // (defaulting to `node`, what every npm bin declares).
216
+ const shebang = isGjsBundle ? null : readShebangOf(targetAbs);
217
+ const shims = isGjsBundle
218
+ ? buildLauncherShims({
219
+ interpreter: 'gjs',
220
+ interpreterArgs: ['-m'],
221
+ target: targetAbs,
222
+ prependEnv,
223
+ })
224
+ : buildLauncherShims({
225
+ interpreter: shebang?.prog || 'node',
226
+ interpreterArgs: shebang?.args.trim() ? shebang.args.trim().split(/\s+/) : [],
227
+ target: targetAbs,
228
+ });
229
+ fs.writeFileSync(`${linkPath}.cmd`, shims.cmd);
230
+ fs.writeFileSync(`${linkPath}.ps1`, shims.ps1);
231
+ }
199
232
  created.push({ name: binName, target: targetAbs, link: linkPath });
200
233
  }
201
234
  }
@@ -206,20 +239,37 @@ function shQuote(s) {
206
239
  }
207
240
  /**
208
241
  * Build the POSIX-sh `export` lines that prepend the given prebuild
209
- * directories to GI_TYPELIB_PATH and LD_LIBRARY_PATH. Any pre-existing value
210
- * inherited from the user's environment is preserved as a suffix so
211
- * user-installed typelibs/libraries still resolve.
242
+ * directories to GI_TYPELIB_PATH and the host's shared-library search path.
243
+ * Any pre-existing value inherited from the user's environment is preserved as
244
+ * a suffix so user-installed typelibs/libraries still resolve.
245
+ *
246
+ * The library variable is host-dependent — `LD_LIBRARY_PATH` on Linux,
247
+ * `DYLD_LIBRARY_PATH` on macOS (dyld never reads the ELF one), so a launcher
248
+ * written on a Mac used to export a variable nothing consults, leaving every
249
+ * `darwin-arm64` prebuild unloadable. `platform` is a parameter so both
250
+ * branches are unit-testable from either host.
212
251
  *
213
252
  * Returns the empty string when no prebuilds were found — avoids emitting an
214
253
  * inert assignment in the launcher.
215
254
  */
216
- function buildLauncherEnvPreamble(prebuildsDirs) {
255
+ export function buildLauncherEnvPreamble(prebuildsDirs, platform = process.platform) {
217
256
  if (prebuildsDirs.length === 0)
218
257
  return '';
258
+ const { name: libVar } = libraryPathVar(platform);
219
259
  const joined = shQuote(prebuildsDirs.join(':'));
220
260
  return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
221
- `LD_LIBRARY_PATH=${joined}\${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}\n` +
222
- `export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
261
+ `${libVar}=${joined}\${${libVar}:+":$${libVar}"}\n` +
262
+ `export GI_TYPELIB_PATH ${libVar}\n`);
263
+ }
264
+ /** Parse a bin file's `#!` line; `null` when unreadable or not a shebang. */
265
+ function readShebangOf(file) {
266
+ try {
267
+ const head = fs.readFileSync(file, 'utf-8');
268
+ return parseShebang(head.trimStart().split(/\r*\n/, 1)[0] ?? '');
269
+ }
270
+ catch {
271
+ return null;
272
+ }
223
273
  }
224
274
  function pickBinMap(pkgName, pkgJson) {
225
275
  const gjsifyEntry = pkgJson.gjsify;
@@ -48,12 +48,12 @@
48
48
  // (tracked in STATUS.md Open TODOs).
49
49
  import { existsSync, readFileSync } from 'node:fs';
50
50
  import { join, resolve } from 'node:path';
51
- import { spawn, spawnSync } from 'node:child_process';
52
51
  import { createRequire } from 'node:module';
53
52
  import { pathToFileURL } from 'node:url';
54
53
  import { findWorkspaceRoot } from './workspace-root.js';
55
54
  import { resolveNpmPackage } from './resolve-npm-package.js';
56
55
  import { nodeBinary } from './run-node.js';
56
+ import { spawnToCompletion } from './spawn.js';
57
57
  import { isGjs, gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
58
58
  /**
59
59
  * Map Node.js `process.platform` + `arch` (+ musl on Linux) to the
@@ -298,6 +298,14 @@ export async function runOxc(tool, args, opts = {}) {
298
298
  * launcher as its first positional (yargs prints top-level help) and the parent
299
299
  * hangs waiting on a child that never does the intended work. `nodeBinary()`
300
300
  * resolves to PATH `node` under GJS, so the launcher runs on Node as required.
301
+ *
302
+ * `completion: 'return'`: `lint` / `format` / `fix` hand the tool's exit code
303
+ * to `setOxcExitCode`, which deliberately does NOT terminate the process on a
304
+ * clean run — so under GJS this must take `spawnToCompletion`'s blocking path
305
+ * (see `utils/spawn.ts`; the async one would leave the main loop `spawn()`
306
+ * arms parked at 0% CPU on the way out). That path also captures and re-emits
307
+ * the tool's stdout/stderr with a raised `maxBuffer`, which is exactly what a
308
+ * full-workspace lint needs.
301
309
  */
302
310
  function spawnOxcLauncher(tool, args, opts = {}) {
303
311
  const cwd = opts.cwd ?? process.cwd();
@@ -306,61 +314,16 @@ function spawnOxcLauncher(tool, args, opts = {}) {
306
314
  if (opts.verbose) {
307
315
  console.log(`[gjsify oxc] ${node} ${launcher} ${args.join(' ')}`);
308
316
  }
309
- // Under the GJS bundle the ASYNC spawn's exit/close event is never delivered
310
- // back to JS: the child runs, streams its output, and exits (reaped, no
311
- // zombie), but the `Gio.Subprocess` `wait_async` callback that emits
312
- // `exit`/`close` does not fire, so the parent's await hangs at 0% CPU
313
- // forever (the process sits in `SNl`). `spawnSync` uses the synchronous
314
- // `communicate()` path instead — no async callback to lose — so it
315
- // completes reliably. It blocks, which is exactly right for a CLI leaf
316
- // command that just runs the tool to completion. On Node the async spawn is
317
- // fine (and keeps the event loop free), so it is kept there.
318
- if (isGjs()) {
319
- // Capture stdout/stderr (`'inherit'` is not forwarded through the GJS
320
- // spawnSync path — the tool's diagnostics would be silently lost), then
321
- // re-emit them so oxlint/oxfmt's own output still reaches the user.
322
- // maxBuffer generous: a full-workspace lint with many findings can emit
323
- // well past the 1 MB Node default; overflow would truncate diagnostics.
324
- const r = spawnSync(node, [launcher, ...args], {
325
- stdio: ['inherit', 'pipe', 'pipe'],
326
- cwd,
327
- maxBuffer: 64 * 1024 * 1024,
328
- });
329
- if (r.error) {
330
- const code = r.error.code;
331
- if (code === 'ENOENT')
332
- return Promise.reject(new OxcNotFoundError(tool, cwd));
333
- return Promise.reject(r.error);
334
- }
335
- if (r.stdout && r.stdout.length)
336
- process.stdout.write(r.stdout);
337
- if (r.stderr && r.stderr.length)
338
- process.stderr.write(r.stderr);
339
- if (r.signal) {
340
- console.error(`[gjsify oxc] ${tool} terminated by signal ${r.signal}`);
341
- return Promise.resolve(1);
317
+ return spawnToCompletion(node, [launcher, ...args], {
318
+ completion: 'return',
319
+ cwd,
320
+ notFound: () => new OxcNotFoundError(tool, cwd),
321
+ }).then(({ code, signal }) => {
322
+ if (signal !== null) {
323
+ console.error(`[gjsify oxc] ${tool} terminated by signal ${signal}`);
324
+ return 1;
342
325
  }
343
- return Promise.resolve(r.status ?? 0);
344
- }
345
- return new Promise((res, rej) => {
346
- const spawnOpts = { stdio: 'inherit', cwd };
347
- const child = spawn(node, [launcher, ...args], spawnOpts);
348
- child.on('error', (err) => {
349
- if (err.code === 'ENOENT') {
350
- rej(new OxcNotFoundError(tool, cwd));
351
- }
352
- else {
353
- rej(err);
354
- }
355
- });
356
- child.on('exit', (code, signal) => {
357
- if (signal) {
358
- console.error(`[gjsify oxc] ${tool} terminated by signal ${signal}`);
359
- res(1);
360
- return;
361
- }
362
- res(code ?? 0);
363
- });
326
+ return code ?? 0;
364
327
  });
365
328
  }
366
329
  /** Convenience wrappers. */
@@ -24,9 +24,8 @@ export interface PublishHeaderOptions {
24
24
  */
25
25
  authorization?: string;
26
26
  /**
27
- * npm user-agent. Defaults to an npm-CLI-shaped string. The registry does
28
- * not validate the exact value, but a real UA is what npm sends; the dead
29
- * `gjsify-install/0.3.7` placeholder is not used here.
27
+ * npm user-agent. Defaults to an npm-CLI-shaped string carrying this CLI's
28
+ * real version — never a hard-coded placeholder (see {@link cliVersion}).
30
29
  */
31
30
  userAgent?: string;
32
31
  }
@@ -27,8 +27,12 @@
27
27
  // literal `@`. (gjsify's URL encoding already matches this — it was never
28
28
  // the bug.)
29
29
  //
30
- // This module is pure (no I/O, no `gi://`) so it is unit-testable on both Node
31
- // and GJS without dragging in the runtime fetch/Soup stack.
30
+ // This module pulls in no `gi://` and no runtime fetch/Soup stack, so it is
31
+ // unit-testable on both Node and GJS. Its one filesystem touch is the
32
+ // manifest walk backing the user-agent version — see `cliVersion()`.
33
+ import { readFileSync } from 'node:fs';
34
+ import { dirname } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
32
36
  import { buildHeaders } from '@gjsify/npm-registry';
33
37
  /**
34
38
  * Percent-encode a package name for the publish PUT path, matching
@@ -85,6 +89,63 @@ export function buildPublishHeaders(url, opts) {
85
89
  headers['authorization'] = opts.authorization;
86
90
  return headers;
87
91
  }
92
+ /**
93
+ * This CLI's own `package.json#version`, memoised. Two layers, because
94
+ * `@gjsify/cli` is a DUAL-ENTRY package and neither layer covers both entries:
95
+ *
96
+ * 1. **`__PACKAGE_VERSION__`** — the compile-time define
97
+ * (`package.json#gjsify.defineFromPackageJson`), the mechanism
98
+ * `@gjsify/npm-registry` uses for the same stale-UA bug. Zero I/O, but the
99
+ * bundler only ever writes `dist/cli.gjs.mjs`: the npm-wired Node bin under
100
+ * `lib/` is emitted by plain `tsc`, which knows nothing about defines. A
101
+ * define alone would therefore have left the Node entry — the one `npm
102
+ * install @gjsify/cli` actually runs — on a permanent placeholder.
103
+ *
104
+ * 2. **an upward manifest walk** for the nearest `package.json` naming
105
+ * `@gjsify/cli`, covering `lib/`. Deliberately NOT a fixed
106
+ * `new URL('../../package.json', import.meta.url)` read: that resolves
107
+ * correctly from `lib/utils/` but points one directory too high from
108
+ * `dist/`, and the build-time `inlineStaticReads` pass that would have
109
+ * frozen it is scoped to `node_modules` paths, so first-party source never
110
+ * gets it. The walk is depth-independent and so works from `src/`, `lib/`,
111
+ * `dist/`, and from an installed `node_modules/@gjsify/cli/**` alike.
112
+ *
113
+ * `GJSIFY_CLI_PACKAGE_JSON` overrides both, matching `self-update.ts`.
114
+ */
115
+ let cachedCliVersion;
116
+ function cliVersion() {
117
+ if (cachedCliVersion !== undefined)
118
+ return cachedCliVersion;
119
+ cachedCliVersion =
120
+ (typeof __PACKAGE_VERSION__ === 'string' ? __PACKAGE_VERSION__ : '') || walkForCliVersion() || 'unknown';
121
+ return cachedCliVersion;
122
+ }
123
+ /** Nearest ancestor `package.json` whose `name` is `@gjsify/cli`; '' when none. */
124
+ function walkForCliVersion() {
125
+ const override = globalThis.process?.env?.['GJSIFY_CLI_PACKAGE_JSON'];
126
+ let dir;
127
+ try {
128
+ dir = override ? dirname(override) : dirname(fileURLToPath(import.meta.url));
129
+ }
130
+ catch {
131
+ return '';
132
+ }
133
+ for (let i = 0; i < 12; i++) {
134
+ try {
135
+ const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'));
136
+ if (pkg.name === '@gjsify/cli' && typeof pkg.version === 'string')
137
+ return pkg.version;
138
+ }
139
+ catch {
140
+ /* no manifest here (or unreadable) — keep climbing */
141
+ }
142
+ const parent = dirname(dir);
143
+ if (parent === dir)
144
+ break;
145
+ dir = parent;
146
+ }
147
+ return '';
148
+ }
88
149
  /**
89
150
  * npm-CLI-shaped user-agent. Format mirrors npm-registry-fetch's
90
151
  * `default-opts.js` (`npm@<v>/node@<v>+<arch> (<platform>)`); we substitute the
@@ -95,5 +156,5 @@ function defaultUserAgent() {
95
156
  const proc = globalThis.process;
96
157
  const arch = proc?.arch ?? 'x64';
97
158
  const platform = proc?.platform ?? 'linux';
98
- return `gjsify-publish/0.5.0 (${platform} ${arch})`;
159
+ return `gjsify-publish/${cliVersion()} (${platform} ${arch})`;
99
160
  }
@@ -0,0 +1,22 @@
1
+ import type { Workspace } from '@gjsify/workspace';
2
+ /**
3
+ * Package-group directories holding publishable packages that are NOT root
4
+ * workspaces. Add one here the moment a new non-workspace publishable group
5
+ * appears — this list is the only thing standing between such a package and a
6
+ * silently broken first release.
7
+ */
8
+ export declare const NON_WORKSPACE_PUBLISHABLE_DIRS: readonly [readonly ["packages", "node-gi"], readonly ["packages", "napi"]];
9
+ /**
10
+ * Publishable packages in {@link NON_WORKSPACE_PUBLISHABLE_DIRS}, shaped as
11
+ * `Workspace` entries so callers can treat them like any other package.
12
+ *
13
+ * Best-effort by design: a missing directory or an unreadable/malformed
14
+ * manifest is skipped rather than thrown, because this runs inside sweeps whose
15
+ * value is covering everything they can reach.
16
+ */
17
+ export declare function discoverNonWorkspacePublishables(cwd: string): Workspace[];
18
+ /**
19
+ * Merge workspace-discovered packages with the non-workspace publishables,
20
+ * deduped by name. A real workspace always wins over a scanned directory.
21
+ */
22
+ export declare function mergePublishables(workspaces: Workspace[], cwd: string): Workspace[];
@@ -0,0 +1,93 @@
1
+ // Publishable-package discovery shared by `gjsify trust` and `gjsify onboard`.
2
+ //
3
+ // `discoverWorkspaces` only sees ROOT WORKSPACES. Some publishable packages
4
+ // deliberately are not: each is an independent native engine with its own CI
5
+ // workflow, kept out of the workspace graph so a full `gjsify foreach` never
6
+ // tries to build it. They still publish through `release.yml`, so every sweep
7
+ // that reasons about "what do we publish" has to include them — otherwise the
8
+ // sweep silently under-reports and the package's first release fails the OIDC
9
+ // exchange with `404 — package not found`.
10
+ //
11
+ // That is not hypothetical: `gjsify onboard` reported 127 packages "already
12
+ // done" on 2026-07-26 while `@gjsify/napi` was neither in the list nor
13
+ // published at all, because only `packages/node-gi/*` was carved out.
14
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ /**
17
+ * Package-group directories holding publishable packages that are NOT root
18
+ * workspaces. Add one here the moment a new non-workspace publishable group
19
+ * appears — this list is the only thing standing between such a package and a
20
+ * silently broken first release.
21
+ */
22
+ export const NON_WORKSPACE_PUBLISHABLE_DIRS = [
23
+ ['packages', 'node-gi'], // @gjsify/node-gi + the @gjsify/gtk-runtime-* bundles
24
+ ['packages', 'napi'], // @gjsify/napi (the N-API host in GJS, own napi.yml)
25
+ ];
26
+ /**
27
+ * Publishable packages in {@link NON_WORKSPACE_PUBLISHABLE_DIRS}, shaped as
28
+ * `Workspace` entries so callers can treat them like any other package.
29
+ *
30
+ * Best-effort by design: a missing directory or an unreadable/malformed
31
+ * manifest is skipped rather than thrown, because this runs inside sweeps whose
32
+ * value is covering everything they can reach.
33
+ */
34
+ export function discoverNonWorkspacePublishables(cwd) {
35
+ // Walk up to the repo root. Anchored on the FIRST configured group rather
36
+ // than on `packages/` itself, which also exists inside individual packages.
37
+ let root = cwd;
38
+ const anchor = NON_WORKSPACE_PUBLISHABLE_DIRS[0];
39
+ for (let i = 0; i < 8; i++) {
40
+ if (existsSync(join(root, ...anchor)))
41
+ break;
42
+ const parent = dirname(root);
43
+ if (parent === root)
44
+ return [];
45
+ root = parent;
46
+ }
47
+ const out = [];
48
+ for (const group of NON_WORKSPACE_PUBLISHABLE_DIRS) {
49
+ const base = join(root, ...group);
50
+ if (!existsSync(base))
51
+ continue;
52
+ let entries;
53
+ try {
54
+ entries = readdirSync(base, { withFileTypes: true });
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ for (const entry of entries) {
60
+ if (!entry.isDirectory())
61
+ continue;
62
+ const location = join(base, entry.name);
63
+ const pkgPath = join(location, 'package.json');
64
+ if (!existsSync(pkgPath))
65
+ continue;
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(pkgPath, 'utf8'));
68
+ if (!manifest || typeof manifest.name !== 'string' || manifest.name.length === 0)
69
+ continue;
70
+ out.push({
71
+ location,
72
+ relativeLocation: join(...group, entry.name),
73
+ name: manifest.name,
74
+ version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
75
+ manifest,
76
+ private: manifest.private === true,
77
+ });
78
+ }
79
+ catch {
80
+ /* unreadable / malformed — skip */
81
+ }
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * Merge workspace-discovered packages with the non-workspace publishables,
88
+ * deduped by name. A real workspace always wins over a scanned directory.
89
+ */
90
+ export function mergePublishables(workspaces, cwd) {
91
+ const seen = new Set(workspaces.map((ws) => ws.name));
92
+ return [...workspaces, ...discoverNonWorkspacePublishables(cwd).filter((ws) => !seen.has(ws.name))];
93
+ }
@@ -1,14 +1,13 @@
1
+ import { type NativeEnv } from './detect-native-packages.js';
1
2
  /**
2
- * Pure env computation for a given bundle. Returns the LD_LIBRARY_PATH /
3
- * GI_TYPELIB_PATH values that {@link runGjsBundle} would inject into the
3
+ * Pure env computation for a given bundle. Returns the typelib +
4
+ * shared-library search paths that {@link runGjsBundle} would inject into the
4
5
  * spawned `gjs` process, plus the formatted env-prefix string used for the
5
- * `$ …` echo.
6
+ * `$ …` echo. Which library variable is set is host-dependent
7
+ * (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see `buildNativeEnv`.
6
8
  */
7
9
  export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: string): {
8
- env: {
9
- LD_LIBRARY_PATH: string;
10
- GI_TYPELIB_PATH: string;
11
- };
10
+ env: NativeEnv;
12
11
  envPrefix: string;
13
12
  };
14
13
  /**
@@ -20,10 +20,11 @@ import { spawn } from 'node:child_process';
20
20
  import { dirname, resolve } from 'node:path';
21
21
  import { detectNativePackages, buildNativeEnv } from './detect-native-packages.js';
22
22
  /**
23
- * Pure env computation for a given bundle. Returns the LD_LIBRARY_PATH /
24
- * GI_TYPELIB_PATH values that {@link runGjsBundle} would inject into the
23
+ * Pure env computation for a given bundle. Returns the typelib +
24
+ * shared-library search paths that {@link runGjsBundle} would inject into the
25
25
  * spawned `gjs` process, plus the formatted env-prefix string used for the
26
- * `$ …` echo.
26
+ * `$ …` echo. Which library variable is set is host-dependent
27
+ * (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see `buildNativeEnv`.
27
28
  */
28
29
  export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd()) {
29
30
  const resolvedBundle = resolve(bundlePath);
@@ -0,0 +1,82 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+ /**
3
+ * What the calling command does once this spawn settles.
4
+ *
5
+ * - `'exit'` — the command terminates the process itself (`process.exit(…)`,
6
+ * `forceExit(…)`, …). The armed GJS main loop is quit by that exit, so the
7
+ * streaming async `spawn` is safe on both hosts.
8
+ * - `'return'` — the command returns normally and lets the CLI unwind. Under
9
+ * GJS that MUST NOT leave a main loop armed, so the child is run on the
10
+ * blocking `spawnSync` path instead.
11
+ *
12
+ * See the module header for the full rationale.
13
+ */
14
+ export type SpawnCompletionContract = 'exit' | 'return';
15
+ export interface SpawnToCompletionOptions {
16
+ /** The caller's teardown contract. Required — see {@link SpawnCompletionContract}. */
17
+ completion: SpawnCompletionContract;
18
+ /** Working directory for the child. Defaults to the parent's. */
19
+ cwd?: string;
20
+ /**
21
+ * Environment for the child. Omit to inherit the parent's environment
22
+ * verbatim (what `spawn` does with no `env`).
23
+ */
24
+ env?: NodeJS.ProcessEnv;
25
+ /**
26
+ * Seed `FORCE_COLOR=1` (on top of `env` ?? `process.env`) unless the user
27
+ * explicitly set `FORCE_COLOR` or `NO_COLOR`, so tools that key on
28
+ * `process.stdout.isTTY` (chalk, picocolors, …) still emit ANSI when their
29
+ * stdout is a pipe. Mirrors yarn / npm. Default `false`.
30
+ */
31
+ color?: boolean;
32
+ /**
33
+ * `'inherit'` (default) forwards the parent's stdio. `'pipe'` gives the
34
+ * caller `child.stdout` / `child.stderr` via {@link onSpawn} — only
35
+ * meaningful together with `completion: 'exit'` (see {@link onSpawn}).
36
+ */
37
+ stdio?: 'inherit' | 'pipe';
38
+ /**
39
+ * Invoked synchronously with the live `ChildProcess` right after a
40
+ * successful async spawn — the seam for kill-tracking registries and for
41
+ * wiring piped output forwarders.
42
+ *
43
+ * ONLY fires on the streaming path: the blocking `spawnSync` path has no
44
+ * `ChildProcess` handle at all. A site that needs the handle must
45
+ * therefore declare `completion: 'exit'`.
46
+ */
47
+ onSpawn?: (child: ChildProcess) => void;
48
+ /**
49
+ * Map an `ENOENT` spawn failure (command not on PATH) to a domain error
50
+ * carrying an install hint. Other spawn errors are rejected as-is.
51
+ */
52
+ notFound?: (err: NodeJS.ErrnoException) => Error;
53
+ /**
54
+ * Capture ceiling for the blocking path. Defaults to 64 MiB — a
55
+ * full-workspace lint with many findings runs well past Node's 1 MB
56
+ * default, and an overflow would silently truncate diagnostics.
57
+ */
58
+ maxBuffer?: number;
59
+ }
60
+ export interface SpawnCompletion {
61
+ /** The child's exit code, or `null` when it was terminated by a signal. */
62
+ code: number | null;
63
+ /** The terminating signal, or `null` on a normal exit. */
64
+ signal: NodeJS.Signals | null;
65
+ }
66
+ /**
67
+ * Run `cmd args…` to completion and resolve with its exit code and signal.
68
+ *
69
+ * Resolves for ANY child exit — a non-zero code is data, not an error, so each
70
+ * call site keeps its own failure message. Rejects only when the child could
71
+ * not be started at all (mapped through {@link SpawnToCompletionOptions.notFound}
72
+ * for ENOENT).
73
+ *
74
+ * Picks the streaming async path or the blocking captured path per the GJS
75
+ * teardown contract documented at the top of this file.
76
+ */
77
+ export declare function spawnToCompletion(cmd: string, args: readonly string[], opts: SpawnToCompletionOptions): Promise<SpawnCompletion>;
78
+ /**
79
+ * Format the "child failed" suffix the way the call sites report it: an exit
80
+ * code when the child exited, the signal name when it was killed.
81
+ */
82
+ export declare function describeExit(result: SpawnCompletion): string;