@gjsify/cli 0.16.3 → 0.16.4

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.
@@ -28,7 +28,8 @@
28
28
  // into different prefixes stay parallel; the shared XDG tarball/packument
29
29
  // caches need no lock because their writes are atomic tmp+rename.
30
30
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
31
- import { dirname, join, relative } from 'node:path';
31
+ import { dirname, join, relative, resolve } from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
32
33
  import { spawn, spawnSync } from 'node:child_process';
33
34
  import { discoverWorkspaces } from '@gjsify/workspace';
34
35
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
@@ -36,6 +37,7 @@ import { detectNativePackages } from '../utils/detect-native-packages.js';
36
37
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
37
38
  import { atomicWriteStrict } from '../utils/install-cache-fs.js';
38
39
  import { acquireInstallLock } from '../utils/install-lock.js';
40
+ import { findWorkspaceRoot } from '../utils/workspace-root.js';
39
41
  import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
40
42
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
41
43
  // Default 30min wall-clock budget for the full install. Big workspaces
@@ -132,6 +134,13 @@ export const installCommand = {
132
134
  await installGlobalAndLink(args.packages, { verbose: args.verbose });
133
135
  return;
134
136
  }
137
+ // Surface a CLI/workspace version skew BEFORE the heavy install, so
138
+ // it's visible even if a later phase is slow. A gjsify CLI that is out
139
+ // of step with the @gjsify/cli the workspace pins is the root cause of
140
+ // both the mysterious install stall (a mismatched CLI resolves a
141
+ // differently-shaped lock → full fresh resolve → a wedge-prone extract)
142
+ // and silent-wrong-builds (the stale CLI builds with stale semantics).
143
+ warnOnCliVersionSkew(process.cwd());
135
144
  // Backend selection (in precedence order):
136
145
  // 1. --backend flag (explicit user choice)
137
146
  // 2. GJSIFY_INSTALL_BACKEND env (back-compat shape from pre-flag era)
@@ -142,16 +151,47 @@ export const installCommand = {
142
151
  await runPostInstallChecks();
143
152
  return;
144
153
  }
145
- // Overall wall-clock budget for the install (default 5 min). On
146
- // timeout we abort every in-flight registry fetch via this controller
147
- // so the process exits cleanly with an actionable message instead of
148
- // a silent hang. Per-request timeouts inside @gjsify/npm-registry
154
+ // Overall wall-clock budget for the install (default 30 min — big
155
+ // workspaces on slow networks legitimately take a while). On timeout
156
+ // we abort every in-flight registry fetch via this controller so a
157
+ // signal-aware await rejects and the process exits cleanly with an
158
+ // actionable message. Per-request timeouts inside @gjsify/npm-registry
149
159
  // (default 30s, retried) still apply within this budget.
160
+ //
161
+ // The abort only rescues awaits that OBSERVE the signal. Some awaits on
162
+ // the fresh-resolve path do not — most notably tarball extraction (a
163
+ // Gio-backed decompress whose stream close-event can be dropped under
164
+ // GJS) and the workspace symlink fs-pool — so an aborted controller
165
+ // alone cannot guarantee the process ever stops. `hardExitTimerId`
166
+ // below is the backstop: a grace period after the abort, if the install
167
+ // STILL hasn't returned, force a non-zero exit. This converts any
168
+ // residual never-settling await into a clean failure instead of a
169
+ // silent 0%-CPU hang (the observed "it never completed; I killed it"
170
+ // pathology). `extractOne` additionally caps each extract with its own
171
+ // stall timeout so the common case fails fast, long before this.
150
172
  const overallTimeoutMs = args.timeout > 0 ? args.timeout : 0;
151
173
  const overallController = overallTimeoutMs > 0 ? new AbortController() : null;
152
174
  const overallTimerId = overallController !== null
153
175
  ? setTimeout(() => overallController.abort(new Error('install-overall-timeout')), overallTimeoutMs)
