@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
@@ -16,6 +16,12 @@ import { statSync } from 'node:fs';
16
16
  * what `rpm -qi` then shows a user, and an artifact that looks broken is a
17
17
  * support question. The mtime keeps the property that matters (pack the same
18
18
  * build twice, get the same bytes) while saying something true.
19
+ *
20
+ * Only the ASSEMBLING host runs this. A stage records the answer it got
21
+ * (`.gjsify-ship-stage.json` → `mtime`) and the packing host reuses it: an
22
+ * artifact upload does not carry mtimes, so re-stat'ing the stage there would
23
+ * stamp every header with "whenever the download finished" and quietly destroy
24
+ * the reproducibility this function exists to protect.
19
25
  */
20
26
  export function buildTimestamp(bundlePath, env = process.env) {
21
27
  const raw = env.SOURCE_DATE_EPOCH;
@@ -28,6 +34,72 @@ export function buildTimestamp(bundlePath, env = process.env) {
28
34
  }
29
35
  return Math.floor(statSync(bundlePath).mtimeMs / 1000);
30
36
  }
37
+ /**
38
+ * Read {@link PayloadFacts} off a payload or off a plan.
39
+ *
40
+ * Takes anything with a `path`, so the assembling phase can ask the same
41
+ * question of the PLAN (before the tree is read back) that the packing phase
42
+ * asks of the payload. One function, so the two phases cannot disagree about
43
+ * whether a package installs a schema.
44
+ */
45
+ export function readPayloadFacts(entries) {
46
+ const paths = entries.map((entry) => entry.path);
47
+ return {
48
+ hasDesktopEntry: paths.some((path) => path.startsWith('share/applications/') && path.endsWith('.desktop')),
49
+ hasIcons: paths.some((path) => path.startsWith('share/icons/hicolor/')),
50
+ hasSchemas: paths.some((path) => path.startsWith('share/glib-2.0/schemas/') && path.endsWith('.gschema.xml')),
51
+ hasMimeTypes: paths.some((path) => path.startsWith('share/mime/packages/') && path.endsWith('.xml')),
52
+ // Anywhere in the payload, not only `lib/<name>/gi/`: `gjsify.ship.extraFiles` can place
53
+ // one elsewhere, and a typelib the package carries is a typelib the package must not also
54
+ // declare a distro dependency for, wherever it sits.
55
+ bundledTypelibs: paths.filter((path) => path.endsWith('.typelib')),
56
+ };
57
+ }
58
+ /**
59
+ * The interpreters the payload's own executables need, read off their shebangs.
60
+ *
61
+ * An interpreter is a dependency like any other, and rpm expects it declared:
62
+ * `rpmbuild`'s file-based generator emits one `Requires` per executable
63
+ * shebang, with the `RPMSENSE_FIND_REQUIRES` sense that says "derived, not
64
+ * declared". Measured on Fedora 44 against a package whose only file is a
65
+ * `#!/bin/sh` script: `rpm -qp --requires` → `/usr/bin/sh 16384`.
66
+ *
67
+ * The LITERAL path, not a resolved one. `rpmbuild` prints `/usr/bin/sh` there
68
+ * because it resolved `/bin` through the symlink of the usrmerged host it ran
69
+ * on; this writer has no target host to resolve against (ADR 0024 § A1 — the
70
+ * packers are pure JavaScript and run anywhere), and `/bin/sh` is satisfied on
71
+ * both layouts: measured on Fedora 44, `rpm -q --whatprovides /bin/sh` and
72
+ * `/usr/bin/sh` both answer `bash`. It is also the spelling the scriptlet
73
+ * requirements already use.
74
+ *
75
+ * EXECUTABLE files only, which is the same rule `rpmbuild` applies. A GJS
76
+ * bundle staged 0644 carries `#!/usr/bin/env -S gjs -m` for the days it is run
77
+ * directly, but nothing in the installed package executes it as a program — the
78
+ * launcher `exec`s `gjs` with it as an argument — so declaring `/usr/bin/env`
79
+ * for it would be a dependency on a path this package never uses.
80
+ */
81
+ export function readShebangInterpreters(payload) {
82
+ const found = new Set();
83
+ for (const entry of payload) {
84
+ if ((entry.mode & 0o111) === 0)
85
+ continue;
86
+ const interpreter = readShebang(entry.data);
87
+ if (interpreter !== null)
88
+ found.add(interpreter);
89
+ }
90
+ return [...found].sort();
91
+ }
92
+ /** The absolute interpreter path of a `#!` line, or `null` when there is none to read. */
93
+ function readShebang(data) {
94
+ if (data[0] !== 0x23 || data[1] !== 0x21)
95
+ return null; // `#!`
96
+ // A shebang is one LINE; reading further would let a long file's contents
97
+ // decide how much work this does. 256 bytes is above every real one and is
98
+ // what Linux itself truncates at (BINPRM_BUF_SIZE).
99
+ const line = new TextDecoder().decode(data.subarray(2, Math.min(data.byteLength, 256))).split('\n')[0] ?? '';
100
+ const interpreter = line.trim().split(/\s+/)[0] ?? '';
101
+ return interpreter.startsWith('/') ? interpreter : null;
102
+ }
31
103
  /**
32
104
  * Does the payload contain anything architecture-specific?
33
105
  *
@@ -44,6 +116,23 @@ export function isArchIndependent(payload) {
44
116
  // values this repository can actually produce are listed; an unknown one reads
45
117
  // as "cannot tell" rather than as a mismatch, because refusing an artifact over
46
118
  // a machine constant nobody here emits would be a guess wearing a gate's clothes.
119
+ // The machine values this project actually ships packages for — no more. A value
120
+ // missing here makes `readBinaryArch` return null, and null is SILENT, so an
121
+ // absent row costs nothing but a check that does not fire.
122
+ //
123
+ // `EM_MIPS` (0x08) is absent for that reason and no other. An earlier version of
124
+ // this comment claimed it was absent because one value covers `mips` and
125
+ // `mipsel` and the row would have to guess; that was wrong three times over, and
126
+ // is corrected here rather than deleted because it is the kind of reasoning that
127
+ // gets re-derived: (1) `mipsel` IS little-endian MIPS, so the discriminator is
128
+ // `EI_DATA`, which `readBinaryArch` reads four lines below; (2) it could not
129
+ // "refuse a correct pack" either way, because `formats.ts` has no `mips` row in
130
+ // `DEBIAN_ARCH`/`RPM_ARCH`, so `archName` throws before any label is written;
131
+ // and (3) the principle it invoked is already broken one row down — `0x16` maps
132
+ // to `s390x`, but `EM_S390` is emitted by 31-bit `s390` too, and its
133
+ // discriminator is `EI_CLASS` at offset 4, which this function does NOT read.
134
+ // That row is the ambiguous one. Unreachable today (nothing here builds s390),
135
+ // but it is the row to fix first if this table ever grows.
47
136
  const ELF_MACHINE_TO_ARCH = {
48
137
  0x03: 'ia32',
49
138
  0x15: 'ppc64',
@@ -121,7 +210,8 @@ export function assertPayloadMatchesArch(payload, arch) {
121
210
  'anything.\n' +
122
211
  ` A package labelled ${arch} installs on ${arch} and then fails to load. Build the payload ` +
123
212
  `for ${arch}\n` +
124
- ' (its own prebuild), or drop `--arch` and label the payload you actually have.');
213
+ ' (its own prebuild), or assemble the stage without `--arch` and label the payload you ' +
214
+ 'actually have.');
125
215
  }
126
216
  }
127
217
  /** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
@@ -1,4 +1,4 @@
1
- import type { FormatDescriptor, ShipSettings, StagedFile } from './types.js';
1
+ import type { FormatDescriptor, FormatId, ShipSettings, StagedFile } from './types.js';
2
2
  /** Everything the planner needs that it cannot derive from the settings alone. */
