@gjsify/cli 0.40.0 → 0.42.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 (72) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1043 -404
  3. package/lib/actions/build.d.ts +14 -0
  4. package/lib/actions/build.js +54 -7
  5. package/lib/bundler-pick.js +10 -2
  6. package/lib/cli-app.js +3 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/create.d.ts +3 -0
  9. package/lib/commands/create.js +49 -6
  10. package/lib/commands/debug.js +1 -1
  11. package/lib/commands/dev.d.ts +13 -0
  12. package/lib/commands/dev.js +132 -0
  13. package/lib/commands/dlx.js +1 -1
  14. package/lib/commands/index.d.ts +2 -0
  15. package/lib/commands/index.js +2 -0
  16. package/lib/commands/install.d.ts +1 -0
  17. package/lib/commands/install.js +32 -1
  18. package/lib/commands/prune.d.ts +11 -0
  19. package/lib/commands/prune.js +84 -0
  20. package/lib/commands/run.js +38 -85
  21. package/lib/commands/self-update.d.ts +1 -0
  22. package/lib/commands/self-update.js +55 -0
  23. package/lib/commands/ship.js +6 -1
  24. package/lib/commands/showcase.js +1 -1
  25. package/lib/commands/storybook.js +17 -74
  26. package/lib/commands/test.js +12 -16
  27. package/lib/commands/tsc.js +96 -45
  28. package/lib/config.js +4 -0
  29. package/lib/templates/install.mjs.tmpl +8 -3
  30. package/lib/types/config-data.d.ts +27 -0
  31. package/lib/utils/app-metadata.js +27 -6
  32. package/lib/utils/check-system-deps.d.ts +2 -2
  33. package/lib/utils/check-system-deps.js +92 -13
  34. package/lib/utils/daemon-command.d.ts +7 -0
  35. package/lib/utils/daemon-command.js +30 -0
  36. package/lib/utils/dev-plan.d.ts +52 -0
  37. package/lib/utils/dev-plan.js +117 -0
  38. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  39. package/lib/utils/gi-runtime-paths.js +84 -0
  40. package/lib/utils/gi-typelib.d.ts +49 -0
  41. package/lib/utils/gi-typelib.js +160 -0
  42. package/lib/utils/install-provenance.d.ts +44 -0
  43. package/lib/utils/install-provenance.js +106 -0
  44. package/lib/utils/node-script.d.ts +4 -1
  45. package/lib/utils/node-script.js +27 -3
  46. package/lib/utils/npm-oidc.js +8 -5
  47. package/lib/utils/prune-prefix.d.ts +83 -0
  48. package/lib/utils/prune-prefix.js +333 -0
  49. package/lib/utils/run-gjs.d.ts +12 -1
  50. package/lib/utils/run-gjs.js +24 -31
  51. package/lib/utils/run-node.d.ts +9 -4
  52. package/lib/utils/run-node.js +34 -41
  53. package/lib/utils/ship/depends.d.ts +5 -0
  54. package/lib/utils/ship/depends.js +38 -0
  55. package/lib/utils/ship/discover.js +106 -1
  56. package/lib/utils/ship/launcher.js +27 -4
  57. package/lib/utils/ship/payload.d.ts +35 -0
  58. package/lib/utils/ship/payload.js +84 -0
  59. package/lib/utils/ship/plan.js +20 -0
  60. package/lib/utils/ship/settings.d.ts +8 -1
  61. package/lib/utils/ship/settings.js +13 -1
  62. package/lib/utils/ship/types.d.ts +19 -0
  63. package/lib/utils/simple-command.d.ts +16 -0
  64. package/lib/utils/simple-command.js +77 -0
  65. package/lib/utils/spawn.d.ts +7 -1
  66. package/lib/utils/spawn.js +34 -7
  67. package/lib/utils/system-gi.d.ts +24 -0
  68. package/lib/utils/system-gi.js +14 -3
  69. package/lib/utils/watch-loop.d.ts +91 -0
  70. package/lib/utils/watch-loop.js +207 -0
  71. package/package.json +27 -18
  72. package/lib/templates/app/desktop.tmpl +0 -10
@@ -17,7 +17,6 @@
17
17
  // if the dep was removed or the consumer is running a forked CLI.
18
18
  //
19
19
  // Reference: packages/infra/cli/src/commands/run.ts (env-setup precedent).
20
- import { spawn } from 'node:child_process';
21
20
  import { createRequire } from 'node:module';
22
21
  import { dirname, join } from 'node:path';
23
22
  import { pathToFileURL } from 'node:url';
@@ -27,6 +26,7 @@ import { existsSync } from 'node:fs';
27
26
  import { forceExit } from '../utils/force-exit.js';
