@gjsify/cli 0.42.0 → 0.44.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 (51) hide show
  1. package/dist/cli.gjs.mjs +444 -415
  2. package/lib/actions/build.d.ts +6 -0
  3. package/lib/actions/build.js +66 -9
  4. package/lib/commands/affected-classify.js +7 -5
  5. package/lib/commands/build.js +6 -0
  6. package/lib/commands/flatpak/ci.js +10 -2
  7. package/lib/commands/flatpak/init.js +15 -16
  8. package/lib/commands/flatpak/utils.d.ts +0 -26
  9. package/lib/commands/flatpak/utils.js +4 -29
  10. package/lib/commands/ship.d.ts +2 -0
  11. package/lib/commands/ship.js +358 -104
  12. package/lib/config.js +25 -0
  13. package/lib/types/cli-build-options.d.ts +11 -0
  14. package/lib/types/config-data.d.ts +106 -2
  15. package/lib/utils/flatpak-runtime.d.ts +41 -0
  16. package/lib/utils/flatpak-runtime.js +54 -0
  17. package/lib/utils/gjs-bundle-guard.d.ts +57 -3
  18. package/lib/utils/gjs-bundle-guard.js +122 -13
  19. package/lib/utils/jsx-config.d.ts +45 -0
  20. package/lib/utils/jsx-config.js +98 -0
  21. package/lib/utils/onboard-probe.d.ts +23 -0
  22. package/lib/utils/onboard-probe.js +65 -0
  23. package/lib/utils/ship/deb.d.ts +3 -3
  24. package/lib/utils/ship/deb.js +6 -2
  25. package/lib/utils/ship/depends.d.ts +13 -13
  26. package/lib/utils/ship/depends.js +25 -3
  27. package/lib/utils/ship/discover.js +72 -6
  28. package/lib/utils/ship/flatpak-config.d.ts +66 -0
  29. package/lib/utils/ship/flatpak-config.js +132 -0
  30. package/lib/utils/ship/flatpak.d.ts +45 -0
  31. package/lib/utils/ship/flatpak.js +148 -0
  32. package/lib/utils/ship/formats.d.ts +35 -0
  33. package/lib/utils/ship/formats.js +152 -5
  34. package/lib/utils/ship/mime.d.ts +19 -0
  35. package/lib/utils/ship/mime.js +89 -0
  36. package/lib/utils/ship/payload.d.ts +76 -0
  37. package/lib/utils/ship/payload.js +91 -1
  38. package/lib/utils/ship/plan.d.ts +30 -1
  39. package/lib/utils/ship/plan.js +54 -2
  40. package/lib/utils/ship/rpm.d.ts +3 -3
  41. package/lib/utils/ship/rpm.js +20 -3
  42. package/lib/utils/ship/scripts.d.ts +14 -4
  43. package/lib/utils/ship/scripts.js +26 -8
  44. package/lib/utils/ship/settings.js +20 -0
  45. package/lib/utils/ship/stage-manifest.d.ts +163 -0
  46. package/lib/utils/ship/stage-manifest.js +445 -0
  47. package/lib/utils/ship/stage-writer.d.ts +30 -3
  48. package/lib/utils/ship/stage-writer.js +76 -9
  49. package/lib/utils/ship/types.d.ts +203 -27
  50. package/lib/utils/ship/types.js +5 -0
  51. package/package.json +19 -19
