@gjsify/cli 0.44.0 → 0.45.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 (73) hide show
  1. package/dist/cli.gjs.mjs +192 -165
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +833 -54
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/types/config-data.d.ts +63 -1
  11. package/lib/utils/msgfmt-merge.d.ts +35 -0
  12. package/lib/utils/msgfmt-merge.js +102 -0
  13. package/lib/utils/npm-otp.d.ts +27 -2
  14. package/lib/utils/npm-otp.js +82 -7
  15. package/lib/utils/onboard-discovery.d.ts +68 -0
  16. package/lib/utils/onboard-discovery.js +178 -0
  17. package/lib/utils/onboard-probe.d.ts +118 -1
  18. package/lib/utils/onboard-probe.js +153 -4
  19. package/lib/utils/prompt-output.d.ts +12 -0
  20. package/lib/utils/prompt-output.js +54 -0
  21. package/lib/utils/prompt.js +22 -2
  22. package/lib/utils/publishable-packages.d.ts +1 -1
  23. package/lib/utils/publishable-packages.js +1 -0
  24. package/lib/utils/resolve-npm-package.d.ts +56 -0
  25. package/lib/utils/resolve-npm-package.js +69 -0
  26. package/lib/utils/ship/app-runtime.d.ts +214 -0
  27. package/lib/utils/ship/app-runtime.js +429 -0
  28. package/lib/utils/ship/depends.d.ts +50 -0
  29. package/lib/utils/ship/depends.js +117 -3
  30. package/lib/utils/ship/discover.js +18 -1
  31. package/lib/utils/ship/dmg.d.ts +71 -0
  32. package/lib/utils/ship/dmg.js +181 -0
  33. package/lib/utils/ship/flatpak.js +1 -1
  34. package/lib/utils/ship/formats.d.ts +85 -7
  35. package/lib/utils/ship/formats.js +734 -15
  36. package/lib/utils/ship/launcher.d.ts +13 -3
  37. package/lib/utils/ship/launcher.js +362 -17
  38. package/lib/utils/ship/layout.d.ts +268 -0
  39. package/lib/utils/ship/layout.js +351 -0
  40. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  41. package/lib/utils/ship/localize-metadata.js +126 -0
  42. package/lib/utils/ship/mime.js +1 -9
  43. package/lib/utils/ship/msi.d.ts +126 -0
  44. package/lib/utils/ship/msi.js +417 -0
  45. package/lib/utils/ship/node-runtime.d.ts +71 -0
  46. package/lib/utils/ship/node-runtime.js +122 -0
  47. package/lib/utils/ship/payload.d.ts +156 -5
  48. package/lib/utils/ship/payload.js +580 -14
  49. package/lib/utils/ship/plan.d.ts +21 -0
  50. package/lib/utils/ship/plan.js +20 -7
  51. package/lib/utils/ship/plist.d.ts +58 -0
  52. package/lib/utils/ship/plist.js +198 -0
  53. package/lib/utils/ship/rpm.js +9 -3
  54. package/lib/utils/ship/schemas.d.ts +61 -0
  55. package/lib/utils/ship/schemas.js +118 -0
  56. package/lib/utils/ship/scripts.js +7 -3
  57. package/lib/utils/ship/settings.d.ts +19 -1
  58. package/lib/utils/ship/settings.js +12 -1
  59. package/lib/utils/ship/share-dirs.d.ts +23 -0
  60. package/lib/utils/ship/share-dirs.js +46 -0
  61. package/lib/utils/ship/signing.d.ts +195 -0
  62. package/lib/utils/ship/signing.js +317 -0
  63. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  64. package/lib/utils/ship/stage-manifest.js +99 -13
  65. package/lib/utils/ship/stage-writer.d.ts +48 -0
  66. package/lib/utils/ship/stage-writer.js +71 -1
  67. package/lib/utils/ship/types.d.ts +158 -13
  68. package/lib/utils/ship/xml.d.ts +7 -0
  69. package/lib/utils/ship/xml.js +25 -0
  70. package/lib/utils/ship/zip.d.ts +69 -0
  71. package/lib/utils/ship/zip.js +245 -0
  72. package/lib/utils/trust-registry.js +5 -0
  73. package/package.json +19 -19
