@gjsify/cli 0.39.0 → 0.40.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.
- package/dist/cli.gjs.mjs +197 -180
- package/lib/cli-app.js +11 -25
- package/lib/commands/flatpak/check.js +1 -1
- package/lib/commands/flatpak/ci.js +1 -1
- package/lib/commands/flatpak/diff.js +1 -1
- package/lib/commands/flatpak/init.js +1 -1
- package/lib/commands/flatpak/scaffold.d.ts +2 -4
- package/lib/commands/flatpak/scaffold.js +25 -298
- package/lib/commands/flatpak/sync-flathub.js +1 -1
- package/lib/commands/foreach.js +34 -20
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/ship.d.ts +11 -0
- package/lib/commands/ship.js +217 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.js +15 -0
- package/lib/types/config-data.d.ts +136 -46
- package/lib/utils/app-metadata.d.ts +51 -0
- package/lib/utils/app-metadata.js +338 -0
- package/lib/utils/cli-runtime-closure.d.ts +23 -0
- package/lib/utils/cli-runtime-closure.js +274 -0
- package/lib/utils/publish-headers.d.ts +13 -0
- package/lib/utils/publish-headers.js +30 -4
- package/lib/utils/ship/ar.d.ts +21 -0
- package/lib/utils/ship/ar.js +57 -0
- package/lib/utils/ship/bytes.d.ts +4 -0
- package/lib/utils/ship/bytes.js +23 -0
- package/lib/utils/ship/cpio.d.ts +22 -0
- package/lib/utils/ship/cpio.js +93 -0
- package/lib/utils/ship/deb.d.ts +24 -0
- package/lib/utils/ship/deb.js +118 -0
- package/lib/utils/ship/depends.d.ts +62 -0
- package/lib/utils/ship/depends.js +202 -0
- package/lib/utils/ship/discover.d.ts +21 -0
- package/lib/utils/ship/discover.js +129 -0
- package/lib/utils/ship/formats.d.ts +5 -0
- package/lib/utils/ship/formats.js +83 -0
- package/lib/utils/ship/gi-namespaces.d.ts +7 -0
- package/lib/utils/ship/gi-namespaces.js +49 -0
- package/lib/utils/ship/gzip.d.ts +2 -0
- package/lib/utils/ship/gzip.js +21 -0
- package/lib/utils/ship/launcher.d.ts +7 -0
- package/lib/utils/ship/launcher.js +37 -0
- package/lib/utils/ship/payload.d.ts +30 -0
- package/lib/utils/ship/payload.js +59 -0
- package/lib/utils/ship/plan.d.ts +34 -0
- package/lib/utils/ship/plan.js +167 -0
- package/lib/utils/ship/rpm-header.d.ts +29 -0
- package/lib/utils/ship/rpm-header.js +170 -0
- package/lib/utils/ship/rpm.d.ts +12 -0
- package/lib/utils/ship/rpm.js +377 -0
- package/lib/utils/ship/scripts.d.ts +31 -0
- package/lib/utils/ship/scripts.js +76 -0
- package/lib/utils/ship/settings.d.ts +60 -0
- package/lib/utils/ship/settings.js +184 -0
- package/lib/utils/ship/stage-writer.d.ts +17 -0
- package/lib/utils/ship/stage-writer.js +49 -0
- package/lib/utils/ship/types.d.ts +114 -0
- package/lib/utils/ship/types.js +10 -0
- package/lib/utils/ship/version.d.ts +17 -0
- package/lib/utils/ship/version.js +55 -0
- package/package.json +19 -18
- /package/lib/templates/{flatpak → app}/desktop.tmpl +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
* The `Depends:` / `Requires:` list for one format.
|
|
111
|
+
*
|
|
112
|
+
* @throws when a namespace has no entry in the table — see the module header.
|
|
113
|
+
*/
|
|
114
|
+
export function deriveDepends(format, inputs) {
|
|
115
|
+
const out = [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
|
|
116
|
+
const unmapped = [];
|
|
117
|
+
for (const namespace of [...new Set(inputs.namespaces)].sort()) {
|
|
118
|
+
const entry = lookupTypelib(namespace, inputs.typelibPackages);
|
|
119
|
+
if (entry === undefined) {
|
|
120
|
+
unmapped.push(namespace);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
out.push(entry[format]);
|
|
124
|
+
}
|
|
125
|
+
if (unmapped.length > 0) {
|
|
126
|
+
throw new Error(`gjsify ship: the bundle imports ${unmapped.map((n) => `gi://${n.split('-')[0]}`).join(', ')}, ` +
|
|
127
|
+
`and no ${format} package is known to ship ${unmapped.length > 1 ? 'those typelibs' : 'that typelib'}. ` +
|
|
128
|
+
"An undeclared runtime dependency does not fail here — it fails on a user's machine after the " +
|
|
129
|
+
'download. Fill the gap in package.json:\n' +
|
|
130
|
+
` "gjsify": { "ship": { "typelibPackages": { ${JSON.stringify(unmapped[0])}: ` +
|
|
131
|
+
'{ "deb": "gir1.2-…", "rpm": "…" } } } }\n' +
|
|
132
|
+
'and please contribute the mapping back to `utils/ship/depends.ts` so the next project does not ' +
|
|
133
|
+
'have to. (`gjsify.ship.depends.<format>` is for dependencies that are not typelibs at all — it ' +
|
|
134
|
+
'appends, and deliberately does not silence this.)');
|
|
135
|
+
}
|
|
136
|
+
if (inputs.kind === 'app')
|
|
137
|
+
out.push('hicolor-icon-theme');
|
|
138
|
+
// The package that ships `glib-compile-schemas`, NOT `gsettings-desktop-schemas`
|
|
139
|
+
// (which ships GNOME's own `org.gnome.desktop.*` schemas and has nothing to
|
|
140
|
+
// do with compiling ours). Measured: `rpm -qf /usr/bin/glib-compile-schemas`
|
|
141
|
+
// → glib2; on Debian the binary lives in `libglib2.0-bin`, which none of the
|
|
142
|
+
// `gir1.2-*` packages pull in. Without it the postinst's `command -v` guard
|
|
143
|
+
// silently skips, the schema is never compiled, and the first
|
|
144
|
+
// `Gio.Settings.new()` aborts the app — an install that succeeds and an app
|
|
145
|
+
// that does not start.
|
|
146
|
+
if (inputs.hasSchemas)
|
|
147
|
+
out.push(format === 'deb' ? 'libglib2.0-bin' : 'glib2');
|
|
148
|
+
out.push(...inputs.extra);
|
|
149
|
+
// Set-dedupe keeps first-insertion order, so `gjs` stays first and the
|
|
150
|
+
// list is stable across runs.
|
|
151
|
+
return [...new Set(out)];
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Resolve a scanned specifier to a table row.
|
|
155
|
+
*
|
|
156
|
+
* `gi://Gtk?version=4.0` arrives as `Gtk-4.0` and matches directly. A bare
|
|
157
|
+
* `gi://Gtk` arrives as `Gtk` — GJS then loads whichever version the host has,
|
|
158
|
+
* so the honest answer is the table's single `Gtk-*` row, and an ambiguity
|
|
159
|
+
* (two rows for one namespace) has to be a build failure rather than a guess:
|
|
160
|
+
* picking one would silently declare a dependency on a different library than
|
|
161
|
+
* the one the app will load.
|
|
162
|
+
*/
|
|
163
|
+
function lookupTypelib(specifier, overrides = {}) {
|
|
164
|
+
// A project row wins over the built-in one: the table here is a snapshot
|
|
165
|
+
// of what two distributions call things today, and a project that knows
|
|
166
|
+
// better must not have to wait for a gjsify release to say so.
|
|
167
|
+
const table = { ...TYPELIB_PACKAGES, ...overrides };
|
|
168
|
+
const exact = table[specifier];
|
|
169
|
+
if (exact !== undefined)
|
|
170
|
+
return exact;
|
|
171
|
+
if (specifier.includes('-'))
|
|
172
|
+
return undefined;
|
|
173
|
+
const candidates = Object.keys(table).filter((key) => key.startsWith(`${specifier}-`));
|
|
174
|
+
if (candidates.length !== 1)
|
|
175
|
+
return undefined;
|
|
176
|
+
return table[candidates[0]];
|
|
177
|
+
}
|
|
178
|
+
/** `gjs >= 1.86` → its parts. Both packers need this; only the spelling differs. */
|
|
179
|
+
export function parseDepend(depend) {
|
|
180
|
+
const match = /^(\S+)\s*(>=|<=|=|>|<)\s*(\S+)$/.exec(depend);
|
|
181
|
+
if (!match)
|
|
182
|
+
return { name: depend.trim() };
|
|
183
|
+
return { name: match[1], relation: match[2], version: match[3] };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Format a dependency for `Depends:`.
|
|
187
|
+
*
|
|
188
|
+
* dpkg puts the bound in parentheses and spells strict inequality `<<`/`>>` —
|
|
189
|
+
* bare `<`/`>` are deprecated aliases for `<=`/`>=`, so emitting them means the
|
|
190
|
+
* opposite of what it reads like.
|
|
191
|
+
*/
|
|
192
|
+
export function formatDebDepend(depend) {
|
|
193
|
+
const parsed = parseDepend(depend);
|
|
194
|
+
if (parsed.relation === undefined)
|
|
195
|
+
return parsed.name;
|
|
196
|
+
const relation = parsed.relation === '>' ? '>>' : parsed.relation === '<' ? '<<' : parsed.relation;
|
|
197
|
+
return `${parsed.name} (${relation} ${parsed.version})`;
|
|
198
|
+
}
|
|
199
|
+
/** The namespaces the table knows — exported so the unit tests can assert on the set. */
|
|
200
|
+
export function knownNamespaces() {
|
|
201
|
+
return Object.keys(TYPELIB_PACKAGES).sort();
|
|
202
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Which GI namespaces does this bundle actually load?
|
|
2
|
+
//
|
|
3
|
+
// Read off the emitted `--app gjs` bundle, because that is the file that gets
|
|
4
|
+
// installed. `gi://` is a real module protocol under GJS, so the bundler keeps
|
|
5
|
+
// those specifiers in the output verbatim (`rolldown-plugin-gjsify`'s externals
|
|
6
|
+
// plugin) — the artifact carries its own dependency list and nothing has to be
|
|
7
|
+
// declared twice.
|
|
8
|
+
//
|
|
9
|
+
// PARSED, not pattern-matched, and the first version of this file is why. A
|
|
10
|
+
// regex over the bundle text got it wrong in both directions at once:
|
|
11
|
+
//
|
|
12
|
+
// * it missed `import "gi://Soup?version=3.0"` — the bare side-effect form,
|
|
13
|
+
// which is exactly what `@gjsify/fetch` puts at the top of every bundle
|
|
14
|
+
// that pulls it. The package would have shipped without libsoup, installed
|
|
15
|
+
// cleanly, and died at the first request.
|
|
16
|
+
// * it matched `gi://…` inside a diagnostic STRING containing the word
|
|
17
|
+
// `from`, and since an unmapped namespace fails the build, that made a
|
|
18
|
+
// correct project unbuildable.
|
|
19
|
+
//
|
|
20
|
+
// `moduleSpecifiers` answers the question exactly, and it is the same acorn
|
|
21
|
+
// pass the CLI already uses to compute its own runtime closure — so there is
|
|
22
|
+
// one definition of "what does this file import" rather than two.
|
|
23
|
+
import { moduleSpecifiers } from '../cli-runtime-closure.js';
|
|
24
|
+
/**
|
|
25
|
+
* Extract the GI namespaces a bundle imports, as `Ns-Version` when the
|
|
26
|
+
* specifier pins one and bare `Ns` when it does not.
|
|
27
|
+
*/
|
|
28
|
+
export function scanGiNamespaces(source) {
|
|
29
|
+
const found = new Set();
|
|
30
|
+
for (const specifier of moduleSpecifiers(source)) {
|
|
31
|
+
const key = parseGiSpecifier(specifier);
|
|
32
|
+
if (key !== null)
|
|
33
|
+
found.add(key);
|
|
34
|
+
}
|
|
35
|
+
return [...found].sort();
|
|
36
|
+
}
|
|
37
|
+
/** `gi://Gtk?version=4.0` → `Gtk-4.0`; `gi://Gtk` → `Gtk`; anything else → null. */
|
|
38
|
+
export function parseGiSpecifier(specifier) {
|
|
39
|
+
if (!specifier.startsWith('gi://'))
|
|
40
|
+
return null;
|
|
41
|
+
const rest = specifier.slice('gi://'.length);
|
|
42
|
+
const [namespace, query] = rest.split('?', 2);
|
|
43
|
+
if (namespace === undefined || !/^[A-Za-z][A-Za-z0-9_]*$/.test(namespace))
|
|
44
|
+
return null;
|
|
45
|
+
if (query === undefined)
|
|
46
|
+
return namespace;
|
|
47
|
+
const version = /(?:^|&)version=([^&]+)/.exec(query)?.[1];
|
|
48
|
+
return version === undefined ? namespace : `${namespace}-${version}`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Deterministic gzip.
|
|
2
|
+
//
|
|
3
|
+
// The gzip header carries its own modification time in bytes 4-7 and the
|
|
4
|
+
// compressing OS in byte 9. Leaving them alone makes two runs over identical
|
|
5
|
+
// input produce different bytes — which no test notices, because every test
|
|
6
|
+
// compares the DECOMPRESSED content. It is the single most-missed
|
|
7
|
+
// reproducibility bug in package writers, and it costs nothing to close.
|
|
8
|
+
import { gzip } from '@gjsify/tar';
|
|
9
|
+
/** gzip with the header's timestamp zeroed and the OS byte pinned to Unix. */
|
|
10
|
+
export async function gzipDeterministic(input) {
|
|
11
|
+
const out = await gzip(input);
|
|
12
|
+
if (out.byteLength < 10 || out[0] !== 0x1f || out[1] !== 0x8b) {
|
|
13
|
+
throw new Error('gjsify ship: internal error — gzip did not produce a gzip stream.');
|
|
14
|
+
}
|
|
15
|
+
out[4] = 0;
|
|
16
|
+
out[5] = 0;
|
|
17
|
+
out[6] = 0;
|
|
18
|
+
out[7] = 0;
|
|
19
|
+
out[9] = 3;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ShipSettings } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Render the launcher for a GJS application.
|
|
4
|
+
*
|
|
5
|
+
* `bundleRelPath` is the bundle's path inside `lib/<binaryName>/`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function renderLauncher(settings: ShipSettings, bundleRelPath: string): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The `bin/<name>` entry a package installs.
|
|
2
|
+
//
|
|
3
|
+
// It derives its own prefix at runtime instead of having one baked in. That is
|
|
4
|
+
// what lets ONE staged payload become a `.deb` under `/usr`, an `.rpm` under
|
|
5
|
+
// `/usr` and a Flatpak under `/app` (ADR 0024 § 3) — a baked path would force a
|
|
6
|
+
// payload per format and collapse the whole design back into N packagers.
|
|
7
|
+
//
|
|
8
|
+
// `XDG_DATA_DIRS` is prepended for the same reason. Under `/usr` it is already
|
|
9
|
+
// on the list and the line is a no-op; installed anywhere else (`/opt`, a
|
|
10
|
+
// `--prefix=$HOME/.local` tree, a relocated Flatpak) it is what makes the
|
|
11
|
+
// staged icons, the desktop entry and the compiled GSettings schemas findable.
|
|
12
|
+
// Getting that from the launcher rather than from the app keeps the app free of
|
|
13
|
+
// install-layout knowledge.
|
|
14
|
+
/** POSIX-shell single-quote: the only escape inside `'…'` is `'\''`. */
|
|
15
|
+
function shellQuote(value) {
|
|
16
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Render the launcher for a GJS application.
|
|
20
|
+
*
|
|
21
|
+
* `bundleRelPath` is the bundle's path inside `lib/<binaryName>/`.
|
|
22
|
+
*/
|
|
23
|
+
export function renderLauncher(settings, bundleRelPath) {
|
|
24
|
+
const lib = `"$prefix"/lib/${settings.binaryName}`;
|
|
25
|
+
const args = settings.execArgs.map(shellQuote).join(' ');
|
|
26
|
+
return [
|
|
27
|
+
'#!/bin/sh',
|
|
28
|
+
'# Generated by `gjsify ship` — do not edit.',
|
|
29
|
+
'set -e',
|
|
30
|
+
'self=$(readlink -f "$0")',
|
|
31
|
+
'prefix=$(dirname "$(dirname "$self")")',
|
|
32
|
+
'XDG_DATA_DIRS="$prefix/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"',
|
|
33
|
+
'export XDG_DATA_DIRS',
|
|
34
|
+
`exec gjs -m ${lib}/${bundleRelPath}${args ? ` ${args}` : ''} "$@"`,
|
|
35
|
+
'',
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** One file in the payload, with its bytes. */
|
|
2
|
+
export interface PayloadEntry {
|
|
3
|
+
/** Prefix-relative path, POSIX-separated, e.g. `bin/learn6502`. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** POSIX mode bits. */
|
|
6
|
+
mode: number;
|
|
7
|
+
data: Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The build stamp every header gets.
|
|
11
|
+
*
|
|
12
|
+
* `SOURCE_DATE_EPOCH` is the cross-ecosystem convention and wins when it is
|
|
13
|
+
* set. Without it the stamp is the BUNDLE's mtime — never `Date.now()`, which
|
|
14
|
+
* is the one input guaranteeing that packing the same tree twice produces
|
|
15
|
+
* different bytes, and never a fixed 0 either: `Build Date: 1 Jan 1970` is
|
|
16
|
+
* what `rpm -qi` then shows a user, and an artifact that looks broken is a
|
|
17
|
+
* support question. The mtime keeps the property that matters (pack the same
|
|
18
|
+
* build twice, get the same bytes) while saying something true.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildTimestamp(bundlePath: string, env?: Record<string, string | undefined>): number;
|
|
21
|
+
/**
|
|
22
|
+
* Does the payload contain anything architecture-specific?
|
|
23
|
+
*
|
|
24
|
+
* Decided from the file's MAGIC, not from its name. A bundled runtime is just
|
|
25
|
+
* called `node`, a stripped helper may have no extension at all, and an
|
|
26
|
+
* extension list that misses one of them produces `Architecture: all` on an
|
|
27
|
+
* x86-64 payload — which apt and dnf will happily install on arm64, where it
|
|
28
|
+
* does not run.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isArchIndependent(payload: readonly PayloadEntry[]): boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The payload the packers see.
|
|
2
|
+
//
|
|
3
|
+
// One shape, read back off the staged tree rather than carried in memory from
|
|
4
|
+
// the planner. That is deliberate: it makes `gjsify ship --stage` and
|
|
5
|
+
// `gjsify ship --target deb` provably the same payload, because the second
|
|
6
|
+
// reads what the first wrote. A packer fed straight from the planner could
|
|
7
|
+
// drift from the staged tree and nothing would notice.
|
|
8
|
+
import { statSync } from 'node:fs';
|
|
9
|
+
/**
|
|
10
|
+
* The build stamp every header gets.
|
|
11
|
+
*
|
|
12
|
+
* `SOURCE_DATE_EPOCH` is the cross-ecosystem convention and wins when it is
|
|
13
|
+
* set. Without it the stamp is the BUNDLE's mtime — never `Date.now()`, which
|
|
14
|
+
* is the one input guaranteeing that packing the same tree twice produces
|
|
15
|
+
* different bytes, and never a fixed 0 either: `Build Date: 1 Jan 1970` is
|
|
16
|
+
* what `rpm -qi` then shows a user, and an artifact that looks broken is a
|
|
17
|
+
* support question. The mtime keeps the property that matters (pack the same
|
|
18
|
+
* build twice, get the same bytes) while saying something true.
|
|
19
|
+
*/
|
|
20
|
+
export function buildTimestamp(bundlePath, env = process.env) {
|
|
21
|
+
const raw = env.SOURCE_DATE_EPOCH;
|
|
22
|
+
if (raw !== undefined) {
|
|
23
|
+
const parsed = Number(raw);
|
|
24
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
25
|
+
throw new Error(`gjsify ship: SOURCE_DATE_EPOCH must be a non-negative integer, got "${raw}".`);
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
return Math.floor(statSync(bundlePath).mtimeMs / 1000);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Does the payload contain anything architecture-specific?
|
|
33
|
+
*
|
|
34
|
+
* Decided from the file's MAGIC, not from its name. A bundled runtime is just
|
|
35
|
+
* called `node`, a stripped helper may have no extension at all, and an
|
|
36
|
+
* extension list that misses one of them produces `Architecture: all` on an
|
|
37
|
+
* x86-64 payload — which apt and dnf will happily install on arm64, where it
|
|
38
|
+
* does not run.
|
|
39
|
+
*/
|
|
40
|
+
export function isArchIndependent(payload) {
|
|
41
|
+
return !payload.some((entry) => isNativeBinary(entry.data));
|
|
42
|
+
}
|
|
43
|
+
/** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
|
|
44
|
+
function isNativeBinary(data) {
|
|
45
|
+
if (data.byteLength < 4)
|
|
46
|
+
return false;
|
|
47
|
+
const magic = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3];
|
|
48
|
+
switch (magic >>> 0) {
|
|
49
|
+
case 0x7f454c46: // \x7fELF
|
|
50
|
+
case 0xfeedface: // Mach-O 32
|
|
51
|
+
case 0xfeedfacf: // Mach-O 64
|
|
52
|
+
case 0xcefaedfe: // Mach-O 32, byte-swapped
|
|
53
|
+
case 0xcffaedfe: // Mach-O 64, byte-swapped
|
|
54
|
+
case 0xcafebabe: // Mach-O universal binary
|
|
55
|
+
return true;
|
|
56
|
+
default:
|
|
57
|
+
return data[0] === 0x4d && data[1] === 0x5a; // MZ — PE/COFF
|
|
58
|
+
}
|
|
59
|
+
}
|