@gjsify/cli 0.39.0 → 0.41.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 (78) hide show
  1. package/dist/cli.gjs.mjs +202 -181
  2. package/lib/cli-app.js +12 -25
  3. package/lib/commands/create.d.ts +3 -0
  4. package/lib/commands/create.js +49 -6
  5. package/lib/commands/flatpak/check.js +1 -1
  6. package/lib/commands/flatpak/ci.js +1 -1
  7. package/lib/commands/flatpak/diff.js +1 -1
  8. package/lib/commands/flatpak/init.js +1 -1
  9. package/lib/commands/flatpak/scaffold.d.ts +2 -4
  10. package/lib/commands/flatpak/scaffold.js +25 -298
  11. package/lib/commands/flatpak/sync-flathub.js +1 -1
  12. package/lib/commands/foreach.js +34 -20
  13. package/lib/commands/index.d.ts +2 -0
  14. package/lib/commands/index.js +2 -0
  15. package/lib/commands/install.d.ts +1 -0
  16. package/lib/commands/install.js +32 -1
  17. package/lib/commands/prune.d.ts +11 -0
  18. package/lib/commands/prune.js +84 -0
  19. package/lib/commands/self-update.d.ts +1 -0
  20. package/lib/commands/self-update.js +55 -0
  21. package/lib/commands/ship.d.ts +11 -0
  22. package/lib/commands/ship.js +217 -0
  23. package/lib/config.d.ts +12 -0
  24. package/lib/config.js +15 -0
  25. package/lib/templates/install.mjs.tmpl +8 -3
  26. package/lib/types/config-data.d.ts +136 -46
  27. package/lib/utils/app-metadata.d.ts +51 -0
  28. package/lib/utils/app-metadata.js +338 -0
  29. package/lib/utils/check-system-deps.d.ts +2 -2
  30. package/lib/utils/check-system-deps.js +43 -6
  31. package/lib/utils/cli-runtime-closure.d.ts +23 -0
  32. package/lib/utils/cli-runtime-closure.js +274 -0
  33. package/lib/utils/install-provenance.d.ts +44 -0
  34. package/lib/utils/install-provenance.js +106 -0
  35. package/lib/utils/prune-prefix.d.ts +83 -0
  36. package/lib/utils/prune-prefix.js +333 -0
  37. package/lib/utils/publish-headers.d.ts +13 -0
  38. package/lib/utils/publish-headers.js +30 -4
  39. package/lib/utils/ship/ar.d.ts +21 -0
  40. package/lib/utils/ship/ar.js +57 -0
  41. package/lib/utils/ship/bytes.d.ts +4 -0
  42. package/lib/utils/ship/bytes.js +23 -0
  43. package/lib/utils/ship/cpio.d.ts +22 -0
  44. package/lib/utils/ship/cpio.js +93 -0
  45. package/lib/utils/ship/deb.d.ts +24 -0
  46. package/lib/utils/ship/deb.js +118 -0
  47. package/lib/utils/ship/depends.d.ts +62 -0
  48. package/lib/utils/ship/depends.js +221 -0
  49. package/lib/utils/ship/discover.d.ts +21 -0
  50. package/lib/utils/ship/discover.js +129 -0
  51. package/lib/utils/ship/formats.d.ts +5 -0
  52. package/lib/utils/ship/formats.js +83 -0
  53. package/lib/utils/ship/gi-namespaces.d.ts +7 -0
  54. package/lib/utils/ship/gi-namespaces.js +49 -0
  55. package/lib/utils/ship/gzip.d.ts +2 -0
  56. package/lib/utils/ship/gzip.js +21 -0
  57. package/lib/utils/ship/launcher.d.ts +7 -0
  58. package/lib/utils/ship/launcher.js +37 -0
  59. package/lib/utils/ship/payload.d.ts +30 -0
  60. package/lib/utils/ship/payload.js +59 -0
  61. package/lib/utils/ship/plan.d.ts +34 -0
  62. package/lib/utils/ship/plan.js +167 -0
  63. package/lib/utils/ship/rpm-header.d.ts +29 -0
  64. package/lib/utils/ship/rpm-header.js +170 -0
  65. package/lib/utils/ship/rpm.d.ts +12 -0
  66. package/lib/utils/ship/rpm.js +377 -0
  67. package/lib/utils/ship/scripts.d.ts +31 -0
  68. package/lib/utils/ship/scripts.js +76 -0
  69. package/lib/utils/ship/settings.d.ts +60 -0
  70. package/lib/utils/ship/settings.js +184 -0
  71. package/lib/utils/ship/stage-writer.d.ts +17 -0
  72. package/lib/utils/ship/stage-writer.js +49 -0
  73. package/lib/utils/ship/types.d.ts +114 -0
  74. package/lib/utils/ship/types.js +10 -0
  75. package/lib/utils/ship/version.d.ts +17 -0
  76. package/lib/utils/ship/version.js +55 -0
  77. package/package.json +27 -18
  78. /package/lib/templates/{flatpak → app}/desktop.tmpl +0 -0