@@ -0,0 +1,445 @@
1
+ // `.gjsify-ship-stage.json` — what one `gjsify ship --stage` hands the
2
+ // `gjsify ship --from-stage` that finishes it (ADR 0024 § A2).
3
+ //
4
+ // A CLOSURE, not a settings dump. The packing host has the staged tree and
5
+ // nothing else: no project directory, no `package.json`, no built bundle, no
6
+ // `gjsify.ship` config. Everything the packers still need has to be in here,
7
+ // and everything that is a BUILD-HOST PATH must not be — serialised verbatim,
8
+ // `bundlePath` and friends name files that do not exist where they are read.
9
+ //
10
+ // The measurement that decided the contents: each of these omissions fails
11
+ // SILENTLY, at exit 0, with an artifact that installs.
12
+ //
13
+ // * no `staged` — modes come from the plan (see `stage-writer.ts`), so the
14
+ // launcher packs 0644 and the installed `bin/<name>` is not executable.
15
+ // * no `overlay` — the `.deb` ships no `/usr/share/doc/<pkg>/copyright`,
16
+ // which Debian Policy § 12.5 requires and lintian errors on, and the
17
+ // `.rpm` ships no `/usr/share/licenses/<pkg>/LICENSE`.
18
+ // * no `namespaces` — they are scanned from the BUILD TREE's bundle, so a
19
+ // packing host that re-derived nothing would emit `Depends:` without
20
+ // `gir1.2-gtk-4.0` and `gir1.2-adw-1`: the package installs on a machine
21
+ // with no GTK and dies at the first import. That is the exact failure
22
+ // ADR 0024 § 6 exists to prevent, moved one host to the left.
23
+ // * no `mtime` — an artifact upload does not carry mtimes, so re-stat'ing
24
+ // the stage stamps every header with the download time and two hosts
25
+ // packing the same stage produce different bytes.
26
+ //
27
+ // What is deliberately NOT carried, so the next reader does not add it back:
28
+ // * per-file digests. ADR 0024 § A4 measured that the darwin finish leg has
29
+ // to re-sign all 106 Mach-O images inside the stage, at which point a
30
+ // sha256 set either refuses the artifact it exists to produce or is
31
+ // relaxed to exempt 106 files and stops checking anything. A per-file SIZE
32
+ // survives that and still catches the truncated transfer, so that is what
33
+ // `staged[].bytes` is.
34
+ // * anything derivable from the payload — `hasIcons`, `hasSchemas`, the
35
+ // bundled typelibs. `readPayloadFacts` answers all three from the tree
36
+ // that is right there.
37
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
38
+ import { join, sep } from 'node:path';
39
+ import { cliVersion } from '../publish-headers.js';
40
+ import { FORMAT_IDS } from './formats.js';
41
+ /** The one file a stage carries that is not payload. Lives at the stage ROOT. */
42
+ export const STAGE_MANIFEST_FILE = '.gjsify-ship-stage.json';
43
+ /**
44
+ * The schema this CLI writes and the only one it reads.
45
+ *
46
+ * A stage is a short-lived intermediate between two jobs of ONE run, not an
47
+ * archive, so there is no migration path in either direction and both
48
+ * mismatches are refused rather than guessed at — see {@link readStageManifest}.
49
+ * Bump it whenever a field's MEANING changes, not when one is added that an
50
+ * older reader would simply ignore; a reader that ignores a field it does not
51
+ * know is exactly how a mode plan silently loses an entry.
52
+ *
53
+ * 2 added `settings.appId` and `settings.flatpak` for the Flatpak target. That
54
+ * is the second case and not the first: an older reader ignoring
55
+ * `settings.flatpak` would not skip a field, it would pack a Flatpak against
56
+ * whatever runtime IT defaults to — a different `org.gnome.Platform` version
57
+ * 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
+ *
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.
68
+ */
69
+ export const STAGE_LAYOUT_OS = 'linux';
70
+ /**
71
+ * The half of {@link ShipSettings} that crosses the host boundary.
72
+ *
73
+ * Written out field by field rather than spread: a spread would carry
74
+ * `projectDir`, `bundlePath`, `bundleDir`, `iconFiles`, `schemaFiles`,
75
+ * `extraFiles` and `licenseFile` — seven absolute paths from the assembling
76
+ * host — into a file whose whole purpose is to be read somewhere else. The
77
+ * explicit list is also what makes adding a field a decision: see the comment
78
+ * on {@link PackSettings} for what belongs on each side. `appId` and `flatpak`
79
+ * were that decision for the Flatpak target, and the `--from-stage` e2e is
80
+ * where an omission would have shown: it deletes the project, so a field the
81
+ * packer needs and the manifest lacks has nowhere to be read from.
82
+ */
83
+ export function toPackSettings(settings) {
84
+ return {
85
+ binaryName: settings.binaryName,
86
+ appId: settings.appId,
87
+ version: settings.version,
88
+ release: settings.release,
89
+ maintainer: settings.maintainer,
90
+ summary: settings.summary,
91
+ description: settings.description,
92
+ license: settings.license,
93
+ ...(settings.homepage === undefined ? {} : { homepage: settings.homepage }),
94
+ section: settings.section,
95
+ group: settings.group,
96
+ extraDepends: settings.extraDepends,
97
+ typelibPackages: settings.typelibPackages,
98
+ minGjsVersion: settings.minGjsVersion,
99
+ flatpak: settings.flatpak,
100
+ };
101
+ }
102
+ /** Absolute path of a stage's manifest. */
103
+ export function stageManifestPath(stageDir) {
104
+ return join(stageDir, STAGE_MANIFEST_FILE);
105
+ }
106
+ /** `linux-x64`. One spelling repo-wide (root AGENTS.md § Runtime & platform model). */
107
+ export function formatTarget(target) {
108
+ return `${target.os}-${target.arch}`;
109
+ }
110
+ /**
111
+ * Write the manifest at the stage root, AFTER the tree itself.
112
+ *
113
+ * `bytes` is stat'ed off the written tree rather than taken from the plan: the
114
+ * plan holds a source path or a string, and what the packing host will compare
115
+ * against is what actually landed on disk here.
116
+ */
117
+ export function writeStageManifest(input) {
118
+ const overlay = {};
119
+ for (const format of input.formats) {
120
+ overlay[format.id] = (input.overlay.get(format.id) ?? []).map((file) => {
121
+ if (file.source.kind !== 'text') {
122
+ // Every overlay entry `planOverlay` produces today is rendered text. A file
123
+ // reference would be a build-host path, and putting one in here is how the
124
+ // manifest would start naming files the packing host cannot open.
125
+ throw new Error(`gjsify ship: internal error — the ${format.id} overlay entry ${file.path} is a file ` +
126
+ 'reference, and a stage manifest may only carry rendered text. Render it in ' +
127
+ '`planOverlay` instead.');
128
+ }
129
+ return { path: file.path, mode: file.mode, text: file.source.text };
130
+ });
131
+ }
132
+ const manifest = {
133
+ schema: STAGE_SCHEMA_VERSION,
134
+ tool: { name: '@gjsify/cli', version: cliVersion() },
135
+ target: { os: STAGE_LAYOUT_OS, arch: input.settings.arch },
136
+ formats: input.formats.map((format) => format.id),
137
+ mtime: input.mtime,
138
+ namespaces: [...input.namespaces],
139
+ settings: toPackSettings(input.settings),
140
+ staged: input.staged.map((file) => ({
141
+ path: file.path,
142
+ mode: file.mode,
143
+ bytes: statSync(join(input.stageDir, file.path.split('/').join(sep))).size,
144
+ })),
145
+ overlay,
146
+ };
147
+ writeFileSync(stageManifestPath(input.stageDir), `${JSON.stringify(manifest, null, 2)}\n`);
148
+ return manifest;
149
+ }
150
+ /**
151
+ * Read and validate a stage's manifest.
152
+ *
153
+ * Every field is checked, because this file is the only thing standing between
154
+ * "a directory someone pointed us at" and a signed-looking `.deb`. A missing
155
+ * `staged` entry would reach `readStage` as a mode nobody planned; a `mode` that
156
+ * arrived as a string would reach `chmod` as `NaN`.
157
+ */
158
+ export function readStageManifest(stageDir) {
159
+ const path = stageManifestPath(stageDir);
160
+ if (!existsSync(path)) {
161
+ throw new Error(`gjsify ship: ${stageDir} has no ${STAGE_MANIFEST_FILE}, so it is not a stage this command can ` +
162
+ 'pack. A stage is produced by `gjsify ship --stage`, which writes that file at the stage root ' +
163
+ "beside the payload. If you pointed --from-stage at a project's `ship/` directory, point it at " +
164
+ '`ship/stage/` instead.');
165
+ }
166
+ let raw;
167
+ try {
168
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
169
+ }
170
+ catch (error) {
171
+ throw new Error(`gjsify ship: ${path} is not readable JSON (${error.message}). ` +
172
+ 'Re-run the `--stage` phase; a truncated manifest means the stage transfer was truncated too.');
173
+ }
174
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
175
+ throw new Error(`gjsify ship: ${path} is not a JSON object. Re-run the \`--stage\` phase.`);
176
+ }
177
+ const data = raw;
178
+ const schema = data.schema;
179
+ if (typeof schema !== 'number' || !Number.isInteger(schema)) {
180
+ throw new Error(`gjsify ship: ${path} has no integer \`schema\` field, so it was not written by \`gjsify ship ` +
181
+ '--stage`. Re-run the `--stage` phase.');
182
+ }
183
+ if (schema > STAGE_SCHEMA_VERSION) {
184
+ throw new Error(`gjsify ship: ${path} declares schema ${schema} and this gjsify understands ` +
185
+ `${STAGE_SCHEMA_VERSION}. The stage was assembled by a newer gjsify: upgrade this one ` +
186
+ '(`gjsify self-update`), or re-run the `--stage` phase with this version. A manifest from the ' +
187
+ 'future is refused rather than partially read, because an unknown field could be the one that ' +
188
+ "decides a file's mode.");
189
+ }
190
+ if (schema < STAGE_SCHEMA_VERSION) {
191
+ throw new Error(`gjsify ship: ${path} declares schema ${schema} and this gjsify understands ` +
192
+ `${STAGE_SCHEMA_VERSION}. Re-run the \`--stage\` phase with this gjsify. A stage is a ` +
193
+ 'short-lived intermediate between two jobs of one run, not an archive, so no migration is ' +
194
+ 'kept for it.');
195
+ }
196
+ const at = (field) => `${path} → ${field}`;
197
+ const tool = record(data.tool, at('tool'));
198
+ const target = record(data.target, at('target'));
199
+ const manifest = {
200
+ schema,
201
+ tool: {
202
+ name: expectString(tool.name, at('tool.name')),
203
+ version: expectString(tool.version, at('tool.version')),
204
+ },
205
+ target: {
206
+ os: expectString(target.os, at('target.os')),
207
+ arch: expectString(target.arch, at('target.arch')),
208
+ },
209
+ formats: expectArray(data.formats, at('formats')).map((entry, index) => {
210
+ const id = expectString(entry, at(`formats[${index}]`));
211
+ if (!FORMAT_IDS.includes(id)) {
212
+ throw new Error(`gjsify ship: ${at(`formats[${index}]`)} is "${id}", which this gjsify does not know. ` +
213
+ `Known formats: ${FORMAT_IDS.join(', ')}.`);
214
+ }
215
+ return id;
216
+ }),
217
+ mtime: expectInteger(data.mtime, at('mtime')),
218
+ namespaces: expectArray(data.namespaces, at('namespaces')).map((entry, index) => expectString(entry, at(`namespaces[${index}]`))),
219
+ settings: readPackSettings(record(data.settings, at('settings')), at('settings')),
220
+ staged: expectArray(data.staged, at('staged')).map((entry, index) => {
221
+ const file = record(entry, at(`staged[${index}]`));
222
+ return {
223
+ path: expectString(file.path, at(`staged[${index}].path`)),
224
+ mode: expectInteger(file.mode, at(`staged[${index}].mode`)),
225
+ bytes: expectInteger(file.bytes, at(`staged[${index}].bytes`)),
226
+ };
227
+ }),
228
+ overlay: {},
229
+ };
230
+ // `readStage` refuses in both directions — a staged path the plan does not name,
231
+ // and a planned path the stage lacks — but both sides can be empty TOGETHER and
232
+ // agree. Measured: a directory holding only the sidecar packed into a structurally
233
+ // valid `ship-demo_1.2.3-1_all.deb` that `dpkg -i` installs and that contains no
234
+ // `bin/` and no `lib/`; the command simply does not exist afterwards.
235
+ if (manifest.staged.length === 0) {
236
+ throw new Error(`gjsify ship: ${at('staged')} is empty, so this stage describes no payload. ` +
237
+ 'A stage always carries at least the launcher its plan named. Re-run the ' +
238
+ '`--stage` phase; a stage that lost its plan cannot be packed into anything ' +
239
+ 'but an empty package.');
240
+ }
241
+ const overlay = record(data.overlay, at('overlay'));
242
+ for (const id of manifest.formats) {
243
+ // No `?? []`: `writeStageManifest` emits a key for every staged format even
244
+ // when the project has no LICENCE (the value is then an empty array), so a
245
+ // MISSING key can only mean a mutilated manifest. Defaulting it away is the
246
+ // `?? 0o644` bug one field over — measured: with `"overlay": {}` the finish
247
+ // phase exited 0 and wrote a `.deb` carrying no
248
+ // `/usr/share/doc/<pkg>/copyright`, which Debian Policy § 12.5 requires and
249
+ // lintian errors on.
250
+ manifest.overlay[id] = expectArray(overlay[id], at(`overlay.${id}`)).map((entry, index) => {
251
+ const file = record(entry, at(`overlay.${id}[${index}]`));
252
+ return {
253
+ path: expectString(file.path, at(`overlay.${id}[${index}].path`)),
254
+ mode: expectInteger(file.mode, at(`overlay.${id}[${index}].mode`)),
255
+ text: expectString(file.text, at(`overlay.${id}[${index}].text`)),
256
+ };
257
+ });
258
+ }
259
+ return manifest;
260
+ }
261
+ /** The overlay for one format, in the shape `writeStage` takes. */
262
+ export function overlayFiles(manifest, format) {
263
+ return (manifest.overlay[format] ?? []).map((file) => ({
264
+ path: file.path,
265
+ mode: file.mode,
266
+ source: { kind: 'text', text: file.text },
267
+ }));
268
+ }
269
+ /**
270
+ * Refuse a stage that is not the one this job was told to pack.
271
+ *
272
+ * WHAT THIS IS: a matrix-leg mix-up check. It catches a finish job that
273
+ * downloaded the wrong artifact — the leg that forgot to route — and nothing
274
+ * more. It is opt-in (no flag, no check) and it compares two strings the same
275
+ * pipeline wrote one job apart, so treat a green run as "this is the stage I
276
+ * asked for", never as "this stage is correctly labelled".
277
+ *
278
+ * WHAT THIS IS NOT: the guard its precedent
279
+ * (`packages/node-gi/scripts/verify-bundle-manifest.mjs --expect-host-target`)
280
+ * provides. That script compares a bundle to `process.platform`/`process.arch`
281
+ * because the runner BUILT it, so the machine is the right second opinion.
282
+ * Here the stage arrives from another host by design, so a host comparison
283
+ * would refuse a legal use — measured: an `arm64`-labelled stage packs on this
284
+ * x64 host at exit 0 with correct bytes, because both packers are pure JS.
285
+ *
286
+ * The class that precedent guards — a label and the bytes under it disagreeing
287
+ * — is NOT guarded here, and `target.arch` cannot guard it: it is defaulted
288
+ * from `process.arch` only when `--arch` is absent, so the moment an operator
289
+ * says which leg they are on, `target.arch` is that operator's claim and
290
+ * nothing compares it to a machine or to a byte. Measured BEFORE
291
+ * {@link assertPayloadMatchesArch} existed: a stage carrying an x86-64 `.so`,
292
+ * assembled `--stage --arch arm64` and packed `--expect-target linux-arm64`,
293
+ * exited 0 and produced an `aarch64` rpm whose only `.so` was `e_machine=0x3e`.
294
+ * The same command in this tree now exits 1 and writes nothing — kept in the
295
+ * past tense because it is the reason the guard below it exists, not a
296
+ * description of what this function does.
297
+ *
298
+ * That class is guarded one layer down instead, where the oracle is free and
299
+ * cannot be wrong about a machine it never saw: {@link assertPayloadMatchesArch}
300
+ * in `payload.ts` reads ELF `e_machine` / Mach-O `cputype` out of the payload
301
+ * and refuses a label the bytes contradict. Payload-versus-label goes red on
302
+ * the mislabelled stage AND stays green on the legitimate cross-host pack;
303
+ * host-versus-label can only do one of the two.
304
+ */
305
+ export function assertExpectedTarget(manifest, expected) {
306
+ if (expected === undefined)
307
+ return;
308
+ const actual = formatTarget(manifest.target);
309
+ if (expected !== actual) {
310
+ throw new Error(`gjsify ship: --expect-target ${expected} but this stage was assembled for ${actual}. ` +
311
+ 'The stage records the target its `--arch` resolved to at stage time, so this is a stage from ' +
312
+ 'another matrix leg: download the artifact belonging to ' +
313
+ `${expected}, or drop --expect-target if packing this one is what you meant.`);
314
+ }
315
+ }
316
+ /** Refuse a format the stage carries no overlay closure for. */
317
+ export function assertFormatsStaged(manifest, formats) {
318
+ const missing = formats.filter((format) => !manifest.formats.includes(format.id)).map((format) => format.id);
319
+ if (missing.length === 0)
320
+ return;
321
+ throw new Error(`gjsify ship: this stage was assembled for ${manifest.formats.join(', ')}, and --target names ` +
322
+ `${missing.join(', ')}. Phase one renders each format's overlay — rpm wants the licence at ` +
323
+ '`share/licenses/<pkg>/LICENSE`, deb wants a machine-readable copyright at ' +
324
+ '`share/doc/<pkg>/copyright` — so a format the stage never saw would pack without it, and Debian ' +
325
+ `Policy § 12.5 makes that a broken package. Re-run \`gjsify ship --stage --target ${[
326
+ ...manifest.formats,
327
+ ...missing,
328
+ ].join(',')}\` on the assembling host.`);
329
+ }
330
+ /**
331
+ * The build stamp the packing host uses: the stage's, always.
332
+ *
333
+ * A `SOURCE_DATE_EPOCH` set only in the finish job would silently produce a
334
+ * different artifact than the same stage packed on the assembling host, which
335
+ * is the one property the two-phase split has to keep. It is a warning rather
336
+ * than a refusal because the honest fix ("set it in both jobs or in neither")
337
+ * is the caller's, and refusing would break a runner that exports it globally.
338
+ */
339
+ export function resolveStageMtime(manifest, env = process.env) {
340
+ const raw = env.SOURCE_DATE_EPOCH;
341
+ if (raw === undefined || Number(raw) === manifest.mtime)
342
+ return { mtime: manifest.mtime, warnings: [] };
343
+ return {
344
+ mtime: manifest.mtime,
345
+ warnings: [
346
+ `SOURCE_DATE_EPOCH is ${raw} here and this stage was stamped ${manifest.mtime}. The stage's stamp ` +
347
+ 'wins, because changing it would make this artifact differ from the same stage packed on the ' +
348
+ 'assembling host. Set the same value in both jobs, or set it in neither.',
349
+ ],
350
+ };
351
+ }
352
+ /** One actionable line when the two phases ran different gjsify versions. */
353
+ export function warnOnToolSkew(manifest) {
354
+ const running = cliVersion();
355
+ if (manifest.tool.version === running)
356
+ return [];
357
+ return [
358
+ `this stage was assembled by ${manifest.tool.name} ${manifest.tool.version} and is being packed by ` +
359
+ `${running}. Both phases run the same packer, so a version difference can change the artifact's ` +
360
+ 'bytes. Pin both jobs to one gjsify version.',
361
+ ];
362
+ }
363
+ function readPackSettings(data, at) {
364
+ const field = (name) => `${at}.${name}`;
365
+ const extraDepends = record(data.extraDepends, field('extraDepends'));
366
+ const typelibPackages = record(data.typelibPackages, field('typelibPackages'));
367
+ return {
368
+ binaryName: expectString(data.binaryName, field('binaryName')),
369
+ appId: expectString(data.appId, field('appId')),
370
+ version: expectString(data.version, field('version')),
371
+ release: expectString(data.release, field('release')),
372
+ maintainer: expectString(data.maintainer, field('maintainer')),
373
+ summary: expectString(data.summary, field('summary')),
374
+ description: expectArray(data.description, field('description')).map((entry, index) => expectString(entry, field(`description[${index}]`))),
375
+ license: expectString(data.license, field('license')),
376
+ ...(data.homepage === undefined ? {} : { homepage: expectString(data.homepage, field('homepage')) }),
377
+ section: expectString(data.section, field('section')),
378
+ group: expectString(data.group, field('group')),
379
+ extraDepends: {
380
+ deb: expectArray(extraDepends.deb ?? [], field('extraDepends.deb')).map((entry, index) => expectString(entry, field(`extraDepends.deb[${index}]`))),
381
+ rpm: expectArray(extraDepends.rpm ?? [], field('extraDepends.rpm')).map((entry, index) => expectString(entry, field(`extraDepends.rpm[${index}]`))),
382
+ },
383
+ typelibPackages: Object.fromEntries(Object.entries(typelibPackages).map(([namespace, value]) => {
384
+ const row = record(value, field(`typelibPackages.${namespace}`));
385
+ return [
386
+ namespace,
387
+ {
388
+ deb: expectString(row.deb, field(`typelibPackages.${namespace}.deb`)),
389
+ rpm: expectString(row.rpm, field(`typelibPackages.${namespace}.rpm`)),
390
+ },
391
+ ];
392
+ })),
393
+ minGjsVersion: expectString(data.minGjsVersion, field('minGjsVersion')),
394
+ flatpak: readFlatpakSettings(record(data.flatpak, field('flatpak')), field('flatpak')),
395
+ };
396
+ }
397
+ /**
398
+ * The Flatpak half, validated the same way as everything else here.
399
+ *
400
+ * Every field is required and none is defaulted, deliberately: a `?? []` on
401
+ * `finishArgs` would turn a mutilated manifest into an app with no display
402
+ * socket — it installs, it starts, and it draws nothing. The defaults belong on
403
+ * the assembling host, where `gjsify.ship` and `kind` are in reach; by the time
404
+ * a manifest exists the answer has already been decided and writing it down
405
+ * twice is how the two hosts disagree.
406
+ */
407
+ function readFlatpakSettings(data, at) {
408
+ const field = (name) => `${at}.${name}`;
409
+ const strings = (value, name) => expectArray(value, field(name)).map((entry, index) => expectString(entry, field(`${name}[${index}]`)));
410
+ return {
411
+ runtime: expectString(data.runtime, field('runtime')),
412
+ runtimeVersion: expectString(data.runtimeVersion, field('runtimeVersion')),
413
+ sdk: expectString(data.sdk, field('sdk')),
414
+ branch: expectString(data.branch, field('branch')),
415
+ sdkExtensions: strings(data.sdkExtensions, 'sdkExtensions'),
416
+ appendPath: strings(data.appendPath, 'appendPath'),
417
+ finishArgs: strings(data.finishArgs, 'finishArgs'),
418
+ cleanup: strings(data.cleanup, 'cleanup'),
419
+ };
420
+ }
421
+ function record(value, at) {
422
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
423
+ throw new Error(`gjsify ship: ${at} must be an object. Re-run the \`--stage\` phase.`);
424
+ }
425
+ return value;
426
+ }
427
+ function expectArray(value, at) {
428
+ if (!Array.isArray(value)) {
429
+ throw new Error(`gjsify ship: ${at} must be an array. Re-run the \`--stage\` phase.`);
430
+ }
431
+ return value;
432
+ }
433
+ function expectString(value, at) {
434
+ if (typeof value !== 'string') {
435
+ throw new Error(`gjsify ship: ${at} must be a string. Re-run the \`--stage\` phase.`);
436
+ }
437
+ return value;
438
+ }
439
+ function expectInteger(value, at) {
440
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
441
+ throw new Error(`gjsify ship: ${at} must be a non-negative integer. Re-run the \`--stage\` phase. ` +
442
+ '(Modes are decimal here — JSON has no octal literal, so 0o755 is written 493.)');
443
+ }
444
+ return value;
445
+ }
@@ -1,17 +1,44 @@
1
1
  import type { PayloadEntry } from './payload.js';
