@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
@@ -38,6 +38,7 @@ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
38
38
  import { join, sep } from 'node:path';
39
39
  import { cliVersion } from '../publish-headers.js';
40
40
  import { FORMAT_IDS } from './formats.js';
41
+ import { layoutForOs, LAYOUTS, LAYOUT_NAMES } from './layout.js';
41
42
  /** The one file a stage carries that is not payload. Lives at the stage ROOT. */
42
43
  export const STAGE_MANIFEST_FILE = '.gjsify-ship-stage.json';
43
44
  /**
@@ -55,18 +56,45 @@ export const STAGE_MANIFEST_FILE = '.gjsify-ship-stage.json';
55
56
  * `settings.flatpak` would not skip a field, it would pack a Flatpak against
56
57
  * whatever runtime IT defaults to — a different `org.gnome.Platform` version
57
58
  * than the project asked for, at exit 0.
58
- */
59
- export const STAGE_SCHEMA_VERSION = 2;
60
- /**
61
- * The OS whose layout `planStage` produces.
62
59
  *
63
- * A constant rather than a measurement, because it is a property of the PLAN
64
- * (`bin/`, `lib/<name>/`, `share/…` is the Linux/XDG layout), not of the host
65
- * that ran it — assembling is cross-platform (ADR 0024 § A1). When
66
- * `gjsify ship darwin --stage` lands this becomes the positional's value, and
67
- * this constant is where the compiler will point.
60
+ * 3 added `settings.minNodeVersion` and 4 added `settings.app`. Same case as 2
61
+ * for the same reason: an older reader ignoring `app` does not skip a field, it
62
+ * has no concept of an interpreter choice at all, so it seeds `gjs >= …` for a
63
+ * stage whose launcher — already rendered, already in the tree it is packing —
64
+ * execs `node`. The package then declares one interpreter and runs another.
65
+ *
66
+ * Stated as a consequence and not as an incident, because it never happened:
67
+ * `app` and the launcher landed together. An earlier version of this comment
68
+ * claimed the incident anyway, which is worth leaving on the record — a rule
69
+ * with an invented reason gets "simplified" back out the first time somebody
70
+ * notices the reason is not real.
71
+ *
72
+ * WHY TWO BUMPS IN ONE CHANGE, when neither was ever released. Because 3 and 4
73
+ * are DIFFERENT SHAPES and 3 briefly existed: the intermediate commit wrote
74
+ * `schema: 3` with no `app`. Folding `app` into 3 would have made this reader
75
+ * meet that stage and fail on `settings.app must be "gjs" or "node", got
76
+ * undefined` — fail-closed, but naming a field instead of the one thing the
77
+ * reader can do about it. At 4 the schema check above catches it first and says
78
+ * "re-run the `--stage` phase with this gjsify", which is the whole reason this
79
+ * constant's header is an argument for bumping whenever a reader could mis-read.
80
+ *
81
+ * 5 added `settings.name`, and it was decided against the criterion above rather
82
+ * than reflexively — #1354 M2a changed two things and only one of them warrants a
83
+ * bump:
84
+ *
85
+ * - NOT the two new format ids. `formats[]` and `overlay`'s keys carry new
86
+ * VALUES in fields whose meaning is unchanged, and `readStageManifest` already
87
+ * refuses a `formats[i]` it does not know BY NAME. An older gjsify meeting a
88
+ * macOS stage therefore fails closed with a message about the format, which is
89
+ * the right message. Adding a member to a validated enum is the case this
90
+ * header says not to bump for.
91
+ * - YES `settings.name`. It is REQUIRED, so this reader meeting a schema-4 stage
92
+ * would fail on `settings.name must be a string` — naming a field instead of
93
+ * the one thing the reader can do about it. That is the same shape the 3-to-4
94
+ * paragraph describes, and at 5 the schema check above fires first and says
95
+ * "re-run the `--stage` phase with this gjsify".
68
96
  */
