@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,148 @@
1
+ // The Flatpak packer — ADR 0024 § 8, stage 6.
2
+ //
3
+ // This is a MOVE, not a second implementation. `gjsify flatpak` has emitted
4
+ // Flatpak manifests since long before `ship` existed; what it emitted was a
5
+ // `buildsystem: meson` module, which made every app carry meson glue whose only
6
+ // job was to call `gjsify build` and copy the result into a prefix — 158 lines
7
+ // of it in Learn6502, 66 of them `GI_TYPELIB_PATH`/`LD_LIBRARY_PATH` plumbing.
8
+ // `ship` already produces that prefix. So the module becomes:
9
+ //
10
+ // buildsystem: simple
11
+ // build-commands: mkdir -p /app; cp -a stage/. /app/; cp -a overlay/. /app/
12
+ //
13
+ // and meson leaves the sandbox. Nothing else about the payload changes — the
14
+ // launcher derives its own prefix at runtime (§ 3), which is exactly why one
15
+ // staged tree can be `/usr` in a `.deb` and `/app` here.
16
+ //
17
+ // FOUR THINGS ABOUT flatpak-builder 1.4.10 WERE MEASURED before this file was
18
+ // written, because each had a plausible wrong answer and flatpak-builder reports
19
+ // none of them: an absolute `dir` source `path` works, `skip` works and is what
20
+ // keeps the stage's own sidecar out of `/app`, `cp -a` preserves the mode, and
21
+ // `--show-manifest` is NOT a validator (it accepted an unknown source property
22
+ // and `buildsystem: "nonsense"` at exit 0, so it must never be used as a gate).
23
+ // The numbers, the commands and the oracle that replaces `--show-manifest`:
24
+ // `docs/ship-formats.md`.
25
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { describeExit, spawnToCompletion } from '../spawn.js';
28
+ /**
29
+ * The generated Flatpak manifest, as a plain object.
30
+ *
31
+ * Pure, so every field is unit-testable without flatpak tooling — and the
32
+ * suite that needs that is the one running in CI, where there is none.
33
+ *
34
+ * The app id is what makes this legal Flatpak: every file flatpak EXPORTS out
35
+ * of `/app/share` has to be named after it, and `planStage` already enforces
36
+ * exactly that for its own reasons — the desktop entry, the metainfo, the icon,
37
+ * the GSettings schema and the shared-mime-info document are all
38
+ * `<appId>[.…].<ext>`, because `share/glib-2.0/schemas/` and
39
+ * `share/mime/packages/` are directories shared between packages. Two
40
+ * independent constraints, one naming rule.
41
+ */
42
+ export function renderShipFlatpakManifest(input) {
43
+ const { settings, stageDir, overlayDir } = input;
44
+ const flatpak = settings.flatpak;
45
+ const manifest = {
46
+ id: settings.appId,
47
+ runtime: flatpak.runtime,
48
+ 'runtime-version': flatpak.runtimeVersion,
49
+ sdk: flatpak.sdk,
50
+ branch: flatpak.branch,
51
+ // `bin/<binaryName>`, which is the one executable the stage plans. NOT
52
+ // the app id, which is what `gjsify flatpak init` defaults to: under
53
+ // `ship` the launcher's name is decided by `gjsify.ship.binaryName`, and
54
+ // a `command` naming a file that is not in `/app/bin` produces an app
55
+ // that installs and cannot be launched.
56
+ command: settings.binaryName,
57
+ 'finish-args': flatpak.finishArgs,
58
+ };
59
+ if (flatpak.sdkExtensions.length > 0)
60
+ manifest['sdk-extensions'] = flatpak.sdkExtensions;
61
+ if (flatpak.appendPath.length > 0) {
62
+ manifest['build-options'] = { 'append-path': flatpak.appendPath.join(':') };
63
+ }
64
+ if (flatpak.cleanup.length > 0)
65
+ manifest.cleanup = flatpak.cleanup;
66
+ manifest.modules = [
67
+ {
68
+ name: settings.binaryName,
69
+ buildsystem: 'simple',
70
+ // `mkdir -p /app` first: flatpak-builder creates the app directory,
71
+ // but a module whose only command is a `cp` into a path that does
72
+ // not exist yet is one refactor away from failing, and the mkdir is
73
+ // free. `stage/.` and not `stage` — `cp -a stage /app/` would
74
+ // create `/app/stage`, which installs cleanly and puts the whole
75
+ // payload one directory too deep.
76
+ 'build-commands': ['mkdir -p /app', 'cp -a stage/. /app/', 'cp -a overlay/. /app/'],
77
+ sources: [
78
+ { type: 'dir', path: stageDir, dest: 'stage', skip: [input.stageManifestFile] },
79
+ { type: 'dir', path: overlayDir, dest: 'overlay' },
80
+ ],
81
+ },
82
+ ];
83
+ return manifest;
84
+ }
85
+ /**
86
+ * Build the app and export a single-file bundle at `input.target`.
87
+ *
88
+ * The only packer in this tree that execs anything, and the descriptor says so
89
+ * (`FORMATS.flatpak.host.requiredTools`). ADR 0024 § A3 is explicit that this
90
+ * costs the independent oracle the hand-written deb/rpm writers bought: what
91
+ * pays it back here is that a `.flatpak` is an OSTree static delta, so
92
+ * `flatpak build-import-bundle` + `ostree ls -R` read the artifact back with
93
+ * code this tree did not write.
94
+ */
95
+ export async function buildFlatpakBundle(input) {
96
+ const { settings, workDir, archLabel } = input;
97
+ const manifestPath = join(workDir, `${settings.appId}.json`);
98
+ const buildDir = join(workDir, 'build');
99
+ const repoDir = join(workDir, 'repo');
100
+ // Kept between runs: it is flatpak-builder's own ostree cache, and dropping
101
+ // it turns every re-pack into a full rebuild of every module.
102
+ const stateDir = join(workDir, 'state');
103
+ mkdirSync(workDir, { recursive: true });
104
+ // The repo is wiped, the state dir is not. A repo that survives holds the
105
+ // PREVIOUS commit on the same ref, and `build-bundle` would happily export
106
+ // it if this run's export failed for any reason that did not fail the
107
+ // build — a stale artifact wearing the new version's filename.
108
+ rmSync(repoDir, { recursive: true, force: true, maxRetries: 5 });
109
+ writeFileSync(manifestPath, `${JSON.stringify(renderShipFlatpakManifest(input), null, 2)}\n`);
110
+ await run('flatpak-builder', [
111
+ '--force-clean',
112
+ // Needed wherever FUSE is unavailable — containers, most CI runners.
113
+ // It only disables a read-only-bind optimisation, and a build that
114
+ // cannot start is worse than one that copies a few more bytes.
115
+ '--disable-rofiles-fuse',
116
+ '--delete-build-dirs',
117
+ `--state-dir=${stateDir}`,
118
+ `--arch=${archLabel}`,
119
+ `--repo=${repoDir}`,
120
+ buildDir,
121
+ manifestPath,
122
+ ], input, 'flatpak-builder not found. Install it (Fedora: `sudo dnf install flatpak-builder`).');
123
+ // The branch is passed explicitly even though the manifest declares it: the
124
+ // two have to name the same ref, and `build-bundle`'s default is `master`
125
+ // while a manifest that sets `branch` exports somewhere else. Leaving it
126
+ // implicit is how this exports nothing and says "ref not found".
127
+ await run('flatpak', ['build-bundle', `--arch=${archLabel}`, repoDir, input.target, settings.appId, settings.flatpak.branch], input, 'flatpak not found. Install it via your distro: see https://flathub.org/setup.');
128
+ }
129
+ async function run(cmd, args, input, notFoundHint) {
130
+ if (input.verbose)
131
+ console.log(`[gjsify ship] ${cmd} ${args.join(' ')}`);
132
+ // `completion: 'return'` because `ship` packs the remaining formats and
133
+ // prints the artifact list afterwards, so it cannot end in `process.exit()`
134
+ // the way `gjsify flatpak build` does. Under GJS that selects the blocking
135
+ // path, which captures the child's output and re-emits it at the end
136
+ // instead of streaming it — the documented cost of a spawn a caller has to
137
+ // return from (see utils/spawn.ts), and the reason `--verbose` prints the
138
+ // invocation before the silence starts.
139
+ const result = await spawnToCompletion(cmd, args, {
140
+ completion: 'return',
141
+ cwd: input.workDir,
142
+ stdio: 'inherit',
143
+ notFound: () => new Error(`gjsify ship: ${notFoundHint}`),
144
+ });
145
+ if (result.code !== 0) {
146
+ throw new Error(`gjsify ship: ${cmd} failed${describeExit(result)}.`);
147
+ }
148
+ }
@@ -1,5 +1,40 @@
1
1
  import type { FormatDescriptor, FormatId } from './types.js';