2
- import type { StagedFile } from './types.js';
2
+ import type { StagedFile, StagePlanEntry } from './types.js';
3
3
  /**
4
4
  * Materialise a planned tree under `root`, replacing whatever was there.
5
5
  *
6
6
  * The directory is wiped first: a stale file from a previous run is otherwise
7
7
  * indistinguishable from a staged one, and would be packed.
8
+ *
9
+ * NEVER call this on a stage that ARRIVED — the wipe is unconditional, so
10
+ * pointing it at a downloaded stage deletes the payload it was about to pack.
11
+ * `gjsify ship --from-stage` writes only the format's own `overlay/` directory
12
+ * for that reason.
8
13
  */
9
14
  export declare function writeStage(root: string, files: readonly StagedFile[]): void;
10
15
  /**
11
- * Read a staged tree back as a payload.
16
+ * Read a staged tree back as a payload, and refuse a tree that is not the
17
+ * planned one.
12
18
  *
13
19
  * Modes come from the PLAN, not from `stat()`: a checked-out tree can carry
14
20
  * whatever umask the build host had, and an executable bit that survives on
15
21
  * one machine and not another is exactly the kind of difference that ships.
22
+ * A CI artifact is the sharpest case — `actions/upload-artifact` stores no
23
+ * POSIX mode at all, so a stage that made the trip has 0644 on its launcher
24
+ * and the plan is the only surviving record that it should be 0755.
25
+ *
26
+ * That same trip is why a disagreement is now an error in BOTH directions:
27
+ *
28
+ * - a path in the tree the plan does not name used to be packed at `?? 0o644`.
29
+ * That default is a guess about a file nobody planned; if it is a program it
30
+ * installs unrunnable, and the package is otherwise perfect.
31
+ * - a path in the plan the tree does not hold used to be silently absent from
32
+ * the payload. `.deb` and `.rpm` both accept that happily and the result is
33
+ * a package that installs and cannot start — this project's most expensive
34
+ * failure class.
35
+ *
36
+ * `bytes` is compared when the plan carries it (i.e. when the plan came from a
37
+ * stage manifest). It catches the truncated transfer that the file-set check
38
+ * cannot see: a half-written upload leaves every path in place. It is a SIZE
39
+ * and not a digest on purpose — ADR 0024 § A4 measured that the darwin finish
40
+ * leg has to re-sign all 106 Mach-O images inside the stage, so a per-file
41
+ * sha256 would have to be relaxed to exempt them and would then be checking
42
+ * nothing.
16
43
  */
