@gjsify/cli 0.16.3 → 0.16.5
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/dist/cli.gjs.mjs +37 -35
- package/lib/commands/install.js +153 -14
- package/lib/commands/pack.d.ts +13 -0
- package/lib/commands/pack.js +77 -0
- package/lib/commands/run.d.ts +1 -0
- package/lib/commands/run.js +56 -5
- package/lib/utils/install-backend-native.js +71 -1
- package/lib/utils/oxc-resolve.d.ts +6 -3
- package/lib/utils/oxc-resolve.js +52 -7
- package/package.json +17 -17
package/lib/commands/install.js
CHANGED
|
@@ -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
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
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
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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);
|
package/lib/commands/pack.d.ts
CHANGED
|
@@ -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 {};
|
package/lib/commands/pack.js
CHANGED
|
@@ -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',
|
package/lib/commands/run.d.ts
CHANGED
package/lib/commands/run.js
CHANGED
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
// as a bundle file. Everything else is a script name. Users who want
|
|
15
15
|
// to disambiguate can pass `./<file>` explicitly.
|
|
16
16
|
import { statSync } from 'node:fs';
|
|
17
|
-
import { delimiter, join, resolve } from 'node:path';
|
|
17
|
+
import { basename, delimiter, join, resolve } from 'node:path';
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
19
|
import { runGjsBundle } from '../utils/run-gjs.js';
|
|
20
20
|
import { readPackageJson } from '../utils/pkg-json-edit.js';
|
|
21
21
|
import { findWorkspaceRoot } from '../utils/workspace-root.js';
|
|
22
|
+
import { discoverWorkspaces } from '@gjsify/workspace';
|
|
22
23
|
import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
|
|
23
24
|
export const runCommand = {
|
|
24
25
|
command: 'run <target> [args..]',
|
|
@@ -34,6 +35,16 @@ export const runCommand = {
|
|
|
34
35
|
type: 'string',
|
|
35
36
|
array: true,
|
|
36
37
|
default: [],
|
|
38
|
+
})
|
|
39
|
+
.option('workspace', {
|
|
40
|
+
// npm/yarn-style `-w <name>`: run <target> as a SCRIPT in the
|
|
41
|
+
// named workspace instead of the current directory. The name
|
|
42
|
+
// matches the package.json `name`, the workspace-relative path
|
|
43
|
+
// (e.g. `cli`), or the directory basename — so both
|
|
44
|
+
// `-w eco-retrofit-cli` and `-w cli` resolve, matching npm.
|
|
45
|
+
alias: 'w',
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Run <target> as a script in the named workspace (by name or path), like `npm run <script> -w <name>`.',
|
|
37
48
|
})
|
|
38
49
|
.parserConfiguration({
|
|
39
50
|
// Preserve `--` as args['--'] so callers can write
|
|
@@ -61,6 +72,12 @@ export const runCommand = {
|
|
|
61
72
|
const positionalArgs = args.args ?? [];
|
|
62
73
|
const doubleDashArgs = (args['--'] ?? []).filter((v) => typeof v === 'string');
|
|
63
74
|
const extraArgs = [...positionalArgs, ...doubleDashArgs];
|
|
75
|
+
// `-w <name>` (npm/yarn parity): run <target> as a script in the named
|
|
76
|
+
// workspace's directory rather than the current one. Never file mode.
|
|
77
|
+
if (typeof args.workspace === 'string' && args.workspace.length > 0) {
|
|
78
|
+
await runScriptInWorkspace(args.workspace, target, extraArgs);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
64
81
|
// Script lookup wins over file detection. Without this, a bare
|
|
65
82
|
// `gjsify run build` would resolve to a file when a `./build`
|
|
66
83
|
// directory exists in cwd (e.g. meson's build dir from a Vala
|
|
@@ -97,6 +114,31 @@ function looksLikeFile(target) {
|
|
|
97
114
|
return false;
|
|
98
115
|
}
|
|
99
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a workspace by npm/yarn `--workspace` semantics — matching the
|
|
119
|
+
* package.json `name`, the workspace-relative path (`packages/infra/cli` or
|
|
120
|
+
* `cli`), or the directory basename — then run `script` in that workspace's
|
|
121
|
+
* directory. Mirrors `npm run <script> --workspace <name>`.
|
|
122
|
+
*/
|
|
123
|
+
async function runScriptInWorkspace(name, script, extraArgs) {
|
|
124
|
+
const root = findWorkspaceRoot(process.cwd()) ?? process.cwd();
|
|
125
|
+
let workspaces;
|
|
126
|
+
try {
|
|
127
|
+
workspaces = discoverWorkspaces(root);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.error(`gjsify run: could not read workspaces at ${root}: ${err.message}`);
|
|
131
|
+
return process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
const ws = workspaces.find((w) => w.name === name || w.relativeLocation === name || basename(w.location) === name);
|
|
134
|
+
if (!ws) {
|
|
135
|
+
const available = workspaces.map((w) => w.relativeLocation).join(', ') || '<none>';
|
|
136
|
+
console.error(`gjsify run: no workspace "${name}" under ${root} (available: ${available})`);
|
|
137
|
+
// `return process.exit` — a bare exit falls through under GJS (see runScript).
|
|
138
|
+
return process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
await runScript(script, extraArgs, ws.location);
|
|
141
|
+
}
|
|
100
142
|
/**
|
|
101
143
|
* Run a script declared in the current workspace's `package.json#scripts`.
|
|
102
144
|
* Mirrors `yarn run <script>` semantics:
|
|
@@ -107,20 +149,29 @@ function looksLikeFile(target) {
|
|
|
107
149
|
* - executed through `shell: true` so `&&` / `|` / env-var refs work
|
|
108
150
|
* exactly as in package.json scripts (matches npm/yarn)
|
|
109
151
|
*/
|
|
110
|
-
async function runScript(script, extraArgs) {
|
|
111
|
-
const cwd = process.cwd();
|
|
152
|
+
async function runScript(script, extraArgs, cwd = process.cwd()) {
|
|
112
153
|
const pkgPath = join(cwd, 'package.json');
|
|
113
154
|
const pkg = readPackageJson(pkgPath);
|
|
114
155
|
if (!pkg) {
|
|
115
156
|
console.error(`gjsify run: no package.json in ${cwd}`);
|
|
116
|
-
process.exit
|
|
157
|
+
// `return process.exit` — under GJS `process.exit` cannot terminate
|
|
158
|
+
// synchronously (a parked GLib.MainLoop owns the thread): it SCHEDULES
|
|
159
|
+
// the exit and returns, so a bare call falls through. Here that reached
|
|
160
|
+
// `tokenizeSimpleCommand(literal)` with `literal === undefined` →
|
|
161
|
+
// `cannot access property "length"` → the throw raced the scheduled
|
|
162
|
+
// exit into a second `system.exit` → `m_should_exit assertion failed`
|
|
163
|
+
// → core dump. The `return` halts the handler so exactly one exit is
|
|
164
|
+
// scheduled (same discipline as commands/workspace.ts). No-op on Node.
|
|
165
|
+
return process.exit(1);
|
|
117
166
|
}
|
|
118
167
|
const scripts = pkg.scripts ?? {};
|
|
119
168
|
const literal = scripts[script];
|
|
120
169
|
if (typeof literal !== 'string') {
|
|
121
170
|
const available = Object.keys(scripts).join(', ') || '<none>';
|
|
122
171
|
console.error(`gjsify run: no script "${script}" in ${pkgPath} (available: ${available})`);
|
|
123
|
-
process.exit
|
|
172
|
+
// See the `no package.json` branch above — a bare `process.exit` under
|
|
173
|
+
// GJS falls through to `tokenizeSimpleCommand(undefined)` and core-dumps.
|
|
174
|
+
return process.exit(1);
|
|
124
175
|
}
|
|
125
176
|
const monorepoRoot = findWorkspaceRoot(cwd);
|
|
126
177
|
const binDirs = [join(cwd, 'node_modules', '.bin')];
|
|
@@ -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
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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.
|
package/lib/utils/oxc-resolve.js
CHANGED
|
@@ -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
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
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);
|