2
2
  export declare const FORMATS: Record<FormatId, FormatDescriptor>;
3
3
  export declare const FORMAT_IDS: FormatId[];
4
+ /**
5
+ * What `gjsify ship` builds when nothing said otherwise — every format that
6
+ * needs no tool and no particular host.
7
+ *
8
+ * A SECOND derivation from the same table, and it has to be one: `FORMAT_IDS`
9
+ * used to be both "every format that exists" and "every format to build by
10
+ * default", which was the same list only while every format was `finishOn:
11
+ * 'any'`. Adding Flatpak to `FORMAT_IDS` alone would have made a bare
12
+ * `gjsify ship` demand `flatpak-builder` of every project that ever packaged a
13
+ * `.deb` — and on a host without it, of every `release-cut.yml` run, which
14
+ * packs `@gjsify/cli` itself on a bare ubuntu runner. A host-bound format is
15
+ * opt-in through `--target` or `gjsify.ship.targets`; `--help` still lists all
16
+ * of them.
17
+ */
18
+ export declare const DEFAULT_FORMAT_IDS: FormatId[];
19
+ /**
20
+ * Refuse a format this host cannot finish, BEFORE anything is built or written.
21
+ *
22
+ * `host` is injected so both branches are unit-testable from any machine — the
23
+ * same reason `resolvePrebuildDirName` and `checkTypeSkew`'s readers are pure.
24
+ * The message names the two-phase split rather than just saying no: the answer
25
+ * to "I am on macOS and want a Flatpak" is `--stage` here and `--from-stage` on
26
+ * a Linux runner, and a refusal that does not say so reads as "unsupported".
27
+ */
28
+ export declare function assertHostCanFinish(format: FormatDescriptor, host?: string): void;
29
+ /**
30
+ * Refuse a format whose tools are not installed, with the tool named.
31
+ *
32
+ * Separate from {@link assertHostCanFinish} because the two failures have
33
+ * different fixes — one needs a different machine, the other needs a package —
34
+ * and separate from the packer's own `notFound` handler because it fires BEFORE
35
+ * a build: `gjsify ship` runs the project's `build` script first, and finding
36
+ * out afterwards that `flatpak-builder` is absent costs the whole build.
37
+ */
38
+ export declare function assertToolsInstalled(format: FormatDescriptor, present?: (cmd: string) => boolean): void;
4
39
  /** Parse `--target deb,rpm` into a sorted, deduplicated descriptor list. */
