@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
@@ -14,39 +14,70 @@
14
14
  // which is what a developer on one machine wants and what the e2e suite
15
15
  // compares the split against byte for byte.
16
16
  //
17
- // Scope today is Linux and `--app gjs`: no runtime is bundled, because GJS and
18
- // GTK come from the distribution and shipping ~100 MiB of them would be cargo
19
- // cult (ADR 0024 § 4). macOS and Windows layouts, and Flatpak as a target under
20
- // this command, are the later stages of the same ADR — and they are the reason
21
- // the split exists at all: a `.dmg` is a UDIF image over a real HFS+/APFS
22
- // volume and no Linux host in this tree can write one (§ A1).
23
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ // THREE LAYOUTS AND NINE FORMATS as of #1354 M6, which is what the two-phase
18
+ // split was built for. A Linux artifact bundles no runtime — GJS and GTK come
19
+ // from the distribution and shipping ~100 MiB of them would be cargo cult (ADR
20
+ // 0024 § 4) — while a `.app` and a Windows program directory CARRY theirs, since
21
+ // neither OS ships one (`utils/ship/app-runtime.ts`). And the split earns itself
22
+ // on the two rows that are host-bound: a `.dmg` is a UDIF image over a real
23
+ // HFS+/APFS volume that no Linux host in this tree can write (§ A1), and a
24
+ // signature is made where the identity lives (§ A14).
25
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
24
26
  import { basename, dirname, join, relative, resolve } from 'node:path';
25
27
  import { Config } from '../config.js';
26
28
  import { renderDesktopEntry, renderMetainfoApp, renderMetainfoCli, validateAppMetadata, } from '../utils/app-metadata.js';
27
29
  import { readPackageJson } from '../utils/pkg-json.js';
28
30
  import { describeExit, spawnToCompletion } from '../utils/spawn.js';
29
31
  import { buildDeb } from '../utils/ship/deb.js';
30
- import { deriveDepends, warnAboutGjsFloor } from '../utils/ship/depends.js';
32
+ import { deriveDepends, warnAboutGjsFloor, warnAboutNodeFloor } from '../utils/ship/depends.js';
33
+ import { isGtkRuntimeTarget, stageAppRuntime } from '../utils/ship/app-runtime.js';
31
34
  import { discoverPayload } from '../utils/ship/discover.js';
32
- import { resolveFormats, FORMAT_IDS } from '../utils/ship/formats.js';
35
+ import { buildDmgImage, dmgVolumeDir, dmgVolumeName } from '../utils/ship/dmg.js';
36
+ import { buildFlatpakBundle } from '../utils/ship/flatpak.js';
37
+ import { localizeMetadata } from '../utils/ship/localize-metadata.js';
38
+ import { assertHostCanFinish, assertToolsInstalled, configuredFormats, defaultFormatIds, formatIdsFor, resolveFormats, windowsProgramDirName, FORMAT_IDS, FORMATS, } from '../utils/ship/formats.js';
39
+ import { assertLayoutSupportsArch, hostLayout, layoutForOs, place, placeStage, resolveLayout, LAYOUT_NAMES, } from '../utils/ship/layout.js';
40
+ import { isNodeRuntimeTarget, resolveNodeRuntime } from '../utils/ship/node-runtime.js';
41
+ import { compileSchemasForStage } from '../utils/ship/schemas.js';
42
+ import { buildMsi, MSI_PAYLOAD_DIR } from '../utils/ship/msi.js';
43
+ import { buildZip, zipEntriesFromPayload } from '../utils/ship/zip.js';
33
44
  import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
34
- import { assertPayloadMatchesArch, buildTimestamp, isArchIndependent, readPayloadFacts, } from '../utils/ship/payload.js';
45
+ import { assertLauncherMatchesInterpreter, assertPayloadMatchesArch, buildTimestamp, isArchIndependent, linuxInstallDependent, readPayloadFacts, } from '../utils/ship/payload.js';
35
46
  import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/plan.js';
47
+ import { assertHostCanSign, notarizeArtifact, resolveNotaryPlan, resolveSignPlan, signPayload, } from '../utils/ship/signing.js';
36
48
  import { renderLauncher } from '../utils/ship/launcher.js';
37
49
  import { buildRpm } from '../utils/ship/rpm.js';
38
50
  import { declaredBundlePath, resolveShipSettings } from '../utils/ship/settings.js';
39
- import { assertExpectedTarget, assertFormatsStaged, formatTarget, overlayFiles, readStageManifest, resolveStageMtime, warnOnToolSkew, writeStageManifest, } from '../utils/ship/stage-manifest.js';
40
- import { readStage, writeStage } from '../utils/ship/stage-writer.js';
51
+ import { assertExpectedTarget, assertFormatsStaged, formatTarget, overlayFiles, readStageManifest, resolveStageMtime, warnOnToolSkew, writeStageManifest, STAGE_MANIFEST_FILE, } from '../utils/ship/stage-manifest.js';
52
+ import { directorySize, readStage, writePayload, writeStage } from '../utils/ship/stage-writer.js';
41
53
  const LOG = '[gjsify ship]';
