@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.
@@ -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 name of LICENSE_FILE_NAMES) {
201
- const target = join(projectDir, name);
202
- if (existsSync(target))
203
- return target;
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
- export const FORMAT_IDS = ['deb', 'rpm'];
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, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&apos;');
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 drop `--arch` and label the payload you actually have.');
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. */
@@ -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
@@ -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
- return [];
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
@@ -1,7 +1,7 @@
1
- import type { PayloadEntry } from './payload.js';
2
- import type { ShipSettings } from './types.js';
1
+ import { type PayloadEntry } from './payload.js';
2
+ import type { PackSettings } from './types.js';
3
3
  export interface RpmInputs {
4
- settings: ShipSettings;
4
+ settings: PackSettings;
5
5
  payload: readonly PayloadEntry[];
6
6
  prefix: string;
7
7
  depends: readonly string[];
@@ -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
- const scripts = renderRpmScriptlets(settings, inputs.prefix);
209
- const requires = buildRequires(inputs.depends, scripts);
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);