69
- export const STAGE_LAYOUT_OS = 'linux';
97
+ export const STAGE_SCHEMA_VERSION = 5;
70
98
  /**
71
99
  * The half of {@link ShipSettings} that crosses the host boundary.
72
100
  *
@@ -84,6 +112,7 @@ export function toPackSettings(settings) {
84
112
  return {
85
113
  binaryName: settings.binaryName,
86
114
  appId: settings.appId,
115
+ name: settings.name,
87
116
  version: settings.version,
88
117
  release: settings.release,
89
118
  maintainer: settings.maintainer,
@@ -95,7 +124,9 @@ export function toPackSettings(settings) {
95
124
  group: settings.group,
96
125
  extraDepends: settings.extraDepends,
97
126
  typelibPackages: settings.typelibPackages,
127
+ app: settings.app,
98
128
  minGjsVersion: settings.minGjsVersion,
129
+ minNodeVersion: settings.minNodeVersion,
99
130
  flatpak: settings.flatpak,
100
131
  };
101
132
  }
@@ -132,7 +163,13 @@ export function writeStageManifest(input) {
132
163
  const manifest = {
133
164
  schema: STAGE_SCHEMA_VERSION,
134
165
  tool: { name: '@gjsify/cli', version: cliVersion() },
135
- target: { os: STAGE_LAYOUT_OS, arch: input.settings.arch },
166
+ // The POSITIONAL's value, not a constant. It used to be the literal
167
+ // `'linux'`, correct while the planner produced exactly one layout and
168
+ // wrong the moment it produced three — and wrong SILENTLY, because a
169
+ // darwin stage labelled `linux-arm64` passes every structural check in
170
+ // `readStageManifest` and would be accepted by `--expect-target
171
+ // linux-arm64` on a leg that wanted the Linux one.
172
+ target: { os: input.settings.layoutOs, arch: input.settings.arch },
136
173
  formats: input.formats.map((format) => format.id),
137
174
  mtime: input.mtime,
138
175
  namespaces: [...input.namespaces],
@@ -203,7 +240,16 @@ export function readStageManifest(stageDir) {
203
240
  version: expectString(tool.version, at('tool.version')),
204
241
  },
205
242
  target: {
206
- os: expectString(target.os, at('target.os')),
243
+ // `layoutForOs`, which takes the `process.platform` spelling and NO
244
+ // alias. `resolveLayout` — the positional's resolver — also accepts
245
+ // `windows`, and routing this field through it would mean a manifest
246
+ // literally saying `"windows"` was accepted and then COMPARED as
247
+ // `win32`: two files with different bytes in the one field
248
+ // `--expect-target` exists to compare would both match it, on a wire
249
+ // format whose entire job is to catch a job that downloaded the wrong
250
+ // artifact. Refused here rather than reaching the layout lookup as a
251
+ // lie, and named the way every other field in this function is.
252
+ os: expectLayoutOs(expectString(target.os, at('target.os')), at('target.os')),
207
253
  arch: expectString(target.arch, at('target.arch')),
208
254
  },
209
255
  formats: expectArray(data.formats, at('formats')).map((entry, index) => {
@@ -258,6 +304,26 @@ export function readStageManifest(stageDir) {
258
304
  }
259
305
  return manifest;
260
306
  }
307
+ /**
308
+ * `target.os`, resolved to a layout, in the one spelling this field may carry.
309
+ *
310
+ * The catch REPLACES the message rather than swallowing it: `layoutForOs` speaks
311
+ * about a token, and this is a named field in a file that arrived from another
312
+ * host, so the fix is different and so is the sentence. Every other field in
313
+ * `readStageManifest` names its own location the same way.
314
+ */
315
+ function expectLayoutOs(value, where) {
316
+ try {
317
+ return layoutForOs(value).os;
318
+ }
319
+ catch {
320
+ const known = LAYOUT_NAMES.map((name) => LAYOUTS[name].os).join(', ');
321
+ throw new Error(`gjsify ship: ${where} is "${value}", which is not a layout this gjsify can pack. It carries ` +
322
+ `the \`process.platform\` spelling and only that — ${known}, so \`win32\` and never ` +
323
+ "`windows`. A stage holds exactly one OS's layout and every path in it has that OS's shape, " +
324
+ 'so there is nothing to pack here. Re-run the `--stage` phase.');
325
+ }
326
+ }
261
327
  /** The overlay for one format, in the shape `writeStage` takes. */
