@gjsify/cli 0.43.0 → 0.45.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 (87) hide show
  1. package/dist/cli.gjs.mjs +453 -425
  2. package/lib/actions/build.js +2 -1
  3. package/lib/commands/affected-classify.js +7 -5
  4. package/lib/commands/build.js +6 -0
  5. package/lib/commands/flatpak/ci.js +10 -2
  6. package/lib/commands/flatpak/init.js +15 -16
  7. package/lib/commands/flatpak/utils.d.ts +0 -26
  8. package/lib/commands/flatpak/utils.js +4 -29
  9. package/lib/commands/gettext.d.ts +2 -1
  10. package/lib/commands/gettext.js +105 -47
  11. package/lib/commands/onboard.d.ts +7 -0
  12. package/lib/commands/onboard.js +349 -96
  13. package/lib/commands/ship.d.ts +3 -0
  14. package/lib/commands/ship.js +911 -70
  15. package/lib/commands/trust.d.ts +6 -0
  16. package/lib/commands/trust.js +1 -1
  17. package/lib/config.js +25 -0
  18. package/lib/types/cli-build-options.d.ts +11 -0
  19. package/lib/types/config-data.d.ts +137 -3
  20. package/lib/utils/flatpak-runtime.d.ts +41 -0
  21. package/lib/utils/flatpak-runtime.js +54 -0
  22. package/lib/utils/msgfmt-merge.d.ts +35 -0
  23. package/lib/utils/msgfmt-merge.js +102 -0
  24. package/lib/utils/npm-otp.d.ts +27 -2
  25. package/lib/utils/npm-otp.js +82 -7
  26. package/lib/utils/onboard-discovery.d.ts +68 -0
  27. package/lib/utils/onboard-discovery.js +178 -0
  28. package/lib/utils/onboard-probe.d.ts +118 -1
  29. package/lib/utils/onboard-probe.js +153 -4
  30. package/lib/utils/prompt-output.d.ts +12 -0
  31. package/lib/utils/prompt-output.js +54 -0
  32. package/lib/utils/prompt.js +22 -2
  33. package/lib/utils/publishable-packages.d.ts +1 -1
  34. package/lib/utils/publishable-packages.js +1 -0
  35. package/lib/utils/resolve-npm-package.d.ts +56 -0
  36. package/lib/utils/resolve-npm-package.js +69 -0
  37. package/lib/utils/ship/app-runtime.d.ts +214 -0
  38. package/lib/utils/ship/app-runtime.js +429 -0
  39. package/lib/utils/ship/depends.d.ts +54 -7
  40. package/lib/utils/ship/depends.js +120 -6
  41. package/lib/utils/ship/discover.js +18 -1
  42. package/lib/utils/ship/dmg.d.ts +71 -0
  43. package/lib/utils/ship/dmg.js +181 -0
  44. package/lib/utils/ship/flatpak-config.d.ts +66 -0
  45. package/lib/utils/ship/flatpak-config.js +132 -0
  46. package/lib/utils/ship/flatpak.d.ts +45 -0
  47. package/lib/utils/ship/flatpak.js +148 -0
  48. package/lib/utils/ship/formats.d.ts +116 -3
  49. package/lib/utils/ship/formats.js +869 -11
  50. package/lib/utils/ship/launcher.d.ts +13 -3
  51. package/lib/utils/ship/launcher.js +362 -17
  52. package/lib/utils/ship/layout.d.ts +268 -0
  53. package/lib/utils/ship/layout.js +351 -0
  54. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  55. package/lib/utils/ship/localize-metadata.js +126 -0
  56. package/lib/utils/ship/mime.js +1 -9
  57. package/lib/utils/ship/msi.d.ts +126 -0
  58. package/lib/utils/ship/msi.js +417 -0
  59. package/lib/utils/ship/node-runtime.d.ts +71 -0
  60. package/lib/utils/ship/node-runtime.js +122 -0
  61. package/lib/utils/ship/payload.d.ts +156 -5
  62. package/lib/utils/ship/payload.js +580 -14
  63. package/lib/utils/ship/plan.d.ts +21 -0
  64. package/lib/utils/ship/plan.js +20 -7
  65. package/lib/utils/ship/plist.d.ts +58 -0
  66. package/lib/utils/ship/plist.js +198 -0
  67. package/lib/utils/ship/rpm.js +9 -3
  68. package/lib/utils/ship/schemas.d.ts +61 -0
  69. package/lib/utils/ship/schemas.js +118 -0
  70. package/lib/utils/ship/scripts.js +7 -3
  71. package/lib/utils/ship/settings.d.ts +19 -1
  72. package/lib/utils/ship/settings.js +19 -1
  73. package/lib/utils/ship/share-dirs.d.ts +23 -0
  74. package/lib/utils/ship/share-dirs.js +46 -0
  75. package/lib/utils/ship/signing.d.ts +195 -0
  76. package/lib/utils/ship/signing.js +317 -0
  77. package/lib/utils/ship/stage-manifest.d.ts +50 -13
  78. package/lib/utils/ship/stage-manifest.js +137 -14
  79. package/lib/utils/ship/stage-writer.d.ts +48 -0
  80. package/lib/utils/ship/stage-writer.js +71 -1
  81. package/lib/utils/ship/types.d.ts +264 -16
  82. package/lib/utils/ship/xml.d.ts +7 -0
  83. package/lib/utils/ship/xml.js +25 -0
  84. package/lib/utils/ship/zip.d.ts +69 -0
  85. package/lib/utils/ship/zip.js +245 -0
  86. package/lib/utils/trust-registry.js +5 -0
  87. package/package.json +19 -19
@@ -1,7 +1,16 @@
1
- // The per-format table. Everything that differs between `.deb` and `.rpm`
2
- // outside the archive container itself lives here as DATA, so a new format is
3
- // a row rather than an edit across the stager, the overlay builder and the
4
- // artifact namer.
1
+ // The per-format table. Everything that differs between the formats outside the
2
+ // archive container itself lives here as DATA, so a new format is a row rather
3
+ // than an edit across the stager, the overlay builder and the artifact namer.
4
+ //
5
+ // Flatpak is the row that proves the claim: ADR 0024 § 2 predicted "the whole
6
+ // difference between a Flatpak and an `.rpm` is a four-line prefix map", and
7
+ // this file is where that turned out to be one `prefix: '/app'`, one arch table
8
+ // and one filename. What it also proved is what the design did NOT predict —
9
+ // a format can be host-bound (§ A3), so `host` is a descriptor field now.
10
+ import { isOnPath } from '../check-system-deps.js';
11
+ import { DMG_TOOL } from './dmg.js';
12
+ import { LAYOUTS, LAYOUT_NAMES } from './layout.js';
13
+ import { SCHEMA_COMPILER, SCHEMA_COMPILER_HINT } from './schemas.js';
5
14
  // `process.arch` → the format's architecture name. Taken from dpkg's own
6
15
  // `data/cputable` and rpm's arch table. `arm` cannot be told apart from
7
16
  // `armel` by a running process, and Debian's hard-float port is what anything
@@ -32,30 +41,726 @@ function lookupArch(table, arch, label) {
32
41
  }
33
42
  return mapped;
34
43
  }
44
+ // Flatpak's own vocabulary, which is ostree's: `flatpak --supported-arches` on
45
+ // x86_64 answers `x86_64` then `i386`, and a ref is `app/<id>/<arch>/<branch>`.
46
+ // Deliberately short — a name this table does not know is refused rather than
47
+ // guessed, because the arch is not a label here, it is part of the ref the
48
+ // bundle is exported under and installed from.
49
+ const FLATPAK_ARCH = {
50
+ x64: 'x86_64',
51
+ arm64: 'aarch64',
52
+ ia32: 'i386',
53
+ arm: 'arm',
54
+ };
35
55
  function debArch(arch, archIndependent) {
36
56
  return archIndependent ? 'all' : lookupArch(DEBIAN_ARCH, arch, 'Debian');
37
57
  }
