@gjsify/cli 0.30.0 → 0.31.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 (47) hide show
  1. package/dist/cli.gjs.mjs +186 -185
  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/test.js +4 -4
  18. package/lib/commands/tsc.js +59 -11
  19. package/lib/commands/workspace.js +13 -0
  20. package/lib/config.js +6 -2
  21. package/lib/index.js +18 -1
  22. package/lib/templates/install.mjs.tmpl +27 -2
  23. package/lib/types/command.d.ts +18 -0
  24. package/lib/utils/check-system-deps.d.ts +66 -1
  25. package/lib/utils/check-system-deps.js +139 -2
  26. package/lib/utils/detect-native-packages.js +12 -1
  27. package/lib/utils/discover-showcases.d.ts +0 -2
  28. package/lib/utils/discover-showcases.js +0 -1
  29. package/lib/utils/dlx-cache.d.ts +22 -2
  30. package/lib/utils/dlx-cache.js +62 -6
  31. package/lib/utils/force-exit.d.ts +10 -0
  32. package/lib/utils/force-exit.js +43 -0
  33. package/lib/utils/gi-search-path.d.ts +36 -0
  34. package/lib/utils/gi-search-path.js +146 -0
  35. package/lib/utils/gjs-source-escape.d.ts +10 -0
  36. package/lib/utils/gjs-source-escape.js +49 -0
  37. package/lib/utils/gjsify-shim.d.ts +29 -2
  38. package/lib/utils/gjsify-shim.js +131 -22
  39. package/lib/utils/install-global.d.ts +30 -2
  40. package/lib/utils/install-global.js +38 -4
  41. package/lib/utils/oxc-resolve.d.ts +1 -1
  42. package/lib/utils/oxc-resolve.js +10 -6
  43. package/lib/utils/stdin.d.ts +3 -13
  44. package/lib/utils/stdin.js +21 -100
  45. package/lib/utils/system-gi.js +10 -1
  46. package/package.json +22 -17
  47. 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
