@gjsify/cli 0.47.0 → 0.48.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.
@@ -1,10 +1,24 @@
1
1
  // Which GI namespaces does this bundle actually load?
2
2
  //
3
- // Read off the emitted `--app gjs` bundle, because that is the file that gets
4
- // installed. `gi://` is a real module protocol under GJS, so the bundler keeps
5
- // those specifiers in the output verbatim (`rolldown-plugin-gjsify`'s externals
6
- // plugin) — the artifact carries its own dependency list and nothing has to be
7
- // declared twice.
3
+ // Read off the EMITTED bundle, because that is the file that gets installed —
4
+ // and the emitted form is not one form but two, which is the correction this
5
+ // header carries. Under `--app gjs`, `gi://` is a real module protocol, so the
6
+ // bundler keeps those specifiers in the output verbatim
7
+ // (`rolldown-plugin-gjsify`'s externals plugin) and the artifact carries its own
8
+ // dependency list. Under `--app node` there is no such protocol:
9
+ // `gjsGiNodePlugin` rewrites every `gi://Ns?version=X` into a shim whose body is
10
+ // `require('@gjsify/node-gi/gi').requireGi("Ns", "X")`, so the specifier this
11
+ // scanner was written to find is gone from the file by construction.
12
+ //
13
+ // WHAT THAT COST, and why reading one form is not "mostly right": these
14
+ // namespaces are what `depends.ts` maps to `gir1.2-gtk-4.0` and friends
15
+ // (ADR 0024 § 6). A `--app node` project therefore derived an EMPTY typelib
16
+ // dependency set — the package installed cleanly and died at its first GI call,
17
+ // which is the same failure the bare-side-effect-import fix below was written to
18
+ // prevent, arriving again through the other build target. Nothing caught it
19
+ // because every ship fixture's "node bundle" was hand-written with `gi://`
20
+ // imports in it: a shape `gjsify build --app node` cannot emit and node cannot
21
+ // run.
8
22
  //
9
23
  // PARSED, not pattern-matched, and the first version of this file is why. A
10
24
  // regex over the bundle text got it wrong in both directions at once:
@@ -17,18 +31,65 @@
17
31
  // `from`, and since an unmapped namespace fails the build, that made a
18
32
  // correct project unbuildable.
19
33
  //
20
- // `moduleSpecifiers` answers the question exactly, and it is the same acorn
21
- // pass the CLI already uses to compute its own runtime closure — so there is
22
- // one definition of "what does this file import" rather than two.
23
- import { moduleSpecifiers } from '../cli-runtime-closure.js';
34
+ // `walkModuleAst` answers both questions in ONE pass, and it is the same acorn
35
+ // pass the CLI already uses to compute its own runtime closure — so there is one
36
+ // definition of "what does this file import" rather than two.
37
+ import { importedSpecifier, staticStringValue, walkModuleAst, } from '../cli-runtime-closure.js';
38
+ /** The module a `--app node` bundle reaches GI through. */
39
+ const NODE_GI_MODULE = '@gjsify/node-gi/gi';
40
+ /** Namespaces are GI identifiers; anything else is a call this reader misread. */
41
+ const NAMESPACE = /^[A-Za-z][A-Za-z\d_]*$/;
24
42
  /**
25
43
  * Extract the GI namespaces a bundle imports, as `Ns-Version` when the
26
44
  * specifier pins one and bare `Ns` when it does not.
27
45
  */
