@gjsify/cli 0.44.0 → 0.46.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 (81) hide show
  1. package/dist/cli.gjs.mjs +202 -167
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +850 -58
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/commands/upgrade.d.ts +23 -0
  11. package/lib/commands/upgrade.js +95 -12
  12. package/lib/templates/install.mjs.tmpl +2 -2
  13. package/lib/types/config-data.d.ts +127 -1
  14. package/lib/utils/gi-search-path.js +11 -3
  15. package/lib/utils/msgfmt-merge.d.ts +35 -0
  16. package/lib/utils/msgfmt-merge.js +102 -0
  17. package/lib/utils/npm-otp.d.ts +27 -2
  18. package/lib/utils/npm-otp.js +82 -7
  19. package/lib/utils/onboard-discovery.d.ts +68 -0
  20. package/lib/utils/onboard-discovery.js +178 -0
  21. package/lib/utils/onboard-probe.d.ts +118 -1
  22. package/lib/utils/onboard-probe.js +153 -4
  23. package/lib/utils/prompt-output.d.ts +12 -0
  24. package/lib/utils/prompt-output.js +54 -0
  25. package/lib/utils/prompt.js +22 -2
  26. package/lib/utils/publishable-packages.d.ts +1 -1
  27. package/lib/utils/publishable-packages.js +1 -0
  28. package/lib/utils/resolve-npm-package.d.ts +56 -0
  29. package/lib/utils/resolve-npm-package.js +69 -0
  30. package/lib/utils/ship/app-runtime.d.ts +214 -0
  31. package/lib/utils/ship/app-runtime.js +429 -0
  32. package/lib/utils/ship/depends.d.ts +50 -0
  33. package/lib/utils/ship/depends.js +117 -3
  34. package/lib/utils/ship/discover.js +103 -1
  35. package/lib/utils/ship/dmg.d.ts +71 -0
  36. package/lib/utils/ship/dmg.js +181 -0
  37. package/lib/utils/ship/flatpak.js +1 -1
  38. package/lib/utils/ship/formats.d.ts +108 -7
  39. package/lib/utils/ship/formats.js +778 -15
  40. package/lib/utils/ship/launcher.d.ts +13 -3
  41. package/lib/utils/ship/launcher.js +419 -17
  42. package/lib/utils/ship/layout.d.ts +330 -0
  43. package/lib/utils/ship/layout.js +504 -0
  44. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  45. package/lib/utils/ship/localize-metadata.js +126 -0
  46. package/lib/utils/ship/mime.js +1 -9
  47. package/lib/utils/ship/msi.d.ts +126 -0
  48. package/lib/utils/ship/msi.js +434 -0
  49. package/lib/utils/ship/node-runtime.d.ts +71 -0
  50. package/lib/utils/ship/node-runtime.js +122 -0
  51. package/lib/utils/ship/payload.d.ts +156 -5
  52. package/lib/utils/ship/payload.js +599 -14
  53. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  54. package/lib/utils/ship/pe-launcher.js +663 -0
  55. package/lib/utils/ship/plan.d.ts +21 -0
  56. package/lib/utils/ship/plan.js +55 -7
  57. package/lib/utils/ship/plist.d.ts +98 -0
  58. package/lib/utils/ship/plist.js +256 -0
  59. package/lib/utils/ship/rpm.js +16 -3
  60. package/lib/utils/ship/schemas.d.ts +61 -0
  61. package/lib/utils/ship/schemas.js +118 -0
  62. package/lib/utils/ship/scripts.js +7 -3
  63. package/lib/utils/ship/settings.d.ts +91 -2
  64. package/lib/utils/ship/settings.js +93 -1
  65. package/lib/utils/ship/share-dirs.d.ts +53 -0
  66. package/lib/utils/ship/share-dirs.js +76 -0
  67. package/lib/utils/ship/signing.d.ts +311 -0
  68. package/lib/utils/ship/signing.js +506 -0
  69. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  70. package/lib/utils/ship/stage-manifest.js +99 -13
  71. package/lib/utils/ship/stage-writer.d.ts +48 -0
  72. package/lib/utils/ship/stage-writer.js +91 -2
  73. package/lib/utils/ship/types.d.ts +191 -14
  74. package/lib/utils/ship/xml.d.ts +7 -0
  75. package/lib/utils/ship/xml.js +25 -0
  76. package/lib/utils/ship/zip.d.ts +69 -0
  77. package/lib/utils/ship/zip.js +245 -0
  78. package/lib/utils/system-gi.d.ts +20 -5
  79. package/lib/utils/system-gi.js +36 -13
  80. package/lib/utils/trust-registry.js +5 -0
  81. package/package.json +19 -19
@@ -14,7 +14,32 @@ import type { DistroFormatId } 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
+ * The Node the `--app node` bundles target, as a MAJOR.
19
+ *
20
+ * Node 24 is the LTS line `@gjsify/node-runtime-*` bundles for macOS and
21
+ * Windows, and this is the Linux half of the same decision: where an
22
+ * interpreter is bundled, the floor is what was bundled; where it is depended
23
+ * on, the floor is what the bundle needs.
24
+ *
25
+ * It excludes EVERY current DEB stable/LTS — Debian 13 trixie 20, Ubuntu 24.04
26
+ * LTS 18, Ubuntu 26.04 LTS 22; only Debian 14 forky has 24. `warnAboutNodeFloor`
27
+ * is what makes that visible at ship time, and `gjsify.ship.minNodeVersion` is
28
+ * how a project whose bundle really does run on an older Node lowers it. The
29
+ * same honest-floor-plus-warning answer as {@link DEFAULT_GJS_FLOOR}, one axis
30
+ * over — and it will fire far more often.
31
+ */
32
+ export declare const DEFAULT_NODE_FLOOR = "24";
17
33
  export declare function warnAboutGjsFloor(format: DistroFormatId, floor: string): string[];