@@ -0,0 +1,429 @@
1
+ // The runtime a self-contained `<App>.app` CARRIES — found, then staged with its
2
+ // tree intact (#1354 M2b, ADR 0024 stage 4).
3
+ //
4
+ // WHAT M2a LEFT. `gjsify ship darwin` produced a real bundle — `Info.plist`,
5
+ // `PkgInfo`, a compiled schema cache — whose launcher still `exec`ed the bare
6
+ // name `node` off `PATH` and whose GI namespaces still expected a GTK on the
7
+ // machine. macOS ships neither. So the artifact was an application in shape and
8
+ // not in behaviour, and `formats.ts`'s `macos-app` row says so in as many words:
9
+ // "whatever the bundle does not carry, it finds on the machine or it does not
10
+ // run — which is what makes M2b (staging a runtime) the milestone that matters".
11
+ // This module is that staging.
12
+ //
13
+ // FOUR PIECES, THREE OWNERS — `@gjsify/node-gi` supplies two of them — and none
14
+ // is a dependency of anything here:
15
+ //
16
+ // * the interpreter — `@gjsify/node-runtime-<target>`, resolved by
17
+ // {@link resolveNodeRuntime} in `node-runtime.ts`. This module is the caller
18
+ // that module was written for, and #1354 M3 gave it a second layout to serve.
19
+ // * the relocated GTK closure — `@gjsify/gtk-runtime-<target>`'s `gtk/`.
20
+ // * the node-gi addon built against that closure —
21
+ // `@gjsify/node-gi`'s `prebuilds/<target>/node_gi.node`, whose `@rpath` is
22
+ // `@loader_path/gtk/lib`.
23
+ // * node-gi's own JAVASCRIPT, which is a fourth piece and not an afterthought:
24
+ // `@gjsify/node-gi/*` is EXTERNAL in every `--app node` bundle by design
25
+ // (`rolldown-plugin-gjsify/src/app/node.ts`: the reverse-bridge modules
26
+ // "must resolve at runtime against the consumer's node_modules"), so a
27
+ // `gi://Gtk` import compiles to `require('@gjsify/node-gi/gi')` in the
28
+ // shipped file. Measured on the `.app` fixture's own bundle. A `.app` has no
29
+ // consumer node_modules, so the package is staged into one the bundle's
30
+ // directory owns.
31
+ //
32
+ // THE LAYOUT IS NOT OURS TO CHOOSE. `resolveGtkRuntimeBundle()` in
33
+ // `packages/node-gi/node-gi/gtk-runtime.js` walks four candidates and probes each
34
+ // for `<dir>/lib` + `<dir>/girepository-1.0`; the addon's `@rpath` reaches its
35
+ // dylibs as `@loader_path/gtk/lib`; `loaders.cache` names each gdk-pixbuf loader
36
+ // `@loader_path/../../..`-relative; `GST_PLUGIN_SCANNER` is an executable under
37
+ // `libexec/gstreamer-1.0`. Every one of those relations is RELATIVE, so the whole
38
+ // tree survives a copy — as long as it is copied as a tree. Hence
39
+ // {@link appRuntimePaths}: the staged shape is node-gi's own sibling layout,
40
+ // `prebuilds/<target>/{node_gi.node, gtk/}`, the same one
41
+ // `.github/workflows/node-gi.yml`'s macOS legs assemble by hand before they run
42
+ // the conformance.
43
+ //
44
+ // AND THAT SPLIT IS WHY THE TWO ENVIRONMENT VARIABLES ARE LOAD-BEARING rather
45
+ // than belt-and-braces. Once node-gi's JS sits at
46
+ // `Contents/Resources/lib/node_modules/@gjsify/node-gi`, its `packageRoot` is
47
+ // THAT directory — so `prebuildAddonPath()` looks for
48
+ // `…/node_modules/@gjsify/node-gi/prebuilds/<target>/node_gi.node` and
49
+ // `resolveGtkRuntimeBundle()`'s candidate 2 for `…/prebuilds/<target>/gtk`, and
50
+ // neither is where a `.app` may keep loadable code: `Contents/Frameworks` is, and
51
+ // it is what `codesign` now reaches (ADR 0024 § A4) — M6 landed, and
52
+ // `utils/ship/signing.ts` re-signs every Mach-O in the payload by magic number,
53
+ // which is most of this closure. Putting it under `Resources` to save two
54
+ // `export` lines would trade a correct bundle for a shorter launcher.
55
+ //
56
+ // WHY NOT `gjsify.ship.bundledTypelibs`. Because it FLATTENS. `plan.ts` stages
57
+ // each such file as `posix.join(libDir, 'gi', basename(file))`, and
58
+ // `discoverTypelibs` walks the directory recursively — so three inputs at three
59
+ // depths collapse into one directory. Measured on this tree, through the built
60
+ // planner:
61
+ //
62
+ // gi/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so → lib/demo/gi/libpixbufloader-svg.so
63
+ // gi/loaders.cache → lib/demo/gi/loaders.cache
64
+ // gi/Gtk-4.0.typelib → lib/demo/gi/Gtk-4.0.typelib
65
+ //
66
+ // That destroys the loader paths `loaders.cache` records, and it leaves a
67
+ // directory with no `lib/` and no `girepository-1.0/` — so
68
+ // `resolveGtkRuntimeBundle()`'s existence probe rejects it and node-gi falls back
69
+ // to a GTK the machine does not have. `bundledTypelibs` stays what it is (a FLAT
70
+ // pair of a typelib and its backer, which is all it ever claimed to be); a
71
+ // relocated closure needs its own stager, and this is it.
72
+ //
73
+ // `null`-not-throw, like `node-runtime.ts`, and for the same reason: whether a
74
+ // missing piece is fatal depends on what the caller is building. `assemble` in
75
+ // `commands/ship.ts` stages what was found, names what was not, and keeps
76
+ // `Layout.runtimeGap` for exactly the case where the interpreter is still absent.
77
+ import { existsSync, readdirSync, statSync } from 'node:fs';
78
+ import { dirname, join, posix, sep } from 'node:path';
79
+ import { listFilesRecursive } from './discover.js';
80
+ import { nodeRuntimeBinaryName } from './node-runtime.js';
81
+ import { isExecutableAsset } from './plan.js';
82
+ import { resolveInstalledPackage } from '../resolve-npm-package.js';
83
+ /**
84
+ * The targets a relocated GTK closure is published for.
85
+ *
86
+ * The same three `@gjsify/gtk-runtime-*` packages node-gi resolves, and
87
+ * deliberately NOT derived from {@link NODE_RUNTIME_TARGETS}: the two lists agree
88
+ * today by coincidence of which platforms lack a dependable system stack, and
89
+ * deriving one from the other would make a future Linux GTK bundle silently imply
90
+ * a bundled Linux interpreter (which `node-runtime.ts` refuses on purpose).
91
+ */
92
+ export const GTK_RUNTIME_TARGETS = ['darwin-arm64', 'darwin-x64', 'win32-x64'];
93
+ /** Is this `<platform>-<arch>` one of the published GTK-closure targets? */
94
+ export function isGtkRuntimeTarget(target) {
95
+ return GTK_RUNTIME_TARGETS.includes(target);
96
+ }
97
+ /** The npm name carrying the relocated closure for a target. One spelling, derived. */
98
+ export function gtkRuntimePackageName(target) {
99
+ return `@gjsify/gtk-runtime-${target}`;
100
+ }
101
+ /** The addon's filename, node-gi's `ADDON_FILENAME`. Identical in every prebuild. */
102
+ export const NODE_GI_ADDON_FILENAME = 'node_gi.node';
103
+ /** The package whose `prebuilds/<target>/` holds the addon and, in CI, the closure beside it. */
104
+ export const NODE_GI_PACKAGE = '@gjsify/node-gi';
105
+ /**
106
+ * Locate the relocated GTK closure for `target`, or `null`.
107
+ *
108
+ * The search order MIRRORS `resolveGtkRuntimeBundle()` rather than inventing a
109
+ * second one, because the two have to agree: this function decides what gets
110
+ * copied into the bundle, and that function decides what the bundle finds at
111
+ * runtime. Four candidates, first complete hit wins:
112
+ *
113
+ * 1. `GJSIFY_GTK_RUNTIME` — an explicit bundle directory, for a maintainer
114
+ * holding an unpublished or patched closure. The same variable the launcher
115
+ * later EXPORTS, and the same precedence it has there.
116
+ * 2. `@gjsify/node-gi`'s own `prebuilds/<target>/gtk`. This is the layout the
117
+ * macOS CI legs stage by hand (`node-gi.yml`: download the bundle artifact
118
+ * into `prebuilds/darwin-<arch>/gtk`, the addon beside it), so a job that has
119
+ * prepared node-gi for a conformance run has, by that act alone, prepared it
120
+ * for a ship run.
121
+ * 3. the sibling monorepo directory `../gtk-runtime-<target>/gtk`, which is what
122
+ * an in-repo `npm run build:bundle` writes.
123
+ * 4. the published `@gjsify/gtk-runtime-<target>` package's `gtk/`.
124
+ *
125
+ * A candidate counts only when it holds BOTH the loadable-code directory and the
126
+ * typelib directory — node-gi's own probe, restated here rather than weakened,
127
+ * because a directory that passes here and fails there is a `.app` that ships
128
+ * 200 MB and still cannot open a window.
129
+ */
130
+ export function resolveGtkRuntime(target, options = {}) {
131
+ const env = options.env ?? process.env;
132
+ // `bin` on win32, `lib` everywhere else — the NAME differs, the ROLE does not
133
+ // (`gtk-runtime.js`'s `nativeSubdir`). Read off the TARGET and never
134
+ // `process.platform`: assembling a win32 tree from Linux is a supported path.
135
+ const nativeSubdir = target.startsWith('win32-') ? 'bin' : 'lib';
136
+ const candidates = [];
137
+ const override = env['GJSIFY_GTK_RUNTIME'];
138
+ if (override)
139
+ candidates.push({ source: 'GJSIFY_GTK_RUNTIME', dir: override });
140
+ const nodeGiRoot = resolveNodeGiRoot(options);
141
+ if (nodeGiRoot !== null) {
142
+ candidates.push({
143
+ source: `${NODE_GI_PACKAGE}/prebuilds/${target}`,
144
+ dir: join(nodeGiRoot, 'prebuilds', target, 'gtk'),
145
+ });
146
+ candidates.push({
147
+ source: `${NODE_GI_PACKAGE} sibling`,
148
+ dir: join(nodeGiRoot, '..', `gtk-runtime-${target}`, 'gtk'),
149
+ });
150
+ }
151
+ const packageName = gtkRuntimePackageName(target);
152
+ const entry = resolveStagedPackage(packageName, options);
153
+ if (entry !== null)
154
+ candidates.push({ source: packageName, dir: join(dirname(entry), 'gtk') });
155
+ for (const candidate of candidates) {
156
+ const libDir = join(candidate.dir, nativeSubdir);
157
+ const typelibDir = join(candidate.dir, 'girepository-1.0');
158
+ if (!existsSync(libDir) || !existsSync(typelibDir))
159
+ continue;
160
+ return { source: candidate.source, dir: candidate.dir, libDir, typelibDir };
161
+ }
162
+ return null;
163
+ }
164
+ /**
165
+ * Locate the node-gi addon built for `target`, or `null`.
166
+ *
167
+ * ONE source, and no override. The addon and the closure ship as a matched pair —
168
+ * the addon's `@rpath` is `@loader_path/gtk/lib`, so it names the very directory
169
+ * the closure occupies — and `@gjsify/node-gi` publishes `prebuilds/` for all five
170
+ * of its platforms in ONE package, so resolving the package by name is already
171
+ * enough to reach a foreign target's binary from a Linux host. A second
172
+ * environment knob here would be a way to pair an addon with a closure it was not
173
+ * built against, which is #910's failure shape wearing a convenience.
174
+ */
175
+ export function resolveNodeGiAddon(target, options = {}) {
176
+ const root = resolveNodeGiRoot(options);
177
+ if (root === null)
178
+ return null;
179
+ const addonPath = join(root, 'prebuilds', target, NODE_GI_ADDON_FILENAME);
180
+ // A package resolved but not POPULATED is `null`, not a path — `prebuilds/` is
181
+ // gitignored, so an in-repo checkout is exactly what that looks like, and a
182
+ // path to a file that is not there fails later, in a copy, with the target's
183
+ // name nowhere in the message. Same rule as `node-runtime.ts`'s `complete()`.
184
+ return existsSync(addonPath) ? { source: NODE_GI_PACKAGE, addonPath } : null;
185
+ }
186
+ /** `@gjsify/node-gi`'s package root, or `null` when it is not resolvable from here. */
187
+ function resolveNodeGiRoot(options) {
188
+ const entry = resolveStagedPackage(NODE_GI_PACKAGE, options);
189
+ return entry === null ? null : dirname(entry);
190
+ }
191
+ /**
192
+ * Resolve a package whose BYTES this module stages into a redistributed artifact.
193
+ *
194
+ * `resolveInstalledPackage`, never `resolveNpmPackage`: under Bun the latter
195
+ * answers from the runtime's global install cache for a project that merely has a
196
+ * `package.json` and no `node_modules`, which would put files the author never
197
+ * declared inside a `.app`. See that function for the measurement.
198
+ */
199
+ function resolveStagedPackage(specifier, options) {
200
+ const opts = { bundleUrl: import.meta.url };
201
+ if (options.cwd !== undefined)
202
+ opts.cwd = options.cwd;
203
+ if (options.resolve !== undefined)
204
+ opts.resolve = options.resolve;
205
+ return resolveInstalledPackage(specifier, opts);
206
+ }
207
+ /**
208
+ * The files a SHIPPED `.app` needs from `@gjsify/node-gi`, or `null`.
209
+ *
210
+ * NOT the published tarball. `files` in node-gi's manifest also lists `src/`,
211
+ * `binding.gyp` and `scripts/install.mjs` — the inputs to `node-gyp`, which a
212
+ * `.app` never runs — and the `.d.ts` declarations, which nothing inside a bundle
213
+ * compiles against. And it lists `prebuilds/`, which this module stages into
214
+ * `Contents/Frameworks` instead, where a `.app` keeps loadable code.
215
+ *
216
+ * So the rule is what `require('@gjsify/node-gi/<subpath>')` can REACH:
217
+ * `package.json` (the `exports` map is what resolves every subpath), every `.js`
218
+ * at the package root (every `exports` target is one), and `overrides/`, which
219
+ * `gi.js` loads at runtime. `tests/e2e/ship-macos` holds that rule against the
220
+ * REAL package's `exports` map, so a node-gi release that adds an entry outside
221
+ * this set fails there rather than in a `.app` a stranger downloaded.
222
+ */
223
+ export function resolveNodeGiPackage(options = {}) {
224
+ const root = resolveNodeGiRoot(options);
225
+ if (root === null)
226
+ return null;
227
+ const files = ['package.json'];
228
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
229
+ if (entry.isFile() && entry.name.endsWith('.js'))
230
+ files.push(entry.name);
231
+ }
232
+ const overrides = join(root, 'overrides');
233
+ if (existsSync(overrides)) {
234
+ for (const rel of listFilesRecursive(overrides))
235
+ files.push(posix.join('overrides', rel));
236
+ }
237
+ // One `.js` is the floor: a package whose root holds only `package.json` is a
238
+ // directory that resolved and a runtime that is not there, and staging it would
239
+ // produce a `.app` failing at `require` with node-gi's name in the message and
240
+ // nothing to act on.
241
+ return files.length > 1 ? { source: NODE_GI_PACKAGE, root, files: files.sort() } : null;
242
+ }
243
+ export function appRuntimePaths(layout, identity, target) {
244
+ const dirs = layout.dirs(identity);
245
+ const nodeGiRoot = posix.join(dirs.native, 'node-gi');
246
+ const prebuildDir = posix.join(nodeGiRoot, 'prebuilds', target);
247
+ return {
248
+ nodeGiRoot,
249
+ nodeGiPackageDir: posix.join(dirs.bundle, 'node_modules', NODE_GI_PACKAGE),
250
+ prebuildDir,
251
+ gtkDir: posix.join(prebuildDir, 'gtk'),
252
+ addonPath: posix.join(prebuildDir, NODE_GI_ADDON_FILENAME),
253
+ // `Contents/MacOS`, because that is where a `.app` keeps executables and
254
+ // because `$here` — which the launcher already computes — is then the whole
255
+ // path expression. `Contents/Frameworks` would work for dyld and would make
256
+ // the launcher walk back up for the one file it execs. On Windows
257
+ // `dirs.launcher` is the program directory itself, so the interpreter lands
258
+ // beside the `.cmd` for the same reason.
259
+ //
260
+ // THE LEAF COMES FROM `node-runtime.ts`, which is the module that already
261
+ // decided it for the SOURCE file — `node.exe` on win32, `node` elsewhere,
262
+ // read off the TARGET and never `process.platform` (assembling a Windows
263
+ // program directory on Linux is the supported path). Restating the rule here
264
+ // would let the two drift into a stage that copies `node.exe` to `node` and a
265
+ // launcher that execs neither.
266
+ interpreterPath: posix.join(dirs.launcher, nodeRuntimeBinaryName(target)),
267
+ // rpm's `share/licenses/<name>/LICENSE` shape, which the `macos-app` row
268
+ // already uses for the app's own licence — one layout for a reader to learn,
269
+ // and `node` is a name no `binaryName` can collide with silently: `placeStage`
270
+ // refuses the collision by name.
271
+ interpreterLicensePath: posix.join(dirs.data, 'licenses', 'node', 'LICENSE'),
272
+ };
273
+ }
274
+ /**
275
+ * Stage the interpreter, the GTK closure and the addon into one bundle.
276
+ *
277
+ * PARTIAL BY DESIGN, and reported. An all-or-nothing rule would drop a 200 MB
278
+ * closure that IS present because an interpreter that is not shares the milestone
279
+ * with it, and would do it at exit 0 — while a bundle carrying the closure and no
280
+ * interpreter is a real, useful intermediate (it runs wherever a Node is on
281
+ * `PATH`, which is every developer's machine and no stranger's). So each piece is
282
+ * staged when found and named when not, and `commands/ship.ts` keeps
283
+ * `Layout.runtimeGap` for precisely the case where the interpreter is still
284
+ * missing.
285
+ *
286
+ * TREE-PRESERVING for the closure and FLAT for the other two, which is the whole
287
+ * distinction this module exists to draw: `listFilesRecursive` walks the bundle
288
+ * and every relative path is carried through unchanged, so
289
+ * `lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so` is still four levels
290
+ * under `gtk/` on the other side of the copy.
291
+ */
292
+ export function stageAppRuntime(input) {
293
+ const paths = appRuntimePaths(input.layout, input.identity, input.target);
294
+ const files = [];
295
+ const launcher = {};
296
+ const found = [];
297
+ const missing = [];
298
+ if (input.interpreter !== null) {
299
+ files.push({
300
+ path: paths.interpreterPath,
301
+ mode: 0o755,
302
+ source: { kind: 'file', path: input.interpreter.nodePath },
303
+ });
304
+ // The licence travels with the binary or neither does — `ResolvedNodeRuntime`
305
+ // makes that a property of the RESULT for this reason, and this is the caller
306
+ // that would otherwise have been free to forget it.
307
+ files.push({
308
+ path: paths.interpreterLicensePath,
309
+ mode: 0o644,
310
+ source: { kind: 'file', path: input.interpreter.licensePath },
311
+ });
312
+ launcher.interpreter = paths.interpreterPath;
313
+ found.push(`interpreter from ${input.interpreter.source}`);
314
+ }
315
+ else {
316
+ missing.push(`no bundled interpreter — install \`@gjsify/node-runtime-${input.target}\` (or point ` +
317
+ '`GJSIFY_NODE_RUNTIME` at a `bin/` directory holding `node` and its `LICENSE`)');
318
+ }
319
+ const gtk = resolveGtkRuntime(input.target, discoveryOptions(input));
320
+ if (gtk !== null) {
321
+ for (const rel of listFilesRecursive(gtk.dir)) {
322
+ const abs = join(gtk.dir, rel.split('/').join(sep));
323
+ files.push({
324
+ path: posix.join(paths.gtkDir, rel),
325
+ mode: stagedMode(rel, abs),
326
+ source: { kind: 'file', path: abs },
327
+ });
328
+ }
329
+ launcher.gtkRuntimeDir = paths.gtkDir;
330
+ found.push(`GTK closure from ${gtk.source}`);
331
+ }
332
+ else {
333
+ // The native subdirectory is `bin` on win32 and `lib` everywhere else,
334
+ // exactly as `resolveGtkRuntimeBundle()` probes for it. Naming `lib/`
335
+ // unconditionally sent a Windows reader to build a directory this very
336
+ // resolver then refuses -- a hint that is wrong is worse than none,
337
+ // because it costs the reader the time to follow it first.
338
+ const nativeSubdir = input.target.startsWith('win32-') ? 'bin' : 'lib';
339
+ missing.push(`no GTK closure — install \`${gtkRuntimePackageName(input.target)}\` (or point ` +
340
+ `\`GJSIFY_GTK_RUNTIME\` at a bundle directory holding \`${nativeSubdir}/\` and \`girepository-1.0/\`)`);
341
+ }
342
+ // node-gi's JavaScript, which the bundle `require`s by name. Staged whenever
343
+ // the package is resolvable and reported separately from the addon: a bundle
344
+ // that carries the JS and no addon fails with node-gi's own diagnosis
345
+ // (`load-diagnostics.js`), while one that carries the addon and no JS fails at
346
+ // `require` before anything of node-gi's has run.
347
+ const js = resolveNodeGiPackage(discoveryOptions(input));
348
+ if (js !== null) {
349
+ for (const rel of js.files) {
350
+ files.push({
351
+ path: posix.join(paths.nodeGiPackageDir, rel),
352
+ mode: 0o644,
353
+ source: { kind: 'file', path: join(js.root, rel.split('/').join(sep)) },
354
+ });
355
+ }
356
+ found.push(`node-gi runtime (${js.files.length} file(s)) from ${js.source}`);
357
+ }
358
+ else {
359
+ missing.push(`no node-gi JavaScript — install \`${NODE_GI_PACKAGE}\`; a \`--app node\` bundle keeps ` +
360
+ '`@gjsify/node-gi/*` external, so the staged bundle would `require` a package the artifact ' +
361
+ 'does not carry');
362
+ }
363
+ const addon = resolveNodeGiAddon(input.target, discoveryOptions(input));
364
+ if (addon !== null) {
365
+ files.push({ path: paths.addonPath, mode: 0o755, source: { kind: 'file', path: addon.addonPath } });
366
+ launcher.nodeGiAddon = paths.addonPath;
367
+ found.push(`node-gi addon from ${addon.source}`);
368
+ }
369
+ else {
370
+ missing.push(`no node-gi addon — install \`${NODE_GI_PACKAGE}\`, whose \`prebuilds/${input.target}/` +
371
+ `${NODE_GI_ADDON_FILENAME}\` is the binary built against that closure`);
372
+ }
373
+ return { files, launcher, found, missing };
374
+ }
375
+ /**
376
+ * The discovery options every piece is looked up with.
377
+ *
378
+ * ONE function, so a field added to {@link StageAppRuntimeInput} cannot reach two
379
+ * of the three lookups and miss the third. It missed all three once already:
380
+ * `resolve` was declared on the input, spelled correctly at the call site in the
381
+ * spec, and silently dropped here — `packages/infra/cli/tsconfig.json` excludes
382
+ * `src/**` + `.spec.ts`, so no excess-property check ever ran on it and the test
383
+ * that was supposed to red the global-cache guard passed with the guard removed.
384
+ */
385
+ function discoveryOptions(input) {
386
+ const options = {};
387
+ if (input.cwd !== undefined)
388
+ options.cwd = input.cwd;
389
+ if (input.env !== undefined)
390
+ options.env = input.env;
391
+ if (input.resolve !== undefined)
392
+ options.resolve = input.resolve;
393
+ return options;
394
+ }
395
+ /**
396
+ * The mode one staged closure file gets.
397
+ *
398
+ * THREE tests, and each covers a case the others do not:
399
+ *
400
+ * * the source's own execute bit — the truth when the closure came off a real
401
+ * filesystem, and the ONLY thing that knows `libexec/gstreamer-1.0/gst-plugin-scanner`
402
+ * is a program (it has no extension, and GStreamer forks it out-of-process so a
403
+ * plugin that crashes on load cannot take the app down with it);
404
+ * * the shared-library spelling, shared with `plan.ts` rather than restated —
405
+ * load-bearing because `actions/upload-artifact` stores NO POSIX mode, so a
406
+ * closure that arrived through a CI artifact has 0644 on every file and the
407
+ * source-mode test above is blind on exactly the path this milestone's own CI
408
+ * leg takes;
409
+ * * `libexec/` and `bin/`, for the same reason applied to the extensionless
410
+ * programs the artifact round trip flattened.
411
+ *
412
+ * A dylib does not need the execute bit to be `dlopen`'d, so this is not about
413
+ * making the closure loadable — it is about the two files that are EXEC'd.
414
+ */
415
+ function stagedMode(rel, abs) {
416
+ if (isExecutableAsset(rel))
417
+ return 0o755;
418
+ if (rel.startsWith('libexec/') || rel.startsWith('bin/'))
419
+ return 0o755;
420
+ try {
421
+ if ((statSync(abs).mode & 0o111) !== 0)
422
+ return 0o755;
423
+ }
424
+ catch {
425
+ // Unreadable here means unreadable in the copy too, and `writeStage` is
426
+ // where that gets reported against the path it happened on.
427
+ }
428
+ return 0o644;
429
+ }
@@ -14,7 +14,32 @@ import type { DistroFormatId } from './types.js';
14
14
  * is how a project whose bundle really does run on older GJS lowers it.