28
46
  export function scanGiNamespaces(source) {
29
47
  const found = new Set();
30
- for (const specifier of moduleSpecifiers(source)) {
31
- const key = parseGiSpecifier(specifier);
48
+ // TWO SETS, because the two kinds of binding are not interchangeable and
49
+ // treating them as one over-approximated on a real minified bundle. A
50
+ // namespace object (`import * as gi`) and a required module object are only
51
+ // ever the OBJECT of `.requireGi(…)`: calling a module namespace is a
52
+ // TypeError, so a bare `gi(…)` is never one of ours. Only `requireGi` itself
53
+ // — the named import, or the default export, which IS `requireGi` — is
54
+ // callable on its own.
55
+ //
56
+ // Measured on `gjsify build --app node` (minify is the DEFAULT): a file with
57
+ // `import * as gi from '@gjsify/node-gi/gi'` and a callback parameter emitted
58
+ // as `function render(e,t){return e(t)+e(\`Zzqfoo\`)}` — where the minifier
59
+ // gave the import and the parameter the SAME short name in different scopes.
60
+ // Read as one set, `e(\`Zzqfoo\`)` became the namespace `Zzqfoo`, and
61
+ // `deriveDepends` then refused to package a correct project.
62
+ const callable = new Set();
63
+ const objects = new Set();
64
+ const calls = [];
65
+ walkModuleAst(source, (node) => {
66
+ const specifier = importedSpecifier(node);
67
+ if (specifier !== null) {
68
+ const key = parseGiSpecifier(specifier);
69
+ if (key !== null)
70
+ found.add(key);
71
+ if (node.type === 'ImportDeclaration' && specifier === NODE_GI_MODULE) {
72
+ collectNodeGiBindings(node, callable, objects);
73
+ }
74
+ return;
75
+ }
76
+ if (node.type === 'VariableDeclarator' && loadsNodeGiModule(node.init)) {
77
+ const id = node.id;
78
+ // An OBJECT: `const gi = require('@gjsify/node-gi/gi')` is the module,
79
+ // reached as `gi.requireGi(…)` and never called directly.
80
+ if (id?.type === 'Identifier' && typeof id.name === 'string')
81
+ objects.add(id.name);
82
+ return;
83
+ }
84
+ // Collected rather than resolved in place: a call can precede the
85
+ // `require` that binds its callee (the shim's `load()` is hoisted above
86
+ // nothing, but a minifier is free to reorder declarations), so the
87
+ // binding set has to be complete before any call is judged.
88
+ if (node.type === 'CallExpression')
89
+ calls.push(node);
90
+ });
91
+ for (const call of calls) {
92
+ const key = requireGiNamespace(call, callable, objects);
32
93
  if (key !== null)
33
94
  found.add(key);
34
95
  }
@@ -40,10 +101,112 @@ export function parseGiSpecifier(specifier) {
40
101
  return null;
41
102
  const rest = specifier.slice('gi://'.length);
42
103
  const [namespace, query] = rest.split('?', 2);
43
- if (namespace === undefined || !/^[A-Za-z][A-Za-z0-9_]*$/.test(namespace))
104
+ if (namespace === undefined || !NAMESPACE.test(namespace))
44
105
  return null;
45
106
  if (query === undefined)
46
107
  return namespace;
47
108
  const version = /(?:^|&)version=([^&]+)/.exec(query)?.[1];
48
109
  return version === undefined ? namespace : `${namespace}-${version}`;
49
110
  }
111
+ /**
112
+ * What an `import … from '@gjsify/node-gi/gi'` binds, split by what it can BE.
113
+ *
114
+ * All three spellings appear in real code and the default export IS `requireGi`
115
+ * (`packages/node-gi/node-gi/gi.d.ts`), so the named import (under any local
116
+ * name — `import { requireGi as gi }` renames it) and the default import are
117
+ * CALLABLE. A namespace import is not: calling a module namespace object throws,
118
+ * so it can only ever be the object of `.requireGi(…)`, and putting it in the
119
+ * callable set is what let a minified callback parameter of the same name be
120
+ * read as a `requireGi` call.
121
+ */
122
+ function collectNodeGiBindings(node, callable, objects) {
123
+ const specifiers = node.specifiers ?? [];
124
+ for (const specifier of specifiers) {
125
+ const local = specifier.local;
126
+ if (local?.type !== 'Identifier' || typeof local.name !== 'string')
127
+ continue;
128
+ const imported = specifier.imported;
129
+ if (specifier.type === 'ImportNamespaceSpecifier')
130
+ objects.add(local.name);
131
+ else if (specifier.type === 'ImportDefaultSpecifier')
132
+ callable.add(local.name);
133
+ else if (imported?.type === 'Identifier' && imported.name === 'requireGi')
134
+ callable.add(local.name);
135
+ }
136
+ }
137
+ /**
138
+ * A call that LOADS `@gjsify/node-gi/gi` — recognised by its argument, never by
139
+ * the name of the callee.
140
+ *
141
+ * THE CALLEE NAME DOES NOT SURVIVE BUNDLING, and reading it is how the first cut
142
+ * of this function passed its own tests while answering `[]` for every real
143
+ * artifact. Measured on `gjsify build --app node` output for a two-import app,
144
+ * which is the exact input this module exists to read:
145
+ *
146
+ * unminified const require$1 = createRequire(import.meta.url);
147
+ * … require$1("@gjsify/node-gi/gi").requireGi("Gtk", "4.0")
148
+ * minified const n = e(import.meta.url);
149
+ * … n(`@gjsify/node-gi/gi`).requireGi(`Gtk`, `4.0`)
150
+ *
151
+ * Two shims in one bundle means two `require` bindings, so the second is renamed
152
+ * even without `--minify` (which is the DEFAULT). A reader keyed on the
153
+ * identifier `require` therefore loses one namespace unminified and both
154
+ * minified — silently, since an empty namespace list is a legal answer.
155
+ *
156
+ * The module STRING is what survives every rename, and it is a precise
157
+ * discriminator: a call taking `'@gjsify/node-gi/gi'` as its first argument is a
158
+ * load of that module under any name a bundler gives the loader.
159
+ */
160
+ function loadsNodeGiModule(node) {
161
+ if (!node || typeof node !== 'object')
162
+ return false;
163
+ const call = node;
164
+ if (call.type !== 'CallExpression')
165
+ return false;
166
+ const args = call.arguments ?? [];
167
+ return staticStringValue(args[0]) === NODE_GI_MODULE;
168
+ }
169
+ /**
170
+ * The namespace a `requireGi(…)` call names, or `null` when this is not one.
171
+ *
172
+ * BINDING-TRACED rather than name-matched, and the asymmetry is deliberate: a
173
+ * bare `.requireGi(…)` on any object would over-approximate, and
174
+ * over-approximating is NOT the harmless direction here — an unmapped namespace
175
+ * fails the build, so a foreign method sharing the name would make a correct
176
+ * project unpackageable. Every accepted shape traces back to an import or
177
+ * require of `@gjsify/node-gi/gi` in this same file, which is what a bundle
178
+ * always has: the shim carries its own `require`, and a hand-written node-gi app
179
+ * carries its own import.
180
+ *
181
+ * Shadowing is the one gap left, and the split above is what makes it small.
182
+ * This reader has no scope analysis, so a local binding that reuses the name of
183
+ * the CALLABLE import — `requireGi` itself, or the default import — and is called
184
+ * with a GI-shaped string literal would still be read as one. A minifier can
185
+ * manufacture exactly that collision, which is why the two shapes it produces
186
+ * most (the namespace object and the required module object) are the two that no
187
+ * longer count as callees.
188
+ */
189
+ function requireGiNamespace(call, callable, objects) {
190
+ const callee = call.callee;
191
+ let reached = false;
192
+ if (callee?.type === 'Identifier') {
193
+ reached = typeof callee.name === 'string' && callable.has(callee.name);
194
+ }
195
+ else if (callee?.type === 'MemberExpression' && callee.computed !== true) {
196
+ const property = callee.property;
197
+ if (property?.type !== 'Identifier' || property.name !== 'requireGi')
198
+ return null;
199
+ const object = callee.object;
200
+ reached =
201
+ loadsNodeGiModule(object) ||
202
+ (object?.type === 'Identifier' && typeof object.name === 'string' && objects.has(object.name));
203
+ }
204
+ if (!reached)
205
+ return null;
206
+ const args = call.arguments ?? [];
207
+ const namespace = staticStringValue(args[0]);
208
+ if (namespace === null || !NAMESPACE.test(namespace))
209
+ return null;
210
+ const version = staticStringValue(args[1]);
211
+ return version === null ? namespace : `${namespace}-${version}`;
212
+ }
@@ -11,6 +11,10 @@ export interface StageInputs {
11
11
  desktopEntry?: string;
12
12
  /** Contents of the project's licence file, when one was found. */
13
13
  licenseText?: string;
14
+ /** Contents of the project's own changelog, when one was found. */
15
+ changelogText?: string;
16
+ /** The build stamp every header and every rendered file shares — the changelog's date. */
17
+ mtime: number;
14
18
  }
15
19
  /**
16
20
  * Build the prefix-relative payload.
@@ -46,7 +50,7 @@ export declare function planStage(settings: ShipSettings, inputs: StageInputs):
46
50
  * missing `LICENSE` — actually is; {@link assertOverlayIsLicensed} repeats it at
47
51
  * pack time for a stage that arrived from somewhere else.
48
52
  */
49
- export declare function planOverlay(settings: ShipSettings, format: FormatDescriptor, inputs: StageInputs): StagedFile[];
53
+ export declare function planOverlay(settings: ShipSettings, format: FormatDescriptor, inputs: StageInputs): Promise<StagedFile[]>;
50
54
  /**
51
55
  * The same refusal, at pack time.
52
56
  *
@@ -2,6 +2,8 @@
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 { renderDebianChangelog } from './changelog.js';
6
+ import { gzipDeterministic } from './gzip.js';
5
7
  import { renderMimePackage } from './mime.js';
6
8
  import { SHARE } from './share-dirs.js';
7
9
  import { basename, extname, posix } from 'node:path';
@@ -152,7 +154,7 @@ function planFonts(settings) {
152
154
  * missing `LICENSE` — actually is; {@link assertOverlayIsLicensed} repeats it at
153
155
  * pack time for a stage that arrived from somewhere else.
154
156
  */
155
- export function planOverlay(settings, format, inputs) {
157
+ export async function planOverlay(settings, format, inputs) {
156
158
  if (inputs.licenseText === undefined) {
157
159
  throw new Error(`gjsify ship: no licence file found, so the ${format.id} package would ship no ` +
158
160
  `${format.licenseDest(settings.binaryName)}. Debian Policy § 12.5 requires it and lintian ` +
@@ -163,7 +165,24 @@ export function planOverlay(settings, format, inputs) {
163
165
  const text = format.licenseKind === 'debian-copyright'
164
166
  ? renderDebianCopyright(settings, inputs.licenseText)
165
167
  : inputs.licenseText;
166
- return [{ path: format.licenseDest(settings.binaryName), mode: 0o644, source: { kind: 'text', text } }];
168
+ const files = [
169
+ { path: format.licenseDest(settings.binaryName), mode: 0o644, source: { kind: 'text', text } },
170
+ ];
171
+ // COMPRESSED HERE, not at pack time, and that is what makes the two-phase
172
+ // handoff sound: `--from-stage` rehydrates the overlay out of the sidecar and
173
+ // packs it, so the assembling host's bytes have to BE the shipped bytes. A
174
+ // gzip performed on the packing host would put a second compressor's output in
175
+ // an artifact whose every other byte came from the first. `gzipDeterministic`
176
+ // zeroes the header stamp for the usual reason (utils/ship/gzip.ts).
177
+ if (format.changelogDest !== undefined) {
178
+ const changelog = renderDebianChangelog(settings, inputs.changelogText, inputs.mtime);
179
+ files.push({
180
+ path: format.changelogDest(settings.binaryName),
181
+ mode: 0o644,
182
+ source: { kind: 'bytes', data: await gzipDeterministic(new TextEncoder().encode(changelog)) },
183
+ });
184
+ }
185
+ return files;
167
186
  }
168
187
  /**
169
188
  * The same refusal, at pack time.
@@ -37,6 +37,7 @@ export interface DiscoveredPayload {
37
37
  /** Absolute paths of the font faces the application ships itself. */
38
38
  fontFiles: string[];
39
39
  licenseFile?: string;
40
+ changelogFile?: string;
40
41
  }
41
42
  export interface SettingsInput {
42
43
  projectDir: string;
@@ -144,13 +145,58 @@ export interface ResolvedSettings {
144
145
  warnings: string[];
145
146
  }
146
147
  export declare function resolveShipSettings(input: SettingsInput): ResolvedSettings;
147
- /** The bundle path a project declares, in the order the rest of the CLI reads them. */
148
- export declare function declaredBundlePath(pkg: ShipPackageManifest, ship: ConfigDataShip): string | undefined;
148
+ /** The entry one ship target installs, which key named it, and what it must NOT carry. */
149
+ export interface ResolvedShipBundle {
150
+ /** The declared path as written — project-relative or absolute. */
151
+ declared?: string;
152
+ /** The config key the answer came from — named in the refusal and in the notice. */
153
+ key: string;
154
+ /** Whether a per-target key overrode the project default. */
155
+ overridden: boolean;
156
+ /**
157
+ * The entries OTHER targets declared, as written.
158
+ *
159
+ * Not decoration: `discoverPayload` stages the whole directory the entry
160
+ * lives in, so a project that keeps `dist/app.gjs.mjs` beside
161
+ * `dist/app.node.mjs` ships both to both targets. One of them is a bundle
162
+ * that target's interpreter cannot load, in an artifact advertising that it
163
+ * can — and on macOS it is inside the signed bundle.
164
+ */
165
+ foreign: string[];
166
+ }
167
+ /**
168
+ * The ONE place a ship target's ENTRY is decided — {@link resolveShipApp}'s twin.
169
+ *
170
+ * The two are twins because they answer one question between them, and #1545 is
171
+ * what answering only half of it costs: `gjsify.ship.app.darwin: "node"` moved
172
+ * the launcher to `node` while the payload stayed `gjsify.main`, which for a
173
+ * project whose primary target is Linux is its `--app gjs` bundle. Both keys
174
+ * were honoured, they contradicted each other, and the `.app` died on its first
175
+ * import with the only line of output on the subject reading as confirmation.
176
+ *
177
+ * A DECLARATION rather than a derivation, and both alternatives were considered
178
+ * where the issue raised them. Deriving the entry from the runtime by filename
179
+ * convention (`app.node.mjs` beside `app.gjs.mjs`) is a heuristic on an output
180
+ * name the project owns — the same guess `tests/e2e/ship`'s fixture exists to
181
+ * refuse. Having ship invoke `gjsify build --app <runtime>` itself cannot work
182
+ * for the phase that needs it most: `--from-stage` packs on a host with no
183
+ * project, no bundler and no sources (ADR 0024 § A2).
184
+ *
185
+ * Fall-through, not requirement: a target with no key keeps `gjsify.main`, so
186
+ * every single-host project is unaffected and nothing new is mandatory.
187
+ */
188
+ export declare function resolveShipBundle(input: {
189
+ pkg: ShipPackageManifest;
190
+ ship: ConfigDataShip;
191
+ layoutOs: HostOs;
192
+ }): ResolvedShipBundle;
149
193
  /** npm package name → a package/binary name both dpkg and rpm accept. */
150
194
  export declare function deriveBinaryName(pkgName: string | undefined): string;
151
195
  /** AppStream description (string or blocks) → plain paragraphs for deb/rpm. */
152
196
  export declare function descriptionParagraphs(description: string | Array<DescriptionBlock | string> | undefined): string[] | undefined;
153
197
  /** `LICENSE`, `LICENSE.md`, … — the names a licence file actually has. */
154
198
  export declare const LICENSE_FILE_NAMES: readonly ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "COPYING.md"];
199
+ /** `CHANGELOG.md`, … — the names a project's own changelog actually has. */
200
+ export declare const CHANGELOG_FILE_NAMES: readonly ["CHANGELOG.md", "CHANGELOG", "CHANGELOG.txt", "NEWS.md", "NEWS"];
155
201
  /** The bundle's own basename, used as the launcher's exec target. */
156
202
  export declare function bundleEntryName(bundlePath: string): string;
@@ -177,6 +177,7 @@ export function resolveShipSettings(input) {
177
177
  description,
178
178
  license,
179
179
  licenseFile: discovered.licenseFile,
180
+ changelogFile: discovered.changelogFile,
180
181
  homepage: metadata.homepageUrl ?? pkg.homepage,
181
182
  section: ship.section ?? sections.section,
182
183
  group: ship.group ?? sections.group,
@@ -203,9 +204,66 @@ export function resolveShipSettings(input) {
203
204
  };
204
205
  return { settings, metadata, warnings };
205
206
  }
206
- /** The bundle path a project declares, in the order the rest of the CLI reads them. */
207
- export function declaredBundlePath(pkg, ship) {
208
- return ship.bundle ?? pkg.gjsify?.main ?? pkg.main;
207
+ /**
208
+ * The ONE place a ship target's ENTRY is decided — {@link resolveShipApp}'s twin.
209
+ *
210
+ * The two are twins because they answer one question between them, and #1545 is
211
+ * what answering only half of it costs: `gjsify.ship.app.darwin: "node"` moved
212
+ * the launcher to `node` while the payload stayed `gjsify.main`, which for a
213
+ * project whose primary target is Linux is its `--app gjs` bundle. Both keys
214
+ * were honoured, they contradicted each other, and the `.app` died on its first
215
+ * import with the only line of output on the subject reading as confirmation.
216
+ *
217
+ * A DECLARATION rather than a derivation, and both alternatives were considered
218
+ * where the issue raised them. Deriving the entry from the runtime by filename
219
+ * convention (`app.node.mjs` beside `app.gjs.mjs`) is a heuristic on an output
220
+ * name the project owns — the same guess `tests/e2e/ship`'s fixture exists to
221
+ * refuse. Having ship invoke `gjsify build --app <runtime>` itself cannot work
222
+ * for the phase that needs it most: `--from-stage` packs on a host with no
223
+ * project, no bundler and no sources (ADR 0024 § A2).
224
+ *
225
+ * Fall-through, not requirement: a target with no key keeps `gjsify.main`, so
226
+ * every single-host project is unaffected and nothing new is mandatory.
227
+ */
228
+ export function resolveShipBundle(input) {
229
+ const declaredTable = input.ship.bundle;
230
+ const perTarget = typeof declaredTable === 'object' && declaredTable !== null ? declaredTable : {};
231
+ // The WHOLE table, for the reason `resolveShipApp` gives: a mis-keyed
232
+ // override is silent by construction, so the run that must report `windows`
233
+ // is the one assembling some OTHER layout.
234
+ for (const os of Object.keys(perTarget)) {
235
+ if (Object.hasOwn(SHIP_APP_OSES, os))
236
+ continue;
237
+ throw new Error(`gjsify ship: \`gjsify.ship.bundle.${os}\` is not an OS this command assembles for. Known: ` +
238
+ `${Object.keys(SHIP_APP_OSES).join(', ')} — the \`process.platform\` spelling, not the ` +
239
+ "`gjsify ship <os>` positional's. An entry under a key nothing reads leaves that target on " +
240
+ '`gjsify.main` with nothing to say so.');
241
+ }
242
+ const projectWide = typeof declaredTable === 'string' ? declaredTable : undefined;
243
+ const fallback = input.pkg.gjsify?.main ?? input.pkg.main;
244
+ const entryFor = (os) => perTarget[os] ?? projectWide ?? fallback;
245
+ const override = perTarget[input.layoutOs];
246
+ const declared = entryFor(input.layoutOs);
247
+ const key = override !== undefined
248
+ ? `gjsify.ship.bundle.${input.layoutOs}`
249
+ : projectWide !== undefined
250
+ ? 'gjsify.ship.bundle'
251
+ : input.pkg.gjsify?.main !== undefined
252
+ ? 'gjsify.main'
253
+ : 'main';
254
+ // RESOLVED per OS, not read off the table — because the entry another target
255
+ // ships is as often the FALL-THROUGH as it is a declared one, and that is
256
+ // #1545's own project shape: `main` is the GJS bundle, only darwin declares
257
+ // its own, and reading the table alone leaves the GJS bundle sitting inside
258
+ // the signed `.app` with nothing to name it. When nothing is keyed per OS
259
+ // every target resolves the same entry and this set is empty by construction.
260
+ const foreign = [
261
+ ...new Set(Object.keys(SHIP_APP_OSES)
262
+ .filter((os) => os !== input.layoutOs)
263
+ .map(entryFor)
264
+ .filter((entry) => entry !== undefined && entry !== declared)),
265
+ ];
266
+ return { declared, key, overridden: override !== undefined, foreign };
209
267
  }
210
268
  /** npm package name → a package/binary name both dpkg and rpm accept. */
211
269
  export function deriveBinaryName(pkgName) {
@@ -302,6 +360,8 @@ export function descriptionParagraphs(description) {
302
360
  }
303
361
  /** `LICENSE`, `LICENSE.md`, … — the names a licence file actually has. */
304
362
  export const LICENSE_FILE_NAMES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING', 'COPYING.md'];
363
+ /** `CHANGELOG.md`, … — the names a project's own changelog actually has. */
364
+ export const CHANGELOG_FILE_NAMES = ['CHANGELOG.md', 'CHANGELOG', 'CHANGELOG.txt', 'NEWS.md', 'NEWS'];
305
365
  /** The bundle's own basename, used as the launcher's exec target. */
306
366
  export function bundleEntryName(bundlePath) {
307
367
  return basename(bundlePath);
@@ -53,8 +53,17 @@ export declare const STAGE_MANIFEST_FILE = ".gjsify-ship-stage.json";
53
53
  * the one thing the reader can do about it. That is the same shape the 3-to-4
54
54
  * paragraph describes, and at 5 the schema check above fires first and says
55
55
  * "re-run the `--stage` phase with this gjsify".
56
+ *
57
+ * 6 made an overlay entry carry EITHER `text` or `base64`, for the gzipped
58
+ * `changelog.Debian.gz` Debian Policy § 4.4 requires. That is the meaning case
59
+ * again and the sharpest one yet: a schema-5 reader demands `text` on every
60
+ * entry, so it meets a 6 stage and fails on `overlay.deb[1].text must be a
61
+ * string` — a sentence about a field, over a stage whose only problem is the
62
+ * gjsify reading it. And the inverse is worse than a bad message: a reader that
63
+ * ignored the unknown key would write an entry with no bytes, or write base64 AS
64
+ * text, and ship a `.deb` whose changelog is a wall of ASCII at exit 0.
56
65
  */
57
- export declare const STAGE_SCHEMA_VERSION = 5;
66
+ export declare const STAGE_SCHEMA_VERSION = 6;
58
67
  /** What this stage was assembled FOR, in the repo-wide `${process.platform}-${process.arch}` spelling. */
59
68
  export interface StageTarget {
60
69
  /** The layout the stage carries, in the `process.platform` spelling — `win32`, never `windows`. */
@@ -65,9 +74,19 @@ export interface StageTarget {
65
74
  export interface StageFileRecord extends StagedMode {
66
75
  bytes: number;
67
76
  }
68
- /** One pre-rendered overlay file. Text only — see {@link writeStageManifest}. */
77
+ /**
78
+ * One pre-rendered overlay file. EXACTLY ONE of `text` and `base64` is present —
79
+ * see {@link writeStageManifest} for why a file reference is still refused.
80
+ *
81
+ * `text` is preferred wherever the content is text, because the sidecar is the one
82
+ * artifact in this handoff a human reads: the whole copyright is legible in it. The
83
+ * `base64` half exists for the gzipped Debian changelog, whose bytes must be the
84
+ * bytes that ship — a stage packed on another host cannot re-compress it and get
85
+ * the same file.
86
+ */
69
87
  export interface StageOverlayFile extends StagedMode {
70
- text: string;
88
+ text?: string;
89
+ base64?: string;
71
90
  }
72
91
  export interface StageManifest {
73
92
  schema: number;
@@ -13,8 +13,10 @@
13
13
  // * no `staged` — modes come from the plan (see `stage-writer.ts`), so the
14
14
  // launcher packs 0644 and the installed `bin/<name>` is not executable.
15
15
  // * no `overlay` — the `.deb` ships no `/usr/share/doc/<pkg>/copyright`,
16
- // which Debian Policy § 12.5 requires and lintian errors on, and the
17
- // `.rpm` ships no `/usr/share/licenses/<pkg>/LICENSE`.
16
+ // which Debian Policy § 12.5 requires and lintian errors on, no
17
+ // `/usr/share/doc/<pkg>/changelog.Debian.gz`, which § 4.4 requires and
18
+ // lintian also errors on, and the `.rpm` ships no
19
+ // `/usr/share/licenses/<pkg>/LICENSE`.
18
20
  // * no `namespaces` — they are scanned from the BUILD TREE's bundle, so a
19
21
  // packing host that re-derived nothing would emit `Depends:` without
20
22
  // `gir1.2-gtk-4.0` and `gir1.2-adw-1`: the package installs on a machine
@@ -36,6 +38,7 @@
36
38
  // that is right there.
37
39
  import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
38
40
  import { join, sep } from 'node:path';
41
+ import { base64Decode, base64Encode } from '../base64.js';
39
42
  import { cliVersion } from '../publish-headers.js';
40
43
  import { FORMAT_IDS } from './formats.js';
41
44
  import { layoutForOs, LAYOUTS, LAYOUT_NAMES } from './layout.js';
@@ -93,8 +96,17 @@ export const STAGE_MANIFEST_FILE = '.gjsify-ship-stage.json';
93
96
  * the one thing the reader can do about it. That is the same shape the 3-to-4
94
97
  * paragraph describes, and at 5 the schema check above fires first and says
95
98
  * "re-run the `--stage` phase with this gjsify".
99
+ *
100
+ * 6 made an overlay entry carry EITHER `text` or `base64`, for the gzipped
101
+ * `changelog.Debian.gz` Debian Policy § 4.4 requires. That is the meaning case
102
+ * again and the sharpest one yet: a schema-5 reader demands `text` on every
103
+ * entry, so it meets a 6 stage and fails on `overlay.deb[1].text must be a
104
+ * string` — a sentence about a field, over a stage whose only problem is the
105
+ * gjsify reading it. And the inverse is worse than a bad message: a reader that
106
+ * ignored the unknown key would write an entry with no bytes, or write base64 AS
107
+ * text, and ship a `.deb` whose changelog is a wall of ASCII at exit 0.
96
108
  */
97
- export const STAGE_SCHEMA_VERSION = 5;
109
+ export const STAGE_SCHEMA_VERSION = 6;
98
110
  /**
99
111
  * The half of {@link ShipSettings} that crosses the host boundary.
100
112
  *
@@ -149,15 +161,17 @@ export function writeStageManifest(input) {
149
161
  const overlay = {};
150
162
  for (const format of input.formats) {
151
163
  overlay[format.id] = (input.overlay.get(format.id) ?? []).map((file) => {
152
- if (file.source.kind !== 'text') {
153
- // Every overlay entry `planOverlay` produces today is rendered text. A file
154
- // reference would be a build-host path, and putting one in here is how the
155
- // manifest would start naming files the packing host cannot open.
156
- throw new Error(`gjsify ship: internal error — the ${format.id} overlay entry ${file.path} is a file ` +
157
- 'reference, and a stage manifest may only carry rendered text. Render it in ' +
158
- '`planOverlay` instead.');
164
+ if (file.source.kind === 'text')
165
+ return { path: file.path, mode: file.mode, text: file.source.text };
166
+ if (file.source.kind === 'bytes') {
167
+ return { path: file.path, mode: file.mode, base64: base64Encode(file.source.data) };
159
168
  }
160
- return { path: file.path, mode: file.mode, text: file.source.text };
169
+ // A FILE reference is still refused. It would be a build-host path, and
170
+ // putting one in here is how the manifest would start naming files the
171
+ // packing host cannot open — the failure the closure exists to prevent.
172
+ throw new Error(`gjsify ship: internal error — the ${format.id} overlay entry ${file.path} is a file ` +
173
+ 'reference, and a stage manifest may only carry rendered bytes. Render it in ' +
174
+ '`planOverlay` instead.');
161
175
  });
162
176
  }
163
177
  const manifest = {
@@ -295,10 +309,21 @@ export function readStageManifest(stageDir) {
295
309
  // lintian errors on.
296
310
  manifest.overlay[id] = expectArray(overlay[id], at(`overlay.${id}`)).map((entry, index) => {
297
311
  const file = record(entry, at(`overlay.${id}[${index}]`));
312
+ const where = at(`overlay.${id}[${index}]`);
313
+ // EXACTLY ONE, checked in both directions. Neither would write an empty
314
+ // file at a path Debian policy demands content at; both would leave which
315
+ // one wins to whichever branch `overlayFiles` happens to test first.
316
+ if ((file.text === undefined) === (file.base64 === undefined)) {
317
+ throw new Error(`gjsify ship: ${where} must carry exactly one of \`text\` and \`base64\`. ` +
318
+ 'An overlay entry is either rendered text or rendered bytes; an entry with ' +
319
+ 'neither would pack an empty file at a path the format requires content at.');
320
+ }
298
321
  return {
299
322
  path: expectString(file.path, at(`overlay.${id}[${index}].path`)),
300
323
  mode: expectInteger(file.mode, at(`overlay.${id}[${index}].mode`)),
301
- text: expectString(file.text, at(`overlay.${id}[${index}].text`)),
324
+ ...(file.text === undefined
325
+ ? { base64: expectString(file.base64, at(`overlay.${id}[${index}].base64`)) }
326
+ : { text: expectString(file.text, at(`overlay.${id}[${index}].text`)) }),
302
327
  };
303
328
  });
304
329
  }
@@ -329,7 +354,9 @@ export function overlayFiles(manifest, format) {
329
354
  return (manifest.overlay[format] ?? []).map((file) => ({
330
355
  path: file.path,
331
356
  mode: file.mode,
332
- source: { kind: 'text', text: file.text },
357
+ source: file.base64 === undefined
358
+ ? { kind: 'text', text: file.text ?? '' }
359
+ : { kind: 'bytes', data: base64Decode(file.base64) },
333
360
  }));
334
361
  }
335
362
  /**
@@ -224,6 +224,17 @@ export interface FormatDescriptor {
224
224
  interpreterGap: string;
225
225
  /** Where the project's licence text goes in this format's overlay. */
226
226
  licenseDest: (binaryName: string) => string;
227
+ /**
228
+ * Where this format wants a packaging changelog, when it wants one at all.
229
+ *
230
+ * OPTIONAL, unlike {@link licenseDest}, and the asymmetry is a policy
231
+ * difference rather than an oversight: Debian Policy § 4.4 makes
232
+ * `share/doc/<package>/changelog.Debian.gz` mandatory and lintian errors
233
+ * without it, while rpm's `%doc` changelog is a convention and `rpm` says
234
+ * nothing when it is absent. A format that leaves this undefined has
235
+ * answered "this format does not require one".
236
+ */
237
+ changelogDest?: (binaryName: string) => string;
227
238
  /**
228
239
  * `debian-copyright` renders the machine-readable copyright 1.0 format
229
240
  * Debian policy asks for; `plain` copies the licence file verbatim.
@@ -420,6 +431,8 @@ export interface ShipSettings extends PackSettings {
420
431
  projectDir: string;
421
432
  /** Absolute path to the licence file, when one was found. */
422
433
  licenseFile?: string;
434
+ /** Absolute path to the project's own changelog, when one was found. */
435
+ changelogFile?: string;
423
436
  /** `'app'` stages a desktop entry and an icon; `'cli'` stages neither. */
424
437
  kind: 'app' | 'cli';
425
438
  /** Absolute path to the built bundle that `bin/<binaryName>` executes. */