@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
|
@@ -98,19 +98,45 @@ export function cliVersion() {
|
|
|
98
98
|
}
|
|
99
99
|
/** Nearest ancestor `package.json` whose `name` is `@gjsify/cli`; '' when none. */
|
|
100
100
|
function walkForCliVersion() {
|
|
101
|
+
const dir = cliPackageDir();
|
|
102
|
+
if (!dir)
|
|
103
|
+
return '';
|
|
104
|
+
try {
|
|
105
|
+
const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'));
|
|
106
|
+
if (typeof pkg.version === 'string')
|
|
107
|
+
return pkg.version;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* cliPackageDir() already parsed it once; a race or a permission flip lands here */
|
|
111
|
+
}
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Directory of the RUNNING `@gjsify/cli` package — the nearest ancestor of this
|
|
116
|
+
* module holding a `package.json` named `@gjsify/cli`; `null` when none.
|
|
117
|
+
*
|
|
118
|
+
* The climb, not a fixed `'..'` hop, for the reason {@link cliVersion} states: the
|
|
119
|
+
* package is dual-entry and the two entries sit at different depths.
|
|
120
|
+
* `GJSIFY_CLI_PACKAGE_JSON` overrides the starting point, same as for the version.
|
|
121
|
+
*
|
|
122
|
+
* Exported because the answer to "where am I installed" is the same question as
|
|
123
|
+
* "which version am I", and this file is where that climb lives — a second copy
|
|
124
|
+
* elsewhere is the drift `check-cli-own-version-read.mjs` exists to prevent.
|
|
125
|
+
*/
|
|
126
|
+
export function cliPackageDir() {
|
|
101
127
|
const override = globalThis.process?.env?.['GJSIFY_CLI_PACKAGE_JSON'];
|
|
102
128
|
let dir;
|
|
103
129
|
try {
|
|
104
130
|
dir = override ? dirname(override) : dirname(fileURLToPath(import.meta.url));
|
|
105
131
|
}
|
|
106
132
|
catch {
|
|
107
|
-
return
|
|
133
|
+
return null;
|
|
108
134
|
}
|
|
109
135
|
for (let i = 0; i < 12; i++) {
|
|
110
136
|
try {
|
|
111
137
|
const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'));
|
|
112
|
-
if (pkg.name === '@gjsify/cli'
|
|
113
|
-
return
|
|
138
|
+
if (pkg.name === '@gjsify/cli')
|
|
139
|
+
return dir;
|
|
114
140
|
}
|
|
115
141
|
catch {
|
|
116
142
|
/* no manifest here (or unreadable) — keep climbing */
|
|
@@ -120,7 +146,7 @@ function walkForCliVersion() {
|
|
|
120
146
|
break;
|
|
121
147
|
dir = parent;
|
|
122
148
|
}
|
|
123
|
-
return
|
|
149
|
+
return null;
|
|
124
150
|
}
|
|
125
151
|
/**
|
|
126
152
|
* npm-CLI-shaped user-agent, mirroring npm-registry-fetch's
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ArMember {
|
|
2
|
+
/** Member name, at most 16 bytes. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Member contents. */
|
|
5
|
+
data: Uint8Array;
|
|
6
|
+
/** Unix mtime in seconds. Default 0 — deterministic archives. */
|
|
7
|
+
mtime?: number;
|
|
8
|
+
/** POSIX mode. Default 0o100644. */
|
|
9
|
+
mode?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Build an ar archive.
|
|
13
|
+
*
|
|
14
|
+
* Names are written space-padded with NO trailing `/`: that is what
|
|
15
|
+
* `dpkg-deb -b` emits and what dpkg's own reader expects for a `.deb`. The
|
|
16
|
+
* GNU `<name>/` convention and the BSD `#1/<len>` long-name extension are both
|
|
17
|
+
* for archives with names longer than 15 bytes — a `.deb`'s three members are
|
|
18
|
+
* `debian-binary`, `control.tar.<ext>` and `data.tar.<ext>`, all short, so
|
|
19
|
+
* neither extension can arise and refusing a long name is honest.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createArArchive(members: readonly ArMember[]): Uint8Array;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The `ar` container a `.deb` is.
|
|
2
|
+
//
|
|
3
|
+
// Two hundred lines of dependency avoided: a Debian package is an ar archive of
|
|
4
|
+
// exactly three members, and the format is a text header per member with no
|
|
5
|
+
// index, no compression and no symbol table. Writing it here keeps `gjsify
|
|
6
|
+
// ship` free of `dpkg-deb` — which matters beyond convenience, because this
|
|
7
|
+
// project's CI runs on Fedora and `dpkg-deb` is not installable there without
|
|
8
|
+
// dragging in a foreign package manager. The same reasoning produced
|
|
9
|
+
// `gjsify flatpak sources` (a Node-free reader replacing
|
|
10
|
+
// `flatpak-node-generator`), and it has the same payoff: the packer runs
|
|
11
|
+
// wherever the CLI runs, including under GJS.
|
|
12
|
+
import { concatBytes, encodeUtf8 } from './bytes.js';
|
|
13
|
+
const GLOBAL_HEADER = '!<arch>\n';
|
|
14
|
+
const HEADER_SIZE = 60;
|
|
15
|
+
/**
|
|
16
|
+
* Build an ar archive.
|
|
17
|
+
*
|
|
18
|
+
* Names are written space-padded with NO trailing `/`: that is what
|
|
19
|
+
* `dpkg-deb -b` emits and what dpkg's own reader expects for a `.deb`. The
|
|
20
|
+
* GNU `<name>/` convention and the BSD `#1/<len>` long-name extension are both
|
|
21
|
+
* for archives with names longer than 15 bytes — a `.deb`'s three members are
|
|
22
|
+
* `debian-binary`, `control.tar.<ext>` and `data.tar.<ext>`, all short, so
|
|
23
|
+
* neither extension can arise and refusing a long name is honest.
|
|
24
|
+
*/
|
|
25
|
+
export function createArArchive(members) {
|
|
26
|
+
const chunks = [encodeUtf8(GLOBAL_HEADER)];
|
|
27
|
+
for (const member of members) {
|
|
28
|
+
chunks.push(buildMemberHeader(member));
|
|
29
|
+
chunks.push(member.data);
|
|
30
|
+
// Members are aligned to an even offset; the pad byte is `\n`.
|
|
31
|
+
if (member.data.byteLength % 2 === 1)
|
|
32
|
+
chunks.push(encodeUtf8('\n'));
|
|
33
|
+
}
|
|
34
|
+
return concatBytes(chunks);
|
|
35
|
+
}
|
|
36
|
+
function buildMemberHeader(member) {
|
|
37
|
+
// `pad()` below is what refuses an over-long name, with the width in the
|
|
38
|
+
// message — a second length check here would differ only for a non-ASCII
|
|
39
|
+
// name, and all three member names are literals.
|
|
40
|
+
const header = pad(member.name, 16) +
|
|
41
|
+
pad(String(member.mtime ?? 0), 12) +
|
|
42
|
+
pad('0', 6) + // uid
|
|
43
|
+
pad('0', 6) + // gid
|
|
44
|
+
pad((member.mode ?? 0o100644).toString(8), 8) +
|
|
45
|
+
pad(String(member.data.byteLength), 10) +
|
|
46
|
+
'`\n';
|
|
47
|
+
if (header.length !== HEADER_SIZE) {
|
|
48
|
+
throw new Error(`gjsify ship: internal error — ar header is ${header.length} bytes, expected ${HEADER_SIZE}.`);
|
|
49
|
+
}
|
|
50
|
+
return encodeUtf8(header);
|
|
51
|
+
}
|
|
52
|
+
function pad(value, width) {
|
|
53
|
+
if (value.length > width) {
|
|
54
|
+
throw new Error(`gjsify ship: ar header field "${value}" does not fit in ${width} bytes.`);
|
|
55
|
+
}
|
|
56
|
+
return value.padEnd(width, ' ');
|
|
57
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Join byte chunks into one buffer. */
|
|
2
|
+
export declare function concatBytes(chunks: readonly Uint8Array[]): Uint8Array;
|
|
3
|
+
/** UTF-8 encode. The archive formats are byte formats; every length is a BYTE length. */
|
|
4
|
+
export declare function encodeUtf8(value: string): Uint8Array;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The byte-level helpers all three writers need.
|
|
2
|
+
//
|
|
3
|
+
// `concatBytes` existed once per writer before this file did — the same nine
|
|
4
|
+
// lines in `ar.ts`, `cpio.ts` and `rpm.ts`. The second copy is where a helper
|
|
5
|
+
// belongs (root AGENTS.md § Code anti-patterns): a drifted copy of THIS
|
|
6
|
+
// function would not throw, it would silently produce a truncated archive.
|
|
7
|
+
/** Join byte chunks into one buffer. */
|
|
8
|
+
export function concatBytes(chunks) {
|
|
9
|
+
let total = 0;
|
|
10
|
+
for (const chunk of chunks)
|
|
11
|
+
total += chunk.byteLength;
|
|
12
|
+
const out = new Uint8Array(total);
|
|
13
|
+
let offset = 0;
|
|
14
|
+
for (const chunk of chunks) {
|
|
15
|
+
out.set(chunk, offset);
|
|
16
|
+
offset += chunk.byteLength;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** UTF-8 encode. The archive formats are byte formats; every length is a BYTE length. */
|
|
21
|
+
export function encodeUtf8(value) {
|
|
22
|
+
return new TextEncoder().encode(value);
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface CpioEntry {
|
|
2
|
+
/** Archive path. RPM spells these `./usr/bin/foo`. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Full st_mode, i.e. file type bits OR permission bits. */
|
|
5
|
+
mode: number;
|
|
6
|
+
/** File contents; empty for directories and symlinks. */
|
|
7
|
+
data?: Uint8Array;
|
|
8
|
+
/** Symlink target — stored as the entry's data, per the cpio format. */
|
|
9
|
+
linkTarget?: string;
|
|
10
|
+
/** Unix mtime in seconds. Default 0. */
|
|
11
|
+
mtime?: number;
|
|
12
|
+
/** Inode number. RPM numbers entries from 1. */
|
|
13
|
+
ino?: number;
|
|
14
|
+
/** Hardlink count. Default 1. */
|
|
15
|
+
nlink?: number;
|
|
16
|
+
}
|
|
17
|
+
/** POSIX `st_mode` file-type bits, spelled out so call sites read as intent. */
|
|
18
|
+
export declare const S_IFREG = 32768;
|
|
19
|
+
export declare const S_IFDIR = 16384;
|
|
20
|
+
export declare const S_IFLNK = 40960;
|
|
21
|
+
/** Build a cpio archive, terminated by the mandatory `TRAILER!!!` entry. */
|
|
22
|
+
export declare function createCpioArchive(entries: readonly CpioEntry[]): Uint8Array;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The "new ASCII" cpio archive (magic `070701`) an RPM carries as its payload.
|
|
2
|
+
//
|
|
3
|
+
// Every field is 8 hex digits, which makes the format trivially writable and —
|
|
4
|
+
// more usefully — trivially INSPECTABLE: `rpm2cpio x.rpm | cpio -it` reads what
|
|
5
|
+
// this writes, so the e2e suite has an oracle that shares no code with the
|
|
6
|
+
// writer. (rpm itself formats them with `%8.8lx`, i.e. LOWERCASE; both parse,
|
|
7
|
+
// and this writer emits uppercase.)
|
|
8
|
+
//
|
|
9
|
+
// Two padding rules, both easy to get subtly wrong and both silent when wrong:
|
|
10
|
+
// the filename (including its NUL) is padded so that the DATA starts on a
|
|
11
|
+
// 4-byte boundary counted from the start of the header, and the data itself is
|
|
12
|
+
// padded so the NEXT header does too.
|
|
13
|
+
import { concatBytes, encodeUtf8 } from './bytes.js';
|
|
14
|
+
const MAGIC = '070701';
|
|
15
|
+
const HEADER_FIELDS = 13;
|
|
16
|
+
const HEADER_SIZE = MAGIC.length + HEADER_FIELDS * 8; // 110
|
|
17
|
+
/** POSIX `st_mode` file-type bits, spelled out so call sites read as intent. */
|
|
18
|
+
export const S_IFREG = 0o100000;
|
|
19
|
+
export const S_IFDIR = 0o040000;
|
|
20
|
+
export const S_IFLNK = 0o120000;
|
|
21
|
+
/** Build a cpio archive, terminated by the mandatory `TRAILER!!!` entry. */
|
|
22
|
+
export function createCpioArchive(entries) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let offset = 0;
|
|
25
|
+
entries.forEach((entry, index) => {
|
|
26
|
+
const body = entry.linkTarget !== undefined ? encodeUtf8(entry.linkTarget) : (entry.data ?? new Uint8Array(0));
|
|
27
|
+
const nameBytes = encodeUtf8(`${entry.name}\0`);
|
|
28
|
+
const header = buildHeader({
|
|
29
|
+
ino: entry.ino ?? index + 1,
|
|
30
|
+
mode: entry.mode,
|
|
31
|
+
nlink: entry.nlink ?? 1,
|
|
32
|
+
mtime: entry.mtime ?? 0,
|
|
33
|
+
filesize: body.byteLength,
|
|
34
|
+
namesize: nameBytes.byteLength,
|
|
35
|
+
});
|
|
36
|
+
offset = push(chunks, header, offset);
|
|
37
|
+
offset = push(chunks, nameBytes, offset);
|
|
38
|
+
offset = padTo4(chunks, offset);
|
|
39
|
+
offset = push(chunks, body, offset);
|
|
40
|
+
offset = padTo4(chunks, offset);
|
|
41
|
+
});
|
|
42
|
+
// The archive ends at the entry NAMED `TRAILER!!!` — that name is the
|
|
43
|
+
// signal, not any of the numeric fields. rpm writes `nlink = 1` there and
|
|
44
|
+
// zeroes the rest, so this does too.
|
|
45
|
+
const trailerName = encodeUtf8('TRAILER!!!\0');
|
|
46
|
+
offset = push(chunks, buildHeader({ ino: 0, mode: 0, nlink: 1, mtime: 0, filesize: 0, namesize: trailerName.byteLength }), offset);
|
|
47
|
+
offset = push(chunks, trailerName, offset);
|
|
48
|
+
padTo4(chunks, offset);
|
|
49
|
+
return concatBytes(chunks);
|
|
50
|
+
}
|
|
51
|
+
function buildHeader(fields) {
|
|
52
|
+
const header = MAGIC +
|
|
53
|
+
hex8(fields.ino) +
|
|
54
|
+
hex8(fields.mode) +
|
|
55
|
+
hex8(0) + // uid — packages install as root
|
|
56
|
+
hex8(0) + // gid
|
|
57
|
+
hex8(fields.nlink) +
|
|
58
|
+
hex8(fields.mtime) +
|
|
59
|
+
hex8(fields.filesize) +
|
|
60
|
+
hex8(0) + // devmajor
|
|
61
|
+
hex8(0) + // devminor
|
|
62
|
+
hex8(0) + // rdevmajor
|
|
63
|
+
hex8(0) + // rdevminor
|
|
64
|
+
hex8(fields.namesize) +
|
|
65
|
+
hex8(0); // check — unused for 070701, but the field is not optional
|
|
66
|
+
if (header.length !== HEADER_SIZE) {
|
|
67
|
+
throw new Error(`gjsify ship: internal error — cpio header is ${header.length} bytes, expected ${HEADER_SIZE}.`);
|
|
68
|
+
}
|
|
69
|
+
return encodeUtf8(header);
|
|
70
|
+
}
|
|
71
|
+
function hex8(value) {
|
|
72
|
+
if (value < 0 || !Number.isInteger(value)) {
|
|
73
|
+
throw new Error(`gjsify ship: cpio header field must be a non-negative integer, got ${value}.`);
|
|
74
|
+
}
|
|
75
|
+
const text = value.toString(16).toUpperCase();
|
|
76
|
+
if (text.length > 8) {
|
|
77
|
+
throw new Error(`gjsify ship: cpio header field ${value} does not fit in 8 hex digits.`);
|
|
78
|
+
}
|
|
79
|
+
return text.padStart(8, '0');
|
|
80
|
+
}
|
|
81
|
+
function push(chunks, bytes, offset) {
|
|
82
|
+
if (bytes.byteLength > 0)
|
|
83
|
+
chunks.push(bytes);
|
|
84
|
+
return offset + bytes.byteLength;
|
|
85
|
+
}
|
|
86
|
+
function padTo4(chunks, offset) {
|
|
87
|
+
const remainder = offset % 4;
|
|
88
|
+
if (remainder === 0)
|
|
89
|
+
return offset;
|
|
90
|
+
const padding = 4 - remainder;
|
|
91
|
+
chunks.push(new Uint8Array(padding));
|
|
92
|
+
return offset + padding;
|
|
93
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PayloadEntry } from './payload.js';
|
|
2
|
+
import type { ShipSettings } from './types.js';
|
|
3
|
+
export interface DebInputs {
|
|
4
|
+
settings: ShipSettings;
|
|
5
|
+
/** The staged payload, prefix-relative. */
|
|
6
|
+
payload: readonly PayloadEntry[];
|
|
7
|
+
/** Install prefix, e.g. `/usr`. */
|
|
8
|
+
prefix: string;
|
|
9
|
+
/** Already-derived dependency list, in `name`/`name >= version` form. */
|
|
10
|
+
depends: readonly string[];
|
|
11
|
+
/** Debian architecture (`amd64`, `all`, …). */
|
|
12
|
+
archLabel: string;
|
|
13
|
+
/** Unix seconds stamped into every header. */
|
|
14
|
+
mtime: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function buildDeb(inputs: DebInputs): Promise<Uint8Array>;
|
|
17
|
+
/**
|
|
18
|
+
* `./`, `./usr/`, `./usr/bin/`, … for every path in the archive.
|
|
19
|
+
*
|
|
20
|
+
* dpkg opens each file with `O_CREAT | O_EXCL` and never creates a parent, so
|
|
21
|
+
* a missing directory entry is `cannot create '<path>'` mid-unpack — with the
|
|
22
|
+
* package half-installed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ancestorDirectories(paths: readonly string[]): string[];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The `.deb` packer.
|
|
2
|
+
//
|
|
3
|
+
// A Debian package is `debian-binary` + `control.tar.gz` + `data.tar.gz` in an
|
|
4
|
+
// ar container, and every one of those three parts already had a writer in
|
|
5
|
+
// this tree (`utils/ship/ar.ts`, `@gjsify/tar`) or is four bytes long. So no
|
|
6
|
+
// `dpkg-deb`, no vendored Go binary, no download at ship time — which is what
|
|
7
|
+
// lets the packer run under GJS, offline, on a Fedora CI image where dpkg does
|
|
8
|
+
// not exist.
|
|
9
|
+
//
|
|
10
|
+
// Everything below that looks like a detail is a documented dpkg failure mode:
|
|
11
|
+
// the ancestor-directory expansion (dpkg never calls `mkdir -p` and an ENOENT
|
|
12
|
+
// aborts the unpack), the `md5sums` path spelling (no `./`, exactly two
|
|
13
|
+
// spaces), the final newline in `control` (its absence is a parse error), and
|
|
14
|
+
// the gzip header's mtime (not a dpkg failure at all — it silently destroys
|
|
15
|
+
// reproducibility instead).
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { posix } from 'node:path';
|
|
18
|
+
import { createTarball } from '@gjsify/tar';
|
|
19
|
+
import { createArArchive } from './ar.js';
|
|
20
|
+
import { formatDebDepend } from './depends.js';
|
|
21
|
+
import { gzipDeterministic } from './gzip.js';
|
|
22
|
+
import { renderDebScripts } from './scripts.js';
|
|
23
|
+
export async function buildDeb(inputs) {
|
|
24
|
+
const { settings, payload, mtime } = inputs;
|
|
25
|
+
const prefix = inputs.prefix.replace(/^\/+/, '');
|
|
26
|
+
const named = payload.map((entry) => ({ ...entry, tarPath: `./${posix.join(prefix, entry.path)}` }));
|
|
27
|
+
const directories = ancestorDirectories(named.map((entry) => entry.tarPath));
|
|
28
|
+
const dataEntries = [
|
|
29
|
+
...directories.map((name) => ({ name, mode: 0o755, mtime, directory: true })),
|
|
30
|
+
...named.map((entry) => ({ name: entry.tarPath, body: entry.data, mode: entry.mode, mtime })),
|
|
31
|
+
];
|
|
32
|
+
const dataTar = await gzipDeterministic(createTarball(dataEntries));
|
|
33
|
+
// Policy 5.6.20: regular files and symlinks round UP to whole KiB, every
|
|
34
|
+
// other filesystem object counts as one. Getting the unit wrong (bytes
|
|
35
|
+
// instead of KiB) is invisible — dpkg never validates this field, apt just
|
|
36
|
+
// shows the user a wrong number.
|
|
37
|
+
const installedSize = named.reduce((total, entry) => total + Math.ceil(entry.data.byteLength / 1024), 0) + directories.length;
|
|
38
|
+
const control = renderControl(inputs, installedSize);
|
|
39
|
+
const md5sums = named
|
|
40
|
+
.map((entry) => `${createHash('md5').update(entry.data).digest('hex')} ${entry.tarPath.slice(2)}\n`)
|
|
41
|
+
.join('');
|
|
42
|
+
const controlEntries = [
|
|
43
|
+
{ name: './', mode: 0o755, mtime, directory: true },
|
|
44
|
+
{ name: './control', body: control, mode: 0o644, mtime },
|
|
45
|
+
{ name: './md5sums', body: md5sums, mode: 0o644, mtime },
|
|
46
|
+
];
|
|
47
|
+
const maintainerScripts = renderDebScripts(settings, inputs.prefix);
|
|
48
|
+
for (const [name, body] of Object.entries(maintainerScripts)) {
|
|
49
|
+
// dpkg refuses a maintainer script outside 0555–0775 with the mode in
|
|
50
|
+
// the error, so this is 0755 rather than "whatever the source had".
|
|
51
|
+
controlEntries.push({ name: `./${name}`, body, mode: 0o755, mtime });
|
|
52
|
+
}
|
|
53
|
+
const controlTar = await gzipDeterministic(createTarball(controlEntries));
|
|
54
|
+
return createArArchive([
|
|
55
|
+
{ name: 'debian-binary', data: new TextEncoder().encode('2.0\n'), mtime },
|
|
56
|
+
{ name: 'control.tar.gz', data: controlTar, mtime },
|
|
57
|
+
{ name: 'data.tar.gz', data: dataTar, mtime },
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Collapse a value that has to occupy exactly one control line.
|
|
62
|
+
*
|
|
63
|
+
* A newline here does not corrupt the file — it FORGES a field: a summary
|
|
64
|
+
* pasted with a hard wrap turns its second line into whatever that line happens
|
|
65
|
+
* to start with, and `Pre-Depends: …` is a real thing to end up with by
|
|
66
|
+
* accident. The extended description already collapses whitespace per
|
|
67
|
+
* paragraph; these fields did not.
|
|
68
|
+
*/
|
|
69
|
+
function singleLine(value) {
|
|
70
|
+
return value.replace(/[\r\n]+/g, ' ').trim();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `./`, `./usr/`, `./usr/bin/`, … for every path in the archive.
|
|
74
|
+
*
|
|
75
|
+
* dpkg opens each file with `O_CREAT | O_EXCL` and never creates a parent, so
|
|
76
|
+
* a missing directory entry is `cannot create '<path>'` mid-unpack — with the
|
|
77
|
+
* package half-installed.
|
|
78
|
+
*/
|
|
79
|
+
export function ancestorDirectories(paths) {
|
|
80
|
+
const directories = new Set(['./']);
|
|
81
|
+
for (const path of paths) {
|
|
82
|
+
const parts = path.replace(/^\.\//, '').split('/');
|
|
83
|
+
parts.pop();
|
|
84
|
+
let current = '.';
|
|
85
|
+
for (const part of parts) {
|
|
86
|
+
current = `${current}/${part}`;
|
|
87
|
+
directories.add(`${current}/`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...directories].sort();
|
|
91
|
+
}
|
|
92
|
+
function renderControl(inputs, installedSize) {
|
|
93
|
+
const { settings } = inputs;
|
|
94
|
+
const fields = [
|
|
95
|
+
['Package', settings.binaryName],
|
|
96
|
+
['Version', `${settings.version}-${settings.release}`],
|
|
97
|
+
['Architecture', inputs.archLabel],
|
|
98
|
+
['Maintainer', settings.maintainer],
|
|
99
|
+
['Installed-Size', String(installedSize)],
|
|
100
|
+
];
|
|
101
|
+
if (inputs.depends.length > 0)
|
|
102
|
+
fields.push(['Depends', inputs.depends.map(formatDebDepend).join(', ')]);
|
|
103
|
+
fields.push(['Section', settings.section], ['Priority', 'optional']);
|
|
104
|
+
if (settings.homepage)
|
|
105
|
+
fields.push(['Homepage', settings.homepage]);
|
|
106
|
+
const lines = fields.map(([key, value]) => `${key}: ${singleLine(value)}`);
|
|
107
|
+
// The extended description is continuation lines: one leading space per
|
|
108
|
+
// line, and a lone ` .` for a paragraph break — a truly blank line ends
|
|
109
|
+
// the field and silently truncates everything after it.
|
|
110
|
+
lines.push(`Description: ${singleLine(settings.summary)}`);
|
|
111
|
+
const body = settings.description.filter((paragraph) => paragraph !== settings.summary);
|
|
112
|
+
body.forEach((paragraph, index) => {
|
|
113
|
+
if (index > 0)
|
|
114
|
+
lines.push(' .');
|
|
115
|
+
lines.push(` ${paragraph}`);
|
|
116
|
+
});
|
|
117
|
+
return `${lines.join('\n')}\n`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { FormatId } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The GJS the bundles target (root AGENTS.md § Constraints: GJS 1.86 /
|
|
4
|
+
* SpiderMonkey 140, Rolldown `firefox140`). A `--app gjs` bundle can carry
|
|
5
|
+
* ES2024 syntax an older SpiderMonkey does not parse, so this is a real floor
|
|
6
|
+
* and not a formality.
|
|
7
|
+
*
|
|
8
|
+
* It is also, today, UNSATISFIABLE ON DEBIAN — measured, not assumed: Debian
|
|
9
|
+
* went 1.82.3 (trixie) straight to 1.88.1 (forky), skipping 1.84 and 1.86, so
|
|
10
|
+
* no Debian suite ships a `gjs` that satisfies `>= 1.86`. Emitting the honest
|
|
11
|
+
* floor means a `.deb` apt REFUSES on trixie; lowering it silently would mean
|
|
12
|
+
* one apt installs and the app then dies on a syntax error. `warnAboutGjsFloor`
|
|
13
|
+
* exists so the choice is visible at ship time, and `gjsify.ship.minGjsVersion`
|
|
14
|
+
* is how a project whose bundle really does run on older GJS lowers it.
|
|
15
|
+
*/
|
|
16
|
+
export declare const DEFAULT_GJS_FLOOR = "1.86";
|
|
17
|
+
/**
|
|
18
|
+
* Warn when the derived floor cannot be satisfied by a released Debian.
|
|
19
|
+
* Returns the warning lines; empty when the floor is fine.
|
|
20
|
+
*/
|
|
21
|
+
export declare function warnAboutGjsFloor(format: FormatId, floor: string): string[];
|
|
22
|
+
export interface DependsInputs {
|
|
23
|
+
/** GI namespaces the bundle imports, in `Ns-Version` spelling. */
|
|
24
|
+
namespaces: readonly string[];
|
|
25
|
+
/** `'app'` installs a desktop entry and icons; `'cli'` does not. */
|
|
26
|
+
kind: 'app' | 'cli';
|
|
27
|
+
/** Whether the payload installs GSettings schemas. */
|
|
28
|
+
hasSchemas: boolean;
|
|
29
|
+
/** User-supplied additions for this format. */
|
|
30
|
+
extra: readonly string[];
|
|
31
|
+
/** Project-supplied table rows, filling gaps in {@link TYPELIB_PACKAGES}. */
|
|
32
|
+
typelibPackages?: Record<string, {
|
|
33
|
+
deb: string;
|
|
34
|
+
rpm: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** Minimum GJS. Default {@link DEFAULT_GJS_FLOOR}. */
|
|
37
|
+
minGjsVersion?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The `Depends:` / `Requires:` list for one format.
|
|
41
|
+
*
|
|
42
|
+
* @throws when a namespace has no entry in the table — see the module header.
|
|
43
|
+
*/
|
|
44
|
+
export declare function deriveDepends(format: FormatId, inputs: DependsInputs): string[];
|
|
45
|
+
/** One parsed dependency. `relation`/`version` are absent for an unversioned one. */
|
|
46
|
+
export interface ParsedDepend {
|
|
47
|
+
name: string;
|
|
48
|
+
relation?: '>=' | '>' | '<=' | '<' | '=';
|
|
49
|
+
version?: string;
|
|
50
|
+
}
|
|
51
|
+
/** `gjs >= 1.86` → its parts. Both packers need this; only the spelling differs. */
|
|
52
|
+
export declare function parseDepend(depend: string): ParsedDepend;
|
|
53
|
+
/**
|
|
54
|
+
* Format a dependency for `Depends:`.
|
|
55
|
+
*
|
|
56
|
+
* dpkg puts the bound in parentheses and spells strict inequality `<<`/`>>` —
|
|
57
|
+
* bare `<`/`>` are deprecated aliases for `<=`/`>=`, so emitting them means the
|
|
58
|
+
* opposite of what it reads like.
|
|
59
|
+
*/
|
|
60
|
+
export declare function formatDebDepend(depend: string): string;
|
|
61
|
+
/** The namespaces the table knows — exported so the unit tests can assert on the set. */
|
|
62
|
+
export declare function knownNamespaces(): string[];
|