@@ -0,0 +1,118 @@
1
+ // The `.deb` packer.
2
+ //
3
+ // A Debian package is `debian-binary` + `control.tar.gz` + `data.tar.gz` in an
4
+ // ar container, and every one of those three parts already had a writer in
5
+ // this tree (`utils/ship/ar.ts`, `@gjsify/tar`) or is four bytes long. So no
6
+ // `dpkg-deb`, no vendored Go binary, no download at ship time — which is what
7
+ // lets the packer run under GJS, offline, on a Fedora CI image where dpkg does
8
+ // not exist.
9
+ //
10
+ // Everything below that looks like a detail is a documented dpkg failure mode:
11
+ // the ancestor-directory expansion (dpkg never calls `mkdir -p` and an ENOENT
12
+ // aborts the unpack), the `md5sums` path spelling (no `./`, exactly two
13
+ // spaces), the final newline in `control` (its absence is a parse error), and
14
+ // the gzip header's mtime (not a dpkg failure at all — it silently destroys
15
+ // reproducibility instead).
16
+ import { createHash } from 'node:crypto';
17
+ import { posix } from 'node:path';
18
+ import { createTarball } from '@gjsify/tar';
19
+ import { createArArchive } from './ar.js';
20
+ import { formatDebDepend } from './depends.js';
21
+ import { gzipDeterministic } from './gzip.js';
22
+ import { renderDebScripts } from './scripts.js';
23
+ export async function buildDeb(inputs) {
24
+ const { settings, payload, mtime } = inputs;
25
+ const prefix = inputs.prefix.replace(/^\/+/, '');
26
+ const named = payload.map((entry) => ({ ...entry, tarPath: `./${posix.join(prefix, entry.path)}` }));
27
+ const directories = ancestorDirectories(named.map((entry) => entry.tarPath));
28
+ const dataEntries = [
29
+ ...directories.map((name) => ({ name, mode: 0o755, mtime, directory: true })),
30
+ ...named.map((entry) => ({ name: entry.tarPath, body: entry.data, mode: entry.mode, mtime })),
31
+ ];
32
+ const dataTar = await gzipDeterministic(createTarball(dataEntries));
33
+ // Policy 5.6.20: regular files and symlinks round UP to whole KiB, every
34
+ // other filesystem object counts as one. Getting the unit wrong (bytes
35
+ // instead of KiB) is invisible — dpkg never validates this field, apt just
36
+ // shows the user a wrong number.
37
+ const installedSize = named.reduce((total, entry) => total + Math.ceil(entry.data.byteLength / 1024), 0) + directories.length;
38
+ const control = renderControl(inputs, installedSize);
39
+ const md5sums = named
40
+ .map((entry) => `${createHash('md5').update(entry.data).digest('hex')} ${entry.tarPath.slice(2)}\n`)
41
+ .join('');
42
+ const controlEntries = [
43
+ { name: './', mode: 0o755, mtime, directory: true },
44
+ { name: './control', body: control, mode: 0o644, mtime },
45
+ { name: './md5sums', body: md5sums, mode: 0o644, mtime },
46
+ ];
47
+ const maintainerScripts = renderDebScripts(settings, inputs.prefix);
48
+ for (const [name, body] of Object.entries(maintainerScripts)) {
49
+ // dpkg refuses a maintainer script outside 0555–0775 with the mode in
50
+ // the error, so this is 0755 rather than "whatever the source had".
51
+ controlEntries.push({ name: `./${name}`, body, mode: 0o755, mtime });
52
+ }
53
+ const controlTar = await gzipDeterministic(createTarball(controlEntries));
54
+ return createArArchive([
55
+ { name: 'debian-binary', data: new TextEncoder().encode('2.0\n'), mtime },
56
+ { name: 'control.tar.gz', data: controlTar, mtime },
57
+ { name: 'data.tar.gz', data: dataTar, mtime },
58
+ ]);
59
+ }
60
+ /**
61
+ * Collapse a value that has to occupy exactly one control line.
62
+ *
63
+ * A newline here does not corrupt the file — it FORGES a field: a summary
64
+ * pasted with a hard wrap turns its second line into whatever that line happens
65
+ * to start with, and `Pre-Depends: …` is a real thing to end up with by
66
+ * accident. The extended description already collapses whitespace per
67
+ * paragraph; these fields did not.
68
+ */
69
+ function singleLine(value) {
70
+ return value.replace(/[\r\n]+/g, ' ').trim();
71
+ }
72
+ /**
73
+ * `./`, `./usr/`, `./usr/bin/`, … for every path in the archive.
74
+ *
75
+ * dpkg opens each file with `O_CREAT | O_EXCL` and never creates a parent, so
76
+ * a missing directory entry is `cannot create '<path>'` mid-unpack — with the
77
+ * package half-installed.
78
+ */
79
+ export function ancestorDirectories(paths) {
80
+ const directories = new Set(['./']);
81
+ for (const path of paths) {
82
+ const parts = path.replace(/^\.\//, '').split('/');
83
+ parts.pop();
84
+ let current = '.';
85
+ for (const part of parts) {
86
+ current = `${current}/${part}`;
87
+ directories.add(`${current}/`);
88
+ }
89
+ }
90
+ return [...directories].sort();
91
+ }
92
+ function renderControl(inputs, installedSize) {
93
+ const { settings } = inputs;
94
+ const fields = [
95
+ ['Package', settings.binaryName],
96
+ ['Version', `${settings.version}-${settings.release}`],
97
+ ['Architecture', inputs.archLabel],
98
+ ['Maintainer', settings.maintainer],
99
+ ['Installed-Size', String(installedSize)],
100
+ ];
101
+ if (inputs.depends.length > 0)
102
+ fields.push(['Depends', inputs.depends.map(formatDebDepend).join(', ')]);
103
+ fields.push(['Section', settings.section], ['Priority', 'optional']);
104
+ if (settings.homepage)
105
+ fields.push(['Homepage', settings.homepage]);
106
+ const lines = fields.map(([key, value]) => `${key}: ${singleLine(value)}`);
107
+ // The extended description is continuation lines: one leading space per
108
+ // line, and a lone ` .` for a paragraph break — a truly blank line ends
109
+ // the field and silently truncates everything after it.
110
+ lines.push(`Description: ${singleLine(settings.summary)}`);
111
+ const body = settings.description.filter((paragraph) => paragraph !== settings.summary);
112
+ body.forEach((paragraph, index) => {
113
+ if (index > 0)
114
+ lines.push(' .');
115
+ lines.push(` ${paragraph}`);
116
+ });
117
+ return `${lines.join('\n')}\n`;
118
+ }
@@ -0,0 +1,62 @@
1
+ import type { FormatId } from './types.js';
2
+ /**
3
+ * The GJS the bundles target (root AGENTS.md § Constraints: GJS 1.86 /
4
+ * SpiderMonkey 140, Rolldown `firefox140`). A `--app gjs` bundle can carry
5
+ * ES2024 syntax an older SpiderMonkey does not parse, so this is a real floor
6
+ * and not a formality.
7
+ *
8
+ * It is also, today, UNSATISFIABLE ON DEBIAN — measured, not assumed: Debian
9
+ * went 1.82.3 (trixie) straight to 1.88.1 (forky), skipping 1.84 and 1.86, so
10
+ * no Debian suite ships a `gjs` that satisfies `>= 1.86`. Emitting the honest
11
+ * floor means a `.deb` apt REFUSES on trixie; lowering it silently would mean
12
+ * one apt installs and the app then dies on a syntax error. `warnAboutGjsFloor`
13
+ * exists so the choice is visible at ship time, and `gjsify.ship.minGjsVersion`
14
+ * is how a project whose bundle really does run on older GJS lowers it.
15
+ */
16
+ export declare const DEFAULT_GJS_FLOOR = "1.86";
17
+ /**
18
+ * Warn when the derived floor cannot be satisfied by a released Debian.
19
+ * Returns the warning lines; empty when the floor is fine.
20
+ */
21
+ export declare function warnAboutGjsFloor(format: FormatId, floor: string): string[];
22
+ export interface DependsInputs {
23
+ /** GI namespaces the bundle imports, in `Ns-Version` spelling. */
24
+ namespaces: readonly string[];
25
+ /** `'app'` installs a desktop entry and icons; `'cli'` does not. */
26
+ kind: 'app' | 'cli';
27
+ /** Whether the payload installs GSettings schemas. */
28
+ hasSchemas: boolean;
29
+ /** User-supplied additions for this format. */
30
+ extra: readonly string[];
31
+ /** Project-supplied table rows, filling gaps in {@link TYPELIB_PACKAGES}. */
32
+ typelibPackages?: Record<string, {
33
+ deb: string;
34
+ rpm: string;
35
+ }>;
36
+ /** Minimum GJS. Default {@link DEFAULT_GJS_FLOOR}. */
37
+ minGjsVersion?: string;
38
+ }
39
+ /**
40
+ * The `Depends:` / `Requires:` list for one format.
41
+ *
42
+ * @throws when a namespace has no entry in the table — see the module header.
43
+ */
44
+ export declare function deriveDepends(format: FormatId, inputs: DependsInputs): string[];
45
+ /** One parsed dependency. `relation`/`version` are absent for an unversioned one. */
46
+ export interface ParsedDepend {
47
+ name: string;
48
+ relation?: '>=' | '>' | '<=' | '<' | '=';
49
+ version?: string;
50
+ }
51
+ /** `gjs >= 1.86` → its parts. Both packers need this; only the spelling differs. */
52
+ export declare function parseDepend(depend: string): ParsedDepend;
53
+ /**
54
+ * Format a dependency for `Depends:`.
55
+ *
56
+ * dpkg puts the bound in parentheses and spells strict inequality `<<`/`>>` —
57
+ * bare `<`/`>` are deprecated aliases for `<=`/`>=`, so emitting them means the
58
+ * opposite of what it reads like.
59
+ */
60
+ export declare function formatDebDepend(depend: string): string;
61
+ /** The namespaces the table knows — exported so the unit tests can assert on the set. */
62
+ export declare function knownNamespaces(): string[];
@@ -0,0 +1,221 @@
1
+ // Runtime dependencies, derived from the artifact rather than declared.
2
+ //
3
+ // The namespaces come out of the BUILT BUNDLE, not out of a config key: the
4
+ // bundle is what ships, so it is the only honest source. A declaration can be
5
+ // stale in a way nothing notices until a user's dynamic linker complains.
6
+ //
7
+ // An unmapped namespace FAILS the build (ADR 0024 § 6). The reference
8
+ // implementation returns `[]` for anything its four-entry table does not know,
9
+ // which produces a package that installs cleanly and dies on first launch —
10
+ // the failure lands on the user's machine, after the download, and reads as an
11
+ // application bug. A table will always be incomplete; what may not stay is the
12
+ // silence — so `gjsify.ship.typelibPackages` lets a project add the row itself
13
+ // and the error prints the exact JSON to paste. That is deliberately a
14
+ // different key from `gjsify.ship.depends`, which appends free-form
15
+ // dependencies and does NOT silence this: a hatch that turns the check off is
16
+ // how the check stops meaning anything.
17
+ //
18
+ // Not to be confused with `utils/check-system-deps.ts`'s `PM_PACKAGES`: that
19
+ // maps BUILD-time `-dev` packages (headers, pkg-config files) for a developer
20
+ // machine. What an installed app needs is the runtime typelib, and on Debian
21
+ // those live in separate `gir1.2-*` packages — the two tables answer different
22
+ // questions and would be wrong as one.
23
+ /**
24
+ * The GJS the bundles target (root AGENTS.md § Constraints: GJS 1.86 /
25
+ * SpiderMonkey 140, Rolldown `firefox140`). A `--app gjs` bundle can carry
26
+ * ES2024 syntax an older SpiderMonkey does not parse, so this is a real floor
27
+ * and not a formality.
28
+ *
29
+ * It is also, today, UNSATISFIABLE ON DEBIAN — measured, not assumed: Debian
30
+ * went 1.82.3 (trixie) straight to 1.88.1 (forky), skipping 1.84 and 1.86, so
31
+ * no Debian suite ships a `gjs` that satisfies `>= 1.86`. Emitting the honest
32
+ * floor means a `.deb` apt REFUSES on trixie; lowering it silently would mean
33
+ * one apt installs and the app then dies on a syntax error. `warnAboutGjsFloor`
34
+ * exists so the choice is visible at ship time, and `gjsify.ship.minGjsVersion`
35
+ * is how a project whose bundle really does run on older GJS lowers it.
36
+ */
37
+ export const DEFAULT_GJS_FLOOR = '1.86';
38
+ /** Debian's `gjs` versions per suite, as measured 2026-08-15 — see {@link warnAboutGjsFloor}. */
39
+ const DEBIAN_GJS = 'trixie ships 1.82.3 and forky 1.88.1; 1.84 and 1.86 were skipped';
40
+ /**
41
+ * Warn when the derived floor cannot be satisfied by a released Debian.
42
+ * Returns the warning lines; empty when the floor is fine.
43
+ */
44
+ export function warnAboutGjsFloor(format, floor) {
45
+ if (format !== 'deb')
46
+ return [];
47
+ // Only a floor forky ACTUALLY satisfies is quiet. The first cut tested
48
+ // `>= 1.88.1` and therefore went silent for 1.90 and 2.0 as well — the
49
+ // floors no Debian will satisfy for years.
50
+ if (compareVersions(floor, '1.82.3') <= 0)
51
+ return [];
52
+ if (compareVersions(floor, '1.88.1') === 0)
53
+ return [];
54
+ return [
55
+ `gjsify ship: \`Depends: gjs (>= ${floor})\` is not satisfiable on Debian stable — ${DEBIAN_GJS}. ` +
56
+ 'The package installs on forky/sid and on distributions with a newer GJS. Set ' +
57
+ '`gjsify.ship.minGjsVersion` if this bundle genuinely runs on an older GJS; do NOT lower it ' +
58
+ 'to make apt happy, because the result is an install that succeeds and an app that does not start.',
59
+ ];
60
+ }
61
+ /** Numeric dotted-version compare. Enough for `1.82.3` vs `1.86`. */
62
+ function compareVersions(a, b) {
63
+ const left = a.split('.').map(Number);
64
+ const right = b.split('.').map(Number);
65
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
66
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
67
+ if (diff !== 0)
68
+ return diff;
69
+ }
70
+ return 0;
71
+ }
72
+ /**
73
+ * GI namespace → the package that ships its typelib.
74
+ *
75
+ * Fedora entries were read off this project's own CI image with
76
+ * `rpm -qf /usr/lib64/girepository-1.0/<Ns>.typelib`; Debian ships the typelib
77
+ * in a `gir1.2-*` package separate from the runtime library, which is why the
78
+ * two columns look nothing alike for the same namespace.
79
+ */
80
+ const TYPELIB_PACKAGES = {
81
+ // GLib stack — one package each side covers all four namespaces.
82
+ 'GLib-2.0': { deb: 'gir1.2-glib-2.0', rpm: 'glib2' },
83
+ 'GObject-2.0': { deb: 'gir1.2-glib-2.0', rpm: 'glib2' },
84
+ 'Gio-2.0': { deb: 'gir1.2-glib-2.0', rpm: 'glib2' },
85
+ 'GioUnix-2.0': { deb: 'gir1.2-glib-2.0', rpm: 'glib2' },
86
+ 'GModule-2.0': { deb: 'gir1.2-glib-2.0', rpm: 'glib2' },
87
+ // GTK4 — Gdk/Gsk/Gtk all live in the same shared object.
88
+ 'Gtk-4.0': { deb: 'gir1.2-gtk-4.0', rpm: 'gtk4' },
89
+ 'Gdk-4.0': { deb: 'gir1.2-gtk-4.0', rpm: 'gtk4' },
90
+ 'Gsk-4.0': { deb: 'gir1.2-gtk-4.0', rpm: 'gtk4' },
91
+ 'Adw-1': { deb: 'gir1.2-adw-1', rpm: 'libadwaita' },
92
+ 'GdkPixbuf-2.0': { deb: 'gir1.2-gdkpixbuf-2.0', rpm: 'gdk-pixbuf2' },
93
+ 'Pango-1.0': { deb: 'gir1.2-pango-1.0', rpm: 'pango' },
94
+ 'PangoCairo-1.0': { deb: 'gir1.2-pango-1.0', rpm: 'pango' },
95
+ 'Graphene-1.0': { deb: 'gir1.2-graphene-1.0', rpm: 'graphene' },
96
+ // cairo's typelib is packaged with gobject-introspection on Fedora and in
97
+ // the catch-all gir1.2-freedesktop on Debian.
98
+ 'cairo-1.0': { deb: 'gir1.2-freedesktop', rpm: 'gobject-introspection' },
99
+ 'GtkSource-5': { deb: 'gir1.2-gtksource-5', rpm: 'gtksourceview5' },
100
+ 'WebKit-6.0': { deb: 'gir1.2-webkit-6.0', rpm: 'webkitgtk6.0' },
101
+ 'Soup-3.0': { deb: 'gir1.2-soup-3.0', rpm: 'libsoup3' },
102
+ 'Json-1.0': { deb: 'gir1.2-json-1.0', rpm: 'json-glib' },
103
+ 'Gst-1.0': { deb: 'gir1.2-gstreamer-1.0', rpm: 'gstreamer1' },
104
+ 'Secret-1': { deb: 'gir1.2-secret-1', rpm: 'libsecret' },
105
+ 'Notify-0.7': { deb: 'gir1.2-notify-0.7', rpm: 'libnotify' },
106
+ 'Vte-3.91': { deb: 'gir1.2-vte-3.91', rpm: 'vte291-gtk4' },
107
+ 'Manette-0.2': { deb: 'gir1.2-manette-0.2', rpm: 'libmanette' },
108
+ };
109
+ /**
110
+ * A typelib the payload CARRIES, rather than one the system provides.
111
+ *
112
+ * `@gjsify/*`'s native bridges ship their own `.typelib` inside the package —
113
+ * `GjsifyHttpSoupBridge`, which `@gjsify/http`'s server pulls in unconditionally,
114
+ * plus `GjsifyHttp2`, `GjsifySabNative` and the rest of the `*-native` set. No
115
+ * distribution has ever packaged a `gir1.2-gjsifyhttpsoupbridge` and none can:
116
+ * the file is in the tarball being built. So these are not a gap in the table.
117
+ * Mapping one would emit a dependency apt and dnf must fail to resolve, and the
118
+ * module header's rule — an unmapped namespace FAILS the build — is about a
119
+ * SYSTEM library nobody declared, which is the opposite situation.
120
+ *
121
+ * Excluding them is a precondition for widening the namespace scanner rather
122
+ * than a refinement of it: without this, `gjsify ship` throws on every project
123
+ * whose bundle reaches one, which includes every `@gjsify/http` server.
124
+ */
125
+ const BUNDLED_TYPELIB = /^Gjsify[A-Z]/;
126
+ /**
127
+ * The `Depends:` / `Requires:` list for one format.
128
+ *
129
+ * @throws when a namespace has no entry in the table — see the module header.
130
+ */
131
+ export function deriveDepends(format, inputs) {
132
+ const out = [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
133
+ const unmapped = [];
134
+ for (const namespace of [...new Set(inputs.namespaces)].sort()) {
135
+ if (BUNDLED_TYPELIB.test(namespace))
136
+ continue;
137
+ const entry = lookupTypelib(namespace, inputs.typelibPackages);
138
+ if (entry === undefined) {
139
+ unmapped.push(namespace);
140
+ continue;
141
+ }
142
+ out.push(entry[format]);
143
+ }
144
+ if (unmapped.length > 0) {
145
+ throw new Error(`gjsify ship: the bundle imports ${unmapped.map((n) => `gi://${n.split('-')[0]}`).join(', ')}, ` +
146
+ `and no ${format} package is known to ship ${unmapped.length > 1 ? 'those typelibs' : 'that typelib'}. ` +
147
+ "An undeclared runtime dependency does not fail here — it fails on a user's machine after the " +
148
+ 'download. Fill the gap in package.json:\n' +
149
+ ` "gjsify": { "ship": { "typelibPackages": { ${JSON.stringify(unmapped[0])}: ` +
150
+ '{ "deb": "gir1.2-…", "rpm": "…" } } } }\n' +
151
+ 'and please contribute the mapping back to `utils/ship/depends.ts` so the next project does not ' +
152
+ 'have to. (`gjsify.ship.depends.<format>` is for dependencies that are not typelibs at all — it ' +
153
+ 'appends, and deliberately does not silence this.)');
154
+ }
155
+ if (inputs.kind === 'app')
156
+ out.push('hicolor-icon-theme');
157
+ // The package that ships `glib-compile-schemas`, NOT `gsettings-desktop-schemas`
158
+ // (which ships GNOME's own `org.gnome.desktop.*` schemas and has nothing to
159
+ // do with compiling ours). Measured: `rpm -qf /usr/bin/glib-compile-schemas`
160
+ // → glib2; on Debian the binary lives in `libglib2.0-bin`, which none of the
161
+ // `gir1.2-*` packages pull in. Without it the postinst's `command -v` guard
162
+ // silently skips, the schema is never compiled, and the first
163
+ // `Gio.Settings.new()` aborts the app — an install that succeeds and an app
164
+ // that does not start.
165
+ if (inputs.hasSchemas)
166
+ out.push(format === 'deb' ? 'libglib2.0-bin' : 'glib2');
167
+ out.push(...inputs.extra);
168
+ // Set-dedupe keeps first-insertion order, so `gjs` stays first and the
169
+ // list is stable across runs.
170
+ return [...new Set(out)];
171
+ }
172
+ /**
173
+ * Resolve a scanned specifier to a table row.
174
+ *
175
+ * `gi://Gtk?version=4.0` arrives as `Gtk-4.0` and matches directly. A bare
176
+ * `gi://Gtk` arrives as `Gtk` — GJS then loads whichever version the host has,
177
+ * so the honest answer is the table's single `Gtk-*` row, and an ambiguity
178
+ * (two rows for one namespace) has to be a build failure rather than a guess:
179
+ * picking one would silently declare a dependency on a different library than
180
+ * the one the app will load.
181
+ */
182
+ function lookupTypelib(specifier, overrides = {}) {
183
+ // A project row wins over the built-in one: the table here is a snapshot
184
+ // of what two distributions call things today, and a project that knows
185
+ // better must not have to wait for a gjsify release to say so.
186
+ const table = { ...TYPELIB_PACKAGES, ...overrides };
187
+ const exact = table[specifier];
188
+ if (exact !== undefined)
189
+ return exact;
190
+ if (specifier.includes('-'))
191
+ return undefined;
192
+ const candidates = Object.keys(table).filter((key) => key.startsWith(`${specifier}-`));
193
+ if (candidates.length !== 1)
194
+ return undefined;
195
+ return table[candidates[0]];
196
+ }
197
+ /** `gjs >= 1.86` → its parts. Both packers need this; only the spelling differs. */
198
+ export function parseDepend(depend) {
199
+ const match = /^(\S+)\s*(>=|<=|=|>|<)\s*(\S+)$/.exec(depend);
200
+ if (!match)
201
+ return { name: depend.trim() };
202
+ return { name: match[1], relation: match[2], version: match[3] };
203
+ }
204
+ /**
205
+ * Format a dependency for `Depends:`.
206
+ *
207
+ * dpkg puts the bound in parentheses and spells strict inequality `<<`/`>>` —
208
+ * bare `<`/`>` are deprecated aliases for `<=`/`>=`, so emitting them means the
209
+ * opposite of what it reads like.
210
+ */
211
+ export function formatDebDepend(depend) {
212
+ const parsed = parseDepend(depend);
213
+ if (parsed.relation === undefined)
214
+ return parsed.name;
215
+ const relation = parsed.relation === '>' ? '>>' : parsed.relation === '<' ? '<<' : parsed.relation;
216
+ return `${parsed.name} (${relation} ${parsed.version})`;
217
+ }
218
+ /** The namespaces the table knows — exported so the unit tests can assert on the set. */
219
+ export function knownNamespaces() {
220
+ return Object.keys(TYPELIB_PACKAGES).sort();
221
+ }
@@ -0,0 +1,21 @@
1
+ import type { ConfigDataShip } from '../../types/config-data.js';
2
+ import { type DiscoveredPayload, type ShipPackageManifest } from './settings.js';
3
+ export interface DiscoverInput {
4
+ projectDir: string;
5
+ pkg: ShipPackageManifest;
6
+ ship: ConfigDataShip;
7
+ /** `gjsify.flatpak.icon`, the pre-existing spelling of the same thing. */
8
+ flatpakIcon?: string;
9
+ /** The bundle path the project declares. */
10
+ declaredBundle?: string;
11
+ }
12
+ export declare function discoverPayload(input: DiscoverInput): DiscoveredPayload;
13
+ /**
14
+ * Every regular file under `dir`, as POSIX-separated relative paths.
15
+ *
16
+ * A symlink is refused rather than followed: both package formats can carry
17
+ * one, but a symlink into the build tree would resolve to a path that does not
18
+ * exist on the user's machine, and silently copying its TARGET turns one file
19
+ * into an unexplained duplicate.
20
+ */
21
+ export declare function listFilesRecursive(dir: string): string[];
@@ -0,0 +1,129 @@
1
+ // Finding the payload on disk.
2
+ //
3
+ // The half of `gjsify ship` that must touch the filesystem, kept in one module
4
+ // so `settings.ts` and `plan.ts` can stay pure. Every probe has a configured
5
+ // override; the probes exist so a project that follows the GNOME convention
6
+ // (`data/icons/hicolor/…`, `data/*.gschema.xml`) needs no configuration at all.
7
+ import { existsSync, readdirSync, statSync } from 'node:fs';
8
+ import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
9
+ import { LICENSE_FILE_NAMES } from './settings.js';
10
+ const ICON_EXTENSIONS = ['.svg', '.png'];
11
+ export function discoverPayload(input) {
12
+ const { projectDir, ship } = input;
13
+ const bundlePath = resolveBundle(input);
14
+ const bundleDir = resolve(bundlePath, '..');
15
+ return {
16
+ bundlePath,
17
+ bundleDir,
18
+ bundleFiles: listFilesRecursive(bundleDir),
19
+ iconFiles: discoverIcons(projectDir, ship.icon ?? input.flatpakIcon),
20
+ schemaFiles: discoverSchemas(projectDir, ship.schemas),
21
+ licenseFile: discoverLicense(projectDir, ship.licenseFile),
22
+ };
23
+ }
24
+ function resolveBundle(input) {
25
+ const declared = input.declaredBundle;
26
+ if (declared === undefined) {
27
+ throw new Error('gjsify ship: no bundle to ship. Set `gjsify.main` (or `main`) in package.json to the built ' +
28
+ 'bundle, or `gjsify.ship.bundle` to override it.');
29
+ }
30
+ const bundlePath = isAbsolute(declared) ? declared : resolve(input.projectDir, declared);
31
+ if (!existsSync(bundlePath)) {
32
+ throw new Error(`gjsify ship: the bundle ${declared} does not exist. Build it first (\`gjsify run build\`), ` +
33
+ 'or drop `--skip-build`.');
34
+ }
35
+ return bundlePath;
36
+ }
37
+ /**
38
+ * Icons: an explicit file, an explicit directory, or the GNOME convention.
39
+ *
40
+ * A directory is walked rather than globbed because the size lives in the PATH
41
+ * (`hicolor/128x128/apps/…`) as often as in the filename.
42
+ */
43
+ function discoverIcons(projectDir, configured) {
44
+ if (configured !== undefined) {
45
+ const target = resolve(projectDir, configured);
46
+ if (!existsSync(target)) {
47
+ throw new Error(`gjsify ship: the configured icon path ${configured} does not exist.`);
48
+ }
49
+ return statSync(target).isDirectory() ? listIcons(target) : [target];
50
+ }
51
+ for (const candidate of ['data/icons', 'data/icons/hicolor']) {
52
+ const target = join(projectDir, candidate);
53
+ if (existsSync(target) && statSync(target).isDirectory()) {
54
+ const icons = listIcons(target);
55
+ if (icons.length > 0)
56
+ return icons;
57
+ }
58
+ }
59
+ return [];
60
+ }
61
+ function listIcons(dir) {
62
+ return listFilesRecursive(dir)
63
+ .filter((rel) => ICON_EXTENSIONS.some((ext) => rel.toLowerCase().endsWith(ext)))
64
+ .map((rel) => join(dir, rel.split('/').join(sep)))
65
+ .sort();
66
+ }
67
+ function discoverSchemas(projectDir, configured) {
68
+ const roots = configured !== undefined ? [resolve(projectDir, configured)] : [join(projectDir, 'data')];
69
+ for (const root of roots) {
70
+ if (!existsSync(root)) {
71
+ if (configured !== undefined) {
72
+ throw new Error(`gjsify ship: the configured schema path ${configured} does not exist.`);
73
+ }
74
+ continue;
75
+ }
76
+ if (!statSync(root).isDirectory())
77
+ return [root];
78
+ const schemas = listFilesRecursive(root)
79
+ .filter((rel) => rel.endsWith('.gschema.xml'))
80
+ .map((rel) => join(root, rel.split('/').join(sep)))
81
+ .sort();
82
+ if (schemas.length > 0)
83
+ return schemas;
84
+ }
85
+ return [];
86
+ }
87
+ function discoverLicense(projectDir, configured) {
88
+ if (configured !== undefined) {
89
+ const target = resolve(projectDir, configured);
90
+ if (!existsSync(target)) {
91
+ throw new Error(`gjsify ship: the configured licence file ${configured} does not exist.`);
92
+ }
93
+ return target;
94
+ }
95
+ for (const name of LICENSE_FILE_NAMES) {
96
+ const target = join(projectDir, name);
97
+ if (existsSync(target))
98
+ return target;
99
+ }
100
+ return undefined;
101
+ }
102
+ /**
103
+ * Every regular file under `dir`, as POSIX-separated relative paths.
104
+ *
105
+ * A symlink is refused rather than followed: both package formats can carry
106
+ * one, but a symlink into the build tree would resolve to a path that does not
107
+ * exist on the user's machine, and silently copying its TARGET turns one file
108
+ * into an unexplained duplicate.
109
+ */
110
+ export function listFilesRecursive(dir) {
111
+ const out = [];
112
+ const walk = (current) => {
113
+ for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
114
+ const full = join(current, entry.name);
115
+ if (entry.isDirectory()) {
116
+ walk(full);
117
+ }
118
+ else if (entry.isSymbolicLink()) {
119
+ throw new Error(`gjsify ship: ${full} is a symlink. The payload has to be self-contained — replace it ` +
120
+ 'with the file it points at, or exclude it from the bundle directory.');
121
+ }
122
+ else if (entry.isFile()) {
123
+ out.push(posix.join(...relative(dir, full).split(sep)));
124
+ }
125
+ }
126
+ };
127
+ walk(dir);
128
+ return out.sort();
129
+ }
@@ -0,0 +1,5 @@
1
+ import type { FormatDescriptor, FormatId } from './types.js';
2
+ export declare const FORMATS: Record<FormatId, FormatDescriptor>;
3
+ export declare const FORMAT_IDS: FormatId[];
4
+ /** Parse `--target deb,rpm` into a sorted, deduplicated descriptor list. */
5
+ export declare function resolveFormats(raw: readonly string[]): FormatDescriptor[];
@@ -0,0 +1,83 @@
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.
5
+ // `process.arch` → the format's architecture name. Taken from dpkg's own
6
+ // `data/cputable` and rpm's arch table. `arm` cannot be told apart from
7
+ // `armel` by a running process, and Debian's hard-float port is what anything
8
+ // current actually runs, so `armhf` is the default rather than a guess.
9
+ const DEBIAN_ARCH = {
10
+ x64: 'amd64',
11
+ arm64: 'arm64',
12
+ ia32: 'i386',
13
+ arm: 'armhf',
14
+ riscv64: 'riscv64',
15
+ ppc64: 'ppc64el',
16
+ s390x: 's390x',
17
+ };
18
+ const RPM_ARCH = {
19
+ x64: 'x86_64',
20
+ arm64: 'aarch64',
21
+ ia32: 'i686',
22
+ arm: 'armv7hl',
23
+ riscv64: 'riscv64',
24
+ ppc64: 'ppc64le',
25
+ s390x: 's390x',
26
+ };
27
+ function lookupArch(table, arch, label) {
28
+ const mapped = table[arch];
29
+ if (!mapped) {
30
+ throw new Error(`gjsify ship: no ${label} architecture is known for \`process.arch\` "${arch}". ` +
31
+ `Known: ${Object.keys(table).join(', ')}.`);
32
+ }
33
+ return mapped;
34
+ }
35
+ function debArch(arch, archIndependent) {
36
+ return archIndependent ? 'all' : lookupArch(DEBIAN_ARCH, arch, 'Debian');
37
+ }
38
+ function rpmArch(arch, archIndependent) {
39
+ return archIndependent ? 'noarch' : lookupArch(RPM_ARCH, arch, 'RPM');
40
+ }
41
+ export const FORMATS = {
42
+ deb: {
43
+ id: 'deb',
44
+ prefix: '/usr',
45
+ // Debian policy § 12.5: every package ships its copyright in
46
+ // /usr/share/doc/<package>/copyright, and lintian errors without it.
47
+ licenseDest: (binaryName) => `share/doc/${binaryName}/copyright`,
48
+ licenseKind: 'debian-copyright',
49
+ archName: debArch,
50
+ fileName: (s, archLabel) => `${s.binaryName}_${s.version}-${s.release}_${archLabel}.deb`,
51
+ },
52
+ rpm: {
53
+ id: 'rpm',
54
+ prefix: '/usr',
55
+ licenseDest: (binaryName) => `share/licenses/${binaryName}/LICENSE`,
56
+ licenseKind: 'plain',
57
+ archName: rpmArch,
58
+ fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.rpm`,
59
+ },
60
+ };
61
+ export const FORMAT_IDS = ['deb', 'rpm'];
62
+ /** Parse `--target deb,rpm` into a sorted, deduplicated descriptor list. */
63
+ export function resolveFormats(raw) {
64
+ const names = raw
65
+ .flatMap((entry) => entry.split(','))
66
+ .map((entry) => entry.trim())
67
+ .filter((entry) => entry.length > 0);
68
+ // `Object.hasOwn`, not `in`: `in` walks the prototype chain, so
69
+ // `--target constructor` resolved to `Object` and the refusal below never
70
+ // fired — the run then died somewhere unrelated.
71
+ const unknown = names.filter((name) => !Object.hasOwn(FORMATS, name));
72
+ if (unknown.length > 0) {
73
+ throw new Error(`gjsify ship: unknown target${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}. ` +
74
+ `Known targets: ${FORMAT_IDS.join(', ')}.`);
75
+ }
76
+ const unique = [...new Set(names)].sort();
77
+ // An empty list would otherwise stage the payload and pack nothing, exit 0,
78
+ // and print no artifact line — a success that produced no artifact.
79
+ if (unique.length === 0) {
80
+ throw new Error(`gjsify ship: --target named no format. Known targets: ${FORMAT_IDS.join(', ')}.`);
81
+ }
82
+ return unique.map((name) => FORMATS[name]);
83
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Extract the GI namespaces a bundle imports, as `Ns-Version` when the
3
+ * specifier pins one and bare `Ns` when it does not.
4
+ */
5
+ export declare function scanGiNamespaces(source: string): string[];
6
+ /** `gi://Gtk?version=4.0` → `Gtk-4.0`; `gi://Gtk` → `Gtk`; anything else → null. */
7
+ export declare function parseGiSpecifier(specifier: string): string | null;