@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,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,2 @@
1
+ /** gzip with the header's timestamp zeroed and the OS byte pinned to Unix. */
2
+ export declare function gzipDeterministic(input: Uint8Array): Promise<Uint8Array>;
@@ -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>;