5
40
  export declare function resolveFormats(raw: readonly string[]): FormatDescriptor[];
@@ -1,7 +1,13 @@
1
- // The per-format table. Everything that differs between `.deb` and `.rpm`
2
- // outside the archive container itself lives here as DATA, so a new format is
3
- // a row rather than an edit across the stager, the overlay builder and the
4
- // artifact namer.
1
+ // The per-format table. Everything that differs between the formats outside the
2
+ // archive container itself lives here as DATA, so a new format is a row rather
3
+ // than an edit across the stager, the overlay builder and the artifact namer.
4
+ //
5
+ // Flatpak is the row that proves the claim: ADR 0024 § 2 predicted "the whole
6
+ // difference between a Flatpak and an `.rpm` is a four-line prefix map", and
7
+ // this file is where that turned out to be one `prefix: '/app'`, one arch table
8
+ // and one filename. What it also proved is what the design did NOT predict —
9
+ // a format can be host-bound (§ A3), so `host` is a descriptor field now.
10
+ import { isOnPath } from '../check-system-deps.js';
5
11
  // `process.arch` → the format's architecture name. Taken from dpkg's own
6
12
  // `data/cputable` and rpm's arch table. `arm` cannot be told apart from
7
13
  // `armel` by a running process, and Debian's hard-float port is what anything
