@gjsify/cli 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/cli.gjs.mjs +192 -165
  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 +833 -54
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/types/config-data.d.ts +63 -1
  11. package/lib/utils/msgfmt-merge.d.ts +35 -0
  12. package/lib/utils/msgfmt-merge.js +102 -0
  13. package/lib/utils/npm-otp.d.ts +27 -2
  14. package/lib/utils/npm-otp.js +82 -7
  15. package/lib/utils/onboard-discovery.d.ts +68 -0
  16. package/lib/utils/onboard-discovery.js +178 -0
  17. package/lib/utils/onboard-probe.d.ts +118 -1
  18. package/lib/utils/onboard-probe.js +153 -4
  19. package/lib/utils/prompt-output.d.ts +12 -0
  20. package/lib/utils/prompt-output.js +54 -0
  21. package/lib/utils/prompt.js +22 -2
  22. package/lib/utils/publishable-packages.d.ts +1 -1
  23. package/lib/utils/publishable-packages.js +1 -0
  24. package/lib/utils/resolve-npm-package.d.ts +56 -0
  25. package/lib/utils/resolve-npm-package.js +69 -0
  26. package/lib/utils/ship/app-runtime.d.ts +214 -0
  27. package/lib/utils/ship/app-runtime.js +429 -0
  28. package/lib/utils/ship/depends.d.ts +50 -0
  29. package/lib/utils/ship/depends.js +117 -3
  30. package/lib/utils/ship/discover.js +18 -1
  31. package/lib/utils/ship/dmg.d.ts +71 -0
  32. package/lib/utils/ship/dmg.js +181 -0
  33. package/lib/utils/ship/flatpak.js +1 -1
  34. package/lib/utils/ship/formats.d.ts +85 -7
  35. package/lib/utils/ship/formats.js +734 -15
  36. package/lib/utils/ship/launcher.d.ts +13 -3
  37. package/lib/utils/ship/launcher.js +362 -17
  38. package/lib/utils/ship/layout.d.ts +268 -0
  39. package/lib/utils/ship/layout.js +351 -0
  40. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  41. package/lib/utils/ship/localize-metadata.js +126 -0
  42. package/lib/utils/ship/mime.js +1 -9
  43. package/lib/utils/ship/msi.d.ts +126 -0
  44. package/lib/utils/ship/msi.js +417 -0
  45. package/lib/utils/ship/node-runtime.d.ts +71 -0
  46. package/lib/utils/ship/node-runtime.js +122 -0
  47. package/lib/utils/ship/payload.d.ts +156 -5
  48. package/lib/utils/ship/payload.js +580 -14
  49. package/lib/utils/ship/plan.d.ts +21 -0
  50. package/lib/utils/ship/plan.js +20 -7
  51. package/lib/utils/ship/plist.d.ts +58 -0
  52. package/lib/utils/ship/plist.js +198 -0
  53. package/lib/utils/ship/rpm.js +9 -3
  54. package/lib/utils/ship/schemas.d.ts +61 -0
  55. package/lib/utils/ship/schemas.js +118 -0
  56. package/lib/utils/ship/scripts.js +7 -3
  57. package/lib/utils/ship/settings.d.ts +19 -1
  58. package/lib/utils/ship/settings.js +12 -1
  59. package/lib/utils/ship/share-dirs.d.ts +23 -0
  60. package/lib/utils/ship/share-dirs.js +46 -0
  61. package/lib/utils/ship/signing.d.ts +195 -0
  62. package/lib/utils/ship/signing.js +317 -0
  63. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  64. package/lib/utils/ship/stage-manifest.js +99 -13
  65. package/lib/utils/ship/stage-writer.d.ts +48 -0
  66. package/lib/utils/ship/stage-writer.js +71 -1
  67. package/lib/utils/ship/types.d.ts +158 -13
  68. package/lib/utils/ship/xml.d.ts +7 -0
  69. package/lib/utils/ship/xml.js +25 -0
  70. package/lib/utils/ship/zip.d.ts +69 -0
  71. package/lib/utils/ship/zip.js +245 -0
  72. package/lib/utils/trust-registry.js +5 -0
  73. package/package.json +19 -19