38
58
  function rpmArch(arch, archIndependent) {
39
59
  return archIndependent ? 'noarch' : lookupArch(RPM_ARCH, arch, 'RPM');
40
60
  }
61
+ /**
62
+ * Flatpak has no `noarch`, so `archIndependent` is ignored — and that is a
63
+ * measured difference, not an oversight.
64
+ *
65
+ * `all`/`noarch` exist because apt and dnf REFUSE a package whose architecture
66
+ * is not the machine's. Flatpak asks the opposite question: every ref names an
67
+ * arch, `flatpak install` resolves the one matching the host, and an app with no
68
+ * arch is not installable at all. So a payload of pure JavaScript still ships as
69
+ * `app/<id>/x86_64/stable` — which is also what Flathub does for every
70
+ * interpreted app it hosts.
71
+ */
72
+ function flatpakArch(arch, _archIndependent) {
73
+ return lookupArch(FLATPAK_ARCH, arch, 'Flatpak');
74
+ }
75
+ /**
76
+ * `process.arch` → the name Apple's tools use.
77
+ *
78
+ * Two rows, because two architectures exist: `x86_64` and `arm64` are what
79
+ * `uname -m`, `lipo -archs` and a Mach-O `cputype` all report on the machines
80
+ * macOS runs on. Anything else is REFUSED rather than passed through, for the
81
+ * reason the Flatpak table gives: an unknown value would end up in a filename a
82
+ * user is asked to download, labelling an artifact for a machine that does not
83
+ * exist. Note this is not `arm64` verbatim from `process.arch` by coincidence —
84
+ * it happens to agree, and the day `process.arch` gains a spelling Apple does
85
+ * not use, this table is where it is caught.
86
+ */
87
+ const MACOS_ARCH = { x64: 'x86_64', arm64: 'arm64' };
88
+ /**
89
+ * A `.app` carries no `noarch`, and unlike Flatpak that is not because the
90
+ * format forbids one — it is because `archIndependent` answers the wrong
91
+ * question here.
92
+ *
93
+ * `all`/`noarch` exist so apt and dnf do not refuse a pure-JavaScript package on
94
+ * a machine of another architecture. Nothing gatekeeps a `.app`: it is dragged
95
+ * into `/Applications` and run. What the label is FOR is the download a user
96
+ * picks between, and that is a claim about which Mac the bundle runs on — which,
97
+ * once M2b stages a runtime, is architecture-specific whatever the JavaScript is.
98
+ * Naming the arch on a payload that happens to be portable is honest; naming
99
+ * `noarch` on one that will not be is not.
100
+ */
101
+ function macosArch(arch, _archIndependent) {
102
+ return lookupArch(MACOS_ARCH, arch, 'macOS');
103
+ }
104
+ /**
105
+ * `process.arch` → the name Windows and everything that targets it use.
106
+ *
107
+ * ONE ROW, and it is not caution: `wingtk/gvsbuild` hardcodes
108
+ * `self.platform = "x64"` and publishes no arm64 GTK, so there is nothing to build
109
+ * `@gjsify/gtk-runtime-win32-arm64` out of and no GTK for a Windows/ARM artifact
110
+ * to load (#1117). `Layout.arches` refuses the same value one phase earlier, at
111
+ * stage time, with that reason spelled out; this table is the second half — the
112
+ * one that stops a stage assembled by an older gjsify from acquiring a label here.
113
+ *
114
+ * `x64` maps to itself, and unlike the macOS table that is not a coincidence to be
115
+ * caught later: `x64` is the spelling Node's own release archives use
116
+ * (`node-v24.20.0-win-x64.zip`), the spelling gvsbuild's assets use
117
+ * (`GTK4_Gvsbuild_<v>_x64.zip`), and the spelling a `windows-latest x64` runner
118
+ * label uses. `%PROCESSOR_ARCHITECTURE%` says `AMD64` and nothing a user downloads
119
+ * is named after it.
120
+ */
121
+ const WINDOWS_ARCH = { x64: 'x64' };
122
+ function windowsArch(arch, _archIndependent) {
123
+ return lookupArch(WINDOWS_ARCH, arch, 'Windows');
124
+ }
125
+ /**
126
+ * What a Windows program-directory name may not be.
127
+ *
128
+ * THREE RULES, not one, because Win32 forbids three different things and an
129
+ * earlier draft called the character class "the Win32 reserved set" while
130
+ * `windowsProgramDirName('CON')`, `'Demo.'` and `'Demo '` all sailed through —
131
+ * each of them producing exactly the failure this check exists to prevent:
132
+ *
133
+ * * the reserved CHARACTERS, plus the control range (a `*` or a `?` cannot be
134
+ * created or extracted at all);
135
+ * * the reserved DEVICE names, with or without an extension — `CON`, `PRN`,
136
+ * `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9` are devices at every path, so
137
+ * `CON\app\app.node.mjs` is not a path;
138
+ * * a trailing dot or space, which the Win32 API silently STRIPS — so the
139
+ * directory an installer creates is not the one the launcher's `%~dp0`
140
+ * resolves against.
141
+ *
142
+ * Refused HERE rather than on the Windows machine, because that machine is on the
143
+ * other side of a download: the artifact assembles at exit 0 on Linux, uploads,
144
+ * and fails on a stranger's box. Same shape and same reason as `layout.ts`'s
145
+ * `BUNDLE_NAME_FORBIDDEN`, one OS over — and a wider rule, because Windows
146
+ * reserves more than HFS+ does.
147
+ */
148
+ const WINDOWS_NAME_FORBIDDEN = /[<>:"/\\|?*]/;
149
+ const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
150
+ /**
151
+ * Does this name break one of the three rules?
152
+ *
153
+ * The control range is a code-point test rather than a fourth character class:
154
+ * `no-control-regex` refuses `\u0000-\u001f` inside a literal — correctly, since a
155
+ * control character in a regex is almost always a typo — and the exception here is
156
+ * that the characters ARE the subject. Saying so in code costs one comparison and
157
+ * spends no lint suppression.
158
+ */
159
+ function windowsNameIsUnusable(name) {
160
+ if (WINDOWS_NAME_FORBIDDEN.test(name) || WINDOWS_RESERVED_NAMES.test(name))
161
+ return true;
162
+ if (/[. ]$/.test(name))
163
+ return true;
164
+ return [...name].some((char) => (char.codePointAt(0) ?? 0) < 0x20);
165
+ }
166
+ /**
167
+ * The directory `windows-dir` produces, and the top level `windows-dir-zip` puts
168
+ * its entries under.
169
+ *
170
+ * ONE function with TWO readers, deliberately: the zip has to expand to the same
171
+ * directory the other row writes, and two literals would be two names a user is
172
+ * asked to believe are one.
173
+ */
174
+ export function windowsProgramDirName(settings) {
175
+ const name = settings.name;
176
+ // EMPTY FIRST, and it is not defensive: `resolveShipSettings` derives the name
177
+ // as `metadata.name ?? titleCase(binaryName)`, and `??` passes `''` straight
178
+ // through. An empty name makes `windows-dir` write into the output root itself
179
+ // and gives the zip no top level at all — which is precisely the scattering
180
+ // `windows-dir-zip` synthesises one to prevent, reproduced at exit 0 by the
181
+ // function that prevents it. Neither oracle catches it either: `verify-app-zip.sh`
182
+ // takes `basename` of the directory it is handed and would compare the archive
183
+ // against the output root.
184
+ if (name.trim() === '') {
185
+ throw new Error('gjsify ship: the windows layout has no name to call the program directory — ' +
186
+ '`gjsify.ship.name` (or the package name it is derived from) is empty. The artifact IS a ' +
187
+ 'directory and its zip expands into one, so an empty name would unpack `app\\`, `share\\` and ' +
188
+ 'the launcher loose into whatever folder the user was in.');
189
+ }
190
+ if (windowsNameIsUnusable(name)) {
191
+ throw new Error(`gjsify ship: the windows layout would put this app in a directory called "${name}", and Windows ` +
192
+ 'cannot hold it: it reserves < > : " / \\ | ? * and the control characters, reserves CON, PRN, ' +
193
+ 'AUX, NUL, COM1-9 and LPT1-9 as device names at every path, and silently strips a trailing dot ' +
194
+ 'or space.\n' +
195
+ ' The artifact would assemble here and be unextractable — or extract under a different name ' +
196
+ 'than\n' +
197
+ ' the launcher resolves against. Set `gjsify.ship.name` to the display name you want on disk.');
198
+ }
199
+ return name;
200
+ }
201
+ /**
202
+ * The tools ONE host needs for a format, whichever shape the row declares.
203
+ *
204
+ * The one place the union in {@link RequiredTools} is opened, so a caller cannot
205
+ * read the map and get `undefined.filter` or read the array on a row that has a
206
+ * map. An OS the map does not name answers `[]` — not because nothing is needed
207
+ * there, but because {@link assertHostCanFinish} has already refused that host by
208
+ * the time this is asked, and inventing a tool list for a host that cannot finish
209
+ * the format would answer a question nobody may ask.
210
+ */
211
+ export function requiredToolsOn(tools, host) {
212
+ if (Array.isArray(tools))
213
+ return tools;
214
+ return tools[host] ?? [];
215
+ }
216
+ /**
217
+ * Every tool any host could need for a format — the question `installHint`'s
218
+ * presence is gated on.
219
+ *
220
+ * Deliberately NOT what {@link assertToolsInstalled} checks. "Does this row need a
221
+ * hint at all" is a property of the ROW; "is this tool here" is a question about
222
+ * one machine, and answering the first with the second would let a row declare a
223
+ * Windows-only tool and no hint, because the gate happened to run on Linux.
224
+ */
225
+ export function allRequiredTools(tools) {
226
+ if (Array.isArray(tools))
227
+ return tools;
228
+ return Object.values(tools).flat();
229
+ }
230
+ /**
231
+ * Why a format's runtime provides the interpreters it does — one string per
232
+ * RUNTIME STORY, three stories under nine rows.
233
+ *
234
+ * These are PRINTED: `assertFormatCanRunInterpreter` puts the row's own sentence
235
+ * in front of an author whose project cannot ship this way. Per row they were
236
+ * five redundant copies of one paragraph, so a correction on one row would have
237
+ * left the two beside it telling that author something else about the same fact.
238
+ */
239
+ // Neither interpreter is restricted on `deb`/`rpm`: `Depends: gjs` and
240
+ // `Depends: nodejs` are both satisfiable from any distribution's own archive, so
241
+ // the artifact's runtime is the machine's.
242
+ const DISTRO_INTERPRETER_GAP = 'a distro package declares its interpreter as a dependency instead of carrying one, and every ' +
243
+ 'distribution ships both';
244
+ const MACOS_INTERPRETER_GAP = 'a `.app` a stranger downloads has to CARRY its interpreter, and there is no relocatable GJS to ' +
245
+ 'put in one — `packages/node-gi/scripts/build-gtk-runtime-darwin.mjs` records "GJS ships no ' +
246
+ 'relocation", which is why ADR 0024 § 4 derives Node on macOS and stage 7 is what would change it';
247
+ const WINDOWS_INTERPRETER_GAP = 'there is no GJS host on Windows at all (ADR 0024 § 4) — not a system one to depend on and not a ' +
248
+ 'relocatable one to carry — so a `--app gjs` payload cannot be shipped this way. That is why ' +
249
+ '§ 4 derives Node here, and why `@gjsify/node-runtime-win32-x64` exists';
250
+ /**
251
+ * rpm's `share/licenses/<name>/LICENSE` — eight of the nine rows.
252
+ *
253
+ * `deb` is the one that does not, and the one with a POLICY: Debian § 12.5 puts
254
+ * the copyright at `share/doc/<package>/copyright` and lintian errors without it.
255
+ * Nothing dictates a location for the other eight, so they share one shape and
256
+ * the layout map does the rest.
257
+ */
258
+ const SHARE_LICENSE_DEST = (binaryName) => `share/licenses/${binaryName}/LICENSE`;
259
+ /** `.deb` and `.rpm` are written by this tree, so they exec nothing and read back with GNU tools. */
260
+ const WRITTEN_HERE = (readWith) => ({
261
+ finishOn: 'any',
262
+ requiredTools: [],
263
+ oracle: { readWith, readOn: ['linux'], selfReading: false },
264
+ });
41
265
  export const FORMATS = {
42
266
  deb: {
43
267
  id: 'deb',
268
+ layoutOs: 'linux',
44
269
  prefix: '/usr',
270
+ host: WRITTEN_HERE(['ar', 'tar', 'dpkg-deb', 'lintian']),
271
+ depends: 'deb',
272
+ interpreters: ['gjs', 'node'],
273
+ interpreterGap: DISTRO_INTERPRETER_GAP,
45
274
  // Debian policy § 12.5: every package ships its copyright in
46
275
  // /usr/share/doc/<package>/copyright, and lintian errors without it.
47
276
  licenseDest: (binaryName) => `share/doc/${binaryName}/copyright`,
48
277
  licenseKind: 'debian-copyright',
49
278
  archName: debArch,
50
279
  fileName: (s, archLabel) => `${s.binaryName}_${s.version}-${s.release}_${archLabel}.deb`,
280
+ artifactKind: 'file',
51
281
  },
52
282
  rpm: {
53
283
  id: 'rpm',
284
+ layoutOs: 'linux',
54
285
  prefix: '/usr',
55
- licenseDest: (binaryName) => `share/licenses/${binaryName}/LICENSE`,
286
+ host: WRITTEN_HERE(['rpm', 'rpm2cpio', 'cpio']),
287
+ depends: 'rpm',
288
+ interpreters: ['gjs', 'node'],
289
+ interpreterGap: DISTRO_INTERPRETER_GAP,
290
+ licenseDest: SHARE_LICENSE_DEST,
56
291
  licenseKind: 'plain',
57
292
  archName: rpmArch,
58
293
  fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.rpm`,
294
+ artifactKind: 'file',
295
+ },
296
+ flatpak: {
297
+ id: 'flatpak',
298
+ layoutOs: 'linux',
299
+ // The one-line difference ADR 0024 § 2 promised. Nothing in the payload
300
+ // changes: the launcher derives its prefix at runtime (§ 3), so the same
301
+ // staged `bin/<name>` works under /usr and under /app.
302
+ prefix: '/app',
303
+ host: {
304
+ // A single-file bundle is an OSTree static delta, and the only writer
305
+ // of one is `flatpak build-bundle`. Unlike the `.dmg` (§ A6) that is
306
+ // not an OS restriction: flatpak runs on Linux, so the format is
307
+ // Linux-bound the way the app itself is.
308
+ finishOn: ['linux'],
309
+ requiredTools: ['flatpak-builder', 'flatpak'],
310
+ installHint: 'Fedora: `sudo dnf install flatpak flatpak-builder`, Debian/Ubuntu: ' +
311
+ '`sudo apt install flatpak flatpak-builder`',
312
+ oracle: {
313
+ // `flatpak build-import-bundle` into a FRESH repo, then
314
+ // `ostree ls -R`: ostree parses the delta this tree never wrote,
315
+ // and prints a path + mode + size per file. Measured on this
316
+ // workstation — that listing is what the e2e suite compares
317
+ // against the staged payload.
318
+ readWith: ['flatpak', 'ostree'],
319
+ readOn: ['linux'],
320
+ selfReading: false,
321
+ },
322
+ },
323
+ // A Flatpak's one dependency is its runtime, named in the manifest. See
324
+ // `DistroFormatId`: there is no `Depends:` field here to under-declare,
325
+ // which also means the unmapped-namespace refusal ADR 0024 § 6 built for
326
+ // deb and rpm has nothing to say about this format.
327
+ depends: null,
328
+ // GJS ONLY. `org.gnome.Platform` ships `gjs`; Node is a build-time SDK
329
+ // extension, not a runtime — see the field's doc on `FormatDescriptor`.
330
+ interpreters: ['gjs'],
331
+ interpreterGap: 'a Flatpak runs against `org.gnome.Platform`, which ships `gjs` and no `node`. Node exists only ' +
332
+ 'as `org.freedesktop.Sdk.Extension.node2x`, and that extension puts node on the BUILD path, not ' +
333
+ 'in the runtime — so the artifact would install and then fail at `exec node`',
334
+ licenseDest: SHARE_LICENSE_DEST,
335
+ licenseKind: 'plain',
336
+ archName: flatpakArch,
337
+ // Named after the APP ID, not the binary: the id is the ref the file
338
+ // installs as, and it is what `flatpak install ./file.flatpak` prints.
339
+ fileName: (s, archLabel) => `${s.appId}-${s.version}-${s.release}.${archLabel}.flatpak`,
340
+ artifactKind: 'file',
341
+ },
342
+ // ── macOS (#1354 M2a) ────────────────────────────────────────────────
343
+ //
344
+ // TWO ROWS FOR ONE TREE, and the pair is what `layoutOs` was added for. The
345
+ // bundle and the zip around it wrap the same staged `<App>.app`; they differ
346
+ // only in whether the artifact is a directory a user drags or a file a user
347
+ // downloads. Both are `finishOn: 'any'` — the `.dmg` that is not is the third
348
+ // row below (ADR 0024 § A6, #1354 M4).
349
+ //
350
+ // NEITHER IS SIGNED BY DEFAULT, and unsigned is a deliverable rather than an
351
+ // omission this table hides: `codesign` is macOS-only, so a bundle assembled
352
+ // on Linux is unsigned by construction and Gatekeeper quarantines it on a
353
+ // stranger's Mac. M6 has since landed and holds the other half — `gjsify ship
354
+ // --from-stage --sign <identity>` on a macOS host re-signs the whole Mach-O
355
+ // closure inside the stage (`utils/ship/signing.ts`, § A4). `SIGNERS` is keyed
356
+ // by LAYOUT, so these two rows cannot disagree about their signature.
357
+ 'macos-app': {
358
+ id: 'macos-app',
359
+ layoutOs: 'darwin',
360
+ // The bundle IS the prefix. `Contents/` hangs directly off the artifact,
361
+ // so unlike `/usr` or `/app` there is nothing above it — and unlike those
362
+ // two, the launcher does not derive this at runtime: it walks up from
363
+ // `Contents/MacOS` (see `renderAppBundleLauncher`), because a `.app` can
364
+ // be anywhere and `/Applications` is a convention, not a path.
365
+ prefix: '',
366
+ host: {
367
+ // Assembled anywhere, packed anywhere: everything here is a file copy.
368
+ finishOn: 'any',
369
+ // …with ONE exception, and it is the honest one. A non-Linux layout
370
+ // has no install step, so `gschemas.compiled` has to be produced while
371
+ // the tree is being assembled or the bundle aborts at its first
372
+ // `Gio.Settings.new()` (`utils/ship/schemas.ts`). `glib-compile-schemas`
373
+ // is GLib's own and runs on all three OSes, so declaring it does NOT
374
+ // make the format host-bound — `assertToolsInstalled` is deliberately
375
+ // separate from `assertHostCanFinish` for exactly this shape.
376
+ requiredTools: [SCHEMA_COMPILER],
377
+ installHint: SCHEMA_COMPILER_HINT,
378
+ oracle: {
379
+ // CPython's `plistlib` — a DIFFERENT implementation family from
380
+ // the XML this tree writes, and already precedent here
381
+ // (`.github/ship-oracle/verify-modes.py`). Measured on this
382
+ // workstation against a plist with a `<key>` and no value:
383
+ // `plistlib.load` exits 1 naming the line, `plistutil -i` exits 0
384
+ // and prints `<dict/>`, and `xmllint --noout` exits 0 because
385
+ // well-formedness is a weaker question. So `plistlib` is the
386
+ // oracle and the other two are not.
387
+ readWith: ['python3'],
388
+ readOn: ['linux', 'darwin'],
389
+ selfReading: false,
390
+ },
391
+ },
392
+ // No package list. macOS resolves nothing for an app: whatever the bundle
393
+ // does not carry, it finds on the machine or it does not run — which is
394
+ // what makes M2b (staging a runtime) the milestone that matters, not a
395
+ // dependency field.
396
+ depends: null,
397
+ // NODE ONLY, and it is a measured limit rather than caution.
398
+ // `packages/node-gi/scripts/build-gtk-runtime-darwin.mjs` records that GJS
399
+ // ships no relocation, so there is no relocatable GJS to put inside a
400
+ // bundle a stranger downloads (ADR 0024 § 4, stage 7). A `--app gjs`
401
+ // payload therefore cannot be shipped this way, and
402
+ // `assertFormatCanRunInterpreter` says so by name instead of producing a
403
+ // bundle that dies at `exec gjs`.
404
+ interpreters: ['node'],
405
+ interpreterGap: MACOS_INTERPRETER_GAP,
406
+ licenseDest: SHARE_LICENSE_DEST,
407
+ licenseKind: 'plain',
408
+ archName: macosArch,
409
+ // The DISPLAY name, not the binary: this artifact is the thing a user
410
+ // drags into `/Applications`, and its name is what the Finder shows.
411
+ fileName: (s) => `${s.name}.app`,
412
+ artifactKind: 'directory',
413
+ },
414
+ 'macos-app-zip': {
415
+ id: 'macos-app-zip',
416
+ layoutOs: 'darwin',
417
+ prefix: '',
418
+ host: {
419
+ finishOn: 'any',
420
+ // The ZIP is written by this tree (`utils/ship/zip.ts`), so the only
421
+ // tool here is the schema compiler the layout needs — see the row
422
+ // above. Writing the archive ourselves is what keeps `zipinfo` an
423
+ // INDEPENDENT reader rather than the other half of a `zip`/`unzip`
424
+ // round trip; same argument ADR 0024 § A3 makes for the deb and rpm.
425
+ requiredTools: [SCHEMA_COMPILER],
426
+ installHint: SCHEMA_COMPILER_HINT,
427
+ oracle: {
428
+ // `zipinfo -l`, not `unzip -Z1`. The failure that matters for a
429
+ // distributed `.app` is a launcher extracted 0644 that will not
430
+ // run, and `-Z1` prints NAMES ONLY — it is structurally blind to
431
+ // it. `zipinfo` prints the Unix mode (`-rwxr-xr-x`) and ships in
432
+ // the same `unzip` package, which is already in the CI image.
433
+ readWith: ['zipinfo'],
434
+ readOn: ['linux', 'darwin'],
435
+ selfReading: false,
436
+ },
437
+ },
438
+ depends: null,
439
+ interpreters: ['node'],
440
+ interpreterGap: MACOS_INTERPRETER_GAP,
441
+ licenseDest: SHARE_LICENSE_DEST,
442
+ licenseKind: 'plain',
443
+ archName: macosArch,
444
+ // The BINARY name here and the display name above, deliberately. This one
445
+ // is a download: it lands in a browser's downloads folder beside other
446
+ // files, so it carries the version and the arch, and it avoids the spaces
447
+ // a display name may contain.
448
+ fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.zip`,
449
+ artifactKind: 'file',
450
+ },
451
+ // ── macOS (#1354 M4) ─────────────────────────────────────────────────
452
+ //
453
+ // THE THIRD CONTAINER OVER THE SAME TREE, and the first row in this table
454
+ // whose `finishOn` is a real restriction rather than a statement about where
455
+ // the application runs. The two rows above wrap the `<App>.app` in nothing
456
+ // and in a zip, both of which this tree writes; this one wraps it in a UDIF
457
+ // image over an HFS+ volume, which nothing in this tree can write and which
458
+ // `hdiutil` — macOS-only — can. ADR 0024 § A1 is that sentence; § A6 is why
459
+ // writing one ourselves stays rejected.
460
+ 'macos-app-dmg': {
461
+ id: 'macos-app-dmg',
462
+ layoutOs: 'darwin',
463
+ // The BUNDLE is still the prefix. A `.dmg` is a volume around the same
464
+ // `<App>.app` the two rows above produce, so nothing in the payload moves
465
+ // — which is the whole claim ADR 0024 § 2 makes about a new format, and
466
+ // the third macOS row is where it stops being a prediction.
467
+ prefix: '',
468
+ host: {
469
+ // THE FIRST `finishOn` THAT IS NOT ABOUT THE APPLICATION. Flatpak is
470
+ // Linux-bound because flatpak runs on Linux; this is darwin-bound
471
+ // because the only UDIF writer in existence is on darwin. A stage
472
+ // assembled on Linux crosses with `--stage` / `--from-stage`, and
473
+ // `assertHostCanFinish`'s refusal names that route.
474
+ finishOn: ['darwin'],
475
+ // `hdiutil` ALONE, and the omission is the decision: the two rows
476
+ // above also declare `glib-compile-schemas`, which is an ASSEMBLY
477
+ // tool — `schemas.ts` runs it while the tree is staged, because a
478
+ // non-Linux layout has no install step to run it later. This is the
479
+ // first row whose pack phase is separated from its assembly by a HOST
480
+ // boundary, and `assertToolsInstalled` fires on the pack path only. So
481
+ // declaring the compiler here would refuse a `--from-stage` pack on a
482
+ // Mac with no GLib — a pack that works, because `gschemas.compiled` is
483
+ // already in the stage that arrived. The assembly-time absence is
484
+ // still caught, by `compileSchemasForStage`'s own ENOENT refusal, on
485
+ // the host that can act on it.
486
+ requiredTools: [DMG_TOOL],
487
+ // Not a package name, because there is none: `hdiutil` ships with
488
+ // macOS and exists nowhere else. `assertHostCanFinish` has already
489
+ // refused every non-darwin host by the time this can fire, so the only
490
+ // reader of this hint is on a Mac whose `/usr/bin` is broken.
491
+ installHint: '`hdiutil` is part of macOS and cannot be installed elsewhere — on a Mac it is /usr/bin/hdiutil, ' +
492
+ 'so an absent one means a broken installation rather than a missing package',
493
+ oracle: {
494
+ // NOT `hdiutil verify`, which is hdiutil reading what hdiutil
495
+ // wrote (ADR 0024 § A3 names this format as the case). THREE
496
+ // readers, all on Linux, none of them Apple's and none of them
497
+ // ours:
498
+ //
499
+ // * `7z l` — 7-Zip's own `Dmg` handler over the UDIF container,
500
+ // then its `HFS` handler over the volume inside. Measured on
501
+ // ubuntu-24.04's 7-Zip 23.01: `Dmg`, `HFS` and `APFS` are all
502
+ // in `7z i`.
503
+ // * `dmg2img` — an independent UDIF decoder that writes the raw
504
+ // volume out, so the next reader gets a filesystem rather than
505
+ // an archive listing.
506
+ // * `fsck.hfsplus` (hfsprogs) — Apple's fsck_hfs sources built
507
+ // for Linux, which walks the catalog, the extents overflow
508
+ // file and the volume bitmap. This is the reader that
509
+ // distinguishes "a file is listed" from "the volume is
510
+ // structurally sound".
511
+ //
512
+ // `readOn: ['linux']` and not darwin, deliberately: a reader on
513
+ // the packing host is worth less, and the leg that runs these is
514
+ // the bare `ubuntu-latest` one that already reads the `.deb`.
515
+ readWith: ['7z', 'dmg2img', 'fsck.hfsplus'],
516
+ readOn: ['linux'],
517
+ selfReading: false,
518
+ },
519
+ },
520
+ // No package list — macOS resolves nothing for an app, exactly as the two
521
+ // rows above record.
522
+ depends: null,
523
+ // NODE ONLY, inherited from the tree this wraps rather than decided here:
524
+ // the image carries the same bundle, and there is no relocatable GJS to
525
+ // put in one.
526
+ interpreters: ['node'],
527
+ interpreterGap: MACOS_INTERPRETER_GAP,
528
+ licenseDest: SHARE_LICENSE_DEST,
529
+ licenseKind: 'plain',
530
+ archName: macosArch,
531
+ // The BINARY name, version and arch — the zip's convention and not the
532
+ // bundle's, because this artifact is a DOWNLOAD: it lands in a browser's
533
+ // folder beside other files. The display name is what the VOLUME carries
534
+ // (`dmgVolumeName`), which is the name a user actually reads, in the
535
+ // Finder window the image mounts into.
536
+ fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.dmg`,
537
+ artifactKind: 'file',
538
+ },
539
+ // ── Windows (#1354 M3) ───────────────────────────────────────────────
540
+ //
541
+ // THE SAME PAIR ONE OS OVER, and the differences from the macOS pair are all
542
+ // the OS's rather than ours:
543
+ //
544
+ // * the DIRECTORY is not the stage root. A `<App>.app` is dragged to
545
+ // `/Applications` as one object, so `Layout.root` puts it in the stage; a
546
+ // Windows program directory is laid INTO `C:\Program Files\<Publisher>\<App>`
547
+ // by an installer that picks the parent, so the stage IS its contents
548
+ // (`layout.ts` records that asymmetry). Which means the ZIP has to
549
+ // SYNTHESISE the top level the `.app` zip inherits — without it the archive
550
+ // expands to a bare `app\`, `share\` and `<name>.cmd` scattered into
551
+ // whatever directory the user was in.
552
+ // * there is no `Info.plist` and no metadata file at all: what a Windows
553
+ // installer says about an application lives in the `.msi`'s own tables
554
+ // (#1354 M5), which is why `Layout.metadata` answers `[]` here.
555
+ // * `x64` alone, and the blocker is upstream — see `WINDOWS_ARCH`.
556
+ //
557
+ // NEITHER IS SIGNED BY DEFAULT, and Windows is the softer of the two asymmetries
558
+ // ADR 0024 § A5 records: Gatekeeper BLOCKS an unsigned `.app`, while SmartScreen
559
+ // only WARNS until per-file download reputation accrues. So an unsigned program
560
+ // directory is a usable artifact in a way an unsigned `.app` is not. `--sign`
561
+ // reaches these rows through `SIGNERS.win32` (signtool — argv unit-tested, the
562
+ // invocation itself UNVERIFIED, since no run here has ever held a certificate);
563
+ // the `.msi` around this tree is the third row below.
564
+ 'windows-dir': {
565
+ id: 'windows-dir',
566
+ layoutOs: 'win32',
567
+ // The program directory IS the prefix, and — unlike `/usr` or `/app` — the
568
+ // launcher derives it at run time from `%~dp0` rather than having it baked,
569
+ // because an installer, a portable unzip and a build tree put it in three
570
+ // different places.
571
+ prefix: '',
572
+ host: {
573
+ // Assembled anywhere, packed anywhere: everything here is a file copy.
574
+ // Windows enters at the RUNNING proof, not at the packing.
575
+ finishOn: 'any',
576
+ // The same one exception the `.app` has, for the same reason: a non-Linux
577
+ // layout has no install step, so `gschemas.compiled` is produced while
578
+ // the tree is assembled or the app aborts at its first `Gio.Settings.new()`.
579
+ requiredTools: [SCHEMA_COMPILER],
580
+ installHint: SCHEMA_COMPILER_HINT,
581
+ oracle: {
582
+ // TWO READERS, because the artifact has two halves and no single
583
+ // reader covers both.
584
+ //
585
+ // `cmd` is the authoritative one and it runs on exactly one OS:
586
+ // `cmd.exe` is the only thing that reads a batch launcher, and
587
+ // nothing on Linux parses one. That is why M3's proof is a
588
+ // `windows-latest` leg that unzips this directory and starts the
589
+ // app — assert-the-toolkit-is-absent-first, like
590
+ // `windows-batteries-included`.
591
+ //
592
+ // `python3` is the Linux-runnable half: CPython reads the PE
593
+ // headers of everything the directory CARRIES (`node.exe`, the
594
+ // gvsbuild DLL closure), which is a different implementation
595
+ // family from both PE readers in this repository — the CLI's
596
+ // `readBinaryArch` and `manifest-conformance/lib/binary.mjs`. It
597
+ // reads what the staging DID; it cannot read what the launcher
598
+ // MEANS.
599
+ readWith: ['cmd', 'python3'],
600
+ readOn: ['linux', 'win32'],
601
+ selfReading: false,
602
+ },
603
+ },
604
+ // No package list. Windows resolves nothing for an application: whatever
605
+ // the directory does not carry, it finds on the machine or it does not run
606
+ // — and on Windows "on the machine" is emptier than on macOS, since there
607
+ // is no Homebrew and no system GTK at all.
608
+ depends: null,
609
+ // NODE ONLY, and here it is not even a relocation question: there is NO GJS
610
+ // host on Windows at all (ADR 0024 § 4), so a `--app gjs` payload has
611
+ // nothing anywhere that could run it.
612
+ interpreters: ['node'],
613
+ interpreterGap: WINDOWS_INTERPRETER_GAP,
614
+ licenseDest: SHARE_LICENSE_DEST,
615
+ licenseKind: 'plain',
616
+ archName: windowsArch,
617
+ // The DISPLAY name: this artifact is the directory an installer lays down
618
+ // and a user browses to, so it is named the way the application is.
619
+ fileName: (s) => windowsProgramDirName(s),
620
+ artifactKind: 'directory',
621
+ },
622
+ 'windows-dir-zip': {
623
+ id: 'windows-dir-zip',
624
+ layoutOs: 'win32',
625
+ prefix: '',
626
+ host: {
627
+ finishOn: 'any',
628
+ requiredTools: [SCHEMA_COMPILER],
629
+ installHint: SCHEMA_COMPILER_HINT,
630
+ oracle: {
631
+ // `zipinfo -l`, the same independent reader the `.app` zip uses and
632
+ // for the same reason: this tree WRITES the archive
633
+ // (`utils/ship/zip.ts`), so a `zip`/`unzip` round trip would be the
634
+ // two halves of one package agreeing with each other.
635
+ //
636
+ // What it is asked here is a DIFFERENT question, though, and the
637
+ // difference is the OS's: a POSIX mode means nothing to Windows,
638
+ // which decides executability from the extension. What can go wrong
639
+ // with THIS archive is the top level — entries written at the root
640
+ // expand into whatever directory the user was in — so the reader is
641
+ // pointed at the names first and the modes second (the modes still
642
+ // matter to anyone unzipping the artifact on Linux or macOS to
643
+ // inspect it).
644
+ readWith: ['zipinfo'],
645
+ readOn: ['linux', 'darwin'],
646
+ selfReading: false,
647
+ },
648
+ },
649
+ depends: null,
650
+ interpreters: ['node'],
651
+ interpreterGap: WINDOWS_INTERPRETER_GAP,
652
+ licenseDest: SHARE_LICENSE_DEST,
653
+ licenseKind: 'plain',
654
+ archName: windowsArch,
655
+ // The BINARY name here and the display name above, exactly as the macOS
656
+ // pair splits them: this one is a download that lands in a browser's
657
+ // folder beside other files, so it carries the version and the arch and
658
+ // avoids the spaces a display name may contain.
659
+ fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.zip`,
660
+ artifactKind: 'file',
661
+ },
662
+ // ── The Windows installer (#1354 M5) ─────────────────────────────────
663
+ //
664
+ // The THIRD row over the windows layout, and the first in this table whose
665
+ // producer is not this tree. It wraps the same program directory the two rows
666
+ // above wrap; what it adds is the three things a directory cannot do — put
667
+ // itself somewhere, give a user something to click, and come off again.
668
+ //
669
+ // ONE AUTHORED `.wxs`, TWO COMPILERS, and that pair is the whole design (ADR
670
+ // 0024 § A6). `msitools` ships `wixl` AND `msiinfo`, so a wixl-only path would
671
+ // be our writer read back by its own package — `selfReading` with extra steps.
672
+ // Instead the document `utils/ship/msi.ts` renders is compiled by `wixl` on
673
+ // Linux and by WiX v3 on Windows, and each backend's output is read by the
674
+ // OTHER family: `msiexec` (Microsoft's own installer service) installs and RUNS
675
+ // the wixl-built file, `msiinfo` reads back the WiX-built one. Neither leg is a
676
+ // package agreeing with itself.
677
+ //
678
+ // A HAND-WRITTEN MSI STAYS REJECTED, and the reason is specific rather than
679
+ // effort. The three constraints that forced the hand-written `.deb`/`.rpm`
680
+ // writers — must run under GJS, must run offline, the CI image is Fedora — have
681
+ // no subject here: § 4 records that there is NO GJS host on Windows at all, so
682
+ // there is no sandbox in which an `.msi` would have to be produced without
683
+ // distro packages. ≈1 300 lines for nothing this milestone can spend.
684
+ //
685
+ // MSIX STAYS REJECTED until a certificate exists. An unsigned one cannot be
686
+ // installed at all, and a self-signed cert dropped into `TrustedPeople` buys a
687
+ // green leg that proves the leg trusts itself.
688
+ msi: {
689
+ id: 'msi',
690
+ layoutOs: 'win32',
691
+ // Same empty prefix as the directory it wraps, and for a sharper reason
692
+ // here: the install location is a RUNTIME decision. `INSTALLDIR` resolves
693
+ // to `%ProgramFiles%\<App>` by default and to whatever `msiexec
694
+ // INSTALLDIR=…` names otherwise, so a prefix baked at pack time would be a
695
+ // claim the installer is free to contradict. The launcher never needed one
696
+ // anyway — it derives its own from `%~dp0`.
697
+ prefix: '',
698
+ host: {
699
+ // BOTH, and that is the measured answer rather than the ADR's
700
+ // either/or. § A5 wrote "`.msi` is `'any'` if we write it and
701
+ // `['win32']` if WiX does", which was the choice between a hand-written
702
+ // writer and a WiX-only one. The third option is the one that gets an
703
+ // independent reader on both legs: two backends over one document, so
704
+ // the format is finishable on Linux AND on Windows and neither host has
705
+ // to send its stage to the other. `darwin` is deliberately absent —
706
+ // Homebrew does package `msitools`, but nothing in this repository has
707
+ // ever run it there, and a row that claims a host no leg exercises is
708
+ // the shape a `--target msi` on a Mac fails at pack time for.
709
+ finishOn: ['linux', 'win32'],
710
+ // A MAP, not a list, and `RequiredTools` exists for this row alone. The
711
+ // schema compiler is on both sides because it is the LAYOUT's, exactly
712
+ // as on the two rows above — a windows stage has no install step, so
713
+ // `gschemas.compiled` is produced while the tree is assembled or the app
714
+ // aborts at its first `Gio.Settings.new()`.
715
+ //
716
+ // `candle.exe`/`light.exe` carry their extension because `isOnPath` does
717
+ // an `existsSync(join(dir, cmd))` and appends nothing: on Windows the
718
+ // bare word `candle` is not a file, so a row spelling it that way would
719
+ // refuse a host that has WiX installed.
720
+ requiredTools: {
721
+ linux: [SCHEMA_COMPILER, 'wixl'],
722
+ win32: [SCHEMA_COMPILER, 'candle.exe', 'light.exe'],
723
+ },
724
+ installHint: `${SCHEMA_COMPILER_HINT}; the MSI compiler is Fedora: \`sudo dnf install msitools\`, ` +
725
+ 'Debian/Ubuntu: `sudo apt install msitools`, Windows: WiX Toolset v3.14 ' +
726
+ '(https://github.com/wixtoolset/wix3/releases) with its `bin` directory on PATH',
727
+ oracle: {
728
+ // TWO READERS, ONE PER BACKEND, and each reads the file the OTHER
729
+ // backend wrote — which is what makes `selfReading: false` true here
730
+ // rather than merely declared.
731
+ //
732
+ // `msiexec` is Windows Installer itself: it INSTALLS the wixl-built
733
+ // file into a prefix and the leg then runs the installed launcher.
734
+ // Installing without running would prove the database parses, not
735
+ // that it laid down something that works — and the artifact's whole
736
+ // claim is the second one.
737
+ //
738
+ // `msiinfo` reads back the WiX-built file on Linux — `tables`,
739
+ // `streams`, `export <table>`, `suminfo`. `suminfo` is what makes
740
+ // the cross-check checkable rather than assumed: it prints the
741
+ // producer (`Application: msitools 0.106.58-a155` on a wixl file),
742
+ // so a leg can assert the file in front of it was NOT written by the
743
+ // package reading it.
744
+ readWith: ['msiexec', 'msiinfo'],
745
+ readOn: ['win32', 'linux'],
746
+ selfReading: false,
747
+ },
748
+ },
749
+ // Windows resolves nothing for an application — see `windows-dir`. An
750
+ // installer does not change that; it moves the same self-contained tree.
751
+ depends: null,
752
+ interpreters: ['node'],
753
+ interpreterGap: WINDOWS_INTERPRETER_GAP,
754
+ licenseDest: SHARE_LICENSE_DEST,
755
+ licenseKind: 'plain',
756
+ archName: windowsArch,
757
+ // The BINARY name, like the zip beside it and for the same reason: this is a
758
+ // download that lands in a browser's folder next to other files, so it
759
+ // carries the version and the arch and avoids the spaces a display name may
760
+ // contain. The DISPLAY name is what the installer lays down INSIDE — see
761
+ // `windowsProgramDirName`, which both this row and `windows-dir` call.
762
+ fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.msi`,
763
+ artifactKind: 'file',
59
764
  },
60
765
  };
61
766
  // DERIVED, never a second list. `FORMATS` is `Record<FormatId, …>`, so the
@@ -63,12 +768,93 @@ export const FORMATS = {
63
768
  // back inherits that guarantee for free. Written out by hand this was the one
64
769
  // unbound copy of the vocabulary: adding a format to `FormatId` and `FORMATS`
65
770
  // 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.
771
+ // missing from `--help` and REFUSED by `readStage` as an unknown id.
68
772
  // Insertion order is stable for string keys, and `resolveFormats` sorts anyway.
69
773
  export const FORMAT_IDS = Object.keys(FORMATS);
70
- /** Parse `--target deb,rpm` into a sorted, deduplicated descriptor list. */
71
- export function resolveFormats(raw) {
774
+ /**
775
+ * What `gjsify ship <os>` builds when nothing said otherwise — every format that
776
+ * wraps THAT OS's layout and needs no tool and no particular host.
777
+ *
778
+ * A SECOND derivation from the same table, and it has to be one: `FORMAT_IDS`
779
+ * used to be both "every format that exists" and "every format to build by
780
+ * default", which was the same list only while every format was `finishOn:
781
+ * 'any'`. Adding Flatpak to `FORMAT_IDS` alone would have made a bare
782
+ * `gjsify ship` demand `flatpak-builder` of every project that ever packaged a
783
+ * `.deb` — and on a host without it, of every `release-cut.yml` run, which
784
+ * packs `@gjsify/cli` itself on a bare ubuntu runner. A host-bound format is
785
+ * opt-in through `--target` or `gjsify.ship.targets`; `--help` still lists all
786
+ * of them.
787
+ *
788
+ * `layoutOs` is the SECOND criterion, and it is what the layout axis needed: a
789
+ * `.app` and its zip and a Windows program directory are all `finishOn: 'any'`,
790
+ * so on the one criterion a bare `gjsify ship` on Linux would start emitting
791
+ * five artifacts (ADR 0024 issue #1354, open question 3). Answered with § A2's
792
+ * positional rather than instead of it — the positional picks the layout, this
793
+ * picks the formats that wrap it, and `gjsify ship` on Linux keeps emitting
794
+ * exactly `deb` and `rpm`.
795
+ *
796
+ * An EMPTY answer is legal and is what darwin and windows give today: their
797
+ * layouts assemble (`--stage`) and no format wraps them yet. `assemble` refuses
798
+ * a pack with nothing to pack rather than exiting 0 having produced no artifact.
799
+ */
800
+ export function defaultFormatIds(layoutOs) {
801
+ return FORMAT_IDS.filter((id) => FORMATS[id].layoutOs === layoutOs && FORMATS[id].host.finishOn === 'any');
802
+ }
803
+ /** `process.platform` token → the positional that selects it, so a message says `windows`, not `win32`. */
804
+ const LAYOUT_NAME_BY_OS = Object.fromEntries(LAYOUT_NAMES.map((name) => [LAYOUTS[name].os, name]));
805
+ /** Every format that wraps one OS's layout, tool-bound ones included. */
806
+ export function formatIdsFor(layoutOs) {
807
+ return FORMAT_IDS.filter((id) => FORMATS[id].layoutOs === layoutOs);
808
+ }
809
+ /**
810
+ * Refuse a format this host cannot finish, BEFORE anything is built or written.
811
+ *
812
+ * `host` is injected so both branches are unit-testable from any machine — the
813
+ * same reason `resolvePrebuildDirName` and `checkTypeSkew`'s readers are pure.
814
+ * The message names the two-phase split rather than just saying no: the answer
815
+ * to "I am on macOS and want a Flatpak" is `--stage` here and `--from-stage` on
816
+ * a Linux runner, and a refusal that does not say so reads as "unsupported".
817
+ */
818
+ export function assertHostCanFinish(format, host = process.platform) {
819
+ const { finishOn } = format.host;
820
+ if (finishOn === 'any' || finishOn.includes(host))
821
+ return;
822
+ throw new Error(`gjsify ship: a ${format.id} artifact is packed on ${finishOn.join(' or ')} and ` +
823
+ `this host is ${host} (ADR 0024 § A1: a container is produced where its format's tool lives). ` +
824
+ 'Assembly is not host-bound, so the way across is the two-phase split: run ' +
825
+ '`gjsify ship --stage` here, move `ship/stage/` to a ' +
826
+ `${finishOn.join('/')} runner, and finish with ` +
827
+ `\`gjsify ship --from-stage <dir> --target ${format.id}\`.`);
828
+ }
829
+ /**
830
+ * Refuse a format whose tools are not installed, with the tool named.
831
+ *
832
+ * Separate from {@link assertHostCanFinish} because the two failures have
833
+ * different fixes — one needs a different machine, the other needs a package —
834
+ * and separate from the packer's own `notFound` handler because it fires BEFORE
835
+ * a build: `gjsify ship` runs the project's `build` script first, and finding
836
+ * out afterwards that `flatpak-builder` is absent costs the whole build.
837
+ */
838
+ export function assertToolsInstalled(format, present = isOnPath, host = process.platform) {
839
+ const tools = requiredToolsOn(format.host.requiredTools, host);
840
+ const missing = tools.filter((tool) => !present(tool));
841
+ if (missing.length === 0)
842
+ return;
843
+ // The install instruction is the DESCRIPTOR's, not this function's: hardcoded
844
+ // here it was `dnf install flatpak flatpak-builder` for every format that
845
+ // will ever need a tool, which is the branch this table exists to avoid.
846
+ const hint = format.host.installHint;
847
+ throw new Error(`gjsify ship: packing a ${format.id} on ${host} needs ${tools.join(' and ')}, and ` +
848
+ `${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not on PATH. ` +
849
+ `${hint === undefined ? 'Install it' : `Install it (${hint})`}, or drop this target — the other ` +
850
+ `formats need no tools at all. \`gjsify ship --stage\` also works without ${missing.join('/')}: ` +
851
+ 'the payload is assembled by this CLI and only the container needs them.');
852
+ }
853
+ /**
854
+ * The shared half: names in, `FormatId`s out, with the two refusals that hold for
855
+ * every caller.
856
+ */
857
+ function parseFormatNames(raw, source) {
72
858
  const names = raw
73
859
  .flatMap((entry) => entry.split(','))
74
860
  .map((entry) => entry.trim())
@@ -78,14 +864,86 @@ export function resolveFormats(raw) {
78
864
  // fired — the run then died somewhere unrelated.
79
865
  const unknown = names.filter((name) => !Object.hasOwn(FORMATS, name));
80
866
  if (unknown.length > 0) {
81
- throw new Error(`gjsify ship: unknown target${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}. ` +
867
+ throw new Error(`gjsify ship: unknown target${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')} in ${source}. ` +
82
868
  `Known targets: ${FORMAT_IDS.join(', ')}.`);
83
869
  }
84
870
  const unique = [...new Set(names)].sort();
85
871
  // An empty list would otherwise stage the payload and pack nothing, exit 0,
86
872
  // and print no artifact line — a success that produced no artifact.
87
873
  if (unique.length === 0) {
88
- throw new Error(`gjsify ship: --target named no format. Known targets: ${FORMAT_IDS.join(', ')}.`);
874
+ throw new Error(`gjsify ship: ${source} named no format. Known targets: ${FORMAT_IDS.join(', ')}.`);
875
+ }
876
+ return unique;
877
+ }
878
+ /**
879
+ * Parse an explicit `--target deb,rpm` into a sorted, deduplicated descriptor list.
880
+ *
881
+ * A format belonging to another layout is an ERROR here, not a filter:
882
+ * `gjsify ship darwin --target deb` is a mistake worth naming, and dropping it
883
+ * silently would stage the darwin layout, pack nothing and exit 0 — the shape
884
+ * the empty-list refusal above already exists to prevent, arriving through a
885
+ * different door.
886
+ *
887
+ * The FLAG is what makes that the right answer, and it is the whole difference
888
+ * from {@link configuredFormats}: a value typed on this command line is a claim
889
+ * about THIS run.
890
+ */
891
+ export function resolveFormats(raw, layout) {
892
+ const unique = parseFormatNames(raw, '--target');
893
+ const foreign = unique.filter((name) => FORMATS[name].layoutOs !== layout.os);
894
+ if (foreign.length > 0) {
895
+ const wrap = formatIdsFor(layout.os);
896
+ // Deduplicated: two formats wrapping the same layout read as
897
+ // "the linux/linux layout" without it.
898
+ const foreignLayouts = [...new Set(foreign.map((name) => FORMATS[name].layoutOs))];
899
+ throw new Error(`gjsify ship: ${foreign.join(', ')} ${foreign.length > 1 ? 'wrap' : 'wraps'} the ` +
900
+ `${foreignLayouts.join('/')} layout and this run assembles the ${layout.name} one. ` +
901
+ // The empty branch is unreachable from the three layouts that exist
902
+ // — all of them have formats as of #1354 M3 — and is kept for the
903
+ // day a fourth is added before a `FORMATS` row wraps it, which is
904
+ // the state windows was in between M1 and M3.
905
+ (wrap.length === 0
906
+ ? `No format wraps the ${layout.name} layout — \`gjsify ship ${layout.name} --stage\` ` +
907
+ 'assembles it and stops.'
908
+ : `Formats for this layout: ${wrap.join(', ')}.`) +
909
+ // One suggestion PER foreign layout. `foreign[0]`'s layout was
910
+ // being offered for all of them, two lines under the dedup that
911
+ // exists precisely because several are anticipated — so a mixed
912
+ // `--target` would have printed a command that fails the same way.
913
+ ` To build ${foreign.join(', ')}, name the layout each wraps: ` +
914
+ `${foreignLayouts
915
+ .map((os) => `\`gjsify ship ${LAYOUT_NAME_BY_OS[os] ?? os} --target ` +
916
+ `${foreign.filter((name) => FORMATS[name].layoutOs === os).join(',')}\``)
917
+ .join(', ')}.`);
89
918
  }
90
919
  return unique.map((name) => FORMATS[name]);
91
920
  }
921
+ /**
922
+ * The same names from `gjsify.ship.targets`, FILTERED to the current layout.
923
+ *
924
+ * A configured list is a project-level DEFAULT — "when I ship this, build these"
925
+ * — written once and read by every run, so it cannot be a claim about a layout
926
+ * the author had not heard of when they wrote it. Refusing it the way
927
+ * {@link resolveFormats} refuses a flag makes the new positional unusable in
928
+ * every project that has the key, and this repository is the proof: with
929
+ * `targets: ["deb", "rpm"]` in `packages/infra/cli/package.json`,
930
+ * `gjsify ship darwin --stage` exited 1 telling the author to run
931
+ * `gjsify ship darwin --stage`. There was no `--target` value that got a darwin
932
+ * stage out of such a project at all.
933
+ *
934
+ * Filtering is safe HERE and only here, because an empty result is not a silent
935
+ * success: `assemble` refuses a PACK with nothing to pack, and a `--stage` run
936
+ * legitimately wants exactly this — assemble the layout, wrap nothing.
937
+ */
938
+ export function configuredFormats(raw, layout) {
939
+ const named = parseFormatNames(raw, '`gjsify.ship.targets`');
940
+ const kept = named.filter((name) => FORMATS[name].layoutOs === layout.os);
941
+ // The dropped names are RETURNED rather than swallowed. The `--target` path
942
+ // refuses by name; this one silently produced a shorter list, so a project
943
+ // whose configured formats all belong to another layout got a stage and no
944
+ // explanation of why nothing was packed.
945
+ return {
946
+ formats: kept.map((name) => FORMATS[name]),
947
+ dropped: named.filter((name) => FORMATS[name].layoutOs !== layout.os),
948
+ };
949
+ }