28
27
  import { nodeBinary } from '../utils/run-node.js';
29
28
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
29
+ import { spawnToCompletion } from '../utils/spawn.js';
30
30
  export const tscCommand = {
31
31
  command: 'tsc [tscArgs..]',
32
32
  description: 'Run TypeScript compiler (tsc) under GJS via the @gjsify/tsc bundle. All arguments are passed through to tsc. Equivalent to `gjsify-tsc <args>` from the @gjsify/tsc bin.',
@@ -139,7 +139,7 @@ export const tscCommand = {
139
139
  // fallback below is the NORMAL path there, not an edge case.
140
140
  // `run-node.ts` owns the one correct answer; every other spawn site in
141
141
  // this CLI already routes through it.
142
- const runNodeTsc = () => {
142
+ const runNodeTsc = async () => {
143
143
  const nodeBin = nodeBinary();
144
144
  // PRE-FLIGHT, deliberately NOT the spawn's `'error'` event.
145
145
  //
@@ -161,64 +161,115 @@ export const tscCommand = {
161
161
  console.error(' …or install Node.js so the upstream `typescript` fallback can be spawned.');
162
162
  return forceExit(1);
163
163
  }
164
- const child = spawn(nodeBin, [nodeTscPath, ...tscArgs], { env, stdio: 'inherit' });
165
- child.on('close', (code) => forceExit(code ?? 1));
166
- child.on('error', (err) => {
167
- if (err.code === 'ENOENT') {
164
+ // `forceExit` runs from the child's own `'close'` DISPATCH, never from a
165
+ // promise continuation — which is why this awaits a promise that only
166
+ // ever REJECTS instead of `await`ing the completion.
167
+ //
168
+ // Under GJS `forceExit` ends the process by THROWING SpiderMonkey's
169
+ // uncatchable exit exception. Thrown inside an `await` continuation that
170
+ // exception is captured by the async machinery as a rejection rather than
171
+ // unwinding the stack, so nothing exits — and the GLib main loop the spawn
172
+ // armed then keeps the process alive with nothing left to quit it.
173
+ // Measured: `gjsify tsc --version` printed `Version 6.0.3` and hung
174
+ // forever, which in CI is a 30-minute step timeout.
175
+ try {
176
+ await new Promise((_never, reject) => {
177
+ void spawnToCompletion(nodeBin, [nodeTscPath, ...tscArgs], {
178
+ completion: 'exit',
179
+ env,
180
+ onSpawn: (child) => {
181
+ // A FAILED spawn emits both `'error'` and `'close'`, and
182
+ // that `'close'` carries the SPAWN ERRNO rather than an
183
+ // exit status — win32 ENOENT is -4058, which reached the
184
+ // shell as 4294963238 and took every Windows build down
185
+ // with it. Exiting on it would also pre-empt the Node
186
+ // fallback. This is the race the old `gjsSpawnFailed`
187
+ // flag existed to break; deleting the flag deleted the
188
+ // property, so it is back, with its measurement.
189
+ let spawnFailed = false;
190
+ child.on('error', () => {
191
+ spawnFailed = true;
192
+ });
193
+ child.on('close', (code) => {
194
+ if (!spawnFailed)
195
+ forceExit(code ?? 1);
196
+ });
197
+ },
198
+ }).catch(reject);
199
+ });
200
+ }
201
+ catch (err) {
202
+ const e = err;
203
+ if (e.code === 'ENOENT') {
168
204
  console.error('gjsify tsc: the @gjsify/tsc GJS bundle is unavailable and `node` is not on PATH, ' +
169
205
  'so upstream `typescript` cannot run either.');
170
206
  console.error(' Build the bundle with: gjsify workspace @gjsify/tsc build');
171
207
  }
172
208
  else {
173
- console.error(`gjsify tsc (node typescript): ${err.message}`);
209
+ console.error(`gjsify tsc (node typescript): ${e.message}`);
174
210
  }
175
- forceExit(1);
176
- });
211
+ return forceExit(1);
212
+ }
177
213
  };
178
214
  // Prefer GJS — the Node-free @gjsify/tsc bundle — when it resolves. If
179
215
  // `gjs` is not on PATH (ENOENT), transparently fall back to upstream
180
216
  // `typescript` under Node when available.
181
217
  if (bundlePath) {
182
- // A failed spawn (notably ENOENT when `gjs` is absent) emits BOTH
183
- // 'error' AND 'close'. The 'error' handler owns the outcome (Node
184
- // fallback or a clear exit), so the 'close' handler must NOT also
185
- // exit — otherwise it races and exits with the spawn errno (e.g.
186
- // ENOENT → -2), which surfaced as a misleading 254 and pre-empted the
187
- // fallback (gjsify tsc died instead of degrading to upstream tsc on a
188
- // gjs-less host such as a CI runner).
189
- let gjsSpawnFailed = false;
190
- const child = spawn('gjs', ['-m', bundlePath, ...tscArgs], { env, stdio: 'inherit' });
191
- await new Promise((resolvePromise) => {
192
- child.on('close', (code) => {
193
- if (gjsSpawnFailed)
194
- return;
195
- forceExit(code ?? 1);
196
- });
197
- child.on('error', (err) => {
198
- gjsSpawnFailed = true;
199
- if (err.code === 'ENOENT' && nodeTscPath) {
200
- // No gjs on PATH — run upstream typescript under Node.
201
- runNodeTsc();
202
- }
203
- else if (err.code === 'ENOENT') {
204
- console.error('gjsify tsc: `gjs` not found on PATH and no npm `typescript` fallback.');
205
- console.error(' Install GJS (e.g. `dnf install gjs`), or add `typescript` to the project.');
206
- forceExit(1);
207
- }
208
- else {
209
- console.error(`gjsify tsc: ${err.message}`);
210
- forceExit(1);
211
- }
218
+ // A raw failed spawn (notably ENOENT when `gjs` is absent) emits BOTH
219
+ // 'error' AND 'close', and the two raced: the 'close' handler exited
220
+ // with the spawn errno (ENOENT → -2, surfacing as a misleading 254)
221
+ // and pre-empted the Node fallback, so `gjsify tsc` died instead of
222
+ // degrading to upstream tsc on a gjs-less host such as a CI runner. A
223
+ // promise cannot both reject and resolve, so routing through
224
+ // `spawnToCompletion` makes that race structurally impossible rather
225
+ // than guarded by a flag.
226
+ // Same shape, and the same reason, as `runNodeTsc` above: exit from the
227
+ // child's `'close'` dispatch, await a promise that only rejects.
228
+ try {
229
+ await new Promise((_never, reject) => {
230
+ void spawnToCompletion('gjs', ['-m', bundlePath, ...tscArgs], {
231
+ completion: 'exit',
232
+ env,
233
+ onSpawn: (child) => {
234
+ // A FAILED spawn emits both `'error'` and `'close'`, and
235
+ // that `'close'` carries the SPAWN ERRNO rather than an
236
+ // exit status — win32 ENOENT is -4058, which reached the
237
+ // shell as 4294963238 and took every Windows build down
238
+ // with it. Exiting on it would also pre-empt the Node
239
+ // fallback. This is the race the old `gjsSpawnFailed`
240
+ // flag existed to break; deleting the flag deleted the
241
+ // property, so it is back, with its measurement.
242
+ let spawnFailed = false;
243
+ child.on('error', () => {
244
+ spawnFailed = true;
245
+ });
246
+ child.on('close', (code) => {
247
+ if (!spawnFailed)
248
+ forceExit(code ?? 1);
249
+ });
250
+ },
251
+ }).catch(reject);
212
252
  });
213
- // Never resolves naturally — process.exit() above terminates the
214
- // program once the child closes. The promise just keeps the
215
- // handler alive while the child runs.
216
- void resolvePromise;
217
- });
253
+ }
254
+ catch (err) {
255
+ const e = err;
256
+ if (e.code === 'ENOENT' && nodeTscPath) {
257
+ // No gjs on PATH — run upstream typescript under Node.
258
+ return await runNodeTsc();
259
+ }
260
+ if (e.code === 'ENOENT') {
261
+ console.error('gjsify tsc: `gjs` not found on PATH and no npm `typescript` fallback.');
262
+ console.error(' Install GJS (e.g. `dnf install gjs`), or add `typescript` to the project.');
263
+ }
264
+ else {
265
+ console.error(`gjsify tsc: ${e.message}`);
266
+ }
267
+ return forceExit(1);
268
+ }
218
269
  }
219
270
  else {
220
271
  // No GJS bundle resolvable — go straight to the Node fallback.
221
- runNodeTsc();
272
+ await runNodeTsc();
222
273
  }
223
274
  },
224
275
  };
package/lib/config.js CHANGED
@@ -38,6 +38,10 @@ async function loadConfigViaGjsBundle(filepath, verbose) {
38
38
  // import.meta.url)), 'package.json'))`) relative to ITS OWN location.
39
39
  // Bake the original config's URL so those reads hit the real dir.
40
40
  define: { 'import.meta.url': JSON.stringify(pathToFileURL(filepath).href) },
41
+ // A config file is evaluated INSIDE the CLI process — toolchain by
42
+ // construction, so a `@gjsify/*` the project cannot resolve may come
43
+ // from the CLI's own install.
44
+ resolveFromToolchain: true,
41
45
  });
42
46
  }
43
47
  catch (bundleErr) {
@@ -188,9 +188,14 @@ function checkGjsVersion() {
188
188
  if (major < 1 || (major === 1 && minor < 86)) {
189
189
  error(`gjs ${major}.${minor} is too old — gjsify requires gjs 1.86 or newer.`);
190
190
  error('Install hints:');
191
- error(' Fedora 43+: sudo dnf install gjs');
192
- error(' Debian 13+: sudo apt install gjs');
193
- error(' Arch: sudo pacman -S gjs');
191
+ error(' Fedora 43+: sudo dnf install gjs');
192
+ error(' Arch: sudo pacman -S gjs');
193
+ // NOT "Debian 13+". Measured 2026-08-16 against tracker.debian.org: trixie
194
+ // (13, stable) ships 1.82.3, forky (testing) 1.88.1, sid 1.89.2 — 1.84 and
195
+ // 1.86 were skipped entirely. Sending a trixie user to `apt install gjs`
196
+ // hands them the very version this branch just rejected, from the branch
197
+ // that rejected it. Same fact, same wording as `utils/ship/depends.ts`.
198
+ error(' Debian forky/sid: sudo apt install gjs (trixie ships 1.82.3 — too old)');
194
199
  exit(1);
195
200
  }
196
201
  }
@@ -433,6 +433,33 @@ export interface ConfigDataShip extends AppMetadata {
433
433
  deb: string;
434
434
  rpm: string;
435
435
  }>;
436
+ /**
437
+ * Directories whose `*.typelib` and `lib*.so*` the package CARRIES ITSELF, e.g.
438
+ * `["../node_modules/@gjsify/webgl-linux-x64/prebuilds/linux-x64"]`.
439
+ *
440
+ * gjsify's own GI libraries — `Gwebgl`, the GTK runtime bundles, the napi host — arrive as npm
441
+ * prebuilds, not as distro packages, so an app that imports one has no `gir1.2-…` to depend on.
442
+ * Staging them here puts them in `lib/<binary>/gi/` and makes the launcher point
443
+ * `GI_TYPELIB_PATH` and `LD_LIBRARY_PATH` at that directory.
444
+ *
445
+ * The namespaces this covers are read back off the STAGED FILES, not from a separate list:
446
+ * a declaration that a namespace is bundled, without the file being there, is exactly the lie
447
+ * the dependency check exists to prevent.
448
+ */
449
+ bundledTypelibs?: string[];
450
+ /**
451
+ * Directory holding COMPILED gettext catalogues in the layout `bindtextdomain` reads —
452
+ * `<lang>/LC_MESSAGES/<domain>.mo` — e.g. `"dist/locale"`, which is what
453
+ * `@gjsify/vite-plugin-gettext`'s `gettextPlugin` writes by default.
454
+ *
455
+ * Staged into `share/locale/` with that structure preserved, and the launcher then exports
456
+ * `GJSIFY_LOCALE_DIR` so the app can call `bindtextdomain(domain, dir)` without knowing which
457
+ * prefix it was installed under.
458
+ *
459
+ * `.po` sources are NOT accepted: `bindtextdomain` reads `.mo` only, and staging a `.po`
460
+ * produces a package that installs its translations and shows none of them.
461
+ */
462
+ localeDir?: string;
436
463
  /** Extra payload entries: prefix-relative destination → project-relative source. */
437
464
  extraFiles?: Record<string, string>;
438
465
  /** Arguments the launcher appends before the user's own. */
@@ -13,15 +13,36 @@
13
13
  // attributes, provides) has too many optional nested sections for a
14
14
  // template+placeholder approach to stay legible. The `.desktop` entry stays
15
15
  // template-based — it is flat `key=value`.
16
- import { readFileSync } from 'node:fs';
17
16
  /**
18
- * Lazy template loader. `static-read-inliner` matches this exact shape
19
- * (`readFileSync(new URL(<literal>, import.meta.url), 'utf-8')`) and inlines
20
- * the template into the GJS bundle at build time — a `join(__dirname, …)`
21
- * spelling silently loses that and ENOENTs under GJS.
17
+ * The `.desktop` skeleton, as source rather than as a file to find at runtime.
18
+ *
19
+ * It used to live in `src/templates/app/desktop.tmpl` and be read through
20
+ * `readFileSync(new URL(…, import.meta.url))`, with a comment stating that
21
+ * `static-read-inliner` inlines it into the GJS bundle. It does not: the
22
+ * inliner's `shouldRewrite` requires the file to sit under `node_modules`, and
23
+ * the CLI bundles its OWN source. So the read survived into
24
+ * `dist/cli.gjs.mjs`, resolved `../templates/…` against `dist/`, and
25
+ * `gjsify ship` died with ENOENT on every project — while the Node `lib/`
26
+ * entry, where the relative path happens to be right, worked. A comment
27
+ * asserting the opposite is why it went unnoticed.
28
+ *
29
+ * A ten-line skeleton is not worth a file the bundle has to locate. As a
30
+ * template literal there is nothing to resolve, nothing to package, and the
31
+ * two entry points cannot disagree.
22
32
  */
33
+ const DESKTOP_TEMPLATE = `[Desktop Entry]
34
+ Name={{NAME}}
35
+ Comment={{SUMMARY}}
36
+ Exec={{COMMAND}}
37
+ Icon={{APP_ID}}
38
+ Terminal=false
39
+ Type=Application
40
+ Categories={{CATEGORIES_LINE}}
41
+ {{KEYWORDS_LINE}}{{MIMETYPES_LINE}}StartupNotify=true
42
+ StartupWMClass={{APP_ID}}
43
+ `;
23
44
  function loadDesktopTemplate() {
24
- return readFileSync(new URL('../templates/app/desktop.tmpl', import.meta.url), 'utf-8');
45
+ return DESKTOP_TEMPLATE;
25
46
  }
26
47
  /**
27
48
  * Validate that the metadata has the minimum set of fields AppStream needs.
@@ -126,8 +126,8 @@ export declare const PACKAGE_DEPS: Record<string, string[]>;
126
126
  */
127
127
  export declare function runAllChecks(cwd: string): DepCheck[];
128
128
  /**
129
- * Minimal checks needed to run any GJS example (Node + GJS binaries only).
130
- * Used by `gjsify showcase` for examples that have no native deps.
129
+ * Minimal checks needed to run any GJS example (the GJS binary; Node is reported but
130
+ * not required). Used by `gjsify showcase` for examples that have no native deps.
131
131
  */
132
132
  export declare function runMinimalChecks(): DepCheck[];
133
133
  /**
@@ -18,6 +18,8 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs';
18
18
  // The zero-dependency subpath, deliberately: importing the plugin root would
19
19
  // pull vite/execa/minify-xml into a system CHECK.
20
20
  import { resolveBlueprintCompiler } from '@gjsify/vite-plugin-blueprint/resolve';
21
+ import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
22
+ import { findSystemTypelib } from './gi-typelib.js';
21
23
  /** Run a binary and return its stdout trimmed, or null if it fails. */
22
24
  function tryExecFile(binary, args) {
23
25
  try {
@@ -78,6 +80,35 @@ function checkPkgConfig(id, name, libName, severity, requiredBy) {
78
80
  return { id, name, found: false, severity, requiredBy };
79
81
  return { id, name, found: true, version: version.split('\n')[0], severity, requiredBy };
80
82
  }
83
+ /**
84
+ * Check a GI library the way a RUNNING app needs it: by its typelib.
85
+ *
86
+ * `checkPkgConfig` answers a different question — "is the DEVELOPMENT package
87
+ * installed" — and for a library an app merely loads, that question has the
88
+ * wrong answer on every distribution that splits `-dev` out. Measured on
89
+ * postmarketOS v26.06 / aarch64 (OnePlus 6T, gjs 1.88.1): libadwaita 1.9.2
90
+ * installed, `Adw-1.typelib` present, Adwaita apps running, and `system-check`
91
+ * printing `✗ libadwaita` under **Required** and exiting 1 because
92
+ * `libadwaita-dev` was absent. The install hint then named a package the user
93
+ * had no reason to install.
94
+ *
95
+ * So PRESENCE comes from {@link findSystemTypelib} — the same file
96
+ * `gi_repository_require()` resolves — and the VERSION still comes from
97
+ * pkg-config, which is a perfectly good version oracle when it happens to be
98
+ * there. A bare `✓ libadwaita` with no version is the honest reading of "the
99
+ * library is here and the `.pc` file is not"; `✗` was not.
100
+ *
101
+ * pkg-config remains the sole oracle for anything whose subject genuinely IS the
102
+ * development package (`gobject-introspection-1.0` gates `g-ir-scanner`, a build
103
+ * tool with no typelib of its own).
104
+ */
105
+ function checkGiLibrary(id, name, namespace, apiVersion, pkgConfigName, severity, requiredBy) {
106
+ const found = findSystemTypelib(namespace, apiVersion) !== null;
107
+ if (!found)
108
+ return { id, name, found: false, severity, requiredBy };
109
+ const version = tryExecFile('pkg-config', ['--modversion', pkgConfigName]);
110
+ return { id, name, found: true, version: version?.split('\n')[0], severity, requiredBy };
111
+ }
81
112
  /**
82
113
  * Check for an npm package. Tries the user's project first (cwd), then falls
83
114
  * back to the CLI's own node_modules. This way a locally installed version
@@ -378,13 +409,41 @@ export function runAllChecks(cwd) {
378
409
  ];
379
410
  }
380
411
  /**
381
- * Minimal checks needed to run any GJS example (Node + GJS binaries only).
382
- * Used by `gjsify showcase` for examples that have no native deps.
412
+ * Is a Node.js runtime available — asked of the host rather than asserted.
413
+ *
414
+ * This row used to be the literal `{found: true, version: process.version}`, which is
415
+ * a lie on three of the four hosts: `@gjsify/process` stubs `process.version` to
416
+ * `"v20.0.0"` for npm compatibility (Bun and Deno fake `process.versions.node` for the
417
+ * same reason), so `gjsify system-check` under GJS reported `✓ Node.js (v20.0.0)` on a
418
+ * machine with no Node on it — under the NODE-FREE toolchain, whose entire premise is
419
+ * that there is none. `cli-app.ts`'s `runtimeLabel()` already had to know better; one
420
+ * question gets one answer, so this asks the same module.
421
+ *
422
+ * Under Node the running process IS the answer and its version cannot be faked from
423
+ * inside; elsewhere it is a PATH question whose answer may legitimately be "no".
424
+ *
425
+ * `isNode()` rather than `hostRuntime() === 'node'`: the latter RETURNS `'node'` as its
426
+ * fallback for an unrecognised host, which would put an exotic runtime's possibly-stubbed
427
+ * `process.version` on the trusted branch. `isNode()` demands positive evidence.
428
+ *
429
+ * `optional`, never `required`: `gjsify showcase` and `gjsify install` both treat a
430
+ * missing `required` row as fatal, and a GJS showcase needs no Node. The old row could
431
+ * not have failed either gate — it was constant — so nothing that used to pass now
432
+ * fails; what changes is only that the printed line is true.
433
+ */
434
+ function checkNodeRuntime() {
435
+ if (isNode()) {
436
+ return { id: 'nodejs', name: 'Node.js', found: true, version: process.version, severity: 'optional' };
437
+ }
438
+ return checkBinary('nodejs', 'Node.js', 'node', ['--version'], 'optional');
439
+ }
440
+ /**
441
+ * Minimal checks needed to run any GJS example (the GJS binary; Node is reported but
442
+ * not required). Used by `gjsify showcase` for examples that have no native deps.
383
443
  */
384
444
  export function runMinimalChecks() {
385
445
  const results = [];
386
- // Node.js — always present
387
- results.push({ id: 'nodejs', name: 'Node.js', found: true, version: process.version, severity: 'required' });
446
+ results.push(checkNodeRuntime());
388
447
  // GJS
389
448
  results.push(checkBinary('gjs', 'GJS', 'gjs', ['--version'], 'required', (out) => out.replace(/^GJS\s+/i, '').split('\n')[0] ?? out));
390
449
  // The one win32 system dependency that is real and was undiagnosed (#997).
@@ -433,14 +492,21 @@ export function checkGwebgl(cwd) {
433
492
  */
434
493
  function runRequiredChecks(_cwd) {
435
494
  const results = [];
436
- // Build toolchain
437
- results.push(checkBinary('blueprint-compiler', 'Blueprint Compiler', 'blueprint-compiler', ['--version'], 'required'));
495
+ // Build toolchain. NO blueprint-compiler here: it is checked by
496
+ // `checkBlueprintCompiler()` among the build toolchain below, through the
497
+ // SAME resolver the build uses. A second `checkBinary` probe lived here and
498
+ // was wrong twice over — it reported a PATH miss on win32, where the
499
+ // compiler is normally an MSYS2 script deliberately off PATH (the exact case
500
+ // that function's comment exists to explain), and it made `system-check`
501
+ // exit 1 on any host without it, `.blp` files in the project or not. It also
502
+ // printed a second entry under a second display name and put
503
+ // `blueprint-compiler` into the install hint twice.
438
504
  results.push(checkBinary('pkg-config', 'pkg-config', 'pkg-config', ['--version'], 'required'));
439
505
  results.push(checkBinary('meson', 'Meson', 'meson', ['--version'], 'required'));
440
- // Foundational libraries
441
- results.push(checkPkgConfig('gtk4', 'GTK4', 'gtk4', 'required'));
442
- results.push(checkPkgConfig('libadwaita', 'libadwaita', 'libadwaita-1', 'required'));
443
- results.push(checkPkgConfig('libsoup3', 'libsoup3', 'libsoup-3.0', 'required'));
506
+ // Foundational libraries — by TYPELIB, the runtime question. See checkGiLibrary.
507
+ results.push(checkGiLibrary('gtk4', 'GTK4', 'Gtk', '4.0', 'gtk4', 'required'));
508
+ results.push(checkGiLibrary('libadwaita', 'libadwaita', 'Adw', '1', 'libadwaita-1', 'required'));
509
+ results.push(checkGiLibrary('libsoup3', 'libsoup3', 'Soup', '3.0', 'libsoup-3.0', 'required'));
444
510
  results.push(checkPkgConfig('gobject-introspection', 'GObject Introspection', 'gobject-introspection-1.0', 'required'));
445
511
  return results;
446
512
  }
@@ -528,6 +594,7 @@ function runOptionalChecks(needed, cwd) {
528
594
  // Entries with multiple space-separated packages are intentional (one dep needs multiple system pkgs).
529
595
  const PM_PACKAGES = {
530
596
  apt: {
597
+ nodejs: 'nodejs',
531
598
  gjs: 'gjs',
532
599
  'blueprint-compiler': 'blueprint-compiler',
533
600
  'pkg-config': 'pkg-config',
@@ -553,6 +620,7 @@ const PM_PACKAGES = {
553
620
  epoxy: 'libepoxy-dev',
554
621
  },
555
622
  dnf: {
623
+ nodejs: 'nodejs',
556
624
  gjs: 'gjs',
557
625
  'blueprint-compiler': 'blueprint-compiler',
558
626
  'pkg-config': 'pkgconf-pkg-config',
@@ -578,6 +646,7 @@ const PM_PACKAGES = {
578
646
  epoxy: 'libepoxy-devel',
579
647
  },
580
648
  pacman: {
649
+ nodejs: 'nodejs',
581
650
  gjs: 'gjs',
582
651
  'blueprint-compiler': 'blueprint-compiler',
583
652
  'pkg-config': 'pkgconf',
@@ -603,6 +672,7 @@ const PM_PACKAGES = {
603
672
  epoxy: 'libepoxy',
604
673
  },
605
674
  zypper: {
675
+ nodejs: 'nodejs',
606
676
  gjs: 'gjs',
607
677
  'blueprint-compiler': 'blueprint-compiler',
608
678
  'pkg-config': 'pkg-config',
@@ -628,6 +698,7 @@ const PM_PACKAGES = {
628
698
  epoxy: 'libepoxy-devel',
629
699
  },
630
700
  apk: {
701
+ nodejs: 'nodejs',
631
702
  gjs: 'gjs',
632
703
  'blueprint-compiler': 'blueprint-compiler',
633
704
  'pkg-config': 'pkgconf',
@@ -675,6 +746,7 @@ const PM_PACKAGES = {
675
746
  // Recorded here so the next reader does not "fix" the gap by guessing a name;
676
747
  // both are genuine platform gaps, not table omissions.
677
748
  brew: {
749
+ nodejs: 'node',
678
750
  gjs: 'gjs',
679
751
  'blueprint-compiler': 'blueprint-compiler',
680
752
  'pkg-config': 'pkgconf',
@@ -750,15 +822,22 @@ export function buildInstallCommand(pm, missing) {
750
822
  'mingw-w64-ucrt-x86_64-libadwaita # inside MSYS2; found automatically afterwards');
751
823
  continue;
752
824
  }
753
- if (dep.id === 'nodejs')
754
- continue; // can't be missing if we're running
825
+ // No `continue` for `nodejs`. It USED to be unmissable — the row was the
826
+ // literal `found: true` — and since it became a real probe it can report
827
+ // absent under GJS, so skipping it here printed "Missing optional: Node.js"
828
+ // above an install command that did not install it.
755
829
  const pkg = pkgMap[dep.id];
756
830
  if (pkg)
757
831
  pkgs.push(pkg);
758
832
  }
759
833
  const lines = [];
760
834
  if (pkgs.length > 0) {
761
- lines.push(`${PM_INSTALL_PREFIX[pm]} ${pkgs.join(' ')}`);
835
+ // Deduplicated because the command is COPY-PASTED: two checks mapping to
836
+ // one system package (or one check reported under two ids) produced
837
+ // `apk add blueprint-compiler libadwaita-dev … blueprint-compiler`,
838
+ // which is how the duplicate blueprint check first became visible.
839
+ // Dropping the second occurrence cannot change what gets installed.
840
+ lines.push(`${PM_INSTALL_PREFIX[pm]} ${[...new Set(pkgs)].join(' ')}`);
762
841
  }
763
842
  if (npmDeps.length > 0) {
764
843
  lines.push(`npm install ${npmDeps.join(' ')}`);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Declare that this command keeps running after its handler resolves, so a
3
+ * caller must not treat that resolution as completion.
4
+ */
5
+ export declare function markDaemonCommand(): void;
6
+ /** Whether a command dispatched in this process declared itself a daemon. */
7
+ export declare function isDaemonCommand(): boolean;
@@ -0,0 +1,30 @@
1
+ // Whether the command currently running SETTLES.
2
+ //
3
+ // Almost every gjsify command finishes: its handler resolves and the caller acts
4
+ // on the result. A watch loop does not — `runWatchLoop` resolves as soon as the
5
+ // watcher is armed and the process then lives on the watcher (Node) or the held
6
+ // main loop (GJS) until Ctrl+C. To anything that merely awaits the handler the
7
+ // two are the same event, and one caller has to tell them apart: `gjsify run`'s
8
+ // in-process fast path dispatches a `gjsify <sub>` script through the same yargs
9
+ // surface and then `process.exit()`s on the result. For `gjsify run dev` that
10
+ // exit fired the instant the first build+launch was done, killing the loop the
11
+ // script had just asked for — and that IS the documented entry point (`gjsify
12
+ // create` prints `gjsify run dev`, and every template's `dev` script is a bare
13
+ // `gjsify dev`). Invisible on Node, where the same script takes the spawn path.
14
+ //
15
+ // A module-level flag rather than a return value because the answer has to cross
16
+ // `runCli`, whose contract is `Promise<void>` for every command; threading a
17
+ // "did not settle" result through yargs would change the shape of all of them to
18
+ // carry a fact about one.
19
+ let daemon = false;
20
+ /**
21
+ * Declare that this command keeps running after its handler resolves, so a
22
+ * caller must not treat that resolution as completion.
23
+ */
24
+ export function markDaemonCommand() {
25
+ daemon = true;
26
+ }
27
+ /** Whether a command dispatched in this process declared itself a daemon. */
28
+ export function isDaemonCommand() {
29
+ return daemon;
30
+ }
@@ -0,0 +1,52 @@
1
+ export interface DevPlanRequest {
2
+ /** The project's `package.json#scripts`. */
3
+ scripts: Readonly<Record<string, string>>;
4
+ /** Name of the build script to reuse, e.g. `build:gjs`. */
5
+ scriptName: string;
6
+ /** `gjsify build --app <app>` target the chosen runtime consumes. */
7
+ app: 'gjs' | 'node';
8
+ /** The bundle the package declares for `app` (`gjsify.main` / `gjsify.example.node`). */
9
+ declaredBundle?: string;
10
+ /** `gjsify dev [entry]` — overrides the entry point the script names. */
11
+ entry?: string;
12
+ /** `--globals` override. */
13
+ globals?: string;
14
+ /** `--outfile` override. */
15
+ outfile?: string;
16
+ /** `--watch-dir` override. */
17
+ watchDir?: string;
18
+ }
19
+ export interface DevPlan {
20
+ /** argv fed to `runCli` on every rebuild (starts with `build`). */
21
+ buildArgv: string[];
22
+ /**
23
+ * Bundle launched after a successful build, or undefined when nothing names
24
+ * one. See {@link noBundleError}.
25
+ */
26
+ bundle?: string;
27
+ /** Directory watched recursively. */
28
+ watchDir: string;
29
+ }
30
+ /** The value of `--name value` / `--name=value` in `argv`, if present. */
31
+ export declare function readFlag(argv: readonly string[], name: string): string | undefined;
32
+ /** `argv` with `--name value` set — replacing any existing occurrence, else appended. */
33
+ export declare function withFlag(argv: readonly string[], name: string, value: string): string[];
34
+ /**
35
+ * The entry point a `gjsify build` argv names positionally. Only the token
36
+ * DIRECTLY after `build` counts: further positionals are indistinguishable from
37
+ * the value of a preceding flag without knowing every flag's arity, and a wrong
38
+ * guess here would silently build the wrong file.
39
+ */
40
+ export declare function buildEntryPoint(argv: readonly string[]): string | undefined;
41
+ /**
42
+ * Resolve the build argv, the bundle to launch and the directory to watch.
43
+ * Throws with both fixes named when the project declares neither.
44
+ */
45
+ export declare function planDev(req: DevPlanRequest): DevPlan;
46
+ /**
47
+ * Message for a launch that has no bundle to launch. Separate from `planDev`
48
+ * because only the LAUNCHING caller needs one: a build writing an `--outdir`
49
+ * legitimately has no single output file, and refusing inside the planner would
50
+ * fail `--build-only` for a reason that does not apply to it.
51
+ */
52
+ export declare function noBundleError(scriptName: string, app: 'gjs' | 'node'): Error;