@@ -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';
package/lib/config.js CHANGED
@@ -223,8 +223,12 @@ export class Config {
223
223
  const pkg = (await this.readPackageJSON(configFilePath));
224
224
  const tsConfig = (await this.readTSConfig(configFilePath));
225
225
  tsConfig.reflection ||= cliArgs.reflection;
226
- // TODO(open-todos: 10 small API gaps): replace with `cliArgs.logLevel`
227
- configData.verbose = cliArgs.verbose || false;
226
+ // `--verbose` and `--log-level` are two spellings of one question, and
227
+ // only the boolean reached here — so `--log-level debug` turned the
228
+ // BUNDLER's logging up (see the mapping further down, which sets
229
+ // `bundler.logLevel`) while the CLI's own verbose output stayed off.
230
+ // Either spelling now answers it; neither silences the other.
231
+ configData.verbose = cliArgs.verbose || cliArgs.logLevel === 'debug' || cliArgs.logLevel === 'verbose';
228
232
  configData.exclude = cliArgs.exclude || [];
229
233
  if (cliArgs.consoleShim !== undefined)
230
234
  configData.consoleShim = cliArgs.consoleShim;
package/lib/index.js CHANGED
@@ -17,7 +17,24 @@ catch (err) {
17
17
  // a scary `Gjs-CRITICAL **: Module … threw an exception` that buries the
18
18
  // real, already-actionable message (e.g. "gjsify build: no usable bundler
19
19
  // engine under GJS — build the facade first or run under Node"). See #597.
20
- console.error(err instanceof Error ? err.message : String(err));
20
+ //
21
+ // The `code` is printed WITH the message, not dropped. Errors in this CLI
22
+ // carry npm's own codes on purpose — `badPlatformError()` documents that it
23
+ // reproduces npm's `EBADPLATFORM` payload "so a consumer's error handling
24
+ // (and a human reading the message) sees what npm reports" — and npm itself
25
+ // prints the code (`npm ERR! code EBADPLATFORM`) rather than only the prose.
26
+ //
27
+ // It has to be explicit here because this catch became the SINGLE printer:
28
+ // before the `.fail()` handler in `cli-app.ts`, yargs logged the error OBJECT
29
+ // and the code came along for free. Printing `err.message` alone silently
30
+ // dropped it for every coded error, which is what turned
31
+ // `tests/e2e/install-optional-both-blocks` red on `main` — it asserts the
32
+ // code is visible, and the prose it still printed did not contain it.
33
+ const code = typeof err === 'object' && err !== null && typeof err.code === 'string'
34
+ ? err.code
35
+ : '';
36
+ const message = err instanceof Error ? err.message : String(err);
37
+ console.error(code && !message.includes(code) ? `${code}: ${message}` : message);
21
38
  if (process.env.GJSIFY_DEBUG && err instanceof Error && err.stack)
22
39
  console.error(err.stack);
23
40
  // `process.exitCode` is the gentle Node idiom (streams flush). GJS has no
@@ -58,8 +58,18 @@ const USER_AGENT = 'gjsify-installer/1.0';
58
58
  const CACHE_PREFIX = 'cli-';
59
59
  const CACHE_SUFFIX = '.gjs.mjs';
60
60
 
61
+ /**
62
+ * Under `--fetch-only` the ONLY thing on stdout must be the bundle path, so
63
+ * progress goes to stderr instead. A caller does
64
+ * `BOOTSTRAP=$(gjs -m install.mjs --fetch-only)`, and one stray `info()` line
65
+ * would make that a path with a log message glued to the front — a failure that
66
+ * surfaces as a confusing "file not found" rather than as bad output.
67
+ */
68
+ let logToStderr = false;
69
+
61
70
  function info(msg) {
62
- print(`[gjsify] ${msg}`);
71
+ if (logToStderr) printerr(`[gjsify] ${msg}`);
72
+ else print(`[gjsify] ${msg}`);
63
73
  }
64
74
  function error(msg) {
65
75
  printerr(`[gjsify] ERROR: ${msg}`);
@@ -71,6 +81,7 @@ function parseArgs() {
71
81
  let tag = 'latest';
72
82
  let force = false;
73
83
  let help = false;
84
+ let fetchOnly = false;
74
85
  let bootstrapUrl = GLib.getenv('GJSIFY_INSTALL_BOOTSTRAP_URL') || DEFAULT_BOOTSTRAP_URL;
75
86
  let bootstrapSha256Url = GLib.getenv('GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL');
76
87
  if (bootstrapSha256Url === null || bootstrapSha256Url === undefined) {
@@ -87,8 +98,9 @@ function parseArgs() {
87
98
  else if (a.startsWith('--tag=')) tag = a.slice('--tag='.length);
88
99
  else if (a === '--bootstrap-url') bootstrapUrl = argv[++i];
89
100
  else if (a.startsWith('--bootstrap-url=')) bootstrapUrl = a.slice('--bootstrap-url='.length);
101
+ else if (a === '--fetch-only') fetchOnly = true;
90
102
  }
91
- return { target, tag, force, help, bootstrapUrl, bootstrapSha256Url };
103
+ return { target, tag, force, help, bootstrapUrl, bootstrapSha256Url, fetchOnly };
92
104
  }
93
105
 
94
106
  function printUsage() {
@@ -102,6 +114,10 @@ Options:
102
114
  --tag <tag> npm dist-tag or version (default: latest)
103
115
  --force, -f Reinstall even when present.
104
116
  --bootstrap-url <url> Override the cli.gjs.mjs download URL.
117
+ --fetch-only Download + verify the bootstrap bundle, print its cached
118
+ path to stdout and exit — no install. Lets a caller (CI)
119
+ reuse this downloader instead of growing a second one
120
+ beside it; progress goes to stderr so stdout is the path.
105
121
  --help, -h Show this message.
106
122
 
107
123
  Env vars:
@@ -318,6 +334,9 @@ async function main() {
318
334
  printUsage();
319
335
  exit(0);
320
336
  }
337
+ // Set BEFORE the first `info()` — resolveBootstrap logs while downloading,
338
+ // and those lines would otherwise land on stdout ahead of the path.
339
+ logToStderr = opts.fetchOnly;
321
340
  checkGjsVersion();
322
341
 
323
342
  const session = new Soup.Session();
@@ -329,6 +348,12 @@ async function main() {
329
348
  exit(1);
330
349
  }
331
350
 
351
+ if (opts.fetchOnly) {
352
+ // The one thing on stdout. Everything above went to stderr.
353
+ print(bundlePath);
354
+ exit(0);
355
+ }
356
+
332
357
  const spec = buildSpec(opts.target, opts.tag);
333
358
  try {
334
359
  await runInstall(bundlePath, spec);
@@ -7,3 +7,21 @@ export interface Command<T = unknown, U = T> {
7
7
  middlewares?: MiddlewareFunction[];
8
8
  deprecated?: boolean | string;
9
9
  }
10
+ /**
11
+ * A command that definitely runs something — the shape of every command except
12
+ * the builder-only parents.
13
+ *
14
+ * WHY IT EXISTS: `Command.handler` has to stay optional for parent commands,
15
+ * but that optionality then lands on every CALL site, including the ones
16
+ * outside `cli-app.ts`'s yargs registration where a missing handler would be a
17
+ * silent no-op rather than a type error. `src/affected-entry.ts` — the entry
18
+ * the committed `dist/affected.gjs.mjs` is built from — invoked
19
+ * `affectedCommand.handler(...)` through a cast that suppressed exactly that,
20
+ * and nothing noticed for as long as the package's tsconfig scoped the compile
21
+ * to the import closure of `src/index.ts`. Annotate a command that a standalone
22
+ * entry invokes directly with this type, and the guarantee is in the type
23
+ * instead of in a cast.
24
+ */
25
+ export type LeafCommand<T = unknown, U = T> = Command<T, U> & {
26
+ handler: NonNullable<Command<T, U>['handler']>;
27
+ };
@@ -12,7 +12,26 @@ export interface DepCheck {
12
12
  /** For optional deps: which @gjsify/* packages need this lib */
13
13
  requiredBy?: string[];
14
14
  }
15
- export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew' | 'unknown';
15
+ export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew' | 'winget' | 'unknown';
16
+ /**
17
+ * True when `cmd` is on PATH. Walks `process.env.PATH` with `existsSync`
18
+ * instead of shelling out to `which`(1).
19
+ *
20
+ * `which` is NOT a probe you can rely on: the Fedora 43/44 minimal containers
21
+ * CI runs in ship without it (only the `which-2.x` rpm provides it), so a
22
+ * `which`-based check silently answers "missing" for EVERY command. That is
23
+ * exactly where this file's answer matters most — `detectPackageManager()`
24
+ * returning `unknown` makes `buildInstallCommand()` return null, so the
25
+ * distro-specific install hint for a missing system library prints nothing on
26
+ * the hosts that needed it. Measured twice on 2026-08-05: ts-for-gir#437 had to
27
+ * discover `json-glib` by hand, bauplaner#40 `libsoup3` — both in such a
28
+ * container, both while this file already declared the dependency.
29
+ *
30
+ * `tests/e2e/helpers.mjs`'s `hasCommand()` carries the same walk for the same
31
+ * reason. It stays a second copy on purpose: it is `.mjs` under `tests/` and
32
+ * cannot be imported from the shipped CLI.
33
+ */
34
+ export declare function isOnPath(cmd: string): boolean;
16
35
  /**
17
36
  * The package manager whose install command we may suggest.
18
37
  *
@@ -28,6 +47,29 @@ export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew
28
47
  * MacPorts or Nix host, and it is why this must never fall back to "probably brew".
29
48
  */
30
49
  export declare function detectPackageManager(): PackageManager;
50
+ /**
51
+ * The Visual C++ runtime — an undeclared, undiagnosed win32 prerequisite (#997).
52
+ *
53
+ * `@gjsify/gtk-runtime-win32-x64` is built with gvsbuild, i.e. against the MSVC
54
+ * ABI, and its DLLs load into stock (also MSVC-ABI) Node. On a Windows host
55
+ * WITHOUT the Visual C++ redistributable that load fails — and nothing in the
56
+ * toolchain said so. The precedent for why that matters is PR #994: a missing
57
+ * load-time library made a typelib unloadable and the user-visible message was
58
+ * `Unsupported type void, deriving from fundamental void`, which names nothing
59
+ * a user could act on.
60
+ *
61
+ * Probed by FILE PRESENCE in the system directory rather than by asking a
62
+ * package database: the redistributable can arrive with Visual Studio, with
63
+ * another application's installer, or as part of a Windows image, and only one
64
+ * of those routes is visible to a package manager. What the loader actually
65
+ * needs is the DLL, so that is what is checked.
66
+ *
67
+ * `vcruntime140.dll` and `msvcp140.dll` are the C and C++ halves;
68
+ * `vcruntime140_1.dll` carries the x64 exception-handling machinery and is
69
+ * absent from pre-2019 redistributables, which is exactly the "installed, but
70
+ * too old" case that otherwise fails at first use rather than at load.
71
+ */
72
+ export declare function checkMsvcRuntime(): DepCheck;
31
73
  interface OptionalDep {
32
74
  /** DepCheck id */
33
75
  id: string;
@@ -38,6 +80,29 @@ interface OptionalDep {
38
80
  }
39
81
  /** Optional system deps keyed by their DepCheck id. Exported for the spec's declaration invariant. */
40
82
  export declare const OPTIONAL_DEPS: Record<string, OptionalDep>;
83
+ /**
84
+ * The declared system libraries a single `@gjsify/*` package needs that are NOT
85
+ * present on this host.
86
+ *
87
+ * Exists so a FAILURE can answer the question this file already knows the answer
88
+ * to. The tables below have declared `@gjsify/rolldown-native → json-glib` since
89
+ * #994, complete with per-distro package names — but the only reader was
90
+ * `runAllChecks()`, i.e. `gjsify system-check`, a command the user has to think
91
+ * to run. So when the engine was installed and simply would not LOAD, the build
92
+ * said "@gjsify/rolldown-native is NOT INSTALLED … install it" (it was) or
93
+ * guessed at a bypassed launcher. Two repositories spent a CI round rediscovering
94
+ * a library this file could have named: ts-for-gir#437, bauplaner#40.
95
+ *
96
+ * Cannot throw, by construction rather than by a catch: `checkPkgConfig` goes
97
+ * through `tryExecFile`, which returns null on any failure. That matters because
98
+ * the caller is `diagnoseNativeEngine()`, which runs *while explaining another
99
+ * failure* — a probe that dies there would replace the diagnosis with its own
100
+ * stack.
101
+ *
102
+ * Returns `[]` for an unknown package or one with no declared deps, so a caller
103
+ * needs no special case for "nothing to say".
104
+ */
105
+ export declare function missingSystemDepsFor(packageName: string): DepCheck[];
41
106
  /**
42
107
  * Map of @gjsify/* package name → ids of OPTIONAL_DEPS this package needs.
43
108
  * Generated by walking each package's `gi://` imports (excluding the always-