@gjsify/cli 0.41.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.
Files changed (80) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1068 -405
  3. package/lib/actions/build.d.ts +20 -0
  4. package/lib/actions/build.js +118 -15
  5. package/lib/bundler-pick.js +10 -2
  6. package/lib/cli-app.js +2 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/debug.js +1 -1
  9. package/lib/commands/dev.d.ts +13 -0
  10. package/lib/commands/dev.js +132 -0
  11. package/lib/commands/dlx.js +1 -1
  12. package/lib/commands/index.d.ts +1 -0
  13. package/lib/commands/index.js +1 -0
  14. package/lib/commands/run.js +38 -85
  15. package/lib/commands/ship.d.ts +2 -0
  16. package/lib/commands/ship.js +285 -88
  17. package/lib/commands/showcase.js +1 -1
  18. package/lib/commands/storybook.js +17 -74
  19. package/lib/commands/test.js +12 -16
  20. package/lib/commands/tsc.js +96 -45
  21. package/lib/config.js +4 -0
  22. package/lib/types/config-data.d.ts +59 -0
  23. package/lib/utils/app-metadata.js +27 -6
  24. package/lib/utils/check-system-deps.js +49 -7
  25. package/lib/utils/daemon-command.d.ts +7 -0
  26. package/lib/utils/daemon-command.js +30 -0
  27. package/lib/utils/dev-plan.d.ts +52 -0
  28. package/lib/utils/dev-plan.js +117 -0
  29. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  30. package/lib/utils/gi-runtime-paths.js +84 -0
  31. package/lib/utils/gi-typelib.d.ts +49 -0
  32. package/lib/utils/gi-typelib.js +160 -0
  33. package/lib/utils/gjs-bundle-guard.d.ts +57 -3
  34. package/lib/utils/gjs-bundle-guard.js +122 -13
  35. package/lib/utils/jsx-config.d.ts +45 -0
  36. package/lib/utils/jsx-config.js +98 -0
  37. package/lib/utils/node-script.d.ts +4 -1
  38. package/lib/utils/node-script.js +27 -3
  39. package/lib/utils/npm-oidc.js +8 -5
  40. package/lib/utils/onboard-probe.d.ts +23 -0
  41. package/lib/utils/onboard-probe.js +65 -0
  42. package/lib/utils/run-gjs.d.ts +12 -1
  43. package/lib/utils/run-gjs.js +24 -31
  44. package/lib/utils/run-node.d.ts +9 -4
  45. package/lib/utils/run-node.js +34 -41
  46. package/lib/utils/ship/deb.d.ts +3 -3
  47. package/lib/utils/ship/deb.js +6 -2
  48. package/lib/utils/ship/depends.d.ts +14 -6
  49. package/lib/utils/ship/depends.js +45 -4
  50. package/lib/utils/ship/discover.js +178 -7
  51. package/lib/utils/ship/formats.js +9 -1
  52. package/lib/utils/ship/launcher.js +27 -4
  53. package/lib/utils/ship/mime.d.ts +19 -0
  54. package/lib/utils/ship/mime.js +89 -0
  55. package/lib/utils/ship/payload.d.ts +111 -0
  56. package/lib/utils/ship/payload.js +174 -0
  57. package/lib/utils/ship/plan.d.ts +30 -1
  58. package/lib/utils/ship/plan.js +74 -2
  59. package/lib/utils/ship/rpm.d.ts +3 -3
  60. package/lib/utils/ship/rpm.js +20 -3
  61. package/lib/utils/ship/scripts.d.ts +14 -4
  62. package/lib/utils/ship/scripts.js +26 -8
  63. package/lib/utils/ship/settings.d.ts +8 -1
  64. package/lib/utils/ship/settings.js +26 -1
  65. package/lib/utils/ship/stage-manifest.d.ts +154 -0
  66. package/lib/utils/ship/stage-manifest.js +408 -0
  67. package/lib/utils/ship/stage-writer.d.ts +30 -3
  68. package/lib/utils/ship/stage-writer.js +76 -9
  69. package/lib/utils/ship/types.d.ts +110 -18
  70. package/lib/utils/ship/types.js +5 -0
  71. package/lib/utils/simple-command.d.ts +16 -0
  72. package/lib/utils/simple-command.js +77 -0
  73. package/lib/utils/spawn.d.ts +7 -1
  74. package/lib/utils/spawn.js +34 -7
  75. package/lib/utils/system-gi.d.ts +24 -0
  76. package/lib/utils/system-gi.js +14 -3
  77. package/lib/utils/watch-loop.d.ts +91 -0
  78. package/lib/utils/watch-loop.js +207 -0
  79. package/package.json +19 -18
  80. package/lib/templates/app/desktop.tmpl +0 -10
@@ -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, '&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
  *