17
- export declare function readStage(roots: readonly string[], files: readonly StagedFile[]): PayloadEntry[];
44
+ export declare function readStage(roots: readonly string[], plan: readonly StagePlanEntry[]): PayloadEntry[];
@@ -5,17 +5,41 @@
5
5
  // in-memory list, so "what the user inspected" and "what got packed" cannot
6
6
  // diverge. It also makes the staged tree a legitimate deliverable on its own —
7
7
  // which is what a Flatpak module's `cp -a stage/.` will consume once Flatpak
8
- // moves under `ship` (ADR 0024 § 8).
8
+ // moves under `ship` (ADR 0024 § 8), and what `gjsify ship --from-stage` packs
9
+ // on a host that has the tree and nothing else (ADR 0024 § A2).
10
+ //
11
+ // That second consumer is why {@link readStage} refuses rather than defaults.
12
+ // A stage now arrives from another machine, so "the tree and the plan disagree"
13
+ // stopped being impossible and became a transfer failure — and every way of
14
+ // papering over it produces an artifact that installs.
9
15
  import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
16
  import { dirname, join, sep } from 'node:path';
11
17
  import { listFilesRecursive } from './discover.js';
18
+ import { STAGE_MANIFEST_FILE } from './stage-manifest.js';
12
19
  /**
13
20
  * Materialise a planned tree under `root`, replacing whatever was there.
14
21
  *
15
22
  * The directory is wiped first: a stale file from a previous run is otherwise
16
23
  * indistinguishable from a staged one, and would be packed.
24
+ *
25
+ * NEVER call this on a stage that ARRIVED — the wipe is unconditional, so
26
+ * pointing it at a downloaded stage deletes the payload it was about to pack.
27
+ * `gjsify ship --from-stage` writes only the format's own `overlay/` directory
28
+ * for that reason.
17
29
  */