3
3
  export interface StageInputs {
4
4
  /** Paths inside `settings.bundleDir`, relative and POSIX-separated. */
@@ -24,8 +24,37 @@ export declare function planStage(settings: ShipSettings, inputs: StageInputs):
24
24
  * The per-format additions. Only the licence differs today, and it differs in
25
25
  * BOTH place and shape: Debian policy wants a machine-readable `copyright` in
26
26
  * `share/doc/<pkg>/`, RPM wants the licence text under `share/licenses/<pkg>/`.
27
+ *
28
+ * A project with no licence file is REFUSED here rather than packaged without
29
+ * one. Debian Policy § 12.5 makes the copyright file mandatory and lintian
30
+ * raises `no-copyright-file` as an error, but nothing between this function and
31
+ * the installed package says so: an empty overlay produced a `.deb` whose
32
+ * control file `dpkg-deb --info` prints cleanly, that `dpkg -i` installs, and
33
+ * that is simply unlicensed. That is what gjsify's own package was, every
34
+ * release of it, until the first CI leg that read one with a real `dpkg` said
35
+ * so on its eighth assertion.
36
+ *
37
+ * It throws on the host that can act on it. This runs during `--stage` as well
38
+ * as during a one-shot `ship`, so the refusal lands where the project — and the
39
+ * missing `LICENSE` — actually is; {@link assertOverlayIsLicensed} repeats it at
40
+ * pack time for a stage that arrived from somewhere else.
27
41
  */
28
42
  export declare function planOverlay(settings: ShipSettings, format: FormatDescriptor, inputs: StageInputs): StagedFile[];
43
+ /**
44
+ * The same refusal, at pack time.
45
+ *
46
+ * `planOverlay` cannot cover `--from-stage`: that path never plans anything, it
47
+ * rehydrates the closure the assembling host wrote. A manifest whose
48
+ * `overlay.deb` is `[]` passes every structural check in `readStageManifest`
49
+ * — the key is present, the value is an array of the right shape — and the
50
+ * silent unlicensed package comes back. The stage manifest's own header lists
51
+ * "no `overlay`" among the omissions that fail at exit 0 with an artifact that
52
+ * installs; this is where that stops being a comment.
53
+ *
54
+ * Cheap enough to run on both paths, so it does, and a stage written by this
55
+ * version can no longer reach it.
56
+ */
57
+ export declare function assertOverlayIsLicensed(formatId: FormatId, binaryName: string, overlay: readonly StagedFile[]): void;
29
58
  /**
30
59
  * The hicolor subdirectory for an icon: `scalable` for SVG, otherwise the
31
60
  * pixel size read from a `<n>x<n>` path component or a trailing number in the
@@ -2,6 +2,7 @@
2
2
  // case (including the ones that must be REFUSED) is unit-testable. Same split
3
3
  // as `utils/copy-targets.ts`, and for the same reason: a stager that discovers
4
4
  // its own inputs can only be tested by building a real project.
5
+ import { renderMimePackage } from './mime.js';
5
6
  import { basename, extname, posix } from 'node:path';
6
7
  /**
7
8
  * Build the prefix-relative payload.
@@ -65,6 +66,16 @@ export function planStage(settings, inputs) {
65
66
  source: { kind: 'file', path: file },
66
67
  });
67
68
  }
69
+ // A shared-mime-info document, when the project DEFINES a type rather than only handling one.
70
+ // `share/mime/packages/` is shared between packages, so the app id is the filename for the same
71
+ // reason it is for the GSettings schema: a generic name collides with another package's file.
72
+ if (settings.mimeTypes.length > 0) {
73
+ files.push({
74
+ path: `share/mime/packages/${settings.appId}.xml`,
75
+ mode: 0o644,
76
+ source: { kind: 'text', text: renderMimePackage(settings.mimeTypes) },
77
+ });
78
+ }
68
79
  // Compiled gettext catalogues, layout preserved: `share/locale/<lang>/LC_MESSAGES/<domain>.mo`
69
80
  // is where `bindtextdomain` looks, and nowhere else.
70
81
  for (const locale of settings.localeFiles) {
@@ -83,15 +94,56 @@ export function planStage(settings, inputs) {
83
94
  * The per-format additions. Only the licence differs today, and it differs in
84
95
  * BOTH place and shape: Debian policy wants a machine-readable `copyright` in
85
96
  * `share/doc/<pkg>/`, RPM wants the licence text under `share/licenses/<pkg>/`.
97
+ *
98
+ * A project with no licence file is REFUSED here rather than packaged without
99
+ * one. Debian Policy § 12.5 makes the copyright file mandatory and lintian
100
+ * raises `no-copyright-file` as an error, but nothing between this function and
101
+ * the installed package says so: an empty overlay produced a `.deb` whose
102
+ * control file `dpkg-deb --info` prints cleanly, that `dpkg -i` installs, and
103
+ * that is simply unlicensed. That is what gjsify's own package was, every
104
+ * release of it, until the first CI leg that read one with a real `dpkg` said
105
+ * so on its eighth assertion.
106
+ *
107
+ * It throws on the host that can act on it. This runs during `--stage` as well
108
+ * as during a one-shot `ship`, so the refusal lands where the project — and the
109
+ * missing `LICENSE` — actually is; {@link assertOverlayIsLicensed} repeats it at
110
+ * pack time for a stage that arrived from somewhere else.
86
111
  */
87
112
  export function planOverlay(settings, format, inputs) {
88
- if (inputs.licenseText === undefined)
89
- return [];
113
+ if (inputs.licenseText === undefined) {
114
+ throw new Error(`gjsify ship: no licence file found, so the ${format.id} package would ship no ` +
115
+ `${format.licenseDest(settings.binaryName)}. Debian Policy § 12.5 requires it and lintian ` +
116
+ 'errors without it, and neither dpkg nor rpm will tell you it is missing. Add a LICENSE file ' +
117
+ `to ${settings.projectDir} or to the root of its repository, or point ` +
118
+ '`gjsify.ship.licenseFile` at the one this package ships under.');
119
+ }
90
120
  const text = format.licenseKind === 'debian-copyright'
91
121
  ? renderDebianCopyright(settings, inputs.licenseText)
92
122
  : inputs.licenseText;
93
123
  return [{ path: format.licenseDest(settings.binaryName), mode: 0o644, source: { kind: 'text', text } }];
94
124
  }
125
+ /**
126
+ * The same refusal, at pack time.
127
+ *
128
+ * `planOverlay` cannot cover `--from-stage`: that path never plans anything, it
129
+ * rehydrates the closure the assembling host wrote. A manifest whose
130
+ * `overlay.deb` is `[]` passes every structural check in `readStageManifest`
131
+ * — the key is present, the value is an array of the right shape — and the
132
+ * silent unlicensed package comes back. The stage manifest's own header lists
133
+ * "no `overlay`" among the omissions that fail at exit 0 with an artifact that
134
+ * installs; this is where that stops being a comment.
135
+ *
136
+ * Cheap enough to run on both paths, so it does, and a stage written by this
137
+ * version can no longer reach it.
138
+ */
139
+ export function assertOverlayIsLicensed(formatId, binaryName, overlay) {
140
+ if (overlay.length > 0)
141
+ return;
142
+ throw new Error(`gjsify ship: this stage carries no ${formatId} licence overlay, so the package would ship no ` +
143
+ `licence file for ${binaryName} — Debian Policy § 12.5 requires one and lintian errors without ` +
144
+ 'it. The fix is on the assembling host, not this one: give that project a LICENSE (or set ' +
145
+ '`gjsify.ship.licenseFile`) and re-run the `--stage` phase.');
146
+ }
95
147
  /**
96
148
  * Debian machine-readable copyright, format 1.0. The licence text is indented
97
149
  * by one space and its blank lines become ` .` — a bare blank line ends the
@@ -1,7 +1,7 @@
1
- import type { PayloadEntry } from './payload.js';
2
- import type { ShipSettings } from './types.js';
1
+ import { type PayloadEntry } from './payload.js';
2
+ import type { PackSettings } from './types.js';
3
3
  export interface RpmInputs {
4
- settings: ShipSettings;
4
+ settings: PackSettings;
5
5
  payload: readonly PayloadEntry[];
6
6
  prefix: string;
7
7
  depends: readonly string[];
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
15
15
  import { concatBytes } from './bytes.js';
16
16
  import { createCpioArchive, S_IFDIR, S_IFREG } from './cpio.js';
17
17
  import { parseDepend } from './depends.js';
18
+ import { readPayloadFacts, readShebangInterpreters } from './payload.js';
18
19
  import { buildRpmHeader, buildRpmLead, padToEight, RpmType, RPM_TAG_HEADERIMMUTABLE, RPM_TAG_HEADERSIGNATURES, } from './rpm-header.js';
19
20
  import { gzipDeterministic } from './gzip.js';
20
21
  import { renderRpmScriptlets } from './scripts.js';
@@ -80,6 +81,9 @@ const SENSE = {
80
81
  INTERP: 1 << 8,
81
82
  SCRIPT_POST: 1 << 10,
82
83
  SCRIPT_POSTUN: 1 << 12,
84
+ // "This one was derived from the payload, not declared by the packager" —
85
+ // the sense `rpmbuild`'s own dependency generator uses (measured: 16384).
86
+ FIND_REQUIRES: 1 << 14,
83
87
  RPMLIB: 1 << 24,
84
88
  };
85
89
  /** SHA-256 in rpm's digest-algorithm numbering (PGP hash algorithm ids). */
@@ -205,8 +209,9 @@ function mainHeaderEntries(inputs, files, cpio, compressed) {
205
209
  dirIndexes.push(index);
206
210
  baseNames.push(file.path.slice(slash + 1));
207
211
  }
208
- const scripts = renderRpmScriptlets(settings, inputs.prefix);
209
- const requires = buildRequires(inputs.depends, scripts);
212
+ // From the PAYLOAD, not the settings — see the same call in `deb.ts`.
213
+ const scripts = renderRpmScriptlets(readPayloadFacts(inputs.payload), inputs.prefix);
214
+ const requires = buildRequires(inputs.depends, scripts, readShebangInterpreters(inputs.payload));
210
215
  const entries = [
211
216
  { tag: TAG.HEADERI18NTABLE, type: RpmType.STRING_ARRAY, value: ['C'] },
212
217
  { tag: TAG.NAME, type: RpmType.STRING, value: settings.binaryName },
@@ -325,8 +330,15 @@ function assertParallelFileArrays(entries, fileCount) {
325
330
  * A scriptlet's interpreter is a dependency like any other and rpm expects it
326
331
  * declared with the matching `SCRIPT_*` sense bit — without it, a system
327
332
  * without `/bin/sh` installs the package and then fails inside `%post`.
333
+ *
334
+ * The payload's own executables need the same treatment, and until the first
335
+ * real `rpm -qp --requires` ran against this writer nothing here said so: every
336
+ * `.rpm` it produced installed a `#!/bin/sh` launcher and declared no shell.
337
+ * The two senses are not redundant and rpm emits both — a package can need a
338
+ * shell to CONFIGURE (`Requires(post)`) and to RUN (a plain requirement), and
339
+ * dropping either is a different broken system.
328
340
  */
329
- function buildRequires(depends, scripts) {
341
+ function buildRequires(depends, scripts, interpreters) {
330
342
  const names = [];
331
343
  const flags = [];
332
344
  const versions = [];
@@ -346,6 +358,11 @@ function buildRequires(depends, scripts) {
346
358
  flags.push(SENSE.INTERP | SENSE.SCRIPT_POSTUN);
347
359
  versions.push('');
348
360
  }
361
+ for (const interpreter of interpreters) {
362
+ names.push(interpreter);
363
+ flags.push(SENSE.FIND_REQUIRES);
364
+ versions.push('');
365
+ }
349
366
  for (const [name, version] of RPMLIB_REQUIRES) {
350
367
  names.push(name);
351
368
  flags.push(SENSE.RPMLIB | SENSE.LESS | SENSE.EQUAL);
@@ -1,4 +1,4 @@
1
- import type { ShipSettings } from './types.js';
1
+ import type { PayloadFacts } from './payload.js';
2
2
  /**
3
3
  * The cache refreshes this payload makes necessary, each guarded by the tool's
4
4
  * presence and `|| true`.
@@ -6,8 +6,18 @@ import type { ShipSettings } from './types.js';
6
6
  * The caches belong to the distribution, not to us: on removal the helper may
7
7
  * already be gone, and a maintainer script that fails during a purge leaves a
8
8
  * package the user cannot uninstall.
9
+ *
10
+ * Keyed on the PAYLOAD, not on the settings. Each line refreshes exactly one
11
+ * directory, so the honest precondition is "did this package install anything
12
+ * into that directory" — which the payload answers and a project's file lists
13
+ * only approximate. They came apart in one measured case: a `kind: 'cli'`
14
+ * project with a `data/icons/` folder staged no icon at all (icons are an
15
+ * `'app'` thing in `planStage`) while `settings.iconFiles` was non-empty, so
16
+ * the postinst refreshed an icon cache for files that were never installed.
17
+ * The second reason is ADR 0024 § A2: those lists are absolute BUILD-host
18
+ * paths, and this code now also runs on a host that has only the staged tree.
9
19
  */
10
- export declare function cacheRefreshCommands(settings: ShipSettings, prefix: string): string[];
20
+ export declare function cacheRefreshCommands(facts: PayloadFacts, prefix: string): string[];
11
21
  /**
12
22
  * dpkg maintainer scripts, keyed by their `control.tar` filename.
13
23
  *
@@ -15,7 +25,7 @@ export declare function cacheRefreshCommands(settings: ShipSettings, prefix: str
15
25
  * and several abort paths, and on an upgrade the incoming package's `postinst`
16
26
  * refreshes the caches anyway.
17
27
  */
18
- export declare function renderDebScripts(settings: ShipSettings, prefix: string): Record<string, string>;
28
+ export declare function renderDebScripts(facts: PayloadFacts, prefix: string): Record<string, string>;
19
29
  /**
20
30
  * rpm scriptlets, for the `POSTIN` / `POSTUN` header tags.
21
31
  *
@@ -25,7 +35,7 @@ export declare function renderDebScripts(settings: ShipSettings, prefix: string)
25
35
  * work rather than a wrong result, where guarding on `[ $1 -eq 0 ]` would be
26
36
  * one more place to get the convention backwards.
27
37
  */
28
- export declare function renderRpmScriptlets(settings: ShipSettings, prefix: string): {
38
+ export declare function renderRpmScriptlets(facts: PayloadFacts, prefix: string): {
29
39
  post?: string;
30
40
  postun?: string;
31
41
  };
@@ -18,22 +18,40 @@
18
18
  * The caches belong to the distribution, not to us: on removal the helper may
19
19
  * already be gone, and a maintainer script that fails during a purge leaves a
20
20
  * package the user cannot uninstall.
21
+ *
22
+ * Keyed on the PAYLOAD, not on the settings. Each line refreshes exactly one
23
+ * directory, so the honest precondition is "did this package install anything
24
+ * into that directory" — which the payload answers and a project's file lists
25
+ * only approximate. They came apart in one measured case: a `kind: 'cli'`
26
+ * project with a `data/icons/` folder staged no icon at all (icons are an
27
+ * `'app'` thing in `planStage`) while `settings.iconFiles` was non-empty, so
28
+ * the postinst refreshed an icon cache for files that were never installed.
29
+ * The second reason is ADR 0024 § A2: those lists are absolute BUILD-host
30
+ * paths, and this code now also runs on a host that has only the staged tree.
21
31
  */
22
- export function cacheRefreshCommands(settings, prefix) {
32
+ export function cacheRefreshCommands(facts, prefix) {
23
33
  const refreshes = [];
24
- if (settings.kind === 'app') {
34
+ if (facts.hasDesktopEntry) {
25
35
  refreshes.push({
26
36
  probe: 'update-desktop-database',
27
37
  run: `update-desktop-database -q ${prefix}/share/applications`,
28
38
  });
29
39
  }
30
- if (settings.iconFiles.length > 0) {
40
+ if (facts.hasIcons) {
31
41
  refreshes.push({
32
42
  probe: 'gtk-update-icon-cache',
33
43
  run: `gtk-update-icon-cache -q -t -f ${prefix}/share/icons/hicolor`,
34
44
  });
35
45
  }
36
- if (settings.schemaFiles.length > 0) {
46
+ if (facts.hasMimeTypes) {
47
+ // Without this the document is installed and the type still does not exist: detection runs
48
+ // off the compiled cache in `share/mime/`, not off the packages directory.
49
+ refreshes.push({
50
+ probe: 'update-mime-database',
51
+ run: `update-mime-database ${prefix}/share/mime`,
52
+ });
53
+ }
54
+ if (facts.hasSchemas) {
37
55
  refreshes.push({
38
56
  probe: 'glib-compile-schemas',
39
57
  run: `glib-compile-schemas ${prefix}/share/glib-2.0/schemas`,
@@ -48,8 +66,8 @@ export function cacheRefreshCommands(settings, prefix) {
48
66
  * and several abort paths, and on an upgrade the incoming package's `postinst`
49
67
  * refreshes the caches anyway.
50
68
  */
51
- export function renderDebScripts(settings, prefix) {
52
- const commands = cacheRefreshCommands(settings, prefix);
69
+ export function renderDebScripts(facts, prefix) {
70
+ const commands = cacheRefreshCommands(facts, prefix);
53
71
  if (commands.length === 0)
54
72
  return {};
55
73
  const body = commands.map((line) => ` ${line}`).join('\n');
@@ -67,8 +85,8 @@ export function renderDebScripts(settings, prefix) {
67
85
  * work rather than a wrong result, where guarding on `[ $1 -eq 0 ]` would be
68
86
  * one more place to get the convention backwards.
69
87
  */
70
- export function renderRpmScriptlets(settings, prefix) {
71
- const commands = cacheRefreshCommands(settings, prefix);
88
+ export function renderRpmScriptlets(facts, prefix) {
89
+ const commands = cacheRefreshCommands(facts, prefix);
72
90
  if (commands.length === 0)
73
91
  return {};
74
92
  const body = `${commands.join('\n')}\n`;
@@ -9,8 +9,10 @@
9
9
  // Precedence is uniform: CLI flag > `gjsify.ship` > `gjsify.flatpak` (metadata
10
10
  // only) > derived from package.json. Never a yargs `default:`, which is
11
11
  // indistinguishable from a value the user typed and would clobber the config.
12
+ import { validateMimeTypes } from './mime.js';
12
13
  import { basename } from 'node:path';
13
14
  import { DEFAULT_GJS_FLOOR } from './depends.js';
15
+ import { resolveShipFlatpakSettings } from './flatpak-config.js';
14
16
  import { assertRelease, normaliseVersion } from './version.js';
15
17
  /** Freedesktop main category → deb `Section:` / rpm `Group:`. First match wins. */
16
18
  const CATEGORY_SECTIONS = [
@@ -32,6 +34,17 @@ export function resolveShipSettings(input) {
32
34
  // `gjsify.flatpak` key-by-key is exactly the fallback ADR 0024 § 8 asks
33
35
  // for. The non-metadata keys each block carries ride along unread.
34
36
  const metadata = { ...flatpak, ...definedOnly(ship) };
37
+ // A type this package DEFINES is also a type it handles. Folding it into `provides.mimetypes`
38
+ // here means the desktop entry and the metainfo need no knowledge of `mimeTypes` at all: they
39
+ // already render `MimeType=` (with the `%f` field code) and `<mediatype>` from that one field.
40
+ // Doing it the other way — leaving the two lists independent — makes "defined but not handled"
41
+ // a state you can reach by omission, and that state installs cleanly and does nothing.
42
+ const mimeTypes = ship.mimeTypes ?? [];
43
+ if (mimeTypes.length > 0) {
44
+ validateMimeTypes(mimeTypes);
45
+ const handled = new Set([...(metadata.provides?.mimetypes ?? []), ...mimeTypes.map((m) => m.type)]);
46
+ metadata.provides = { ...metadata.provides, mimetypes: [...handled] };
47
+ }
35
48
  const binaryName = ship.binaryName ?? deriveBinaryName(pkg.name);
36
49
  const appId = ship.appId ?? flatpak.appId ?? reverseDnsOrThrow(pkg.name, binaryName);
37
50
  const name = metadata.name ?? titleCase(binaryName);
@@ -59,6 +72,11 @@ export function resolveShipSettings(input) {
59
72
  warnings.push(`gjsify ship: no icon found — the installed app will show a placeholder in menus and app stores. ` +
60
73
  'Point `gjsify.ship.icon` at an SVG (or a directory of sized PNGs).');
61
74
  }
75
+ // Resolved here rather than in the packer, because the packer may run on a
76
+ // host with no `package.json` at all (ADR 0024 § A2) — and because the
77
+ // finish-args default depends on `kind`, which is a project fact.
78
+ const flatpakSettings = resolveShipFlatpakSettings({ ship, flatpak, kind });
79
+ warnings.push(...flatpakSettings.warnings);
62
80
  const settings = {
63
81
  projectDir: input.projectDir,
64
82
  appId,
@@ -75,6 +93,7 @@ export function resolveShipSettings(input) {
75
93
  section: ship.section ?? sections.section,
76
94
  group: ship.group ?? sections.group,
77
95
  kind,
96
+ mimeTypes,
78
97
  extraDepends: { deb: ship.depends?.deb ?? [], rpm: ship.depends?.rpm ?? [] },
79
98
  typelibPackages: ship.typelibPackages ?? {},
80
99
  bundlePath: discovered.bundlePath,
@@ -88,6 +107,7 @@ export function resolveShipSettings(input) {
88
107
  outDir: input.cli.outDir ?? ship.outDir ?? 'ship',
89
108
  arch: input.cli.arch ?? process.arch,
90
109
  minGjsVersion: ship.minGjsVersion ?? DEFAULT_GJS_FLOOR,
110
+ flatpak: flatpakSettings.settings,
91
111
  };
92
112
  return { settings, metadata, warnings };
93
113
  }
@@ -0,0 +1,163 @@
1
+ import type { FormatDescriptor, FormatId, PackSettings, ShipSettings, StagedFile, StagedMode } from './types.js';
2
+ /** The one file a stage carries that is not payload. Lives at the stage ROOT. */
3
+ export declare const STAGE_MANIFEST_FILE = ".gjsify-ship-stage.json";
4
+ /**
5
+ * The schema this CLI writes and the only one it reads.
6
+ *
7
+ * A stage is a short-lived intermediate between two jobs of ONE run, not an
8
+ * archive, so there is no migration path in either direction and both
9
+ * mismatches are refused rather than guessed at — see {@link readStageManifest}.
10
+ * Bump it whenever a field's MEANING changes, not when one is added that an
11
+ * older reader would simply ignore; a reader that ignores a field it does not
12
+ * know is exactly how a mode plan silently loses an entry.
13
+ *
14
+ * 2 added `settings.appId` and `settings.flatpak` for the Flatpak target. That
15
+ * is the second case and not the first: an older reader ignoring
16
+ * `settings.flatpak` would not skip a field, it would pack a Flatpak against
17
+ * whatever runtime IT defaults to — a different `org.gnome.Platform` version
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
+ *
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.
29
+ */
30
+ export declare const STAGE_LAYOUT_OS = "linux";
31
+ /** What this stage was assembled FOR, in the repo-wide `${process.platform}-${process.arch}` spelling. */
32
+ export interface StageTarget {
33
+ os: string;
34
+ arch: string;
35
+ }
36
+ /** One staged path, its planned mode, and the size it had when the stage was written. */
37
+ export interface StageFileRecord extends StagedMode {
38
+ bytes: number;
39
+ }
40
+ /** One pre-rendered overlay file. Text only — see {@link writeStageManifest}. */
41
+ export interface StageOverlayFile extends StagedMode {
42
+ text: string;
43
+ }
44
+ export interface StageManifest {
45
+ schema: number;
46
+ /** The tool that assembled the stage, so a mixed-version pipeline is answerable after the fact. */
47
+ tool: {
48
+ name: string;
49
+ version: string;
50
+ };
51
+ target: StageTarget;
52
+ /** The formats phase 1 rendered an overlay closure for. */
53
+ formats: FormatId[];
54
+ /** The one build stamp every header and every rendered file shares. */
55
+ mtime: number;
56
+ /** GI namespaces the bundle imports, `Ns-Version` where the specifier pins one. */
57
+ namespaces: string[];
58
+ settings: PackSettings;
59
+ staged: StageFileRecord[];
60
+ overlay: Partial<Record<FormatId, StageOverlayFile[]>>;
61
+ }
62
+ export interface StageManifestInput {
63
+ /** Absolute path of the stage root, already written. */
64
+ stageDir: string;
65
+ settings: ShipSettings;
66
+ formats: readonly FormatDescriptor[];
67
+ mtime: number;
68
+ namespaces: readonly string[];
69
+ staged: readonly StagedFile[];
70
+ /** Per-format overlay, as `planOverlay` produced it. */
71
+ overlay: ReadonlyMap<FormatId, readonly StagedFile[]>;
72
+ }
73
+ /**
74
+ * The half of {@link ShipSettings} that crosses the host boundary.
75
+ *
76
+ * Written out field by field rather than spread: a spread would carry
77
+ * `projectDir`, `bundlePath`, `bundleDir`, `iconFiles`, `schemaFiles`,
78
+ * `extraFiles` and `licenseFile` — seven absolute paths from the assembling
79
+ * host — into a file whose whole purpose is to be read somewhere else. The
80
+ * explicit list is also what makes adding a field a decision: see the comment
81
+ * on {@link PackSettings} for what belongs on each side. `appId` and `flatpak`
82
+ * were that decision for the Flatpak target, and the `--from-stage` e2e is
83
+ * where an omission would have shown: it deletes the project, so a field the
84
+ * packer needs and the manifest lacks has nowhere to be read from.
85
+ */
86
+ export declare function toPackSettings(settings: ShipSettings): PackSettings;
87
+ /** Absolute path of a stage's manifest. */
88
+ export declare function stageManifestPath(stageDir: string): string;
89
+ /** `linux-x64`. One spelling repo-wide (root AGENTS.md § Runtime & platform model). */
90
+ export declare function formatTarget(target: StageTarget): string;
91
+ /**
92
+ * Write the manifest at the stage root, AFTER the tree itself.
93
+ *
94
+ * `bytes` is stat'ed off the written tree rather than taken from the plan: the
95
+ * plan holds a source path or a string, and what the packing host will compare
96
+ * against is what actually landed on disk here.
97
+ */
98
+ export declare function writeStageManifest(input: StageManifestInput): StageManifest;
99
+ /**
100
+ * Read and validate a stage's manifest.
101
+ *
102
+ * Every field is checked, because this file is the only thing standing between
103
+ * "a directory someone pointed us at" and a signed-looking `.deb`. A missing
104
+ * `staged` entry would reach `readStage` as a mode nobody planned; a `mode` that
105
+ * arrived as a string would reach `chmod` as `NaN`.
106
+ */
107
+ export declare function readStageManifest(stageDir: string): StageManifest;
108
+ /** The overlay for one format, in the shape `writeStage` takes. */
109
+ export declare function overlayFiles(manifest: StageManifest, format: FormatId): StagedFile[];
110
+ /**
111
+ * Refuse a stage that is not the one this job was told to pack.
112
+ *
113
+ * WHAT THIS IS: a matrix-leg mix-up check. It catches a finish job that
114
+ * downloaded the wrong artifact — the leg that forgot to route — and nothing
115
+ * more. It is opt-in (no flag, no check) and it compares two strings the same
116
+ * pipeline wrote one job apart, so treat a green run as "this is the stage I
117
+ * asked for", never as "this stage is correctly labelled".
118
+ *
119
+ * WHAT THIS IS NOT: the guard its precedent
120
+ * (`packages/node-gi/scripts/verify-bundle-manifest.mjs --expect-host-target`)
121
+ * provides. That script compares a bundle to `process.platform`/`process.arch`
122
+ * because the runner BUILT it, so the machine is the right second opinion.
123
+ * Here the stage arrives from another host by design, so a host comparison
124
+ * would refuse a legal use — measured: an `arm64`-labelled stage packs on this
125
+ * x64 host at exit 0 with correct bytes, because both packers are pure JS.
126
+ *
127
+ * The class that precedent guards — a label and the bytes under it disagreeing
128
+ * — is NOT guarded here, and `target.arch` cannot guard it: it is defaulted
129
+ * from `process.arch` only when `--arch` is absent, so the moment an operator
130
+ * says which leg they are on, `target.arch` is that operator's claim and
131
+ * nothing compares it to a machine or to a byte. Measured BEFORE
132
+ * {@link assertPayloadMatchesArch} existed: a stage carrying an x86-64 `.so`,
133
+ * assembled `--stage --arch arm64` and packed `--expect-target linux-arm64`,
134
+ * exited 0 and produced an `aarch64` rpm whose only `.so` was `e_machine=0x3e`.
135
+ * The same command in this tree now exits 1 and writes nothing — kept in the
136
+ * past tense because it is the reason the guard below it exists, not a
137
+ * description of what this function does.
138
+ *
139
+ * That class is guarded one layer down instead, where the oracle is free and
140
+ * cannot be wrong about a machine it never saw: {@link assertPayloadMatchesArch}
141
+ * in `payload.ts` reads ELF `e_machine` / Mach-O `cputype` out of the payload
142
+ * and refuses a label the bytes contradict. Payload-versus-label goes red on
143
+ * the mislabelled stage AND stays green on the legitimate cross-host pack;
144
+ * host-versus-label can only do one of the two.
145
+ */
146
+ export declare function assertExpectedTarget(manifest: StageManifest, expected: string | undefined): void;
147
+ /** Refuse a format the stage carries no overlay closure for. */
148
+ export declare function assertFormatsStaged(manifest: StageManifest, formats: readonly FormatDescriptor[]): void;
149
+ /**
150
+ * The build stamp the packing host uses: the stage's, always.
151
+ *
152
+ * A `SOURCE_DATE_EPOCH` set only in the finish job would silently produce a
153
+ * different artifact than the same stage packed on the assembling host, which
154
+ * is the one property the two-phase split has to keep. It is a warning rather
155
+ * than a refusal because the honest fix ("set it in both jobs or in neither")
156
+ * is the caller's, and refusing would break a runner that exports it globally.
157
+ */
158
+ export declare function resolveStageMtime(manifest: StageManifest, env?: Record<string, string | undefined>): {
159
+ mtime: number;
160
+ warnings: string[];
161
+ };
162
+ /** One actionable line when the two phases ran different gjsify versions. */
163
+ export declare function warnOnToolSkew(manifest: StageManifest): string[];