15
15
  */
16
16
  export declare const DEFAULT_GJS_FLOOR = "1.86";
17
+ /**
18
+ * The Node the `--app node` bundles target, as a MAJOR.
19
+ *
20
+ * Node 24 is the LTS line `@gjsify/node-runtime-*` bundles for macOS and
21
+ * Windows, and this is the Linux half of the same decision: where an
22
+ * interpreter is bundled, the floor is what was bundled; where it is depended
23
+ * on, the floor is what the bundle needs.
24
+ *
25
+ * It excludes EVERY current DEB stable/LTS — Debian 13 trixie 20, Ubuntu 24.04
26
+ * LTS 18, Ubuntu 26.04 LTS 22; only Debian 14 forky has 24. `warnAboutNodeFloor`
27
+ * is what makes that visible at ship time, and `gjsify.ship.minNodeVersion` is
28
+ * how a project whose bundle really does run on an older Node lowers it. The
29
+ * same honest-floor-plus-warning answer as {@link DEFAULT_GJS_FLOOR}, one axis
30
+ * over — and it will fire far more often.
31
+ */
32
+ export declare const DEFAULT_NODE_FLOOR = "24";
17
33
  export declare function warnAboutGjsFloor(format: DistroFormatId, floor: string): string[];
34
+ /**
35
+ * Warn when the Node floor cannot be satisfied by a released Debian or Ubuntu.
36
+ * Returns the warning lines; empty when the floor is fine.
37
+ *
38
+ * Only reached when the payload actually needs an interpreter — a `--app gjs`
39
+ * package declares no Node dependency, so warning about its floor would be
40
+ * noise attached to a line that is never emitted.
41
+ */
42
+ export declare function warnAboutNodeFloor(format: DistroFormatId, floor: string): string[];
18
43
  export interface DependsInputs {
19
44
  /** GI namespaces the bundle imports, in `Ns-Version` spelling. */
20
45
  namespaces: readonly string[];
@@ -29,6 +54,29 @@ export interface DependsInputs {
29
54
  hasIcons: boolean;
30
55
  /** Whether the payload installs GSettings schemas. */
31
56
  hasSchemas: boolean;
57
+ /**
58
+ * The interpreter the launcher execs — `settings.app`, the SAME field
59
+ * `renderLauncher` branches on.
60
+ *
61
+ * Not a payload fact, and the attempt to make it one is worth keeping: the
62
+ * first cut derived it from a staged FILENAME (`*.node.mjs`), which is wrong
63
+ * in both directions. `discoverPayload` stages the whole directory beside
64
+ * the bundle and `dist/<name>.gjs.js` next to `dist/<name>.node.mjs` is the
65
+ * documented normal layout, so a pure `--app gjs` project that also builds a
66
+ * Node bundle declared a Node dependency it never execs — and with a `>= 24`
67
+ * floor that `.deb` is REFUSED by apt on trixie, Ubuntu 24.04 and Ubuntu
68
+ * 26.04. A working package made uninstallable is not "one extra package
69
+ * installed".
70
+ *
71
+ * The honest source is the one thing that decides both the launcher and this
72
+ * list. `assertLauncherMatchesInterpreter` re-reads the staged launcher and
73
+ * refuses a package where the two disagree, which is the check a payload
74
+ * heuristic could never be.
75
+ *
76
+ * macOS and Windows never reach here: there the interpreter is CARRIED, from
77
+ * `@gjsify/node-runtime-<target>`.
78
+ */
79
+ interpreter: 'gjs' | 'node';
32
80
  /** User-supplied additions for this format. */
33
81
  extra: readonly string[];
34
82
  /** Project-supplied table rows, filling gaps in {@link TYPELIB_PACKAGES}. */
@@ -40,6 +88,8 @@ export interface DependsInputs {
40
88
  bundledTypelibs?: readonly string[];
41
89
  /** Minimum GJS. Default {@link DEFAULT_GJS_FLOOR}. */
42
90
  minGjsVersion?: string;
91
+ /** Minimum Node major, when one is depended on. Default {@link DEFAULT_NODE_FLOOR}. */
92
+ minNodeVersion?: string;
43
93
  }
44
94
  /**
45
95
  * The `Depends:` / `Requires:` list for one format.
@@ -35,6 +35,70 @@
35
35
  * is how a project whose bundle really does run on older GJS lowers it.
36
36
  */
37
37
  export const DEFAULT_GJS_FLOOR = '1.86';
38
+ /**
39
+ * The Node the `--app node` bundles target, as a MAJOR.
40
+ *
41
+ * Node 24 is the LTS line `@gjsify/node-runtime-*` bundles for macOS and
42
+ * Windows, and this is the Linux half of the same decision: where an
43
+ * interpreter is bundled, the floor is what was bundled; where it is depended
44
+ * on, the floor is what the bundle needs.
45
+ *
46
+ * It excludes EVERY current DEB stable/LTS — Debian 13 trixie 20, Ubuntu 24.04
47
+ * LTS 18, Ubuntu 26.04 LTS 22; only Debian 14 forky has 24. `warnAboutNodeFloor`
48
+ * is what makes that visible at ship time, and `gjsify.ship.minNodeVersion` is
49
+ * how a project whose bundle really does run on an older Node lowers it. The
50
+ * same honest-floor-plus-warning answer as {@link DEFAULT_GJS_FLOOR}, one axis
51
+ * over — and it will fire far more often.
52
+ */
53
+ export const DEFAULT_NODE_FLOOR = '24';
54
+ /**
55
+ * The package providing a Node interpreter, per format.
56
+ *
57
+ * ⚠️ THE TWO STRINGS ARE NOT THE SAME NAME, and getting rpm's wrong is silent.
58
+ * `Requires: nodejs >= 24` is a NO-OP on Fedora. Measured with `dnf repoquery`
59
+ * on Fedora 44:
60
+ *
61
+ * --whatprovides 'nodejs >= 24' → nodejs22-1:22.23.1-2.fc44
62
+ * --whatprovides 'nodejs(engine) >= 24' → nodejs24-1:24.18.0-1.fc44
63
+ *
64
+ * F44 has no real `nodejs` package at all — the name is a virtual Provide of the
65
+ * stream packages, which carry **Epoch 1**. A bare `>= 24` desugars to `0:24`,
66
+ * and `1:22.23.1` beats it on epoch alone, so the floor admits the very version
67
+ * it was written to exclude. `nodejs(engine)` carries no epoch and compares as
68
+ * written. (Streams 20/22/24 are parallel-installable, which is why the name has
69
+ * to be a Provide in the first place.) The spelling appears nowhere in the
70
+ * Fedora packaging guideline's own source — it comes from real spec files.
71
+ *
72
+ * No `/usr/bin/node` file dependency beside it — right answer, and the FIRST
73
+ * reason written here for it was wrong, so both are recorded. It is not that
74
+ * `nodejs24` pulls the symlink in: `/usr/bin/node` is provided by `nodejs20-bin`,
75
+ * `nodejs22-bin` AND `nodejs24-bin`, all three of which
76
+ * `Provides`/`Conflicts: alternative-for(nodejs-bin)` and are therefore mutually
77
+ * exclusive. `nodejs24` itself installs `/usr/bin/node-24`. So the file
78
+ * dependency is satisfied by ANY stream's `-bin`, which makes it worthless as a
79
+ * version constraint — that is why it is not emitted.
80
+ *
81
+ * ⚠️ AND THAT LEAVES A REAL HOLE, which no dependency this writer can emit
82
+ * closes. Measured on Fedora 44:
83
+ *
84
+ * dnf install --assumeno nodejs22-bin 'nodejs(engine) >= 24'
85
+ * → installs nodejs22-bin, nodejs22, nodejs24, nodejs24-libs
86
+ * → and NOT nodejs24-bin
87
+ *
88
+ * Every requirement is met and `node` on `PATH` is still 22. rpm's alternatives
89
+ * scheme has no vocabulary for "the stream that owns the symlink must be at
90
+ * least 24", so an app that must have 24 on `PATH` has to check at runtime; a
91
+ * package cannot ask for it. Recorded here because the gap is invisible from the
92
+ * emitted `Requires:` line, which looks correct and is.
93
+ *
94
+ * (`nodejs24` carries no bare `nodejs` Provide at all — only `nodejs22` does on
95
+ * F44, which is the other half of why the epoch trap above bites.)
96
+ *
97
+ * A `Record`, for the reason {@link SCHEMA_COMPILER_PACKAGE} records: a ternary
98
+ * reads as a two-way choice and is really "deb, or ELSE rpm's name", and a third
99
+ * format silently inheriting rpm's spelling has happened here before, at exit 0.
100
+ */
101
+ const NODE_PACKAGE = { deb: 'nodejs', rpm: 'nodejs(engine)' };
38
102
  /**
39
103
  * The package providing `glib-compile-schemas`, per format.
40
104
  *
@@ -78,6 +142,49 @@ export function warnAboutGjsFloor(format, floor) {
78
142
  'to make apt happy, because the result is an install that succeeds and an app that does not start.',
79
143
  ];
80
144
  }
145
+ /**
146
+ * Node majors per DEB suite, as measured 2026-08-28 — see {@link warnAboutNodeFloor}.
147
+ */
148
+ const DEBIAN_NODE = 'trixie ships 20, forky 24; Ubuntu 24.04 LTS ships 18 and 26.04 LTS 22';
149
+ /**
150
+ * Which formats the Node-floor warning is ABOUT.
151
+ *
152
+ * `rpm` is false, and it is a measured fact about Fedora rather than a default:
153
+ * F43/44/45 default to Node 22 but ship `nodejs24` in the base repository,
154
+ * parallel-installable, so `nodejs(engine) >= 24` resolves without an extra
155
+ * repository. Debian and Ubuntu have no such stream packages — one `nodejs` per
156
+ * suite, and it is older than 24 in every current stable/LTS.
157
+ *
158
+ * A `Record` and not `format !== 'deb'`, for the reason
159
+ * {@link GJS_FLOOR_IS_DEBIAN_NEWS} spells out: the negative form answers "stay
160
+ * quiet" for every format that will ever exist, including ones nobody has
161
+ * checked a distribution for.
162
+ */
163
+ const NODE_FLOOR_IS_DEBIAN_NEWS = { deb: true, rpm: false };
164
+ /**
165
+ * Warn when the Node floor cannot be satisfied by a released Debian or Ubuntu.
166
+ * Returns the warning lines; empty when the floor is fine.
167
+ *
168
+ * Only reached when the payload actually needs an interpreter — a `--app gjs`
169
+ * package declares no Node dependency, so warning about its floor would be
170
+ * noise attached to a line that is never emitted.
171
+ */
172
+ export function warnAboutNodeFloor(format, floor) {
173
+ if (!NODE_FLOOR_IS_DEBIAN_NEWS[format])
174
+ return [];
175
+ // Ubuntu 26.04 LTS's 22 is the newest a current DEB stable/LTS carries;
176
+ // trixie's 20 is older still. A floor of 22 or below is satisfiable
177
+ // somewhere released and stays quiet.
178
+ if (compareVersions(floor, '22') <= 0)
179
+ return [];
180
+ return [
181
+ `gjsify ship: \`Depends: nodejs (>= ${floor})\` is not satisfiable on any current DEB stable/LTS — ` +
182
+ `${DEBIAN_NODE}. The package installs on forky/sid and on distributions with a newer Node. Set ` +
183
+ '`gjsify.ship.minNodeVersion` if this bundle genuinely runs on an older Node; do NOT lower it ' +
184
+ 'to make apt happy, because the result is an install that succeeds and an app that dies on the ' +
185
+ 'first syntax or API the older interpreter does not have.',
186
+ ];
187
+ }
81
188
  /** Numeric dotted-version compare. Enough for `1.82.3` vs `1.86`. */
82
189
  function compareVersions(a, b) {
83
190
  const left = a.split('.').map(Number);
@@ -155,7 +262,14 @@ const BUNDLED_TYPELIB = /^Gjsify[A-Z]/;
155
262
  * @throws when a namespace has no entry in the table — see the module header.
156
263
  */
157
264
  export function deriveDepends(format, inputs) {
158
- const out = [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
265
+ // The interpreter the launcher execs, and nothing else: a package declares ONE.
266
+ // Linux is the only place this is a dependency at all — macOS and Windows have
267
+ // no system Node, so an artifact for those CARRIES one from
268
+ // `@gjsify/node-runtime-<target>`, and that is the whole reason those three
269
+ // packages exist and this one line is their opposite.
270
+ const out = inputs.interpreter === 'node'
271
+ ? [`${NODE_PACKAGE[format]} >= ${inputs.minNodeVersion ?? DEFAULT_NODE_FLOOR}`]
272
+ : [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
159
273
  const unmapped = [];
160
274
  // Namespaces the package SHIPS ITSELF, read off the staged filenames rather than from a
161
275
  // separate declaration: `Gwebgl-0.1.typelib` → `Gwebgl-0.1` and `Gwebgl`. Deriving it is the
@@ -206,8 +320,8 @@ export function deriveDepends(format, inputs) {
206
320
  if (inputs.hasSchemas)
207
321
  out.push(SCHEMA_COMPILER_PACKAGE[format]);
208
322
  out.push(...inputs.extra);
209
- // Set-dedupe keeps first-insertion order, so `gjs` stays first and the
210
- // list is stable across runs.
323
+ // Set-dedupe keeps first-insertion order, so the interpreter stays first and
324
+ // the list is stable across runs.
211
325
  return [...new Set(out)];
212
326
  }
213
327
  /**