@@ -1,4 +1,4 @@
1
- import type { FormatDescriptor, FormatId, PackSettings, ShipSettings, StagedFile, StagedMode } from './types.js';
1
+ import type { FormatDescriptor, FormatId, HostOs, PackSettings, ShipSettings, StagedFile, StagedMode } from './types.js';
2
2
  /** The one file a stage carries that is not payload. Lives at the stage ROOT. */
3
3
  export declare const STAGE_MANIFEST_FILE = ".gjsify-ship-stage.json";
4
4
  /**
@@ -16,21 +16,49 @@ export declare const STAGE_MANIFEST_FILE = ".gjsify-ship-stage.json";
16
16
  * `settings.flatpak` would not skip a field, it would pack a Flatpak against
17
17
  * whatever runtime IT defaults to — a different `org.gnome.Platform` version
18
18
  * than the project asked for, at exit 0.
19
- */
20
- export declare const STAGE_SCHEMA_VERSION = 2;
21
- /**
22
- * The OS whose layout `planStage` produces.
23
19
  *
24
- * A constant rather than a measurement, because it is a property of the PLAN
25
- * (`bin/`, `lib/<name>/`, `share/…` is the Linux/XDG layout), not of the host
26
- * that ran it — assembling is cross-platform (ADR 0024 § A1). When
27
- * `gjsify ship darwin --stage` lands this becomes the positional's value, and
28
- * this constant is where the compiler will point.
20
+ * 3 added `settings.minNodeVersion` and 4 added `settings.app`. Same case as 2
21
+ * for the same reason: an older reader ignoring `app` does not skip a field, it
22
+ * has no concept of an interpreter choice at all, so it seeds `gjs >= …` for a
23
+ * stage whose launcher — already rendered, already in the tree it is packing —
24
+ * execs `node`. The package then declares one interpreter and runs another.
25
+ *
26
+ * Stated as a consequence and not as an incident, because it never happened:
27
+ * `app` and the launcher landed together. An earlier version of this comment
28
+ * claimed the incident anyway, which is worth leaving on the record — a rule
29
+ * with an invented reason gets "simplified" back out the first time somebody
30
+ * notices the reason is not real.
31
+ *
32
+ * WHY TWO BUMPS IN ONE CHANGE, when neither was ever released. Because 3 and 4
33
+ * are DIFFERENT SHAPES and 3 briefly existed: the intermediate commit wrote
34
+ * `schema: 3` with no `app`. Folding `app` into 3 would have made this reader
35
+ * meet that stage and fail on `settings.app must be "gjs" or "node", got
36
+ * undefined` — fail-closed, but naming a field instead of the one thing the
37
+ * reader can do about it. At 4 the schema check above catches it first and says
38
+ * "re-run the `--stage` phase with this gjsify", which is the whole reason this
39
+ * constant's header is an argument for bumping whenever a reader could mis-read.
40
+ *
41
+ * 5 added `settings.name`, and it was decided against the criterion above rather
42
+ * than reflexively — #1354 M2a changed two things and only one of them warrants a
43
+ * bump:
44
+ *
45
+ * - NOT the two new format ids. `formats[]` and `overlay`'s keys carry new
46
+ * VALUES in fields whose meaning is unchanged, and `readStageManifest` already
47
+ * refuses a `formats[i]` it does not know BY NAME. An older gjsify meeting a
48
+ * macOS stage therefore fails closed with a message about the format, which is
49
+ * the right message. Adding a member to a validated enum is the case this
50
+ * header says not to bump for.
51
+ * - YES `settings.name`. It is REQUIRED, so this reader meeting a schema-4 stage
52
+ * would fail on `settings.name must be a string` — naming a field instead of
53
+ * the one thing the reader can do about it. That is the same shape the 3-to-4
54
+ * paragraph describes, and at 5 the schema check above fires first and says
55
+ * "re-run the `--stage` phase with this gjsify".
29
56
  */
