@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.
- package/dist/cli.gjs.mjs +202 -181
- package/lib/cli-app.js +12 -25
- package/lib/commands/create.d.ts +3 -0
- package/lib/commands/create.js +49 -6
- 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 +2 -0
- package/lib/commands/index.js +2 -0
- package/lib/commands/install.d.ts +1 -0
- package/lib/commands/install.js +32 -1
- package/lib/commands/prune.d.ts +11 -0
- package/lib/commands/prune.js +84 -0
- package/lib/commands/self-update.d.ts +1 -0
- package/lib/commands/self-update.js +55 -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/templates/install.mjs.tmpl +8 -3
- 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/check-system-deps.d.ts +2 -2
- package/lib/utils/check-system-deps.js +43 -6
- package/lib/utils/cli-runtime-closure.d.ts +23 -0
- package/lib/utils/cli-runtime-closure.js +274 -0
- package/lib/utils/install-provenance.d.ts +44 -0
- package/lib/utils/install-provenance.js +106 -0
- package/lib/utils/prune-prefix.d.ts +83 -0
- package/lib/utils/prune-prefix.js +333 -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 +221 -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 +27 -18
- /package/lib/templates/{flatpak → app}/desktop.tmpl +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FormatDescriptor, ShipSettings, StagedFile } from './types.js';
|
|
2
|
+
/** Everything the planner needs that it cannot derive from the settings alone. */
|
|
3
|
+
export interface StageInputs {
|
|
4
|
+
/** Paths inside `settings.bundleDir`, relative and POSIX-separated. */
|
|
5
|
+
bundleFiles: readonly string[];
|
|
6
|
+
/** The rendered launcher script. */
|
|
7
|
+
launcher: string;
|
|
8
|
+
/** The rendered AppStream MetaInfo XML. */
|
|
9
|
+
metainfo: string;
|
|
10
|
+
/** The rendered desktop entry — required for `kind: 'app'`, unused for `'cli'`. */
|
|
11
|
+
desktopEntry?: string;
|
|
12
|
+
/** Contents of the project's licence file, when one was found. */
|
|
13
|
+
licenseText?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build the prefix-relative payload.
|
|
17
|
+
*
|
|
18
|
+
* The result is deduplicated by path (last write wins, so `extraFiles` can
|
|
19
|
+
* override anything the defaults staged) and sorted, which makes the tree —
|
|
20
|
+
* and therefore every artifact built from it — deterministic.
|
|
21
|
+
*/
|
|
22
|
+
export declare function planStage(settings: ShipSettings, inputs: StageInputs): StagedFile[];
|
|
23
|
+
/**
|
|
24
|
+
* The per-format additions. Only the licence differs today, and it differs in
|
|
25
|
+
* BOTH place and shape: Debian policy wants a machine-readable `copyright` in
|
|
26
|
+
* `share/doc/<pkg>/`, RPM wants the licence text under `share/licenses/<pkg>/`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function planOverlay(settings: ShipSettings, format: FormatDescriptor, inputs: StageInputs): StagedFile[];
|
|
29
|
+
/**
|
|
30
|
+
* The hicolor subdirectory for an icon: `scalable` for SVG, otherwise the
|
|
31
|
+
* pixel size read from a `<n>x<n>` path component or a trailing number in the
|
|
32
|
+
* filename.
|
|
33
|
+
*/
|
|
34
|
+
export declare function iconSizeDir(iconPath: string): string;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Which file lands where — decided without touching the filesystem, so every
|
|
2
|
+
// case (including the ones that must be REFUSED) is unit-testable. Same split
|
|
3
|
+
// as `utils/copy-targets.ts`, and for the same reason: a stager that discovers
|
|
4
|
+
// its own inputs can only be tested by building a real project.
|
|
5
|
+
import { basename, extname, posix } from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Build the prefix-relative payload.
|
|
8
|
+
*
|
|
9
|
+
* The result is deduplicated by path (last write wins, so `extraFiles` can
|
|
10
|
+
* override anything the defaults staged) and sorted, which makes the tree —
|
|
11
|
+
* and therefore every artifact built from it — deterministic.
|
|
12
|
+
*/
|
|
13
|
+
export function planStage(settings, inputs) {
|
|
14
|
+
const files = [];
|
|
15
|
+
const libDir = `lib/${settings.binaryName}`;
|
|
16
|
+
files.push({ path: `bin/${settings.binaryName}`, mode: 0o755, source: { kind: 'text', text: inputs.launcher } });
|
|
17
|
+
for (const rel of inputs.bundleFiles) {
|
|
18
|
+
files.push({
|
|
19
|
+
path: posix.join(libDir, rel),
|
|
20
|
+
mode: isExecutableAsset(rel) ? 0o755 : 0o644,
|
|
21
|
+
source: { kind: 'file', path: posix.join(settings.bundleDir, rel) },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
files.push({
|
|
25
|
+
path: `share/metainfo/${settings.appId}.metainfo.xml`,
|
|
26
|
+
mode: 0o644,
|
|
27
|
+
source: { kind: 'text', text: inputs.metainfo },
|
|
28
|
+
});
|
|
29
|
+
if (settings.kind === 'app') {
|
|
30
|
+
if (inputs.desktopEntry === undefined) {
|
|
31
|
+
throw new Error('gjsify ship: internal error — a desktop entry is required for `kind: "app"`.');
|
|
32
|
+
}
|
|
33
|
+
files.push({
|
|
34
|
+
path: `share/applications/${settings.appId}.desktop`,
|
|
35
|
+
mode: 0o644,
|
|
36
|
+
source: { kind: 'text', text: inputs.desktopEntry },
|
|
37
|
+
});
|
|
38
|
+
for (const icon of planIcons(settings))
|
|
39
|
+
files.push(icon);
|
|
40
|
+
}
|
|
41
|
+
for (const schema of settings.schemaFiles) {
|
|
42
|
+
const name = basename(schema);
|
|
43
|
+
// Every installed schema shares ONE system directory
|
|
44
|
+
// (`/usr/share/glib-2.0/schemas`), so a generic name is a collision
|
|
45
|
+
// with whatever else the distro installed there — and the loser is
|
|
46
|
+
// decided by install order.
|
|
47
|
+
if (!name.startsWith(settings.appId)) {
|
|
48
|
+
throw new Error(`gjsify ship: the GSettings schema ${name} must be named \`${settings.appId}[.…].gschema.xml\`. ` +
|
|
49
|
+
'Every installed schema shares one system directory, so the app id is what keeps it unique.');
|
|
50
|
+
}
|
|
51
|
+
files.push({
|
|
52
|
+
path: `share/glib-2.0/schemas/${name}`,
|
|
53
|
+
mode: 0o644,
|
|
54
|
+
source: { kind: 'file', path: schema },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
for (const [dest, source] of Object.entries(settings.extraFiles)) {
|
|
58
|
+
files.push({ path: assertInsidePrefix(dest), mode: 0o644, source: { kind: 'file', path: source } });
|
|
59
|
+
}
|
|
60
|
+
return normalise(files);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The per-format additions. Only the licence differs today, and it differs in
|
|
64
|
+
* BOTH place and shape: Debian policy wants a machine-readable `copyright` in
|
|
65
|
+
* `share/doc/<pkg>/`, RPM wants the licence text under `share/licenses/<pkg>/`.
|
|
66
|
+
*/
|
|
67
|
+
export function planOverlay(settings, format, inputs) {
|
|
68
|
+
if (inputs.licenseText === undefined)
|
|
69
|
+
return [];
|
|
70
|
+
const text = format.licenseKind === 'debian-copyright'
|
|
71
|
+
? renderDebianCopyright(settings, inputs.licenseText)
|
|
72
|
+
: inputs.licenseText;
|
|
73
|
+
return [{ path: format.licenseDest(settings.binaryName), mode: 0o644, source: { kind: 'text', text } }];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Debian machine-readable copyright, format 1.0. The licence text is indented
|
|
77
|
+
* by one space and its blank lines become ` .` — a bare blank line ends the
|
|
78
|
+
* field, which silently truncates the licence to its first paragraph.
|
|
79
|
+
*/
|
|
80
|
+
function renderDebianCopyright(settings, licenseText) {
|
|
81
|
+
const body = licenseText
|
|
82
|
+
.trimEnd()
|
|
83
|
+
.split('\n')
|
|
84
|
+
.map((line) => (line.trim() === '' ? ' .' : ` ${line}`))
|
|
85
|
+
.join('\n');
|
|
86
|
+
return [
|
|
87
|
+
'Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/',
|
|
88
|
+
`Upstream-Name: ${settings.name}`,
|
|
89
|
+
...(settings.homepage ? [`Source: ${settings.homepage}`] : []),
|
|
90
|
+
'',
|
|
91
|
+
'Files: *',
|
|
92
|
+
`Copyright: ${settings.maintainer}`,
|
|
93
|
+
`License: ${settings.license}`,
|
|
94
|
+
body,
|
|
95
|
+
'',
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Icons land in the hicolor theme, named after the app id — that name is what
|
|
100
|
+
* the desktop entry's `Icon=` and the MetaInfo `<id>` both point at, so a file
|
|
101
|
+
* keeping its source basename installs an icon nothing ever looks up.
|
|
102
|
+
*/
|
|
103
|
+
function planIcons(settings) {
|
|
104
|
+
const out = [];
|
|
105
|
+
const seen = new Map();
|
|
106
|
+
for (const icon of settings.iconFiles) {
|
|
107
|
+
const ext = extname(icon);
|
|
108
|
+
const dir = iconSizeDir(icon);
|
|
109
|
+
const path = `share/icons/hicolor/${dir}/apps/${settings.appId}${ext}`;
|
|
110
|
+
const previous = seen.get(path);
|
|
111
|
+
if (previous !== undefined) {
|
|
112
|
+
throw new Error(`gjsify ship: ${icon} and ${previous} both install as ${path}. ` +
|
|
113
|
+
'Icon size is read from the path (`.../128x128/...`) or the filename (`icon-128.png`); ' +
|
|
114
|
+
'give them distinguishable sizes or point `gjsify.ship.icon` at a single file.');
|
|
115
|
+
}
|
|
116
|
+
seen.set(path, icon);
|
|
117
|
+
out.push({ path, mode: 0o644, source: { kind: 'file', path: icon } });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The hicolor subdirectory for an icon: `scalable` for SVG, otherwise the
|
|
123
|
+
* pixel size read from a `<n>x<n>` path component or a trailing number in the
|
|
124
|
+
* filename.
|
|
125
|
+
*/
|
|
126
|
+
export function iconSizeDir(iconPath) {
|
|
127
|
+
if (extname(iconPath).toLowerCase() === '.svg')
|
|
128
|
+
return 'scalable';
|
|
129
|
+
const square = /(?:^|[\\/])(\d{1,4})x\1(?:[\\/]|$)/.exec(iconPath);
|
|
130
|
+
if (square)
|
|
131
|
+
return `${square[1]}x${square[1]}`;
|
|
132
|
+
const tokens = basename(iconPath, extname(iconPath)).split(/[-_.]/);
|
|
133
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
134
|
+
const token = tokens[i];
|
|
135
|
+
if (token !== undefined && /^\d{1,4}$/.test(token))
|
|
136
|
+
return `${token}x${token}`;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`gjsify ship: cannot tell what size ${iconPath} is. ` +
|
|
139
|
+
'Put it in a `<size>x<size>/` directory, end its name with the size (`icon-128.png`), or ship an SVG.');
|
|
140
|
+
}
|
|
141
|
+
/** A staged path must stay under the prefix — no absolute paths, no `..`. */
|
|
142
|
+
function assertInsidePrefix(dest) {
|
|
143
|
+
const normalised = posix.normalize(dest);
|
|
144
|
+
if (posix.isAbsolute(normalised) || normalised === '..' || normalised.startsWith('../')) {
|
|
145
|
+
throw new Error(`gjsify ship: \`gjsify.ship.extraFiles\` destination "${dest}" escapes the install prefix. ` +
|
|
146
|
+
'Destinations are relative to the prefix, e.g. `share/applications/extra.desktop`.');
|
|
147
|
+
}
|
|
148
|
+
return normalised;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A native module inside the payload has to keep its executable bit.
|
|
152
|
+
*
|
|
153
|
+
* By NAME here, unlike `isArchIndependent`, which reads the file's magic: the
|
|
154
|
+
* planner is pure and has no bytes to look at. The two answer different
|
|
155
|
+
* questions — "should this be 0755" versus "is this package portable" — and
|
|
156
|
+
* getting this one wrong costs a mode bit, not an unusable package.
|
|
157
|
+
*/
|
|
158
|
+
function isExecutableAsset(rel) {
|
|
159
|
+
return /\.(so|node|dylib)(\.\d+)*$/.test(rel);
|
|
160
|
+
}
|
|
161
|
+
/** Last write wins, then sort — deterministic output for a deterministic artifact. */
|
|
162
|
+
function normalise(files) {
|
|
163
|
+
const byPath = new Map();
|
|
164
|
+
for (const file of files)
|
|
165
|
+
byPath.set(file.path, file);
|
|
166
|
+
return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
167
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const RPM_TAG_HEADERSIGNATURES = 62;
|
|
2
|
+
export declare const RPM_TAG_HEADERIMMUTABLE = 63;
|
|
3
|
+
export declare enum RpmType {
|
|
4
|
+
INT16 = 3,
|
|
5
|
+
INT32 = 4,
|
|
6
|
+
STRING = 6,
|
|
7
|
+
BIN = 7,
|
|
8
|
+
STRING_ARRAY = 8,
|
|
9
|
+
I18NSTRING = 9
|
|
10
|
+
}
|
|
11
|
+
export type RpmValue = string | string[] | number | number[] | Uint8Array;
|
|
12
|
+
export interface RpmEntry {
|
|
13
|
+
tag: number;
|
|
14
|
+
type: RpmType;
|
|
15
|
+
value: RpmValue;
|
|
16
|
+
}
|
|
17
|
+
/** Build a complete header blob, magic included. */
|
|
18
|
+
export declare function buildRpmHeader(entries: readonly RpmEntry[], regionTag: number): Uint8Array;
|
|
19
|
+
/**
|
|
20
|
+
* The 96-byte lead.
|
|
21
|
+
*
|
|
22
|
+
* Legacy: modern rpm validates the magic and the major version and takes
|
|
23
|
+
* everything else from the headers, which is why the name field is allowed to
|
|
24
|
+
* be truncated. It is written because the magic is how `file(1)`, and rpm's
|
|
25
|
+
* own first read, recognise the format at all.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildRpmLead(nevr: string): Uint8Array;
|
|
28
|
+
/** Pad a signature header to the 8-byte boundary the main header starts on. */
|
|
29
|
+
export declare function padToEight(bytes: Uint8Array): Uint8Array;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The RPM header structure — an index of typed entries over a shared store.
|
|
2
|
+
//
|
|
3
|
+
// Both headers in an RPM (the signature and the main one) are this same
|
|
4
|
+
// structure, which is why it is its own module: `rpm.ts` decides WHICH tags to
|
|
5
|
+
// write, this file decides how a header is spelled.
|
|
6
|
+
//
|
|
7
|
+
// The one genuinely surprising part is the REGION. `rpm` does not read the
|
|
8
|
+
// index as a flat list: the first index entry is a region trailer pointer with
|
|
9
|
+
// tag 62 (signatures) or 63 (immutable), whose store payload is a second,
|
|
10
|
+
// 16-byte index entry carrying a NEGATIVE offset — the byte distance back to
|
|
11
|
+
// the start of the index. Getting that offset wrong does not corrupt anything
|
|
12
|
+
// visibly; `rpm -qp` simply reports "region trailer" errors or silently sees a
|
|
13
|
+
// header with no tags in it.
|
|
14
|
+
const HEADER_MAGIC = Uint8Array.from([0x8e, 0xad, 0xe8, 0x01, 0x00, 0x00, 0x00, 0x00]);
|
|
15
|
+
const INDEX_ENTRY_SIZE = 16;
|
|
16
|
+
/** The region trailer is one index entry, and rpm checks the count literally. */
|
|
17
|
+
const REGION_COUNT = 16;
|
|
18
|
+
export const RPM_TAG_HEADERSIGNATURES = 62;
|
|
19
|
+
export const RPM_TAG_HEADERIMMUTABLE = 63;
|
|
20
|
+
export var RpmType;
|
|
21
|
+
(function (RpmType) {
|
|
22
|
+
RpmType[RpmType["INT16"] = 3] = "INT16";
|
|
23
|
+
RpmType[RpmType["INT32"] = 4] = "INT32";
|
|
24
|
+
RpmType[RpmType["STRING"] = 6] = "STRING";
|
|
25
|
+
RpmType[RpmType["BIN"] = 7] = "BIN";
|
|
26
|
+
RpmType[RpmType["STRING_ARRAY"] = 8] = "STRING_ARRAY";
|
|
27
|
+
RpmType[RpmType["I18NSTRING"] = 9] = "I18NSTRING";
|
|
28
|
+
})(RpmType || (RpmType = {}));
|
|
29
|
+
/** Store alignment per type — rpm reads INT32s with an aligned load. */
|
|
30
|
+
const ALIGNMENT = {
|
|
31
|
+
[RpmType.INT16]: 2,
|
|
32
|
+
[RpmType.INT32]: 4,
|
|
33
|
+
[RpmType.STRING]: 1,
|
|
34
|
+
[RpmType.BIN]: 1,
|
|
35
|
+
[RpmType.STRING_ARRAY]: 1,
|
|
36
|
+
[RpmType.I18NSTRING]: 1,
|
|
37
|
+
};
|
|
38
|
+
/** Build a complete header blob, magic included. */
|
|
39
|
+
export function buildRpmHeader(entries, regionTag) {
|
|
40
|
+
// rpm expects the index sorted by tag; the store is laid out in the same
|
|
41
|
+
// order, which is not required but makes a hexdump readable next to a real
|
|
42
|
+
// package's.
|
|
43
|
+
const sorted = [...entries].sort((a, b) => a.tag - b.tag);
|
|
44
|
+
const index = [];
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let storeSize = 0;
|
|
47
|
+
for (const entry of sorted) {
|
|
48
|
+
if (entry.tag < 100) {
|
|
49
|
+
throw new Error(`gjsify ship: rpm tag ${entry.tag} is below 100 and would be rejected by hdrchkTag.`);
|
|
50
|
+
}
|
|
51
|
+
const { data, count } = encodeValue(entry.type, entry.value, entry.tag);
|
|
52
|
+
const alignment = ALIGNMENT[entry.type];
|
|
53
|
+
const padding = (alignment - (storeSize % alignment)) % alignment;
|
|
54
|
+
if (padding > 0) {
|
|
55
|
+
chunks.push(new Uint8Array(padding));
|
|
56
|
+
storeSize += padding;
|
|
57
|
+
}
|
|
58
|
+
index.push({ tag: entry.tag, type: entry.type, offset: storeSize, count });
|
|
59
|
+
chunks.push(data);
|
|
60
|
+
storeSize += data.byteLength;
|
|
61
|
+
}
|
|
62
|
+
const trailerOffset = storeSize;
|
|
63
|
+
const indexCount = index.length + 1; // the region entry counts itself
|
|
64
|
+
chunks.push(indexEntry(regionTag, RpmType.BIN, -(INDEX_ENTRY_SIZE * indexCount), REGION_COUNT));
|
|
65
|
+
storeSize += INDEX_ENTRY_SIZE;
|
|
66
|
+
const header = new Uint8Array(HEADER_MAGIC.byteLength + 8 + indexCount * INDEX_ENTRY_SIZE + storeSize);
|
|
67
|
+
const view = new DataView(header.buffer);
|
|
68
|
+
header.set(HEADER_MAGIC, 0);
|
|
69
|
+
view.setUint32(8, indexCount);
|
|
70
|
+
view.setUint32(12, storeSize);
|
|
71
|
+
let offset = 16;
|
|
72
|
+
header.set(indexEntry(regionTag, RpmType.BIN, trailerOffset, REGION_COUNT), offset);
|
|
73
|
+
offset += INDEX_ENTRY_SIZE;
|
|
74
|
+
for (const item of index) {
|
|
75
|
+
header.set(indexEntry(item.tag, item.type, item.offset, item.count), offset);
|
|
76
|
+
offset += INDEX_ENTRY_SIZE;
|
|
77
|
+
}
|
|
78
|
+
for (const chunk of chunks) {
|
|
79
|
+
header.set(chunk, offset);
|
|
80
|
+
offset += chunk.byteLength;
|
|
81
|
+
}
|
|
82
|
+
return header;
|
|
83
|
+
}
|
|
84
|
+
function indexEntry(tag, type, offset, count) {
|
|
85
|
+
const bytes = new Uint8Array(INDEX_ENTRY_SIZE);
|
|
86
|
+
const view = new DataView(bytes.buffer);
|
|
87
|
+
view.setInt32(0, tag);
|
|
88
|
+
view.setInt32(4, type);
|
|
89
|
+
view.setInt32(8, offset);
|
|
90
|
+
view.setInt32(12, count);
|
|
91
|
+
return bytes;
|
|
92
|
+
}
|
|
93
|
+
function encodeValue(type, value, tag) {
|
|
94
|
+
const encoder = new TextEncoder();
|
|
95
|
+
switch (type) {
|
|
96
|
+
case RpmType.STRING: {
|
|
97
|
+
if (typeof value !== 'string')
|
|
98
|
+
throw typeMismatch(tag, 'a string');
|
|
99
|
+
return { data: encoder.encode(`${value}\0`), count: 1 };
|
|
100
|
+
}
|
|
101
|
+
case RpmType.STRING_ARRAY:
|
|
102
|
+
case RpmType.I18NSTRING: {
|
|
103
|
+
const list = typeof value === 'string' ? [value] : value;
|
|
104
|
+
if (!Array.isArray(list))
|
|
105
|
+
throw typeMismatch(tag, 'a string array');
|
|
106
|
+
// rpm rejects a zero count, so an empty array is a bug rather than
|
|
107
|
+
// "no value" — the caller must omit the tag instead.
|
|
108
|
+
if (list.length === 0)
|
|
109
|
+
throw new Error(`gjsify ship: rpm tag ${tag} has an empty array; omit it instead.`);
|
|
110
|
+
return { data: encoder.encode(list.map((item) => `${item}\0`).join('')), count: list.length };
|
|
111
|
+
}
|
|
112
|
+
case RpmType.BIN: {
|
|
113
|
+
if (!(value instanceof Uint8Array))
|
|
114
|
+
throw typeMismatch(tag, 'binary data');
|
|
115
|
+
return { data: value, count: value.byteLength };
|
|
116
|
+
}
|
|
117
|
+
case RpmType.INT16:
|
|
118
|
+
case RpmType.INT32: {
|
|
119
|
+
const list = typeof value === 'number' ? [value] : value;
|
|
120
|
+
if (!Array.isArray(list))
|
|
121
|
+
throw typeMismatch(tag, 'a number array');
|
|
122
|
+
if (list.length === 0)
|
|
123
|
+
throw new Error(`gjsify ship: rpm tag ${tag} has an empty array; omit it instead.`);
|
|
124
|
+
const width = type === RpmType.INT16 ? 2 : 4;
|
|
125
|
+
const data = new Uint8Array(list.length * width);
|
|
126
|
+
const view = new DataView(data.buffer);
|
|
127
|
+
list.forEach((item, i) => {
|
|
128
|
+
if (type === RpmType.INT16)
|
|
129
|
+
view.setUint16(i * width, item & 0xffff);
|
|
130
|
+
else
|
|
131
|
+
view.setUint32(i * width, item >>> 0);
|
|
132
|
+
});
|
|
133
|
+
return { data, count: list.length };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function typeMismatch(tag, expected) {
|
|
138
|
+
return new Error(`gjsify ship: internal error — rpm tag ${tag} expects ${expected}.`);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The 96-byte lead.
|
|
142
|
+
*
|
|
143
|
+
* Legacy: modern rpm validates the magic and the major version and takes
|
|
144
|
+
* everything else from the headers, which is why the name field is allowed to
|
|
145
|
+
* be truncated. It is written because the magic is how `file(1)`, and rpm's
|
|
146
|
+
* own first read, recognise the format at all.
|
|
147
|
+
*/
|
|
148
|
+
export function buildRpmLead(nevr) {
|
|
149
|
+
const lead = new Uint8Array(96);
|
|
150
|
+
const view = new DataView(lead.buffer);
|
|
151
|
+
lead.set([0xed, 0xab, 0xee, 0xdb], 0);
|
|
152
|
+
lead[4] = 3; // major
|
|
153
|
+
lead[5] = 0; // minor
|
|
154
|
+
view.setInt16(6, 0); // type: binary package
|
|
155
|
+
view.setInt16(8, 0); // archnum — informational; the header's ARCH is authoritative
|
|
156
|
+
const name = new TextEncoder().encode(nevr).slice(0, 65);
|
|
157
|
+
lead.set(name, 10);
|
|
158
|
+
view.setInt16(76, 0); // osnum — same story as archnum
|
|
159
|
+
view.setInt16(78, 5); // signature type: header-style signatures
|
|
160
|
+
return lead;
|
|
161
|
+
}
|
|
162
|
+
/** Pad a signature header to the 8-byte boundary the main header starts on. */
|
|
163
|
+
export function padToEight(bytes) {
|
|
164
|
+
const padding = (8 - (bytes.byteLength % 8)) % 8;
|
|
165
|
+
if (padding === 0)
|
|
166
|
+
return bytes;
|
|
167
|
+
const out = new Uint8Array(bytes.byteLength + padding);
|
|
168
|
+
out.set(bytes, 0);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PayloadEntry } from './payload.js';
|
|
2
|
+
import type { ShipSettings } from './types.js';
|
|
3
|
+
export interface RpmInputs {
|
|
4
|
+
settings: ShipSettings;
|
|
5
|
+
payload: readonly PayloadEntry[];
|
|
6
|
+
prefix: string;
|
|
7
|
+
depends: readonly string[];
|
|
8
|
+
/** RPM architecture (`x86_64`, `noarch`, …). */
|
|
9
|
+
archLabel: string;
|
|
10
|
+
mtime: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildRpm(inputs: RpmInputs): Promise<Uint8Array>;
|