@gjsify/cli 0.50.0 → 0.51.1

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.
@@ -0,0 +1,716 @@
1
+ // The AppImage packer — ADR 0024 § A24, issue JumpLink/Learn6502#93.
2
+ //
3
+ // ADR 0024 § 9 DEFERRED this format, and the deferral is what this module has to
4
+ // answer rather than quietly outlive. Its argument: an AppImage promises *one
5
+ // file, no install, any distro*, and that promise holds only if the file carries
6
+ // everything the target is not guaranteed to have — for a GJS application that is
7
+ // GTK4, libadwaita, GJS itself and the typelibs. No relocatable Linux closure
8
+ // exists (`packages/node-gi/gtk-runtime-*` covers darwin and win32 and
9
+ // deliberately not linux, because on Linux GTK has always come from the distro),
10
+ // so a file built today takes its runtime from the host. § 9 called that "a
11
+ // tarball with a launcher, wearing the name of something that promises more".
12
+ //
13
+ // WHAT CHANGED IS NOT THE CLOSURE, IT IS WHAT THE ARTIFACT SAYS ABOUT ITSELF.
14
+ // Two of the three promises hold with no closure at all — *no install* and *no
15
+ // root* — and they are the two a `.deb`, an `.rpm` and a Flatpak between them
16
+ // cannot make on a machine whose distro is not yours and whose sudo you do not
17
+ // have. The third is the one that would be a lie if it were silent, so it is not
18
+ // silent: {@link appImageHostRequirements} derives what the file does NOT carry
19
+ // from the same scan the `.deb` beside it derives its `Depends:` from, `ship`
20
+ // PRINTS it at pack time, and {@link renderAppRun} puts the interpreter half
21
+ // inside the artifact as a refusal that names the missing runtime before the app
22
+ // dies on its first import. The day `@gjsify/gtk-runtime-linux-<arch>` exists the
23
+ // list shrinks to nothing and nothing else here changes — which is exactly the
24
+ // shape § 9 said would unblock it.
25
+ //
26
+ // THE TOOL IS THE HOST'S, and that is the repository's existing line rather than
27
+ // a new one. `.deb` and `.rpm` are written by this tree because they are
28
+ // containers — a table of contents over bytes already in hand. An AppImage is an
29
+ // ELF runtime with a squashfs filesystem concatenated onto it, and a filesystem
30
+ // writer is what ADR 0024 § A6 rejects by name for the `.dmg`: a project rather
31
+ // than a target, every mistake silent. So `appimagetool` is required on PATH, the
32
+ // way `flatpak-builder` is for a Flatpak and WiX is for an `.msi` on Windows —
33
+ // including the part of that precedent that looks unattractive, since neither
34
+ // Fedora nor Debian packages `appimagetool` and the install hint has to name a
35
+ // GitHub release. VENDORING IT IS REFUSED for the reason ADR 0023 refuses a
36
+ // from-source GTK: a binary in this tree is this tree's to license, to update and
37
+ // to have audited, and appimagetool is GPL-3.0 while gjsify is MIT in 185
38
+ // manifests. DOWNLOADING THE TOOL AT PACK TIME is refused for a different one:
39
+ // every other packer here runs offline, and a pack step that fetches is a release
40
+ // step that fails when GitHub does. The tool fetches its RUNTIME on its own
41
+ // account, which nearly made that refusal untrue in practice — see the first
42
+ // measurement below, and the pin that answers it.
43
+ //
44
+ // MEASURED ON appimagetool 1.9.1 (build 296, 2025-12-04), because three of its
45
+ // behaviours decide this file and none of them are in its `--help`:
46
+ //
47
+ // * IT FETCHES THE RUNTIME — it does NOT embed one, which the first draft of
48
+ // this header recorded as measured and which is the opposite of what it does.
49
+ // Re-measured on the version `.docker/ci-fedora.Dockerfile` pins (1.9.1, build
50
+ // 296, git 8c8c91f): every invocation prints `Downloading runtime file from
51
+ // …/type2-runtime/releases/download/continuous/runtime-<arch>` BEFORE
52
+ // "Embedding ELF…", for the host's own architecture as much as for a foreign
53
+ // one, caching nothing — four consecutive packs, four downloads. With the
54
+ // network blocked it exits 1 having written no file, naming `--runtime-file`.
55
+ // So the ~940 KB of ELF a user DOWNLOADS AND EXECUTES came from a ROLLING tag
56
+ // with no digest behind it while the Dockerfile pinned the tool that fetched
57
+ // it by SHA-256 — a pin with a hole under it — and two packs of one build
58
+ // agreed only because `continuous` had not moved between them. It moves:
59
+ // `continuous`'s `runtime-x86_64` and the dated `20251108` release's are the
60
+ // same 944632 bytes and different content.
61
+ // SO THE RUNTIME IS PINNED TOO ({@link findPinnedRuntime},
62
+ // {@link appImageToolArgs}) and an architecture without one is ANNOUNCED
63
+ // rather than refused ({@link appImageRuntimeNotice}) — declare rather than
64
+ // imply, the rule {@link appImageHostRequirements} already follows. Measured
65
+ // with a pin: the pack succeeds with the network blocked and two packs are
66
+ // byte-identical.
67
+ // * IT CANNOT GUESS OUR ARCHITECTURE. appimagetool reads the AppDir's binaries
68
+ // to decide, and a `--app gjs` payload is JavaScript and a `/bin/sh`
69
+ // launcher: there is no ELF to read. `ARCH` in the environment is therefore
70
+ // REQUIRED here, not a hint — see {@link appImageToolEnv}.
71
+ // * IT IS REPRODUCIBLE, GIVEN mtimes. Two builds a second apart are
72
+ // byte-identical (measured, sha256), and the squashfs superblock's creation
73
+ // time is already pinned to epoch 0 — but `touch`ing the AppDir changes the
74
+ // output, because mksquashfs stores per-file mtimes. `ship` promises
75
+ // byte-identical artifacts from one build, so {@link stampAppDirTimes} sets
76
+ // every path in the AppDir to the run's `mtime`, the same value `deb.ts`, `rpm.ts`
77
+ // and `zip.ts` write into their own headers.
78
+ //
79
+ // FUSE IS THE CLASSIC TRAP AND IT IS TWO TRAPS, on opposite sides of the pack:
80
+ //
81
+ // * BUILDING. `appimagetool` is itself distributed as an AppImage, so running
82
+ // it mounts a squashfs through libfuse — which a CI container without
83
+ // `/dev/fuse` cannot do, and the failure is `dlopen(): libfuse.so.2` or a
84
+ // bare exit 1 rather than anything about FUSE. {@link appImageToolEnv} sets
85
+ // `APPIMAGE_EXTRACT_AND_RUN=1` unconditionally: the AppImage runtime reads it
86
+ // and extracts itself to a temp directory instead of mounting, and a native
87
+ // `appimagetool` binary ignores an environment variable it does not read. So
88
+ // the FUSE-less path is the DEFAULT here rather than something a CI author
89
+ // has to know.
90
+ // * RUNNING. The artifact this writes mounts itself the same way, so a user on
91
+ // a FUSE-less host needs `--appimage-extract-and-run` (or
92
+ // `--appimage-extract`). That is the runtime's own flag and nothing here can
93
+ // remove it; what this file can do is make sure nobody reads the resulting
94
+ // silence as a broken build, which is why {@link buildAppImage}'s failure
95
+ // names FUSE and both escape hatches.
96
+ import { existsSync, utimesSync } from 'node:fs';
97
+ import { join, posix, sep } from 'node:path';
98
+ import { describeExit, spawnToCompletion } from '../spawn.js';
99
+ import { encodeUtf8 } from './bytes.js';
100
+ import { DEFAULT_GJS_FLOOR, DEFAULT_NODE_FLOOR, hostProvidedNamespaces } from './depends.js';
101
+ import { LAYOUTS } from './layout.js';
102
+ import { isSchemaSource, SCHEMA_CACHE } from './schemas.js';
103
+ import { SHARE } from './share-dirs.js';
104
+ /** The tool this packer execs. One spelling, read by this module AND by the format row. */
105
+ export const APPIMAGE_TOOL = 'appimagetool';
106
+ /**
107
+ * The environment variable the AppImage runtime reads to extract instead of mount.
108
+ *
109
+ * Named rather than inlined because two different programs read it for two
110
+ * different reasons — `appimagetool` when this packer runs it, and the artifact
111
+ * when a user runs that — and the spec asserts on the pair.
112
+ */
113
+ export const EXTRACT_AND_RUN = 'APPIMAGE_EXTRACT_AND_RUN';
114
+ /**
115
+ * Where a PINNED AppImage runtime is looked for, and the variable that moves it.
116
+ *
117
+ * THE RUNTIME IS THE HALF OF THE ARTIFACT THIS TREE DOES NOT WRITE, and until it
118
+ * was pinned nothing pinned it: appimagetool fetches `runtime-<arch>` from
119
+ * `type2-runtime`'s ROLLING `continuous` tag on every pack (module header), so
120
+ * the ~940 KB of ELF a user downloads and executes arrived with no digest behind
121
+ * it, while `.docker/ci-fedora.Dockerfile` carefully pinned the tool that fetched
122
+ * it. Measured, and not a theoretical drift: `continuous`'s `runtime-x86_64` and
123
+ * the dated `20251108` release's differ in content at the same 944632 bytes.
124
+ *
125
+ * A DIRECTORY AND AN ENVIRONMENT VARIABLE, rather than a download this packer
126
+ * performs, because that is ADR 0024 § A25's rule applied one level down — a
127
+ * pack step that fetches is a release step that fails when GitHub does. The CI
128
+ * image puts a digest-checked file here; a workstation either does the same or
129
+ * takes the announced path below. Arch-named, `runtime-<arch>`, which is
130
+ * type2-runtime's own asset name, so pinning one is a copy and not a rename.
131
+ */
132
+ export const APPIMAGE_RUNTIME_DIR_ENV = 'GJSIFY_APPIMAGE_RUNTIME_DIR';
133
+ /** The default home for {@link APPIMAGE_RUNTIME_DIR_ENV}, which the CI image fills. */
134
+ export const APPIMAGE_RUNTIME_DIR = '/usr/local/share/gjsify/appimage-runtime';
135
+ /** The directory name the AppImage specification gives the tree appimagetool packs. */
136
+ const APPDIR_NAME = 'AppDir';
137
+ /** The entry point the AppImage runtime execs inside the mounted image. */
138
+ const APPRUN_NAME = 'AppRun';
139
+ /**
140
+ * The AppDir's thumbnail, and the reason this packer writes it rather than
141
+ * letting appimagetool do it.
142
+ *
143
+ * MEASURED, AS A REPRODUCIBILITY DEFECT, and it is the kind only a byte
144
+ * comparison finds: appimagetool creates `.DirIcon` as a SYMLINK to the root icon
145
+ * when the AppDir does not already have one, and it does that AFTER
146
+ * {@link stampAppDirTimes} has run — so the link carries the wall clock, the
147
+ * AppDir root directory's own mtime moves with it, and mksquashfs stores both.
148
+ * Two `gjsify ship --target appimage` runs over ONE build then differ in sha256
149
+ * while every listing, mode, size and byte of content is identical (measured: 965
150
+ * differing bytes, the first inside `.digest_md5`, because the embedded digest
151
+ * follows the filesystem it covers). Four packs over the SAME AppDir are
152
+ * byte-identical, which is what makes this a mutation of the tree rather than a
153
+ * nondeterministic packer.
154
+ *
155
+ * A REGULAR FILE and not a symlink, for `dmg.ts`'s reason: `writePayload` writes
156
+ * regular files and nothing else — a property `tests/e2e/ship` asserts, and one
157
+ * `actions/upload-artifact` would silently dereference anyway. squashfs
158
+ * deduplicates the second copy, so it costs an inode and no data.
159
+ */
160
+ export const DIR_ICON_NAME = '.DirIcon';
161
+ /**
162
+ * The one directory the staged prefix goes under inside the AppDir.
163
+ *
164
+ * Exported because `stampAppDirTimes` and the e2e reader both address paths
165
+ * through it, and a second `'usr'` literal is the kind that stays right until
166
+ * somebody changes the first one.
167
+ */
168
+ export const APPDIR_PREFIX_DIR = 'usr';
169
+ /**
170
+ * The extensions an AppDir root icon may have — a SET, deliberately not an order.
171
+ *
172
+ * Which of two candidates wins is `rootIconRank`'s question and is answered by
173
+ * the hicolor CONTEXT, not by the file type; the first draft of this constant
174
+ * claimed "PNG before SVG" and the code below ranks a scalable SVG above every
175
+ * raster, so the comment described a preference nothing implemented. Both are
176
+ * accepted because both are what a GNOME application ships: `.xpm` and `.svgz`,
177
+ * which appimagetool also reads, are absent because `plan.ts` stages neither.
178
+ */
179
+ const ICON_EXTENSIONS = ['.png', '.svg'];
180
+ /**
181
+ * Where the AppDir is assembled, under the run's own output root.
182
+ *
183
+ * A directory of its own, never `out/`, for the reason `dmgVolumeDir` gives one
184
+ * format over: appimagetool packs everything it finds in the AppDir, so a stray
185
+ * sibling would ride into the image and the reader that would catch it is the
186
+ * same listing comparison that would then have to be relaxed to allow it.
187
+ */
188
+ export function appDirFor(outRoot) {
189
+ return join(outRoot, 'appimage', APPDIR_NAME);
190
+ }
191
+ /**
192
+ * What the AppImage does NOT carry, derived from the payload rather than authored.
193
+ *
194
+ * THE HONEST HALF OF ADR 0024 § 9, as data. The same scan that gives the `.deb`
195
+ * its `Depends: gir1.2-gtk-4.0` gives this its `Gtk-4.0`, so the two artifacts
196
+ * built from one payload cannot disagree about what the host has to provide —
197
+ * and the AppImage's version is distro-NEUTRAL on purpose, because there is no
198
+ * distribution to name package names in. A user reading `Gtk-4.0` can find it on
199
+ * any system; a user reading `gir1.2-gtk-4.0` on Fedora cannot.
200
+ *
201
+ * The interpreter is first and always present: it is the requirement that is
202
+ * never satisfied by accident, and the one {@link renderAppRun} can check from a
203
+ * shell script.
204
+ */
205
+ export function appImageHostRequirements(input) {
206
+ const floor = interpreterFloor(input);
207
+ return [
208
+ `${input.app} (>= ${floor})`,
209
+ ...hostProvidedNamespaces(input.namespaces, input.bundledTypelibs).map((ns) => `the ${ns} typelib`),
210
+ ];
211
+ }
212
+ /**
213
+ * The version this payload's interpreter must be at least, as the project declared it.
214
+ *
215
+ * ONE DECISION, TWO READERS, which is why it is a function and not two ternaries:
216
+ * {@link appImageHostRequirements} PRINTS this floor and {@link renderAppRun}
217
+ * CHECKS it, and an artifact whose message names 1.86 while its script compares
218
+ * against something else is worse than one that does neither.
219
+ */
220
+ export function interpreterFloor(input) {
221
+ return input.app === 'node'
222
+ ? (input.minNodeVersion ?? DEFAULT_NODE_FLOOR)
223
+ : (input.minGjsVersion ?? DEFAULT_GJS_FLOOR);
224
+ }
225
+ /**
226
+ * The `AppRun` the AppImage runtime execs, and the one check it makes first.
227
+ *
228
+ * IT EXECS THE STAGED LAUNCHER AND ADDS NOTHING TO IT. `bin/<name>` already
229
+ * resolves its own prefix — `readlink -f "$0"` then two `dirname`s
230
+ * (`renderPrefixLauncher`) — and exports `XDG_DATA_DIRS`, `GI_TYPELIB_PATH`,
231
+ * `GJSIFY_LOCALE_DIR` and `GJSIFY_FONT_DIR` relative to it. Inside a mounted
232
+ * image that resolves to `/tmp/.mount_xxxx/usr`, measured, so every locator the
233
+ * `.deb` sets up is already correct here. Re-exporting any of them from AppRun
234
+ * would be a second answer to a question the payload answers, and the copy that
235
+ * drifts is the one nothing tests: `tests/e2e/ship-layout` compares the staged
236
+ * trees, not this script.
237
+ *
238
+ * THAT CLAIM WAS RE-MEASURED AGAINST THE ARTIFACT and it holds — which is worth
239
+ * writing down, because when Learn6502 0.8.0's AppImage died on `GSettings schema
240
+ * … not found` this script was the obvious suspect and adding
241
+ * `GSETTINGS_SCHEMA_DIR` here was the obvious fix. It would have changed nothing.
242
+ * Measured on the real 0.8.0 image, by compiling `gschemas.compiled` into the
243
+ * extracted AppDir and touching NO environment variable anywhere: the application
244
+ * started. The launcher's `XDG_DATA_DIRS` was already pointing GSettings at the
245
+ * right directory; the directory had no cache in it. An extra export would have
246
+ * been a second name for a path that was already correct, and it would have
247
+ * "fixed" the bug by sitting next to the change that did.
248
+ *
249
+ * WHAT IT DOES ADD is the refusal ADR 0024 § 9 asks for. An AppImage whose
250
+ * interpreter is missing exits with the loader's own message — `gjs: not found`
251
+ * from a shell, or nothing at all when a desktop launches it — and the user
252
+ * concludes the download is broken. `command -v` costs one fork and turns that
253
+ * into a sentence naming the runtime, this file's promise and the escape hatch.
254
+ * The typelib half is deliberately NOT checked here: girepository's search path
255
+ * is not a shell-visible thing, and GJS's own "Typelib file for namespace 'Gtk'
256
+ * not found" is already precise. Listing them in the refusal is what this file
257
+ * can honestly do — checking them would be a probe that guesses.
258
+ *
259
+ * AND THE FLOOR IS CHECKED, FAIL-OPEN, which is the one design decision in this
260
+ * script. `command -v gjs` alone accepted GJS 1.70 while the message beside it
261
+ * said `gjs (>= 1.86)` — a floor the `.deb` enforces through `Depends:` and this
262
+ * artifact merely printed, so the failure it exists to prevent came back one
263
+ * import later. The check costs the same fork `command -v` already cost.
264
+ *
265
+ * FAIL-OPEN IS THE POINT AND NOT A HEDGE. A version comparison in `/bin/sh` that
266
+ * gets it wrong REFUSES A WORKING SYSTEM, which is strictly worse than not
267
+ * checking: the user cannot argue with an artifact. So the refusal fires only on
268
+ * two integers that were actually parsed out of the interpreter's own
269
+ * `--version`, and every other outcome — no output, a spelling this `sed` does
270
+ * not match, a distro build that prints something else entirely — RUNS THE
271
+ * APPLICATION. `gjs --version` prints `gjs 1.88.1` and `node --version` prints
272
+ * `v24.19.0`; both reduce to a major and a minor, and a floor with no minor
273
+ * (`24`) compares as `24.0`.
274
+ *
275
+ * EXIT 126 AND NOT 127 for the too-old case, because the shell's own vocabulary
276
+ * already separates them: 127 is "not found", 126 is "found and cannot be run".
277
+ * A wrapper script that wants to tell the two apart can, and the message says
278
+ * which it is either way.
279
+ */
280
+ export function renderAppRun(settings, hostRequirements) {
281
+ // `LAYOUTS.linux.dirs()` and not the literal `bin`: this path and the one
282
+ // `place()` stages the launcher at are ONE decision, and a second spelling here
283
+ // would break the day the layout map moves it — silently, because the AppDir
284
+ // would still build and only the mounted image would 127.
285
+ const launcherDir = LAYOUTS.linux.dirs(settings).launcher;
286
+ const floor = interpreterFloor(settings);
287
+ const [floorMajor = '0', floorMinor = '0'] = floor.split('.');
288
+ return [
289
+ '#!/bin/sh',
290
+ '# Generated by `gjsify ship` — do not edit.',
291
+ 'set -e',
292
+ 'here=$(dirname "$(readlink -f "$0")")',
293
+ `if ! command -v ${settings.app} >/dev/null 2>&1; then`,
294
+ ` echo "${shellSafe(settings.name)} needs ${settings.app}, and this system has none." >&2`,
295
+ ' echo "This AppImage carries the application, not its runtime. It needs:" >&2',
296
+ ...hostRequirements.map((need) => ` echo " - ${shellSafe(need)}" >&2`),
297
+ ' exit 127',
298
+ 'fi',
299
+ // The first `<major>.<minor>` anywhere in the interpreter's own version
300
+ // output. `2>/dev/null` and `|| true` because `set -e` is on and an
301
+ // interpreter that fails its own `--version` must not kill the launcher —
302
+ // that is the fail-open path, reached before any comparison.
303
+ `found=$(${settings.app} --version 2>/dev/null | sed -n 's/[^0-9]*\\([0-9][0-9]*\\)\\.\\([0-9][0-9]*\\).*/\\1 \\2/p' | head -n 1 || true)`,
304
+ 'major=${found% *}',
305
+ 'minor=${found#* }',
306
+ // THE FAIL-OPEN GATE, and it is this ONE test because the `sed` above can
307
+ // only ever emit two runs of digits or nothing at all — both captures are
308
+ // `[0-9][0-9]*`. A `case "$major$minor" in ""|*[!0-9]*)` stood here first
309
+ // and was deleted after the mutation that should have red it stayed green:
310
+ // it guarded a state nothing can produce, which is a guard that cannot
311
+ // fail. What DOES reach here is the empty parse, and that is what this
312
+ // sees.
313
+ 'if [ -n "$major" ]; then',
314
+ ` if [ "$major" -lt ${floorMajor} ] || { [ "$major" -eq ${floorMajor} ] && [ "$minor" -lt ${floorMinor} ]; }; then`,
315
+ ` echo "${shellSafe(settings.name)} needs ${settings.app} ${floor} or newer, and this system has $major.$minor." >&2`,
316
+ ' echo "This AppImage carries the application, not its runtime. It needs:" >&2',
317
+ ...hostRequirements.map((need) => ` echo " - ${shellSafe(need)}" >&2`),
318
+ ' exit 126',
319
+ ' fi',
320
+ 'fi',
321
+ `exec "$here/usr/${launcherDir}/${settings.binaryName}" "$@"`,
322
+ '',
323
+ ].join('\n');
324
+ }
325
+ /**
326
+ * A string that is safe between double quotes in `/bin/sh`.
327
+ *
328
+ * Narrow on purpose: the three characters the shell still expands inside double
329
+ * quotes, plus the quote itself. `settings.name` is a display name a project
330
+ * chose and `hostRequirements` is derived from GI namespaces, so neither is
331
+ * hostile — but a name containing `$` would otherwise produce a message with a
332
+ * variable expansion in it, and an AppRun is the one file here nobody reads
333
+ * until it has already failed.
334
+ */
335
+ function shellSafe(text) {
336
+ return text.replace(/(["$`\\])/g, '\\$1');
337
+ }
338
+ /**
339
+ * The icon appimagetool needs at the AppDir root, chosen from the payload.
340
+ *
341
+ * AN APPIMAGE NEEDS TWO FILES THE PREFIX ALREADY HAS, AT A PLACE IT DOES NOT:
342
+ * appimagetool refuses an AppDir without a `.desktop` at its root, and the file
343
+ * named by that entry's `Icon=` must sit beside it. `plan.ts` stages both — the
344
+ * entry at `share/applications/<appId>.desktop` and every icon renamed to
345
+ * `<appId>` under `share/icons/hicolor/…` — so this function is a SELECTION over
346
+ * the payload rather than anything new in the stage, which is ADR 0024 § 2's
347
+ * "one payload, a handful of layouts" holding for a fourth format.
348
+ *
349
+ * LARGEST RASTER WINS, and the size comes from the hicolor directory rather than
350
+ * from reading the file: `share/icons/hicolor/256x256/apps/<id>.png` says 256 in
351
+ * its path, and a PNG header read would be a decoder this packer would then own.
352
+ * `scalable` sorts above every raster size as the SVG fallback and `symbolic` is
353
+ * excluded outright — its staged name is `<appId>-symbolic`, which is not what
354
+ * the desktop entry's `Icon=` says, so shipping it as the root icon installs an
355
+ * icon nothing ever looks up (`plan.ts` documents the same rule for the theme).
356
+ */
357
+ /**
358
+ * The pinned runtime for this architecture, or `undefined` when none is staged here.
359
+ *
360
+ * PURE APART FROM ONE INJECTED PROBE, so both answers are testable on a host that
361
+ * has neither — the split {@link appImageToolArgs} draws for the flags. `exists`
362
+ * is a parameter for exactly that reason and defaults to the real one.
363
+ */
364
+ export function findPinnedRuntime(archLabel, env = process.env, exists = existsSync) {
365
+ const dir = env[APPIMAGE_RUNTIME_DIR_ENV] || APPIMAGE_RUNTIME_DIR;
366
+ // `posix.join`, not `join`: this path is a LINUX path by construction — the
367
+ // default is baked into the Linux CI image and the format is `finishOn:
368
+ // ['linux']`, so it never names a location on the host running this code.
369
+ // Composing it with the HOST's separator produced a backslash path on
370
+ // win32, where the unit suite runs: caught by CI as two red assertions,
371
+ // after every local run on Linux had agreed with itself.
372
+ const file = posix.join(dir, `runtime-${archLabel}`);
373
+ return exists(file) ? file : undefined;
374
+ }
375
+ /**
376
+ * What this pack is about to do about the runtime, as ONE sentence `ship` prints
377
+ * unconditionally.
378
+ *
379
+ * DECLARE RATHER THAN IMPLY — {@link appImageHostRequirements}'s rule, applied to
380
+ * the other half of the artifact. With a pin the claim is checkable: no network,
381
+ * and two packs of one build agree because the embedded bytes are fixed. Without
382
+ * one the pack still works and still produces a correct-architecture container —
383
+ * so refusing would cost the three architectures nothing here pins, for a
384
+ * property they never had. What it must not do is happen silently: an AppImage
385
+ * built this way carries bytes from a rolling tag that nothing in this tree can
386
+ * name, and the person who can fix that is the one reading this line.
387
+ */
388
+ export function appImageRuntimeNotice(archLabel, runtimeFile) {
389
+ if (runtimeFile !== undefined) {
390
+ return `the AppImage runtime is pinned: ${runtimeFile} — this pack needs no network`;
391
+ }
392
+ return (`NO pinned AppImage runtime for ${archLabel}: appimagetool will fetch runtime-${archLabel} from ` +
393
+ "type2-runtime's rolling `continuous` tag, so this pack NEEDS A NETWORK and the ~940 KB of runtime " +
394
+ `it embeds carries no digest from this tree. Pin one at ${APPIMAGE_RUNTIME_DIR}/runtime-${archLabel} ` +
395
+ `(or point ${APPIMAGE_RUNTIME_DIR_ENV} at a directory holding it).`);
396
+ }
397
+ export function selectAppDirIcon(payload, appId) {
398
+ let best;
399
+ for (const entry of payload) {
400
+ const rank = rootIconRank(entry.path, appId);
401
+ if (rank === undefined)
402
+ continue;
403
+ if (best === undefined || rank > best.rank)
404
+ best = { entry, rank };
405
+ }
406
+ return best?.entry;
407
+ }
408
+ /**
409
+ * How good a candidate a staged path is for the AppDir root icon, or `undefined`
410
+ * when it is not a candidate at all.
411
+ *
412
+ * Scalable outranks every raster because an SVG is every size; among rasters the
413
+ * pixel count decides. Both are ranks rather than a sort key so the caller stays
414
+ * a single pass — and so the `undefined` arm is the ONLY place a path is
415
+ * rejected, instead of a filter here and a guard there.
416
+ */
417
+ function rootIconRank(path, appId) {
418
+ const extension = ICON_EXTENSIONS.find((candidate) => path.endsWith(`${appId}${candidate}`));
419
+ if (extension === undefined)
420
+ return undefined;
421
+ if (!path.startsWith(`${SHARE.icons}/`))
422
+ return undefined;
423
+ // `scalable` and `symbolic` are contexts, not sizes — and only the first is
424
+ // wanted. `symbolic` cannot reach here anyway (its staged basename carries the
425
+ // suffix, so the `endsWith` above misses it), and the explicit refusal stays
426
+ // because the day a project names a plain icon `.../symbolic/apps/<id>.svg` is
427
+ // the day the implicit version silently ships a 16px monochrome glyph as the
428
+ // application's face.
429
+ if (path.includes('/symbolic/'))
430
+ return undefined;
431
+ if (path.includes('/scalable/'))
432
+ return Number.MAX_SAFE_INTEGER;
433
+ const size = /\/(\d+)x\1\//.exec(path);
434
+ return size === null ? 1 : Number(size[1]);
435
+ }
436
+ /**
437
+ * Refuse a payload that cannot become an AppImage, naming the missing half.
438
+ *
439
+ * BEFORE appimagetool RATHER THAN THROUGH IT, and the two refusals it replaces
440
+ * are why: a `kind: 'cli'` project stages no desktop entry at all, and
441
+ * appimagetool answers that with `Desktop file not found, aborting` — true, and
442
+ * about a file the author never wrote and cannot find. Same for the icon. Both
443
+ * failures are decided by the project's OWN configuration (`gjsify.ship.kind`,
444
+ * `gjsify.ship.icon`), so the message that helps is the one that names the key.
445
+ *
446
+ * Separate from `assertToolsInstalled` and `assertHostCanFinish` for the reason
447
+ * those two are separate from each other (ADR 0024 § A3): three failures, three
448
+ * different fixes — a different machine, a package, or a line in package.json.
449
+ */
450
+ export function assertAppImageIsPackable(payload, settings, schemaCache) {
451
+ const desktopEntry = `${SHARE.applications}/${settings.appId}.desktop`;
452
+ if (!payload.some((entry) => entry.path === desktopEntry)) {
453
+ throw new Error(`gjsify ship: an AppImage needs a desktop entry and this payload has no ${desktopEntry}. ` +
454
+ 'appimagetool refuses an AppDir without one, and the AppImage runtime has nothing to tell a ' +
455
+ 'desktop about the application it just mounted. A `kind: "cli"` project stages no entry by ' +
456
+ 'design — set `gjsify.ship.kind` to "app" if this is a GUI application, or ship it as a `.deb`, ' +
457
+ 'an `.rpm` or a Flatpak, none of which need one.');
458
+ }
459
+ if (selectAppDirIcon(payload, settings.appId) === undefined) {
460
+ throw new Error(`gjsify ship: an AppImage needs an icon called ${settings.appId} beside its desktop entry, and ` +
461
+ `this payload stages none under ${SHARE.icons}/. The entry's \`Icon=\` names the app id, so a ` +
462
+ 'file keeping its own basename is an icon nothing looks up — point `gjsify.ship.icon` at a PNG ' +
463
+ 'or an SVG and it is renamed into the theme for you.');
464
+ }
465
+ // THE THIRD REFUSAL, and the only one whose failure is invisible until a user
466
+ // double-clicks the file. The two above are decided by `package.json` and
467
+ // appimagetool would have caught both eventually; this one is decided by the
468
+ // PACK PATH and nothing catches it — the image builds, the oracle reads it
469
+ // back, every listing agrees, and the app dies at its first
470
+ // `Gio.Settings.new()` because `share/glib-2.0/schemas` holds a source with no
471
+ // compiled cache beside it. That is exactly what Learn6502 0.8.0 shipped.
472
+ //
473
+ // A GUARD ON THE PURE FUNCTION rather than a step in `packOne`, because this
474
+ // is the INVARIANT and not the procedure: whoever assembles an AppDir from a
475
+ // payload carrying schema sources owes it a cache, and an assembler that
476
+ // forgets must not be able to produce a tree. It is also what keeps the e2e
477
+ // honest — the suite calls this function to build its own AppDir, so it
478
+ // inherits the refusal instead of quietly modelling the defect.
479
+ if (schemaCache === undefined && payload.some((entry) => isSchemaSource(entry.path))) {
480
+ throw new Error(`gjsify ship: this payload stages GSettings schema sources under ${SHARE.schemas}/ and no ` +
481
+ `${SCHEMA_CACHE} was compiled for it. An AppImage has NO INSTALL STEP — that is the whole ` +
482
+ 'point of the format — so the `.deb`/`.rpm` postinst that compiles the system schema ' +
483
+ 'directory never runs, and GSettings aborts on a directory holding only sources. The AppDir ' +
484
+ `must carry ${SCHEMA_CACHE}; \`compileSchemasForPayload\` produces it from this payload.`);
485
+ }
486
+ }
487
+ /**
488
+ * The whole AppDir as one payload: the prefix under `usr/`, plus the compiled
489
+ * schema cache, plus the three files at its root.
490
+ *
491
+ * THE SCHEMA CACHE IS AN ARGUMENT AND NOT A COMPILE, so this function stays pure
492
+ * and so the one impure step in the AppDir has exactly one home (`packOne`). It
493
+ * is prefix-relative on the way in — `share/glib-2.0/schemas/gschemas.compiled`,
494
+ * the same shape every other entry has — and gets the `usr/` prefix from the same
495
+ * map below, because a second spelling of that prefix is the thing this packer
496
+ * spent a comment explaining it must not have. `undefined` is legal and means the
497
+ * payload has no schemas at all; a payload that HAS them and no cache is refused
498
+ * by {@link assertAppImageIsPackable}, not silently packed.
499
+ *
500
+ * PURE, AND ONE LIST RATHER THAN A WRITER, which is the shape `msi.ts` and
501
+ * `dmg.ts` already have — `packOne` owns the single `writePayload` call for every
502
+ * format, and the packer module owns what goes in it. Two calls here would be two
503
+ * wipes, and the second would not clean the first: an AppDir root still holding
504
+ * the PREVIOUS app id's `.desktop` and icon is an AppDir appimagetool packs
505
+ * without complaint, because it looks for *a* desktop file and finds two.
506
+ *
507
+ * `usr/` AND NOT THE APPDIR ROOT — that prefix is the whole layout difference
508
+ * between this format and the `.deb` beside it, and it is why
509
+ * `FormatDescriptor.prefix` can stay `/usr`. The staged launcher walks up two
510
+ * directories from its own resolved path, so under a mount it computes
511
+ * `<mount>/usr` and every locator it exports is already right. A payload written
512
+ * at the root would make it compute the MOUNTPOINT, and the app would look for
513
+ * its schemas one directory too high — at exit 0, until the first
514
+ * `Gio.Settings.new()`.
515
+ */
516
+ export function appDirPayload(settings, payload, hostRequirements, schemaCache) {
517
+ assertAppImageIsPackable(payload, settings, schemaCache);
518
+ // Non-null by construction: the assertion above refuses a payload with
519
+ // neither, and both reads are the SAME selection it made.
520
+ const icon = selectAppDirIcon(payload, settings.appId);
521
+ const desktopPath = `${SHARE.applications}/${settings.appId}.desktop`;
522
+ const desktop = payload.find((entry) => entry.path === desktopPath);
523
+ return [
524
+ ...[...payload, ...(schemaCache === undefined ? [] : [schemaCache])].map((entry) => ({
525
+ ...entry,
526
+ path: `${APPDIR_PREFIX_DIR}/${entry.path}`,
527
+ })),
528
+ { path: APPRUN_NAME, mode: 0o755, data: encodeUtf8(renderAppRun(settings, hostRequirements)) },
529
+ // THE SAME BYTES, TWICE, and that is the AppImage specification rather
530
+ // than a duplication to lift: the entry under `usr/share/applications` is
531
+ // what a desktop reads after an install, and the copy at the AppDir root
532
+ // is what appimagetool reads to name the image. Rendering it a second
533
+ // time is what would drift; copying the staged bytes cannot.
534
+ { path: `${settings.appId}.desktop`, mode: 0o644, data: desktop.data },
535
+ { path: icon.path.slice(icon.path.lastIndexOf('/') + 1), mode: 0o644, data: icon.data },
536
+ // THE THIRD COPY OF THE SAME BYTES, and the one that is here to keep
537
+ // appimagetool from writing it — see {@link DIR_ICON_NAME}.
538
+ { path: DIR_ICON_NAME, mode: 0o644, data: icon.data },
539
+ ];
540
+ }
541
+ /**
542
+ * The argument vector, as data.
543
+ *
544
+ * Pure, so every flag is unit-testable on a host with no `appimagetool` — the
545
+ * same split `hdiutilCreateArgs` draws for `hdiutil` and `renderShipFlatpakManifest`
546
+ * for `flatpak-builder`: what the tool is TOLD is checkable everywhere, what the
547
+ * tool DOES needs the tool.
548
+ *
549
+ * `--no-appstream` IS A DECISION AND NOT A DEFAULT. appimagetool otherwise runs
550
+ * `appstreamcli validate-tree` over the AppDir, which would make the pack succeed
551
+ * or fail depending on whether a package unrelated to packaging is installed —
552
+ * green on a workstation with `appstream` and red in a container without it, over
553
+ * a payload that is byte-identical. A validator whose presence changes the
554
+ * outcome is the class this repository calls green-CI-that-checked-nothing, read
555
+ * backwards. The MetaInfo is still validated, by the tool that exists for it:
556
+ * `gjsify flatpak check --appstream` runs `appstreamcli validate --strict` on
557
+ * demand, where a missing `appstreamcli` is the point of the command rather than
558
+ * a surprise inside another one.
559
+ *
560
+ * `--comp zstd` is pinned for `hdiutilCreateArgs`' reason one format over: it is
561
+ * 1.9.1's current default, and a default is a thing that changes. The reader the
562
+ * oracle leg runs is `unsquashfs` from squashfs-tools, which needs its zstd
563
+ * support compiled in — measured present on Fedora 44 (4.6.1) and on
564
+ * ubuntu-24.04's 4.6.1. Pinning means the day appimagetool switches to something
565
+ * newer, this file changes and the reader is considered, instead of the reader
566
+ * silently losing the ability to open what we ship.
567
+ *
568
+ * `--runtime-file` IS PASSED WHEN THERE IS ONE TO PASS, and its absence is the
569
+ * only thing in this vector that is not a decision — see
570
+ * {@link findPinnedRuntime}. With it, the pack embeds bytes this tree pinned and
571
+ * needs no network (measured: the whole pack succeeds with the network blocked,
572
+ * and two packs are byte-identical). Without it appimagetool fetches its own, the
573
+ * pack is announced as doing so, and the artifact is still correct — just built
574
+ * from something nothing here can name.
575
+ */
576
+ export function appImageToolArgs(input) {
577
+ return [
578
+ '--no-appstream',
579
+ '--comp',
580
+ 'zstd',
581
+ ...(input.runtimeFile === undefined ? [] : ['--runtime-file', input.runtimeFile]),
582
+ input.appDir,
583
+ input.target,
584
+ ];
585
+ }
586
+ /**
587
+ * The environment appimagetool is run with — both entries REQUIRED, neither a hint.
588
+ *
589
+ * `ARCH` because appimagetool derives the architecture from the ELF binaries it
590
+ * finds in the AppDir, and a `--app gjs` payload has none: it is JavaScript and a
591
+ * `/bin/sh` launcher. Without this the tool exits 1 with "Could not determine
592
+ * architecture automatically", on a payload that is perfectly fine. The value is
593
+ * the format row's `archName`, so the architecture inside the file and the one in
594
+ * its name are one decision.
595
+ *
596
+ * `APPIMAGE_EXTRACT_AND_RUN` because appimagetool is itself an AppImage and
597
+ * mounts itself through libfuse to start — see the module header. Set
598
+ * unconditionally: a native `appimagetool` build ignores it, so there is no host
599
+ * for which this is wrong and no flag for a CI author to discover.
600
+ */
601
+ export function appImageToolEnv(archLabel) {
602
+ return { ARCH: archLabel, [EXTRACT_AND_RUN]: '1' };
603
+ }
604
+ /**
605
+ * What a non-zero `appimagetool` exit is, in the order a host actually hits them.
606
+ *
607
+ * PURE, so the one message a user reads at the worst moment is assertable without
608
+ * a container — the same split {@link appImageToolArgs} draws. It was inline until
609
+ * the list was found to be missing its FIRST entry, which is the shape a message
610
+ * that nothing reads back tends to have.
611
+ *
612
+ * THE FIRST CAUSE DEPENDS ON WHETHER A RUNTIME WAS PINNED, and getting that
613
+ * wrong is what this function existed to fix in the first place: with no pin the
614
+ * download is where an offline host fails (measured — exit 1, no file, `Failed to
615
+ * download runtime file`), and with one there is no download to fail, so naming
616
+ * it would send a reader with a working network to debug their firewall. So the
617
+ * pinned branch names the pinned FILE instead, which is the thing that is new in
618
+ * that configuration and the only one this tree chose.
619
+ */
620
+ export function appImageToolFailureMessage(exit, runtimeFile) {
621
+ const first = runtimeFile === undefined
622
+ ? 'the AppImage RUNTIME could not be downloaded — ' +
623
+ `${APPIMAGE_TOOL} fetches it from github.com/AppImage/type2-runtime when no runtime is pinned, ` +
624
+ 'so this pack needs a network and an offline host fails here having written nothing; or '
625
+ : `the pinned runtime at ${runtimeFile} is not an AppImage runtime — it is passed through to ` +
626
+ '`--runtime-file` unread, so a truncated or wrong-architecture file fails here; or ';
627
+ return (`gjsify ship: ${APPIMAGE_TOOL} failed with ${exit}. ` +
628
+ `Four causes, measured in this order: ${first}` +
629
+ '`file` is not installed — ' +
630
+ `${APPIMAGE_TOOL} requires file(1) and says so, even though this pack already tells it the ` +
631
+ 'architecture; or FUSE is unavailable and the tool on PATH is a wrapper that dropped ' +
632
+ `${EXTRACT_AND_RUN}=1, which this pack sets so the extract path is taken instead of a mount; ` +
633
+ 'or the work directory is full — the AppDir is copied into a squashfs image beside it.');
634
+ }
635
+ /**
636
+ * Run appimagetool over the AppDir.
637
+ *
638
+ * THE FAILURE PATHS ARE THE POINT. A packer that execs a foreign tool inherits
639
+ * that tool's silence, and appimagetool's container failures were MEASURED on
640
+ * `fedora:44` rather than guessed. Without `APPIMAGE_EXTRACT_AND_RUN=1` it exits
641
+ * **127** pointing at the AppImageKit FUSE wiki — which is why
642
+ * {@link appImageToolEnv} sets that variable and this branch should never see it.
643
+ * Without `file(1)` it exits **1** with "file command is missing but required",
644
+ * EVEN with `ARCH` set, which is the failure a minimal image really hits and
645
+ * which says nothing about packaging. And without a network it exits **1** having
646
+ * written nothing, because the runtime is fetched rather than embedded — see the
647
+ * module header. {@link appImageToolFailureMessage} names all four.
648
+ *
649
+ * AND THE ARTIFACT IS VERIFIED TO EXIST. appimagetool prints "Success" and a
650
+ * request to submit to AppImageHub; it does not guarantee the destination was
651
+ * written, and a packer whose contract is "there is a file at `target`" must not
652
+ * hand `statSync` a path that is not there and report the ENOENT as a `ship` bug.
653
+ */
654
+ export async function buildAppImage(input) {
655
+ const args = appImageToolArgs(input);
656
+ const env = appImageToolEnv(input.archLabel);
657
+ if (input.verbose) {
658
+ console.log(`[gjsify ship] ${Object.entries(env)
659
+ .map(([key, value]) => `${key}=${value}`)
660
+ .join(' ')} ${APPIMAGE_TOOL} ${args.join(' ')}`);
661
+ }
662
+ // `completion: 'return'`, matching the Flatpak and `.dmg` packers: `ship` packs
663
+ // the remaining formats and prints the artifact list afterwards, so it cannot
664
+ // end in `process.exit()`.
665
+ const result = await spawnToCompletion(APPIMAGE_TOOL, args, {
666
+ completion: 'return',
667
+ cwd: input.workDir,
668
+ stdio: 'inherit',
669
+ env: { ...process.env, ...env },
670
+ notFound: () => new Error(`gjsify ship: ${APPIMAGE_TOOL} is not on PATH. Unlike flatpak-builder and msitools it is not ` +
671
+ 'packaged by Fedora or Debian: take the release from ' +
672
+ 'https://github.com/AppImage/appimagetool/releases, `chmod +x` it and put it on PATH as ' +
673
+ `\`${APPIMAGE_TOOL}\`. \`gjsify ship linux --stage\` needs none of this — the payload is ` +
674
+ 'assembled by this CLI and only the container needs the tool.'),
675
+ });
676
+ if (result.code !== 0) {
677
+ throw new Error(appImageToolFailureMessage(describeExit(result), input.runtimeFile));
678
+ }
679
+ if (!existsSync(input.target)) {
680
+ throw new Error(`gjsify ship: ${APPIMAGE_TOOL} exited 0 and wrote no file at ${input.target}. ` +
681
+ 'An exit code is not an artifact — re-run with `--verbose` to see what it was told.');
682
+ }
683
+ }
684
+ /**
685
+ * Every path an AppDir holding this payload has, files and the directories above
686
+ * them, deepest LAST.
687
+ *
688
+ * Pure and separate from the stamping, so the "which paths" half is testable with
689
+ * no filesystem at all — and so the answer comes from the payload rather than
690
+ * from a directory walk, which would read back whatever the writer happened to
691
+ * leave rather than what it was told to write.
692
+ */
693
+ export function appDirPaths(payload) {
694
+ const directories = new Set();
695
+ for (const entry of payload) {
696
+ const parts = entry.path.split('/');
697
+ for (let depth = 1; depth < parts.length; depth++)
698
+ directories.add(parts.slice(0, depth).join('/'));
699
+ }
700
+ return [...[...directories].sort(), ...payload.map((entry) => entry.path)];
701
+ }
702
+ /**
703
+ * Give every path in the AppDir one mtime.
704
+ *
705
+ * See the module header: this is the single difference between two packs of one
706
+ * build, measured by sha256. `utimesSync` on a directory is legal and is where a
707
+ * squashfs directory inode's time comes from, so directories are stamped as well
708
+ * — `writePayload`'s `mkdir` gives them the wall clock and nothing else would
709
+ * take it away.
710
+ */
711
+ export function stampAppDirTimes(appDir, payload, mtime) {
712
+ for (const rel of appDirPaths(payload)) {
713
+ utimesSync(join(appDir, rel.split('/').join(sep)), mtime, mtime);
714
+ }
715
+ utimesSync(appDir, mtime, mtime);
716
+ }