30
- export declare const STAGE_LAYOUT_OS = "linux";
57
+ export declare const STAGE_SCHEMA_VERSION = 5;
31
58
  /** What this stage was assembled FOR, in the repo-wide `${process.platform}-${process.arch}` spelling. */
32
59
  export interface StageTarget {
33
- os: string;
60
+ /** The layout the stage carries, in the `process.platform` spelling — `win32`, never `windows`. */
61
+ os: HostOs;
34
62
  arch: string;
35
63
  }
36
64
  /** One staged path, its planned mode, and the size it had when the stage was written. */
@@ -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';
@@ -114,3 +114,73 @@ export function readStage(roots, plan) {
114
114
  }
115
115
  return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
116
116
  }
117
+ /**
118
+ * Materialise a PAYLOAD — bytes already in hand — under `root`, replacing it.
119
+ *
120
+ * The twin of {@link writeStage}, and the difference is what each takes.
121
+ * `writeStage` writes a PLAN, whose sources are paths on the assembling host or
122
+ * strings; this writes what {@link readStage} handed back, which is the only shape
123
+ * available on a host that has the stage and nothing else.
124
+ *
125
+ * It exists for the one artifact in `FORMATS` that is a DIRECTORY: a `<App>.app`
126
+ * is not a container around the payload, it IS the payload plus the format's own
127
+ * overlay, laid out on disk. Writing it through this function rather than by
128
+ * copying the stage directory keeps the same two properties every other packer
129
+ * has — the modes come from the plan (`readStage` has already applied them, and
130
+ * the artifact upload that flattens them cannot reach in between), and the
131
+ * stage's own `.gjsify-ship-stage.json` stays out, because it was never in the
132
+ * payload.
133
+ *
134
+ * `root` is WIPED first, exactly as `writeStage` wipes: a `.app` left over from a
135
+ * previous run with a file the current payload no longer names would otherwise be
136
+ * signed, zipped and shipped.
137
+ *
138
+ * `stripPrefix` is what makes it the ARTIFACT rather than a directory holding
139
+ * one, and leaving it out produced exactly that: every staged darwin path already
140
+ * begins with `<App>.app/` — that is what `Layout.root` means — so writing them
141
+ * verbatim under an artifact called `<App>.app` gave
142
+ * `out/Ship Demo.app/Ship Demo.app/Contents/…`. The Finder shows the outer
143
+ * directory as a plain folder, so the failure is a bundle that is not one, and it
144
+ * is invisible to every reader that walks the tree looking for `Contents/`.
145
+ * Measured on the WIP this replaces; the discriminator is the depth assertion in
146
+ * `tests/e2e/ship-macos`.
147
+ *
148
+ * A path OUTSIDE `stripPrefix` throws rather than being written somewhere near
149
+ * the artifact. The alternative — pass it through — would put a file beside the
150
+ * bundle instead of inside it, which is the shape ADR 0024 § A4 records
151
+ * `codesign` refusing, and it would be silent.
152
+ */
153
+ export function writePayload(root, payload, stripPrefix) {
154
+ // `''` means "the payload is already artifact-relative"; anything else is a
155
+ // directory name and gets its separator here rather than at three call sites.
156
+ const strip = stripPrefix === '' ? '' : `${stripPrefix}/`;
157
+ rmSync(root, { recursive: true, force: true, maxRetries: 5 });
158
+ mkdirSync(root, { recursive: true });
159
+ for (const entry of payload) {
160
+ if (!entry.path.startsWith(strip)) {
161
+ throw new Error(`gjsify ship: ${entry.path} is outside ${stripPrefix || 'the artifact'}, which this artifact ` +
162
+ 'IS — so there is nowhere inside it for the file to go. A payload file that escaped the ' +
163
+ 'bundle would ship beside it, unsigned and unfound.');
164
+ }
165
+ const target = join(root, entry.path.slice(strip.length).split('/').join(sep));
166
+ mkdirSync(dirname(target), { recursive: true });
167
+ writeFileSync(target, entry.data);
168
+ chmodSync(target, entry.mode);
169
+ }
170
+ }
171
+ /**
172
+ * Total bytes of the regular files under `root`.
173
+ *
174
+ * What `packOne` reports for a `FormatDescriptor.artifactKind: 'directory'`.
175
+ * `statSync` on a directory answers the directory ENTRY's size — 4096 on ext4 —
176
+ * so a `.app` carrying a 20 MiB bundle would be printed as "4096 bytes", which is
177
+ * not a rounding error but a different number. Directory entries themselves are
178
+ * deliberately not counted: what a user wants to know is how much payload they
179
+ * are about to move.
180
+ */
181
+ export function directorySize(root) {
182
+ let total = 0;
183
+ for (const rel of listFilesRecursive(root))
184
+ total += statSync(join(root, rel.split('/').join(sep))).size;
185
+ return total;
186
+ }
@@ -36,7 +36,22 @@ export interface ShipArtifact {
36
36
  /** Size in bytes. */
37
37
  size: number;
38
38
  }
