@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.
- package/README.md +24 -1
- package/dist/cli.gjs.mjs +257 -184
- package/lib/bundler-pick.js +12 -4
- package/lib/commands/affected.js +22 -10
- package/lib/commands/check.js +38 -29
- package/lib/commands/flatpak/build.js +19 -18
- package/lib/commands/foreach.js +48 -41
- package/lib/commands/info.js +11 -5
- package/lib/commands/install.d.ts +16 -0
- package/lib/commands/install.js +103 -32
- package/lib/commands/onboard.js +6 -1
- package/lib/commands/pack.js +69 -4
- package/lib/commands/run.js +26 -14
- package/lib/commands/trust.js +4 -62
- package/lib/commands/workspace.js +49 -30
- package/lib/utils/bin-shim.d.ts +66 -0
- package/lib/utils/bin-shim.js +246 -0
- package/lib/utils/build-cache.d.ts +42 -6
- package/lib/utils/build-cache.js +114 -42
- package/lib/utils/detect-native-packages.d.ts +112 -6
- package/lib/utils/detect-native-packages.js +201 -29
- package/lib/utils/install-backend-native.d.ts +24 -0
- package/lib/utils/install-backend-native.js +89 -16
- package/lib/utils/install-global.d.ts +16 -0
- package/lib/utils/install-global.js +58 -8
- package/lib/utils/oxc-resolve.js +18 -55
- package/lib/utils/publish-headers.d.ts +2 -3
- package/lib/utils/publish-headers.js +64 -3
- package/lib/utils/publishable-packages.d.ts +22 -0
- package/lib/utils/publishable-packages.js +93 -0
- package/lib/utils/run-gjs.d.ts +6 -7
- package/lib/utils/run-gjs.js +4 -3
- package/lib/utils/spawn.d.ts +82 -0
- package/lib/utils/spawn.js +139 -0
- package/lib/utils/stdin.d.ts +25 -0
- package/lib/utils/stdin.js +132 -0
- package/package.json +23 -18
package/lib/utils/oxc-resolve.js
CHANGED
|
@@ -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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
|
28
|
-
*
|
|
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
|
|
31
|
-
// and GJS
|
|
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
|
|
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
|
+
}
|
package/lib/utils/run-gjs.d.ts
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
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
|
/**
|
package/lib/utils/run-gjs.js
CHANGED
|
@@ -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
|
|
24
|
-
*
|
|
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[];
|