@gjsify/cli 0.41.0 → 0.43.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 (80) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1068 -405
  3. package/lib/actions/build.d.ts +20 -0
  4. package/lib/actions/build.js +118 -15
  5. package/lib/bundler-pick.js +10 -2
  6. package/lib/cli-app.js +2 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/debug.js +1 -1
  9. package/lib/commands/dev.d.ts +13 -0
  10. package/lib/commands/dev.js +132 -0
  11. package/lib/commands/dlx.js +1 -1
  12. package/lib/commands/index.d.ts +1 -0
  13. package/lib/commands/index.js +1 -0
  14. package/lib/commands/run.js +38 -85
  15. package/lib/commands/ship.d.ts +2 -0
  16. package/lib/commands/ship.js +285 -88
  17. package/lib/commands/showcase.js +1 -1
  18. package/lib/commands/storybook.js +17 -74
  19. package/lib/commands/test.js +12 -16
  20. package/lib/commands/tsc.js +96 -45
  21. package/lib/config.js +4 -0
  22. package/lib/types/config-data.d.ts +59 -0
  23. package/lib/utils/app-metadata.js +27 -6
  24. package/lib/utils/check-system-deps.js +49 -7
  25. package/lib/utils/daemon-command.d.ts +7 -0
  26. package/lib/utils/daemon-command.js +30 -0
  27. package/lib/utils/dev-plan.d.ts +52 -0
  28. package/lib/utils/dev-plan.js +117 -0
  29. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  30. package/lib/utils/gi-runtime-paths.js +84 -0
  31. package/lib/utils/gi-typelib.d.ts +49 -0
  32. package/lib/utils/gi-typelib.js +160 -0
  33. package/lib/utils/gjs-bundle-guard.d.ts +57 -3
  34. package/lib/utils/gjs-bundle-guard.js +122 -13
  35. package/lib/utils/jsx-config.d.ts +45 -0
  36. package/lib/utils/jsx-config.js +98 -0
  37. package/lib/utils/node-script.d.ts +4 -1
  38. package/lib/utils/node-script.js +27 -3
  39. package/lib/utils/npm-oidc.js +8 -5
  40. package/lib/utils/onboard-probe.d.ts +23 -0
  41. package/lib/utils/onboard-probe.js +65 -0
  42. package/lib/utils/run-gjs.d.ts +12 -1
  43. package/lib/utils/run-gjs.js +24 -31
  44. package/lib/utils/run-node.d.ts +9 -4
  45. package/lib/utils/run-node.js +34 -41
  46. package/lib/utils/ship/deb.d.ts +3 -3
  47. package/lib/utils/ship/deb.js +6 -2
  48. package/lib/utils/ship/depends.d.ts +14 -6
  49. package/lib/utils/ship/depends.js +45 -4
  50. package/lib/utils/ship/discover.js +178 -7
  51. package/lib/utils/ship/formats.js +9 -1
  52. package/lib/utils/ship/launcher.js +27 -4
  53. package/lib/utils/ship/mime.d.ts +19 -0
  54. package/lib/utils/ship/mime.js +89 -0
  55. package/lib/utils/ship/payload.d.ts +111 -0
  56. package/lib/utils/ship/payload.js +174 -0
  57. package/lib/utils/ship/plan.d.ts +30 -1
  58. package/lib/utils/ship/plan.js +74 -2
  59. package/lib/utils/ship/rpm.d.ts +3 -3
  60. package/lib/utils/ship/rpm.js +20 -3
  61. package/lib/utils/ship/scripts.d.ts +14 -4
  62. package/lib/utils/ship/scripts.js +26 -8
  63. package/lib/utils/ship/settings.d.ts +8 -1
  64. package/lib/utils/ship/settings.js +26 -1
  65. package/lib/utils/ship/stage-manifest.d.ts +154 -0
  66. package/lib/utils/ship/stage-manifest.js +408 -0
  67. package/lib/utils/ship/stage-writer.d.ts +30 -3
  68. package/lib/utils/ship/stage-writer.js +76 -9
  69. package/lib/utils/ship/types.d.ts +110 -18
  70. package/lib/utils/ship/types.js +5 -0
  71. package/lib/utils/simple-command.d.ts +16 -0
  72. package/lib/utils/simple-command.js +77 -0
  73. package/lib/utils/spawn.d.ts +7 -1
  74. package/lib/utils/spawn.js +34 -7
  75. package/lib/utils/system-gi.d.ts +24 -0
  76. package/lib/utils/system-gi.js +14 -3
  77. package/lib/utils/watch-loop.d.ts +91 -0
  78. package/lib/utils/watch-loop.js +207 -0
  79. package/package.json +19 -18
  80. package/lib/templates/app/desktop.tmpl +0 -10
