@gjsify/cli 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cli.gjs.mjs +194 -193
  2. package/lib/actions/build.d.ts +14 -0
  3. package/lib/actions/build.js +78 -3
  4. package/lib/actions/index.d.ts +2 -0
  5. package/lib/actions/index.js +2 -0
  6. package/lib/affected-entry.d.ts +1 -0
  7. package/lib/affected-entry.js +49 -0
  8. package/lib/bundler-pick.js +84 -19
  9. package/lib/cli-app.js +20 -0
  10. package/lib/cli-fail.d.ts +22 -0
  11. package/lib/cli-fail.js +67 -0
  12. package/lib/commands/affected.d.ts +2 -2
  13. package/lib/commands/affected.js +16 -0
  14. package/lib/commands/install.d.ts +55 -0
  15. package/lib/commands/install.js +38 -21
  16. package/lib/commands/run.js +25 -6
  17. package/lib/commands/self-update.d.ts +27 -0
  18. package/lib/commands/self-update.js +77 -2
  19. package/lib/commands/showcase.d.ts +14 -0
  20. package/lib/commands/showcase.js +130 -10
  21. package/lib/commands/test.js +4 -4
  22. package/lib/commands/tsc.js +59 -11
  23. package/lib/commands/workspace.js +13 -0
  24. package/lib/config.js +6 -2
  25. package/lib/index.js +18 -1
  26. package/lib/templates/install.mjs.tmpl +27 -2
  27. package/lib/types/command.d.ts +18 -0
  28. package/lib/utils/check-system-deps.d.ts +66 -1
  29. package/lib/utils/check-system-deps.js +139 -2
  30. package/lib/utils/detect-native-packages.js +12 -1
  31. package/lib/utils/discover-showcases.d.ts +0 -2
  32. package/lib/utils/discover-showcases.js +0 -1
  33. package/lib/utils/dlx-cache.d.ts +22 -2
  34. package/lib/utils/dlx-cache.js +62 -6
  35. package/lib/utils/force-exit.d.ts +10 -0
  36. package/lib/utils/force-exit.js +43 -0
  37. package/lib/utils/gi-search-path.d.ts +36 -0
  38. package/lib/utils/gi-search-path.js +146 -0
  39. package/lib/utils/gjs-source-escape.d.ts +10 -0
  40. package/lib/utils/gjs-source-escape.js +49 -0
  41. package/lib/utils/gjsify-shim.d.ts +29 -2
  42. package/lib/utils/gjsify-shim.js +131 -22
  43. package/lib/utils/install-global.d.ts +79 -2
  44. package/lib/utils/install-global.js +130 -8
  45. package/lib/utils/oxc-resolve.d.ts +1 -1
  46. package/lib/utils/oxc-resolve.js +10 -6
  47. package/lib/utils/stdin.d.ts +3 -13
  48. package/lib/utils/stdin.js +21 -100
  49. package/lib/utils/system-gi.js +10 -1
  50. package/package.json +22 -17
  51. package/showcases.json +8 -16
@@ -31,7 +31,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, syml
31
31
  import { dirname, join, relative, resolve } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { spawnSync } from 'node:child_process';
34
- import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
34
+ import { forceExit } from '../utils/force-exit.js';
35
35
  import { discoverWorkspaces } from '@gjsify/workspace';
36
36
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
37
37
  import { detectNativePackages } from '../utils/detect-native-packages.js';
@@ -42,7 +42,7 @@ import { acquireInstallLock } from '../utils/install-lock.js';
42
42
  import { nodeBinary } from '../utils/run-node.js';
43
43
  import { spawnToCompletion } from '../utils/spawn.js';
44
44
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
45
- import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
45
+ import { binDirOnPath, BUNDLER_ENGINE_PACKAGE, defaultGlobalLayout, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
46
46
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
47
47
  /**
48
48
  * Link type for the workspace↔workspace directory links.
@@ -198,7 +198,7 @@ export const installCommand = {
198
198
  const backend = args.backend ?? process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
199
199
  if (backend === 'npm') {
200
200
  await projectInstallViaNpm(args);
201
- await runPostInstallChecks();
201
+ await runPostInstallChecks(args);
202
202
  return;
203
203
  }
204
204
  // Overall wall-clock budget for the install (default 30 min — big
@@ -263,7 +263,7 @@ export const installCommand = {
263
263
  hardExitTimerId?.unref?.();
264
264
  try {
265
265
  await projectInstallNative(args, overallController?.signal);
266
- await runPostInstallChecks();
266
+ await runPostInstallChecks(args);
267
267
  }
268
268
  catch (err) {
269
269
  if (overallController !== null && overallController.signal.aborted && isAbortedFromOverallTimeout(err)) {
@@ -345,21 +345,6 @@ function isAbortedFromOverallTimeout(err) {
345
345
  return true;
346
346
  return false;
347
347
  }
348
- /**
349
- * Force a non-zero process exit that is honored on BOTH runtimes.
350
- *
351
- * Node exits at natural shutdown once `process.exitCode` is set, but GJS has
352
- * no atexit hook, so `imports.system.exit` is called directly there —
353
- * SpiderMonkey raises its uncatchable exit exception from any context,
354
- * including the `setTimeout` continuation the backstop timer runs in. Mirrors
355
- * the entry wrapper (`index.ts`) and `setOxcExitCode` (`oxc-resolve.ts`).
356
- */
357
- function forceExit(code) {
358
- process.exitCode = code;
359
- if (gjsExit(code))
360
- return;
361
- process.exit(code);
362
- }
363
348
  const CLI_PACKAGE_NAME = '@gjsify/cli';
364
349
  /** Read a `version` from a package.json IFF its `name` is `@gjsify/cli`. */
365
350
  function readCliVersionFrom(pkgJsonPath) {
@@ -1419,7 +1404,33 @@ function reportLinkedBins(created, binDir) {
1419
1404
  `Add it to your shell rc file:\n export PATH="${binDir}:$PATH"`);
1420
1405
  }
1421
1406
  }
