@gjsify/cli 0.44.0 → 0.46.0

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