34
+ /**
35
+ * Warn when the Node floor cannot be satisfied by a released Debian or Ubuntu.
36
+ * Returns the warning lines; empty when the floor is fine.
37
+ *
38
+ * Only reached when the payload actually needs an interpreter — a `--app gjs`
39
+ * package declares no Node dependency, so warning about its floor would be
40
+ * noise attached to a line that is never emitted.
41
+ */
42
+ export declare function warnAboutNodeFloor(format: DistroFormatId, floor: string): string[];
18
43
  export interface DependsInputs {
19
44
  /** GI namespaces the bundle imports, in `Ns-Version` spelling. */
20
45
  namespaces: readonly string[];
@@ -29,6 +54,29 @@ export interface DependsInputs {
29
54
  hasIcons: boolean;
30
55
  /** Whether the payload installs GSettings schemas. */
31
56
  hasSchemas: boolean;
57
+ /**
58
+ * The interpreter the launcher execs — `settings.app`, the SAME field
59
+ * `renderLauncher` branches on.
60
+ *
61
+ * Not a payload fact, and the attempt to make it one is worth keeping: the
62
+ * first cut derived it from a staged FILENAME (`*.node.mjs`), which is wrong
63
+ * in both directions. `discoverPayload` stages the whole directory beside
64
+ * the bundle and `dist/<name>.gjs.js` next to `dist/<name>.node.mjs` is the
65
+ * documented normal layout, so a pure `--app gjs` project that also builds a
66
+ * Node bundle declared a Node dependency it never execs — and with a `>= 24`
67
+ * floor that `.deb` is REFUSED by apt on trixie, Ubuntu 24.04 and Ubuntu
68
+ * 26.04. A working package made uninstallable is not "one extra package
69
+ * installed".
70
+ *
71
+ * The honest source is the one thing that decides both the launcher and this
72
+ * list. `assertLauncherMatchesInterpreter` re-reads the staged launcher and
73
+ * refuses a package where the two disagree, which is the check a payload
74
+ * heuristic could never be.
75
+ *
76
+ * macOS and Windows never reach here: there the interpreter is CARRIED, from
77
+ * `@gjsify/node-runtime-<target>`.
78
+ */
79
+ interpreter: 'gjs' | 'node';
32
80
  /** User-supplied additions for this format. */
33
81
  extra: readonly string[];
34
82
  /** Project-supplied table rows, filling gaps in {@link TYPELIB_PACKAGES}. */
@@ -40,6 +88,8 @@ export interface DependsInputs {
40
88
  bundledTypelibs?: readonly string[];
41
89
  /** Minimum GJS. Default {@link DEFAULT_GJS_FLOOR}. */
42
90
  minGjsVersion?: string;
91
+ /** Minimum Node major, when one is depended on. Default {@link DEFAULT_NODE_FLOOR}. */
92
+ minNodeVersion?: string;
43
93
  }
44
94
  /**
45
95
  * The `Depends:` / `Requires:` list for one format.
@@ -35,6 +35,70 @@
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 Node the `--app node` bundles target, as a MAJOR.
40
+ *
41
+ * Node 24 is the LTS line `@gjsify/node-runtime-*` bundles for macOS and
42
+ * Windows, and this is the Linux half of the same decision: where an
43
+ * interpreter is bundled, the floor is what was bundled; where it is depended
44
+ * on, the floor is what the bundle needs.
45
+ *
46
+ * It excludes EVERY current DEB stable/LTS — Debian 13 trixie 20, Ubuntu 24.04
47
+ * LTS 18, Ubuntu 26.04 LTS 22; only Debian 14 forky has 24. `warnAboutNodeFloor`
48
+ * is what makes that visible at ship time, and `gjsify.ship.minNodeVersion` is
49
+ * how a project whose bundle really does run on an older Node lowers it. The
50
+ * same honest-floor-plus-warning answer as {@link DEFAULT_GJS_FLOOR}, one axis
51
+ * over — and it will fire far more often.
52
+ */
53
+ export const DEFAULT_NODE_FLOOR = '24';
54
+ /**
55
+ * The package providing a Node interpreter, per format.
56
+ *
57
+ * ⚠️ THE TWO STRINGS ARE NOT THE SAME NAME, and getting rpm's wrong is silent.
58
+ * `Requires: nodejs >= 24` is a NO-OP on Fedora. Measured with `dnf repoquery`
59
+ * on Fedora 44:
60
+ *
61
+ * --whatprovides 'nodejs >= 24' → nodejs22-1:22.23.1-2.fc44
62
+ * --whatprovides 'nodejs(engine) >= 24' → nodejs24-1:24.18.0-1.fc44
63
+ *
64
+ * F44 has no real `nodejs` package at all — the name is a virtual Provide of the
65
+ * stream packages, which carry **Epoch 1**. A bare `>= 24` desugars to `0:24`,
66
+ * and `1:22.23.1` beats it on epoch alone, so the floor admits the very version
67
+ * it was written to exclude. `nodejs(engine)` carries no epoch and compares as
68
+ * written. (Streams 20/22/24 are parallel-installable, which is why the name has
69
+ * to be a Provide in the first place.) The spelling appears nowhere in the
70
+ * Fedora packaging guideline's own source — it comes from real spec files.
71
+ *
72
+ * No `/usr/bin/node` file dependency beside it — right answer, and the FIRST
73
+ * reason written here for it was wrong, so both are recorded. It is not that
74
+ * `nodejs24` pulls the symlink in: `/usr/bin/node` is provided by `nodejs20-bin`,
75
+ * `nodejs22-bin` AND `nodejs24-bin`, all three of which
76
+ * `Provides`/`Conflicts: alternative-for(nodejs-bin)` and are therefore mutually
77
+ * exclusive. `nodejs24` itself installs `/usr/bin/node-24`. So the file
78
+ * dependency is satisfied by ANY stream's `-bin`, which makes it worthless as a
79
+ * version constraint — that is why it is not emitted.
80
+ *
81
+ * ⚠️ AND THAT LEAVES A REAL HOLE, which no dependency this writer can emit
82
+ * closes. Measured on Fedora 44:
83
+ *
84
+ * dnf install --assumeno nodejs22-bin 'nodejs(engine) >= 24'
85
+ * → installs nodejs22-bin, nodejs22, nodejs24, nodejs24-libs
86
+ * → and NOT nodejs24-bin
87
+ *
88
+ * Every requirement is met and `node` on `PATH` is still 22. rpm's alternatives
89
+ * scheme has no vocabulary for "the stream that owns the symlink must be at
90
+ * least 24", so an app that must have 24 on `PATH` has to check at runtime; a
91
+ * package cannot ask for it. Recorded here because the gap is invisible from the
92
+ * emitted `Requires:` line, which looks correct and is.
93
+ *
94
+ * (`nodejs24` carries no bare `nodejs` Provide at all — only `nodejs22` does on
95
+ * F44, which is the other half of why the epoch trap above bites.)
96
+ *
97
+ * A `Record`, for the reason {@link SCHEMA_COMPILER_PACKAGE} records: a ternary
98
+ * reads as a two-way choice and is really "deb, or ELSE rpm's name", and a third
99
+ * format silently inheriting rpm's spelling has happened here before, at exit 0.
100
+ */
101
+ const NODE_PACKAGE = { deb: 'nodejs', rpm: 'nodejs(engine)' };
38
102
  /**
39
103
  * The package providing `glib-compile-schemas`, per format.
40
104
  *
@@ -78,6 +142,49 @@ export function warnAboutGjsFloor(format, floor) {
78
142
  'to make apt happy, because the result is an install that succeeds and an app that does not start.',
79
143
  ];
80
144
  }
145
+ /**
146
+ * Node majors per DEB suite, as measured 2026-08-28 — see {@link warnAboutNodeFloor}.
147
+ */
148
+ const DEBIAN_NODE = 'trixie ships 20, forky 24; Ubuntu 24.04 LTS ships 18 and 26.04 LTS 22';
149
+ /**
150
+ * Which formats the Node-floor warning is ABOUT.
151
+ *
152
+ * `rpm` is false, and it is a measured fact about Fedora rather than a default:
153
+ * F43/44/45 default to Node 22 but ship `nodejs24` in the base repository,
154
+ * parallel-installable, so `nodejs(engine) >= 24` resolves without an extra
155
+ * repository. Debian and Ubuntu have no such stream packages — one `nodejs` per
156
+ * suite, and it is older than 24 in every current stable/LTS.
157
+ *
158
+ * A `Record` and not `format !== 'deb'`, for the reason
159
+ * {@link GJS_FLOOR_IS_DEBIAN_NEWS} spells out: the negative form answers "stay
160
+ * quiet" for every format that will ever exist, including ones nobody has
161
+ * checked a distribution for.
162
+ */
163
+ const NODE_FLOOR_IS_DEBIAN_NEWS = { deb: true, rpm: false };
164
+ /**
165
+ * Warn when the Node floor cannot be satisfied by a released Debian or Ubuntu.
166
+ * Returns the warning lines; empty when the floor is fine.
167
+ *
168
+ * Only reached when the payload actually needs an interpreter — a `--app gjs`
169
+ * package declares no Node dependency, so warning about its floor would be
170
+ * noise attached to a line that is never emitted.
171
+ */
172
+ export function warnAboutNodeFloor(format, floor) {
173
+ if (!NODE_FLOOR_IS_DEBIAN_NEWS[format])
174
+ return [];
175
+ // Ubuntu 26.04 LTS's 22 is the newest a current DEB stable/LTS carries;
176
+ // trixie's 20 is older still. A floor of 22 or below is satisfiable
177
+ // somewhere released and stays quiet.
178
+ if (compareVersions(floor, '22') <= 0)
179
+ return [];
180
+ return [
181
+ `gjsify ship: \`Depends: nodejs (>= ${floor})\` is not satisfiable on any current DEB stable/LTS — ` +
182
+ `${DEBIAN_NODE}. The package installs on forky/sid and on distributions with a newer Node. Set ` +
183
+ '`gjsify.ship.minNodeVersion` if this bundle genuinely runs on an older Node; do NOT lower it ' +
184
+ 'to make apt happy, because the result is an install that succeeds and an app that dies on the ' +
185
+ 'first syntax or API the older interpreter does not have.',
186
+ ];
187
+ }
81
188
  /** Numeric dotted-version compare. Enough for `1.82.3` vs `1.86`. */
82
189
  function compareVersions(a, b) {
83
190
  const left = a.split('.').map(Number);
@@ -155,7 +262,14 @@ const BUNDLED_TYPELIB = /^Gjsify[A-Z]/;
155
262
  * @throws when a namespace has no entry in the table — see the module header.
156
263
  */
157
264
  export function deriveDepends(format, inputs) {
158
- const out = [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
265
+ // The interpreter the launcher execs, and nothing else: a package declares ONE.
266
+ // Linux is the only place this is a dependency at all — macOS and Windows have
267
+ // no system Node, so an artifact for those CARRIES one from
268
+ // `@gjsify/node-runtime-<target>`, and that is the whole reason those three
269
+ // packages exist and this one line is their opposite.
270
+ const out = inputs.interpreter === 'node'
271
+ ? [`${NODE_PACKAGE[format]} >= ${inputs.minNodeVersion ?? DEFAULT_NODE_FLOOR}`]
272
+ : [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
159
273
  const unmapped = [];
160
274
  // Namespaces the package SHIPS ITSELF, read off the staged filenames rather than from a
161
275
  // separate declaration: `Gwebgl-0.1.typelib` → `Gwebgl-0.1` and `Gwebgl`. Deriving it is the
@@ -206,8 +320,8 @@ export function deriveDepends(format, inputs) {
206
320
  if (inputs.hasSchemas)
207
321
  out.push(SCHEMA_COMPILER_PACKAGE[format]);
208
322
  out.push(...inputs.extra);
209
- // Set-dedupe keeps first-insertion order, so `gjs` stays first and the
210
- // list is stable across runs.
323
+ // Set-dedupe keeps first-insertion order, so the interpreter stays first and
324
+ // the list is stable across runs.
211
325
  return [...new Set(out)];
212
326
  }
213
327
  /**
@@ -8,6 +8,30 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
8
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
+ /**
12
+ * The face formats every FreeType opens, with no build option deciding it.
13
+ *
14
+ * TrueType, OpenType and their collections. Deliberately short: the point of the
15
+ * set is that a file in it resolves inside the app's OWN runtime closure as well
16
+ * as it does on the machine that packaged it.
17
+ */
18
+ const FONT_EXTENSIONS = ['.ttf', '.otf', '.ttc', '.otc'];
19
+ /**
20
+ * Web-font wrappers, refused BY NAME rather than filtered away.
21
+ *
22
+ * They are the mistake worth a diagnostic. An `OFL.txt` beside the faces is
23
+ * ignored the way a stray file in an icon directory is; a `.woff2` is a font
24
+ * somebody put there on purpose, and dropping it silently costs the same as
25
+ * staging it — the app ships without the family and Pango substitutes, at exit 0.
26
+ *
27
+ * Measured in BOTH directions, which is why this is a refusal rather than a
28
+ * warning: `fc-query` reads a `.woff2`'s family fine on Fedora 44 (fontconfig
29
+ * 2.17.0, FreeType built with brotli), and that support is
30
+ * `FT_CONFIG_OPTION_USE_BROTLI` — a property of whichever FreeType the shipped
31
+ * artifact loads, which for a `.app` or a Windows program directory is the one
32
+ * inside `@gjsify/gtk-runtime-<target>` and not this host's.
33
+ */
34
+ const WEB_FONT_EXTENSIONS = ['.woff', '.woff2', '.eot'];
11
35
  export function discoverPayload(input) {
12
36
  const { projectDir, ship } = input;
13
37
  const bundlePath = resolveBundle(input);
@@ -29,6 +53,7 @@ export function discoverPayload(input) {
29
53
  schemaFiles: discoverSchemas(projectDir, ship.schemas),
30
54
  typelibFiles: discoverTypelibs(projectDir, ship.bundledTypelibs),
31
55
  localeFiles,
56
+ fontFiles: discoverFonts(projectDir, ship.fonts),
32
57
  licenseFile: discoverLicense(projectDir, ship.licenseFile),
33
58
  };
34
59
  }
@@ -52,7 +77,24 @@ function discoverTypelibs(projectDir, dirs) {
52
77
  'A missing directory here means the package would install without the typelib it promises.');
53
78
  }
54
79
  for (const file of listFilesRecursive(root)) {
55
- if (/\.typelib$|\.so(\.\d+)*$/.test(file))
80
+ // Every shared-library spelling, not only ELF's. A typelib is
81
+ // useless without its library on macOS and Windows too, and the
82
+ // `.so`-only test dropped `libgwebgl.dylib` and `gwebgl-0.dll` while
83
+ // staging the `.typelib` beside them — a package that installs and
84
+ // dies at the first import, with nothing in the output saying so.
85
+ // The layout axis (ADR 0024 § 2) is what put those two files in
86
+ // reach: they land in `Contents/Frameworks` and in the program
87
+ // directory's `lib\`.
88
+ // Case-insensitive for the extensions that come off case-preserving-
89
+ // but-INSENSITIVE filesystems — `LIBFOO.DLL` and `Foo.Dylib` are
90
+ // ordinary names on macOS and Windows, and a lowercase-only test drops
91
+ // them into exactly the silent failure the paragraph above describes.
92
+ //
93
+ // NOT for `.so`, and the asymmetry is the reason rather than an
94
+ // oversight: ELF sonames come off case-SENSITIVE filesystems where
95
+ // `libfoo.so` is the only spelling, and `/i` there additionally makes
96
+ // `README.SO` a shared library. One rationale, one scope.
97
+ if (/\.(typelib|dylib|dll)$/i.test(file) || /\.so(\.\d+)*$/.test(file))
56
98
  out.push(join(root, file));
57
99
  }
58
100
  }
@@ -169,6 +211,66 @@ function listIcons(dir) {
169
211
  .map((rel) => join(dir, rel.split('/').join(sep)))
170
212
  .sort();
171
213
  }
214
+ /**
215
+ * Font faces the application SHIPS (`gjsify.ship.fonts`), or the GNOME-shaped
216
+ * default `data/fonts` when it exists.
217
+ *
218
+ * A FILE or a DIRECTORY, like `icon`, and a directory is walked rather than
219
+ * globbed because a family arrives as several faces and often with its licence
220
+ * beside them.
221
+ *
222
+ * The refusals are the interesting half, and both cover the same failure: a font
223
+ * that installs and is never found. Pango does not report a missing family — it
224
+ * substitutes, so the app renders in the wrong typeface with no error, no exit
225
+ * code and nothing for CI to see. That is why a configured path holding no usable
226
+ * face fails here rather than staging an empty directory, and why a web-font
227
+ * wrapper is named rather than filtered (see {@link WEB_FONT_EXTENSIONS}).
228
+ */
229
+ function discoverFonts(projectDir, configured) {
230
+ const declared = configured ?? 'data/fonts';
231
+ const root = configured === undefined ? join(projectDir, 'data', 'fonts') : resolve(projectDir, configured);
232
+ if (!existsSync(root)) {
233
+ if (configured === undefined)
234
+ return [];
235
+ throw new Error(`gjsify ship: the configured font path ${configured} does not exist.`);
236
+ }
237
+ if (!statSync(root).isDirectory()) {
238
+ assertNotWebFont(root, declared);
239
+ if (!FONT_EXTENSIONS.some((ext) => root.toLowerCase().endsWith(ext))) {
240
+ throw new Error(`gjsify ship: \`gjsify.ship.fonts\` names ${configured}, which is not a font face ` +
241
+ `fontconfig will open (${FONT_EXTENSIONS.join(', ')}). ` +
242
+ 'A file it cannot read installs and is never found, and Pango substitutes a fallback ' +
243
+ 'family rather than reporting anything.');
244
+ }
245
+ return [root];
246
+ }
247
+ const out = [];
248
+ for (const rel of listFilesRecursive(root)) {
249
+ assertNotWebFont(rel, declared);
250
+ if (FONT_EXTENSIONS.some((ext) => rel.toLowerCase().endsWith(ext))) {
251
+ out.push(join(root, rel.split('/').join(sep)));
252
+ }
253
+ }
254
+ if (out.length === 0 && configured !== undefined) {
255
+ throw new Error(`gjsify ship: \`gjsify.ship.fonts\` (${configured}) holds no font face ` +
256
+ `(${FONT_EXTENSIONS.join(', ')}). Declaring fonts that ship nothing is a promise the ` +
257
+ 'package does not keep — and its symptom is a substituted family, not an error.');
258
+ }
259
+ return out.sort();
260
+ }
261
+ /** A `.woff`/`.woff2`/`.eot` is named, never dropped — {@link WEB_FONT_EXTENSIONS} carries the reason. */
262
+ function assertNotWebFont(path, where) {
263
+ const lower = path.toLowerCase();
264
+ const wrapper = WEB_FONT_EXTENSIONS.find((ext) => lower.endsWith(ext));
265
+ if (wrapper === undefined)
266
+ return;
267
+ throw new Error(`gjsify ship: \`gjsify.ship.fonts\` (${where}) carries ${path}, and \`${wrapper}\` is a web-font ` +
268
+ 'wrapper whose support is a FreeType BUILD option — brotli for woff2, zlib for woff. Whether it ' +
269
+ 'opens is therefore a property of the FreeType the SHIPPED artifact loads (inside ' +
270
+ '`@gjsify/gtk-runtime-<target>` for a `.app` or a Windows program directory), not of the machine ' +
271
+ `that packaged it. Ship the desktop face instead (${FONT_EXTENSIONS.join(', ')}); a font that ` +
272
+ 'fails to open is not an error, it is Pango quietly substituting a different typeface.');
273
+ }
172
274
  function discoverSchemas(projectDir, configured) {
173
275
  const roots = configured !== undefined ? [resolve(projectDir, configured)] : [join(projectDir, 'data')];
174
276
  for (const root of roots) {
@@ -0,0 +1,71 @@
1
+ import type { PackSettings } from './types.js';
2
+ /** The tool this packer execs. One spelling, read by this module AND by the format row. */
3
+ export declare const DMG_TOOL = "hdiutil";
4
+ /**
5
+ * What the mounted image is called in the Finder's sidebar.
6
+ *
7
+ * The DISPLAY name, like the `.app` inside it — the volume and the bundle are
8
+ * the two things a user reads during the one interaction this format has, and
9
+ * spelling them differently (`Ship Demo.app` inside `ship-demo-1.2.3`) makes the
10
+ * window look like somebody else's download. The FILE keeps the versioned,
11
+ * space-free name, because that one lands in a downloads folder beside others;
12
+ * `FormatDescriptor.fileName` is where that split lives, exactly as it does for
13
+ * the `.app` and its zip.
14
+ *
15
+ * EMPTY IS REFUSED, and it is not defensive: `resolveShipSettings` derives the
16
+ * name as `metadata.name ?? titleCase(binaryName)` and `??` passes `''` straight
17
+ * through — the same hole `windowsProgramDirName` documents. `hdiutil` answers an
18
+ * empty `-volname` by calling the volume `untitled`, at exit 0, so the image
19
+ * would build and mount under a name nothing in the project ever chose.
20
+ */
21
+ export declare function dmgVolumeName(settings: PackSettings): string;
22
+ export interface DmgCreateInput {
23
+ /** The volume label — see {@link dmgVolumeName}. */
24
+ volumeName: string;
25
+ /** The directory whose CONTENTS become the volume root; it holds `<App>.app` and nothing else. */
26
+ sourceDir: string;
27
+ /** Absolute path the finished `.dmg` is written to. */
28
+ target: string;
29
+ }
30
+ /**
31
+ * The exact `hdiutil` invocation, as data.
32
+ *
33
+ * Pure, so every flag is unit-testable from Linux — which is the only kind of
34
+ * host this project's contributors and its `e2e` job have. Same split as
35
+ * `renderShipFlatpakManifest`: what the tool is TOLD is checkable everywhere,
36
+ * what the tool DOES is checkable on the one OS that has it, and the two are not
37
+ * the same test.
38
+ *
39
+ * `-srcfolder` and not `-srcdevice`/`-size`: the folder's contents become the
40
+ * volume root, so `hdiutil` sizes the image itself. Passing a `-size` instead is
41
+ * how a `.dmg` build starts failing on the day the payload grows past a number
42
+ * somebody typed once.
43
+ */
44
+ export declare function hdiutilCreateArgs(input: DmgCreateInput): string[];
45
+ export interface DmgPackInput extends DmgCreateInput {
46
+ verbose: boolean;
47
+ /** Working root, so the log can name the directory that became the volume. */
48
+ workDir: string;
49
+ }
50
+ /**
51
+ * Build the image at `input.target`.
52
+ *
53
+ * Unlike the Flatpak packer's tool, this one cannot be installed where it is
54
+ * missing: `hdiutil` ships with macOS and exists nowhere else. So the `notFound` branch does not say "install
55
+ * it"; it says which host this belongs on and how the payload gets there, which
56
+ * is the same two-phase sentence `assertHostCanFinish` prints one step earlier.
57
+ * Reaching this branch at all means `process.platform` said darwin and `hdiutil`
58
+ * was still absent, so the honest advice is "this macOS installation is broken",
59
+ * not a package name.
60
+ */
61
+ export declare function buildDmgImage(input: DmgPackInput): Promise<void>;
62
+ /**
63
+ * Where the volume is assembled before `hdiutil` turns it into an image.
64
+ *
65
+ * A directory of its own under the run's output root, never `out/` itself: the
66
+ * volume root holds the `.app` and NOTHING ELSE, because `-srcfolder` copies
67
+ * whatever it finds — a stray sibling would ride into the image, and the reader
68
+ * that would catch it is the same listing comparison that would then have to be
69
+ * relaxed to allow it.
70
+ */
71
+ export declare function dmgVolumeDir(outRoot: string): string;
@@ -0,0 +1,181 @@
1
+ // The `.dmg` packer — ADR 0024 § A1/§ A6, issue #1354 M4.
2
+ //
3
+ // THE FIRST FORMAT THAT IS HOST-BOUND IN THE `HostRequirement` SENSE, and the
4
+ // distinction ADR 0024 § A3 drew between its two refusals is what this row is
5
+ // the first subject of. A Flatpak is `finishOn: ['linux']` because flatpak runs
6
+ // on Linux — the format is bound the way the application is. A `.dmg` is bound
7
+ // by the CONTAINER: it is a UDIF image over a real HFS+/APFS volume, no
8
+ // HFS+/APFS writer exists anywhere in this tree, and `hdiutil` is macOS-only.
9
+ // So `assertHostCanFinish` refuses it off darwin and `assertToolsInstalled`
10
+ // names `hdiutil` on a darwin host that somehow lacks it — two failures with
11
+ // two different fixes, which is exactly why they are two functions.
12
+ //
13
+ // A HAND-WRITTEN UDIF WRITER STAYS REJECTED (§ A6), in that section's own
14
+ // words: 1200-2000 lines against a whole existing packer surface of ~1243, and
15
+ // every mistake silent, because the Finder mounts the image and shows an empty
16
+ // window. What we would buy is an independent reader, and 7-Zip already
17
+ // advertises `Dmg`, `HFS` and `APFS` handlers regardless of who wrote the file.
18
+ //
19
+ // WHICH MAKES THE ORACLE THE INTERESTING PART, because `hdiutil verify` is
20
+ // `hdiutil` reading what `hdiutil` wrote. The reader is a chain that runs on
21
+ // LINUX — `7z l` (7-Zip 23.01 on ubuntu-24.04 carries `Dmg`, `HFS` and `APFS`),
22
+ // `7z t` to inflate what it stores, then `dmg2img` to a raw volume and
23
+ // `fsck.hfsplus -f -n` over that — driven by `.github/ship-oracle/verify-dmg.py`
24
+ // and compared against the stage manifest by name, size and MODE. Three
25
+ // implementations, none of them Apple's and none of them ours.
26
+ //
27
+ // TWO hdiutil FLAGS ARE DECISIONS AND NOT DEFAULTS:
28
+ //
29
+ // * `-fs HFS+J`. APFS is what a modern `hdiutil` reaches for when nothing says
30
+ // otherwise, and the whole Linux reader chain above is an HFS+ chain —
31
+ // `dmg2img` writes a raw volume and `fsck.hfsplus` is an HFS+ fsck. An APFS
32
+ // image would leave 7-Zip's handler as the only reader, i.e. one reader for
33
+ // the negative control to fool. Journaling costs a read-only image nothing
34
+ // and `fsck.hfsplus` reads it: measured on ubuntu-24.04 / hfsprogs
35
+ // 540.1.linux3-5build3 against a `mkfs.hfsplus -J` volume, "** Checking
36
+ // Journaled HFS Plus volume … appears to be OK", exit 0.
37
+ // * `-format UDZO`, i.e. zlib. The alternatives are ULFO (LZFSE) and ULMO
38
+ // (LZMA), and the reader that would have to decode them is the OLD one:
39
+ // ubuntu-24.04 ships 7-Zip 23.01, not this workstation's 26.02. zlib is the
40
+ // method its `Dmg` handler has read since long before either.
41
+ //
42
+ // WHAT THIS PACKER DOES NOT PUT IN THE VOLUME: an `/Applications` symlink, the
43
+ // drag-here convention. `writePayload` writes regular files and nothing else —
44
+ // which is a property `tests/e2e/ship` and the `ship-stage` job both assert, and
45
+ // a symlink is also what `actions/upload-artifact` silently dereferences. The
46
+ // artifact is still draggable out of the mounted volume; what it lacks is the
47
+ // arrow. Unblocker: a staged-symlink concept with a reader that can see one, at
48
+ // which point the link and its oracle land together.
49
+ import { join } from 'node:path';
50
+ import { describeExit, spawnToCompletion } from '../spawn.js';
51
+ /** The tool this packer execs. One spelling, read by this module AND by the format row. */
52
+ export const DMG_TOOL = 'hdiutil';
53
+ /**
54
+ * Characters an HFS+ volume name may not contain.
55
+ *
56
+ * Two of the three `layout.ts` refuses in a bundle DIRECTORY name — its
57
+ * `BUNDLE_NAME_FORBIDDEN` is `/[/:\\]/` — and for the same measured reason: `/`
58
+ * is the POSIX separator and `:` is HFS+'s, and the Finder shows a stored `:` as
59
+ * a `/` and vice versa. A volume called `Ship/Demo` mounts as `Ship:Demo`, so the
60
+ * name a user is told to look for is not the name they see. `\` is the one that
61
+ * is NOT here: it is an ordinary character in a volume name, while a bundle
62
+ * directory refuses it too and `windowsProgramDirName` refuses it for Windows'
63
+ * own reasons.
64
+ */
65
+ const VOLUME_NAME_FORBIDDEN = /[/:]/;
66
+ /**
67
+ * What the mounted image is called in the Finder's sidebar.
68
+ *
69
+ * The DISPLAY name, like the `.app` inside it — the volume and the bundle are
70
+ * the two things a user reads during the one interaction this format has, and
71
+ * spelling them differently (`Ship Demo.app` inside `ship-demo-1.2.3`) makes the
72
+ * window look like somebody else's download. The FILE keeps the versioned,
73
+ * space-free name, because that one lands in a downloads folder beside others;
74
+ * `FormatDescriptor.fileName` is where that split lives, exactly as it does for
75
+ * the `.app` and its zip.
76
+ *
77
+ * EMPTY IS REFUSED, and it is not defensive: `resolveShipSettings` derives the
78
+ * name as `metadata.name ?? titleCase(binaryName)` and `??` passes `''` straight
79
+ * through — the same hole `windowsProgramDirName` documents. `hdiutil` answers an
80
+ * empty `-volname` by calling the volume `untitled`, at exit 0, so the image
81
+ * would build and mount under a name nothing in the project ever chose.
82
+ */
83
+ export function dmgVolumeName(settings) {
84
+ const name = settings.name;
85
+ if (name.trim() === '') {
86
+ throw new Error('gjsify ship: the macOS image has no name to call its volume — `gjsify.ship.name` (or the ' +
87
+ 'package name it is derived from) is empty. `hdiutil` would accept that and mount the ' +
88
+ 'image as `untitled`, which is the name every nameless disk image on the machine has.');
89
+ }
90
+ if (VOLUME_NAME_FORBIDDEN.test(name)) {
91
+ throw new Error(`gjsify ship: the macOS image would mount a volume called "${name}", and HFS+ cannot hold it: ` +
92
+ 'the Finder shows a stored ":" as a "/" and a stored "/" as a ":", so the volume would not ' +
93
+ 'be the one this project named. Set `gjsify.ship.name` to the display name you want.');
94
+ }
95
+ return name;
96
+ }
97
+ /**
98
+ * The exact `hdiutil` invocation, as data.
99
+ *
100
+ * Pure, so every flag is unit-testable from Linux — which is the only kind of
101
+ * host this project's contributors and its `e2e` job have. Same split as
102
+ * `renderShipFlatpakManifest`: what the tool is TOLD is checkable everywhere,
103
+ * what the tool DOES is checkable on the one OS that has it, and the two are not
104
+ * the same test.
105
+ *
106
+ * `-srcfolder` and not `-srcdevice`/`-size`: the folder's contents become the
107
+ * volume root, so `hdiutil` sizes the image itself. Passing a `-size` instead is
108
+ * how a `.dmg` build starts failing on the day the payload grows past a number
109
+ * somebody typed once.
110
+ */
111
+ export function hdiutilCreateArgs(input) {
112
+ return [
113
+ 'create',
114
+ // Read-only + zlib. See the header: the alternatives are decodable only
115
+ // by a newer 7-Zip than the reader leg has.
116
+ '-format',
117
+ 'UDZO',
118
+ // HFS+ EXPLICITLY, because the default is the thing the reader chain
119
+ // cannot read. See the header.
120
+ '-fs',
121
+ 'HFS+J',
122
+ '-volname',
123
+ input.volumeName,
124
+ '-srcfolder',
125
+ input.sourceDir,
126
+ // hdiutil REFUSES an existing target rather than replacing it, and
127
+ // `packOne` writes into `out/` which a previous run may have filled. The
128
+ // failure without this is a pack that dies on its second invocation only.
129
+ '-ov',
130
+ // Progress on a TTY is a spinner; in a CI log it is thousands of lines.
131
+ '-quiet',
132
+ input.target,
133
+ ];
134
+ }
135
+ /**
136
+ * Build the image at `input.target`.
137
+ *
138
+ * Unlike the Flatpak packer's tool, this one cannot be installed where it is
139
+ * missing: `hdiutil` ships with macOS and exists nowhere else. So the `notFound` branch does not say "install
140
+ * it"; it says which host this belongs on and how the payload gets there, which
141
+ * is the same two-phase sentence `assertHostCanFinish` prints one step earlier.
142
+ * Reaching this branch at all means `process.platform` said darwin and `hdiutil`
143
+ * was still absent, so the honest advice is "this macOS installation is broken",
144
+ * not a package name.
145
+ */
146
+ export async function buildDmgImage(input) {
147
+ const args = hdiutilCreateArgs(input);
148
+ if (input.verbose)
149
+ console.log(`[gjsify ship] ${DMG_TOOL} ${args.join(' ')}`);
150
+ // `completion: 'return'`, matching the Flatpak packer: `ship` packs the
151
+ // remaining formats and prints the artifact list afterwards, so it cannot end
152
+ // in `process.exit()`. Under GJS that selects the blocking path, which
153
+ // captures the child's output and re-emits it — the documented cost of a
154
+ // spawn a caller has to return from (`utils/spawn.ts`), and why `--verbose`
155
+ // prints the invocation before the silence starts.
156
+ const result = await spawnToCompletion(DMG_TOOL, args, {
157
+ completion: 'return',
158
+ cwd: input.workDir,
159
+ stdio: 'inherit',
160
+ notFound: () => new Error(`gjsify ship: ${DMG_TOOL} is not on PATH. It ships with macOS and cannot be installed ` +
161
+ 'anywhere else, so this is either a broken macOS installation or a host that is not ' +
162
+ 'macOS at all. A `.dmg` is packed on darwin (ADR 0024 § A1); from anywhere else the ' +
163
+ 'route is `gjsify ship darwin --stage` here and `gjsify ship --from-stage <dir> ' +
164
+ '--target macos-app-dmg` there.'),
165
+ });
166
+ if (result.code !== 0) {
167
+ throw new Error(`gjsify ship: ${DMG_TOOL} failed with ${describeExit(result)}.`);
168
+ }
169
+ }
170
+ /**
171
+ * Where the volume is assembled before `hdiutil` turns it into an image.
172
+ *
173
+ * A directory of its own under the run's output root, never `out/` itself: the
174
+ * volume root holds the `.app` and NOTHING ELSE, because `-srcfolder` copies
175
+ * whatever it finds — a stray sibling would ride into the image, and the reader
176
+ * that would catch it is the same listing comparison that would then have to be
177
+ * relaxed to allow it.
178
+ */
179
+ export function dmgVolumeDir(outRoot) {
180
+ return join(outRoot, 'dmg', 'volume');
181
+ }
@@ -143,6 +143,6 @@ async function run(cmd, args, input, notFoundHint) {
143
143
  notFound: () => new Error(`gjsify ship: ${notFoundHint}`),
144
144
  });
145
145
  if (result.code !== 0) {
146
- throw new Error(`gjsify ship: ${cmd} failed${describeExit(result)}.`);
146
+ throw new Error(`gjsify ship: ${cmd} failed with ${describeExit(result)}.`);
147
147
  }
148
148
  }