1422
- async function runPostInstallChecks() {
1407
+ export async function ensureProjectGjsEngine(args, gjsFound, deps = {}) {
1408
+ const cwd = deps.cwd ?? process.cwd();
1409
+ const hasEngine = deps.hasEngineFn ?? hasBundlerEngineInstalled;
1410
+ if (!existsSync(join(cwd, 'node_modules', '@gjsify', 'cli', 'package.json')))
1411
+ return;
1412
+ if (!gjsFound)
1413
+ return;
1414
+ if (hasEngine(cwd))
1415
+ return;
1416
+ if (args.immutable) {
1417
+ // Name the DURABLE fix, not a transient one: a frozen CI install cannot
1418
+ // be repaired by anything that happens at this moment, so the answer is
1419
+ // to put the engine in the lockfile.
1420
+ console.warn(`\nWarning: \`gjsify build\` will fail under GJS — ${BUNDLER_ENGINE_PACKAGE} is not installed, and\n` +
1421
+ ' --immutable cannot add it (the lockfile does not name it). Declare it so the lockfile carries it:\n' +
1422
+ ` gjsify install ${BUNDLER_ENGINE_PACKAGE}\n` +
1423
+ ' Under Node the npm `rolldown` engine is used instead and this does not apply.');
1424
+ return;
1425
+ }
1426
+ const cliVersion = readCliVersionFrom(join(cwd, 'node_modules', '@gjsify', 'cli', 'package.json')) ?? 'latest';
1427
+ console.log(`\n${BUNDLER_ENGINE_PACKAGE} is missing and this host can run gjs, so \`gjsify build\` would have no\n` +
1428
+ ` bundler engine (there is no npm \`rolldown\` fallback under GJS). Installing the GJS engine set at\n` +
1429
+ ` ${cliVersion}, in lockstep with the CLI.`);
1430
+ const install = deps.installFn ?? installGjsEnginePackages;
1431
+ await install(cwd, cliVersion, { verbose: args.verbose });
1432
+ }
1433
+ async function runPostInstallChecks(args) {
1423
1434
  console.log('\n--- gjsify post-install checks ---');
1424
1435
  // 1. System deps that GJS apps typically need.
1425
1436
  const results = runMinimalChecks();
@@ -1437,8 +1448,14 @@ async function runPostInstallChecks() {
1437
1448
  else {
1438
1449
  console.log('System dependencies OK.');
1439
1450
  }
1440
- // 2. Surface @gjsify/* packages with native prebuilds — `gjsify run`
1451
+ // 2. Close #1005 before reporting the prebuild set, so the report reflects
1452
+ // what the tree ACTUALLY has rather than what it had a moment ago.
1453
+ await ensureProjectGjsEngine(args, results.find((r) => r.id === 'gjs')?.found ?? false);
1454
+ // 3. Surface @gjsify/* packages with native prebuilds — `gjsify run`
1441
1455
  // will set LD_LIBRARY_PATH / GI_TYPELIB_PATH for these automatically.
1456
+ // Runs AFTER step 2 on purpose: an engine installed a moment ago belongs
1457
+ // in this list, and a consumer reading "Detected N …" needs it to be the
1458
+ // tree's current state rather than its state on entry.
1442
1459
  const native = detectNativePackages(process.cwd());
1443
1460
  if (native.length > 0) {
1444
1461
  console.log(`\nDetected ${native.length} @gjsify/* package(s) with native prebuilds:`);
@@ -296,12 +296,15 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
296
296
  if (monorepoRoot && monorepoRoot !== cwd) {
297
297
  binDirs.push(join(monorepoRoot, 'node_modules', '.bin'));
298
298
  }
299
- // Under GJS, the GJS-runnable `gjsify` shim (see `ensureGjsifyShimOnPath`)
300
- // MUST shadow the workspace `node_modules/.bin/gjsify`, which is the Node
301
- // entry and unusable in a node-free sandbox. So put the shim dir ahead of
302
- // the `.bin` dirs in the child's PATH — otherwise a compound script like
303
- // `gjsify run a && gjsify run b` resolves `gjsify` to the Node bin and
304
- // fails. `GJSIFY_SHIM_DIR` is only set under GJS, so this is a no-op on Node.
299
+ // The self-shim (see `ensureGjsifyShimOnPath`) MUST shadow the workspace
300
+ // `node_modules/.bin/gjsify`, so put its dir ahead of the `.bin` dirs in the
301
+ // child's PATH — otherwise a compound script like `gjsify run a && gjsify
302
+ // run b` resolves `gjsify` to the workspace bin. Two trees need that:
303
+ // under GJS the bin is the Node entry and is unusable in a node-free
304
+ // sandbox, and under a BOOTSTRAP Node CLI (npx / global install) the bin
305
+ // dispatches to build outputs that a cold tree has not produced yet.
306
+ // `GJSIFY_SHIM_DIR` is unset whenever the tree's own CLI is the one
307
+ // running, which keeps that case byte-for-byte unchanged.
305
308
  if (process.env.GJSIFY_SHIM_DIR) {
306
309
  binDirs.unshift(process.env.GJSIFY_SHIM_DIR);
307
310
  }
@@ -342,6 +345,22 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
342
345
  process.env.npm_package_version = env.npm_package_version;
343
346
  if (env.FORCE_COLOR !== undefined)
344
347
  process.env.FORCE_COLOR = env.FORCE_COLOR;
348
+ // …and the working directory, for the same reason. The spawn path below
349
+ // passes `cwd` to `spawn()`; this path used to pass it nowhere, so a
350
+ // `gjsify run -w <ws> <script>` resolved the WORKSPACE and then ran the
351
+ // command in the CALLER's directory. Every relative path in the script
352
+ // — `src/app/main.ts`, `dist/…`, `tests/test.mts` — then resolved
353
+ // against the monorepo root.
354
+ //
355
+ // Only this branch was affected, which is why it survived: the fast path
356
+ // fires exactly when the script is a SINGLE `gjsify <subcommand>`, so a
357
+ // compound script (`a && b`) took the shell path and worked. Measured on
358
+ // the two consumers: bauplaner's cli scripts are all single commands and
359
+ // it documents the gap in its AGENTS.md, sidestepping it with
360
+ // `cd cli && …`; buchhaltung's one `-w` use is `build:meson`, a compound
361
+ // command, and it works.
362
+ if (cwd !== process.cwd())
363
+ process.chdir(cwd);
345
364
  // Track the failure LOCALLY rather than through `process.exitCode`.
346
365
  // Under GJS `process.exit()` is deferred (see the note below), so the
347
366
  // `process.exit(1)` that used to live in the catch returned instead of
@@ -6,4 +6,31 @@ interface SelfUpdateOptions {
6
6
  skipDeps?: boolean;
7
7
  }
8
8
  export declare const selfUpdateCommand: Command<unknown, SelfUpdateOptions>;
9
+ /** Verdict of the post-install "did this change what runs?" check. */
10
+ export type PathVerdict = {
11
+ ok: true;
12
+ } | {
13
+ ok: false;
14
+ message: string;
15
+ };
16
+ /**
17
+ * Compare where PATH sends `binName` against the `binDir` we just linked into.
18
+ *
19
+ * Pure so the three outcomes are unit-testable off-host — the interesting two
20
+ * are exactly the ones a developer's own machine never produces, since a working
21
+ * install resolves to its own `binDir` by definition.
22
+ *
23
+ * Path comparison is case-INSENSITIVE on win32: `C:\Users\X\.local\bin` and
24
+ * `c:\users\x\.local\bin` are one directory there, and a case-sensitive compare
25
+ * would report a correct install as shadowed.
26
+ */
27
+ export declare function verifyPathResolution(opts: {
28
+ binName: string;
29
+ binDir: string;
30
+ resolvedBin: string | null;
31
+ /** `null` is what `readCurrentVersion()` returns when it cannot tell. */
32
+ runningVersion: string | null | undefined;
33
+ targetVersion: string;
34
+ platform?: NodeJS.Platform;
35
+ }): PathVerdict;
9
36
  export {};
@@ -11,12 +11,21 @@
11
11
  // `defaultGlobalLayout().prefix` (i.e. via `gjsify install -g` or via the
12
12
  // `install.mjs` bootstrap). Installs from `npm install -g @gjsify/cli` land
13
13
  // elsewhere and we don't try to chase them — we print a warning and exit.
14
+ //
15
+ // AND THE WARNING IS NOT ENOUGH ON ITS OWN (#1064). This command's whole job is
16
+ // to change which binary the user's next `gjsify` runs, so success has to be
17
+ // measured on PATH, not on the prefix it wrote. Reported from a Windows host:
18
+ // the pre-install warning fired, the install succeeded, `Updated @gjsify/cli to
19
+ // v0.31.0` printed — and the shell kept resolving a v0.26.0 npm-global install,
20
+ // so the very next command re-hit the `dlx` EPERM fixed three releases earlier.
21
+ // A silent no-op that reports success is worse than a clean failure, so the
22
+ // post-link verification below is fatal.
14
23
  import { existsSync, readFileSync } from 'node:fs';
15
- import { dirname, join, resolve } from 'node:path';
24
+ import { dirname, join, posix, resolve, win32 } from 'node:path';
16
25
  import { fileURLToPath } from 'node:url';
17
26
  import { fetchPackument } from '@gjsify/npm-registry';
18
27
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
19
- import { defaultGlobalLayout, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, } from '../utils/install-global.js';
28
+ import { defaultGlobalLayout, globalLayoutIsDefault, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, resolveBinOnPath, } from '../utils/install-global.js';
20
29
  const PACKAGE_NAME = '@gjsify/cli';
21
30
  export const selfUpdateCommand = {
22
31
  command: 'self-update',
@@ -157,6 +166,27 @@ export const selfUpdateCommand = {
157
166
  console.log(` • ${bin.link} → ${bin.target}`);
158
167
  }
159
168
  }
169
+ // Did the update change what `gjsify` RUNS? Everything above only proves
170
+ // we wrote the prefix. See the #1064 note at the top of this file.
171
+ //
172
+ // Only for the user's OWN layout: a caller that redirected the install
173
+ // with GJSIFY_GLOBAL_PREFIX/_BIN_DIR manages placement itself, and
174
+ // telling a harness that installed into a temp prefix "your PATH still
175
+ // points elsewhere" is true and useless.
176
+ if (globalLayoutIsDefault()) {
177
+ const verdict = verifyPathResolution({
178
+ binName: BIN_NAME,
179
+ binDir: layout.binDir,
180
+ resolvedBin: resolveBinOnPath(BIN_NAME),
181
+ runningVersion: currentVersion,
182
+ targetVersion: target,
183
+ });
184
+ if (!verdict.ok) {
185
+ console.error(`\nInstalled ${PACKAGE_NAME} v${target} at ${layout.prefix}`);
186
+ console.error(verdict.message);
187
+ return process.exit(1);
188
+ }
189
+ }
160
190
  console.log(`\nUpdated ${PACKAGE_NAME} to v${target}.`);
161
191
  if (pullDeps) {
162
192
  console.log('Runtime dependencies were resolved alongside the bundle.');
@@ -166,6 +196,51 @@ export const selfUpdateCommand = {
166
196
  }
167
197
  },
168
198
  };
199
+ /** The bin `@gjsify/cli` installs, and the name a user types. */
200
+ const BIN_NAME = 'gjsify';
201
+ /**
202
+ * Compare where PATH sends `binName` against the `binDir` we just linked into.
203
+ *
204
+ * Pure so the three outcomes are unit-testable off-host — the interesting two
205
+ * are exactly the ones a developer's own machine never produces, since a working
206
+ * install resolves to its own `binDir` by definition.
207
+ *
208
+ * Path comparison is case-INSENSITIVE on win32: `C:\Users\X\.local\bin` and
209
+ * `c:\users\x\.local\bin` are one directory there, and a case-sensitive compare
210
+ * would report a correct install as shadowed.
211
+ */
212
+ export function verifyPathResolution(opts) {
213
+ const platform = opts.platform ?? process.platform;
214
+ // The TARGET platform's path module, never the host's — see `pathFor` in
215
+ // `utils/install-global.ts` for why (`resolve('C:\\x')` on posix is wrong).
216
+ const p = platform === 'win32' ? win32 : posix;
217
+ const norm = (v) => (platform === 'win32' ? p.resolve(v).toLowerCase() : p.resolve(v));
218
+ if (opts.resolvedBin === null) {
219
+ return {
220
+ ok: false,
221
+ message: `BUT nothing named \`${opts.binName}\` is on PATH, so the update cannot take effect.\n\n` +
222
+ ` Add the bin dir to PATH:\n ${opts.binDir}`,
223
+ };
224
+ }
225
+ if (norm(p.dirname(opts.resolvedBin)) === norm(opts.binDir))
226
+ return { ok: true };
227
+ // Deliberately NOT split into "a rival install" vs "a transient runner"
228
+ // (npx/dlx, whose temp bin wins PATH for the length of the run). Both are the
229
+ // same fact — `gjsify` does not currently mean the tree we just wrote — and
230
+ // guessing which one it is would be a heuristic that can only be wrong. So
231
+ // the finding is stated and both remedies are offered.
232
+ const running = opts.runningVersion ? ` (v${opts.runningVersion})` : '';
233
+ return {
234
+ ok: false,
235
+ message: `BUT \`${opts.binName}\` on PATH resolves to\n ${opts.resolvedBin}${running}\n` +
236
+ `which is NOT the install that was just updated, so the next \`${opts.binName}\`\n` +
237
+ `you run will not be v${opts.targetVersion}.\n\n` +
238
+ ` If that is another install, remove it (e.g. \`npm uninstall -g ${PACKAGE_NAME}\`)\n` +
239
+ ` or put ${opts.binDir} ahead of it on PATH.\n` +
240
+ ` If you ran this through npx/dlx, that temporary copy owns the name only for\n` +
241
+ ` this run — open a new shell and check \`${opts.binName} --version\`.`,
242
+ };
243
+ }
169
244
  /**
170
245
  * Resolve the CLI's own `package.json#version`. Walks up from
171
246
  * `import.meta.url` (the bundle file under GJS, or `lib/index.js` under
@@ -1,4 +1,6 @@
1
1
  import type { Command } from '../types/index.js';
2
+ import { type Packument } from '@gjsify/npm-registry';
3
+ import { type ExampleRuntime } from '../utils/runtimes.js';
2
4
  /**
3
5
  * Pinned invocations for the three runners that cache an unpinned bin.
4
6
  *
@@ -22,4 +24,16 @@ interface ShowcaseOptions {
22
24
  runtime?: string;
23
25
  }
24
26
  export declare const showcaseCommand: Command<unknown, ShowcaseOptions>;
27
+ /**
28
+ * The pure half of {@link preflightDeclaredRuntimes}: pick the version whose
29
+ * declaration decides, and read it.
30
+ *
31
+ * Separate from the fetch so the fail-safe direction is unit-testable — that
32
+ * contract ("never fail a run that would have worked") is the only thing that
33
+ * makes a pre-install check safe, and it lives entirely in which branches return
34
+ * `undefined`.
35
+ *
36
+ * @param cliVersion the CLI's own version; `showcase` pins the showcase to it.
37
+ */
38
+ export declare function declaredRuntimesFromPackument(packument: Packument, cliVersion: string | undefined): ExampleRuntime[] | null | undefined;
25
39
  export {};
@@ -4,6 +4,8 @@ import { createRequire } from 'node:module';
4
4
  import { dirname } from 'node:path';
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { ensurePkgDir } from './dlx.js';
7
+ import { fetchPackument } from '@gjsify/npm-registry';
8
+ import { loadNpmrc } from '../utils/load-npmrc.js';
7
9
  import { parseSpec } from '../utils/parse-spec.js';
8
10
  import { resolveNodeEntry } from '../utils/resolve-gjs-entry.js';
9
11
  import { runRuntimeBundle } from '../utils/run-node.js';
@@ -212,15 +214,9 @@ export const showcaseCommand = {
212
214
  */
213
215
  async function resolveShowcaseDir(showcase, cliVersion, extraSpecs = []) {
214
216
  // Local-first: a workspace showcase resolves via its own package name.
215
- try {
216
- const req = createRequire(import.meta.url);
217
- const localPkgJson = req.resolve(`${showcase.packageName}/package.json`);
218
- if (existsSync(localPkgJson))
219
- return dirname(localPkgJson);
220
- }
221
- catch {
222
- // Not resolvable locally — fall through to dlx install.
223
- }
217
+ const local = resolveLocalShowcaseDir(showcase);
218
+ if (local)
219
+ return local;
224
220
  const spec = cliVersion ? `${showcase.packageName}@${cliVersion}` : showcase.packageName;
225
221
  const { pkgDir } = await ensurePkgDir(parseSpec(spec), {
226
222
  verbose: false,
@@ -230,6 +226,117 @@ async function resolveShowcaseDir(showcase, cliVersion, extraSpecs = []) {
230
226
  });
231
227
  return pkgDir;
232
228
  }
229
+ /**
230
+ * The LOCAL (workspace / node_modules) directory of a showcase, or `null`.
231
+ *
232
+ * Factored out because two callers need the same question answered: the resolver
233
+ * below, and the pre-flight, which must know whether an install is even going to
234
+ * happen before deciding to spend a registry request avoiding it.
235
+ */
236
+ function resolveLocalShowcaseDir(showcase) {
237
+ try {
238
+ const req = createRequire(import.meta.url);
239
+ const localPkgJson = req.resolve(`${showcase.packageName}/package.json`);
240
+ if (existsSync(localPkgJson))
241
+ return dirname(localPkgJson);
242
+ }
243
+ catch {
244
+ // Not resolvable locally — the caller falls back to a dlx install.
245
+ }
246
+ return null;
247
+ }
248
+ /**
249
+ * The batteries-included GTK runtime to add to a showcase's dlx tree, if any.
250
+ *
251
+ * WHY THE LAUNCHER AND NOT THE PACKAGE. `@gjsify/node-gi` deliberately declares
252
+ * no bundle: making one its dependency is #910, where a from-source addon met a
253
+ * foreign GTK and produced wrong method entries and a 29-minute timeout. The rule
254
+ * that replaced it is "whoever SHIPS an app declares the bundle" — and for a
255
+ * showcase, the shipper is this command, which assembles the dlx tree. Same
256
+ * reasoning, same place, as the `@gjsify/node-gi` spec beside it.
257
+ *
258
+ * Only where a system GTK is not a given, which is exactly where a bundle is
259
+ * built: Windows has none at all (#1063 — every `gi://` showcase died in
260
+ * `ERR_DLOPEN_FAILED` on a clean host), and macOS has Homebrew's, which is
261
+ * measurably not findable. On Linux the distribution's GTK is the tested
262
+ * combination and node-gi's own policy prefers it, so pulling ~80 MB nobody would
263
+ * use would be waste.
264
+ *
265
+ * The tree only ever holds the PUBLISHED PREBUILD, never a from-source build, so
266
+ * the mismatch #910 hit cannot arise here by construction.
267
+ */
268
+ function gtkRuntimeSpecs(cliVersion) {
269
+ const target = `${process.platform}-${process.arch}`;
270
+ const shipped = ['win32-x64', 'darwin-arm64', 'darwin-x64'];
271
+ if (!shipped.includes(target))
272
+ return [];
273
+ const name = `@gjsify/gtk-runtime-${target}`;
274
+ return [cliVersion ? `${name}@${cliVersion}` : name];
275
+ }
276
+ /**
277
+ * Read a showcase's declared runtimes from the REGISTRY, with no install (#1069).
278
+ *
279
+ * WHY. `--runtime node` on a gjs-only showcase used to resolve and download the
280
+ * whole dlx tree — plus the pinned native `@gjsify/node-gi` bridge, added
281
+ * BECAUSE `--runtime node` was requested — and only then read the declaration
282
+ * that rejects the run. Measured for `adwaita-storybook`: a 310 KB showcase with
283
+ * no runtime deps, and 3.77 MB of `node-gi` fetched to support a runtime the
284
+ * package was about to refuse. The full packument carries
285
+ * `gjsify.example.runtimes` verbatim, so one metadata GET answers it.
286
+ *
287
+ * NOT solved by putting `runtimes` in `showcases.json`: that is a second copy of
288
+ * a declaration the package already owns, maintained by hand. See the
289
+ * `needsWebgl` post-mortem in `utils/discover-showcases.ts` — "a field nobody
290
+ * reads is a field nobody maintains", proven by a showcase that shipped
291
+ * `"needsWebgl": false` while its engine was WebGL2-only.
292
+ *
293
+ * FAIL-SAFE DIRECTION, and it is the whole contract: this may only ever turn a
294
+ * run that WOULD have failed into a faster failure. It must never fail a run
295
+ * that would have worked, so every uncertain branch — offline, unpublished
296
+ * version, missing field, malformed document — returns `undefined` and lets the
297
+ * install proceed to the authoritative on-disk check.
298
+ *
299
+ * @returns the declared list, `null` for a package that declares none
300
+ * (unconstrained), or `undefined` when the question could not be answered.
301
+ */
302
+ async function preflightDeclaredRuntimes(showcase, cliVersion) {
303
+ try {
304
+ // `fullMetadata` is REQUIRED: the abbreviated (corgi) document omits
305
+ // custom fields, so `gjsify.example` is simply absent there — the same
306
+ // shape as the `libc` note in `@gjsify/npm-registry`'s PackumentVersion.
307
+ const packument = await fetchPackument(showcase.packageName, {
308
+ npmrc: await loadNpmrc(process.cwd()),
309
+ fullMetadata: true,
310
+ });
311
+ return declaredRuntimesFromPackument(packument, cliVersion);
312
+ }
313
+ catch {
314
+ // Offline, private registry, auth failure, 404 — all "cannot say".
315
+ return undefined;
316
+ }
317
+ }
318
+ /**
319
+ * The pure half of {@link preflightDeclaredRuntimes}: pick the version whose
320
+ * declaration decides, and read it.
321
+ *
322
+ * Separate from the fetch so the fail-safe direction is unit-testable — that
323
+ * contract ("never fail a run that would have worked") is the only thing that
324
+ * makes a pre-install check safe, and it lives entirely in which branches return
325
+ * `undefined`.
326
+ *
327
+ * @param cliVersion the CLI's own version; `showcase` pins the showcase to it.
328
+ */
329
+ export function declaredRuntimesFromPackument(packument, cliVersion) {
330
+ // A pinned version that is NOT published is "cannot say", never "ask latest":
331
+ // a different version's declaration is a different answer, and a wrong
332
+ // "unsupported" is precisely the failure this must not introduce. (A dev or
333
+ // pre-release CLI is the normal way to reach this.)
334
+ const version = cliVersion ?? packument['dist-tags']?.latest;
335
+ const entry = version ? packument.versions?.[version] : undefined;
336
+ if (!entry)
337
+ return undefined;
338
+ return readDeclaredRuntimes(entry);
339
+ }
233
340
  /**
234
341
  * Run a showcase on a non-gjs runtime (node/bun/deno). Resolves the package,
235
342
  * validates it against the showcase's `gjsify.example.runtimes` declaration
@@ -237,6 +344,19 @@ async function resolveShowcaseDir(showcase, cliVersion, extraSpecs = []) {
237
344
  * node), resolves its `--app node` bundle and launches it via the shared runner.
238
345
  */
239
346
  async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
347
+ // PRE-FLIGHT, before anything is downloaded. Only for the dlx path: a local
348
+ // showcase resolves with no network and no install, so there is nothing to
349
+ // pre-empt there and the on-disk check below is already free.
350
+ if (resolveLocalShowcaseDir(showcase) === null) {
351
+ const preflight = await preflightDeclaredRuntimes(showcase, cliVersion);
352
+ if (preflight !== undefined) {
353
+ const support = checkRuntimeSupported(runtime, preflight, showcase.name);
354
+ if (!support.ok) {
355
+ console.error(support.message);
356
+ return process.exit(1);
357
+ }
358
+ }
359
+ }
240
360
  let pkgDir;
241
361
  try {
242
362
  // `@gjsify/node-gi` is the `gi://` bridge the `--app node` bundle
@@ -248,7 +368,7 @@ async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
248
368
  // it supplies the bridge. Pinned to the CLI's version like the showcase
249
369
  // itself, so the bridge and the bundle move together.
250
370
  const nodeGiSpec = cliVersion ? `@gjsify/node-gi@${cliVersion}` : '@gjsify/node-gi';
251
- pkgDir = await resolveShowcaseDir(showcase, cliVersion, [nodeGiSpec]);
371
+ pkgDir = await resolveShowcaseDir(showcase, cliVersion, [nodeGiSpec, ...gtkRuntimeSpecs(cliVersion)]);
252
372
  }
253
373
  catch (err) {
254
374
  console.error(`Could not resolve showcase "${showcase.name}": ${err.message}`);
@@ -194,10 +194,10 @@ async function runTestBundle(outfile, runtime) {
194
194
  return;
195
195
  }
196
196
  await new Promise((resolvePromise, reject) => {
197
- // `nodeBinary()`, never a bare `'node'`: under the committed GJS bundle
198
- // `process.execPath` is the bundle itself, and on a host without Node a
199
- // bare literal dies `spawn node ENOENT` — which is how a fully passing
200
- // GJS suite was reported as a failed run.
197
+ // `nodeBinary()`, never a bare `'node'`: under GJS `process.execPath` is
198
+ // the `gjs` interpreter, which cannot run a `--app node` bundle, and on a
199
+ // host without Node a bare literal dies `spawn node ENOENT` — which is
200
+ // how a fully passing GJS suite was reported as a failed run.
201
201
  const child = spawn(nodeBinary(), [outfile], { stdio: 'inherit' });
202
202
  child.on('error', reject);
203
203
  child.on('exit', (code) => {
@@ -22,6 +22,10 @@ import { createRequire } from 'node:module';
22
22
  import { dirname, join } from 'node:path';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import { detectNativePackages, buildNativeEnv } from '../utils/detect-native-packages.js';
25
+ import { isOnPath } from '../utils/check-system-deps.js';
26
+ import { existsSync } from 'node:fs';
27
+ import { forceExit } from '../utils/force-exit.js';
28
+ import { nodeBinary } from '../utils/run-node.js';
25
29
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
26
30
  export const tscCommand = {
27
31
  command: 'tsc [tscArgs..]',
@@ -104,9 +108,12 @@ export const tscCommand = {
104
108
  if (!bundlePath && !nodeTscPath) {
105
109
  console.error('gjsify tsc: neither @gjsify/tsc (GJS bundle) nor npm `typescript` (Node) is installed.');
106
110
  console.error(' Install with: gjsify install --save-dev @gjsify/tsc (or add `typescript`).');
107
- // `return` — a bare `process.exit()` is deferred under GJS and the
108
- // handler would try to spawn a compiler that is not there.
109
- return process.exit(1);
111
+ // `return` — the handler must not fall through and try to spawn a
112
+ // compiler that is not there. `forceExit` and not `process.exit`
113
+ // because the latter is idle-scheduled under GJS, and NOTHING here
114
+ // armed a main loop to deliver it: the process would reach natural
115
+ // shutdown and report 0 while printing the message above.
116
+ return forceExit(1);
110
117
  }
111
118
  // Mirror `gjsify run`'s native-env composition so any future
112
119
  // typescript-the-bundle code path that reaches a native bridge
@@ -118,13 +125,54 @@ export const tscCommand = {
118
125
  ...process.env,
119
126
  ...nativeEnv,
120
127
  };
121
- // Run upstream `typescript` under this Node — the Node-only fallback.
128
+ // Run upstream `typescript` under Node — the Node fallback.
129
+ //
130
+ // `nodeBinary()`, NEVER a bare `process.execPath`: under the GJS bundle
131
+ // `process.execPath` is the GJS interpreter (`/proc/self/exe` →
132
+ // `gjs-console`), so `spawn(process.execPath, [tsc.js])` hands
133
+ // TypeScript's CommonJS CLI to GJS, which dies on the first `module`
134
+ // reference — or, where execPath resolves to a gjsify launcher instead,
135
+ // re-executes the CLI with the tsc entry as an unknown argument. Both
136
+ // were reached from a COLD tree: `@gjsify/tsc`'s `dist/tsc.gjs.mjs` is a
137
+ // build output, and `build:infra` builds `@gjsify/create-app` (its first
138
+ // `gjsify tsc` caller) long before `@gjsify/tsc` produces it, so the
139
+ // fallback below is the NORMAL path there, not an edge case.
140
+ // `run-node.ts` owns the one correct answer; every other spawn site in
141
+ // this CLI already routes through it.
122
142
  const runNodeTsc = () => {
123
- const child = spawn(process.execPath, [nodeTscPath, ...tscArgs], { env, stdio: 'inherit' });
124
- child.on('close', (code) => process.exit(code ?? 1));
143
+ const nodeBin = nodeBinary();
144
+ // PRE-FLIGHT, deliberately NOT the spawn's `'error'` event.
145
+ //
146
+ // Under the GJS bundle that event is not delivered when the program
147
+ // is missing: the handler below never runs, the command returns
148
+ // normally, and `gjsify tsc` exits **0 in total silence** having
149
+ // compiled nothing. Measured in CI by `tests/e2e/tsc-node-fallback`'s
150
+ // "FAILS LOUDLY …" case, whose assertion printed an EMPTY captured
151
+ // output — no stderr at all, so nothing had reported anything.
152
+ //
153
+ // Deciding it here, synchronously, is what makes the failure
154
+ // reportable: the exit happens on the caller's own stack rather than
155
+ // in a continuation that may never be scheduled.
156
+ const runnable = nodeBin.includes('/') || nodeBin.includes('\\') ? existsSync(nodeBin) : isOnPath(nodeBin);
157
+ if (!runnable) {
158
+ console.error(`gjsify tsc: the @gjsify/tsc GJS bundle is unavailable and \`${nodeBin}\` cannot be run, ` +
159
+ 'so upstream `typescript` cannot run either.');
160
+ console.error(' Build the bundle with: gjsify workspace @gjsify/tsc build');
161
+ console.error(' …or install Node.js so the upstream `typescript` fallback can be spawned.');
162
+ return forceExit(1);
163
+ }
164
+ const child = spawn(nodeBin, [nodeTscPath, ...tscArgs], { env, stdio: 'inherit' });
165
+ child.on('close', (code) => forceExit(code ?? 1));
125
166
  child.on('error', (err) => {
126
- console.error(`gjsify tsc (node typescript): ${err.message}`);
127
- process.exit(1);
167
+ if (err.code === 'ENOENT') {
168
+ console.error('gjsify tsc: the @gjsify/tsc GJS bundle is unavailable and `node` is not on PATH, ' +
169
+ 'so upstream `typescript` cannot run either.');
170
+ console.error(' Build the bundle with: gjsify workspace @gjsify/tsc build');
171
+ }
172
+ else {
173
+ console.error(`gjsify tsc (node typescript): ${err.message}`);
174
+ }
175
+ forceExit(1);
128
176
  });
129
177
  };
130
178
  // Prefer GJS — the Node-free @gjsify/tsc bundle — when it resolves. If
@@ -144,7 +192,7 @@ export const tscCommand = {
144
192
  child.on('close', (code) => {
145
193
  if (gjsSpawnFailed)
146
194
  return;
147
- process.exit(code ?? 1);
195
+ forceExit(code ?? 1);
148
196
  });
149
197
  child.on('error', (err) => {
150
198
  gjsSpawnFailed = true;
@@ -155,11 +203,11 @@ export const tscCommand = {
155
203
  else if (err.code === 'ENOENT') {
156
204
  console.error('gjsify tsc: `gjs` not found on PATH and no npm `typescript` fallback.');
157
205
  console.error(' Install GJS (e.g. `dnf install gjs`), or add `typescript` to the project.');
158
- process.exit(1);
206
+ forceExit(1);
159
207
  }
160
208
  else {
161
209
  console.error(`gjsify tsc: ${err.message}`);
162
- process.exit(1);
210
+ forceExit(1);
163
211
  }
164
212
  });
165
213
  // Never resolves naturally — process.exit() above terminates the
@@ -24,6 +24,7 @@ import { buildDependencyGraph, discoverWorkspaces, topologicalSort } from '@gjsi
24
24
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
25
25
  import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
26
26
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
27
+ import { usingSelfShim } from '../utils/gjsify-shim.js';
27
28
  import { suggestClosest } from '../utils/suggest.js';
28
29
  export const workspaceCommand = {
29
30
  command: 'workspace <name> <script> [args..]',
@@ -279,6 +280,18 @@ export function detectPackageManager() {
279
280
  // orchestration work in the Flatpak sandbox.
280
281
  if (isGjs())
281
282
  return 'gjsify';
283
+ // Same answer for a BOOTSTRAP CLI on Node, for a reason one level deeper
284
+ // than it looks. `ensureGjsifyShimOnPath()` puts a working `gjsify` ahead of
285
+ // the tree's `node_modules/.bin` — but npm RE-PREPENDS `node_modules/.bin`
286
+ // for every lifecycle script it starts, so delegating to npm here restores
287
+ // the tree's not-yet-built shim one level down and undoes the repair.
288
+ // Measured on a cold tree: `run build:infra` cleared its first clause and
289
+ // then died inside `npm run build` with `Cannot find module
290
+ // …/packages/infra/cli/lib/index.js`. Running the script ourselves keeps the
291
+ // bootstrap CLI's identity all the way down — which is exactly what the GJS
292
+ // branch above already relies on, and why Linux CI never saw this.
293
+ if (usingSelfShim())
294
+ return 'gjsify';
282
295
  const ua = process.env.npm_config_user_agent ?? '';
283
296
  if (ua.startsWith('yarn/'))
284
297
  return 'yarn';