@gjsify/cli 0.22.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 +24 -0
  23. package/lib/utils/install-backend-native.js +89 -16
  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
@@ -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;
@@ -0,0 +1,139 @@
1
+ // One `spawn` → `Promise<{ code, signal }>` wrapper for the whole CLI.
2
+ //
3
+ // Six near-identical wrappers used to live in `commands/{foreach,check,
4
+ // workspace,install}.ts`, `flatpak/build.ts` and `utils/oxc-resolve.ts`. They
5
+ // diverged on the completion event (`close` vs `exit`), whether an ENOENT
6
+ // spawn failure got a human hint, whether `FORCE_COLOR` was seeded — and, the
7
+ // dangerous one, on how they handled the GJS teardown contract below.
8
+ //
9
+ // ## The GJS teardown contract — why this helper has a host branch
10
+ //
11
+ // `@gjsify/child_process`'s ASYNC `spawn()` calls `ensureMainLoop()`
12
+ // (`@gjsify/utils`) so the `Gio.Subprocess` `wait_async` exit callback has a
13
+ // running GLib main context to be dispatched on. `ensureMainLoop()` arms a
14
+ // GJS **main-loop hook** (`GLib.MainLoop.runAsync()` → `setMainLoopHook`), and
15
+ // GJS's `eval_module` runs that hook AFTER the entry module's top-level await
16
+ // settles — entering a blocking `g_main_loop_run()` that nothing ever quits.
17
+ // GJS has no atexit hook, so the only thing that tears it back down is
18
+ // `process.exit()` (whose `exitProcess` idle-schedules `quitMainLoop()` +
19
+ // `imports.system.exit()`).
20
+ //
21
+ // Consequence, measured on gjs 1.88 against the committed `dist/cli.gjs.mjs`:
22
+ // a command that async-spawns a child and then RETURNS NORMALLY parks at 0%
23
+ // CPU **after** the child has already exited and been reaped. The symptom —
24
+ // a live parent with no children, blocked in `poll`, `SNl` — is *identical*
25
+ // to a lost exit callback, which is how it was previously misdiagnosed as
26
+ // "the async `spawn`'s exit/close event is not delivered under the bundle".
27
+ // It IS delivered: instrumenting the exit handler shows it firing with the
28
+ // right code; the parent then parks in the armed loop on its way out.
29
+ //
30
+ // So the branch is NOT "GJS ⇒ spawnSync". It is exactly this:
31
+ //
32
+ // | host | caller ends with | path |
33
+ // |------|------------------------|-------------------------------------|
34
+ // | Node | anything | async `spawn` (streaming) |
35
+ // | GJS | `process.exit(…)` | async `spawn` (streaming) |
36
+ // | GJS | a normal `return` | blocking `spawnSync` (captured) |
37
+ //
38
+ // which is why `gjsify foreach` / `workspace` / `check` and `runGjsBundle` run
39
+ // hundreds of async-spawned children under GJS without ever hanging: every one
40
+ // of them ends its handler with `process.exit(…)`. `gjsify lint` did not, and
41
+ // that is the hang the `spawnSync` branch in `oxc-resolve.ts` was papering
42
+ // over.
43
+ //
44
+ // Callers therefore declare their teardown contract via the REQUIRED
45
+ // {@link SpawnToCompletionOptions.completion} field. It is required on
46
+ // purpose: a new call site must state which side of the table it is on rather
47
+ // than silently inherit a default that may be wrong for it.
48
+ import { spawn, spawnSync } from 'node:child_process';
49
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
50
+ /** Default capture ceiling on the blocking path — 64 MiB. */
51
+ const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
52
+ /**
53
+ * Build the child's environment: `undefined` (inherit) unless the caller
54
+ * passed one or asked for colour seeding.
55
+ */
56
+ function resolveEnv(opts) {
57
+ if (!opts.color)
58
+ return opts.env;
59
+ const base = opts.env ?? process.env;
60
+ const seed = process.env.FORCE_COLOR !== undefined || process.env.NO_COLOR !== undefined ? {} : { FORCE_COLOR: '1' };
61
+ return { ...base, ...seed };
62
+ }
63
+ /** Turn a spawn-time failure into the caller's domain error when it is ENOENT. */
64
+ function mapSpawnError(err, opts) {
65
+ if (err.code === 'ENOENT' && opts.notFound)
66
+ return opts.notFound(err);
67
+ return err;
68
+ }
69
+ /**
70
+ * Whether this call has to run on the blocking path.
71
+ *
72
+ * Only true under GJS for a caller that returns normally — see the module
73
+ * header's table. On Node the async path is always correct (and keeps the
74
+ * event loop free), and under GJS a caller that exits the process quits the
75
+ * armed main loop itself.
76
+ */
77
+ function mustBlock(opts) {
78
+ return isGjs() && opts.completion === 'return';
79
+ }
80
+ /**
81
+ * Run `cmd args…` to completion and resolve with its exit code and signal.
82
+ *
83
+ * Resolves for ANY child exit — a non-zero code is data, not an error, so each
84
+ * call site keeps its own failure message. Rejects only when the child could
85
+ * not be started at all (mapped through {@link SpawnToCompletionOptions.notFound}
86
+ * for ENOENT).
87
+ *
88
+ * Picks the streaming async path or the blocking captured path per the GJS
89
+ * teardown contract documented at the top of this file.
90
+ */
91
+ export function spawnToCompletion(cmd, args, opts) {
92
+ const env = resolveEnv(opts);
93
+ if (mustBlock(opts)) {
94
+ // `'inherit'` is NOT forwarded through `@gjsify/child_process`'s GJS
95
+ // spawnSync path (it drives a private main context via
96
+ // `communicateWithTimeout`), so the child's diagnostics would be
97
+ // silently dropped. Capture them and re-emit after the child exits.
98
+ // stdin stays inherited so an interactive child still reads the tty.
99
+ const r = spawnSync(cmd, [...args], {
100
+ stdio: ['inherit', 'pipe', 'pipe'],
101
+ cwd: opts.cwd,
102
+ env,
103
+ maxBuffer: opts.maxBuffer ?? DEFAULT_MAX_BUFFER,
104
+ });
105
+ if (r.error)
106
+ return Promise.reject(mapSpawnError(r.error, opts));
107
+ if (r.stdout && r.stdout.length > 0)
108
+ process.stdout.write(r.stdout);
109
+ if (r.stderr && r.stderr.length > 0)
110
+ process.stderr.write(r.stderr);
111
+ return Promise.resolve({ code: r.status, signal: r.signal ?? null });
112
+ }
113
+ return new Promise((resolvePromise, reject) => {
114
+ const spawnOpts = {
115
+ cwd: opts.cwd,
116
+ stdio: opts.stdio === 'pipe' ? ['ignore', 'pipe', 'pipe'] : 'inherit',
117
+ };
118
+ if (env)
119
+ spawnOpts.env = env;
120
+ const child = spawn(cmd, [...args], spawnOpts);
121
+ opts.onSpawn?.(child);
122
+ // `close` (not `exit`) so piped stdio is fully drained before the
123
+ // caller's flushers run. With `stdio: 'inherit'` the two are emitted
124
+ // back to back, so this is a strict superset of the old `exit` sites.
125
+ child.on('close', (code, signal) => {
126
+ resolvePromise({ code, signal });
127
+ });
128
+ child.on('error', (err) => {
129
+ reject(mapSpawnError(err, opts));
130
+ });
131
+ });
132
+ }
133
+ /**
134
+ * Format the "child failed" suffix the way the call sites report it: an exit
135
+ * code when the child exited, the signal name when it was killed.
136
+ */
137
+ export function describeExit(result) {
138
+ return result.signal !== null ? `signal ${result.signal}` : `code ${result.code}`;
139
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Read the whole of stdin as UTF-8. Works on Node and under the GJS bundle.
3
+ *
4
+ * Branches on WHICH RUNTIME CAN WORK, not on which capability is present. The
5
+ * distinction matters because the two branches are not interchangeable:
6
+ * `readFileSync(0)` is correct on Node/Bun/Deno and can NEVER work under GJS —
7
+ * `@gjsify/fs` has no numeric-fd path, so the `0` is coerced to a relative
8
+ * PATH and the call opens `./0`. A GJS run that reaches that line is already
9
+ * lost, so using it as GJS's fallback only converts a clear failure into
10
+ * `ENOENT … read '0'`, which is what a capability probe returning a false
11
+ * negative did.
12
+ *
13
+ * So: the presence of `imports.gi` decides. On GJS both GJS readers are tried
14
+ * and a failure THROWS with what was attempted; the Node reader is never
15
+ * reached. Off GJS there is no gi namespace and `readFileSync(0)` is the only
16
+ * and correct answer.
17
+ */
18
+ export declare function readStdinText(): string;
19
+ /**
20
+ * Read stdin and split it into trimmed, non-empty lines. Accepts LF and CRLF
21
+ * so a `git diff --name-only` piped from any host works unchanged.
22
+ */
23
+ export declare function readStdinLines(): string[];
24
+ /** Pure line-splitter behind {@link readStdinLines} — unit-testable without a pipe. */
25
+ export declare function splitStdinLines(text: string): string[];