@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
@@ -1,4 +1,5 @@
1
1
  import { type ExampleRuntime } from './runtimes.js';
2
+ import { type SpawnCompletionContract } from './spawn.js';
2
3
  /**
3
4
  * Walk up from `startDir` for `<node_modules>/@gjsify/node-gi`, returning its package directory
4
5
  * or null. A plain fs walk because Node's own resolver is not available under the GJS-bundled CLI.
@@ -19,6 +20,8 @@ export declare function resolveNodeGiForBundle(bundlePath: string, cwd: string):
19
20
  /** Pick the node executable: the current one when on Node, else PATH `node`. */
20
21
  export declare function nodeBinary(): string;
21
22
  export interface RunNodeBundleOptions {
23
+ /** The caller's teardown contract — see `RunGjsBundleOptions.completion`. */
24
+ completion: SpawnCompletionContract;
22
25
  /** Exit this process with code 0 once the child succeeds (terminal callers). */
23
26
  exitOnSuccess?: boolean;
24
27
  /** Suppress the `$ <env> node …` echo — see `RunGjsBundleOptions.quiet`. */
@@ -28,12 +31,14 @@ export interface RunNodeBundleOptions {
28
31
  * Run a `--app node` bundle on Node.js with the native typelib env set (same detection as
29
32
  * {@link runGjsBundle}). Throws when `@gjsify/node-gi` is not resolvable.
30
33
  */
31
- export declare function runNodeBundle(bundlePath: string, extraArgs?: string[], options?: RunNodeBundleOptions): Promise<void>;
34
+ export declare function runNodeBundle(bundlePath: string, extraArgs: string[] | undefined, options: RunNodeBundleOptions): Promise<void>;
32
35
  /**
33
36
  * Whether a `--app node` bundle actually consumes `@gjsify/node-gi` — kept EXTERNAL, so a bundle
34
37
  * using gi:// imports the shim by name. A plain Node app does NOT, and must run on node/bun/deno
35
- * WITHOUT node-gi installed, which is why the check is conditional here and not unconditional
36
- * like the storybook path.
38
+ * WITHOUT node-gi installed, which is why the check is conditional rather than a flat "this is a
39
+ * node bundle, demand node-gi". The supervised launcher (`utils/watch-loop.ts`) asks the same
40
+ * question for the same reason: `gjsify dev` on a web-server template builds a node bundle that
41
+ * never reaches a typelib.
37
42
  */
38
43
  export declare function bundleRequiresNodeGi(bundlePath: string): boolean;
39
44
  /**
@@ -44,4 +49,4 @@ export declare function bundleRequiresNodeGi(bundlePath: string): boolean;
44
49
  *
45
50
  * `gjs` is intentionally NOT handled here — the caller dispatches it to {@link runGjsBundle}.
46
51
  */
47
- export declare function runRuntimeBundle(runtime: Exclude<ExampleRuntime, 'gjs'>, bundlePath: string, extraArgs?: string[], options?: RunNodeBundleOptions): Promise<void>;
52
+ export declare function runRuntimeBundle(runtime: Exclude<ExampleRuntime, 'gjs'>, bundlePath: string, extraArgs: string[] | undefined, options: RunNodeBundleOptions): Promise<void>;
@@ -15,12 +15,12 @@
15
15
  //
16
16
  // The node binary is `process.execPath` when the CLI runs under Node, else PATH `node` — under
17
17
  // the GJS bundle `process.execPath` is `gjs`, which cannot run a `--app node` bundle.
18
- import { spawn } from 'node:child_process';
19
18
  import { existsSync, readFileSync } from 'node:fs';
20
19
  import { dirname, join, resolve } from 'node:path';
21
20
  import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
22
21
  import { computeNativeEnvForBundle } from './run-gjs.js';
23
22
  import { RUNTIMES } from './runtimes.js';
23
+ import { describeExit, spawnToCompletion } from './spawn.js';
24
24
  /**
25
25
  * Walk up from `startDir` for `<node_modules>/@gjsify/node-gi`, returning its package directory
26
26
  * or null. A plain fs walk because Node's own resolver is not available under the GJS-bundled CLI.
@@ -60,7 +60,7 @@ export function nodeBinary() {
60
60
  * Run a `--app node` bundle on Node.js with the native typelib env set (same detection as
61
61
  * {@link runGjsBundle}). Throws when `@gjsify/node-gi` is not resolvable.
62
62
  */
63
- export async function runNodeBundle(bundlePath, extraArgs = [], options = {}) {
63
+ export async function runNodeBundle(bundlePath, extraArgs = [], options) {
64
64
  const cwd = process.cwd();
65
65
  if (!resolveNodeGiForBundle(bundlePath, cwd)) {
66
66
  throw new Error('Cannot run the storybook on Node: `@gjsify/node-gi` is not installed.\n' +
@@ -74,27 +74,28 @@ export async function runNodeBundle(bundlePath, extraArgs = [], options = {}) {
74
74
  // Echo on stderr, so a child speaking a protocol on stdout stays uncontaminated.
75
75
  const cmd = [nodeBin, ...nodeArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
76
76
  console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${cmd}`);
77
- const child = spawn(nodeBin, nodeArgs, { env, stdio: 'inherit' });
78
- await new Promise((resolvePromise, reject) => {
79
- child.on('close', (code) => {
80
- if (code !== 0)
81
- reject(new Error(`node exited with code ${code}`));
82
- else
83
- resolvePromise();
84
- });
85
- child.on('error', reject);
86
- }).catch((err) => {
77
+ let result;
78
+ try {
79
+ result = await spawnToCompletion(nodeBin, nodeArgs, { completion: options.completion, env });
80
+ }
81
+ catch (err) {
87
82
  console.error(err.message);
88
- process.exit(1);
89
- });
83
+ return process.exit(1);
84
+ }
85
+ if (result.code !== 0) {
86
+ console.error(`node exited with ${describeExit(result)}`);
87
+ return process.exit(result.code ?? 1);
88
+ }
90
89
  if (options.exitOnSuccess)
91
- process.exit(0);
90
+ return process.exit(0);
92
91
  }
93
92
  /**
94
93
  * Whether a `--app node` bundle actually consumes `@gjsify/node-gi` — kept EXTERNAL, so a bundle
95
94
  * using gi:// imports the shim by name. A plain Node app does NOT, and must run on node/bun/deno
96
- * WITHOUT node-gi installed, which is why the check is conditional here and not unconditional
97
- * like the storybook path.
95
+ * WITHOUT node-gi installed, which is why the check is conditional rather than a flat "this is a
96
+ * node bundle, demand node-gi". The supervised launcher (`utils/watch-loop.ts`) asks the same
97
+ * question for the same reason: `gjsify dev` on a web-server template builds a node bundle that
98
+ * never reaches a typelib.
98
99
  */
99
100
  export function bundleRequiresNodeGi(bundlePath) {
100
101
  try {
@@ -112,7 +113,7 @@ export function bundleRequiresNodeGi(bundlePath) {
112
113
  *
113
114
  * `gjs` is intentionally NOT handled here — the caller dispatches it to {@link runGjsBundle}.
114
115
  */
115
- export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], options = {}) {
116
+ export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], options) {
116
117
  const cwd = process.cwd();
117
118
  const resolvedBundle = resolve(bundlePath);
118
119
  if (bundleRequiresNodeGi(resolvedBundle) && !resolveNodeGiForBundle(resolvedBundle, cwd)) {
@@ -131,32 +132,24 @@ export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], opti
131
132
  const shown = [cmd, ...launchArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
132
133
  if (!options.quiet)
133
134
  console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${shown}`);
134
- const child = spawn(cmd, launchArgs, { env, stdio: 'inherit' });
135
135
  // Re-raise the CHILD's own code rather than a blanket 1 — a script run through
136
136
  // `--node-script` reports its status through this path, and a caller reading `$?` needs
137
- // the real one.
138
- let failed = false;
139
- let childCode = 1;
140
- await new Promise((resolvePromise, reject) => {
141
- child.on('close', (code) => {
142
- if (code !== 0) {
143
- childCode = code ?? 1;
144
- reject(new Error(`${runtime} exited with code ${code}`));
145
- }
146
- else
147
- resolvePromise();
148
- });
149
- child.on('error', reject);
150
- }).catch((err) => {
137
+ // the real one. A spawn failure has no code of its own and falls back to 1.
138
+ const failed = (code) => {
139
+ process.exitCode = code;
140
+ return process.exit(code);
141
+ };
142
+ let result;
143
+ try {
144
+ result = await spawnToCompletion(cmd, launchArgs, { completion: options.completion, env });
145
+ }
146
+ catch (err) {
151
147
  console.error(err.message);
152
- failed = true;
153
- });
154
- // `return process.exit(…)`, and a flag rather than an exit inside the `catch`: under GJS
155
- // `process.exit()` is deferred and RETURNS, so a bare call in the catch fell through to the
156
- // `exitOnSuccess` line below and scheduled a second exit.
157
- if (failed) {
158
- process.exitCode = childCode;
159
- return process.exit(childCode);
148
+ return failed(1);
149
+ }
150
+ if (result.code !== 0) {
151
+ console.error(`${runtime} exited with ${describeExit(result)}`);
152
+ return failed(result.code ?? 1);
160
153
  }
161
154
  if (options.exitOnSuccess)
162
155
  return process.exit(0);
@@ -1,7 +1,7 @@
1
- import type { PayloadEntry } from './payload.js';
2
- import type { ShipSettings } from './types.js';
1
+ import { type PayloadEntry } from './payload.js';
2
+ import type { PackSettings } from './types.js';
3
3
  export interface DebInputs {
4
- settings: ShipSettings;
4
+ settings: PackSettings;
5
5
  /** The staged payload, prefix-relative. */
6
6
  payload: readonly PayloadEntry[];
7
7
  /** Install prefix, e.g. `/usr`. */
@@ -20,8 +20,9 @@ import { createArArchive } from './ar.js';
20
20
  import { formatDebDepend } from './depends.js';
21
21
  import { gzipDeterministic } from './gzip.js';
22
22
  import { renderDebScripts } from './scripts.js';
23
+ import { readPayloadFacts } from './payload.js';
23
24
  export async function buildDeb(inputs) {
24
- const { settings, payload, mtime } = inputs;
25
+ const { payload, mtime } = inputs;
25
26
  const prefix = inputs.prefix.replace(/^\/+/, '');
26
27
  const named = payload.map((entry) => ({ ...entry, tarPath: `./${posix.join(prefix, entry.path)}` }));
27
28
  const directories = ancestorDirectories(named.map((entry) => entry.tarPath));
@@ -44,7 +45,10 @@ export async function buildDeb(inputs) {
44
45
  { name: './control', body: control, mode: 0o644, mtime },
45
46
  { name: './md5sums', body: md5sums, mode: 0o644, mtime },
46
47
  ];
47
- const maintainerScripts = renderDebScripts(settings, inputs.prefix);
48
+ // From the PAYLOAD, not the settings: the maintainer scripts refresh exactly the
49
+ // directories this package wrote into, and since ADR 0024 § A2 this packer also runs on a
50
+ // host that has the staged tree and no project to ask.
51
+ const maintainerScripts = renderDebScripts(readPayloadFacts(payload), inputs.prefix);
48
52
  for (const [name, body] of Object.entries(maintainerScripts)) {
49
53
  // dpkg refuses a maintainer script outside 0555–0775 with the mode in
50
54
  // the error, so this is 0755 rather than "whatever the source had".
@@ -14,16 +14,19 @@ import type { FormatId } from './types.js';
14
14
  * is how a project whose bundle really does run on older GJS lowers it.
15
15
  */
16
16
  export declare const DEFAULT_GJS_FLOOR = "1.86";
17
- /**
18
- * Warn when the derived floor cannot be satisfied by a released Debian.
19
- * Returns the warning lines; empty when the floor is fine.
20
- */
21
17
  export declare function warnAboutGjsFloor(format: FormatId, floor: string): string[];
22
18
  export interface DependsInputs {
23
19
  /** GI namespaces the bundle imports, in `Ns-Version` spelling. */
24
20
  namespaces: readonly string[];
25
- /** `'app'` installs a desktop entry and icons; `'cli'` does not. */
26
- kind: 'app' | 'cli';
21
+ /**
22
+ * Whether the payload installs into `share/icons/hicolor/`.
23
+ *
24
+ * Was `kind: 'app' | 'cli'`, which is a proxy for the question and not the question:
25
+ * `hicolor-icon-theme` is depended on because it OWNS that directory, so an app that
26
+ * installs no icon used to declare a dependency it never touched. Derived from the payload
27
+ * (`readPayloadFacts`), which is also the only thing the packing host has (ADR 0024 § A2).
28
+ */
29
+ hasIcons: boolean;
27
30
  /** Whether the payload installs GSettings schemas. */
28
31
  hasSchemas: boolean;
29
32
  /** User-supplied additions for this format. */
@@ -33,6 +36,11 @@ export interface DependsInputs {
33
36
  deb: string;
34
37
  rpm: string;
35
38
  }>;
39
+ /**
40
+ * Paths of typelib files the payload carries itself. The namespaces they cover need no distro
41
+ * dependency — and are derived from these filenames, never declared separately.
42
+ */
43
+ bundledTypelibs?: readonly string[];
36
44
  /** Minimum GJS. Default {@link DEFAULT_GJS_FLOOR}. */
37
45
  minGjsVersion?: string;
38
46
  }
@@ -35,14 +35,34 @@
35
35
  * is how a project whose bundle really does run on older GJS lowers it.
36
36
  */
37
37
  export const DEFAULT_GJS_FLOOR = '1.86';
38
+ /**
39
+ * The package providing `glib-compile-schemas`, per format.
40
+ *
41
+ * A record, not `format === 'deb' ? … : …`. The ternary reads as a two-way
42
+ * choice and is really "deb, or ELSE rpm's package name" — measured on a third
43
+ * format, `dmg` got `glib2`, at exit 0, in the `Depends:` of a package that has
44
+ * no such thing. Adding a `FormatId` now fails to build here instead.
45
+ */
46
+ const SCHEMA_COMPILER_PACKAGE = { deb: 'libglib2.0-bin', rpm: 'glib2' };
38
47
  /** Debian's `gjs` versions per suite, as measured 2026-08-15 — see {@link warnAboutGjsFloor}. */
39
48
  const DEBIAN_GJS = 'trixie ships 1.82.3 and forky 1.88.1; 1.84 and 1.86 were skipped';
40
49
  /**
41
50
  * Warn when the derived floor cannot be satisfied by a released Debian.
42
51
  * Returns the warning lines; empty when the floor is fine.
43
52
  */
53
+ /**
54
+ * Which formats this Debian-suite warning is ABOUT.
55
+ *
56
+ * A `Record<FormatId, …>` and not `format !== 'deb'`: the negative form answers
57
+ * for every format that will ever exist, and answers "stay quiet" — so a third
58
+ * format would inherit silence about a floor nobody has checked for its distro,
59
+ * with no compile error. As a record, adding a `FormatId` fails to build until
60
+ * someone decides. `rpm` is false because Fedora ships a current GJS, which is a
61
+ * measured fact about Fedora, not a default.
62
+ */
63
+ const GJS_FLOOR_IS_DEBIAN_NEWS = { deb: true, rpm: false };
44
64
  export function warnAboutGjsFloor(format, floor) {
45
- if (format !== 'deb')
65
+ if (!GJS_FLOOR_IS_DEBIAN_NEWS[format])
46
66
  return [];
47
67
  // Only a floor forky ACTUALLY satisfies is quiet. The first cut tested
48
68
  // `>= 1.88.1` and therefore went silent for 1.90 and 2.0 as well — the
@@ -105,6 +125,12 @@ const TYPELIB_PACKAGES = {
105
125
  'Notify-0.7': { deb: 'gir1.2-notify-0.7', rpm: 'libnotify' },
106
126
  'Vte-3.91': { deb: 'gir1.2-vte-3.91', rpm: 'vte291-gtk4' },
107
127
  'Manette-0.2': { deb: 'gir1.2-manette-0.2', rpm: 'libmanette' },
128
+ // libgda — what `@gjsify/sqlite` binds, so every project with a local database lands here.
129
+ // The rpm name is measured on this machine (`rpm -qf …/Gda-6.0.typelib` → libgda); the deb name
130
+ // follows the `gir1.2-<name>-<version>` convention every row above uses and is NOT verified
131
+ // against a Debian system. Said plainly because a wrong name fails at `apt install` with a
132
+ // clear error, while no row at all fails the BUILD of every such project.
133
+ 'Gda-6.0': { deb: 'gir1.2-gda-6.0', rpm: 'libgda' },
108
134
  };
109
135
  /**
110
136
  * A typelib the payload CARRIES, rather than one the system provides.
@@ -131,8 +157,21 @@ const BUNDLED_TYPELIB = /^Gjsify[A-Z]/;
131
157
  export function deriveDepends(format, inputs) {
132
158
  const out = [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
133
159
  const unmapped = [];
160
+ // Namespaces the package SHIPS ITSELF, read off the staged filenames rather than from a
161
+ // separate declaration: `Gwebgl-0.1.typelib` → `Gwebgl-0.1` and `Gwebgl`. Deriving it is the
162
+ // point — a project that declared a namespace bundled without the file being there would get
163
+ // a package that installs and dies at the first import, which is exactly what this check
164
+ // exists to prevent.
165
+ const shipped = new Set();
166
+ for (const file of inputs.bundledTypelibs ?? []) {
167
+ const match = /([A-Za-z0-9]+)-([\d.]+)\.typelib$/.exec(file);
168
+ if (!match)
169
+ continue;
170
+ shipped.add(`${match[1]}-${match[2]}`);
171
+ shipped.add(match[1]);
172
+ }
134
173
  for (const namespace of [...new Set(inputs.namespaces)].sort()) {
135
- if (BUNDLED_TYPELIB.test(namespace))
174
+ if (BUNDLED_TYPELIB.test(namespace) || shipped.has(namespace))
136
175
  continue;
137
176
  const entry = lookupTypelib(namespace, inputs.typelibPackages);
138
177
  if (entry === undefined) {
@@ -152,7 +191,9 @@ export function deriveDepends(format, inputs) {
152
191
  'have to. (`gjsify.ship.depends.<format>` is for dependencies that are not typelibs at all — it ' +
153
192
  'appends, and deliberately does not silence this.)');
154
193
  }
155
- if (inputs.kind === 'app')
194
+ // The package that owns `/usr/share/icons/hicolor` — so this follows the icons, not the
195
+ // app/cli distinction that used to stand in for them.
196
+ if (inputs.hasIcons)
156
197
  out.push('hicolor-icon-theme');
157
198
  // The package that ships `glib-compile-schemas`, NOT `gsettings-desktop-schemas`
158
199
  // (which ships GNOME's own `org.gnome.desktop.*` schemas and has nothing to
@@ -163,7 +204,7 @@ export function deriveDepends(format, inputs) {
163
204
  // `Gio.Settings.new()` aborts the app — an install that succeeds and an app
164
205
  // that does not start.
165
206
  if (inputs.hasSchemas)
166
- out.push(format === 'deb' ? 'libglib2.0-bin' : 'glib2');
207
+ out.push(SCHEMA_COMPILER_PACKAGE[format]);
167
208
  out.push(...inputs.extra);
168
209
  // Set-dedupe keeps first-insertion order, so `gjs` stays first and the
169
210
  // list is stable across runs.
@@ -4,23 +4,128 @@
4
4
  // so `settings.ts` and `plan.ts` can stay pure. Every probe has a configured
5
5
  // override; the probes exist so a project that follows the GNOME convention
6
6
  // (`data/icons/hicolor/…`, `data/*.gschema.xml`) needs no configuration at all.
7
- import { existsSync, readdirSync, statSync } from 'node:fs';
8
- import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
7
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
8
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
9
9
  import { LICENSE_FILE_NAMES } from './settings.js';
10
10
  const ICON_EXTENSIONS = ['.svg', '.png'];
11
11
  export function discoverPayload(input) {
12
12
  const { projectDir, ship } = input;
13
13
  const bundlePath = resolveBundle(input);
14
14
  const bundleDir = resolve(bundlePath, '..');
15
+ const localeFiles = discoverLocales(projectDir, ship.localeDir);
15
16
  return {
16
17
  bundlePath,
17
18
  bundleDir,
18
- bundleFiles: listFilesRecursive(bundleDir),
19
+ // Everything beside the bundle, MINUS the locale tree when it lives there.
20
+ //
21
+ // `dist/locale/` sits next to `dist/app.gjs.mjs` in the layout
22
+ // `@gjsify/vite-plugin-gettext` writes by default, and the wholesale bundle staging would
23
+ // then put every catalogue in `lib/<binary>/locale/` as well as in `share/locale/`.
24
+ // Measured on a probe package: the same `.mo` appeared at both paths. The `lib/` copy is
25
+ // dead weight — nothing looks there — and it is the same failure that once shipped the test
26
+ // suite: whatever is beside the bundle gets carried whether or not it belongs in a package.
27
+ bundleFiles: withoutLocaleTree(listFilesRecursive(bundleDir), bundleDir, projectDir, ship.localeDir),
19
28
  iconFiles: discoverIcons(projectDir, ship.icon ?? input.flatpakIcon),
20
29
  schemaFiles: discoverSchemas(projectDir, ship.schemas),
30
+ typelibFiles: discoverTypelibs(projectDir, ship.bundledTypelibs),
31
+ localeFiles,
21
32
  licenseFile: discoverLicense(projectDir, ship.licenseFile),
22
33
  };
23
34
  }
35
+ /**
36
+ * Typelibs (and their shared libraries) the PROJECT ships itself.
37
+ *
38
+ * Necessary because gjsify's own GI libraries — `Gwebgl` for the WebGL bridge, the GTK runtime
39
+ * bundles, the napi host — arrive as npm prebuilds, not as distro packages. An app using one has
40
+ * no `gir1.2-…` to depend on: it must carry the files, and something must point GI at them.
41
+ *
42
+ * A directory rather than a list of files, because a typelib is never alone: `Gwebgl-0.1.typelib`
43
+ * is useless without `libgwebgl.so`, and staging one without the other produces a package that
44
+ * installs and then fails at the first import.
45
+ */
46
+ function discoverTypelibs(projectDir, dirs) {
47
+ const out = [];
48
+ for (const dir of dirs ?? []) {
49
+ const root = resolve(projectDir, dir);
50
+ if (!existsSync(root)) {
51
+ throw new Error(`gjsify ship: \`gjsify.ship.bundledTypelibs\` names ${dir}, which does not exist. ` +
52
+ 'A missing directory here means the package would install without the typelib it promises.');
53
+ }
54
+ for (const file of listFilesRecursive(root)) {
55
+ if (/\.typelib$|\.so(\.\d+)*$/.test(file))
56
+ out.push(join(root, file));
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Drop the declared locale tree from the wholesale bundle staging.
63
+ *
64
+ * Only when it actually lies inside the bundle directory — a `localeDir` elsewhere in the project
65
+ * was never part of `bundleFiles` and there is nothing to subtract.
66
+ */
67
+ function withoutLocaleTree(bundleFiles, bundleDir, projectDir, localeDir) {
68
+ if (localeDir === undefined)
69
+ return bundleFiles;
70
+ const localeRoot = resolve(projectDir, localeDir);
71
+ const rel = relative(bundleDir, localeRoot);
72
+ // Outside the bundle dir: `relative` climbs out (`..`) or returns an absolute path.
73
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
74
+ return bundleFiles;
75
+ // Only `rel` needs normalising: it comes from `relative`, so it is host-shaped, while
76
+ // `bundleFiles` is POSIX by `listFilesRecursive`'s contract. Comparing a host-shaped prefix
77
+ // against POSIX entries matches nothing on Windows — and matching nothing here means the
78
+ // catalogues quietly ship twice again, on that platform only.
79
+ const prefix = `${rel.split(sep).join(posix.sep)}${posix.sep}`;
80
+ return bundleFiles.filter((file) => !`${file}${posix.sep}`.startsWith(prefix));
81
+ }
82
+ /**
83
+ * Compiled gettext catalogues the project ships (`gjsify.ship.localeDir`).
84
+ *
85
+ * Only `.mo` files, and only in the layout `bindtextdomain` actually reads —
86
+ * `<lang>/LC_MESSAGES/<domain>.mo`. Both refusals cover the same failure, which is the quiet kind:
87
+ * a catalogue in the wrong place, or left as `.po`, installs perfectly and the app then shows the
88
+ * untranslated msgid forever. That is indistinguishable from "no translation exists", so it is not
89
+ * something a user or a maintainer would ever report as a packaging bug.
90
+ */
91
+ function discoverLocales(projectDir, dir) {
92
+ if (dir === undefined)
93
+ return [];
94
+ const root = resolve(projectDir, dir);
95
+ if (!existsSync(root)) {
96
+ throw new Error(`gjsify ship: \`gjsify.ship.localeDir\` names ${dir}, which does not exist. ` +
97
+ 'Compile the catalogues first (msgfmt, or @gjsify/vite-plugin-gettext) — a missing ' +
98
+ 'directory here means the package would install without the translations it promises.');
99
+ }
100
+ const out = [];
101
+ const strays = [];
102
+ for (const rel of listFilesRecursive(root)) {
103
+ if (rel.endsWith('.po') || rel.endsWith('.pot')) {
104
+ strays.push(`${rel} (a .po source; bindtextdomain reads .mo only)`);
105
+ continue;
106
+ }
107
+ if (!rel.endsWith('.mo'))
108
+ continue;
109
+ // `listFilesRecursive` normalises to POSIX, so one separator is all there is to split on.
110
+ const parts = rel.split(posix.sep);
111
+ if (parts.length !== 3 || parts[1] !== 'LC_MESSAGES') {
112
+ strays.push(`${rel} (expected <lang>/LC_MESSAGES/<domain>.mo)`);
113
+ continue;
114
+ }
115
+ out.push({ rel: parts.join('/'), abs: join(root, rel) });
116
+ }
117
+ if (strays.length > 0) {
118
+ throw new Error(`gjsify ship: \`gjsify.ship.localeDir\` (${dir}) holds files no catalogue lookup will find:\n` +
119
+ strays.map((s) => ` ${s}`).join('\n') +
120
+ '\nA misplaced catalogue installs and then shows nothing, which reads exactly like having ' +
121
+ 'no translation at all. Lay them out as <lang>/LC_MESSAGES/<domain>.mo.');
122
+ }
123
+ if (out.length === 0) {
124
+ throw new Error(`gjsify ship: \`gjsify.ship.localeDir\` (${dir}) contains no \`.mo\` catalogue. ` +
125
+ 'Declaring a locale directory that ships nothing is a promise the package does not keep.');
126
+ }
127
+ return out;
128
+ }
24
129
  function resolveBundle(input) {
25
130
  const declared = input.declaredBundle;
26
131
  if (declared === undefined) {
@@ -84,6 +189,34 @@ function discoverSchemas(projectDir, configured) {
84
189
  }
85
190
  return [];
86
191
  }
192
+ /**
193
+ * The licence text this package ships — searched UP the tree, not just beside
194
+ * `package.json`.
195
+ *
196
+ * A monorepo package does not carry its own copy. `packages/infra/cli`
197
+ * declares `"license": "MIT"` and the text lives one `LICENSE` up at the
198
+ * repository root, shared by sixty-odd packages; searching `projectDir` alone
199
+ * found nothing, `planOverlay` turned "nothing" into an empty overlay, and the
200
+ * `.deb` shipped without the `/usr/share/doc/<pkg>/copyright` that Debian
201
+ * Policy § 12.5 requires. Nothing downstream noticed: `dpkg-deb --info` prints
202
+ * a clean control file and `dpkg -i` installs it. It took the first leg that
203
+ * ran a real `lintian` to say so.
204
+ *
205
+ * WHERE THE SEARCH STOPS is the whole design. It climbs to the first ancestor
206
+ * that is a project root — one holding `.git`, or a `package.json` declaring
207
+ * `workspaces` — and searches that directory too, then stops. Above that line
208
+ * a `LICENSE` belongs to some unrelated tree that happens to be a parent
209
+ * directory (`~/Projekte/LICENSE` is not yours), and packaging its text would
210
+ * make a specific false legal claim. When NO marker is found the search is the
211
+ * project directory alone: without a marker there is no way to tell where the
212
+ * project ends, and guessing wrong is the same false claim.
213
+ *
214
+ * What this cannot check is whether the inherited text is the RIGHT licence
215
+ * for this package — an Apache-2.0 package inside an MIT monorepo would
216
+ * inherit the wrong one. Reading an SPDX identifier out of licence prose is
217
+ * not something to guess at, so the caller PRINTS the inherited path instead
218
+ * (see `commands/ship.ts`), and `gjsify.ship.licenseFile` overrides it.
219
+ */
87
220
  function discoverLicense(projectDir, configured) {
88
221
  if (configured !== undefined) {
89
222
  const target = resolve(projectDir, configured);
@@ -92,13 +225,51 @@ function discoverLicense(projectDir, configured) {
92
225
  }
93
226
  return target;
94
227
  }
95
- for (const name of LICENSE_FILE_NAMES) {
96
- const target = join(projectDir, name);
97
- if (existsSync(target))
98
- return target;
228
+ for (const dir of licenseSearchDirs(projectDir)) {
229
+ for (const name of LICENSE_FILE_NAMES) {
230
+ const target = join(dir, name);
231
+ if (existsSync(target))
232
+ return target;
233
+ }
99
234
  }
100
235
  return undefined;
101
236
  }
237
+ /** `projectDir` first, then each ancestor up to and including the project root. */
238
+ function licenseSearchDirs(projectDir) {
239
+ const dirs = [];
240
+ for (let current = projectDir;;) {
241
+ dirs.push(current);
242
+ if (isProjectRoot(current))
243
+ return dirs;
244
+ const parent = dirname(current);
245
+ if (parent === current)
246
+ return [projectDir];
247
+ current = parent;
248
+ }
249
+ }
250
+ /**
251
+ * Is this the top of the tree the project belongs to?
252
+ *
253
+ * `.git` is tested with `existsSync` rather than `statSync().isDirectory()`
254
+ * because in a git WORKTREE it is a file, and this repository is developed in
255
+ * worktrees.
256
+ */
257
+ function isProjectRoot(dir) {
258
+ if (existsSync(join(dir, '.git')))
259
+ return true;
260
+ const manifest = join(dir, 'package.json');
261
+ if (!existsSync(manifest))
262
+ return false;
263
+ try {
264
+ return JSON.parse(readFileSync(manifest, 'utf-8')).workspaces !== undefined;
265
+ }
266
+ catch {
267
+ // A malformed `package.json` in some ancestor is not this command's
268
+ // business, and it must not turn a licence search into a ship failure.
269
+ // Not a root, then: the climb continues and `.git` still stops it.
270
+ return false;
271
+ }
272
+ }
102
273
  /**
103
274
  * Every regular file under `dir`, as POSIX-separated relative paths.
104
275
  *
@@ -58,7 +58,15 @@ export const FORMATS = {
58
58
  fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.rpm`,
59
59
  },
60
60
  };
61
- export const FORMAT_IDS = ['deb', 'rpm'];
61
+ // DERIVED, never a second list. `FORMATS` is `Record<FormatId, …>`, so the
62
+ // compiler already refuses a `FormatId` with no descriptor — reading the keys
63
+ // back inherits that guarantee for free. Written out by hand this was the one
64
+ // unbound copy of the vocabulary: adding a format to `FormatId` and `FORMATS`
65
+ // compiled fine and left this list short, which would have made the new format
66
+ // absent from the default targets (`ship.ts` uses it as the `??` fallback),
67
+ // missing from `--help`, and REFUSED by `readStage` as an unknown id.
68
+ // Insertion order is stable for string keys, and `resolveFormats` sorts anyway.
69
+ export const FORMAT_IDS = Object.keys(FORMATS);
62
70
  /** Parse `--target deb,rpm` into a sorted, deduplicated descriptor list. */
63
71
  export function resolveFormats(raw) {
64
72
  const names = raw
@@ -23,7 +23,7 @@ function shellQuote(value) {
23
23
  export function renderLauncher(settings, bundleRelPath) {
24
24
  const lib = `"$prefix"/lib/${settings.binaryName}`;
25
25
  const args = settings.execArgs.map(shellQuote).join(' ');
26
- return [
26
+ const lines = [
27
27
  '#!/bin/sh',
28
28
  '# Generated by `gjsify ship` — do not edit.',
29
29
  'set -e',
@@ -31,7 +31,30 @@ export function renderLauncher(settings, bundleRelPath) {
31
31
  'prefix=$(dirname "$(dirname "$self")")',
32
32
  'XDG_DATA_DIRS="$prefix/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"',
33
33
  'export XDG_DATA_DIRS',
34
- `exec gjs -m ${lib}/${bundleRelPath}${args ? ` ${args}` : ''} "$@"`,
35
- '',
36
- ].join('\n');
34
+ ];
35
+ // Typelibs the package carries itself (see `gjsify.ship.bundledTypelibs`). PREPENDED, not
36
+ // replacing: an app that ships `Gwebgl` still needs the system `Gtk`, and clobbering the path
37
+ // would trade one missing namespace for all the others. Only emitted when something was
38
+ // actually staged — an empty `GI_TYPELIB_PATH` entry makes GI search a directory that is not
39
+ // there, on every import, forever.
40
+ if (settings.typelibFiles.length > 0) {
41
+ const giDir = '"$prefix"/lib/' + settings.binaryName + '/gi';
42
+ for (const variable of ['GI_TYPELIB_PATH', 'LD_LIBRARY_PATH']) {
43
+ // `${VAR:+:$VAR}` appends the existing value with a separator ONLY when it is set —
44
+ // a plain `:$VAR` leaves a trailing colon, which both loaders read as "and the current
45
+ // directory", and a package that silently adds `.` to a library path is a bad package.
46
+ lines.push(`${variable}=${giDir}\${${variable}:+:$${variable}}`, `export ${variable}`);
47
+ }
48
+ }
49
+ // Where the app's gettext catalogues landed. `bindtextdomain` takes a DIRECTORY and there is no
50
+ // standard environment variable it reads on its own (`TEXTDOMAINDIR` is honoured by the gettext
51
+ // command-line tools, not by the library), so the prefix has to reach the app somehow. Passing
52
+ // it from the launcher keeps the app free of install-layout knowledge, exactly as XDG_DATA_DIRS
53
+ // above does for icons and schemas — a baked `/usr/share/locale` would be wrong in a Flatpak
54
+ // and in any `--prefix` tree.
55
+ if (settings.localeFiles.length > 0) {
56
+ lines.push('GJSIFY_LOCALE_DIR="$prefix"/share/locale', 'export GJSIFY_LOCALE_DIR');
57
+ }
58
+ lines.push(`exec gjs -m ${lib}/${bundleRelPath}${args ? ` ${args}` : ''} "$@"`, '');
59
+ return lines.join('\n');
37
60
  }
@@ -0,0 +1,19 @@
1
+ import type { ShipMimeType } from './types.js';
2
+ /**
3
+ * Validate the declared types, throwing on anything that would install and then not work.
4
+ *
5
+ * Each refusal is a silent failure made loud:
6
+ * - a malformed type name is written into the XML and ignored by `update-mime-database`;
7
+ * - a glob without `*.` never matches (`bauplan` matches only a file called exactly that);
8
+ * - a type with no glob and no parent type can never be DETECTED, so it is registered and unused;
9
+ * - a duplicate definition makes which comment wins depend on document order.
10
+ */
11
+ export declare function validateMimeTypes(types: readonly ShipMimeType[]): void;
12
+ /**
13
+ * The shared-mime-info document for `types`.
14
+ *
15
+ * One file per package, named after the app id, because `share/mime/packages/` is shared: a generic
16
+ * name is a collision with whatever another package put there — the same reason the schema staging
17
+ * uses the app id.
18
+ */
19
+ export declare function renderMimePackage(types: readonly ShipMimeType[]): string;