@gjsify/cli 0.42.0 → 0.43.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 +238 -210
- package/lib/actions/build.d.ts +6 -0
- package/lib/actions/build.js +64 -8
- package/lib/commands/ship.d.ts +2 -0
- package/lib/commands/ship.js +285 -93
- package/lib/types/config-data.d.ts +32 -0
- package/lib/utils/gjs-bundle-guard.d.ts +57 -3
- package/lib/utils/gjs-bundle-guard.js +122 -13
- package/lib/utils/jsx-config.d.ts +45 -0
- package/lib/utils/jsx-config.js +98 -0
- package/lib/utils/onboard-probe.d.ts +23 -0
- package/lib/utils/onboard-probe.js +65 -0
- package/lib/utils/ship/deb.d.ts +3 -3
- package/lib/utils/ship/deb.js +6 -2
- package/lib/utils/ship/depends.d.ts +9 -6
- package/lib/utils/ship/depends.js +25 -3
- package/lib/utils/ship/discover.js +72 -6
- package/lib/utils/ship/formats.js +9 -1
- package/lib/utils/ship/mime.d.ts +19 -0
- package/lib/utils/ship/mime.js +89 -0
- package/lib/utils/ship/payload.d.ts +76 -0
- package/lib/utils/ship/payload.js +91 -1
- package/lib/utils/ship/plan.d.ts +30 -1
- package/lib/utils/ship/plan.js +54 -2
- package/lib/utils/ship/rpm.d.ts +3 -3
- package/lib/utils/ship/rpm.js +20 -3
- package/lib/utils/ship/scripts.d.ts +14 -4
- package/lib/utils/ship/scripts.js +26 -8
- package/lib/utils/ship/settings.js +13 -0
- package/lib/utils/ship/stage-manifest.d.ts +154 -0
- package/lib/utils/ship/stage-manifest.js +408 -0
- package/lib/utils/ship/stage-writer.d.ts +30 -3
- package/lib/utils/ship/stage-writer.js +76 -9
- package/lib/utils/ship/types.d.ts +91 -18
- package/lib/utils/ship/types.js +5 -0
- package/package.json +19 -19
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// so `settings.ts` and `plan.ts` can stay pure. Every probe has a configured
|
|
5
5
|
// override; the probes exist so a project that follows the GNOME convention
|
|
6
6
|
// (`data/icons/hicolor/…`, `data/*.gschema.xml`) needs no configuration at all.
|
|
7
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
8
|
-
import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
8
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
9
9
|
import { LICENSE_FILE_NAMES } from './settings.js';
|
|
10
10
|
const ICON_EXTENSIONS = ['.svg', '.png'];
|
|
11
11
|
export function discoverPayload(input) {
|
|
@@ -189,6 +189,34 @@ function discoverSchemas(projectDir, configured) {
|
|
|
189
189
|
}
|
|
190
190
|
return [];
|
|
191
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* The licence text this package ships — searched UP the tree, not just beside
|
|
194
|
+
* `package.json`.
|
|
195
|
+
*
|
|
196
|
+
* A monorepo package does not carry its own copy. `packages/infra/cli`
|
|
197
|
+
* declares `"license": "MIT"` and the text lives one `LICENSE` up at the
|
|
198
|
+
* repository root, shared by sixty-odd packages; searching `projectDir` alone
|
|
199
|
+
* found nothing, `planOverlay` turned "nothing" into an empty overlay, and the
|
|
200
|
+
* `.deb` shipped without the `/usr/share/doc/<pkg>/copyright` that Debian
|
|
201
|
+
* Policy § 12.5 requires. Nothing downstream noticed: `dpkg-deb --info` prints
|
|
202
|
+
* a clean control file and `dpkg -i` installs it. It took the first leg that
|
|
203
|
+
* ran a real `lintian` to say so.
|
|
204
|
+
*
|
|
205
|
+
* WHERE THE SEARCH STOPS is the whole design. It climbs to the first ancestor
|
|
206
|
+
* that is a project root — one holding `.git`, or a `package.json` declaring
|
|
207
|
+
* `workspaces` — and searches that directory too, then stops. Above that line
|
|
208
|
+
* a `LICENSE` belongs to some unrelated tree that happens to be a parent
|
|
209
|
+
* directory (`~/Projekte/LICENSE` is not yours), and packaging its text would
|
|
210
|
+
* make a specific false legal claim. When NO marker is found the search is the
|
|
211
|
+
* project directory alone: without a marker there is no way to tell where the
|
|
212
|
+
* project ends, and guessing wrong is the same false claim.
|
|
213
|
+
*
|
|
214
|
+
* What this cannot check is whether the inherited text is the RIGHT licence
|
|
215
|
+
* for this package — an Apache-2.0 package inside an MIT monorepo would
|
|
216
|
+
* inherit the wrong one. Reading an SPDX identifier out of licence prose is
|
|
217
|
+
* not something to guess at, so the caller PRINTS the inherited path instead
|
|
218
|
+
* (see `commands/ship.ts`), and `gjsify.ship.licenseFile` overrides it.
|
|
219
|
+
*/
|
|
192
220
|
function discoverLicense(projectDir, configured) {
|
|
193
221
|
if (configured !== undefined) {
|
|
194
222
|
const target = resolve(projectDir, configured);
|
|
@@ -197,13 +225,51 @@ function discoverLicense(projectDir, configured) {
|
|
|
197
225
|
}
|
|
198
226
|
return target;
|
|
199
227
|
}
|
|
200
|
-
for (const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
for (const dir of licenseSearchDirs(projectDir)) {
|
|
229
|
+
for (const name of LICENSE_FILE_NAMES) {
|
|
230
|
+
const target = join(dir, name);
|
|
231
|
+
if (existsSync(target))
|
|
232
|
+
return target;
|
|
233
|
+
}
|
|
204
234
|
}
|
|
205
235
|
return undefined;
|
|
206
236
|
}
|
|
237
|
+
/** `projectDir` first, then each ancestor up to and including the project root. */
|
|
238
|
+
function licenseSearchDirs(projectDir) {
|
|
239
|
+
const dirs = [];
|
|
240
|
+
for (let current = projectDir;;) {
|
|
241
|
+
dirs.push(current);
|
|
242
|
+
if (isProjectRoot(current))
|
|
243
|
+
return dirs;
|
|
244
|
+
const parent = dirname(current);
|
|
245
|
+
if (parent === current)
|
|
246
|
+
return [projectDir];
|
|
247
|
+
current = parent;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Is this the top of the tree the project belongs to?
|
|
252
|
+
*
|
|
253
|
+
* `.git` is tested with `existsSync` rather than `statSync().isDirectory()`
|
|
254
|
+
* because in a git WORKTREE it is a file, and this repository is developed in
|
|
255
|
+
* worktrees.
|
|
256
|
+
*/
|
|
257
|
+
function isProjectRoot(dir) {
|
|
258
|
+
if (existsSync(join(dir, '.git')))
|
|
259
|
+
return true;
|
|
260
|
+
const manifest = join(dir, 'package.json');
|
|
261
|
+
if (!existsSync(manifest))
|
|
262
|
+
return false;
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(readFileSync(manifest, 'utf-8')).workspaces !== undefined;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// A malformed `package.json` in some ancestor is not this command's
|
|
268
|
+
// business, and it must not turn a licence search into a ship failure.
|
|
269
|
+
// Not a root, then: the climb continues and `.git` still stops it.
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
207
273
|
/**
|
|
208
274
|
* Every regular file under `dir`, as POSIX-separated relative paths.
|
|
209
275
|
*
|
|
@@ -58,7 +58,15 @@ export const FORMATS = {
|
|
|
58
58
|
fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.${archLabel}.rpm`,
|
|
59
59
|
},
|
|
60
60
|
};
|
|
61
|
-
|
|
61
|
+
// DERIVED, never a second list. `FORMATS` is `Record<FormatId, …>`, so the
|
|
62
|
+
// compiler already refuses a `FormatId` with no descriptor — reading the keys
|
|
63
|
+
// back inherits that guarantee for free. Written out by hand this was the one
|
|
64
|
+
// unbound copy of the vocabulary: adding a format to `FormatId` and `FORMATS`
|
|
65
|
+
// compiled fine and left this list short, which would have made the new format
|
|
66
|
+
// absent from the default targets (`ship.ts` uses it as the `??` fallback),
|
|
67
|
+
// missing from `--help`, and REFUSED by `readStage` as an unknown id.
|
|
68
|
+
// Insertion order is stable for string keys, and `resolveFormats` sorts anyway.
|
|
69
|
+
export const FORMAT_IDS = Object.keys(FORMATS);
|
|
62
70
|
/** Parse `--target deb,rpm` into a sorted, deduplicated descriptor list. */
|
|
63
71
|
export function resolveFormats(raw) {
|
|
64
72
|
const names = raw
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ShipMimeType } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validate the declared types, throwing on anything that would install and then not work.
|
|
4
|
+
*
|
|
5
|
+
* Each refusal is a silent failure made loud:
|
|
6
|
+
* - a malformed type name is written into the XML and ignored by `update-mime-database`;
|
|
7
|
+
* - a glob without `*.` never matches (`bauplan` matches only a file called exactly that);
|
|
8
|
+
* - a type with no glob and no parent type can never be DETECTED, so it is registered and unused;
|
|
9
|
+
* - a duplicate definition makes which comment wins depend on document order.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateMimeTypes(types: readonly ShipMimeType[]): void;
|
|
12
|
+
/**
|
|
13
|
+
* The shared-mime-info document for `types`.
|
|
14
|
+
*
|
|
15
|
+
* One file per package, named after the app id, because `share/mime/packages/` is shared: a generic
|
|
16
|
+
* name is a collision with whatever another package put there — the same reason the schema staging
|
|
17
|
+
* uses the app id.
|
|
18
|
+
*/
|
|
19
|
+
export declare function renderMimePackage(types: readonly ShipMimeType[]): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Defining a file type for the system — the half `MimeType=` cannot do on its own.
|
|
2
|
+
//
|
|
3
|
+
// A desktop entry's `MimeType=` says "I open this type". It does NOT say the type EXISTS. For a
|
|
4
|
+
// standard type that is fine, because the distribution's shared-mime-info already defines it; for a
|
|
5
|
+
// type of the project's own it is not, and the failure is the quietest kind there is: nothing knows
|
|
6
|
+
// what a `.bauplan` file is, so the file manager never assigns the type, `MimeType=` matches
|
|
7
|
+
// nothing, and a double-click does nothing at all. No error is printed and no log line is written —
|
|
8
|
+
// the association simply never fires, which is indistinguishable from "the app is not installed".
|
|
9
|
+
//
|
|
10
|
+
// So a project that invents a type also ships a shared-mime-info document, and the package refreshes
|
|
11
|
+
// the MIME cache on install (see `scripts.ts`) — an unrefreshed cache is the same silence again.
|
|
12
|
+
/** XML text escaping. Only the five that matter in element content and attributes. */
|
|
13
|
+
function esc(value) {
|
|
14
|
+
return value
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''');
|
|
20
|
+
}
|
|
21
|
+
const TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
22
|
+
/**
|
|
23
|
+
* Validate the declared types, throwing on anything that would install and then not work.
|
|
24
|
+
*
|
|
25
|
+
* Each refusal is a silent failure made loud:
|
|
26
|
+
* - a malformed type name is written into the XML and ignored by `update-mime-database`;
|
|
27
|
+
* - a glob without `*.` never matches (`bauplan` matches only a file called exactly that);
|
|
28
|
+
* - a type with no glob and no parent type can never be DETECTED, so it is registered and unused;
|
|
29
|
+
* - a duplicate definition makes which comment wins depend on document order.
|
|
30
|
+
*/
|
|
31
|
+
export function validateMimeTypes(types) {
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
for (const entry of types) {
|
|
34
|
+
if (!TYPE_RE.test(entry.type)) {
|
|
35
|
+
throw new Error(`gjsify ship: \`gjsify.ship.mimeTypes\` has an invalid type "${entry.type}". ` +
|
|
36
|
+
'Expected `<media>/<subtype>`, e.g. `application/x-bauplan` — update-mime-database ' +
|
|
37
|
+
'ignores a malformed name, so the type would install and never resolve.');
|
|
38
|
+
}
|
|
39
|
+
if (seen.has(entry.type)) {
|
|
40
|
+
throw new Error(`gjsify ship: \`gjsify.ship.mimeTypes\` defines ${entry.type} twice. ` +
|
|
41
|
+
'Which comment and glob set win would depend on document order.');
|
|
42
|
+
}
|
|
43
|
+
seen.add(entry.type);
|
|
44
|
+
if (entry.comment.trim() === '') {
|
|
45
|
+
throw new Error(`gjsify ship: ${entry.type} has an empty \`comment\`. A file manager then shows the ` +
|
|
46
|
+
'user the raw type string instead of a name.');
|
|
47
|
+
}
|
|
48
|
+
for (const glob of entry.globs ?? []) {
|
|
49
|
+
if (!glob.includes('*') && !glob.includes('?') && !glob.includes('[')) {
|
|
50
|
+
throw new Error(`gjsify ship: ${entry.type} has the glob "${glob}", which matches only a file ` +
|
|
51
|
+
`named exactly that. A suffix pattern is written \`*.${glob.replace(/^\.+/, '')}\`.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if ((entry.globs ?? []).length === 0 && entry.subClassOf === undefined) {
|
|
55
|
+
throw new Error(`gjsify ship: ${entry.type} declares neither \`globs\` nor \`subClassOf\`, so nothing ` +
|
|
56
|
+
'can ever match it. The type would be registered and then never assigned to a file.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The shared-mime-info document for `types`.
|
|
62
|
+
*
|
|
63
|
+
* One file per package, named after the app id, because `share/mime/packages/` is shared: a generic
|
|
64
|
+
* name is a collision with whatever another package put there — the same reason the schema staging
|
|
65
|
+
* uses the app id.
|
|
66
|
+
*/
|
|
67
|
+
export function renderMimePackage(types) {
|
|
68
|
+
const body = types
|
|
69
|
+
.map((entry) => {
|
|
70
|
+
const lines = [` <mime-type type="${esc(entry.type)}">`, ` <comment>${esc(entry.comment)}</comment>`];
|
|
71
|
+
if (entry.subClassOf !== undefined) {
|
|
72
|
+
lines.push(` <sub-class-of type="${esc(entry.subClassOf)}"/>`);
|
|
73
|
+
}
|
|
74
|
+
for (const glob of entry.globs ?? []) {
|
|
75
|
+
lines.push(` <glob pattern="${esc(glob)}"/>`);
|
|
76
|
+
}
|
|
77
|
+
if (entry.genericIcon !== undefined) {
|
|
78
|
+
lines.push(` <generic-icon name="${esc(entry.genericIcon)}"/>`);
|
|
79
|
+
}
|
|
80
|
+
lines.push(' </mime-type>');
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
})
|
|
83
|
+
.join('\n');
|
|
84
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
85
|
+
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
|
86
|
+
${body}
|
|
87
|
+
</mime-info>
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
@@ -16,8 +16,84 @@ export interface PayloadEntry {
|
|
|
16
16
|
* what `rpm -qi` then shows a user, and an artifact that looks broken is a
|
|
17
17
|
* support question. The mtime keeps the property that matters (pack the same
|
|
18
18
|
* build twice, get the same bytes) while saying something true.
|
|
19
|
+
*
|
|
20
|
+
* Only the ASSEMBLING host runs this. A stage records the answer it got
|
|
21
|
+
* (`.gjsify-ship-stage.json` → `mtime`) and the packing host reuses it: an
|
|
22
|
+
* artifact upload does not carry mtimes, so re-stat'ing the stage there would
|
|
23
|
+
* stamp every header with "whenever the download finished" and quietly destroy
|
|
24
|
+
* the reproducibility this function exists to protect.
|
|
19
25
|
*/
|
|
20
26
|
export declare function buildTimestamp(bundlePath: string, env?: Record<string, string | undefined>): number;
|
|
27
|
+
/**
|
|
28
|
+
* What the payload itself says about what it installs.
|
|
29
|
+
*
|
|
30
|
+
* Every field here used to be answered from the SETTINGS — `settings.iconFiles.length > 0`,
|
|
31
|
+
* `settings.schemaFiles.length > 0`, `settings.typelibFiles` — i.e. from lists of absolute paths
|
|
32
|
+
* on the BUILD host. Two things were wrong with that, and the second is why this function exists
|
|
33
|
+
* at all:
|
|
34
|
+
*
|
|
35
|
+
* 1. It answered a different question than the one being asked. `cacheRefreshCommands` emits
|
|
36
|
+
* `gtk-update-icon-cache <prefix>/share/icons/hicolor` — the honest precondition is "did this
|
|
37
|
+
* package install anything into that directory", not "did the project have icon files lying
|
|
38
|
+
* around". They come apart for a `kind: 'cli'` project with a `data/icons/` folder: the
|
|
39
|
+
* planner stages no icon (icons are an `'app'` thing), the settings still listed them, and the
|
|
40
|
+
* postinst refreshed a cache for files that were never installed.
|
|
41
|
+
* 2. An absolute build-host path cannot cross to the host that packs the artifact
|
|
42
|
+
* (ADR 0024 § A2). Carrying `iconFiles: ["/home/…/icon.svg"]` in a stage manifest so that
|
|
43
|
+
* `.length > 0` can be read on another machine is authoring a value that is measurable right
|
|
44
|
+
* there in the tree.
|
|
45
|
+
*
|
|
46
|
+
* Path-only, on purpose — `isArchIndependent` is the sibling that reads BYTES, and the two answer
|
|
47
|
+
* different questions from the same payload. The same split as `plan.ts`'s `isExecutableAsset`
|
|
48
|
+
* (by name) versus this module's magic sniffing (by content).
|
|
49
|
+
*/
|
|
50
|
+
export interface PayloadFacts {
|
|
51
|
+
/** The payload installs a `share/applications/*.desktop`. */
|
|
52
|
+
hasDesktopEntry: boolean;
|
|
53
|
+
/** The payload installs into `share/icons/hicolor/`. */
|
|
54
|
+
hasIcons: boolean;
|
|
55
|
+
/** The payload installs a compiled-on-install `share/glib-2.0/schemas/*.gschema.xml`. */
|
|
56
|
+
hasSchemas: boolean;
|
|
57
|
+
/** The payload installs a `share/mime/packages/*.xml`, so the mime cache needs rebuilding. */
|
|
58
|
+
hasMimeTypes: boolean;
|
|
59
|
+
/** Prefix-relative paths of the typelibs the payload carries itself. */
|
|
60
|
+
bundledTypelibs: string[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read {@link PayloadFacts} off a payload or off a plan.
|
|
64
|
+
*
|
|
65
|
+
* Takes anything with a `path`, so the assembling phase can ask the same
|
|
66
|
+
* question of the PLAN (before the tree is read back) that the packing phase
|
|
67
|
+
* asks of the payload. One function, so the two phases cannot disagree about
|
|
68
|
+
* whether a package installs a schema.
|
|
69
|
+
*/
|
|
70
|
+
export declare function readPayloadFacts(entries: readonly {
|
|
71
|
+
path: string;
|
|
72
|
+
}[]): PayloadFacts;
|
|
73
|
+
/**
|
|
74
|
+
* The interpreters the payload's own executables need, read off their shebangs.
|
|
75
|
+
*
|
|
76
|
+
* An interpreter is a dependency like any other, and rpm expects it declared:
|
|
77
|
+
* `rpmbuild`'s file-based generator emits one `Requires` per executable
|
|
78
|
+
* shebang, with the `RPMSENSE_FIND_REQUIRES` sense that says "derived, not
|
|
79
|
+
* declared". Measured on Fedora 44 against a package whose only file is a
|
|
80
|
+
* `#!/bin/sh` script: `rpm -qp --requires` → `/usr/bin/sh 16384`.
|
|
81
|
+
*
|
|
82
|
+
* The LITERAL path, not a resolved one. `rpmbuild` prints `/usr/bin/sh` there
|
|
83
|
+
* because it resolved `/bin` through the symlink of the usrmerged host it ran
|
|
84
|
+
* on; this writer has no target host to resolve against (ADR 0024 § A1 — the
|
|
85
|
+
* packers are pure JavaScript and run anywhere), and `/bin/sh` is satisfied on
|
|
86
|
+
* both layouts: measured on Fedora 44, `rpm -q --whatprovides /bin/sh` and
|
|
87
|
+
* `/usr/bin/sh` both answer `bash`. It is also the spelling the scriptlet
|
|
88
|
+
* requirements already use.
|
|
89
|
+
*
|
|
90
|
+
* EXECUTABLE files only, which is the same rule `rpmbuild` applies. A GJS
|
|
91
|
+
* bundle staged 0644 carries `#!/usr/bin/env -S gjs -m` for the days it is run
|
|
92
|
+
* directly, but nothing in the installed package executes it as a program — the
|
|
93
|
+
* launcher `exec`s `gjs` with it as an argument — so declaring `/usr/bin/env`
|
|
94
|
+
* for it would be a dependency on a path this package never uses.
|
|
95
|
+
*/
|
|
96
|
+
export declare function readShebangInterpreters(payload: readonly PayloadEntry[]): string[];
|
|
21
97
|
/**
|
|
22
98
|
* Does the payload contain anything architecture-specific?
|
|
23
99
|
*
|
|
@@ -16,6 +16,12 @@ import { statSync } from 'node:fs';
|
|
|
16
16
|
* what `rpm -qi` then shows a user, and an artifact that looks broken is a
|
|
17
17
|
* support question. The mtime keeps the property that matters (pack the same
|
|
18
18
|
* build twice, get the same bytes) while saying something true.
|
|
19
|
+
*
|
|
20
|
+
* Only the ASSEMBLING host runs this. A stage records the answer it got
|
|
21
|
+
* (`.gjsify-ship-stage.json` → `mtime`) and the packing host reuses it: an
|
|
22
|
+
* artifact upload does not carry mtimes, so re-stat'ing the stage there would
|
|
23
|
+
* stamp every header with "whenever the download finished" and quietly destroy
|
|
24
|
+
* the reproducibility this function exists to protect.
|
|
19
25
|
*/
|
|
20
26
|
export function buildTimestamp(bundlePath, env = process.env) {
|
|
21
27
|
const raw = env.SOURCE_DATE_EPOCH;
|
|
@@ -28,6 +34,72 @@ export function buildTimestamp(bundlePath, env = process.env) {
|
|
|
28
34
|
}
|
|
29
35
|
return Math.floor(statSync(bundlePath).mtimeMs / 1000);
|
|
30
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Read {@link PayloadFacts} off a payload or off a plan.
|
|
39
|
+
*
|
|
40
|
+
* Takes anything with a `path`, so the assembling phase can ask the same
|
|
41
|
+
* question of the PLAN (before the tree is read back) that the packing phase
|
|
42
|
+
* asks of the payload. One function, so the two phases cannot disagree about
|
|
43
|
+
* whether a package installs a schema.
|
|
44
|
+
*/
|
|
45
|
+
export function readPayloadFacts(entries) {
|
|
46
|
+
const paths = entries.map((entry) => entry.path);
|
|
47
|
+
return {
|
|
48
|
+
hasDesktopEntry: paths.some((path) => path.startsWith('share/applications/') && path.endsWith('.desktop')),
|
|
49
|
+
hasIcons: paths.some((path) => path.startsWith('share/icons/hicolor/')),
|
|
50
|
+
hasSchemas: paths.some((path) => path.startsWith('share/glib-2.0/schemas/') && path.endsWith('.gschema.xml')),
|
|
51
|
+
hasMimeTypes: paths.some((path) => path.startsWith('share/mime/packages/') && path.endsWith('.xml')),
|
|
52
|
+
// Anywhere in the payload, not only `lib/<name>/gi/`: `gjsify.ship.extraFiles` can place
|
|
53
|
+
// one elsewhere, and a typelib the package carries is a typelib the package must not also
|
|
54
|
+
// declare a distro dependency for, wherever it sits.
|
|
55
|
+
bundledTypelibs: paths.filter((path) => path.endsWith('.typelib')),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The interpreters the payload's own executables need, read off their shebangs.
|
|
60
|
+
*
|
|
61
|
+
* An interpreter is a dependency like any other, and rpm expects it declared:
|
|
62
|
+
* `rpmbuild`'s file-based generator emits one `Requires` per executable
|
|
63
|
+
* shebang, with the `RPMSENSE_FIND_REQUIRES` sense that says "derived, not
|
|
64
|
+
* declared". Measured on Fedora 44 against a package whose only file is a
|
|
65
|
+
* `#!/bin/sh` script: `rpm -qp --requires` → `/usr/bin/sh 16384`.
|
|
66
|
+
*
|
|
67
|
+
* The LITERAL path, not a resolved one. `rpmbuild` prints `/usr/bin/sh` there
|
|
68
|
+
* because it resolved `/bin` through the symlink of the usrmerged host it ran
|
|
69
|
+
* on; this writer has no target host to resolve against (ADR 0024 § A1 — the
|
|
70
|
+
* packers are pure JavaScript and run anywhere), and `/bin/sh` is satisfied on
|
|
71
|
+
* both layouts: measured on Fedora 44, `rpm -q --whatprovides /bin/sh` and
|
|
72
|
+
* `/usr/bin/sh` both answer `bash`. It is also the spelling the scriptlet
|
|
73
|
+
* requirements already use.
|
|
74
|
+
*
|
|
75
|
+
* EXECUTABLE files only, which is the same rule `rpmbuild` applies. A GJS
|
|
76
|
+
* bundle staged 0644 carries `#!/usr/bin/env -S gjs -m` for the days it is run
|
|
77
|
+
* directly, but nothing in the installed package executes it as a program — the
|
|
78
|
+
* launcher `exec`s `gjs` with it as an argument — so declaring `/usr/bin/env`
|
|
79
|
+
* for it would be a dependency on a path this package never uses.
|
|
80
|
+
*/
|
|
81
|
+
export function readShebangInterpreters(payload) {
|
|
82
|
+
const found = new Set();
|
|
83
|
+
for (const entry of payload) {
|
|
84
|
+
if ((entry.mode & 0o111) === 0)
|
|
85
|
+
continue;
|
|
86
|
+
const interpreter = readShebang(entry.data);
|
|
87
|
+
if (interpreter !== null)
|
|
88
|
+
found.add(interpreter);
|
|
89
|
+
}
|
|
90
|
+
return [...found].sort();
|
|
91
|
+
}
|
|
92
|
+
/** The absolute interpreter path of a `#!` line, or `null` when there is none to read. */
|
|
93
|
+
function readShebang(data) {
|
|
94
|
+
if (data[0] !== 0x23 || data[1] !== 0x21)
|
|
95
|
+
return null; // `#!`
|
|
96
|
+
// A shebang is one LINE; reading further would let a long file's contents
|
|
97
|
+
// decide how much work this does. 256 bytes is above every real one and is
|
|
98
|
+
// what Linux itself truncates at (BINPRM_BUF_SIZE).
|
|
99
|
+
const line = new TextDecoder().decode(data.subarray(2, Math.min(data.byteLength, 256))).split('\n')[0] ?? '';
|
|
100
|
+
const interpreter = line.trim().split(/\s+/)[0] ?? '';
|
|
101
|
+
return interpreter.startsWith('/') ? interpreter : null;
|
|
102
|
+
}
|
|
31
103
|
/**
|
|
32
104
|
* Does the payload contain anything architecture-specific?
|
|
33
105
|
*
|
|
@@ -44,6 +116,23 @@ export function isArchIndependent(payload) {
|
|
|
44
116
|
// values this repository can actually produce are listed; an unknown one reads
|
|
45
117
|
// as "cannot tell" rather than as a mismatch, because refusing an artifact over
|
|
46
118
|
// a machine constant nobody here emits would be a guess wearing a gate's clothes.
|
|
119
|
+
// The machine values this project actually ships packages for — no more. A value
|
|
120
|
+
// missing here makes `readBinaryArch` return null, and null is SILENT, so an
|
|
121
|
+
// absent row costs nothing but a check that does not fire.
|
|
122
|
+
//
|
|
123
|
+
// `EM_MIPS` (0x08) is absent for that reason and no other. An earlier version of
|
|
124
|
+
// this comment claimed it was absent because one value covers `mips` and
|
|
125
|
+
// `mipsel` and the row would have to guess; that was wrong three times over, and
|
|
126
|
+
// is corrected here rather than deleted because it is the kind of reasoning that
|
|
127
|
+
// gets re-derived: (1) `mipsel` IS little-endian MIPS, so the discriminator is
|
|
128
|
+
// `EI_DATA`, which `readBinaryArch` reads four lines below; (2) it could not
|
|
129
|
+
// "refuse a correct pack" either way, because `formats.ts` has no `mips` row in
|
|
130
|
+
// `DEBIAN_ARCH`/`RPM_ARCH`, so `archName` throws before any label is written;
|
|
131
|
+
// and (3) the principle it invoked is already broken one row down — `0x16` maps
|
|
132
|
+
// to `s390x`, but `EM_S390` is emitted by 31-bit `s390` too, and its
|
|
133
|
+
// discriminator is `EI_CLASS` at offset 4, which this function does NOT read.
|
|
134
|
+
// That row is the ambiguous one. Unreachable today (nothing here builds s390),
|
|
135
|
+
// but it is the row to fix first if this table ever grows.
|
|
47
136
|
const ELF_MACHINE_TO_ARCH = {
|
|
48
137
|
0x03: 'ia32',
|
|
49
138
|
0x15: 'ppc64',
|
|
@@ -121,7 +210,8 @@ export function assertPayloadMatchesArch(payload, arch) {
|
|
|
121
210
|
'anything.\n' +
|
|
122
211
|
` A package labelled ${arch} installs on ${arch} and then fails to load. Build the payload ` +
|
|
123
212
|
`for ${arch}\n` +
|
|
124
|
-
' (its own prebuild), or
|
|
213
|
+
' (its own prebuild), or assemble the stage without `--arch` and label the payload you ' +
|
|
214
|
+
'actually have.');
|
|
125
215
|
}
|
|
126
216
|
}
|
|
127
217
|
/** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
|
package/lib/utils/ship/plan.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FormatDescriptor, ShipSettings, StagedFile } from './types.js';
|
|
1
|
+
import type { FormatDescriptor, FormatId, ShipSettings, StagedFile } from './types.js';
|
|
2
2
|
/** Everything the planner needs that it cannot derive from the settings alone. */
|
|
3
3
|
export interface StageInputs {
|
|
4
4
|
/** Paths inside `settings.bundleDir`, relative and POSIX-separated. */
|
|
@@ -24,8 +24,37 @@ export declare function planStage(settings: ShipSettings, inputs: StageInputs):
|
|
|
24
24
|
* The per-format additions. Only the licence differs today, and it differs in
|
|
25
25
|
* BOTH place and shape: Debian policy wants a machine-readable `copyright` in
|
|
26
26
|
* `share/doc/<pkg>/`, RPM wants the licence text under `share/licenses/<pkg>/`.
|
|
27
|
+
*
|
|
28
|
+
* A project with no licence file is REFUSED here rather than packaged without
|
|
29
|
+
* one. Debian Policy § 12.5 makes the copyright file mandatory and lintian
|
|
30
|
+
* raises `no-copyright-file` as an error, but nothing between this function and
|
|
31
|
+
* the installed package says so: an empty overlay produced a `.deb` whose
|
|
32
|
+
* control file `dpkg-deb --info` prints cleanly, that `dpkg -i` installs, and
|
|
33
|
+
* that is simply unlicensed. That is what gjsify's own package was, every
|
|
34
|
+
* release of it, until the first CI leg that read one with a real `dpkg` said
|
|
35
|
+
* so on its eighth assertion.
|
|
36
|
+
*
|
|
37
|
+
* It throws on the host that can act on it. This runs during `--stage` as well
|
|
38
|
+
* as during a one-shot `ship`, so the refusal lands where the project — and the
|
|
39
|
+
* missing `LICENSE` — actually is; {@link assertOverlayIsLicensed} repeats it at
|
|
40
|
+
* pack time for a stage that arrived from somewhere else.
|
|
27
41
|
*/
|
|
28
42
|
export declare function planOverlay(settings: ShipSettings, format: FormatDescriptor, inputs: StageInputs): StagedFile[];
|
|
43
|
+
/**
|
|
44
|
+
* The same refusal, at pack time.
|
|
45
|
+
*
|
|
46
|
+
* `planOverlay` cannot cover `--from-stage`: that path never plans anything, it
|
|
47
|
+
* rehydrates the closure the assembling host wrote. A manifest whose
|
|
48
|
+
* `overlay.deb` is `[]` passes every structural check in `readStageManifest`
|
|
49
|
+
* — the key is present, the value is an array of the right shape — and the
|
|
50
|
+
* silent unlicensed package comes back. The stage manifest's own header lists
|
|
51
|
+
* "no `overlay`" among the omissions that fail at exit 0 with an artifact that
|
|
52
|
+
* installs; this is where that stops being a comment.
|
|
53
|
+
*
|
|
54
|
+
* Cheap enough to run on both paths, so it does, and a stage written by this
|
|
55
|
+
* version can no longer reach it.
|
|
56
|
+
*/
|
|
57
|
+
export declare function assertOverlayIsLicensed(formatId: FormatId, binaryName: string, overlay: readonly StagedFile[]): void;
|
|
29
58
|
/**
|
|
30
59
|
* The hicolor subdirectory for an icon: `scalable` for SVG, otherwise the
|
|
31
60
|
* pixel size read from a `<n>x<n>` path component or a trailing number in the
|
package/lib/utils/ship/plan.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// case (including the ones that must be REFUSED) is unit-testable. Same split
|
|
3
3
|
// as `utils/copy-targets.ts`, and for the same reason: a stager that discovers
|
|
4
4
|
// its own inputs can only be tested by building a real project.
|
|
5
|
+
import { renderMimePackage } from './mime.js';
|
|
5
6
|
import { basename, extname, posix } from 'node:path';
|
|
6
7
|
/**
|
|
7
8
|
* Build the prefix-relative payload.
|
|
@@ -65,6 +66,16 @@ export function planStage(settings, inputs) {
|
|
|
65
66
|
source: { kind: 'file', path: file },
|
|
66
67
|
});
|
|
67
68
|
}
|
|
69
|
+
// A shared-mime-info document, when the project DEFINES a type rather than only handling one.
|
|
70
|
+
// `share/mime/packages/` is shared between packages, so the app id is the filename for the same
|
|
71
|
+
// reason it is for the GSettings schema: a generic name collides with another package's file.
|
|
72
|
+
if (settings.mimeTypes.length > 0) {
|
|
73
|
+
files.push({
|
|
74
|
+
path: `share/mime/packages/${settings.appId}.xml`,
|
|
75
|
+
mode: 0o644,
|
|
76
|
+
source: { kind: 'text', text: renderMimePackage(settings.mimeTypes) },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
68
79
|
// Compiled gettext catalogues, layout preserved: `share/locale/<lang>/LC_MESSAGES/<domain>.mo`
|
|
69
80
|
// is where `bindtextdomain` looks, and nowhere else.
|
|
70
81
|
for (const locale of settings.localeFiles) {
|
|
@@ -83,15 +94,56 @@ export function planStage(settings, inputs) {
|
|
|
83
94
|
* The per-format additions. Only the licence differs today, and it differs in
|
|
84
95
|
* BOTH place and shape: Debian policy wants a machine-readable `copyright` in
|
|
85
96
|
* `share/doc/<pkg>/`, RPM wants the licence text under `share/licenses/<pkg>/`.
|
|
97
|
+
*
|
|
98
|
+
* A project with no licence file is REFUSED here rather than packaged without
|
|
99
|
+
* one. Debian Policy § 12.5 makes the copyright file mandatory and lintian
|
|
100
|
+
* raises `no-copyright-file` as an error, but nothing between this function and
|
|
101
|
+
* the installed package says so: an empty overlay produced a `.deb` whose
|
|
102
|
+
* control file `dpkg-deb --info` prints cleanly, that `dpkg -i` installs, and
|
|
103
|
+
* that is simply unlicensed. That is what gjsify's own package was, every
|
|
104
|
+
* release of it, until the first CI leg that read one with a real `dpkg` said
|
|
105
|
+
* so on its eighth assertion.
|
|
106
|
+
*
|
|
107
|
+
* It throws on the host that can act on it. This runs during `--stage` as well
|
|
108
|
+
* as during a one-shot `ship`, so the refusal lands where the project — and the
|
|
109
|
+
* missing `LICENSE` — actually is; {@link assertOverlayIsLicensed} repeats it at
|
|
110
|
+
* pack time for a stage that arrived from somewhere else.
|
|
86
111
|
*/
|
|
87
112
|
export function planOverlay(settings, format, inputs) {
|
|
88
|
-
if (inputs.licenseText === undefined)
|
|
89
|
-
|
|
113
|
+
if (inputs.licenseText === undefined) {
|
|
114
|
+
throw new Error(`gjsify ship: no licence file found, so the ${format.id} package would ship no ` +
|
|
115
|
+
`${format.licenseDest(settings.binaryName)}. Debian Policy § 12.5 requires it and lintian ` +
|
|
116
|
+
'errors without it, and neither dpkg nor rpm will tell you it is missing. Add a LICENSE file ' +
|
|
117
|
+
`to ${settings.projectDir} or to the root of its repository, or point ` +
|
|
118
|
+
'`gjsify.ship.licenseFile` at the one this package ships under.');
|
|
119
|
+
}
|
|
90
120
|
const text = format.licenseKind === 'debian-copyright'
|
|
91
121
|
? renderDebianCopyright(settings, inputs.licenseText)
|
|
92
122
|
: inputs.licenseText;
|
|
93
123
|
return [{ path: format.licenseDest(settings.binaryName), mode: 0o644, source: { kind: 'text', text } }];
|
|
94
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* The same refusal, at pack time.
|
|
127
|
+
*
|
|
128
|
+
* `planOverlay` cannot cover `--from-stage`: that path never plans anything, it
|
|
129
|
+
* rehydrates the closure the assembling host wrote. A manifest whose
|
|
130
|
+
* `overlay.deb` is `[]` passes every structural check in `readStageManifest`
|
|
131
|
+
* — the key is present, the value is an array of the right shape — and the
|
|
132
|
+
* silent unlicensed package comes back. The stage manifest's own header lists
|
|
133
|
+
* "no `overlay`" among the omissions that fail at exit 0 with an artifact that
|
|
134
|
+
* installs; this is where that stops being a comment.
|
|
135
|
+
*
|
|
136
|
+
* Cheap enough to run on both paths, so it does, and a stage written by this
|
|
137
|
+
* version can no longer reach it.
|
|
138
|
+
*/
|
|
139
|
+
export function assertOverlayIsLicensed(formatId, binaryName, overlay) {
|
|
140
|
+
if (overlay.length > 0)
|
|
141
|
+
return;
|
|
142
|
+
throw new Error(`gjsify ship: this stage carries no ${formatId} licence overlay, so the package would ship no ` +
|
|
143
|
+
`licence file for ${binaryName} — Debian Policy § 12.5 requires one and lintian errors without ` +
|
|
144
|
+
'it. The fix is on the assembling host, not this one: give that project a LICENSE (or set ' +
|
|
145
|
+
'`gjsify.ship.licenseFile`) and re-run the `--stage` phase.');
|
|
146
|
+
}
|
|
95
147
|
/**
|
|
96
148
|
* Debian machine-readable copyright, format 1.0. The licence text is indented
|
|
97
149
|
* by one space and its blank lines become ` .` — a bare blank line ends the
|
package/lib/utils/ship/rpm.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type {
|
|
1
|
+
import { type PayloadEntry } from './payload.js';
|
|
2
|
+
import type { PackSettings } from './types.js';
|
|
3
3
|
export interface RpmInputs {
|
|
4
|
-
settings:
|
|
4
|
+
settings: PackSettings;
|
|
5
5
|
payload: readonly PayloadEntry[];
|
|
6
6
|
prefix: string;
|
|
7
7
|
depends: readonly string[];
|
package/lib/utils/ship/rpm.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
|
|
|
15
15
|
import { concatBytes } from './bytes.js';
|
|
16
16
|
import { createCpioArchive, S_IFDIR, S_IFREG } from './cpio.js';
|
|
17
17
|
import { parseDepend } from './depends.js';
|
|
18
|
+
import { readPayloadFacts, readShebangInterpreters } from './payload.js';
|
|
18
19
|
import { buildRpmHeader, buildRpmLead, padToEight, RpmType, RPM_TAG_HEADERIMMUTABLE, RPM_TAG_HEADERSIGNATURES, } from './rpm-header.js';
|
|
19
20
|
import { gzipDeterministic } from './gzip.js';
|
|
20
21
|
import { renderRpmScriptlets } from './scripts.js';
|
|
@@ -80,6 +81,9 @@ const SENSE = {
|
|
|
80
81
|
INTERP: 1 << 8,
|
|
81
82
|
SCRIPT_POST: 1 << 10,
|
|
82
83
|
SCRIPT_POSTUN: 1 << 12,
|
|
84
|
+
// "This one was derived from the payload, not declared by the packager" —
|
|
85
|
+
// the sense `rpmbuild`'s own dependency generator uses (measured: 16384).
|
|
86
|
+
FIND_REQUIRES: 1 << 14,
|
|
83
87
|
RPMLIB: 1 << 24,
|
|
84
88
|
};
|
|
85
89
|
/** SHA-256 in rpm's digest-algorithm numbering (PGP hash algorithm ids). */
|
|
@@ -205,8 +209,9 @@ function mainHeaderEntries(inputs, files, cpio, compressed) {
|
|
|
205
209
|
dirIndexes.push(index);
|
|
206
210
|
baseNames.push(file.path.slice(slash + 1));
|
|
207
211
|
}
|
|
208
|
-
|
|
209
|
-
const
|
|
212
|
+
// From the PAYLOAD, not the settings — see the same call in `deb.ts`.
|
|
213
|
+
const scripts = renderRpmScriptlets(readPayloadFacts(inputs.payload), inputs.prefix);
|
|
214
|
+
const requires = buildRequires(inputs.depends, scripts, readShebangInterpreters(inputs.payload));
|
|
210
215
|
const entries = [
|
|
211
216
|
{ tag: TAG.HEADERI18NTABLE, type: RpmType.STRING_ARRAY, value: ['C'] },
|
|
212
217
|
{ tag: TAG.NAME, type: RpmType.STRING, value: settings.binaryName },
|
|
@@ -325,8 +330,15 @@ function assertParallelFileArrays(entries, fileCount) {
|
|
|
325
330
|
* A scriptlet's interpreter is a dependency like any other and rpm expects it
|
|
326
331
|
* declared with the matching `SCRIPT_*` sense bit — without it, a system
|
|
327
332
|
* without `/bin/sh` installs the package and then fails inside `%post`.
|
|
333
|
+
*
|
|
334
|
+
* The payload's own executables need the same treatment, and until the first
|
|
335
|
+
* real `rpm -qp --requires` ran against this writer nothing here said so: every
|
|
336
|
+
* `.rpm` it produced installed a `#!/bin/sh` launcher and declared no shell.
|
|
337
|
+
* The two senses are not redundant and rpm emits both — a package can need a
|
|
338
|
+
* shell to CONFIGURE (`Requires(post)`) and to RUN (a plain requirement), and
|
|
339
|
+
* dropping either is a different broken system.
|
|
328
340
|
*/
|
|
329
|
-
function buildRequires(depends, scripts) {
|
|
341
|
+
function buildRequires(depends, scripts, interpreters) {
|
|
330
342
|
const names = [];
|
|
331
343
|
const flags = [];
|
|
332
344
|
const versions = [];
|
|
@@ -346,6 +358,11 @@ function buildRequires(depends, scripts) {
|
|
|
346
358
|
flags.push(SENSE.INTERP | SENSE.SCRIPT_POSTUN);
|
|
347
359
|
versions.push('');
|
|
348
360
|
}
|
|
361
|
+
for (const interpreter of interpreters) {
|
|
362
|
+
names.push(interpreter);
|
|
363
|
+
flags.push(SENSE.FIND_REQUIRES);
|
|
364
|
+
versions.push('');
|
|
365
|
+
}
|
|
349
366
|
for (const [name, version] of RPMLIB_REQUIRES) {
|
|
350
367
|
names.push(name);
|
|
351
368
|
flags.push(SENSE.RPMLIB | SENSE.LESS | SENSE.EQUAL);
|