18
30
  export function writeStage(root, files) {
31
+ for (const file of files) {
32
+ // The stage root carries exactly one file that is not payload, and this
33
+ // is its name. A planned file with the same path (reachable through
34
+ // `gjsify.ship.extraFiles`) would shadow the manifest: the tree would
35
+ // then either pack the manifest as payload or lose the closure that
36
+ // makes the tree packable somewhere else.
37
+ if (file.path === STAGE_MANIFEST_FILE) {
38
+ throw new Error(`gjsify ship: a payload file may not be called \`${STAGE_MANIFEST_FILE}\` — that name belongs ` +
39
+ 'to the stage manifest, which sits at the stage root beside the payload and carries what ' +
40
+ 'the packing host needs (ADR 0024 § A2). Rename the `gjsify.ship.extraFiles` destination.');
41
+ }
42
+ }
19
43
  rmSync(root, { recursive: true, force: true, maxRetries: 5 });
20
44
  mkdirSync(root, { recursive: true });
21
45
  for (const file of files) {
@@ -27,23 +51,66 @@ export function writeStage(root, files) {
27
51
  }
28
52
  }
29
53
  /**
30
- * Read a staged tree back as a payload.
54
+ * Read a staged tree back as a payload, and refuse a tree that is not the
55
+ * planned one.
31
56
  *
32
57
  * Modes come from the PLAN, not from `stat()`: a checked-out tree can carry
33
58
  * whatever umask the build host had, and an executable bit that survives on
34
59
  * one machine and not another is exactly the kind of difference that ships.
60
+ * A CI artifact is the sharpest case — `actions/upload-artifact` stores no
61
+ * POSIX mode at all, so a stage that made the trip has 0644 on its launcher
62
+ * and the plan is the only surviving record that it should be 0755.
63
+ *
64
+ * That same trip is why a disagreement is now an error in BOTH directions:
65
+ *
66
+ * - a path in the tree the plan does not name used to be packed at `?? 0o644`.
67
+ * That default is a guess about a file nobody planned; if it is a program it
68
+ * installs unrunnable, and the package is otherwise perfect.
69
+ * - a path in the plan the tree does not hold used to be silently absent from
70
+ * the payload. `.deb` and `.rpm` both accept that happily and the result is
71
+ * a package that installs and cannot start — this project's most expensive
72
+ * failure class.
73
+ *
74
+ * `bytes` is compared when the plan carries it (i.e. when the plan came from a
75
+ * stage manifest). It catches the truncated transfer that the file-set check
76
+ * cannot see: a half-written upload leaves every path in place. It is a SIZE
77
+ * and not a digest on purpose — ADR 0024 § A4 measured that the darwin finish
78
+ * leg has to re-sign all 106 Mach-O images inside the stage, so a per-file
79
+ * sha256 would have to be relaxed to exempt them and would then be checking
80
+ * nothing.
35
81
  */
36
- export function readStage(roots, files) {
37
- const modes = new Map(files.map((file) => [file.path, file.mode]));
82
+ export function readStage(roots, plan) {
83
+ const planned = new Map(plan.map((entry) => [entry.path, entry]));
38
84
  const byPath = new Map();
39
85
  for (const root of roots) {
40
86
  for (const rel of listFilesRecursive(root)) {
41
- byPath.set(rel, {
42
- path: rel,
43
- mode: modes.get(rel) ?? 0o644,
44
- data: new Uint8Array(readFileSync(join(root, rel.split('/').join(sep)))),
45
- });
87
+ if (rel === STAGE_MANIFEST_FILE)
88
+ continue;
89
+ const entry = planned.get(rel);
90
+ if (entry === undefined) {
91
+ throw new Error(`gjsify ship: ${join(root, rel.split('/').join(sep))} is in the stage and not in its ` +
92
+ 'manifest, so nothing knows what mode to give it. Every payload file takes its mode ' +
93
+ 'from the plan, never from the filesystem, so a file that arrived some other way would ' +
94
+ 'be packed 0644 — unrunnable if it is a program. Remove it, or add it through ' +
95
+ '`gjsify.ship.extraFiles` and re-run the `--stage` phase.');
96
+ }
97
+ const data = new Uint8Array(readFileSync(join(root, rel.split('/').join(sep))));
98
+ if (entry.bytes !== undefined && entry.bytes !== data.byteLength) {
99
+ throw new Error(`gjsify ship: ${rel} is ${data.byteLength} bytes in the stage and ${entry.bytes} in its ` +
100
+ 'manifest. The stage arrived truncated or was edited after it was assembled. ' +
101
+ 'Re-upload it, or re-run the `--stage` phase.');
102
+ }
103
+ byPath.set(rel, { path: rel, mode: entry.mode, data });
46
104
  }
47
105
  }
106
+ const missing = plan.filter((entry) => !byPath.has(entry.path)).map((entry) => entry.path);
107
+ if (missing.length > 0) {
108
+ throw new Error(`gjsify ship: the stage manifest lists ${missing.length} file(s) the stage does not contain: ` +
109
+ `${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}. ` +
110
+ 'A payload has to be complete before it is packed — both formats would pack the rest without ' +
111
+ 'complaint and install a package that cannot start. Re-run the `--stage` phase, and check that ' +
112
+ 'whatever moved the stage between hosts copied every file (an artifact upload that drops empty ' +
113
+ 'files or dotfiles is the usual cause).');
114
+ }
48
115
  return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
49
116
  }