39
- export type FormatId = 'deb' | 'rpm' | 'flatpak';
39
+ /**
40
+ * The closed vocabulary of formats.
41
+ *
42
+ * The two macOS members are spelled `macos-…` rather than `app` / `app-zip`, and
43
+ * the reason is collision rather than length: the bare word `app` is already
44
+ * taken three times over in this subsystem — the `gjsify.app` config key,
45
+ * `ShipSettings.app`, `PackSettings.app` — and every one of them means the
46
+ * INTERPRETER. A member of this union is also a directory name (`overlay/<id>/`)
47
+ * and a `--target` value a user types, so the ambiguity would have reached both a
48
+ * path and a command line.
49
+ *
50
+ * `scripts/check-ship-format-vocabulary.mjs` reads the declaration below
51
+ * TEXTUALLY and fails if a comment gives it a second match, which is why this one
52
+ * describes the union without quoting its head.
53
+ */
54
+ export type FormatId = 'deb' | 'rpm' | 'flatpak' | 'macos-app' | 'macos-app-zip' | 'macos-app-dmg' | 'windows-dir' | 'windows-dir-zip' | 'msi';
40
55
  /**
41
56
  * The formats whose ARTIFACT carries a distro dependency list.
42
57
  *
@@ -57,6 +72,23 @@ export type FormatId = 'deb' | 'rpm' | 'flatpak';
57
72
  export type DistroFormatId = Extract<FormatId, 'deb' | 'rpm'>;
58
73
  /** An operating system in the repo-wide `process.platform` spelling (root AGENTS.md § Runtime & platform model). */
59
74
  export type HostOs = 'linux' | 'darwin' | 'win32';
75
+ /**
76
+ * Commands a packer EXECS — a flat LIST when every host runs the same ones, and
77
+ * a per-OS MAP when the backend is host-SELECTED.
78
+ *
79
+ * The map arrived with the `.msi` and nothing else needs it, which is exactly why
80
+ * it is a union rather than a map everywhere: five of the eight rows exec one tool
81
+ * or none, and spelling `{ linux: [], darwin: [], win32: [] }` on each of them
82
+ * would make five rows say three times over what one row has to say once.
83
+ *
84
+ * The `.msi` cannot use the flat form and be honest. It is produced by `wixl` on
85
+ * Linux and by WiX v3 on Windows out of ONE authored `.wxs` (ADR 0024 § A6), so a
86
+ * flat list is wrong in both directions: the union of the two demands `wixl` of a
87
+ * Windows host that will never run it, and either half alone silently claims the
88
+ * other OS needs nothing. {@link requiredToolsOn} is the one resolver, and
89
+ * `assertToolsInstalled` takes the host it resolves for.
90
+ */
91
+ export type RequiredTools = readonly string[] | Readonly<Partial<Record<HostOs, readonly string[]>>>;
60
92
  /**
61
93
  * Where a format can be FINISHED, and who can read the result back (ADR 0024 § A3).
62
94
  *
@@ -74,7 +106,7 @@ export interface HostRequirement {
74
106
  /** OSes whose tooling can finish this format. `'any'` = pure JS, under GJS, offline. */