42
54
  export const shipCommand = {
43
- command: 'ship',
55
+ command: 'ship [os]',
44
56
  description: `Build installable artifacts (${FORMAT_IDS.join(', ')}) from the current project.`,
57
+ // `defaultFormatIds` in the flag text, `FORMAT_IDS` in the description:
58
+ // one of them answers "what can this build", the other "what will it build
59
+ // if I say nothing", and they stopped being the same list when a host-bound
60
+ // format arrived — and then stopped again when a second layout did.
45
61
  builder: (yargs) => yargs
62
+ .positional('os', {
63
+ type: 'string',
64
+ // Deliberately no yargs `choices`: `resolveLayout` accepts
65
+ // `win32` beside `windows` — the spelling `--expect-target`
66
+ // prints — and says so when it refuses, which a `choices` list
67
+ // cannot.
68
+ description: `Operating system whose LAYOUT to assemble (${LAYOUT_NAMES.join('|')}, ADR 0024 § A2). ` +
69
+ 'Default: this host. Assembly is not host-bound, so any of them can be staged anywhere.',
70
+ })
46
71
  .option('target', {
47
72
  type: 'string',
48
73
  array: true,
49
- description: `Formats to build, comma-separated or repeated. Default: ${FORMAT_IDS.join(',')}.`,
74
+ description: 'Formats to build, comma-separated or repeated. Default: every format wrapping the ' +
75
+ `target OS's layout that needs no extra tooling — on linux, ` +
76
+ `${defaultFormatIds('linux').join(',')}. Also available for linux, and opt-in because it ` +
77
+ 'needs tooling this CLI does not carry: ' +
78
+ `${formatIdsFor('linux')
79
+ .filter((id) => !defaultFormatIds('linux').includes(id))
80
+ .join(', ')}.`,
50
81
  })
51
82
  .option('out', {
52
83
  type: 'string',
@@ -77,11 +108,46 @@ export const shipCommand = {
77
108
  .option('arch', {
78
109
  type: 'string',
79
110
  description: 'Target architecture in `process.arch` spelling. Default: this host.',
111
+ })
112
+ .option('sign', {
113
+ type: 'string',
114
+ // `requiresArg`, and it is not tidiness. `-` is the RESERVED
115
+ // ad-hoc identity — the one value of this flag that needs no
116
+ // developer identity and the only one this project's own CI can
117
+ // use — and yargs-parser treats a lone `-` as a positional by
118
+ // convention (stdin), so `--sign -` parsed as `sign: ''` plus a
119
+ // stray `-` and the strict-mode refusal was "Unknown argument: -".
120
+ // Measured on the first cut of this flag. The cost is that a bare
121
+ // `--sign` with no value is now "Not enough arguments following"
122
+ // rather than the empty-identity skip; that skip stays reachable
123
+ // through `--sign ''` and through the config key, which is where
124
+ // an unset CI variable actually lands.
125
+ requiresArg: true,
126
+ // An IDENTITY, never a certificate (ADR 0024 § A12): `codesign`
127
+ // and `signtool` are both handed a NAME and look the private key
128
+ // up themselves, so this command is never given a secret and has
129
+ // nothing to redact. Deliberately no `default:` — a yargs default
130
+ // is indistinguishable from a value the user typed and would
131
+ // clobber `gjsify.ship.sign.<os>.identity`.
132
+ description: 'Sign the payload with this identity, e.g. a Developer ID name or SHA-1 fingerprint. ' +
133
+ '`-` signs ad-hoc and needs no developer identity. Default: ' +
134
+ '`gjsify.ship.sign.<darwin|win32>.identity`; absent means unsigned, which is a ' +
135
+ 'legitimate output.',
136
+ })
137
+ .option('notarize', {
138
+ type: 'string',
139
+ // Same reason as `--sign` one option up, applied for consistency
140
+ // rather than for a measured case: a credential that begins with
141
+ // a dash is not a shape anybody should have to think about.
142
+ requiresArg: true,
143
+ description: 'Submit the signed artifact to Apple with this `notarytool` keychain profile. Needs ' +
144
+ '--sign; darwin only.',
80
145
  })
81
146
  .option('verbose', {
82
147
  type: 'boolean',
83
148
  default: false,
84
- description: 'Print each staged file and the GI namespaces the bundle imports.',
149
+ description: 'Print each staged file, the GI namespaces the bundle imports, and every tool a ' +
150
+ 'host-bound packer invokes.',
85
151
  }),
86
152
  handler: async (args) => {
87
153
  // The two phases share nothing but `packOne`, and the branch is first
@@ -99,6 +165,10 @@ export const shipCommand = {
99
165
  */
100
166
  async function assemble(args) {
101
167
  const projectDir = process.cwd();
168
+ // FIRST, because everything below is decided per layout: which formats
169
+ // exist, which build target the launcher can run, and where every staged
170
+ // file lands. A bad positional must not cost a build.
171
+ const layout = args.os === undefined ? hostLayout() : resolveLayout(args.os);
102
172
  const config = new Config();
103
173
  // No `.catch(() => ({}))` here, unlike the flatpak commands: a config
104
174
  // file that fails to load would silently become "no config", and the
@@ -108,10 +178,94 @@ async function assemble(args) {
108
178
  const ship = configData.ship ?? {};
109
179
  const flatpak = configData.flatpak ?? {};
110
180
  assertShippableTarget(configData.app);
181
+ // Resolved HERE and not from `settings`, because the format list is decided
182
+ // before anything is built and `resolveShipSettings` runs after. The
183
+ // defaulting rule is the one `resolveShipSettings` states and must stay the
184
+ // same one: an undeclared target means `gjs`, never the host runtime this
185
+ // command happens to run under.
186
+ const interpreter = configData.app === 'node' ? 'node' : 'gjs';
111
187
  const pkg = (readPackageJson(join(projectDir, 'package.json')) ?? {});
112
188
  // Resolved before anything is built or written: a typo'd `--target`
113
189
  // should not cost a build and leave a half-written `ship/stage/`.
114
- const formats = resolveFormats(args.target ?? ship.targets ?? FORMAT_IDS);
190
+ //
191
+ // THREE sources, three meanings, and collapsing them was a real defect. A
192
+ // `--target` is a claim about THIS run, so a format belonging to another
193
+ // layout is refused by name. `gjsify.ship.targets` is a project-level
194
+ // DEFAULT written once, so it is filtered to this layout instead — passing it
195
+ // through the strict path made `gjsify ship darwin --stage` exit 1 inside
196
+ // this very repository, whose `packages/infra/cli/package.json` declares
197
+ // `targets: ["deb", "rpm"]`, with a message telling the author to run the
198
+ // command they had just run. And the derived default can legitimately be
199
+ // EMPTY, which `resolveFormats` refuses — rightly, for a list a caller typed.
200
+ let formats;
201
+ /** Formats that wrap this layout and cannot run this project — see the derived branch. */
202
+ let unusable = [];
203
+ if (args.target !== undefined) {
204
+ formats = resolveFormats(args.target, layout);
205
+ }
206
+ else if (ship.targets !== undefined) {
207
+ const configured = configuredFormats(ship.targets, layout);
208
+ formats = configured.formats;
209
+ // SAID, not swallowed. The `--target` path refuses a foreign format by
210
+ // name; this one used to shorten the list in silence, so a project whose
211
+ // configured formats all wrap another layout got a stage and no account
212
+ // of where its targets went.
213
+ if (configured.dropped.length > 0) {
214
+ console.log(`${LOG} \`gjsify.ship.targets\` names ${configured.dropped.join(', ')}, which wrap another ` +
215
+ `layout — not built for ${layout.name}. Pass --target to ask for one explicitly.`);
216
+ }
217
+ }
218
+ else {
219
+ // FILTERED BY THE INTERPRETER, and the third of the same shape as the two
220
+ // branches above: a derived default is not a claim anybody made, so a
221
+ // format this project cannot use is dropped rather than refused. It has to
222
+ // be — every format wrapping the darwin layout is `interpreters: ['node']`
223
+ // (there is no relocatable GJS to put in a bundle), so without this
224
+ // `gjsify ship darwin --stage` began exiting 1 on a `--app gjs` project,
225
+ // which is every project this command has today. Measured on the first cut
226
+ // of these rows: the whole audience of M1 lost the ability to assemble the
227
+ // layout M1 added, over a format none of them had asked for.
228
+ const wraps = defaultFormatIds(layout.os).map((id) => FORMATS[id]);
229
+ formats = wraps.filter((format) => format.interpreters.includes(interpreter));
230
+ unusable = wraps.filter((format) => !format.interpreters.includes(interpreter));
231
+ // SAID, like the layout filter one branch up. A stage whose `formats` is
232
+ // empty for this reason looks identical to one whose layout nothing wraps,
233
+ // and the two need different next steps.
234
+ if (unusable.length > 0) {
235
+ console.log(`${LOG} ${unusable.map((format) => format.id).join(', ')} wrap the ${layout.name} layout but ` +
236
+ `cannot run \`gjsify.app: "${interpreter}"\` — ${unusable[0]?.interpreterGap ?? ''}. ` +
237
+ 'Assembling the layout only; pass --target to be told the same thing as an error.');
238
+ }
239
+ }
240
+ if (!args.stage)
241
+ assertPackable(formats, layout, args.os === undefined ? 'host' : 'positional', unusable);
242
+ // BEFORE the build, beside `assertCanPack` and for the same reason: a
243
+ // `--sign` this host cannot honour — wrong OS, no tool, a layout that has no
244
+ // signature at all — is knowable up front, and discovering it after the
245
+ // project's own build has run costs the whole build for a refusal nothing
246
+ // learned from building.
247
+ //
248
+ // `ship.sign` is read HERE and nowhere on the `--from-stage` path, and that
249
+ // asymmetry is § A14's amendment to § A1: a format declares where it can be
250
+ // packed, the RUN declares what it can sign with. The finishing host has no
251
+ // project to read a default out of, so there it is the flag or nothing.
252
+ const sign = resolveSignPlan({ flag: args.sign, config: ship.sign, layoutOs: layout.os });
253
+ const notarize = resolveNotaryPlan({ flag: args.notarize, layoutOs: layout.os, sign });
254
+ if (args.stage)
255
+ assertNothingToSignYet(args);
256
+ if (!args.stage) {
257
+ announceSigning(sign, notarize);
258
+ if (sign.kind === 'sign')
259
+ assertHostCanSign(sign.signer);
260
+ }
261
+ // BEFORE the build, not before the pack. `gjsify ship` runs the project's
262
+ // own `build` script first, so discovering a missing `flatpak-builder`
263
+ // afterwards costs the whole build for a refusal that was knowable up
264
+ // front. Skipped under `--stage`, which is precisely the phase that does
265
+ // NOT need the format's tooling — that asymmetry is the point of the split.
266
+ if (!args.stage)
267
+ for (const format of formats)
268
+ assertCanPack(format);
115
269
  if (!args['skip-build'])
116
270
  await runProjectBuild(projectDir);
117
271
  const discovered = discoverPayload({
@@ -126,11 +280,18 @@ async function assemble(args) {
126
280
  pkg,
127
281
  ship,
128
282
  flatpak,
129
- cli: { outDir: args.out, arch: args.arch },
283
+ cli: { outDir: args.out, arch: args.arch, layoutOs: layout.os },
130
284
  discovered,
285
+ app: configData.app,
131
286
  });
132
287
  for (const warning of warnings)
133
288
  console.warn(`${LOG} ${warning}`);
289
+ // BEFORE anything is staged, and NOT under `assertCanPack` (which `--stage`
290
+ // deliberately skips): a stage whose launcher execs an interpreter the target
291
+ // runtime does not have is already wrong, and it is the stage that crosses to
292
+ // the packing host.
293
+ for (const format of formats)
294
+ assertFormatCanRunInterpreter(format, settings.app);
134
295
  const mtime = buildTimestamp(settings.bundlePath);
135
296
  const metadataInputs = {
136
297
  appId: settings.appId,
@@ -155,21 +316,125 @@ async function assemble(args) {
155
316
  console.log(`${LOG} licence text from ${relative(projectDir, settings.licenseFile)} ` +
156
317
  '(this package carries none of its own)');
157
318
  }
158
- const stageInputs = {
159
- bundleFiles: discovered.bundleFiles,
160
- launcher: renderLauncher(settings, basename(settings.bundlePath)),
319
+ // Rendered in English, then TRANSLATED with the catalogues this package
320
+ // already stages. Without this step the two halves never met: `share/locale/`
321
+ // shipped every `.mo` while `Name=` and `<name>` stayed English, so a fully
322
+ // translated app showed an English entry in the app menu and in Software.
323
+ // Both files stay valid either way, which is why nobody reported it.
324
+ const translated = localizeMetadata({
161
325
  metainfo: settings.kind === 'app' ? renderMetainfoApp(metadataInputs) : renderMetainfoCli(metadataInputs),
162
326
  desktopEntry: settings.kind === 'app' ? renderDesktopEntry(metadataInputs) : undefined,
327
+ }, settings.localeFiles);
328
+ // BEFORE the launcher is rendered, because the launcher's exec line and its two
329
+ // locators are functions of what the stage carries (#1354 M2b). Nothing is
330
+ // resolved for a layout whose interpreter is a package dependency: on Linux the
331
+ // `.deb`/`.rpm` `Depends:` is the promise that `node` or `gjs` is on `PATH`, and
332
+ // carrying a second copy inside the payload would be a private interpreter no
333
+ // distribution update ever patches.
334
+ const runtime = resolveCarriedRuntime(layout, settings, projectDir);
335
+ const stageInputs = {
336
+ bundleFiles: discovered.bundleFiles,
337
+ launcher: renderLauncher(settings, basename(settings.bundlePath), layout, runtime.launcher),
338
+ metainfo: translated.metainfo,
339
+ desktopEntry: translated.desktopEntry,
163
340
  licenseText: settings.licenseFile === undefined ? undefined : readFileSync(settings.licenseFile, 'utf-8'),
164
341
  };
165
342
  const outRoot = resolve(projectDir, settings.outDir);
166
343
  const stageDir = join(outRoot, 'stage');
167
- const staged = planStage(settings, stageInputs);
344
+ // Planned once, in the prefix-relative shape, then PLACED. Keeping the two
345
+ // steps apart is what makes "one payload, three layouts" a checkable claim
346
+ // rather than a slogan: the plan is the payload, `placeStage` is the layout
347
+ // map, and `tests/e2e/ship-layout` asserts the three file sets agree modulo
348
+ // exactly that map.
349
+ //
350
+ // The ONE prefix-relative addition a layout makes, and it is not a second
351
+ // plan: a layout with no install step has to carry `gschemas.compiled`,
352
+ // because every launcher points XDG_DATA_DIRS at the staged `share/` and
353
+ // GSettings ABORTS on a schema directory that holds only sources. On Linux the
354
+ // `.deb`/`.rpm` postinst compiles the SYSTEM directory at install time
355
+ // (`utils/ship/scripts.ts`), where our schemas merge with every other
356
+ // package's — shipping a prebuilt cache there would be a file the install step
357
+ // overwrites. So the compile is conditional on the layout and nothing else.
358
+ const planned = [
359
+ ...planStage(settings, stageInputs),
360
+ ...(layout.os === 'linux'
361
+ ? []
362
+ : await compileSchemasForStage({
363
+ schemaFiles: settings.schemaFiles,
364
+ workDir: join(outRoot, 'schemas'),
365
+ })),
366
+ ];
367
+ const staged = placeStage(layout, settings, planned, runtime.files);
168
368
  writeStage(stageDir, staged);
169
- console.log(`${LOG} staged ${staged.length} file(s) in ${relative(projectDir, stageDir)}/`);
369
+ console.log(`${LOG} staged ${staged.length} file(s) for ${layout.name} in ${relative(projectDir, stageDir)}/`);
170
370
  if (args.verbose)
171
371
  for (const file of staged)
172
372
  console.log(`${LOG} ${file.path}`);
373
+ // Read the tree back and hold the payload against the label, HERE and not
374
+ // only in `packOne`. That check has always existed, one phase to the right,
375
+ // where it guards the artifact — and this is the first milestone in which the
376
+ // STAGE is the deliverable: darwin and windows have no packer, so a stage
377
+ // assembled `--arch x64` from an arm64 payload reached `--expect-target
378
+ // darwin-x64` with nothing having compared the two. Measured before this
379
+ // line: exit 0, `target: {os: "darwin", arch: "x64"}`, and a Mach-O
380
+ // `cputype` of arm64 inside it.
381
+ //
382
+ // It costs a second read of the payload on the one-shot Linux path, where
383
+ // `packOne` reads it again. That is the price of checking the tree that is
384
+ // shipped rather than the bytes that happen to be in memory, and it is the
385
+ // same reason `readStage` exists at all.
386
+ assertPayloadMatchesArch(readStage([stageDir], staged), settings.arch);
387
+ // What the map carried into a layout that has no install step for it.
388
+ //
389
+ // NOT a warning about this command's output being wrong — the files are the
390
+ // payload's and belong to it. It is the half the layout equality cannot see:
391
+ // three of these are only correct on Linux because a `.deb`/`.rpm` scriptlet
392
+ // compiles or reindexes them at install, and two are freedesktop metadata
393
+ // neither OS reads. Printed so the author of a `.app` knows before the
394
+ // container exists to decide what they become (ADR 0024 stages 4 and 5).
395
+ if (layout.os !== 'linux') {
396
+ const carried = linuxInstallDependent(planned);
397
+ if (carried.length > 0) {
398
+ console.warn(`${LOG} the ${layout.name} layout carries ${carried.length} file(s) whose Linux correctness ` +
399
+ 'comes from a package install step it has no equivalent for:');
400
+ // The marker is the whole point of `ShareVerdict`. Every launcher
401
+ // exports XDG_DATA_DIRS at the staged `share/`, so an uncompiled
402
+ // schema directory is not a missing feature — `g_settings_new()`
403
+ // ABORTS on it. Printing that at the same weight as "neither OS reads
404
+ // one" is what buried it in a list of five.
405
+ const marker = { aborts: 'ABORTS: ', unknown: 'UNCLASSIFIED: ', inert: '' };
406
+ for (const { path, verdict, why } of carried) {
407
+ console.warn(`${LOG} ${marker[verdict] ?? ''}${path} — ${why}`);
408
+ }
409
+ }
410
+ // WHAT THE BUNDLE CARRIES, and what it still does not. Printed where the
411
+ // layout's other honesty lives, and the `runtimeGap` is now CONDITIONAL:
412
+ // that string says "the staged launcher execs an interpreter off `PATH`,
413
+ // which a downloaded `.app` cannot assume", and printing it over a stage
414
+ // that carries its own interpreter would be the command telling the author
415
+ // something untrue about the tree it had just written.
416
+ for (const line of runtime.found)
417
+ console.log(`${LOG} carries its own ${line}`);
418
+ for (const line of runtime.missing)
419
+ console.warn(`${LOG} ${line}`);
420
+ if (runtime.launcher.interpreter === undefined && layout.runtimeGap !== undefined) {
421
+ console.warn(`${LOG} ${layout.runtimeGap}`);
422
+ }
423
+ // THE OTHER AXIS, warned about HERE and refused in `packOne`, and the split
424
+ // is the same one `runtimeGap` and `assertFormatCanRunInterpreter` already
425
+ // draw. A stage is a build intermediate, and assembling one for an
426
+ // architecture no runtime exists for is what `tests/e2e/ship-layout` does on
427
+ // purpose: it proves the layout MAP over one payload, and that payload's
428
+ // native file has an architecture. What must not leave is an ARTIFACT — so
429
+ // the refusal sits where an artifact is about to be written and the author
430
+ // is told here, at the `--arch` that caused it, rather than only on the far
431
+ // side of a handoff.
432
+ if (layout.arches !== null && !layout.arches.only.includes(settings.arch)) {
433
+ console.warn(`${LOG} this stage is labelled ${settings.arch} and the ${layout.name} layout has no runtime ` +
434
+ `for it — ${layout.arches.why}. Nothing can pack this stage; ` +
435
+ `\`--arch ${layout.arches.only.join('|')}\` is what produces an artifact.`);
436
+ }
437
+ }
173
438
  // Scanned from the BUILD TREE's bundle, which is why it has to be recorded:
174
439
  // `gi://` specifiers are what the emitted `Depends:` is derived from
175
440
  // (ADR 0024 § 6), and the packing host has the staged copy but no way to
@@ -177,28 +442,62 @@ async function assemble(args) {
177
442
  const namespaces = scanGiNamespaces(readFileSync(settings.bundlePath, 'utf-8'));
178
443
  if (args.verbose)
179
444
  console.log(`${LOG} gi namespaces: ${namespaces.join(', ') || '(none)'}`);
445
+ // PLACED, like the payload, and through the same map. `planOverlay` answers a
446
+ // question about the FORMAT (`share/doc/<pkg>/copyright` for Debian policy,
447
+ // `share/licenses/<pkg>/LICENSE` for rpm) in the prefix-relative shape every
448
+ // plan uses; `packOne` then merges the overlay into the payload it reads back,
449
+ // so the two have to be in one coordinate system. On Linux `place()` is the
450
+ // identity for `share/…` and nothing moves. On darwin the licence would
451
+ // otherwise land at the STAGE root, outside `<App>.app` — beside the bundle
452
+ // rather than in it, where `codesign` later refuses it and no user ever finds
453
+ // it. `dirs.other`'s doc already names that hazard for `extraFiles`; the
454
+ // overlay reaches it by the same route.
180
455
  const overlay = new Map();
181
- for (const format of formats)
182
- overlay.set(format.id, planOverlay(settings, format, stageInputs));
456
+ for (const format of formats) {
457
+ overlay.set(format.id, planOverlay(settings, format, stageInputs).map((file) => ({
458
+ ...file,
459
+ path: place(layout, settings, file.path),
460
+ })));
461
+ }
183
462
  // The dependency derivation runs HERE as well, and its result is thrown
184
463
  // away. It is the only check in the pipeline that can refuse a project
185
464
  // (`gi://Nautilus` with no known package, ADR 0024 § 6), and this is the
186
465
  // host that has the `package.json` the refusal tells you to edit — letting
187
466
  // it fire two jobs later, on a machine with no project, would name a fix
188
- // nobody there can apply. Facts come from the PLAN, which has the same
189
- // paths the payload will have.
190
- const facts = readPayloadFacts(staged);
467
+ // nobody there can apply. Facts come from the PREFIX-RELATIVE plan, not from
468
+ // the placed tree: `readPayloadFacts` asks `share/icons/hicolor/…` questions,
469
+ // which is the shape the plan is written in and the shape the only formats
470
+ // that read the answer (`deb`, `rpm`) install into. Reading the placed tree
471
+ // would give the same answer on Linux and quietly give `false` everywhere
472
+ // else the day a format wraps another layout.
473
+ const facts = readPayloadFacts(planned);
191
474
  for (const format of formats) {
192
- deriveDepends(format.id, {
475
+ // `format.depends`, not `format.id`: a Flatpak has no package
476
+ // dependency list at all, and asking for one would either invent a
477
+ // third column in the typelib table or silently reuse rpm's — which is
478
+ // the exact defect `check-ship-format-vocabulary.mjs`'s header records
479
+ // from the other direction (a third format taking rpm's package name
480
+ // into a Debian `Depends:`, at exit 0).
481
+ if (format.depends === null)
482
+ continue;
483
+ deriveDepends(format.depends, {
193
484
  namespaces,
194
485
  hasIcons: facts.hasIcons,
195
486
  hasSchemas: facts.hasSchemas,
196
- extra: settings.extraDepends[format.id],
487
+ interpreter: settings.app,
488
+ extra: settings.extraDepends[format.depends],
197
489
  typelibPackages: settings.typelibPackages,
198
490
  bundledTypelibs: facts.bundledTypelibs,
199
491
  minGjsVersion: settings.minGjsVersion,
492
+ minNodeVersion: settings.minNodeVersion,
200
493
  });
201
- for (const warning of warnAboutGjsFloor(format.id, settings.minGjsVersion))
494
+ // One warning per package, for the interpreter it actually declares. A
495
+ // floor warning about a dependency this package does not emit is noise,
496
+ // and a noisy warning is the one nobody reads when it matters.
497
+ const floorWarnings = settings.app === 'node'
498
+ ? warnAboutNodeFloor(format.depends, settings.minNodeVersion)
499
+ : warnAboutGjsFloor(format.depends, settings.minGjsVersion);
500
+ for (const warning of floorWarnings)
202
501
  console.warn(`${LOG} ${warning}`);
203
502
  }
204
503
  // Written unconditionally, not only under `--stage`: a `ship/stage/` that
@@ -207,7 +506,23 @@ async function assemble(args) {
207
506
  // one-process artifact with the two-phase one.
208
507
  const manifest = writeStageManifest({ stageDir, settings, formats, mtime, namespaces, staged, overlay });
209
508
  if (args.stage) {
210
- console.log(`${LOG} stage manifest: ${formatTarget(manifest.target)}, formats ${manifest.formats.join(', ')}`);
509
+ // `(none)` spelled out, because an empty list printed as nothing after
510
+ // "formats " reads as a truncated line rather than as a fact. WHICH fact
511
+ // it is depends on the layout: all three now have formats — three rows each
512
+ // for darwin and windows — so an empty list means this project cannot use
513
+ // them, and printing "none yet" would tell a `--app gjs` author to wait for
514
+ // a milestone that has already landed.
515
+ // Three reasons a list can be empty, and each names a different next step:
516
+ // this project cannot use the formats that wrap the layout; the layout has
517
+ // none; or the configured `targets` all wrap another one, which the branch
518
+ // above has already SAID in its own words rather than repeating here.
519
+ const none = unusable.length > 0
520
+ ? `(none — ${unusable.map((format) => format.id).join(' and ')} need \`gjsify.app: "node"\`)`
521
+ : formatIdsFor(layout.os).length === 0
522
+ ? `(none — no format wraps the ${layout.name} layout)`
523
+ : '(none asked for)';
524
+ const wraps = manifest.formats.join(', ') || none;
525
+ console.log(`${LOG} stage manifest: ${formatTarget(manifest.target)}, formats ${wraps}`);
211
526
  return;
212
527
  }
213
528
  const artifacts = [];
@@ -222,10 +537,83 @@ async function assemble(args) {
222
537
  outRoot,
223
538
  namespaces,
224
539
  mtime,
540
+ sign,
541
+ notarize,
542
+ verbose: args.verbose,
225
543
  }));
226
544
  }
227
545
  printArtifacts(projectDir, artifacts);
228
546
  }
547
+ /**
548
+ * Refuse `--sign` / `--notarize` on the phase that produces no artifact.
549
+ *
550
+ * `--stage` writes a build intermediate, and a signature over one would be
551
+ * invalidated by the very next thing that happens to it — the packer reads the
552
+ * tree back and the container is built around it (ADR 0024 § A17 fixes that
553
+ * order for the opposite reason: `readStage` compares SIZES, and a re-signed
554
+ * file has a new one). Silently accepting the flag would produce a stage nobody
555
+ * could tell from an unsigned one.
556
+ */
557
+ function assertNothingToSignYet(args) {
558
+ const given = args.sign !== undefined ? '--sign' : args.notarize !== undefined ? '--notarize' : undefined;
559
+ if (given === undefined)
560
+ return;
561
+ throw new Error(`gjsify ship: ${given} belongs to the finish phase, and --stage produces no artifact to sign — it ` +
562
+ 'writes the tree a packing host reads back. Assemble here, then run ' +
563
+ '`gjsify ship --from-stage <dir> --sign <identity>` on the host that holds the identity ' +
564
+ '(ADR 0024 § A2, § A14).');
565
+ }
566
+ /**
567
+ * Say what this run will do about signatures, once, before any of it happens.
568
+ *
569
+ * PRINTED rather than silent, and to stderr, because ADR 0024 § A13 makes the
570
+ * skip the visible half of "unsigned is the default path": a pipeline that
571
+ * captures the artifact list still shows the line, and an unsigned artifact
572
+ * nobody was told about is how a signature gets claimed that was never made.
573
+ */
574
+ function announceSigning(sign, notarize) {
575
+ if (sign.kind === 'skip')
576
+ console.warn(`${LOG} ${sign.message}`);
577
+ if (notarize.kind === 'skip' && sign.kind === 'sign')
578
+ console.warn(`${LOG} ${notarize.message}`);
579
+ }
580
+ /**
581
+ * The runtime this artifact carries INSIDE itself, or an empty answer.
582
+ *
583
+ * THE GATE IS THE LAYOUT AND THE INTERPRETER, both. A Linux package declares
584
+ * `Depends: nodejs` and takes the distribution's — carrying a second copy would be
585
+ * a private interpreter no security update ever reaches. And a `--app gjs` payload
586
+ * gets nothing even on macOS: there is no relocatable GJS to put in a bundle
587
+ * (`build-gtk-runtime-darwin.mjs`: "GJS ships no relocation"), which is what
588
+ * `macos-app`'s `interpreters: ['node']` already records, so staging a NODE
589
+ * interpreter in front of a GJS bundle would be an interpreter that cannot read
590
+ * its own payload.
591
+ *
592
+ * `${layout.os}-${settings.arch}` — the target the PAYLOAD was built for, never
593
+ * the host's. Assembling a `darwin-arm64` bundle on a Linux x64 workstation is the
594
+ * supported path (ADR 0024 § A1) and is what this project's own CI does; a
595
+ * host-derived target would resolve the wrong closure, and the mismatch would only
596
+ * surface as a dlopen failure on the user's machine. `assertPayloadMatchesArch`
597
+ * reads the staged tree back afterwards and refuses a closure whose Mach-O
598
+ * `cputype` disagrees with the label — a check the carried runtime is the first
599
+ * thing to make non-vacuous on this layout, since a JavaScript bundle has no
600
+ * cputype to disagree with.
601
+ */
602
+ function resolveCarriedRuntime(layout, settings, projectDir) {
603
+ const empty = { files: [], launcher: {}, found: [], missing: [] };
604
+ if (layout.os === 'linux' || settings.app !== 'node')
605
+ return empty;
606
+ const target = `${layout.os}-${settings.arch}`;
607
+ if (!isGtkRuntimeTarget(target))
608
+ return empty;
609
+ return stageAppRuntime({
610
+ layout,
611
+ identity: settings,
612
+ target,
613
+ cwd: projectDir,
614
+ interpreter: isNodeRuntimeTarget(target) ? resolveNodeRuntime(target, { cwd: projectDir }) : null,
615
+ });
616
+ }
229
617
  /**
230
618
  * Phase two: pack a stage that was assembled somewhere else.
231
619
  *
@@ -250,12 +638,54 @@ async function finishFromStage(args, fromStage) {
250
638
  '`gjsify ship --stage`, and use `--expect-target <os>-<arch>` here to assert which leg you are ' +
251
639
  'packing.');
252
640
  }
641
+ if (args.os !== undefined) {
642
+ // Same reason as `--arch`, one axis over: the LAYOUT is what phase one
643
+ // wrote — the launcher's language, every staged path, the recorded
644
+ // target. Naming it again here could only agree with the stage or
645
+ // contradict it, and there is no useful behaviour behind either.
646
+ throw new Error(`gjsify ship: the layout is decided when the stage is assembled, so "${args.os}" has nothing to ` +
647
+ "change here — the staged tree already IS one OS's layout. Use `--expect-target <os>-<arch>` " +
648
+ 'to assert which one you are packing.');
649
+ }
253
650
  const cwd = process.cwd();
254
651
  const stageDir = resolve(cwd, fromStage);
255
652
  const manifest = readStageManifest(stageDir);
256
653
  assertExpectedTarget(manifest, args['expect-target']);
257
- const formats = resolveFormats(args.target ?? manifest.formats);
654
+ // `layoutForOs`, not `resolveLayout`: the manifest is a wire format with one
655
+ // legal spelling per OS, and `readStageManifest` has already refused any other.
656
+ const layout = layoutForOs(manifest.target.os);
657
+ // Before `resolveFormats`, which would otherwise answer a `--target` the
658
+ // caller never typed: a stage for a layout no format wraps carries
659
+ // `formats: []`, and the empty-list refusal there names `--target` and the
660
+ // three Linux formats — three sentences about the wrong thing.
661
+ // THE STAGE'S list, not the layout's. The two agreed while darwin and windows
662
+ // had no formats at all, and stopped the moment two rows wrapped darwin: a
663
+ // `--app gjs` project's darwin stage records `formats: []` against a layout
664
+ // that now has two. Asking the layout would let that stage past this refusal
665
+ // and into `resolveFormats([])`, whose message is about a `--target` the caller
666
+ // never typed.
667
+ if (args.target === undefined) {
668
+ assertPackable(manifest.formats.map((id) => FORMATS[id]), layout, 'stage',
669
+ // What WOULD have wrapped it, so this can tell a `--app gjs` darwin
670
+ // stage apart from a windows one, which nothing wraps at all.
671
+ defaultFormatIds(layout.os)
672
+ .map((id) => FORMATS[id])
673
+ .filter((format) => !format.interpreters.includes(manifest.settings.app)));
674
+ }
675
+ const formats = resolveFormats(args.target ?? manifest.formats, layout);
258
676
  assertFormatsStaged(manifest, formats);
677
+ for (const format of formats)
678
+ assertCanPack(format);
679
+ // NO `config` — this phase has no project, by construction (`tests/e2e/
680
+ // ship-from-stage` deletes the project tree between the two phases). So the
681
+ // identity is the flag or nothing, which is also the right answer: § A14
682
+ // amends § A1 to *a format declares where it can be packed; the RUN declares
683
+ // what it can sign with*, and this run is the one holding the keychain.
684
+ const sign = resolveSignPlan({ flag: args.sign, layoutOs: layout.os });
685
+ const notarize = resolveNotaryPlan({ flag: args.notarize, layoutOs: layout.os, sign });
686
+ announceSigning(sign, notarize);
687
+ if (sign.kind === 'sign')
688
+ assertHostCanSign(sign.signer);
259
689
  for (const warning of warnOnToolSkew(manifest))
260
690
  console.warn(`${LOG} ${warning}`);
261
691
  const { mtime, warnings } = resolveStageMtime(manifest);
@@ -275,8 +705,21 @@ async function finishFromStage(args, fromStage) {
275
705
  const outRoot = args.out === undefined ? dirname(stageDir) : resolve(cwd, args.out);
276
706
  const artifacts = [];
277
707
  for (const format of formats) {
278
- for (const warning of warnAboutGjsFloor(format.id, manifest.settings.minGjsVersion)) {
279
- console.warn(`${LOG} ${warning}`);
708
+ // Re-asserted here and not only in phase one. `--from-stage` can be
709
+ // pointed at any stage with any `--target`, so a stage assembled for
710
+ // deb+rpm can be packed as a Flatpak on the finishing host — the phase
711
+ // that HAS no project and cannot re-read `gjsify.app` from it. The stage
712
+ // manifest carries the answer precisely so this stays checkable.
713
+ assertFormatCanRunInterpreter(format, manifest.settings.app);
714
+ if (format.depends !== null) {
715
+ // From the stage manifest's settings, which is all this phase has: it
716
+ // may be running on a machine that has never seen the project
717
+ // (ADR 0024 § A2).
718
+ const floorWarnings = manifest.settings.app === 'node'
719
+ ? warnAboutNodeFloor(format.depends, manifest.settings.minNodeVersion)
720
+ : warnAboutGjsFloor(format.depends, manifest.settings.minGjsVersion);
721
+ for (const warning of floorWarnings)
722
+ console.warn(`${LOG} ${warning}`);
280
723
  }
281
724
  artifacts.push(await packOne({
282
725
  format,
@@ -288,6 +731,9 @@ async function finishFromStage(args, fromStage) {
288
731
  outRoot,
289
732
  namespaces: manifest.namespaces,
290
733
  mtime,
734
+ sign,
735
+ notarize,
736
+ verbose: args.verbose,
291
737
  }));
292
738
  }
293
739
  printArtifacts(cwd, artifacts);
@@ -297,20 +743,32 @@ async function packOne(input) {
297
743
  assertOverlayIsLicensed(format.id, settings.binaryName, input.overlay);
298
744
  const overlayDir = join(outRoot, 'overlay', format.id);
299
745
  writeStage(overlayDir, input.overlay);
300
- const payload = readStage([stageDir, overlayDir], [...input.staged, ...input.overlay]);
746
+ // THE PRE-SIGN TREE, and the name says which one it is (ADR 0024 § A17).
747
+ // `readStage` holds each file's SIZE against the stage manifest, and a size is
748
+ // no more re-sign-proof than a digest would be — measured on this tree:
749
+ // append one byte to a staged file and it refuses with "… is 6 bytes in the
750
+ // stage and 5 in its manifest". So the validation runs over what phase one
751
+ // wrote, and the signer below takes its OUTPUT: the signed bytes are computed
752
+ // from the validated ones and therefore cannot exist before them, which is
753
+ // what makes the order structural rather than a comment somebody may reorder.
754
+ const assembled = readStage([stageDir, overlayDir], [...input.staged, ...input.overlay]);
301
755
  // What the package installs, read off what it installs. The three questions
302
756
  // this answers used to be answered from the project's file lists, which are
303
757
  // absolute paths on the build host and therefore unavailable here.
304
- const facts = readPayloadFacts(payload);
305
- const depends = deriveDepends(format.id, {
306
- namespaces: input.namespaces,
307
- hasIcons: facts.hasIcons,
308
- hasSchemas: facts.hasSchemas,
309
- extra: settings.extraDepends[format.id],
310
- typelibPackages: settings.typelibPackages,
311
- bundledTypelibs: facts.bundledTypelibs,
312
- minGjsVersion: settings.minGjsVersion,
313
- });
758
+ const facts = readPayloadFacts(assembled);
759
+ const depends = format.depends === null
760
+ ? []
761
+ : deriveDepends(format.depends, {
762
+ namespaces: input.namespaces,
763
+ hasIcons: facts.hasIcons,
764
+ hasSchemas: facts.hasSchemas,
765
+ interpreter: settings.app,
766
+ extra: settings.extraDepends[format.depends],
767
+ typelibPackages: settings.typelibPackages,
768
+ bundledTypelibs: facts.bundledTypelibs,
769
+ minGjsVersion: settings.minGjsVersion,
770
+ minNodeVersion: settings.minNodeVersion,
771
+ });
314
772
  // `--arch` is a CLAIM about the payload and nothing else compared it to one:
315
773
  // an x86-64 `.so` staged with `--arch arm64` packed at exit 0 and `rpm -qp
316
774
  // --qf %{ARCH}` confirmed `aarch64`, because rpm reads the header and the
@@ -324,62 +782,445 @@ async function packOne(input) {
324
782
  // asserting afterwards would compare against a label that no longer names an
325
783
  // architecture, and the mismatch that matters is exactly the one on a payload
326
784
  // that IS architecture-specific.
327
- assertPayloadMatchesArch(payload, input.arch);
785
+ assertPayloadMatchesArch(assembled, input.arch);
786
+ // The second half of the same idea, on the other label this artifact carries:
787
+ // something declares which interpreter runs the app, and until now nothing
788
+ // compared it to the launcher that will. They CAN disagree — a stage assembled
789
+ // by one gjsify and packed by another — and an artifact that promises one
790
+ // interpreter and runs another installs cleanly and dies at first launch.
791
+ //
792
+ // EVERY FORMAT, not only the ones with a `Depends:`. The guard used to run
793
+ // behind `format.depends !== null`, which was the right condition while the
794
+ // only declaration was a package list. The macOS rows have no dependency list
795
+ // and a HARDER promise instead: `interpreters: ['node']`, because there is no
796
+ // relocatable GJS to put in a bundle. So the launcher has to be read for them
797
+ // too. Widening this costs the Flatpak path nothing — its row is
798
+ // `interpreters: ['gjs']` and `assertFormatCanRunInterpreter` has already
799
+ // refused anything else — and the check is asymmetric by construction, so a
800
+ // launcher it cannot resolve stays silent rather than failing a working
801
+ // artifact.
802
+ const layout = layoutForOs(format.layoutOs);
803
+ assertLauncherMatchesInterpreter(assembled, layout, settings, settings.app);
804
+ // The label against the LAYOUT, which is the question one level up from
805
+ // `assertPayloadMatchesArch`: that one asks whether the bytes agree with the
806
+ // label, this asks whether an artifact for that label can exist at all. They
807
+ // are not the same check, and the windows row is where they come apart — a
808
+ // payload of x64 DLLs labelled `arm64` fails the first, while a payload of pure
809
+ // JavaScript labelled `arm64` passes it and still produces a program directory
810
+ // no Windows/ARM machine has a GTK for (#1117).
811
+ //
812
+ // HERE and not at `--stage`, which is the same split `Layout.runtimeGap` draws:
813
+ // a stage is a build intermediate and assembling one for a foreign arch is what
814
+ // `tests/e2e/ship-layout` does on purpose (one payload, three layouts, and that
815
+ // payload's native file has an architecture). What must not leave is an
816
+ // ARTIFACT. `assemble` warns at the `--arch` that caused it.
817
+ //
818
+ // And BEFORE `archName`, deliberately: that function refuses an unknown value
819
+ // too, with a message about a table. This one names the blocker and where it is
820
+ // tracked, which is the difference between "unsupported" and "here is what
821
+ // would have to change".
822
+ assertLayoutSupportsArch(layout, input.arch);
823
+ // AFTER every check above and BEFORE any container below — the seam § A17
824
+ // asks M2's packer to leave, filled. Signing is a MUTATION of the payload
825
+ // (§ A4): what the packers get back is new bytes, not a wrapper, because a
826
+ // Developer-ID-signed main executable will not load ad-hoc-signed dylibs
827
+ // under library validation and all 106 images in the shipped darwin closure
828
+ // are ad-hoc today.
829
+ //
830
+ // Per FORMAT rather than once per run, and that costs darwin a second
831
+ // `codesign` pass over the same images (`macos-app` and `macos-app-zip` are
832
+ // two calls). It is not avoidable by hoisting: the payload a format signs
833
+ // includes that format's own overlay — on darwin the licence, which lands
834
+ // INSIDE `<App>.app` — so two formats have two payloads.
835
+ //
836
+ // `signed/<id>/` is scratch this function owns. The arriving stage is never
837
+ // written to: `writeStage` wipes what it is pointed at, and a re-signed file
838
+ // in it would fail the very `readStage` above on the next run.
839
+ const payload = input.sign.kind === 'sign'
840
+ ? await signPayload({
841
+ payload: assembled,
842
+ identity: input.sign.identity,
843
+ signer: input.sign.signer,
844
+ workDir: join(outRoot, 'signed', format.id),
845
+ verbose: input.verbose,
846
+ log: (line) => console.log(`${LOG} ${line}`),
847
+ })
848
+ : assembled;
328
849
  const archLabel = format.archName(input.arch, isArchIndependent(payload));
329
850
  const common = { settings, payload, prefix: format.prefix, depends, archLabel, mtime };
851
+ const outDir = join(outRoot, 'out');
852
+ mkdirSync(outDir, { recursive: true });
853
+ const target = join(outDir, format.fileName(settings, archLabel));
330
854
  // A SWITCH with a `never` guard, not a ternary. `format.id === 'deb' ? deb : rpm`
331
855
  // read as a dispatch and behaved as one only while there were exactly two
332
856
  // formats: a third would have taken the else-branch and written an RPM under a
333
857
  // `.dmg` name, at exit 0. This is the same closed-vocabulary hazard the format
334
858
  // list has, one level worse — a wrong ARTIFACT rather than a rejected
335
859
  // declaration — so the compiler owns it now.
336
- let bytes;
860
+ //
861
+ // Each branch WRITES `target` rather than returning bytes. It used to return
862
+ // them, which is the right shape only while every packer is a byte writer: a
863
+ // Flatpak is produced by `flatpak build-bundle`, so its bytes exist as a file
864
+ // before this process could hold them, and reading a bundle back into memory
865
+ // to hand it to one `writeFileSync` would be a copy for the sake of a
866
+ // signature. The size is stat'ed off what landed, for every format.
337
867
  switch (format.id) {
338
868
  case 'deb':
339
- bytes = await buildDeb(common);
869
+ writeFileSync(target, await buildDeb(common));
340
870
  break;
341
871
  case 'rpm':
342
- bytes = await buildRpm(common);
872
+ writeFileSync(target, await buildRpm(common));
873
+ break;
874
+ case 'flatpak':
875
+ await buildFlatpakBundle({
876
+ settings,
877
+ stageDir,
878
+ overlayDir,
879
+ stageManifestFile: STAGE_MANIFEST_FILE,
880
+ workDir: join(outRoot, 'flatpak'),
881
+ target,
882
+ archLabel,
883
+ verbose: input.verbose,
884
+ });
343
885
  break;
886
+ case 'macos-app':
887
+ case 'windows-dir':
888
+ // ONE STATEMENT FOR TWO ROWS, folded because the code really is
889
+ // identical: `Layout.root` is where the two differ, and the first draft
890
+ // of the windows arm said "NO REBASE, which is the one place the two
891
+ // rows differ" over a line byte-identical to this one.
892
+ //
893
+ // No container at all: the artifact IS the payload plus this format's
894
+ // overlay, written out. `writePayload` rather than a directory copy of
895
+ // the stage, so the same two properties every other packer has hold
896
+ // here — modes come from the plan (`readStage` has applied them, and
897
+ // the artifact upload that flattens them cannot reach in between), and
898
+ // the stage's own sidecar stays out because it was never payload.
899
+ //
900
+ // REBASED ON `Layout.root`, which is `<App>.app` on darwin and `''` on
901
+ // windows — the difference between the artifact and a directory
902
+ // containing it, measured as a defect on the first: every staged darwin
903
+ // path already begins with `<App>.app/` and `format.fileName` names the
904
+ // artifact the same thing, so writing them verbatim produced
905
+ // `out/Ship Demo.app/Ship Demo.app/Contents/…`, a folder the Finder does
906
+ // not treat as an application with a real bundle hidden one level down,
907
+ // at exit 0, with `zipinfo` on the sibling zip showing the correct tree
908
+ // the whole time. On windows the stage IS the directory's contents,
909
+ // because an installer picks the parent, and `writePayload` documents
910
+ // `stripPrefix === ''` as a no-op — so the same call is right for both.
911
+ // Neither zip needs a rebase, and for opposite reasons: the darwin
912
+ // archive INHERITS its top level from the staged paths, the windows one
913
+ // SYNTHESISES it (see `windows-dir-zip`).
914
+ writePayload(target, payload, layout.root(settings));
915
+ break;
916
+ case 'macos-app-dmg': {
917
+ // FROM THE STAGE, never from the `out/<App>.app` the row above
918
+ // writes, and that is a design decision rather than an accident of
919
+ // where the bytes were handy. Three reasons, in the order they bite:
920
+ //
921
+ // * a `--target macos-app-dmg` run alone must produce a `.dmg`.
922
+ // Reading the sibling artifact would make this row depend on
923
+ // another row having run, and the only thing that orders them
924
+ // today is `resolveFormats`' alphabetical sort — an invariant
925
+ // nothing states and `--target macos-app-dmg` on its own breaks.
926
+ // * ADR 0024 § A17 fixes the seam for a later `--sign`: `readStage`
927
+ // validates the PRE-sign tree, the re-sign runs after it, and the
928
+ // container is built after that. A packer that opened an artifact
929
+ // would put its container step on the far side of a directory
930
+ // nothing validated.
931
+ // * the modes. `writePayload` applies the PLAN's modes, which
932
+ // `readStage` has already resolved from the manifest; a directory
933
+ // copy would inherit whatever the filesystem — or a CI artifact
934
+ // round trip — left behind.
935
+ //
936
+ // `stripPrefix: ''` and not `layout.root(settings)`, which is the one
937
+ // line where this differs from the `macos-app` arm above: that
938
+ // artifact IS the bundle, so the `<App>.app/` prefix comes off; this
939
+ // one is the VOLUME, and the bundle has to sit inside it or the image
940
+ // mounts showing `Contents/` — a window with no application in it.
941
+ const volumeDir = dmgVolumeDir(outRoot);
942
+ writePayload(volumeDir, payload, '');
943
+ await buildDmgImage({
944
+ volumeName: dmgVolumeName(settings),
945
+ sourceDir: volumeDir,
946
+ target,
947
+ workDir: dirname(volumeDir),
948
+ verbose: input.verbose,
949
+ });
950
+ break;
951
+ }
952
+ case 'macos-app-zip':
953
+ // The SAME payload, in the one container a browser download can be.
954
+ // Written by this tree (`utils/ship/zip.ts`) so the packing host needs
955
+ // no `zip(1)` and `zipinfo` stays an independent reader — ADR 0024
956
+ // § A3's argument for the hand-written deb and rpm, applied again.
957
+ // The paths inside are the staged ones, so the archive expands to
958
+ // `<App>.app/…` and not to a bare `Contents/`.
959
+ writeFileSync(target, buildZip(zipEntriesFromPayload(payload), mtime));
960
+ break;
961
+ case 'windows-dir-zip':
962
+ // The same payload, with the top level the layout deliberately does not
963
+ // carry. `windowsProgramDirName` is the same function the row above
964
+ // names the directory with, so unzipping this archive and running the
965
+ // installer put the app at the same relative path — and a user who
966
+ // unzips it in `Downloads` gets one directory rather than `app\`,
967
+ // `share\` and a `.cmd` loose in it.
968
+ writeFileSync(target, buildZip(zipEntriesFromPayload(payload, windowsProgramDirName(settings)), mtime));
969
+ break;
970
+ case 'msi': {
971
+ // The SAME payload again, and the third artifact to carry it —
972
+ // `payload`, which is `signPayload`'s OUTPUT when an identity was
973
+ // passed and `assembled` otherwise (§ A17). The installer therefore
974
+ // packs the signed bytes by construction rather than by ordering: it
975
+ // cannot read them before the signer has produced them.
976
+ //
977
+ // The source document addresses files by PATH, so the tree is written
978
+ // out once into the work directory and the `.wxs` points at it with
979
+ // paths RELATIVE to that directory — the same shape the Flatpak packer
980
+ // uses, where a manifest also names a directory the packer just laid
981
+ // down. Relative and not absolute because the same document is compiled
982
+ // a second time by WiX on another machine; see `utils/ship/msi.ts`.
983
+ // Not a copy of `out/<App>/`: `--target msi` alone must work, and the
984
+ // directory row may not be in this run's format list at all.
985
+ const workDir = join(outRoot, 'msi');
986
+ writePayload(join(workDir, MSI_PAYLOAD_DIR), payload, layout.root(settings));
987
+ await buildMsi({
988
+ settings,
989
+ // POSIX-separated and RELATIVE to `workDir`, so the `.wxs` plus the
990
+ // tree beside it is a pair that can be handed to a second compiler
991
+ // on another machine — which is what the WiX half of the
992
+ // cross-check does with this exact document.
993
+ files: payload.map((entry) => ({
994
+ path: entry.path,
995
+ source: `${MSI_PAYLOAD_DIR}/${entry.path}`,
996
+ })),
997
+ programDirName: windowsProgramDirName(settings),
998
+ archLabel,
999
+ workDir,
1000
+ target,
1001
+ verbose: input.verbose,
1002
+ });
1003
+ break;
1004
+ }
344
1005
  default: {
345
1006
  const unhandled = format.id;
346
1007
  throw new Error(`gjsify ship: no packer is wired for format "${String(unhandled)}".`);
347
1008
  }
348
1009
  }
349
- const outDir = join(outRoot, 'out');
350
- mkdirSync(outDir, { recursive: true });
351
- const target = join(outDir, format.fileName(settings, archLabel));
352
- writeFileSync(target, bytes);
353
- return { format: format.id, path: target, size: bytes.byteLength };
1010
+ // The SIZE of a directory is not `statSync().size`, which answers 4096 for the
1011
+ // directory entry — so a `.app` with a 20 MiB bundle would be reported as
1012
+ // "4096 bytes". `artifactKind` is on the descriptor for exactly this.
1013
+ const size = format.artifactKind === 'directory' ? directorySize(target) : statSync(target).size;
1014
+ // LAST, on the artifact that landed — notarisation is a check on a finished,
1015
+ // signed container and there is nothing to submit before one exists.
1016
+ //
1017
+ // A DIRECTORY IS SKIPPED, not refused. `notarytool` takes an archive or an
1018
+ // installer, so a `<App>.app` has no submittable form of its own; on darwin
1019
+ // the zip beside it is the one that goes. Refusing here would make
1020
+ // `--notarize` unusable with the default format set, which is both rows.
1021
+ if (input.notarize.kind === 'notarize') {
1022
+ if (format.artifactKind === 'directory') {
1023
+ console.warn(`${LOG} ${format.id} is a directory, and notarytool submits an archive or an installer — ` +
1024
+ 'not notarised. The container format beside it is what carries the ticket.');
1025
+ }
1026
+ else {
1027
+ await notarizeArtifact({
1028
+ plan: input.notarize,
1029
+ artifact: target,
1030
+ verbose: input.verbose,
1031
+ log: (line) => console.log(`${LOG} ${line}`),
1032
+ });
1033
+ }
1034
+ }
1035
+ return { format: format.id, path: target, size };
354
1036
  }
355
1037
  function printArtifacts(base, artifacts) {
356
1038
  for (const artifact of artifacts) {
357
1039
  console.log(`${LOG} ${artifact.format}: ${relative(base, artifact.path)} (${artifact.size} bytes)`);
358
1040
  }
359
1041
  }
1042
+ /**
1043
+ * Refuse a format this host cannot finish, and say which half is missing.
1044
+ *
1045
+ * Two checks and not one, because the fixes differ: the wrong OS needs another
1046
+ * machine, an absent tool needs a package. Both run on the pack path only —
1047
+ * `--stage` deliberately needs neither, which is what makes a Flatpak
1048
+ * assemblable on a host that cannot build one (ADR 0024 § A1).
1049
+ */
1050
+ function assertCanPack(format) {
1051
+ assertHostCanFinish(format);
1052
+ assertToolsInstalled(format);
1053
+ }
1054
+ /**
1055
+ * Refuse a pack for a layout no format wraps.
1056
+ *
1057
+ * Reached only when the format list was DERIVED — no `--target`, or a
1058
+ * `gjsify.ship.targets` filtered empty by this layout — where the empty set is a
1059
+ * fact about the layout rather than something a caller typed. Without it,
1060
+ * `gjsify ship darwin` would assemble the tree, iterate an empty format list,
1061
+ * print no artifact line and exit 0 — a success that produced nothing, which is
1062
+ * the same shape `resolveFormats` refuses an empty `--target` for.
1063
+ *
1064
+ * `chosenBy` is not decoration. When the layout came from the HOST rather than
1065
+ * from the positional, this is a BEHAVIOUR CHANGE the caller did not ask for:
1066
+ * a bare `gjsify ship` on a Mac used to emit `.deb` + `.rpm`, because the default
1067
+ * format set was host-independent. It is deliberate — the positional means what
1068
+ * it says, and a Linux package built on a Mac is now something you ask for — but
1069
+ * a refusal that does not name the one-word replacement is just a regression.
1070
+ */
1071
+ function assertPackable(formats, layout, chosenBy, unusable = []) {
1072
+ if (formats.length > 0)
1073
+ return;
1074
+ // A DIFFERENT emptiness, and conflating the two sends the reader to the wrong
1075
+ // fix. "No format wraps this layout" is answered by a later milestone; "the
1076
+ // formats that wrap it cannot run this project's interpreter" is answered by
1077
+ // the project, today — and the two produce byte-identical empty lists.
1078
+ if (unusable.length > 0) {
1079
+ throw new Error(`gjsify ship: ${unusable.map((format) => format.id).join(' and ')} wrap the ${layout.name} ` +
1080
+ `layout, and neither can run this project — ${unusable[0]?.interpreterGap ?? ''}.\n` +
1081
+ ` \`gjsify ship ${layout.name} --stage\` assembles the tree anyway; packing it is what ` +
1082
+ 'the milestone that stages an interpreter is for (#1354 M2b).');
1083
+ }
1084
+ // A THIRD emptiness, and the one the last throw in this function used to
1085
+ // mis-diagnose. A layout that HAS formats still reaches here with none
1086
+ // SELECTED: `configuredFormats` filters `gjsify.ship.targets` to the layout
1087
+ // being assembled and returns an empty list when they all wrap another one,
1088
+ // and `--stage` records that empty list for the finishing host to read.
1089
+ //
1090
+ // MEASURED before this branch existed, on a `--app node` project carrying
1091
+ // `targets: ["deb","rpm"]` — the key `packages/infra/cli/package.json` itself
1092
+ // carries — with `gjsify ship darwin --stage` then `gjsify ship --from-stage`:
1093
+ // "no format wraps the darwin layout … Keep it for the milestone that packs it
1094
+ // … this is a layout added without a `FORMATS` row". Three rows wrap darwin,
1095
+ // the milestone landed, and the cause was a configured list for another OS.
1096
+ const wrapping = formatIdsFor(layout.os).sort();
1097
+ if (wrapping.length > 0) {
1098
+ // The assembling host is where the answer is, whichever caller got here:
1099
+ // phase two cannot add a format the stage never rendered an overlay for
1100
+ // (`assertFormatsStaged`), so `--target` HERE is not the fix.
1101
+ const where = chosenBy === 'stage' ? `${layout.name} --stage --target` : `${layout.name} --target`;
1102
+ throw new Error(`gjsify ship: this run records no formats for the ${layout.name} layout, so there is nothing ` +
1103
+ `to pack — and it is not that none exist: ${wrapping.join(', ')} wrap it.\n` +
1104
+ ' An empty list means none were SELECTED. `gjsify.ship.targets` is filtered to the layout ' +
1105
+ 'being assembled,\n' +
1106
+ " so a configured list naming only another OS's formats leaves this one empty and says so " +
1107
+ 'at stage time.\n' +
1108
+ ` Name one where the payload is assembled: \`gjsify ship ${where} ${wrapping[0]}\`.`);
1109
+ }
1110
+ const linux = defaultFormatIds('linux').join(',');
1111
+ // Three callers, three different next steps. Telling a `--from-stage` caller
1112
+ // to run `gjsify ship <os> --stage` names the command that PRODUCED the stage
1113
+ // they are holding, which is advice to go in a circle.
1114
+ const advice = {
1115
+ host: `This host is ${process.platform}, so \`gjsify ship\` assembled the ${layout.name} layout. ` +
1116
+ 'Assembly is not host-bound (ADR 0024 § A1), so the Linux packages are still one word away: ' +
1117
+ `\`gjsify ship linux\` builds ${linux} from right here.`,
1118
+ positional: `\`gjsify ship ${layout.name} --stage\` assembles the payload and stops, which is the whole of ` +
1119
+ 'what this command does for that OS today.',
1120
+ stage: `This stage carries the ${layout.name} layout and nothing here can wrap it — a stage is a build ` +
1121
+ 'intermediate, not an artifact. Keep it for the milestone that packs it, or re-assemble with ' +
1122
+ '`gjsify ship linux --stage` if a Linux package is what you wanted.',
1123
+ }[chosenBy];
1124
+ // NOW ACTUALLY UNREACHABLE from the three layouts that exist — the branch
1125
+ // above holds every one of them, because `formatIdsFor` answers non-empty for
1126
+ // all three as of #1354 M5. Kept rather than deleted: this is what a fourth
1127
+ // layout gets on the day it is added and before a `FORMATS` row wraps it, the
1128
+ // state windows was in between M1 and M3, and deleting it would make that
1129
+ // day's failure a `TypeError` on an empty list.
1130
+ //
1131
+ // It used to claim this for the whole function while the `targets`-elsewhere
1132
+ // route reached it and got all three of its sentences wrong. A comment saying
1133
+ // "unreachable" is a claim about a predicate; this one now has the predicate
1134
+ // above it in code.
1135
+ throw new Error(`gjsify ship: no format wraps the ${layout.name} layout, so there is nothing to pack. ${advice}` +
1136
+ ' Every layout this gjsify knows has one (ADR 0024 stages 2-5), so this is a layout added ' +
1137
+ 'without a `FORMATS` row.');
1138
+ }
360
1139
  /**
361
1140
  * Refuse a build target this command cannot package correctly.
362
1141
  *
363
- * The launcher execs `gjs -m <bundle>`, so a `--app node` bundle would produce
364
- * a package that installs and then fails at startup — and ADR 0024 § 4 says
365
- * that case needs a BUNDLED Node, whose delivery (fetched at ship time versus a
366
- * platform package) is still an open decision. Better to say so than to ship
367
- * the broken half of it.
1142
+ * `gjs` and `node` are packageable: `renderLauncher` execs the one
1143
+ * `settings.app` names and `deriveDepends` declares the same one — `gjs >= …`,
1144
+ * or `nodejs (>= 24)` / `nodejs(engine) >= 24`. Until #1354's M0 this said "only
1145
+ * `gjs` can be packaged today", which was true: the launcher execed gjs
1146
+ * unconditionally, so a `--app node` package would have installed and died at
1147
+ * startup.
1148
+ *
1149
+ * `browser` and `nativescript` stay refused, and not for want of a launcher
1150
+ * line: a browser bundle has no process to start, and NativeScript ships as an
1151
+ * APK/IPA through a different pipeline entirely (ADR 0024 § 4).
1152
+ *
1153
+ * DELIBERATELY NOT PER LAYOUT, and the first cut of the layout axis got this
1154
+ * exactly backwards. Reading ADR § 4's runtime table as a per-layout `app`
1155
+ * requirement refused `gjsify.app: "gjs"` for the macOS layout — i.e. refused the
1156
+ * entire audience of this command from assembling a `.app` — while a project with
1157
+ * no `gjsify.app` key sailed through and staged a launcher naming `node` in front
1158
+ * of a GJS bundle. Both halves came from the same mistake: § 4 derives the runtime
1159
+ * a SHIPPED ARTIFACT carries, which is `Layout.shippedRuntime` and is printed as
1160
+ * `Layout.runtimeGap`. What the launcher execs is what the PAYLOAD was built for,
1161
+ * and that is `settings.app` on every layout.
368
1162
  *
369
- * An undeclared target is the common case for a GJS app and is allowed: `app`
370
- * otherwise defaults to the HOST runtime, which would refuse a perfectly good
371
- * GJS project merely for running this command under Node.
1163
+ * An undeclared target is the common case for a GJS app and is allowed. It must
1164
+ * NOT be resolved through `Config.forBuild`, whose fallback is the HOST runtime
1165
+ * — that would refuse, or worse silently re-target, a perfectly good GJS project
1166
+ * merely for running this command under Node.
372
1167
  *
373
- * Phase one only, and nothing checks it again in phase two: a stage can only
374
- * exist because this passed, and the staged `bin/<name>` carries the
375
- * `exec gjs -m` line that proves it.
1168
+ * WHY THIS REFUSES AND `Layout.runtimeGap` ONLY WARNS, since the two describe
1169
+ * neighbouring predicaments and get opposite treatment. A target this command
1170
+ * cannot package would leave here as an ARTIFACT — a `.deb` a stranger installs,
1171
+ * which then fails at startup, with no gate between here and their machine. The
1172
+ * `runtimeGap` warns about something that is not an artifact: it is printed over a
1173
+ * staged tree, and only when that tree carries no interpreter of its own.
1174
+ *
1175
+ * This paragraph used to end "the day a Windows format exists, that warning has to
1176
+ * become this refusal". #1354 M3 is that day, and the refusal it became is
1177
+ * {@link assertFormatCanRunInterpreter} — per FORMAT rather than per layout,
1178
+ * because what a runtime can execute is a property of the container and not of the
1179
+ * tree it wraps. This function stayed exactly as wide as it was.
1180
+ *
1181
+ * Phase one only. Phase two does not re-read `gjsify.app` — it has no project —
1182
+ * but it is not unchecked either: `assertLauncherMatchesInterpreter` compares
1183
+ * the staged `bin/<name>`'s own `exec` line against the dependency about to be
1184
+ * written.
376
1185
  */
377
1186
  function assertShippableTarget(app) {
378
- if (app === undefined || app === 'gjs')
1187
+ if (app === undefined || app === 'gjs' || app === 'node')
1188
+ return;
1189
+ throw new Error(`gjsify ship: this project declares \`gjsify.app: "${app}"\`, and only \`gjs\` and \`node\` can be ` +
1190
+ 'packaged. A browser bundle has no process to launch, and a NativeScript app ships as an APK/IPA ' +
1191
+ 'through a different pipeline. ADR 0024 § 4 has the runtime-per-OS table.');
1192
+ }
1193
+ /**
1194
+ * Refuse a format whose runtime cannot provide the interpreter the launcher execs.
1195
+ *
1196
+ * THE HOLE THIS CLOSES, and it was opened by the commit that made `--app node`
1197
+ * packageable. `assertShippableTarget` used to refuse `app: 'node'` outright, so
1198
+ * no format ever saw one. Lifting that made deb and rpm correct and left Flatpak
1199
+ * silently wrong: measured at exit 0, a `--target flatpak --stage` with
1200
+ * `app: 'node'` emitted a manifest with `runtime: org.gnome.Platform`,
1201
+ * `runtime-version: 50`, no `sdk-extensions` and no `append-path`, beside a
1202
+ * launcher that execs `node`. That runtime ships `gjs` and no `node`.
1203
+ *
1204
+ * WHY THE OTHER CHECK DOES NOT CATCH IT. `assertLauncherMatchesInterpreter`
1205
+ * compares the launcher against the DEPENDENCY, and a Flatpak has no dependency
1206
+ * list — `format.depends` is `null`, so both sides come from `settings.app` and
1207
+ * agree by construction. Only the format's runtime knows what it can execute,
1208
+ * which is why the answer is a descriptor field.
1209
+ *
1210
+ * Not a warning. The artifact would install and die at first launch on a user's
1211
+ * machine, which is the failure this whole command is built to make impossible.
1212
+ */
1213
+ function assertFormatCanRunInterpreter(format, app) {
1214
+ if (format.interpreters.includes(app))
379
1215
  return;
380
- throw new Error(`gjsify ship: this project declares \`gjsify.app: "${app}"\`, and only \`gjs\` can be packaged today. ` +
381
- 'The launcher execs `gjs -m <bundle>`, so any other target would install and then fail to start. ' +
382
- 'ADR 0024 § 4 has the runtime-per-OS table and what is still open for the other targets.');
1216
+ throw new Error(`gjsify ship: this project is \`gjsify.app: "${app}"\`, and the ${format.id} runtime cannot run it — ` +
1217
+ `it provides ${format.interpreters.join(', ')}.\n` +
1218
+ // The ROW's own sentence, not one written here. Hardcoded, this
1219
+ // paragraph told the first `.app` author about
1220
+ // `org.freedesktop.Sdk.Extension.node2x` and the GNOME runtime.
1221
+ ` ${format.interpreterGap}.\n` +
1222
+ ' Build the formats that wrap this layout and can run it, or ship this app as ' +
1223
+ `\`gjsify.app: "${format.interpreters.join('" / "')}"\`.`);
383
1224
  }
384
1225
  /**
385
1226
  * Run the project's own `build` script, as a CHILD process.
@@ -409,6 +1250,6 @@ async function runProjectBuild(projectDir) {
409
1250
  'yourself and pass --skip-build.'),
410
1251
  });
411
1252
  if (result.code !== 0) {
412
- throw new Error(`gjsify ship: the project's build script failed${describeExit(result)}.`);
1253
+ throw new Error(`gjsify ship: the project's build script failed with ${describeExit(result)}.`);
413
1254
  }
414
1255
  }