@@ -16,7 +16,6 @@
16
16
  // identically under Node and GJS. @gjsify/storybook is resolved from the
17
17
  // CONSUMER's node_modules by the generated entry, so it stays out of the CLI's
18
18
  // own dependencies.
19
- import { spawn } from 'node:child_process';
20
19
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
21
20
  import { join, relative, resolve } from 'node:path';
22
21
  import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
@@ -165,18 +164,18 @@ export const storybookCommand = {
165
164
  const launch = async () => {
166
165
  if (runtime === 'gjs') {
167
166
  const { runGjsBundle } = await import('../utils/run-gjs.js');
168
- await runGjsBundle(outPath, [], { exitOnSuccess: true });
167
+ await runGjsBundle(outPath, [], { completion: 'exit', exitOnSuccess: true });
169
168
  }
170
169
  else if (runtime === 'node') {
171
170
  const { runNodeBundle } = await import('../utils/run-node.js');
172
- await runNodeBundle(outPath, [], { exitOnSuccess: true });
171
+ await runNodeBundle(outPath, [], { completion: 'exit', exitOnSuccess: true });
173
172
  }
174
173
  else {
175
174
  // bun / deno — run the SAME `--app node` bundle via the shared
176
175
  // runner (Node-API is their common ABI). node-gi is required
177
176
  // because the storybook uses gi:// (checked in runRuntimeBundle).
178
177
  const { runRuntimeBundle } = await import('../utils/run-node.js');
179
- await runRuntimeBundle(runtime, outPath, [], { exitOnSuccess: true });
178
+ await runRuntimeBundle(runtime, outPath, [], { completion: 'exit', exitOnSuccess: true });
180
179
  }
181
180
  };
182
181
  if (!args.watch) {
@@ -186,77 +185,21 @@ export const storybookCommand = {
186
185
  await launch();
187
186
  return;
188
187
  }
189
- // Watch mode: rebuild + relaunch on any change under the stories dir.
190
- // The storybook bundle only needs system Gtk/Adw typelibs at runtime
191
- // (no @gjsify native bridges), so a plain `gjs -m` / `node` child is
192
- // enough and stays killable for relaunch.
193
- const { watch } = await import('node:fs');
194
- const spawnChild = async () => {
195
- if (runtime === 'gjs') {
196
- return spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
197
- }
198
- // node / bun / deno — run the `--app node` bundle with the native
199
- // typelib env wired like `gjsify run`. All three need @gjsify/node-gi
200
- // (the storybook uses gi://), kept external by the bundler.
201
- const { computeNativeEnvForBundle } = await import('../utils/run-gjs.js');
202
- const { resolveNodeGiForBundle, nodeBinary } = await import('../utils/run-node.js');
203
- const { RUNTIMES } = await import('../utils/runtimes.js');
204
- // Same resolution base as `runRuntimeBundle`: the bundle's own dir
205
- // first (that is where the runtime resolves the external specifier
206
- // from), cwd as the fallback.
207
- if (!resolveNodeGiForBundle(outPath, cwd)) {
208
- throw new Error(`Cannot run the storybook on ${runtime}: \`@gjsify/node-gi\` is not installed. ` +
209
- `Add @gjsify/node-gi as a dependency to run the storybook on ${runtime}.`);
210
- }
211
- const { env: nativeEnv } = computeNativeEnvForBundle(outPath, cwd);
212
- const env = { ...process.env, ...nativeEnv };
213
- // `node` uses the resolved node binary (under a GJS-hosted CLI
214
- // `process.execPath` is gjs); bun/deno launch via the shared spec
215
- // (deno's `--node-modules-dir=manual`, etc.).
216
- if (runtime === 'node') {
217
- return spawn(nodeBinary(), [outPath], { stdio: 'inherit', env });
218
- }
219
- const [cmd, launchArgs] = RUNTIMES[runtime].launch(outPath);
220
- return spawn(cmd, launchArgs, { stdio: 'inherit', env });
221
- };
222
- let child = null;
223
- let rebuilding = false;
224
- let pending = false;
225
- const rebuild = async () => {
226
- if (rebuilding) {
227
- pending = true;
228
- return;
229
- }
230
- rebuilding = true;
231
- try {
188
+ // Watch mode: rebuild + relaunch on any change under the stories dir,
189
+ // through the loop shared with `gjsify dev` (`utils/watch-loop.ts`).
190
+ const { runWatchLoop, spawnBundleSupervised } = await import('../utils/watch-loop.js');
191
+ await runWatchLoop({
192
+ dir: storiesDir,
193
+ dirLabel: relative(cwd, storiesDir) || '.',
194
+ label: 'storybook',
195
+ // Stories are DISCOVERED, so the generated entry is re-derived on
196
+ // every pass — a new `*.story.ts` must show up without a restart.
197
+ prepare: () => {
232
198
  writeEntry();
233
- if (child) {
234
- child.kill();
235
- child = null;
236
- }
237
- await buildBundle();
238
- if (!args.buildOnly) {
239
- child = await spawnChild();
240
- }
241
- }
242
- catch (err) {
243
- console.error(`[storybook] rebuild failed: ${err.message}`);
244
- }
245
- finally {
246
- rebuilding = false;
247
- if (pending) {
248
- pending = false;
249
- void rebuild();
250
- }
251
- }
252
- };
253
- await rebuild();
254
- let debounce = null;
255
- watch(storiesDir, { recursive: true }, () => {
256
- if (debounce)
257
- clearTimeout(debounce);
258
- debounce = setTimeout(() => void rebuild(), 200);
199
+ },
200
+ build: buildBundle,
201
+ spawnChild: args.buildOnly ? null : () => spawnBundleSupervised(runtime, outPath, cwd, 'the storybook'),
202
+ output: outPath,
259
203
  });
260
- console.log(`[storybook] watching ${relative(cwd, storiesDir) || '.'} — press Ctrl+C to stop`);
261
204
  },
262
205
  };
@@ -7,9 +7,9 @@
7
7
  // exit codes.
8
8
  import { existsSync, statSync, readdirSync } from 'node:fs';
9
9
  import { join, dirname, resolve, relative } from 'node:path';
10
- import { spawn } from 'node:child_process';
11
10
  import { isRuntimeAvailable, RUNTIMES } from '../utils/runtimes.js';
12
11
  import { nodeBinary } from '../utils/run-node.js';
12
+ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
13
13
  import { Config } from '../config.js';
14
14
  import { BuildAction } from '../actions/build.js';
15
15
  import { runGjsBundle } from '../utils/run-gjs.js';
@@ -190,23 +190,19 @@ async function buildTestBundle(entry, outfile, runtime, verbose) {
190
190
  /** Run a single test bundle and reject on non-zero exit. */
191
191
  async function runTestBundle(outfile, runtime) {
192
192
  if (runtime === 'gjs') {
193
- await runGjsBundle(outfile);
193
+ // `completion: 'exit'` (this handler ends in `process.exit`) WITHOUT
194
+ // `exitOnSuccess`: the two answer different questions, and exiting here
195
+ // would truncate the multi-runtime loop after the first bundle.
196
+ await runGjsBundle(outfile, [], { completion: 'exit' });
194
197
  return;
195
198
  }
196
- await new Promise((resolvePromise, reject) => {
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
- const child = spawn(nodeBinary(), [outfile], { stdio: 'inherit' });
202
- child.on('error', reject);
203
- child.on('exit', (code) => {
204
- if (code === 0)
205
- resolvePromise();
206
- else
207
- reject(new Error(`node exited with code ${code}`));
208
- });
209
- });
199
+ // `nodeBinary()`, never a bare `'node'`: under GJS `process.execPath` is
200
+ // the `gjs` interpreter, which cannot run a `--app node` bundle, and on a
201
+ // host without Node a bare literal dies `spawn node ENOENT` — which is
202
+ // how a fully passing GJS suite was reported as a failed run.
203
+ const result = await spawnToCompletion(nodeBinary(), [outfile], { completion: 'exit' });
204
+ if (result.code !== 0)
205
+ throw new Error(`node exited with ${describeExit(result)}`);
210
206
  }
211
207
  /** True when `outfile` exists and is newer than every `.ts`/`.mts` file under the entry's directory tree. */
212
208
  function isFresh(outfile, entry) {
@@ -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) {
@@ -328,6 +328,38 @@ export interface AppMetadata {
328
328
  * https://www.freedesktop.org/software/appstream/docs/sect-Metadata-DesktopApps.html#tag-dapp-kudos
329
329
  */
330
330
  kudos?: string[];
331
+ /**
332
+ * File types this package DEFINES for the whole system — a shared-mime-info
333
+ * document staged into `share/mime/packages/`.
334
+ *
335
+ * Distinct from `provides.mimetypes`, which only says the app HANDLES a type.
336
+ * For a standard type (`text/plain`, `application/pdf`) handling is all you
337
+ * need: the distribution already defines it. For a type of your own
338
+ * (`application/x-bauplan`) handling it is not enough and the failure is
339
+ * silent — nothing on the system knows the type exists, so a `.bauplan` file
340
+ * is never recognised as one, `MimeType=` in the desktop entry matches
341
+ * nothing, and double-clicking the file does nothing at all. No error, no
342
+ * log line: the association simply never fires.
343
+ *
344
+ * Every type declared here is added to the handled set automatically, because
345
+ * defining a file type in your own package and then not opening it is not a
346
+ * thing anyone means to do.
347
+ */
348
+ mimeTypes?: Array<{
349
+ /** `<media>/<subtype>`, e.g. `application/x-bauplan`. */
350
+ type: string;
351
+ /**
352
+ * What a file manager shows instead of the raw type string. Required:
353
+ * a type with no comment is listed to the user as `application/x-bauplan`.
354
+ */
355
+ comment: string;
356
+ /** Filename patterns, e.g. `["*.bauplan"]`. */
357
+ globs?: string[];
358
+ /** A registered type to inherit from, e.g. `application/zip` for a zip container. */
359
+ subClassOf?: string;
360
+ /** Fallback icon name when no type-specific icon is installed. */
361
+ genericIcon?: string;
362
+ }>;
331
363
  /**
332
364
  * What this app provides to the system. `<binary>` is auto-included with the
333
365
  * value of `command` when omitted — AppStream needs it to register the binary
@@ -433,6 +465,33 @@ export interface ConfigDataShip extends AppMetadata {
433
465
  deb: string;
434
466
  rpm: string;
435
467
  }>;
468
+ /**
469
+ * Directories whose `*.typelib` and `lib*.so*` the package CARRIES ITSELF, e.g.
470
+ * `["../node_modules/@gjsify/webgl-linux-x64/prebuilds/linux-x64"]`.
471
+ *
472
+ * gjsify's own GI libraries — `Gwebgl`, the GTK runtime bundles, the napi host — arrive as npm
473
+ * prebuilds, not as distro packages, so an app that imports one has no `gir1.2-…` to depend on.
474
+ * Staging them here puts them in `lib/<binary>/gi/` and makes the launcher point
475
+ * `GI_TYPELIB_PATH` and `LD_LIBRARY_PATH` at that directory.
476
+ *
477
+ * The namespaces this covers are read back off the STAGED FILES, not from a separate list:
478
+ * a declaration that a namespace is bundled, without the file being there, is exactly the lie
479
+ * the dependency check exists to prevent.
480
+ */
481
+ bundledTypelibs?: string[];
482
+ /**
483
+ * Directory holding COMPILED gettext catalogues in the layout `bindtextdomain` reads —
484
+ * `<lang>/LC_MESSAGES/<domain>.mo` — e.g. `"dist/locale"`, which is what
485
+ * `@gjsify/vite-plugin-gettext`'s `gettextPlugin` writes by default.
486
+ *
487
+ * Staged into `share/locale/` with that structure preserved, and the launcher then exports
488
+ * `GJSIFY_LOCALE_DIR` so the app can call `bindtextdomain(domain, dir)` without knowing which
489
+ * prefix it was installed under.
490
+ *
491
+ * `.po` sources are NOT accepted: `bindtextdomain` reads `.mo` only, and staging a `.po`
492
+ * produces a package that installs its translations and shows none of them.
493
+ */
494
+ localeDir?: string;
436
495
  /** Extra payload entries: prefix-relative destination → project-relative source. */
437
496
  extraFiles?: Record<string, string>;
438
497
  /** 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.
@@ -19,6 +19,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs';
19
19
  // pull vite/execa/minify-xml into a system CHECK.
20
20
  import { resolveBlueprintCompiler } from '@gjsify/vite-plugin-blueprint/resolve';
21
21
  import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
22
+ import { findSystemTypelib } from './gi-typelib.js';
22
23
  /** Run a binary and return its stdout trimmed, or null if it fails. */
23
24
  function tryExecFile(binary, args) {
24
25
  try {
@@ -79,6 +80,35 @@ function checkPkgConfig(id, name, libName, severity, requiredBy) {
79
80
  return { id, name, found: false, severity, requiredBy };
80
81
  return { id, name, found: true, version: version.split('\n')[0], severity, requiredBy };
81
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
+ }
82
112
  /**
83
113
  * Check for an npm package. Tries the user's project first (cwd), then falls
84
114
  * back to the CLI's own node_modules. This way a locally installed version
@@ -462,14 +492,21 @@ export function checkGwebgl(cwd) {
462
492
  */
463
493
  function runRequiredChecks(_cwd) {
464
494
  const results = [];
465
- // Build toolchain
466
- 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.
467
504
  results.push(checkBinary('pkg-config', 'pkg-config', 'pkg-config', ['--version'], 'required'));
468
505
  results.push(checkBinary('meson', 'Meson', 'meson', ['--version'], 'required'));
469
- // Foundational libraries
470
- results.push(checkPkgConfig('gtk4', 'GTK4', 'gtk4', 'required'));
471
- results.push(checkPkgConfig('libadwaita', 'libadwaita', 'libadwaita-1', 'required'));
472
- 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'));
473
510
  results.push(checkPkgConfig('gobject-introspection', 'GObject Introspection', 'gobject-introspection-1.0', 'required'));
474
511
  return results;
475
512
  }
@@ -795,7 +832,12 @@ export function buildInstallCommand(pm, missing) {
795
832
  }
796
833
  const lines = [];
797
834
  if (pkgs.length > 0) {
798
- 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(' ')}`);
799
841
  }
800
842
  if (npmDeps.length > 0) {
801
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
+ }