@@ -28,3 +104,38 @@ export declare function buildTimestamp(bundlePath: string, env?: Record<string,
28
104
  * does not run.
29
105
  */
30
106
  export declare function isArchIndependent(payload: readonly PayloadEntry[]): boolean;
107
+ /**
108
+ * The `process.arch` token an image says it is built for, or `null` when the
109
+ * question cannot be answered from the bytes.
110
+ *
111
+ * `null` covers three different things on purpose, and all three must stay
112
+ * silent: a file that is not a native binary at all (most of a payload), a PE
113
+ * (whose COFF machine field this tree has never parsed — `isNativeBinary` reads
114
+ * `MZ` and stops), and a Mach-O fat archive, which carries several
115
+ * architectures and therefore matches any label a caller could pass.
116
+ */
117
+ export declare function readBinaryArch(data: Uint8Array): string | null;
118
+ /**
119
+ * Refuse a payload whose binaries disagree with the label the package will carry.
120
+ *
121
+ * THE INCIDENT, measured on 0.41.0 before this existed. A project whose payload
122
+ * carries one x86-64 `.so`, packed on this x86-64 host:
123
+ *
124
+ * gjsify ship --skip-build --arch arm64
125
+ * → xarch-demo_1.2.3-1_arm64.deb, xarch-demo-1.2.3-1.aarch64.rpm
126
+ * rpm -qp --qf '%{ARCH}' → aarch64
127
+ * the .so inside it → ELF e_machine 0x3e (x86-64)
128
+ *
129
+ * `--arch` LABELS the payload; it does not cross-compile it, and nothing
130
+ * compared the two. The result installs on an arm64 machine — apt and dnf both
131
+ * believe the header — and then fails to load, which is this tree's most
132
+ * expensive failure class with an independent oracle actively confirming the
133
+ * lie: `rpm` reads the header, and the header was written from the caller's
134
+ * claim.
135
+ *
136
+ * Payload against LABEL, never payload against HOST. Assembling an arm64
137
+ * artifact on an x64 machine is a supported path — the packers are pure
138
+ * JavaScript and ADR 0024 § A1 turns it into a design commitment — so a host
139
+ * comparison would refuse the very case the split exists to allow.
140
+ */
141
+ export declare function assertPayloadMatchesArch(payload: readonly PayloadEntry[], arch: string): void;
@@ -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
  *
@@ -40,6 +112,108 @@ export function buildTimestamp(bundlePath, env = process.env) {
40
112
  export function isArchIndependent(payload) {
41
113
  return !payload.some((entry) => isNativeBinary(entry.data));
42
114
  }
115
+ // `process.arch` tokens, keyed by what the image records about itself. Only the
116
+ // values this repository can actually produce are listed; an unknown one reads
117
+ // as "cannot tell" rather than as a mismatch, because refusing an artifact over
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.
136
+ const ELF_MACHINE_TO_ARCH = {
137
+ 0x03: 'ia32',
138
+ 0x15: 'ppc64',
139
+ 0x16: 's390x',
140
+ 0x28: 'arm',
141
+ 0x3e: 'x64',
142
+ 0xb7: 'arm64',
143
+ 0xf3: 'riscv64',
144
+ };
145
+ const MACHO_CPUTYPE_TO_ARCH = {
146
+ 0x00000007: 'ia32',
147
+ 0x0000000c: 'arm',
148
+ 0x01000007: 'x64',
149
+ 0x0100000c: 'arm64',
150
+ };
151
+ /**
152
+ * The `process.arch` token an image says it is built for, or `null` when the
153
+ * question cannot be answered from the bytes.
154
+ *
155
+ * `null` covers three different things on purpose, and all three must stay
156
+ * silent: a file that is not a native binary at all (most of a payload), a PE
157
+ * (whose COFF machine field this tree has never parsed — `isNativeBinary` reads
158
+ * `MZ` and stops), and a Mach-O fat archive, which carries several
159
+ * architectures and therefore matches any label a caller could pass.
160
+ */
161
+ export function readBinaryArch(data) {
162
+ if (data.byteLength < 20)
163
+ return null;
164
+ const magic = ((data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]) >>> 0;
165
+ if (magic === 0x7f454c46) {
166
+ // ELF: EI_DATA at offset 5 says which end e_machine (offset 18) is written from.
167
+ const littleEndian = data[5] === 1;
168
+ const machine = littleEndian ? data[18] | (data[19] << 8) : (data[18] << 8) | data[19];
169
+ return ELF_MACHINE_TO_ARCH[machine] ?? null;
170
+ }
171
+ if (magic === 0xfeedface || magic === 0xfeedfacf) {
172
+ return MACHO_CPUTYPE_TO_ARCH[((data[4] << 24) | (data[5] << 16) | (data[6] << 8) | data[7]) >>> 0] ?? null;
173
+ }
174
+ if (magic === 0xcefaedfe || magic === 0xcffaedfe) {
175
+ return MACHO_CPUTYPE_TO_ARCH[((data[7] << 24) | (data[6] << 16) | (data[5] << 8) | data[4]) >>> 0] ?? null;
176
+ }
177
+ return null;
178
+ }
179
+ /**
180
+ * Refuse a payload whose binaries disagree with the label the package will carry.
181
+ *
182
+ * THE INCIDENT, measured on 0.41.0 before this existed. A project whose payload
183
+ * carries one x86-64 `.so`, packed on this x86-64 host:
184
+ *
185
+ * gjsify ship --skip-build --arch arm64
186
+ * → xarch-demo_1.2.3-1_arm64.deb, xarch-demo-1.2.3-1.aarch64.rpm
187
+ * rpm -qp --qf '%{ARCH}' → aarch64
188
+ * the .so inside it → ELF e_machine 0x3e (x86-64)
189
+ *
190
+ * `--arch` LABELS the payload; it does not cross-compile it, and nothing
191
+ * compared the two. The result installs on an arm64 machine — apt and dnf both
192
+ * believe the header — and then fails to load, which is this tree's most
193
+ * expensive failure class with an independent oracle actively confirming the
194
+ * lie: `rpm` reads the header, and the header was written from the caller's
195
+ * claim.
196
+ *
197
+ * Payload against LABEL, never payload against HOST. Assembling an arm64
198
+ * artifact on an x64 machine is a supported path — the packers are pure
199
+ * JavaScript and ADR 0024 § A1 turns it into a design commitment — so a host
200
+ * comparison would refuse the very case the split exists to allow.
201
+ */
202
+ export function assertPayloadMatchesArch(payload, arch) {
203
+ for (const entry of payload) {
204
+ const found = readBinaryArch(entry.data);
205
+ if (found === null || found === arch)
206
+ continue;
207
+ throw new Error(`gjsify ship: the payload is ${found}, but the package would be labelled ${arch} — ` +
208
+ `${entry.path} is built for ${found}.\n` +
209
+ ' `--arch` names the architecture the PAYLOAD was built for; it does not cross-compile ' +
210
+ 'anything.\n' +
211
+ ` A package labelled ${arch} installs on ${arch} and then fails to load. Build the payload ` +
212
+ `for ${arch}\n` +
213
+ ' (its own prebuild), or assemble the stage without `--arch` and label the payload you ' +
214
+ 'actually have.');
215
+ }
216
+ }
43
217
  /** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
44
218
  function isNativeBinary(data) {
45
219
  if (data.byteLength < 4)
@@ -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.
@@ -54,6 +55,36 @@ export function planStage(settings, inputs) {
54
55
  source: { kind: 'file', path: schema },
55
56
  });
56
57
  }
58
+ // The typelibs the package carries itself, beside the bundle in `gi/`. The launcher points
59
+ // GI_TYPELIB_PATH and LD_LIBRARY_PATH here — see renderLauncher.
60
+ for (const file of settings.typelibFiles) {
61
+ files.push({
62
+ path: posix.join(libDir, 'gi', basename(file)),
63
+ // A shared library must be executable; a typelib need not be, but one mode for both
64
+ // keeps the staged tree free of a distinction nothing downstream reads.
65
+ mode: 0o755,
66
+ source: { kind: 'file', path: file },
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
+ }
79
+ // Compiled gettext catalogues, layout preserved: `share/locale/<lang>/LC_MESSAGES/<domain>.mo`
80
+ // is where `bindtextdomain` looks, and nowhere else.
81
+ for (const locale of settings.localeFiles) {
82
+ files.push({
83
+ path: posix.join('share/locale', locale.rel),
84
+ mode: 0o644,
85
+ source: { kind: 'file', path: locale.abs },
86
+ });
87
+ }
57
88
  for (const [dest, source] of Object.entries(settings.extraFiles)) {
58
89
  files.push({ path: assertInsidePrefix(dest), mode: 0o644, source: { kind: 'file', path: source } });
59
90
  }
@@ -63,15 +94,56 @@ export function planStage(settings, inputs) {
63
94
  * The per-format additions. Only the licence differs today, and it differs in
64
95
  * BOTH place and shape: Debian policy wants a machine-readable `copyright` in
65
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.
66
111
  */
67
112
  export function planOverlay(settings, format, inputs) {
68
- if (inputs.licenseText === undefined)
69
- 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
+ }
70
120
  const text = format.licenseKind === 'debian-copyright'
71
121
  ? renderDebianCopyright(settings, inputs.licenseText)
72
122
  : inputs.licenseText;
73
123
  return [{ path: format.licenseDest(settings.binaryName), mode: 0o644, source: { kind: 'text', text } }];
74
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
+ }
75
147
  /**
76
148
  * Debian machine-readable copyright, format 1.0. The licence text is indented
77
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);