75
107
  finishOn: 'any' | readonly HostOs[];
76
108
  /** Commands the packer EXECS. Empty iff this tree writes the format itself. */
77
- requiredTools: readonly string[];
109
+ requiredTools: RequiredTools;
78
110
  /**
79
111
  * How to install {@link requiredTools}, in this format's own words.
80
112
  *
@@ -83,8 +115,13 @@ export interface HostRequirement {
83
115
  * `assertToolsInstalled`, which is correct for exactly one format and would
84
116
  * have told the first `.dmg` or `.msi` user to install flatpak — from the
85
117
  * 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`.
118
+ * Required whenever `requiredTools` is non-empty on ANY host; `flatpak.spec.ts`
119
+ * holds that, the same way it holds `selfReading`.
120
+ *
121
+ * ONE string even for a host-selected backend, and that is not a shortcut: the
122
+ * refusal already names the tool that is missing, so a hint covering both routes
123
+ * ("Fedora: … / Windows: …") answers the reader's question without a second
124
+ * per-OS field to keep in step with the first.
88
125
  */
89
126
  installHint?: string;
90
127
  oracle: {
@@ -110,6 +147,22 @@ export interface HostRequirement {
110
147
  */
111
148
  export interface FormatDescriptor {
112
149
  id: FormatId;
150
+ /**
151
+ * The OS whose LAYOUT this format wraps.
152
+ *
153
+ * Not `host.finishOn`, and the two must not be collapsed: `finishOn` says
154
+ * where the container can be PRODUCED, this says which staged tree it is a
155
+ * container FOR. A `.dmg` is `finishOn: ['darwin']` and `layoutOs: 'darwin'`,
156
+ * which makes them look like one field — but the `.app` zip beside it is
157
+ * `finishOn: 'any'` with the same `layoutOs`, and that is the pair that
158
+ * proves they are different questions.
159
+ *
160
+ * This is what gives `defaultFormatIds` its second criterion. Without it, a
161
+ * `.app` and a Windows program directory — both `finishOn: 'any'` — would
162
+ * make a bare `gjsify ship` on Linux emit five artifacts, three of which are
163
+ * for operating systems the caller did not ask about.
164
+ */
165
+ layoutOs: HostOs;
113
166
  /** Install prefix the format's contents hang under. */
114
167
  prefix: string;
115
168
  /** Where this format can be packed, what it execs, and who reads it back. */
@@ -119,6 +172,46 @@ export interface FormatDescriptor {
119
172
  * dependencies are not a package list at all — see {@link DistroFormatId}.
120
173
  */
121
174
  depends: DistroFormatId | null;
175
+ /**
176
+ * The interpreters this format's runtime can actually provide at RUN time.
177
+ *
178
+ * A list and not a boolean, and on the descriptor rather than in a branch,
179
+ * because the answer is a property of the format's runtime and nothing else
180
+ * can be asked for it. `deb`/`rpm` carry both: a distribution ships `gjs` and
181
+ * `nodejs`, and the package declares whichever it execs.
182
+ *
183
+ * ⚠️ `flatpak` carries `gjs` ALONE, and that is a measured limit rather than
184
+ * a conservative default. `org.gnome.Platform` ships `gjs` and no `node`;
185
+ * Node exists only as `org.freedesktop.Sdk.Extension.node2x`, which puts
186
+ * `/usr/lib/sdk/node24/bin` on the **build** PATH — this repo's own
187
+ * `guides/flatpak-cli-tool.md` says so in those words. So a `--app node`
188
+ * Flatpak would install and die at `exec node`.
189
+ *
190
+ * That case was reachable: `assertLauncherMatchesInterpreter` is about the
191
+ * launcher agreeing with the DEPENDENCY, and a Flatpak has no dependency
192
+ * list, so the check is vacuous here — it compares `settings.app` against a
193
+ * launcher rendered from `settings.app`. Only the RUNTIME knows, and only
194
+ * this row can say.
195
+ */
196
+ interpreters: readonly ('gjs' | 'node')[];
197
+ /**
198
+ * Why this format's runtime provides {@link interpreters} and no more — the
199
+ * body of the refusal a project on the wrong interpreter gets.
200
+ *
201
+ * On the row rather than inside the refusal, and the incident is one line
202
+ * old: the refusal carried Flatpak's paragraph about `org.gnome.Platform`
203
+ * hardcoded, and the first macOS row printed it — telling somebody shipping a
204
+ * `.app` that `org.freedesktop.Sdk.Extension.node2x` puts node on the build
205
+ * path. Exactly the shape `installHint` moved out of `assertToolsInstalled`
206
+ * for, one field over.
207
+ *
208
+ * REQUIRED, including on the two rows that accept both interpreters and can
209
+ * therefore never print it. A row that answers "nothing here is restricted,
210
+ * and this is why" has ANSWERED; an optional field is one a sixth format can
211
+ * leave undefined while restricting `interpreters`, and the refusal would go
212
+ * back to having nothing to say.
213
+ */
214
+ interpreterGap: string;
122
215
  /** Where the project's licence text goes in this format's overlay. */
123
216
  licenseDest: (binaryName: string) => string;
124
217
  /**
@@ -137,6 +230,19 @@ export interface FormatDescriptor {
137
230
  archName: (arch: string, archIndependent: boolean) => string;
138
231
  /** Artifact filename, given the pack-time settings and the resolved arch label. */
139
232
  fileName: (settings: PackSettings, archLabel: string) => string;
233
+ /**
234
+ * Whether {@link fileName} names a FILE or a DIRECTORY.
235
+ *
236
+ * A `.deb`, an `.rpm` and a `.flatpak` are single files; a `<App>.app` is a
237
+ * directory that macOS treats as one object, and it is the first artifact in
238
+ * this table that is not a file. Recorded rather than inferred from the
239
+ * suffix, because `packOne` reports a SIZE and `statSync` on a directory
240
+ * answers 4096 on ext4 — a `.app` carrying a 20 MiB bundle would be printed
241
+ * as "4096 bytes", which is not a rounding error but a different number
242
+ * entirely. The `.dmg` over that same tree is `'file'` again, which is what
243
+ * makes this a property of the CONTAINER rather than "macOS is special".
244
+ */
245
+ artifactKind: 'file' | 'directory';
140
246
  }
141
247
  /** One shared-mime-info type definition (`gjsify.ship.mimeTypes`). */
142
248
  export interface ShipMimeType {
@@ -180,12 +286,16 @@ export interface ShipMimeType {
180
286
  * `share/locale/<rel>`, and no packer reads the field. So translations
181
287
  * cross in the payload, where they belong, and the `abs` path does not
182
288
  * 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'`.
289
+ * - `kind`, `execArgs` — read by the planner and the metadata renderers, all of
290
+ * which have run by the time a stage exists. `appId` was on this line and
291
+ * MOVED: the Flatpak packer needs it at pack time twice over — it is the
292
+ * manifest's `id` and the ref `flatpak build-bundle` exports — and neither is
293
+ * derivable from the payload, because the desktop entry that carries the id
294
+ * is only staged for `kind: 'app'`. `name` was on it too and moved for the
295
+ * same kind of reason one milestone later: the macOS artifact IS
296
+ * `<name>.app`, so the packing host has to be able to NAME it, and no other
297
+ * field in this object can spell it (`binaryName` is the executable inside
298
+ * the bundle, not the bundle).
189
299
  * - `arch` — carried by the manifest's `target`, which is also the value a
190
300
  * mismatched stage is refused on, so it lives in exactly one place.
191
301
  * - `mimeTypes` — phase 1 renders the shared-mime-info document into the
@@ -198,6 +308,17 @@ export interface PackSettings {
198
308
  binaryName: string;
199
309
  /** Reverse-DNS application id — the Flatpak manifest's `id` and the exported ref. */
200
310
  appId: string;
311
+ /**
312
+ * Human-readable display name — and, on macOS, the `<App>.app` directory itself.
313
+ *
314
+ * On this side of the host boundary because the darwin packers cannot work
315
+ * without it: `macos-app`'s artifact is a directory called `${name}.app` and
316
+ * `macos-app-zip` wraps that directory, so a finishing host with the stage and
317
+ * nothing else still has to be able to spell it. Also what `Info.plist`'s
318
+ * `CFBundleName`/`CFBundleDisplayName` carry — see `layout.ts`'s
319
+ * `LayoutMetadataInput`.
320
+ */
321
+ name: string;
201
322
  /** Upstream version, normalised for package managers. */
202
323
  version: string;
203
324
  /** Package revision within one upstream version (deb revision / rpm release). */
@@ -219,8 +340,23 @@ export interface PackSettings {
219
340
  extraDepends: Record<DistroFormatId, string[]>;
220
341
  /** Project-supplied GI-namespace → package rows, filling gaps in the built-in table. */
221
342
  typelibPackages: Record<string, Record<DistroFormatId, string>>;
222
- /** Minimum GJS the emitted dependency asks for. */
343
+ /**
344
+ * The interpreter the launcher execs, and therefore the one the package
345
+ * depends on. From `gjsify.app`; default `'gjs'`.
346
+ *
347
+ * ON `PackSettings`, not on `ShipSettings` alone, because BOTH halves need it
348
+ * and they must not answer it differently: `renderLauncher` writes
349
+ * `exec gjs -m` or `exec node` from this field, and `deriveDepends` seeds
350
+ * `gjs >= …` or `nodejs >= …` from the same one. Two sources here is the
351
+ * defect this field replaces — a filename heuristic once produced
352
+ * `Depends: gjs (>= 1.86), nodejs (>= 24)` on a package whose launcher execed
353
+ * gjs.
354
+ */
355
+ app: 'gjs' | 'node';
356
+ /** Minimum GJS the emitted dependency asks for. Used only for `app: 'gjs'`. */
223
357
  minGjsVersion: string;
358
+ /** Minimum Node major. Used only for `app: 'node'`. */
359
+ minNodeVersion: string;
224
360
  /** The Flatpak manifest's non-payload half, fully defaulted at stage time. */
225
361
  flatpak: ShipFlatpakSettings;
226
362
  }
@@ -260,8 +396,6 @@ export interface ShipFlatpakSettings {
260
396
  export interface ShipSettings extends PackSettings {
261
397
  /** Absolute path to the project being shipped. */
262
398
  projectDir: string;
263
- /** Human-readable display name. */
264
- name: string;
265
399
  /** Absolute path to the licence file, when one was found. */
266
400
  licenseFile?: string;
267
401
  /** `'app'` stages a desktop entry and an icon; `'cli'` stages neither. */
@@ -306,4 +440,15 @@ export interface ShipSettings extends PackSettings {
306
440
  outDir: string;
307
441
  /** Target architecture in `process.arch` spelling. */
308
442
  arch: string;
443
+ /**
444
+ * The OS whose LAYOUT this run assembles, from the `gjsify ship <os>`
445
+ * positional (ADR 0024 § A2).
446
+ *
447
+ * Beside `arch` and for the same reason: together they are the stage's
448
+ * `target`, the string `--expect-target <os>-<arch>` is compared against, and
449
+ * the pair a matrix leg is identified by. Deliberately NOT in
450
+ * {@link PackSettings} — the manifest's `target` carries it, so it lives in
451
+ * exactly one place on the far side of the host boundary.
452
+ */
453
+ layoutOs: HostOs;
309
454
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Escape a value for XML element content or an attribute value.
3
+ *
4
+ * `&` FIRST, or the ampersands the four later replacements introduce are escaped
5
+ * again and `Ship & Co` comes out as `Ship &amp;amp; Co`.
6
+ */
7
+ export declare function xmlEscape(value: string): string;