262
328
  export function overlayFiles(manifest, format) {
263
329
  return (manifest.overlay[format] ?? []).map((file) => ({
@@ -318,7 +384,11 @@ export function assertFormatsStaged(manifest, formats) {
318
384
  const missing = formats.filter((format) => !manifest.formats.includes(format.id)).map((format) => format.id);
319
385
  if (missing.length === 0)
320
386
  return;
321
- throw new Error(`gjsify ship: this stage was assembled for ${manifest.formats.join(', ')}, and --target names ` +
387
+ throw new Error(
388
+ // `no format at all` and not an empty join: a stage CAN record zero formats
389
+ // (`gjsify.ship.targets` filtered to a layout none of them wrap), and the
390
+ // join printed that as nothing — "assembled for , and --target names …".
391
+ `gjsify ship: this stage was assembled for ${manifest.formats.length === 0 ? 'no format at all' : manifest.formats.join(', ')}, and --target names ` +
322
392
  `${missing.join(', ')}. Phase one renders each format's overlay — rpm wants the licence at ` +
323
393
  '`share/licenses/<pkg>/LICENSE`, deb wants a machine-readable copyright at ' +
324
394
  '`share/doc/<pkg>/copyright` — so a format the stage never saw would pack without it, and Debian ' +
@@ -367,6 +437,7 @@ function readPackSettings(data, at) {
367
437
  return {
368
438
  binaryName: expectString(data.binaryName, field('binaryName')),
369
439
  appId: expectString(data.appId, field('appId')),
440
+ name: expectString(data.name, field('name')),
370
441
  version: expectString(data.version, field('version')),
371
442
  release: expectString(data.release, field('release')),
372
443
  maintainer: expectString(data.maintainer, field('maintainer')),
@@ -390,7 +461,9 @@ function readPackSettings(data, at) {
390
461
  },
391
462
  ];
392
463
  })),
464
+ app: expectApp(data.app, field('app')),
393
465
  minGjsVersion: expectString(data.minGjsVersion, field('minGjsVersion')),
466
+ minNodeVersion: expectString(data.minNodeVersion, field('minNodeVersion')),
394
467
  flatpak: readFlatpakSettings(record(data.flatpak, field('flatpak')), field('flatpak')),
395
468
  };
396
469
  }
@@ -430,6 +503,19 @@ function expectArray(value, at) {
430
503
  }
431
504
  return value;
432
505
  }
506
+ /**
507
+ * The interpreter field, checked against the two values that exist.
508
+ *
509
+ * Not `expectString`: this one decides which interpreter the package DEPENDS on,
510
+ * so an unknown value must stop the pack rather than flow into a `Depends:` line
511
+ * as itself. The only way to reach it is a stage from a gjsify that knows a third
512
+ * target, which the schema check above already refuses — this is the second wall.
513
+ */
514
+ function expectApp(value, at) {
515
+ if (value === 'gjs' || value === 'node')
516
+ return value;
517
+ throw new Error(`gjsify ship: ${at} must be "gjs" or "node", got ${JSON.stringify(value)}.`);
518
+ }
433
519
  function expectString(value, at) {
434
520
  if (typeof value !== 'string') {
435
521
  throw new Error(`gjsify ship: ${at} must be a string. Re-run the \`--stage\` phase.`);
@@ -42,3 +42,51 @@ export declare function writeStage(root: string, files: readonly StagedFile[]):
42
42
  * nothing.
43
43
  */
44
44
  export declare function readStage(roots: readonly string[], plan: readonly StagePlanEntry[]): PayloadEntry[];
45
+ /**
46
+ * Materialise a PAYLOAD — bytes already in hand — under `root`, replacing it.
47
+ *
48
+ * The twin of {@link writeStage}, and the difference is what each takes.
49
+ * `writeStage` writes a PLAN, whose sources are paths on the assembling host or
50
+ * strings; this writes what {@link readStage} handed back, which is the only shape
51
+ * available on a host that has the stage and nothing else.
52
+ *
53
+ * It exists for the one artifact in `FORMATS` that is a DIRECTORY: a `<App>.app`
54
+ * is not a container around the payload, it IS the payload plus the format's own
55
+ * overlay, laid out on disk. Writing it through this function rather than by
56
+ * copying the stage directory keeps the same two properties every other packer
57
+ * has — the modes come from the plan (`readStage` has already applied them, and
58
+ * the artifact upload that flattens them cannot reach in between), and the
59
+ * stage's own `.gjsify-ship-stage.json` stays out, because it was never in the
60
+ * payload.
61
+ *
62
+ * `root` is WIPED first, exactly as `writeStage` wipes: a `.app` left over from a
63
+ * previous run with a file the current payload no longer names would otherwise be
64
+ * signed, zipped and shipped.
65
+ *
66
+ * `stripPrefix` is what makes it the ARTIFACT rather than a directory holding
67
+ * one, and leaving it out produced exactly that: every staged darwin path already
68
+ * begins with `<App>.app/` — that is what `Layout.root` means — so writing them
69
+ * verbatim under an artifact called `<App>.app` gave
70
+ * `out/Ship Demo.app/Ship Demo.app/Contents/…`. The Finder shows the outer
71
+ * directory as a plain folder, so the failure is a bundle that is not one, and it
72
+ * is invisible to every reader that walks the tree looking for `Contents/`.
73
+ * Measured on the WIP this replaces; the discriminator is the depth assertion in
74
+ * `tests/e2e/ship-macos`.
75
+ *
76
+ * A path OUTSIDE `stripPrefix` throws rather than being written somewhere near
77
+ * the artifact. The alternative — pass it through — would put a file beside the
78
+ * bundle instead of inside it, which is the shape ADR 0024 § A4 records
79
+ * `codesign` refusing, and it would be silent.
80
+ */
81
+ export declare function writePayload(root: string, payload: readonly PayloadEntry[], stripPrefix: string): void;
82
+ /**
83
+ * Total bytes of the regular files under `root`.
84
+ *
85
+ * What `packOne` reports for a `FormatDescriptor.artifactKind: 'directory'`.
86
+ * `statSync` on a directory answers the directory ENTRY's size — 4096 on ext4 —
87
+ * so a `.app` carrying a 20 MiB bundle would be printed as "4096 bytes", which is
88
+ * not a rounding error but a different number. Directory entries themselves are
89
+ * deliberately not counted: what a user wants to know is how much payload they
90
+ * are about to move.
91
+ */
92
+ export declare function directorySize(root: string): number;
@@ -12,7 +12,7 @@
12
12
  // A stage now arrives from another machine, so "the tree and the plan disagree"
13
13
  // stopped being impossible and became a transfer failure — and every way of
14
14
  // papering over it produces an artifact that installs.
15
- import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join, sep } from 'node:path';
17
17
  import { listFilesRecursive } from './discover.js';
18
18
  import { STAGE_MANIFEST_FILE } from './stage-manifest.js';
@@ -45,7 +45,26 @@ export function writeStage(root, files) {
45
45
  for (const file of files) {
46
46
  const target = join(root, file.path.split('/').join(sep));
47
47
  mkdirSync(dirname(target), { recursive: true });
48
- const data = file.source.kind === 'text' ? new TextEncoder().encode(file.source.text) : readFileSync(file.source.path);
48
+ // A `switch` with a `never` guard rather than a ternary chain: the third
49
+ // source kind arrived because a LAYOUT started owning a binary, and a
50
+ // fourth would otherwise be written as whatever the last branch happens
51
+ // to be — the same closed-vocabulary hazard `packOne` uses one for.
52
+ let data;
53
+ switch (file.source.kind) {
54
+ case 'text':
55
+ data = new TextEncoder().encode(file.source.text);
56
+ break;
57
+ case 'file':
58
+ data = new Uint8Array(readFileSync(file.source.path));
59
+ break;
60
+ case 'bytes':
61
+ data = file.source.data;
62
+ break;
63
+ default: {
64
+ const unhandled = file.source;
65
+ throw new Error(`gjsify ship: no writer for the "${JSON.stringify(unhandled)}" source kind.`);
66
+ }
67
+ }
49
68
  writeFileSync(target, data);
50
69
  chmodSync(target, file.mode);
51
70
  }
@@ -114,3 +133,73 @@ export function readStage(roots, plan) {
114
133
  }
115
134
  return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
116
135
  }
136
+ /**
137
+ * Materialise a PAYLOAD — bytes already in hand — under `root`, replacing it.
138
+ *
139
+ * The twin of {@link writeStage}, and the difference is what each takes.
140
+ * `writeStage` writes a PLAN, whose sources are paths on the assembling host or
141
+ * strings; this writes what {@link readStage} handed back, which is the only shape
142
+ * available on a host that has the stage and nothing else.
143
+ *
144
+ * It exists for the one artifact in `FORMATS` that is a DIRECTORY: a `<App>.app`
145
+ * is not a container around the payload, it IS the payload plus the format's own
146
+ * overlay, laid out on disk. Writing it through this function rather than by
147
+ * copying the stage directory keeps the same two properties every other packer
148
+ * has — the modes come from the plan (`readStage` has already applied them, and
149
+ * the artifact upload that flattens them cannot reach in between), and the
150
+ * stage's own `.gjsify-ship-stage.json` stays out, because it was never in the
151
+ * payload.
152
+ *
153
+ * `root` is WIPED first, exactly as `writeStage` wipes: a `.app` left over from a
154
+ * previous run with a file the current payload no longer names would otherwise be
155
+ * signed, zipped and shipped.
156
+ *
157
+ * `stripPrefix` is what makes it the ARTIFACT rather than a directory holding
158
+ * one, and leaving it out produced exactly that: every staged darwin path already
159
+ * begins with `<App>.app/` — that is what `Layout.root` means — so writing them
160
+ * verbatim under an artifact called `<App>.app` gave
161
+ * `out/Ship Demo.app/Ship Demo.app/Contents/…`. The Finder shows the outer
162
+ * directory as a plain folder, so the failure is a bundle that is not one, and it
163
+ * is invisible to every reader that walks the tree looking for `Contents/`.
164
+ * Measured on the WIP this replaces; the discriminator is the depth assertion in
165
+ * `tests/e2e/ship-macos`.
166
+ *
167
+ * A path OUTSIDE `stripPrefix` throws rather than being written somewhere near
168
+ * the artifact. The alternative — pass it through — would put a file beside the
169
+ * bundle instead of inside it, which is the shape ADR 0024 § A4 records
170
+ * `codesign` refusing, and it would be silent.
171
+ */
172
+ export function writePayload(root, payload, stripPrefix) {
173
+ // `''` means "the payload is already artifact-relative"; anything else is a
174
+ // directory name and gets its separator here rather than at three call sites.
175
+ const strip = stripPrefix === '' ? '' : `${stripPrefix}/`;
176
+ rmSync(root, { recursive: true, force: true, maxRetries: 5 });
177
+ mkdirSync(root, { recursive: true });
178
+ for (const entry of payload) {
179
+ if (!entry.path.startsWith(strip)) {
180
+ throw new Error(`gjsify ship: ${entry.path} is outside ${stripPrefix || 'the artifact'}, which this artifact ` +
181
+ 'IS — so there is nowhere inside it for the file to go. A payload file that escaped the ' +
182
+ 'bundle would ship beside it, unsigned and unfound.');
183
+ }
184
+ const target = join(root, entry.path.slice(strip.length).split('/').join(sep));
185
+ mkdirSync(dirname(target), { recursive: true });
186
+ writeFileSync(target, entry.data);
187
+ chmodSync(target, entry.mode);
188
+ }
189
+ }
190
+ /**
191
+ * Total bytes of the regular files under `root`.
192
+ *
193
+ * What `packOne` reports for a `FormatDescriptor.artifactKind: 'directory'`.
194
+ * `statSync` on a directory answers the directory ENTRY's size — 4096 on ext4 —
195
+ * so a `.app` carrying a 20 MiB bundle would be printed as "4096 bytes", which is
196
+ * not a rounding error but a different number. Directory entries themselves are
197
+ * deliberately not counted: what a user wants to know is how much payload they
198
+ * are about to move.
199
+ */
200
+ export function directorySize(root) {
201
+ let total = 0;
202
+ for (const rel of listFilesRecursive(root))
203
+ total += statSync(join(root, rel.split('/').join(sep))).size;
204
+ return total;
205
+ }
@@ -7,13 +7,23 @@ export interface StagedMode {
7
7
  }
8
8
  /** One file in the payload, addressed relative to the install prefix. */
9
9
  export interface StagedFile extends StagedMode {
10
- /** Where the bytes come from. */
10
+ /**
11
+ * Where the bytes come from.
12
+ *
13
+ * `bytes` is the third kind because a layout can now OWN a binary: the
14
+ * windows row emits a GUI-subsystem launcher (`utils/ship/pe-launcher.ts`)
15
+ * that exists nowhere on disk and is not text. Spelling it as `text` would
16
+ * round-trip it through UTF-8 and rewrite every byte above 0x7f.
17
+ */
11
18
  source: {
12
19
  kind: 'text';
13
20
  text: string;
14
21
  } | {
15
22
  kind: 'file';
16
23
  path: string;
24
+ } | {
25
+ kind: 'bytes';
26
+ data: Uint8Array;
17
27
  };
18
28
  }
19
29
  /**
@@ -36,7 +46,22 @@ export interface ShipArtifact {
36
46
  /** Size in bytes. */
37
47
  size: number;
38
48
  }
39
- export type FormatId = 'deb' | 'rpm' | 'flatpak';
49
+ /**
50
+ * The closed vocabulary of formats.
51
+ *
52
+ * The two macOS members are spelled `macos-…` rather than `app` / `app-zip`, and
53
+ * the reason is collision rather than length: the bare word `app` is already
54
+ * taken three times over in this subsystem — the `gjsify.app` config key,
55
+ * `ShipSettings.app`, `PackSettings.app` — and every one of them means the
56
+ * INTERPRETER. A member of this union is also a directory name (`overlay/<id>/`)
57
+ * and a `--target` value a user types, so the ambiguity would have reached both a
58
+ * path and a command line.
59
+ *
60
+ * `scripts/check-ship-format-vocabulary.mjs` reads the declaration below
61
+ * TEXTUALLY and fails if a comment gives it a second match, which is why this one
62
+ * describes the union without quoting its head.
63
+ */
64
+ export type FormatId = 'deb' | 'rpm' | 'flatpak' | 'macos-app' | 'macos-app-zip' | 'macos-app-dmg' | 'windows-dir' | 'windows-dir-zip' | 'msi';
40
65
  /**
41
66
  * The formats whose ARTIFACT carries a distro dependency list.
42
67
  *
@@ -57,6 +82,23 @@ export type FormatId = 'deb' | 'rpm' | 'flatpak';
57
82
  export type DistroFormatId = Extract<FormatId, 'deb' | 'rpm'>;
58
83
  /** An operating system in the repo-wide `process.platform` spelling (root AGENTS.md § Runtime & platform model). */
59
84
  export type HostOs = 'linux' | 'darwin' | 'win32';
85
+ /**
86
+ * Commands a packer EXECS — a flat LIST when every host runs the same ones, and
87
+ * a per-OS MAP when the backend is host-SELECTED.
88
+ *
89
+ * The map arrived with the `.msi` and nothing else needs it, which is exactly why
90
+ * it is a union rather than a map everywhere: five of the eight rows exec one tool
91
+ * or none, and spelling `{ linux: [], darwin: [], win32: [] }` on each of them
92
+ * would make five rows say three times over what one row has to say once.
93
+ *
94
+ * The `.msi` cannot use the flat form and be honest. It is produced by `wixl` on
95
+ * Linux and by WiX v3 on Windows out of ONE authored `.wxs` (ADR 0024 § A6), so a
96
+ * flat list is wrong in both directions: the union of the two demands `wixl` of a
97
+ * Windows host that will never run it, and either half alone silently claims the
98
+ * other OS needs nothing. {@link requiredToolsOn} is the one resolver, and
99
+ * `assertToolsInstalled` takes the host it resolves for.
100
+ */
101
+ export type RequiredTools = readonly string[] | Readonly<Partial<Record<HostOs, readonly string[]>>>;
60
102
  /**
61
103
  * Where a format can be FINISHED, and who can read the result back (ADR 0024 § A3).
62
104
  *
@@ -74,7 +116,7 @@ export interface HostRequirement {
74
116
  /** OSes whose tooling can finish this format. `'any'` = pure JS, under GJS, offline. */
75
117
  finishOn: 'any' | readonly HostOs[];
76
118
  /** Commands the packer EXECS. Empty iff this tree writes the format itself. */
77
- requiredTools: readonly string[];
119
+ requiredTools: RequiredTools;
78
120
  /**
79
121
  * How to install {@link requiredTools}, in this format's own words.
80
122
  *
@@ -83,8 +125,13 @@ export interface HostRequirement {
83
125
  * `assertToolsInstalled`, which is correct for exactly one format and would
84
126
  * have told the first `.dmg` or `.msi` user to install flatpak — from the
85
127
  * same branchless dispatch this whole table exists to keep branchless.
86
- * Required whenever `requiredTools` is non-empty; `flatpak.spec.ts` holds
87
- * that, the same way it holds `selfReading`.
128
+ * Required whenever `requiredTools` is non-empty on ANY host; `flatpak.spec.ts`
129
+ * holds that, the same way it holds `selfReading`.
130
+ *
131
+ * ONE string even for a host-selected backend, and that is not a shortcut: the
132
+ * refusal already names the tool that is missing, so a hint covering both routes
133
+ * ("Fedora: … / Windows: …") answers the reader's question without a second
134
+ * per-OS field to keep in step with the first.
88
135
  */
89
136
  installHint?: string;
90
137
  oracle: {
@@ -110,6 +157,22 @@ export interface HostRequirement {
110
157
  */
111
158
  export interface FormatDescriptor {
112
159
  id: FormatId;
160
+ /**
161
+ * The OS whose LAYOUT this format wraps.
162
+ *
163
+ * Not `host.finishOn`, and the two must not be collapsed: `finishOn` says
164
+ * where the container can be PRODUCED, this says which staged tree it is a
165
+ * container FOR. A `.dmg` is `finishOn: ['darwin']` and `layoutOs: 'darwin'`,
166
+ * which makes them look like one field — but the `.app` zip beside it is
167
+ * `finishOn: 'any'` with the same `layoutOs`, and that is the pair that
168
+ * proves they are different questions.
169
+ *
170
+ * This is what gives `defaultFormatIds` its second criterion. Without it, a
171
+ * `.app` and a Windows program directory — both `finishOn: 'any'` — would
172
+ * make a bare `gjsify ship` on Linux emit five artifacts, three of which are
173
+ * for operating systems the caller did not ask about.
174
+ */
175
+ layoutOs: HostOs;
113
176
  /** Install prefix the format's contents hang under. */
114
177
  prefix: string;
115
178
  /** Where this format can be packed, what it execs, and who reads it back. */
@@ -119,6 +182,46 @@ export interface FormatDescriptor {
119
182
  * dependencies are not a package list at all — see {@link DistroFormatId}.
120
183
  */
121
184
  depends: DistroFormatId | null;
185
+ /**
186
+ * The interpreters this format's runtime can actually provide at RUN time.
187
+ *
188
+ * A list and not a boolean, and on the descriptor rather than in a branch,
189
+ * because the answer is a property of the format's runtime and nothing else
190
+ * can be asked for it. `deb`/`rpm` carry both: a distribution ships `gjs` and
191
+ * `nodejs`, and the package declares whichever it execs.
192
+ *
193
+ * ⚠️ `flatpak` carries `gjs` ALONE, and that is a measured limit rather than
194
+ * a conservative default. `org.gnome.Platform` ships `gjs` and no `node`;
195
+ * Node exists only as `org.freedesktop.Sdk.Extension.node2x`, which puts
196
+ * `/usr/lib/sdk/node24/bin` on the **build** PATH — this repo's own
197
+ * `guides/flatpak-cli-tool.md` says so in those words. So a `--app node`
198
+ * Flatpak would install and die at `exec node`.
199
+ *
200
+ * That case was reachable: `assertLauncherMatchesInterpreter` is about the
201
+ * launcher agreeing with the DEPENDENCY, and a Flatpak has no dependency
202
+ * list, so the check is vacuous here — it compares `settings.app` against a
203
+ * launcher rendered from `settings.app`. Only the RUNTIME knows, and only
204
+ * this row can say.
205
+ */
206
+ interpreters: readonly ('gjs' | 'node')[];
207
+ /**
208
+ * Why this format's runtime provides {@link interpreters} and no more — the
209
+ * body of the refusal a project on the wrong interpreter gets.
210
+ *
211
+ * On the row rather than inside the refusal, and the incident is one line
212
+ * old: the refusal carried Flatpak's paragraph about `org.gnome.Platform`
213
+ * hardcoded, and the first macOS row printed it — telling somebody shipping a
214
+ * `.app` that `org.freedesktop.Sdk.Extension.node2x` puts node on the build
215
+ * path. Exactly the shape `installHint` moved out of `assertToolsInstalled`
216
+ * for, one field over.
217
+ *
218
+ * REQUIRED, including on the two rows that accept both interpreters and can
219
+ * therefore never print it. A row that answers "nothing here is restricted,
220
+ * and this is why" has ANSWERED; an optional field is one a sixth format can
221
+ * leave undefined while restricting `interpreters`, and the refusal would go
222
+ * back to having nothing to say.
223
+ */
224
+ interpreterGap: string;
122
225
  /** Where the project's licence text goes in this format's overlay. */
123
226
  licenseDest: (binaryName: string) => string;
124
227
  /**
@@ -137,6 +240,19 @@ export interface FormatDescriptor {
137
240
  archName: (arch: string, archIndependent: boolean) => string;
138
241
  /** Artifact filename, given the pack-time settings and the resolved arch label. */
139
242
  fileName: (settings: PackSettings, archLabel: string) => string;
243
+ /**
244
+ * Whether {@link fileName} names a FILE or a DIRECTORY.
245
+ *
246
+ * A `.deb`, an `.rpm` and a `.flatpak` are single files; a `<App>.app` is a
247
+ * directory that macOS treats as one object, and it is the first artifact in
248
+ * this table that is not a file. Recorded rather than inferred from the
249
+ * suffix, because `packOne` reports a SIZE and `statSync` on a directory
250
+ * answers 4096 on ext4 — a `.app` carrying a 20 MiB bundle would be printed
251
+ * as "4096 bytes", which is not a rounding error but a different number
252
+ * entirely. The `.dmg` over that same tree is `'file'` again, which is what
253
+ * makes this a property of the CONTAINER rather than "macOS is special".
254
+ */
255
+ artifactKind: 'file' | 'directory';
140
256
  }
141
257
  /** One shared-mime-info type definition (`gjsify.ship.mimeTypes`). */
142
258
  export interface ShipMimeType {
@@ -180,12 +296,22 @@ export interface ShipMimeType {
180
296
  * `share/locale/<rel>`, and no packer reads the field. So translations
181
297
  * cross in the payload, where they belong, and the `abs` path does not
182
298
  * cross at all. This list being explicit is what caught that.
183
- * - `name`, `kind`, `execArgs` — read by the planner and the metadata
184
- * renderers, all of which have run by the time a stage exists. `appId` was
185
- * on this line and MOVED: the Flatpak packer needs it at pack time twice
186
- * over — it is the manifest's `id` and the ref `flatpak build-bundle`
187
- * exports — and neither is derivable from the payload, because the desktop
188
- * entry that carries the id is only staged for `kind: 'app'`.
299
+ * - `fontFiles` — the same shape one milestone later (ADR 0038). `planStage`
300
+ * copies each face into `share/fonts/<appId>/`, no packer reads the field, and
301
+ * the only pack-time question — "does this payload carry fonts" — is answered
302
+ * by the payload's own paths. There is no scriptlet to key off it either:
303
+ * fontconfig scans the directory and caches lazily per user, measured rather
304
+ * than assumed.
305
+ * - `kind`, `execArgs` — read by the planner and the metadata renderers, all of
306
+ * which have run by the time a stage exists. `appId` was on this line and
307
+ * MOVED: the Flatpak packer needs it at pack time twice over — it is the
308
+ * manifest's `id` and the ref `flatpak build-bundle` exports — and neither is
309
+ * derivable from the payload, because the desktop entry that carries the id
310
+ * is only staged for `kind: 'app'`. `name` was on it too and moved for the
311
+ * same kind of reason one milestone later: the macOS artifact IS
312
+ * `<name>.app`, so the packing host has to be able to NAME it, and no other
313
+ * field in this object can spell it (`binaryName` is the executable inside
314
+ * the bundle, not the bundle).
189
315
  * - `arch` — carried by the manifest's `target`, which is also the value a
190
316
  * mismatched stage is refused on, so it lives in exactly one place.
191
317
  * - `mimeTypes` — phase 1 renders the shared-mime-info document into the
@@ -198,6 +324,17 @@ export interface PackSettings {
198
324
  binaryName: string;
199
325
  /** Reverse-DNS application id — the Flatpak manifest's `id` and the exported ref. */
200
326
  appId: string;
327
+ /**
328
+ * Human-readable display name — and, on macOS, the `<App>.app` directory itself.
329
+ *
330
+ * On this side of the host boundary because the darwin packers cannot work
331
+ * without it: `macos-app`'s artifact is a directory called `${name}.app` and
332
+ * `macos-app-zip` wraps that directory, so a finishing host with the stage and
333
+ * nothing else still has to be able to spell it. Also what `Info.plist`'s
334
+ * `CFBundleName`/`CFBundleDisplayName` carry — see `layout.ts`'s
335
+ * `LayoutMetadataInput`.
336
+ */
337
+ name: string;
201
338
  /** Upstream version, normalised for package managers. */
202
339
  version: string;
203
340
  /** Package revision within one upstream version (deb revision / rpm release). */
@@ -219,8 +356,29 @@ export interface PackSettings {
219
356
  extraDepends: Record<DistroFormatId, string[]>;
220
357
  /** Project-supplied GI-namespace → package rows, filling gaps in the built-in table. */
221
358
  typelibPackages: Record<string, Record<DistroFormatId, string>>;
222
- /** Minimum GJS the emitted dependency asks for. */
359
+ /**
360
+ * The interpreter the launcher execs, and therefore the one the package
361
+ * depends on. THIS TARGET's runtime, resolved once by `resolveShipApp` out of
362
+ * `gjsify.ship.app.<os>` falling back to `gjsify.app`; default `'gjs'`.
363
+ *
364
+ * PER TARGET and never the project field, which is #1486's fix and whose
365
+ * measurement `resolveShipApp` carries. That the stage manifest records the
366
+ * RESOLVED value is what keeps `--from-stage` honest: the packing host has no
367
+ * project and needs none.
368
+ *
369
+ * ON `PackSettings`, not on `ShipSettings` alone, because BOTH halves need it
370
+ * and they must not answer it differently: `renderLauncher` writes
371
+ * `exec gjs -m` or `exec node` from this field, and `deriveDepends` seeds
372
+ * `gjs >= …` or `nodejs >= …` from the same one. Two sources here is the
373
+ * defect this field replaces — a filename heuristic once produced
374
+ * `Depends: gjs (>= 1.86), nodejs (>= 24)` on a package whose launcher execed
375
+ * gjs.
376
+ */
377
+ app: 'gjs' | 'node';
378
+ /** Minimum GJS the emitted dependency asks for. Used only for `app: 'gjs'`. */
223
379
  minGjsVersion: string;
380
+ /** Minimum Node major. Used only for `app: 'node'`. */
381
+ minNodeVersion: string;
224
382
  /** The Flatpak manifest's non-payload half, fully defaulted at stage time. */
225
383
  flatpak: ShipFlatpakSettings;
226
384
  }
@@ -260,8 +418,6 @@ export interface ShipFlatpakSettings {
260
418
  export interface ShipSettings extends PackSettings {
261
419
  /** Absolute path to the project being shipped. */
262
420
  projectDir: string;
263
- /** Human-readable display name. */
264
- name: string;
265
421
  /** Absolute path to the licence file, when one was found. */
266
422
  licenseFile?: string;
267
423
  /** `'app'` stages a desktop entry and an icon; `'cli'` stages neither. */
@@ -298,6 +454,16 @@ export interface ShipSettings extends PackSettings {
298
454
  rel: string;
299
455
  abs: string;
300
456
  }[];
457
+ /**
458
+ * Absolute paths of the font faces the application ships (`gjsify.ship.fonts`).
459
+ *
460
+ * Read by `planStage` alone, which copies each into `share/fonts/<appId>/` — the
461
+ * one directory fontconfig reaches through the launcher's existing
462
+ * `XDG_DATA_DIRS` (ADR 0038). Nothing in the pack path reads it, which is why it
463
+ * is not on {@link PackSettings}; the fonts cross in the payload, like the
464
+ * catalogues.
465
+ */
466
+ fontFiles: string[];
301
467
  /** `<dest relative to prefix>` → absolute source path. */
302
468
  extraFiles: Record<string, string>;
303
469
  /** Arguments appended to the launcher's `exec` line. */
@@ -306,4 +472,15 @@ export interface ShipSettings extends PackSettings {
306
472
  outDir: string;
307
473
  /** Target architecture in `process.arch` spelling. */
308
474
  arch: string;
475
+ /**
476
+ * The OS whose LAYOUT this run assembles, from the `gjsify ship <os>`
477
+ * positional (ADR 0024 § A2).
478
+ *
479
+ * Beside `arch` and for the same reason: together they are the stage's
480
+ * `target`, the string `--expect-target <os>-<arch>` is compared against, and
481
+ * the pair a matrix leg is identified by. Deliberately NOT in
482
+ * {@link PackSettings} — the manifest's `target` carries it, so it lives in
483
+ * exactly one place on the far side of the host boundary.
484
+ */
485
+ layoutOs: HostOs;
309
486
  }