@@ -32,16 +38,49 @@ function lookupArch(table, arch, label) {
32
38
  }
33
39
  return mapped;
34
40
  }
41
+ // Flatpak's own vocabulary, which is ostree's: `flatpak --supported-arches` on
42
+ // x86_64 answers `x86_64` then `i386`, and a ref is `app/<id>/<arch>/<branch>`.
43
+ // Deliberately short — a name this table does not know is refused rather than
44
+ // guessed, because the arch is not a label here, it is part of the ref the
45
+ // bundle is exported under and installed from.
46
+ const FLATPAK_ARCH = {
47
+ x64: 'x86_64',
48
+ arm64: 'aarch64',
49
+ ia32: 'i386',
50
+ arm: 'arm',
51
+ };
35
52
  function debArch(arch, archIndependent) {
36
53
  return archIndependent ? 'all' : lookupArch(DEBIAN_ARCH, arch, 'Debian');
37
54
  }
38
55
  function rpmArch(arch, archIndependent) {
39
56
  return archIndependent ? 'noarch' : lookupArch(RPM_ARCH, arch, 'RPM');
40
57
  }
58
+ /**
59
+ * Flatpak has no `noarch`, so `archIndependent` is ignored — and that is a
60
+ * measured difference, not an oversight.
61
+ *
62
+ * `all`/`noarch` exist because apt and dnf REFUSE a package whose architecture
63
+ * is not the machine's. Flatpak asks the opposite question: every ref names an
64
+ * arch, `flatpak install` resolves the one matching the host, and an app with no
65
+ * arch is not installable at all. So a payload of pure JavaScript still ships as
66
+ * `app/<id>/x86_64/stable` — which is also what Flathub does for every
67
+ * interpreted app it hosts.
68
+ */
69
+ function flatpakArch(arch, _archIndependent) {
70
+ return lookupArch(FLATPAK_ARCH, arch, 'Flatpak');
71
+ }
72
+ /** `.deb` and `.rpm` are written by this tree, so they exec nothing and read back with GNU tools. */
73
+ const WRITTEN_HERE = (readWith) => ({
74
+ finishOn: 'any',
75
+ requiredTools: [],
76
+ oracle: { readWith, readOn: ['linux'], selfReading: false },
77
+ });
41
78
  export const FORMATS = {
42
79
  deb: {
43
80
  id: 'deb',
44
81
  prefix: '/usr',
82
+ host: WRITTEN_HERE(['ar', 'tar', 'dpkg-deb', 'lintian']),
83
+ depends: 'deb',
45
84
  // Debian policy § 12.5: every package ships its copyright in
46
85
  // /usr/share/doc/<package>/copyright, and lintian errors without it.
47
86
  licenseDest: (binaryName) => `share/doc/${binaryName}/copyright`,
@@ -52,13 +91,121 @@ export const FORMATS = {
52
91
  rpm: {
53
92
  id: 'rpm',
54
93
  prefix: '/usr',
94
+ host: WRITTEN_HERE(['rpm', 'rpm2cpio', 'cpio']),
95
+ depends: 'rpm',
55
96
  licenseDest: (binaryName) => `share/licenses/${binaryName}/LICENSE`,
56
97
  licenseKind: 'plain',
57
98
  archName: rpmArch,
58
99
  fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.rpm`,
59
100
  },
101
+ flatpak: {
102
+ id: 'flatpak',
103
+ // The one-line difference ADR 0024 § 2 promised. Nothing in the payload
104
+ // changes: the launcher derives its prefix at runtime (§ 3), so the same
105
+ // staged `bin/<name>` works under /usr and under /app.
106
+ prefix: '/app',
107
+ host: {
108
+ // A single-file bundle is an OSTree static delta, and the only writer
109
+ // of one is `flatpak build-bundle`. Unlike the `.dmg` (§ A6) that is
110
+ // not an OS restriction: flatpak runs on Linux, so the format is
111
+ // Linux-bound the way the app itself is.
112
+ finishOn: ['linux'],
113
+ requiredTools: ['flatpak-builder', 'flatpak'],
114
+ installHint: 'Fedora: `sudo dnf install flatpak flatpak-builder`, Debian/Ubuntu: ' +
115
+ '`sudo apt install flatpak flatpak-builder`',
116
+ oracle: {
117
+ // `flatpak build-import-bundle` into a FRESH repo, then
118
+ // `ostree ls -R`: ostree parses the delta this tree never wrote,
119
+ // and prints a path + mode + size per file. Measured on this
120
+ // workstation — that listing is what the e2e suite compares
121
+ // against the staged payload.
122
+ readWith: ['flatpak', 'ostree'],
123
+ readOn: ['linux'],
124
+ selfReading: false,
125
+ },
126
+ },
127
+ // A Flatpak's one dependency is its runtime, named in the manifest. See
128
+ // `DistroFormatId`: there is no `Depends:` field here to under-declare,
129
+ // which also means the unmapped-namespace refusal ADR 0024 § 6 built for
130
+ // deb and rpm has nothing to say about this format.
131
+ depends: null,
132
+ // No policy demands a location, so this follows rpm's — one fewer shape
133
+ // for a reader to learn, and `/app/share/licenses/<name>/LICENSE` is
134
+ // where the equivalent file sits in the `.rpm` built from the same stage.
135
+ licenseDest: (binaryName) => `share/licenses/${binaryName}/LICENSE`,
136
+ licenseKind: 'plain',
137
+ archName: flatpakArch,
138
+ // Named after the APP ID, not the binary: the id is the ref the file
139
+ // installs as, and it is what `flatpak install ./file.flatpak` prints.
140
+ fileName: (s, archLabel) => `${s.appId}-${s.version}-${s.release}.${archLabel}.flatpak`,
141
+ },
60
142
  };
61
- export const FORMAT_IDS = ['deb', 'rpm'];
143
+ // DERIVED, never a second list. `FORMATS` is `Record<FormatId, …>`, so the
144
+ // compiler already refuses a `FormatId` with no descriptor — reading the keys
145
+ // back inherits that guarantee for free. Written out by hand this was the one
146
+ // unbound copy of the vocabulary: adding a format to `FormatId` and `FORMATS`
147
+ // compiled fine and left this list short, which would have made the new format
148
+ // missing from `--help` and REFUSED by `readStage` as an unknown id.
149
+ // Insertion order is stable for string keys, and `resolveFormats` sorts anyway.
150
+ export const FORMAT_IDS = Object.keys(FORMATS);
151
+ /**
152
+ * What `gjsify ship` builds when nothing said otherwise — every format that
153
+ * needs no tool and no particular host.
154
+ *
155
+ * A SECOND derivation from the same table, and it has to be one: `FORMAT_IDS`
156
+ * used to be both "every format that exists" and "every format to build by
157
+ * default", which was the same list only while every format was `finishOn:
158
+ * 'any'`. Adding Flatpak to `FORMAT_IDS` alone would have made a bare
159
+ * `gjsify ship` demand `flatpak-builder` of every project that ever packaged a
160
+ * `.deb` — and on a host without it, of every `release-cut.yml` run, which
161
+ * packs `@gjsify/cli` itself on a bare ubuntu runner. A host-bound format is
162
+ * opt-in through `--target` or `gjsify.ship.targets`; `--help` still lists all
163
+ * of them.
164
+ */
165
+ export const DEFAULT_FORMAT_IDS = FORMAT_IDS.filter((id) => FORMATS[id].host.finishOn === 'any');
166
+ /**
167
+ * Refuse a format this host cannot finish, BEFORE anything is built or written.
168
+ *
169
+ * `host` is injected so both branches are unit-testable from any machine — the
170
+ * same reason `resolvePrebuildDirName` and `checkTypeSkew`'s readers are pure.
171
+ * The message names the two-phase split rather than just saying no: the answer
172
+ * to "I am on macOS and want a Flatpak" is `--stage` here and `--from-stage` on
173
+ * a Linux runner, and a refusal that does not say so reads as "unsupported".
174
+ */
175
+ export function assertHostCanFinish(format, host = process.platform) {
176
+ const { finishOn } = format.host;
177
+ if (finishOn === 'any' || finishOn.includes(host))
178
+ return;
179
+ throw new Error(`gjsify ship: a ${format.id} artifact is packed on ${finishOn.join(' or ')} and ` +
180
+ `this host is ${host} (ADR 0024 § A1: a container is produced where its format's tool lives). ` +
181
+ 'Assembly is not host-bound, so the way across is the two-phase split: run ' +
182
+ '`gjsify ship --stage` here, move `ship/stage/` to a ' +
183
+ `${finishOn.join('/')} runner, and finish with ` +
184
+ `\`gjsify ship --from-stage <dir> --target ${format.id}\`.`);
185
+ }
186
+ /**
187
+ * Refuse a format whose tools are not installed, with the tool named.
188
+ *
189
+ * Separate from {@link assertHostCanFinish} because the two failures have
190
+ * different fixes — one needs a different machine, the other needs a package —
191
+ * and separate from the packer's own `notFound` handler because it fires BEFORE
192
+ * a build: `gjsify ship` runs the project's `build` script first, and finding
193
+ * out afterwards that `flatpak-builder` is absent costs the whole build.
194
+ */
195
+ export function assertToolsInstalled(format, present = isOnPath) {
196
+ const missing = format.host.requiredTools.filter((tool) => !present(tool));
197
+ if (missing.length === 0)
198
+ return;
199
+ // The install instruction is the DESCRIPTOR's, not this function's: hardcoded
200
+ // here it was `dnf install flatpak flatpak-builder` for every format that
201
+ // will ever need a tool, which is the branch this table exists to avoid.
202
+ const hint = format.host.installHint;
203
+ throw new Error(`gjsify ship: packing a ${format.id} needs ${format.host.requiredTools.join(' and ')}, and ` +
204
+ `${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not on PATH. ` +
205
+ `${hint === undefined ? 'Install it' : `Install it (${hint})`}, or drop this target — the other ` +
206
+ `formats need no tools at all. \`gjsify ship --stage\` also works without ${missing.join('/')}: ` +
207
+ 'the payload is assembled by this CLI and only the container needs them.');
208
+ }
62
209
  /** Parse `--target deb,rpm` into a sorted, deduplicated descriptor list. */
63
210
  export function resolveFormats(raw) {
64
211
  const names = raw
@@ -0,0 +1,19 @@
1
+ import type { ShipMimeType } from './types.js';
2
+ /**
3
+ * Validate the declared types, throwing on anything that would install and then not work.
4
+ *
5
+ * Each refusal is a silent failure made loud:
6
+ * - a malformed type name is written into the XML and ignored by `update-mime-database`;
7
+ * - a glob without `*.` never matches (`bauplan` matches only a file called exactly that);
8
+ * - a type with no glob and no parent type can never be DETECTED, so it is registered and unused;
9
+ * - a duplicate definition makes which comment wins depend on document order.
10
+ */
11
+ export declare function validateMimeTypes(types: readonly ShipMimeType[]): void;
12
+ /**
13
+ * The shared-mime-info document for `types`.
14
+ *
15
+ * One file per package, named after the app id, because `share/mime/packages/` is shared: a generic
16
+ * name is a collision with whatever another package put there — the same reason the schema staging
17
+ * uses the app id.
18
+ */
19
+ export declare function renderMimePackage(types: readonly ShipMimeType[]): string;
@@ -0,0 +1,89 @@
1
+ // Defining a file type for the system — the half `MimeType=` cannot do on its own.
2
+ //
3
+ // A desktop entry's `MimeType=` says "I open this type". It does NOT say the type EXISTS. For a
4
+ // standard type that is fine, because the distribution's shared-mime-info already defines it; for a
5
+ // type of the project's own it is not, and the failure is the quietest kind there is: nothing knows
6
+ // what a `.bauplan` file is, so the file manager never assigns the type, `MimeType=` matches
7
+ // nothing, and a double-click does nothing at all. No error is printed and no log line is written —
8
+ // the association simply never fires, which is indistinguishable from "the app is not installed".
9
+ //
10
+ // So a project that invents a type also ships a shared-mime-info document, and the package refreshes
11
+ // the MIME cache on install (see `scripts.ts`) — an unrefreshed cache is the same silence again.
12
+ /** XML text escaping. Only the five that matter in element content and attributes. */
13
+ function esc(value) {
14
+ return value
15
+ .replace(/&/g, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&apos;');
20
+ }
21
+ const TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
22
+ /**
23
+ * Validate the declared types, throwing on anything that would install and then not work.
24
+ *
25
+ * Each refusal is a silent failure made loud:
26
+ * - a malformed type name is written into the XML and ignored by `update-mime-database`;
27
+ * - a glob without `*.` never matches (`bauplan` matches only a file called exactly that);
28
+ * - a type with no glob and no parent type can never be DETECTED, so it is registered and unused;
29
+ * - a duplicate definition makes which comment wins depend on document order.
30
+ */
31
+ export function validateMimeTypes(types) {
32
+ const seen = new Set();
33
+ for (const entry of types) {
34
+ if (!TYPE_RE.test(entry.type)) {
35
+ throw new Error(`gjsify ship: \`gjsify.ship.mimeTypes\` has an invalid type "${entry.type}". ` +
36
+ 'Expected `<media>/<subtype>`, e.g. `application/x-bauplan` — update-mime-database ' +
37
+ 'ignores a malformed name, so the type would install and never resolve.');
38
+ }
39
+ if (seen.has(entry.type)) {
40
+ throw new Error(`gjsify ship: \`gjsify.ship.mimeTypes\` defines ${entry.type} twice. ` +
41
+ 'Which comment and glob set win would depend on document order.');
42
+ }
43
+ seen.add(entry.type);
44
+ if (entry.comment.trim() === '') {
45
+ throw new Error(`gjsify ship: ${entry.type} has an empty \`comment\`. A file manager then shows the ` +
46
+ 'user the raw type string instead of a name.');
47
+ }
48
+ for (const glob of entry.globs ?? []) {
49
+ if (!glob.includes('*') && !glob.includes('?') && !glob.includes('[')) {
50
+ throw new Error(`gjsify ship: ${entry.type} has the glob "${glob}", which matches only a file ` +
51
+ `named exactly that. A suffix pattern is written \`*.${glob.replace(/^\.+/, '')}\`.`);
52
+ }
53
+ }
54
+ if ((entry.globs ?? []).length === 0 && entry.subClassOf === undefined) {
55
+ throw new Error(`gjsify ship: ${entry.type} declares neither \`globs\` nor \`subClassOf\`, so nothing ` +
56
+ 'can ever match it. The type would be registered and then never assigned to a file.');
57
+ }
58
+ }
59
+ }
60
+ /**
61
+ * The shared-mime-info document for `types`.
62
+ *
63
+ * One file per package, named after the app id, because `share/mime/packages/` is shared: a generic
64
+ * name is a collision with whatever another package put there — the same reason the schema staging
65
+ * uses the app id.
66
+ */
67
+ export function renderMimePackage(types) {
68
+ const body = types
69
+ .map((entry) => {
70
+ const lines = [` <mime-type type="${esc(entry.type)}">`, ` <comment>${esc(entry.comment)}</comment>`];
71
+ if (entry.subClassOf !== undefined) {
72
+ lines.push(` <sub-class-of type="${esc(entry.subClassOf)}"/>`);
73
+ }
74
+ for (const glob of entry.globs ?? []) {
75
+ lines.push(` <glob pattern="${esc(glob)}"/>`);
76
+ }
77
+ if (entry.genericIcon !== undefined) {
78
+ lines.push(` <generic-icon name="${esc(entry.genericIcon)}"/>`);
79
+ }
80
+ lines.push(' </mime-type>');
81
+ return lines.join('\n');
82
+ })
83
+ .join('\n');
84
+ return `<?xml version="1.0" encoding="UTF-8"?>
85
+ <mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
86
+ ${body}
87
+ </mime-info>
88
+ `;
89
+ }
@@ -16,8 +16,84 @@ export interface PayloadEntry {
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 declare function buildTimestamp(bundlePath: string, env?: Record<string, string | undefined>): number;
27
+ /**
28
+ * What the payload itself says about what it installs.
29
+ *
30
+ * Every field here used to be answered from the SETTINGS — `settings.iconFiles.length > 0`,
31
+ * `settings.schemaFiles.length > 0`, `settings.typelibFiles` — i.e. from lists of absolute paths
32
+ * on the BUILD host. Two things were wrong with that, and the second is why this function exists
33
+ * at all:
34
+ *
35
+ * 1. It answered a different question than the one being asked. `cacheRefreshCommands` emits
36
+ * `gtk-update-icon-cache <prefix>/share/icons/hicolor` — the honest precondition is "did this
37
+ * package install anything into that directory", not "did the project have icon files lying
38
+ * around". They come apart for a `kind: 'cli'` project with a `data/icons/` folder: the
39
+ * planner stages no icon (icons are an `'app'` thing), the settings still listed them, and the
40
+ * postinst refreshed a cache for files that were never installed.
41
+ * 2. An absolute build-host path cannot cross to the host that packs the artifact
42
+ * (ADR 0024 § A2). Carrying `iconFiles: ["/home/…/icon.svg"]` in a stage manifest so that
43
+ * `.length > 0` can be read on another machine is authoring a value that is measurable right
44
+ * there in the tree.
45
+ *
46
+ * Path-only, on purpose — `isArchIndependent` is the sibling that reads BYTES, and the two answer
47
+ * different questions from the same payload. The same split as `plan.ts`'s `isExecutableAsset`
48
+ * (by name) versus this module's magic sniffing (by content).
49
+ */
50
+ export interface PayloadFacts {
51
+ /** The payload installs a `share/applications/*.desktop`. */
52
+ hasDesktopEntry: boolean;
53
+ /** The payload installs into `share/icons/hicolor/`. */
54
+ hasIcons: boolean;
55
+ /** The payload installs a compiled-on-install `share/glib-2.0/schemas/*.gschema.xml`. */
56
+ hasSchemas: boolean;
57
+ /** The payload installs a `share/mime/packages/*.xml`, so the mime cache needs rebuilding. */
58
+ hasMimeTypes: boolean;
59
+ /** Prefix-relative paths of the typelibs the payload carries itself. */
60
+ bundledTypelibs: string[];
61
+ }
62
+ /**
63
+ * Read {@link PayloadFacts} off a payload or off a plan.
64
+ *
65
+ * Takes anything with a `path`, so the assembling phase can ask the same
66
+ * question of the PLAN (before the tree is read back) that the packing phase
67
+ * asks of the payload. One function, so the two phases cannot disagree about
68
+ * whether a package installs a schema.
69
+ */
70
+ export declare function readPayloadFacts(entries: readonly {
71
+ path: string;
72
+ }[]): PayloadFacts;
73
+ /**
74
+ * The interpreters the payload's own executables need, read off their shebangs.
75
+ *
76
+ * An interpreter is a dependency like any other, and rpm expects it declared:
77
+ * `rpmbuild`'s file-based generator emits one `Requires` per executable
78
+ * shebang, with the `RPMSENSE_FIND_REQUIRES` sense that says "derived, not
79
+ * declared". Measured on Fedora 44 against a package whose only file is a
80
+ * `#!/bin/sh` script: `rpm -qp --requires` → `/usr/bin/sh 16384`.
81
+ *
82
+ * The LITERAL path, not a resolved one. `rpmbuild` prints `/usr/bin/sh` there
83
+ * because it resolved `/bin` through the symlink of the usrmerged host it ran
84
+ * on; this writer has no target host to resolve against (ADR 0024 § A1 — the
85
+ * packers are pure JavaScript and run anywhere), and `/bin/sh` is satisfied on
86
+ * both layouts: measured on Fedora 44, `rpm -q --whatprovides /bin/sh` and
87
+ * `/usr/bin/sh` both answer `bash`. It is also the spelling the scriptlet
88
+ * requirements already use.
89
+ *
90
+ * EXECUTABLE files only, which is the same rule `rpmbuild` applies. A GJS
91
+ * bundle staged 0644 carries `#!/usr/bin/env -S gjs -m` for the days it is run
92
+ * directly, but nothing in the installed package executes it as a program — the
93
+ * launcher `exec`s `gjs` with it as an argument — so declaring `/usr/bin/env`
94
+ * for it would be a dependency on a path this package never uses.
95
+ */
96
+ export declare function readShebangInterpreters(payload: readonly PayloadEntry[]): string[];
21
97
  /**
22
98
  * Does the payload contain anything architecture-specific?
23
99
  *