154
176
  : null;
177
+ // Grace after the abort before we pull the plug. Long enough for a
178
+ // signal-aware reject to propagate through the catch (the clean path),
179
+ // short enough that a genuinely-wedged await doesn't hang for minutes.
180
+ const HARD_EXIT_GRACE_MS = 10_000;
181
+ const hardExitTimerId = overallTimeoutMs > 0
182
+ ? setTimeout(() => {
183
+ const secs = Math.round(overallTimeoutMs / 100) / 10;
184
+ console.error(`gjsify install: still stuck ${Math.round(HARD_EXIT_GRACE_MS / 1000)}s after the ${secs}s ` +
185
+ `budget elapsed and the in-flight aborts — forcing exit. A dependency's extract or ` +
186
+ `link step wedged (typically a dropped Gio stream event under GJS). Re-run; if it ` +
187
+ `persists, raise --timeout or file an issue.`);
188
+ forceExit(1);
189
+ }, overallTimeoutMs + HARD_EXIT_GRACE_MS)
190
+ : null;
191
+ // Don't let the backstop timer keep an otherwise-finished process alive
192
+ // (Node keeps the loop up while a timer is armed). `unref` lets the
193
+ // process end the instant the install returns; `finally` clears it too.
194
+ hardExitTimerId?.unref?.();
155
195
  try {
156
196
  await projectInstallNative(args, overallController?.signal);
157
197
  await runPostInstallChecks();
@@ -159,7 +199,7 @@ export const installCommand = {
159
199
  catch (err) {
160
200
  if (overallController !== null && overallController.signal.aborted && isAbortedFromOverallTimeout(err)) {
161
201
  const secs = Math.round(overallTimeoutMs / 100) / 10;
162
- console.error(`gjsify install: timed out after ${secs}s — likely a registry slowdown.\n` +
202
+ console.error(`gjsify install: timed out after ${secs}s — likely a registry slowdown or a wedged extract.\n` +
163
203
  `Re-run, or override with --timeout <ms> (set --timeout 0 to disable the overall budget).`);
164
204
  process.exit(1);
165
205
  }
@@ -168,6 +208,8 @@ export const installCommand = {
168
208
  finally {
169
209
  if (overallTimerId !== null)
170
210
  clearTimeout(overallTimerId);
211
+ if (hardExitTimerId !== null)
212
+ clearTimeout(hardExitTimerId);
171
213
  }
172
214
  },
173
215
  };
@@ -194,6 +236,94 @@ function isAbortedFromOverallTimeout(err) {
194
236
  return true;
195
237
  return false;
196
238
  }
239
+ /**
240
+ * Force a non-zero process exit that is honored on BOTH runtimes.
241
+ *
242
+ * Node exits at natural shutdown once `process.exitCode` is set, but GJS has
243
+ * no atexit hook, so `imports.system.exit` is called directly there —
244
+ * SpiderMonkey raises its uncatchable exit exception from any context,
245
+ * including the `setTimeout` continuation the backstop timer runs in. Mirrors
246
+ * the entry wrapper (`index.ts`) and `setOxcExitCode` (`oxc-resolve.ts`).
247
+ */
248
+ function forceExit(code) {
249
+ process.exitCode = code;
250
+ const gjs = globalThis.imports;
251
+ if (gjs?.system?.exit) {
252
+ gjs.system.exit(code);
253
+ return;
254
+ }
255
+ process.exit(code);
256
+ }
257
+ const CLI_PACKAGE_NAME = '@gjsify/cli';
258
+ /** Read a `version` from a package.json IFF its `name` is `@gjsify/cli`. */
259
+ function readCliVersionFrom(pkgJsonPath) {
260
+ try {
261
+ if (!existsSync(pkgJsonPath))
262
+ return null;
263
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
264
+ if (pkg.name === CLI_PACKAGE_NAME && typeof pkg.version === 'string')
265
+ return pkg.version;
266
+ }
267
+ catch {
268
+ /* unreadable / malformed — treat as unknown */
269
+ }
270
+ return null;
271
+ }
272
+ /**
273
+ * Version of the @gjsify/cli that is ACTUALLY running (this bundle / lib entry).
274
+ * Walks up from `import.meta.url` to the nearest `@gjsify/cli` package.json —
275
+ * same discovery as `self-update`'s `readCurrentVersion`.
276
+ */
277
+ function readRunningCliVersion() {
278
+ try {
279
+ let dir = dirname(resolve(fileURLToPath(import.meta.url)));
280
+ for (let i = 0; i < 8 && dir !== dirname(dir); i++) {
281
+ const v = readCliVersionFrom(join(dir, 'package.json'));
282
+ if (v)
283
+ return v;
284
+ dir = dirname(dir);
285
+ }
286
+ }
287
+ catch {
288
+ /* not a recognizable layout (e.g. tests) */
289
+ }
290
+ return null;
291
+ }
292
+ /**
293
+ * Version of the @gjsify/cli the workspace at `cwd` pins/ships. Prefers the
294
+ * resolved install (`node_modules/@gjsify/cli`, a hoisted symlink in the
295
+ * gjsify monorepo or a real install in a consumer), falling back to the
296
+ * in-repo source so the check also fires on a fresh, not-yet-installed
297
+ * checkout of the gjsify monorepo itself.
298
+ */
299
+ function readWorkspaceCliVersion(cwd) {
300
+ return (readCliVersionFrom(join(cwd, 'node_modules', CLI_PACKAGE_NAME, 'package.json')) ??
301
+ readCliVersionFrom(join(cwd, 'packages', 'infra', 'cli', 'package.json')));
302
+ }
303
+ /**
304
+ * Warn (never block) when the running gjsify CLI is a different version than
305
+ * the @gjsify/cli the workspace pins. Running a mismatched CLI is the root of
306
+ * two hard-to-diagnose failures documented on real sessions: install stalls (a
307
+ * mismatched CLI discards the lock → full fresh resolve → a wedge-prone
308
+ * extract) and silent-wrong-builds (stale CLI, stale bundle semantics). A
309
+ * single actionable line pointing at `gjsify self-update` beats both silently.
310
+ * Suppress with GJSIFY_NO_VERSION_SKEW_WARNING=1.
311
+ */
312
+ function warnOnCliVersionSkew(cwd) {
313
+ if (process.env.GJSIFY_NO_VERSION_SKEW_WARNING === '1')
314
+ return;
315
+ const running = readRunningCliVersion();
316
+ const pinned = readWorkspaceCliVersion(cwd);
317
+ if (!running || !pinned || running === pinned)
318
+ return;
319
+ console.warn(`gjsify install: version skew — you are running @gjsify/cli v${running}, but this workspace pins ` +
320
+ `v${pinned}.\n` +
321
+ ` A mismatched CLI can stall the install (lock discarded → full re-resolve) or silently produce a ` +
322
+ `wrong build.\n` +
323
+ ` Align them: run \`gjsify self-update\` (global CLI) or use the workspace-local \`node_modules/.bin/` +
324
+ `gjsify\`.\n` +
325
+ ` Suppress this with GJSIFY_NO_VERSION_SKEW_WARNING=1.`);
326
+ }
197
327
  function isWorkspaceRoot(cwd) {
198
328
  const pkgPath = join(cwd, 'package.json');
199
329
  const pkg = readPackageJson(pkgPath);
@@ -241,14 +371,23 @@ async function projectInstallNative(args, signal) {
241
371
  `• If instead you want Yarn to manage dependencies, set \`nodeLinker: node-modules\` ` +
242
372
  `in .yarnrc.yml and run \`yarn install\` (not \`gjsify install\`).`);
243
373
  }
244
- // Workspace install (no args, root pkg.json has `workspaces`).
245
- // Project-local `gjsify install <pkg>` inside a workspace child still
246
- // goes through the single-package code path below (this branch only
247
- // fires for the root no-args case, which is the `yarn install`
248
- // equivalent).
249
- if ((!args.packages || args.packages.length === 0) && isWorkspaceRoot(cwd)) {
250
- await workspaceInstall(cwd, args, signal);
251
- return;
374
+ // Workspace install (no args). Runs from the root, OR from any workspace
375
+ // child — npm/yarn/pnpm resolve the monorepo root when you `install`
376
+ // inside a member, so `findWorkspaceRoot` walks up from cwd (sanity-checked
377
+ // that cwd is actually one of that root's workspaces) and installs the whole
378
+ // workspace there. Without this, a child install treats the child as the
379
+ // root and tries to resolve its `workspace:`/sibling deps from the registry.
380
+ // Project-local `gjsify install <pkg>` inside a child still adds the dep to
381
+ // that child via the single-package path below (this branch is no-args only).
382
+ if (!args.packages || args.packages.length === 0) {
383
+ const wsRoot = isWorkspaceRoot(cwd) ? cwd : findWorkspaceRoot(cwd);
384
+ if (wsRoot) {
385
+ if (wsRoot !== cwd) {
386
+ console.log(`gjsify install: resolved workspace root ${wsRoot} (from ${cwd}); installing the workspace.`);
387
+ }
388
+ await workspaceInstall(wsRoot, args, signal);
389
+ return;
390
+ }
252
391
  }
253
392
  let specs;
254
393
  const pkg = readPackageJson(pkgPath);
@@ -61,4 +61,17 @@ export interface PackWorkspaceOptions {
61
61
  * `wsDir` to an absolute path.
62
62
  */
63
63
  export declare function packWorkspace(wsDir: string, opts?: PackWorkspaceOptions): Promise<PackResult>;
64
+ /**
65
+ * Collect every `types`/`typings` path a consumer's TypeScript would resolve for
66
+ * this package: the top-level `types`/`typings` fields plus every value under a
67
+ * `"types"` / `"typings"` CONDITION key anywhere in the `exports` tree. Values
68
+ * are returned normalized (leading `./` stripped). Wildcard subpath patterns
69
+ * (`./types/*`) are skipped — they can't be checked against concrete files.
70
+ *
71
+ * A subpath KEY named `./types` (as in `@gjsify/vite-plugin-blueprint`) is a
72
+ * subpath, not a condition — it's walked, and the `types` condition INSIDE it is
73
+ * what gets collected. Per Node's `exports` semantics a bare `types` key (no `.`
74
+ * prefix) is a condition, so treating it as one here is correct.
75
+ */
76
+ export declare function collectTypeDeclarationRefs(pkg: Record<string, unknown>): string[];
64
77
  export {};
@@ -124,6 +124,14 @@ export async function packWorkspace(wsDir, opts = {}) {
124
124
  // rewritten contents. We use the post-script `pkgAfterScripts` here so
125
125
  // that any `files` array modified by a prepack script is honored.
126
126
  const filesToPack = collectFiles(wsDir, pkgAfterScripts);
127
+ // Guard against the #655 class of bug: a `types`/`typings` declaration that
128
+ // physically exists in the workspace but is excluded from the tarball by the
129
+ // `files` allowlist. A mass-added `files: ["lib"]` (Phase D.7d, #179) silently
130
+ // stripped `@gjsify/vite-plugin-blueprint`'s `types/blueprint.d.ts` and
131
+ // `@gjsify/adwaita-fonts`' `index.d.ts` — the runtime entry still shipped, so
132
+ // the break was invisible until a TypeScript consumer hit TS2307 / implicit
133
+ // any. Enforce it at the exact point of packing so it can never recur.
134
+ assertTypeDeclarationsShipped(wsDir, pkgAfterScripts, filesToPack);
127
135
  const entries = [{ name: 'package/', directory: true, mode: 0o755 }];
128
136
  const fileMetas = [];
129
137
  let unpackedSize = 0;
@@ -218,6 +226,75 @@ function collectFiles(wsDir, pkg) {
218
226
  }
219
227
  return [...out].sort();
220
228
  }
229
+ /**
230
+ * Collect every `types`/`typings` path a consumer's TypeScript would resolve for
231
+ * this package: the top-level `types`/`typings` fields plus every value under a
232
+ * `"types"` / `"typings"` CONDITION key anywhere in the `exports` tree. Values
233
+ * are returned normalized (leading `./` stripped). Wildcard subpath patterns
234
+ * (`./types/*`) are skipped — they can't be checked against concrete files.
235
+ *
236
+ * A subpath KEY named `./types` (as in `@gjsify/vite-plugin-blueprint`) is a
237
+ * subpath, not a condition — it's walked, and the `types` condition INSIDE it is
238
+ * what gets collected. Per Node's `exports` semantics a bare `types` key (no `.`
239
+ * prefix) is a condition, so treating it as one here is correct.
240
+ */
241
+ export function collectTypeDeclarationRefs(pkg) {
242
+ const out = new Set();
243
+ const add = (v) => {
244
+ if (typeof v !== 'string')
245
+ return;
246
+ const normalized = v.replace(/^\.\//, '');
247
+ if (normalized.includes('*'))
248
+ return;
249
+ out.add(normalized);
250
+ };
251
+ for (const field of ['types', 'typings'])
252
+ add(pkg[field]);
253
+ const walk = (node) => {
254
+ if (!node || typeof node !== 'object')
255
+ return;
256
+ if (Array.isArray(node)) {
257
+ for (const e of node)
258
+ walk(e);
259
+ return;
260
+ }
261
+ for (const [k, v] of Object.entries(node)) {
262
+ if (k === 'types' || k === 'typings')
263
+ add(v);
264
+ else
265
+ walk(v);
266
+ }
267
+ };
268
+ walk(pkg.exports);
269
+ return [...out];
270
+ }
271
+ /**
272
+ * Fail the pack if any `types`/`typings` declaration that physically exists in
273
+ * the workspace is excluded from the tarball. There is NO legitimate reason to
274
+ * reference a type file you don't ship — a consumer installing the package gets
275
+ * silently broken type resolution while the runtime entry keeps working.
276
+ *
277
+ * Deliberately only fires for a file that is PRESENT on disk yet absent from the
278
+ * packed set — the exact, unambiguous #655 mismatch. A `types` path that isn't
279
+ * built yet (absent on disk) is left to `tsc`/the build to catch, so an unbuilt
280
+ * dev tree doesn't produce a false positive.
281
+ */
282
+ function assertTypeDeclarationsShipped(wsDir, pkg, packed) {
283
+ const packedSet = new Set(packed);
284
+ const missing = collectTypeDeclarationRefs(pkg).filter((ref) => {
285
+ const full = join(wsDir, ref);
286
+ const onDisk = existsSync(full) && statSync(full).isFile();
287
+ return onDisk && !packedSet.has(ref);
288
+ });
289
+ if (missing.length === 0)
290
+ return;
291
+ const name = typeof pkg.name === 'string' ? pkg.name : '(unnamed)';
292
+ const filesField = Array.isArray(pkg.files) ? JSON.stringify(pkg.files) : '(none)';
293
+ throw new Error(`gjsify pack: ${name} references TypeScript declaration file(s) via "exports"/"types" ` +
294
+ `that exist on disk but are excluded from the tarball by the "files" allowlist (${filesField}):\n` +
295
+ missing.map((m) => ` - ${m}`).join('\n') +
296
+ `\nAdd the missing path(s) to package.json "files" so type resolution isn't silently broken for consumers.`);
297
+ }
221
298
  const NEVER_INCLUDED_BASENAMES = new Set([
222
299
  '.git',
223
300
  '.svn',
@@ -920,9 +920,79 @@ async function extractOne(node, prefix, npmrc, log, signal) {
920
920
  }
921
921
  fs.rmSync(dest, { recursive: true, force: true });
922
922
  fs.mkdirSync(dest, { recursive: true });
923
- await extractTarball(bytes, dest);
923
+ await extractWithStallGuard(bytes, dest, node, signal);
924
924
  return false;
925
925
  }
926
+ /**
927
+ * Cap over `extractTarball`, which takes no AbortSignal and no timeout of its
928
+ * own. A single tarball extract completes in well under a second even for
929
+ * large packages; anything past `EXTRACT_STALL_MS` means the decompress/write
930
+ * wedged — the classic dropped Gio stream close-event under GJS, a
931
+ * never-settling await that nothing downstream can break. Unlike a network
932
+ * fetch (30s per-request budget in @gjsify/npm-registry), extraction has no
933
+ * self-healing path, so one stuck package used to hang the whole install at
934
+ * 0% CPU indefinitely. We race the extract against (a) a stall timer and (b)
935
+ * the overall-install abort signal, so a wedge surfaces as an actionable
936
+ * error and the install fails fast instead of hanging.
937
+ *
938
+ * Rejecting the race does not tear down the underlying (leaked) extract
939
+ * promise — the process exit that follows reclaims its resources. Override /
940
+ * disable the cap via `GJSIFY_EXTRACT_STALL_MS` (0 disables the timer; the
941
+ * signal race still applies).
942
+ */
943
+ const EXTRACT_STALL_MS = (() => {
944
+ const raw = process.env.GJSIFY_EXTRACT_STALL_MS;
945
+ if (raw === undefined)
946
+ return 120_000;
947
+ const n = Number(raw);
948
+ return Number.isFinite(n) && n >= 0 ? n : 120_000;
949
+ })();
950
+ async function extractWithStallGuard(bytes, dest, node, signal) {
951
+ // Fast path: nothing to guard against (timer disabled + no signal).
952
+ if (EXTRACT_STALL_MS === 0 && !signal) {
953
+ await extractTarball(bytes, dest);
954
+ return;
955
+ }
956
+ let stallTimer;
957
+ let onAbort;
958
+ try {
959
+ await new Promise((resolve, reject) => {
960
+ // Test seam (matches GJSIFY_INSTALL_FORCE_EXTRACT precedent): simulate
961
+ // an extract that never settles, so a suite can prove the stall timer
962
+ // + abort-signal race actually break the wedge. Never call
963
+ // extractTarball — leave the inner promise pending forever.
964
+ if (process.env.GJSIFY_TEST_HANG_EXTRACT !== '1') {
965
+ // extractTarball keeps running if we lose the race; its settlement
966
+ // (resolve or reject) is a no-op on the already-settled promise.
967
+ extractTarball(bytes, dest).then(() => resolve(), reject);
968
+ }
969
+ if (EXTRACT_STALL_MS > 0) {
970
+ stallTimer = setTimeout(() => {
971
+ reject(new Error(`gjsify install: extract of ${node.name}@${node.version} stalled for ` +
972
+ `${Math.round(EXTRACT_STALL_MS / 1000)}s (${node.installPath}) — the tarball ` +
973
+ `decompress/write never completed. This is usually a dropped Gio stream event ` +
974
+ `under GJS; re-run the install. Override the cap with GJSIFY_EXTRACT_STALL_MS=<ms> ` +
975
+ `(0 disables it).`));
976
+ }, EXTRACT_STALL_MS);
977
+ stallTimer.unref?.();
978
+ }
979
+ if (signal) {
980
+ if (signal.aborted) {
981
+ reject(abortError(signal));
982
+ return;
983
+ }
984
+ onAbort = () => reject(abortError(signal));
985
+ signal.addEventListener('abort', onAbort, { once: true });
986
+ }
987
+ });
988
+ }
989
+ finally {
990
+ if (stallTimer)
991
+ clearTimeout(stallTimer);
992
+ if (signal && onAbort)
993
+ signal.removeEventListener('abort', onAbort);
994
+ }
995
+ }
926
996
  /**
927
997
  * Canonical AbortError carrying the signal's `reason` when it is an Error
928
998
  * (e.g. the overall-install timeout sentinel), so `isAbortedFromOverallTimeout`
@@ -41,9 +41,12 @@ export declare function shouldUseNativeOxfmt(): Promise<boolean>;
41
41
  * the full oxfmt CLI in-process (Node-free); otherwise the npm Node ESM
42
42
  * launcher is spawned. `GJSIFY_OXFMT=native|npm` overrides the pick.
43
43
  *
44
- * oxlint: always spawned via its Node ESM launcher with the current Node
45
- * executable (`process.execPath`) so the JS plugin host is available —
46
- * required for the `jsPlugins`-wired `gjsify/register-class-order` rule.
44
+ * oxlint: always spawned via its Node ESM launcher on a real Node host
45
+ * (`nodeBinary()`: `process.execPath` under Node, PATH `node` under the GJS
46
+ * bundle where `process.execPath` is the bundle itself) so the JS plugin host
47
+ * is available — required for the `jsPlugins`-wired `gjsify/register-class-order`
48
+ * rule. Under GJS a `node` on PATH is required (oxlint has no native bridge
49
+ * yet); its absence surfaces as a clear OxcNotFoundError, never a hang.
47
50
  *
48
51
  * Inherits stdio so the tool's own output (diagnostics, reformatted files,
49
52
  * summary lines) reaches the user.
@@ -48,11 +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 } from 'node:child_process';
51
+ import { spawn, spawnSync } from 'node:child_process';
52
52
  import { createRequire } from 'node:module';
53
53
  import { pathToFileURL } from 'node:url';
54
54
  import { findWorkspaceRoot } from './workspace-root.js';
55
55
  import { resolveNpmPackage } from './resolve-npm-package.js';
56
+ import { nodeBinary } from './run-node.js';
56
57
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
57
58
  /**
58
59
  * Map Node.js `process.platform` + `arch` (+ musl on Linux) to the
@@ -260,9 +261,12 @@ export async function shouldUseNativeOxfmt() {
260
261
  * the full oxfmt CLI in-process (Node-free); otherwise the npm Node ESM
261
262
  * launcher is spawned. `GJSIFY_OXFMT=native|npm` overrides the pick.
262
263
  *
263
- * oxlint: always spawned via its Node ESM launcher with the current Node
264
- * executable (`process.execPath`) so the JS plugin host is available —
265
- * required for the `jsPlugins`-wired `gjsify/register-class-order` rule.
264
+ * oxlint: always spawned via its Node ESM launcher on a real Node host
265
+ * (`nodeBinary()`: `process.execPath` under Node, PATH `node` under the GJS
266
+ * bundle where `process.execPath` is the bundle itself) so the JS plugin host
267
+ * is available — required for the `jsPlugins`-wired `gjsify/register-class-order`
268
+ * rule. Under GJS a `node` on PATH is required (oxlint has no native bridge
269
+ * yet); its absence surfaces as a clear OxcNotFoundError, never a hang.
266
270
  *
267
271
  * Inherits stdio so the tool's own output (diagnostics, reformatted files,
268
272
  * summary lines) reaches the user.
@@ -287,16 +291,57 @@ export async function runOxc(tool, args, opts = {}) {
287
291
  return spawnOxcLauncher(tool, args, opts);
288
292
  }
289
293
  /**
290
- * Spawn an oxc tool via its Node ESM launcher (`node_modules/<tool>/bin/…`)
291
- * with the current Node executable.
294
+ * Spawn an oxc tool via its Node ESM launcher (`node_modules/<tool>/bin/…`) on
295
+ * a real Node host. CRITICAL: use `nodeBinary()`, not `process.execPath`
296
+ * directly — under the committed GJS bundle `process.execPath` is the CLI
297
+ * bundle path (`gjs`/the `.mjs`), so spawning it re-executes the CLI with the
298
+ * launcher as its first positional (yargs prints top-level help) and the parent
299
+ * hangs waiting on a child that never does the intended work. `nodeBinary()`
300
+ * resolves to PATH `node` under GJS, so the launcher runs on Node as required.
292
301
  */
293
302
  function spawnOxcLauncher(tool, args, opts = {}) {
294
303
  const cwd = opts.cwd ?? process.cwd();
295
304
  const launcher = findOxcLauncher(tool, cwd);
296
- const node = process.execPath || 'node';
305
+ const node = nodeBinary();
297
306
  if (opts.verbose) {
298
307
  console.log(`[gjsify oxc] ${node} ${launcher} ${args.join(' ')}`);
299
308
  }
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);
342
+ }
343
+ return Promise.resolve(r.status ?? 0);
344
+ }
300
345
  return new Promise((res, rej) => {
301
346
  const spawnOpts = { stdio: 'inherit', cwd };
302
347
  const child = spawn(node, [launcher, ...args], spawnOpts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -122,19 +122,19 @@
122
122
  "cli"
123
123
  ],
124
124
  "dependencies": {
125
- "@gjsify/buffer": "^0.16.3",
126
- "@gjsify/create-app": "^0.16.3",
127
- "@gjsify/node-globals": "^0.16.3",
128
- "@gjsify/node-polyfills": "^0.16.3",
129
- "@gjsify/npm-registry": "^0.16.3",
130
- "@gjsify/resolve-npm": "^0.16.3",
131
- "@gjsify/rolldown-plugin-gjsify": "^0.16.3",
132
- "@gjsify/rolldown-plugin-pnp": "^0.16.3",
133
- "@gjsify/semver": "^0.16.3",
134
- "@gjsify/tar": "^0.16.3",
135
- "@gjsify/tsc": "^0.16.3",
136
- "@gjsify/web-polyfills": "^0.16.3",
137
- "@gjsify/workspace": "^0.16.3",
125
+ "@gjsify/buffer": "^0.16.4",
126
+ "@gjsify/create-app": "^0.16.4",
127
+ "@gjsify/node-globals": "^0.16.4",
128
+ "@gjsify/node-polyfills": "^0.16.4",
129
+ "@gjsify/npm-registry": "^0.16.4",
130
+ "@gjsify/resolve-npm": "^0.16.4",
131
+ "@gjsify/rolldown-plugin-gjsify": "^0.16.4",
132
+ "@gjsify/rolldown-plugin-pnp": "^0.16.4",
133
+ "@gjsify/semver": "^0.16.4",
134
+ "@gjsify/tar": "^0.16.4",
135
+ "@gjsify/tsc": "^0.16.4",
136
+ "@gjsify/web-polyfills": "^0.16.4",
137
+ "@gjsify/workspace": "^0.16.4",
138
138
  "cosmiconfig": "^9.0.2",
139
139
  "get-tsconfig": "^4.14.0",
140
140
  "pkg-types": "^2.3.1",
@@ -142,13 +142,13 @@
142
142
  "yargs": "^18.0.0"
143
143
  },
144
144
  "devDependencies": {
145
- "@gjsify/unit": "^0.16.3",
145
+ "@gjsify/unit": "^0.16.4",
146
146
  "@types/yargs": "^17.0.35",
147
147
  "typescript": "^6.0.3"
148
148
  },
149
149
  "peerDependencies": {
150
- "@gjsify/oxfmt-native": "^0.16.3",
151
- "@gjsify/rolldown-native": "^0.16.3"
150
+ "@gjsify/oxfmt-native": "^0.16.4",
151
+ "@gjsify/rolldown-native": "^0.16.4"
152
152
  },
153
153
  "peerDependenciesMeta": {
154
154
  "@gjsify/oxfmt-native": {