@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
@@ -48,6 +48,20 @@ export declare class BuildAction {
48
48
  * there is no in-bundle plugin, so this hook is the sole injection point.
49
49
  */
50
50
  private applyShebang;
51
+ /**
52
+ * Post-bundle rewrite for `--app gjs`: replace raw U+0000 bytes with `\x00`.
53
+ *
54
+ * GJS hands module source to SpiderMonkey as a NUL-terminated C string, so one raw NUL
55
+ * truncates the file and the loader reports whatever construct was open — typically
56
+ * "`` literal not terminated before end of script", which names neither the NUL nor its
57
+ * location. The minifier produces them by inlining a `'\u0000'` constant into a template
58
+ * literal, so a bundle can build and run unminified and fail under the default `--minify`.
59
+ *
60
+ * Rewrites the files on disk rather than the in-memory chunks: rolldown has already
61
+ * written them by this point, and the same treatment has to reach every chunk of an
62
+ * `--outdir` build, not just a single `--outfile`.
63
+ */
64
+ private escapeRawNul;
51
65
  /**
52
66
  * Post-bundle gate for `--app gjs`: throw when the emitted bundle still
53
67
  * STATICALLY imports Node builtins, which stock GJS cannot resolve (see
@@ -6,11 +6,12 @@ import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, detectN
6
6
  import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
7
7
  import { dirname, extname, join, resolve } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
- import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
9
+ import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
10
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
11
11
  import { inputSourceDirs, isOutdirInsideSource, libraryOutputLeakError } from '../utils/library-output.js';
12
12
  import { detectHtmlEntry, parseHtmlEntry, emitBrowserHtml, htmlOutPathFor } from '../utils/html-entry.js';
13
13
  import { assertGjsBundleLoadable } from '../utils/gjs-bundle-guard.js';
14
+ import { escapeRawNulForGjs } from '../utils/gjs-source-escape.js';
14
15
  import { assertNodeBundleGlobalsShimmed } from '../utils/node-bundle-guard.js';
15
16
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
16
17
  /**
@@ -277,6 +278,68 @@ export class BuildAction {
277
278
  if (verbose)
278
279
  console.debug(`[gjsify] --shebang: wrote ${line} + chmod 0o755 to ${outfile}`);
279
280
  }
281
+ /**
282
+ * Post-bundle rewrite for `--app gjs`: replace raw U+0000 bytes with `\x00`.
283
+ *
284
+ * GJS hands module source to SpiderMonkey as a NUL-terminated C string, so one raw NUL
285
+ * truncates the file and the loader reports whatever construct was open — typically
286
+ * "`` literal not terminated before end of script", which names neither the NUL nor its
287
+ * location. The minifier produces them by inlining a `'\u0000'` constant into a template
288
+ * literal, so a bundle can build and run unminified and fail under the default `--minify`.
289
+ *
290
+ * Rewrites the files on disk rather than the in-memory chunks: rolldown has already
291
+ * written them by this point, and the same treatment has to reach every chunk of an
292
+ * `--outdir` build, not just a single `--outfile`.
293
+ */
294
+ async escapeRawNul(outfile, outdir, verbose) {
295
+ // Deliberately NOT driven by the chunk graph: `--watch`'s BUNDLE_END carries the bundle
296
+ // rather than the generated output, and re-running generate() just to list chunks would
297
+ // double every rebuild. Reading the directory needs neither, so the same hook serves the
298
+ // one-shot build and the watch loop — and a watch rebuild that quietly reintroduced the
299
+ // raw NUL would fail exactly like the bug this exists to prevent, only intermittently.
300
+ const targets = [];
301
+ if (outfile) {
302
+ targets.push(outfile);
303
+ }
304
+ else if (outdir) {
305
+ try {
306
+ for (const name of await readdir(outdir)) {
307
+ if (name.endsWith('.mjs') || name.endsWith('.js'))
308
+ targets.push(resolve(outdir, name));
309
+ }
310
+ }
311
+ catch (err) {
312
+ // ENOENT alone is benign — a watch pass that failed before writing anything has
313
+ // no outdir yet, and there is nothing to escape. Anything ELSE (a permissions
314
+ // error, an I/O error, a wrong outdir) would silently skip the escape and let
315
+ // the build report success while emitting a bundle GJS cannot load, which is
316
+ // precisely the failure this hook exists to prevent.
317
+ if (err?.code === 'ENOENT')
318
+ return;
319
+ throw err;
320
+ }
321
+ }
322
+ for (const target of targets) {
323
+ let original;
324
+ try {
325
+ original = await readFile(target, 'utf-8');
326
+ }
327
+ catch (err) {
328
+ // Same split: a listed-but-unwritten chunk is not this hook's problem; a real
329
+ // read failure must not pass for "nothing to do".
330
+ if (err?.code === 'ENOENT')
331
+ continue;
332
+ throw err;
333
+ }
334
+ const { code, replaced } = escapeRawNulForGjs(original);
335
+ if (replaced === 0)
336
+ continue; // the overwhelmingly common case — touch nothing
337
+ await writeFile(target, code);
338
+ if (verbose) {
339
+ console.debug(`[gjsify] escaped ${replaced} raw NUL byte(s) in ${target} so GJS can load it`);
340
+ }
341
+ }
342
+ }
280
343
  /**
281
344
  * Post-bundle gate for `--app gjs`: throw when the emitted bundle still
282
345
  * STATICALLY imports Node builtins, which stock GJS cannot resolve (see
@@ -624,10 +687,16 @@ export class BuildAction {
624
687
  plugins: [...pnpPlugins, ...userPlugins, ...cfg.plugins],
625
688
  };
626
689
  if (opts.watch) {
627
- await this.runWatchLoop(finalOpts, app, outfile, verbose);
690
+ await this.runWatchLoop(finalOpts, app, outfile, outdir, verbose);
628
691
  return [];
629
692
  }
630
693
  const writeResult = await runBundle(finalOpts);
694
+ // GJS truncates module source at a raw U+0000 (it is handed to SpiderMonkey as a
695
+ // NUL-terminated C string), so escape any the minifier emitted before anything else
696
+ // touches the file. See utils/gjs-source-escape.ts.
697
+ if (app === 'gjs') {
698
+ await this.escapeRawNul(outfile, outdir, verbose);
699
+ }
631
700
  if ((app === 'gjs' || app === 'node') && this.configData.shebang) {
632
701
  await this.applyShebang(app, outfile, verbose);
633
702
  }
@@ -657,7 +726,7 @@ export class BuildAction {
657
726
  * without exiting, clean up on SIGINT/SIGTERM. Resolves only when the
658
727
  * watcher closes — keeps the CLI process alive across rebuilds.
659
728
  */
660
- async runWatchLoop(finalOpts, app, outfile, verbose) {
729
+ async runWatchLoop(finalOpts, app, outfile, outdir, verbose) {
661
730
  const watcher = await runWatch(finalOpts);
662
731
  const closed = new Promise((resolve) => {
663
732
  watcher.on('close', () => resolve());
@@ -689,6 +758,12 @@ export class BuildAction {
689
758
  case 'BUNDLE_END':
690
759
  console.log(`[gjsify build --watch] built in ${event.duration}ms`);
691
760
  try {
761
+ // Before the shebang, which re-reads the file: a rebuild that dropped a
762
+ // raw NUL back in would produce a bundle GJS cannot load, and on the
763
+ // watch loop that reads as "it broke when I saved", not as a build bug.
764
+ if (app === 'gjs') {
765
+ await this.escapeRawNul(outfile, outdir, verbose);
766
+ }
692
767
  if ((app === 'gjs' || app === 'node') && this.configData.shebang) {
693
768
  await this.applyShebang(app, outfile, verbose);
694
769
  }
@@ -0,0 +1,2 @@
1
+ export * from './build.js';
2
+ export * from './barrels-generate.js';
@@ -0,0 +1,2 @@
1
+ export * from './build.js';
2
+ export * from './barrels-generate.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Standalone entry for the affected classifier — bundled to
3
+ // `dist/affected.gjs.mjs` and run by CI's `changes` job (`gjs -m …`) on a plain
4
+ // ubuntu-latest host that only `apt install`s gjs.
5
+ //
6
+ // CRITICAL — ZERO gi:// TYPELIB DEPENDENCIES (beyond GLib/Gio, which ship with
7
+ // gjs): this entry imports ONLY the `affected` command (→ `@gjsify/workspace`
8
+ // + `@gjsify/fs`/`@gjsify/child_process` for the git diff). It MUST NOT import
9
+ // the full CLI, whose network commands (login/publish/trust/whoami/self-update/
10
+ // upgrade) pull `@gjsify/npm-registry` → `@gjsify/fetch` → `gi://Soup?version=3.0`.
11
+ // The classifier host has no Soup typelib, so a bundle that eagerly requires
12
+ // Soup throws at module load → the classifier crashes → main.yml fails open to
13
+ // a full run on every PR (the regression this entry fixes). Keep the import
14
+ // graph below Soup-free; `tests/e2e/affected-classifier-bundle` guards it.
15
+ import { affectedCommand } from './commands/affected.js';
16
+ function parseArgs(argv) {
17
+ const args = { head: 'HEAD', format: 'text', 'changed-from-stdin': false };
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const a = argv[i];
20
+ if (a === '--changed-from-stdin')
21
+ args['changed-from-stdin'] = true;
22
+ else if (a === '--base')
23
+ args.base = argv[++i];
24
+ else if (a === '--head')
25
+ args.head = argv[++i] ?? 'HEAD';
26
+ else if (a === '--cwd')
27
+ args.cwd = argv[++i];
28
+ else if (a.startsWith('--base='))
29
+ args.base = a.slice('--base='.length);
30
+ else if (a.startsWith('--head='))
31
+ args.head = a.slice('--head='.length);
32
+ else if (a.startsWith('--cwd='))
33
+ args.cwd = a.slice('--cwd='.length);
34
+ else if (a.startsWith('--format='))
35
+ args.format = a.slice('--format='.length);
36
+ else if (a === '--format')
37
+ args.format = argv[++i] ?? 'text';
38
+ }
39
+ return args;
40
+ }
41
+ const argv = process.argv.slice(2);
42
+ // Tolerate an optional leading `affected` token so the bundle can be invoked
43
+ // either as `gjs -m affected.gjs.mjs --base …` or `… affected --base …`.
44
+ const rest = argv[0] === 'affected' ? argv.slice(1) : argv;
45
+ // The cast is only about the ARGUMENT shape: `parseArgs` returns the flags this
46
+ // entry accepts, while the handler wants yargs' `ArgumentsCamelCase` wrapper
47
+ // around them. It is no longer also papering over a possibly-undefined handler —
48
+ // `affectedCommand` is a `LeafCommand`, so its handler is required by type.
49
+ await affectedCommand.handler(parseArgs(rest));
@@ -32,6 +32,12 @@ import * as path from 'node:path';
32
32
  import { createRequire } from 'node:module';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { resolveNpmPackage } from './utils/resolve-npm-package.js';
35
+ // Static, not dynamic: `diagnoseNativeEngine()` is synchronous and is called
36
+ // inside a `throw` expression. Safe — check-system-deps imports only `node:`
37
+ // builtins, so there is no cycle, and every one of them is already in the GJS
38
+ // bundle via `commands/install.ts`.
39
+ import { buildInstallCommand, detectPackageManager, missingSystemDepsFor } from './utils/check-system-deps.js';
40
+ import { activateNativePrebuilds } from './utils/gi-search-path.js';
35
41
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
36
42
  // npm `rolldown` is a Rust crate with platform-specific prebuilds; loading
37
43
  // it eagerly at module init pulls musl-detection code that does
@@ -63,17 +69,24 @@ async function loadNpmRolldown() {
63
69
  * Say WHY the native engine could not be loaded, read off disk rather than
64
70
  * guessed.
65
71
  *
66
- * The three causes need three different fixes, and this message used to assert
67
- * the rarest of them ("the CLI bundle was invoked DIRECTLY … bypasses the
68
- * `gjsify` launcher") as the "most likely cause". On the by-far most common
69
- * one — a freshly cloned + freshly installed tree, where the engine's JS facade
70
- * has simply never been built — that reading is not merely unhelpful, it points
71
- * at a launcher that was in fact used correctly, so the reader goes looking for
72
- * an env-var problem that does not exist. `scripts/verify-committed-bundles.mjs`
73
- * hit exactly that and it blocked releases.
72
+ * The causes need different fixes, and this message used to assert the rarest
73
+ * of them ("the CLI bundle was invoked DIRECTLY … bypasses the `gjsify`
74
+ * launcher") as the "most likely cause". On the by-far most common one — a
75
+ * freshly cloned + freshly installed tree, where the engine's JS facade has
76
+ * simply never been built — that reading is not merely unhelpful, it points at
77
+ * a launcher that was in fact used correctly, so the reader goes looking for an
78
+ * env-var problem that does not exist. `scripts/verify-committed-bundles.mjs`
79
+ * hit exactly that and it blocked releases. ADR 0021 then removed the bypassed
80
+ * launcher as a CAUSE outright, so it is no longer among the candidates at all.
74
81
  *
75
- * The probe is `existsSync`-only (no import, no resolution) so it cannot itself
76
- * throw or recurse into the failure being explained.
82
+ * NOTHING here may throw or recurse into the failure being explained. Branches 1
83
+ * and 2 are `existsSync`-only (no import, no resolution). Branch 3 additionally
84
+ * asks `missingSystemDepsFor()` and reads the memoized `activateNativePrebuilds()`
85
+ * result — both safe by construction rather than by a catch: the former routes
86
+ * every probe through `tryExecFile`, which returns null on any failure, and the
87
+ * latter is an array the failing load attempt already computed. Keep that
88
+ * property when editing: a probe that dies here replaces the diagnosis with its
89
+ * own stack.
77
90
  */
78
91
  function diagnoseNativeEngine() {
79
92
  let pkgDir = null;
@@ -109,15 +122,60 @@ function diagnoseNativeEngine() {
109
122
  'that is closed, seed `lib/` from the published package — which ships it — or from a host that has ' +
110
123
  'Node. Tracked in `status/open-todos.md` ("A Node-less host cannot bootstrap a fresh CLONE").');
111
124
  }
112
- return ('The JS facade is built, so this is the typelib/prebuild lookup failing. Most likely the CLI bundle was ' +
113
- 'invoked DIRECTLY (`gjs -m …/cli.gjs.mjs`, or by executing dist/cli.gjs.mjs), which bypasses the `gjsify` ' +
114
- 'launcher that exports GI_TYPELIB_PATH / LD_LIBRARY_PATH for the prebuilds. The typelib lookup happens ' +
115
- 'inside the GJS runtime, so those must be set BEFORE the process starts — the CLI cannot repair it from ' +
116
- 'the inside. Run through the `gjsify` bin instead (`node_modules/.bin/gjsify …`, or the globally installed ' +
117
- 'shim), or export the env yourself:\n' +
118
- ` P=${path.join(pkgDir, 'prebuilds', `${process.platform ?? 'linux'}-${process.arch ?? 'x64'}`)}\n` +
119
- ' GI_TYPELIB_PATH=$P LD_LIBRARY_PATH=$P gjs -m …/cli.gjs.mjs build …\n' +
120
- 'Otherwise: no prebuild exists for this architecture. Running the build under Node also works.');
125
+ // Branch 3: installed AND built, so the typelib/prebuild LOOKUP failed.
126
+ //
127
+ // This branch used to assert "Most likely the CLI bundle was invoked
128
+ // DIRECTLY … bypasses the launcher" — repeating, one branch later, the exact
129
+ // mistake this function's docblock records fixing in branch 2: naming the
130
+ // rarest cause as the most likely one. After #994 measured `ldd` over every
131
+ // committed linux-x64 prebuild on a consumer-baseline host, a missing
132
+ // `libjson-glib-1.0.so.0` is at least as common, and it is what ts-for-gir#437
133
+ // actually hit — with this message on screen.
134
+ //
135
+ // So both candidates are now MEASURED, cheaply and without any call that can
136
+ // throw while explaining a failure, and the measured one is printed first.
137
+ //
138
+ // ADR 0021 RETIRED THE THIRD CANDIDATE. "The launcher was bypassed, export
139
+ // these two variables yourself" is no longer a cause of failure at all:
140
+ // `tryLoadNative()` calls `activateNativePrebuilds()` before the engine is
141
+ // loaded, which puts every detected prebuild dir on girepository's typelib
142
+ // AND library search paths in-process. Do not reintroduce it — besides now
143
+ // being false, the path it printed (`<facade>/prebuilds/<target>`) had been
144
+ // wrong since ADR 0017 moved every artifact into a per-target SIBLING
145
+ // package, so it told readers to export a directory that does not exist.
146
+ const parts = ['The JS facade is built, so this is the typelib/prebuild lookup failing.'];
147
+ // (1) A declared system library that is not on this host. `missingSystemDepsFor`
148
+ // reads the same tables `gjsify system-check` does, so the answer cannot drift
149
+ // from the declaration.
150
+ const missing = missingSystemDepsFor('@gjsify/rolldown-native');
151
+ if (missing.length > 0) {
152
+ const names = missing.map((d) => d.name).join(', ');
153
+ parts.push(`\nMEASURED CAUSE — a system library the engine's prebuild loads is MISSING: ${names}.\n` +
154
+ 'The prebuild is present but its typelib cannot open its backing library, which GJS reports as ' +
155
+ '`Unsupported type void, deriving from fundamental void` — a message that names nothing. ' +
156
+ 'This is a system package, not an npm one.');
157
+ const cmd = buildInstallCommand(detectPackageManager(), missing);
158
+ if (cmd)
159
+ parts.push(`Install it:\n ${cmd}`);
160
+ else
161
+ parts.push(`Install ${names} with your system package manager (this host's manager was not recognised, so no command is suggested rather than a wrong one).`);
162
+ }
163
+ // (2) The remaining cause, last because it is the least actionable: no
164
+ // prebuild for this architecture. Read off the set `activateNativePrebuilds()`
165
+ // ACTUALLY put on the search paths — memoized, so this is a cached-array read
166
+ // that cannot throw, and unlike the old hand-composed path it names the
167
+ // per-target sibling package that really holds the artifact.
168
+ if (missing.length === 0) {
169
+ const engine = activateNativePrebuilds().find((p) => p.name.startsWith('@gjsify/rolldown-native'));
170
+ parts.push(engine
171
+ ? `\nEvery declared system library resolves and ${engine.name}'s prebuild (${engine.prebuildsDir}) IS on ` +
172
+ "girepository's search paths, so it is present but did not load."
173
+ : `\nEvery declared system library resolves, so most likely no prebuild exists for ` +
174
+ `${process.platform}/${process.arch} — no @gjsify/rolldown-native prebuild directory was found ` +
175
+ `walking up from ${process.cwd()}.`);
176
+ }
177
+ parts.push('\nRunning the build under Node also works — there the npm `rolldown` crate is used instead.');
178
+ return parts.join('\n');
121
179
  }
122
180
  /**
123
181
  * Convert a bare npm specifier into something dynamic `import(...)` can
@@ -296,6 +354,13 @@ async function tryLoadNative() {
296
354
  if (!isGjs())
297
355
  return null;
298
356
  try {
357
+ // Make the engine's prebuild resolvable to THIS process before the
358
+ // import that needs it. Without this the engine loads only when the
359
+ // `gjsify` launcher exported GI_TYPELIB_PATH + the library-path var
360
+ // before exec, so a direct `gjs -m …/dist/cli.gjs.mjs build …` — what
361
+ // several e2e suites do — died with "no usable bundler engine under
362
+ // GJS" on a tree where the engine was installed and loadable. ADR 0021.
363
+ activateNativePrebuilds();
299
364
  // Under GJS the ESM loader has no node_modules resolver — a bare
300
365
  // `import('@gjsify/rolldown-native')` would throw `Module not
301
366
  // found`. Resolve via createRequire (PnP+node_modules-aware) to
package/lib/cli-app.js CHANGED
@@ -19,6 +19,7 @@ import { readFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import yargs from 'yargs';
22
+ import { classifyCliFailure } from './cli-fail.js';
22
23
  import { buildCommand as build, clearCommand as clear, copyCommand as copy, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, onboardCommand as onboard, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
23
24
  import { APP_NAME } from './constants.js';
24
25
  import { isBun, isDeno, isNode, gjsSystemVersion } from '@gjsify/rolldown-plugin-gjsify/runtime';
@@ -84,6 +85,25 @@ export async function runCli(argv) {
84
85
  .scriptName(APP_NAME)
85
86
  .version(readBundleVersion())
86
87
  .strict()
88
+ // Sits beside `.strict()` because that is what produces most of what it
89
+ // classifies. See `cli-fail.ts` for the four yargs measurements this
90
+ // depends on; the short version: `msg` is the discriminator (a handler
91
+ // rejection is the ONE call site that passes null), registering a handler
92
+ // removes yargs' own showHelp+exit, and the incoming error must never be
93
+ // re-thrown as-is because yargs routes `YError` back into `fail()`.
94
+ .fail((msg, err) => {
95
+ const failure = classifyCliFailure(msg, err);
96
+ if (failure.kind === 'usage') {
97
+ cli.showHelp('error');
98
+ // Plain Error, not `err`: re-throwing a YError re-enters fail()
99
+ // and prints the help twice. `index.ts` prints this message.
100
+ throw new Error(failure.message, { cause: err });
101
+ }
102
+ // A handler threw. Return WITHOUT printing or exiting — that is the
103
+ // entire fix. The rejection propagates out of `parseAsync()` and
104
+ // `index.ts`'s catch is the one printer (message, optional
105
+ // GJSIFY_DEBUG stack, `process.exitCode = 1`, `gjsExit(1)`).
106
+ })
87
107
  // Use the full terminal width for help (yargs caps at 80 by default).
88
108
  .wrap(cli.terminalWidth())
89
109
  .command(create.command, create.description, create.builder, create.handler)
@@ -0,0 +1,22 @@
1
+ /** What kind of failure yargs is reporting, and the message when it has one. */
2
+ export type CliFailure =
3
+ /** A usage/validation/parse error: the caller's argv was wrong, so help helps. */
4
+ {
5
+ kind: 'usage';
6
+ message: string;
7
+ }
8
+ /** A command handler threw or rejected: help is noise, `index.ts` prints it. */
9
+ | {
10
+ kind: 'runtime';
11
+ };
12
+ /**
13
+ * Classify a yargs `fail(msg, err)` callback into the two cases that need
14
+ * different output.
15
+ *
16
+ * Pure on purpose — the whole point is that the rule is testable without
17
+ * driving a process to its death. `cli-fail.spec.ts` additionally pins the
18
+ * yargs-side half (which call site passes `null`) against a real instance, so a
19
+ * yargs upgrade that changes the routing fails a test instead of silently
20
+ * turning every runtime error back into a help dump.
21
+ */
22
+ export declare function classifyCliFailure(msg: string | null | undefined, _err: unknown): CliFailure;
@@ -0,0 +1,67 @@
1
+ // How the CLI ends when something goes wrong — the one place that decides
2
+ // whether a failure is a USAGE error (help is the right output) or a RUNTIME
3
+ // error (help is noise that buries the answer).
4
+ //
5
+ // The defect this exists for, measured from outside the repo: `gjsify build`
6
+ // under GJS without `@gjsify/rolldown-native` prints a CORRECT and actionable
7
+ // diagnosis — `diagnoseNativeEngine()` names the package and the exact install
8
+ // command — and then yargs prints ~60 lines of `gjsify build` help on top of it
9
+ // and exits 1. In a CI log the actionable line scrolls out of view and the
10
+ // visible artifact is a help dump, which reads as "bad arguments". That is what
11
+ // ts-for-gir's `test-e2e` leg looked like (#1005). A missing engine is not a
12
+ // usage error and must not leave through the usage path.
13
+ //
14
+ // Four things about yargs make this module's shape non-obvious. All four were
15
+ // measured against the pinned `yargs@18.0.0` (note `@types/yargs` is still 17,
16
+ // one major behind, so the types lie in one place — see below):
17
+ //
18
+ // 1. `.fail()` IS reached for an async handler rejection. `command.js:253-260`
19
+ // does `if (isPromise(innerArgv) && !hasParseCallback()) innerArgv.catch(e =>
20
+ // usage.fail(null, e))`, and `hasParseCallback()` is false because
21
+ // `cli-app.ts` calls `parseAsync()` with no function argument. So no
22
+ // try/catch around `parseAsync` is needed to see handler errors here.
23
+ //
24
+ // 2. THE DISCRIMINATOR IS `msg`, NOT `err`. Every usage/validation/parse
25
+ // failure passes a STRING: all ten `validation.js` sites call
26
+ // `usage.fail(msg)`, `command.js:370` calls `fail(parsed.error.message,
27
+ // parsed.error)`, and `yargs-factory.js:188/192/1473` call `fail(msg, err)`.
28
+ // Only `command.js:257` — the handler rejection — passes `null`. Branching on
29
+ // `err` instead would suppress help for genuine parser errors, which is
30
+ // exactly backwards.
31
+ //
32
+ // 3. REGISTERING A HANDLER REMOVES YARGS' OWN EXIT. With no `.fail()` yargs does
33
+ // `showHelp('error')` + `logger.error(msg || err)` + `exit(1)` itself
34
+ // (`usage.js:27-70`). With one, it does none of that — so the usage branch has
35
+ // to terminate on its own. `showHelp()` happens to set `hasOutput`
36
+ // (`yargs-factory.js:834`), which blocks the handler while the chain is still
37
+ // synchronous, but that is a side effect, not a guarantee: `throw` is what
38
+ // survives a future async builder or middleware.
39
+ //
40
+ // 4. NEVER RE-THROW THE INCOMING `YError`. `yargs-factory.js:1471-1475` routes
41
+ // that class back into `usage.fail()`, so re-throwing it prints the help a
42
+ // second time. The usage branch throws a plain `Error` with the original
43
+ // attached as `cause`.
44
+ //
45
+ // The runtime branch deliberately prints NOTHING and exits NOTHING: the rejection
46
+ // propagates out of `parseAsync()` on its own, and `index.ts` is the single
47
+ // printer (it also handles `GJSIFY_DEBUG` stacks and the GJS-side `gjsExit(1)`,
48
+ // which a second printer here would duplicate). Suppressing yargs'
49
+ // help-and-exit IS this branch's whole job — it only looks like a no-op.
50
+ /**
51
+ * Classify a yargs `fail(msg, err)` callback into the two cases that need
52
+ * different output.
53
+ *
54
+ * Pure on purpose — the whole point is that the rule is testable without
55
+ * driving a process to its death. `cli-fail.spec.ts` additionally pins the
56
+ * yargs-side half (which call site passes `null`) against a real instance, so a
57
+ * yargs upgrade that changes the routing fails a test instead of silently
58
+ * turning every runtime error back into a help dump.
59
+ */
60
+ export function classifyCliFailure(msg, _err) {
61
+ // A non-empty string means yargs itself diagnosed the argv. An empty string
62
+ // is treated as "no message" rather than as an empty usage error: it carries
63
+ // nothing to print, and printing help with no reason is the worst of both.
64
+ if (typeof msg === 'string' && msg.length > 0)
65
+ return { kind: 'usage', message: msg };
66
+ return { kind: 'runtime' };
67
+ }
@@ -1,4 +1,4 @@
1
- import type { Command } from '../types/index.js';
1
+ import type { LeafCommand } from '../types/index.js';
2
2
  interface AffectedOptions {
3
3
  base?: string;
4
4
  head?: string;
@@ -6,5 +6,5 @@ interface AffectedOptions {
6
6
  'changed-from-stdin': boolean;
7
7
  cwd?: string;
8
8
  }
9
- export declare const affectedCommand: Command<unknown, AffectedOptions>;
9
+ export declare const affectedCommand: LeafCommand<unknown, AffectedOptions>;
10
10
  export {};
@@ -223,6 +223,22 @@ const OTHER_WORKFLOW_INPUTS = [
223
223
  // other. Only the `scripts/`-side repo-scoped half is ignored, and that
224
224
  // half is precisely the half `main.yml` does not load.
225
225
  /^scripts\/manifest-conformance\//,
226
+ // The authored status data (ADR 0016) and the generator that renders it.
227
+ // Same two halves as the entry above, and both hold: `main.yml` reads
228
+ // NEITHER — it never runs `npm run status:generate` and never opens
229
+ // `status/`, which the grep for it confirms (the only hits are comments
230
+ // citing `open-todos.md` as a ledger) — while `audit-runtimes.yml` runs the
231
+ // `status-data` rule over exactly this data on EVERY pull_request with no
232
+ // `paths:` filter.
233
+ //
234
+ // WHAT IT COSTS TODAY: `status/*.md` already fell into `IGNORE` through the
235
+ // generic `/\.md$/i`, so the drift was invisible for the prose files and
236
+ // showed up only on `status/status.json` — which matched nothing, landed in
237
+ // `unmatched`, and forced a FULL CI run for a file `main.yml` cannot read.
238
+ // Editing one package's status prose paid for the whole matrix. Naming the
239
+ // directory makes the `.md` case intentional rather than incidental.
240
+ /^status\//,
241
+ /^scripts\/generate-status\.mjs$/,
226
242
  ];
227
243
  /** Patterns that contribute no seed and don't force a full run. */
228
244
  const IGNORE = [
@@ -61,4 +61,59 @@ export declare function buildWorkspaceBinShimsForWindows(wsLocation: string, nod
61
61
  cmd: string;
62
62
  ps1: string;
63
63
  };
64
+ /**
65
+ * Lay the GJS bundler engine down after a PROJECT install, when the host will
66
+ * need it and does not have it. Closes #1005.
67
+ *
68
+ * The engine is an OPTIONAL PEER of `@gjsify/cli`, deliberately: a plain
69
+ * `npm install @gjsify/cli` on Node/macOS/Windows must not fetch Linux
70
+ * prebuilds. But nobody resolves it for a project — npm 7+ skips optional peers,
71
+ * and the native backend does not resolve peerDependencies at all
72
+ * (`install-backend-native.ts:76`). Under GJS there is no npm `rolldown`
73
+ * fallback, so `gjsify build` simply cannot work. Every consumer in the
74
+ * workspace independently discovered this and hand-declared the engine.
75
+ *
76
+ * Four conditions, and the second is the one worth reading:
77
+ *
78
+ * (a) `@gjsify/cli` is in this tree — otherwise the engine has no caller.
79
+ * (b) THE HOST CAN RUN GJS, which is NOT the same question as `isGjs()`.
80
+ * `isGjs()` asks which runtime is executing the installer; the launcher
81
+ * (`bin-shim.ts:616`) prefers the GJS bundle whenever `command -v gjs`
82
+ * succeeds, so a tree installed BY NODE is routinely built BY GJS. Gating
83
+ * on the interpreter would reproduce #1005 for every contributor who
84
+ * installs on Node and builds through the shim. `runMinimalChecks()` above
85
+ * already probed for gjs, so this costs no extra spawn — and it goes
86
+ * nowhere near `which`.
87
+ * (c) No engine is already reachable — asked with the same walk the BUILD uses.
88
+ * (d) Not `--immutable`. A frozen install cannot acquire something its
89
+ * lockfile does not name without breaking the contract `--immutable`
90
+ * exists to hold, so that case warns and installs nothing.
91
+ *
92
+ * Deliberately NOT gated on `gjsify.app` or on "declares a build script":
93
+ * neither of the failing consumers declares the former, and the latter would
94
+ * mean pattern-matching script bodies — the hand-maintained judgement that
95
+ * drifts, which ADR 0017 rejects by name.
96
+ *
97
+ * NOT recorded in the lockfile. `installPackages` writes one only under
98
+ * `if (resolved && opts.lockfile)` and the default is falsy, so a consumer's
99
+ * `gjsify-lock.json` is never rewritten by this. An e2e row pins that.
100
+ *
101
+ * Ordering is not a constraint here, unlike the global path (which must precede
102
+ * `linkGlobalBins`): `buildNativeEnvPreamble` scans the prefix at LAUNCH time
103
+ * (`bin-shim.ts:432-444`), so a shim written before the engine arrives still
104
+ * exports its dir. `tests/e2e/global-install-engine/run.mjs:361-374` asserts
105
+ * exactly that case.
106
+ */
107
+ export interface EnsureProjectGjsEngineDeps {
108
+ /** Project root. Defaults to `process.cwd()`. */
109
+ cwd?: string;
110
+ /** Injected so the decision is testable without a registry — the shape
111
+ * `installGjsEnginePackages` already uses for the same reason. */
112
+ installFn?: (prefix: string, version: string, opts: {
113
+ verbose?: boolean;
114
+ }) => Promise<void>;
115
+ /** Injected so a host that HAS an engine can still exercise the missing branch. */
116
+ hasEngineFn?: (dir: string) => boolean;
117
+ }
118
+ export declare function ensureProjectGjsEngine(args: InstallOptions, gjsFound: boolean, deps?: EnsureProjectGjsEngineDeps): Promise<void>;
64
119
  export {};