@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
@@ -1,7 +1,17 @@
1
+ import type { LauncherRuntime } from './app-runtime.js';
2
+ import type { Layout } from './layout.js';
1
3
  import type { ShipSettings } from './types.js';
2
4
  /**
3
- * Render the launcher for a GJS application.
5
+ * Render the launcher for one layout.
4
6
  *
5
- * `bundleRelPath` is the bundle's path inside `lib/<binaryName>/`.
7
+ * `bundleRelPath` is the bundle's path inside the layout's BUNDLE directory —
8
+ * `lib/<binaryName>/` on Linux, `Contents/Resources/lib/` in a `.app`, `app\` in
9
+ * a Windows program directory.
10
+ *
11
+ * `runtime` names what the stage CARRIES (`utils/ship/app-runtime.ts`), as
12
+ * stage-relative paths, and is empty when it carries nothing. The macOS and
13
+ * Windows forms read it; the Linux one does not and never will, because its
14
+ * interpreter is a package dependency. The default is what keeps a stage that
15
+ * carries nothing byte-identical to what M1 wrote.
6
16
  */
7
- export declare function renderLauncher(settings: ShipSettings, bundleRelPath: string): string;
17
+ export declare function renderLauncher(settings: ShipSettings, bundleRelPath: string, layout: Layout, runtime?: LauncherRuntime): string;
@@ -1,35 +1,108 @@
1
- // The `bin/<name>` entry a package installs.
1
+ // The launcher a package installs in front of the app — one form per OS layout.
2
2
  //
3
- // It derives its own prefix at runtime instead of having one baked in. That is
3
+ // Every form execs ONE interpreter, named by `settings.app`, and `deriveDepends`
4
+ // seeds the dependency from that same field. That coupling is the point rather
5
+ // than a convenience: the first cut of the Node half derived "this payload needs
6
+ // Node" from a FILENAME (`*.node.mjs` anywhere in the staged tree) while this
7
+ // function still execed gjs unconditionally, and the two disagreed in both
8
+ // directions — a `--app gjs` project that merely built a Node bundle beside its
9
+ // GJS one got `Depends: gjs (>= 1.86), nodejs (>= 24)` and a launcher running the
10
+ // GJS bundle, and `>= 24` is unsatisfiable on every current DEB stable, so a
11
+ // working package became uninstallable for a reason its author never opted into.
12
+ // `assertLauncherMatchesInterpreter` in `utils/ship/payload.ts` re-reads this
13
+ // line off the STAGED file and is the gate that keeps the two from drifting
14
+ // apart again. It reads the LAYOUT's launcher, so the three forms below share one
15
+ // `execLine` rather than each answering the question themselves.
16
+ //
17
+ // It derives its own location at runtime instead of having one baked in. That is
4
18
  // what lets ONE staged payload become a `.deb` under `/usr`, an `.rpm` under
5
19
  // `/usr` and a Flatpak under `/app` (ADR 0024 § 3) — a baked path would force a
6
20
  // payload per format and collapse the whole design back into N packagers.
7
21
  //
8
- // `XDG_DATA_DIRS` is prepended for the same reason. Under `/usr` it is already
9
- // on the list and the line is a no-op; installed anywhere else (`/opt`, a
10
- // `--prefix=$HOME/.local` tree, a relocated Flatpak) it is what makes the
11
- // staged icons, the desktop entry and the compiled GSettings schemas findable.
12
- // Getting that from the launcher rather than from the app keeps the app free of
13
- // install-layout knowledge.
22
+ // `XDG_DATA_DIRS` is prepended for the same reason, and on all three OSes: GTK
23
+ // and GLib read it on macOS and Windows too, and it is what makes the staged
24
+ // icons, the desktop entry and the compiled GSettings schemas findable wherever
25
+ // the tree was installed. Getting that from the launcher rather than from the app
26
+ // keeps the app free of install-layout knowledge.
27
+ //
28
+ // THREE FORMS, because neither other OS runs this file. macOS runs a `/bin/sh`
29
+ // script too but cannot use `readlink -f` and must not be handed a `DYLD_*`;
30
+ // Windows runs `cmd.exe`, which shares no syntax with either. What they DO share
31
+ // is the set of decisions — where the prefix comes from, which variables are
32
+ // exported, what is exec'd — so the three renderers sit here side by side and the
33
+ // dispatch is a `switch` with a `never` guard, the same closed-vocabulary
34
+ // discipline `packOne` uses for the packers.
35
+ import { posix } from 'node:path';
14
36
  /** POSIX-shell single-quote: the only escape inside `'…'` is `'\''`. */
15
37
  function shellQuote(value) {
16
38
  return `'${value.replace(/'/g, `'\\''`)}'`;
17
39
  }
18
40
  /**
19
- * Render the launcher for a GJS application.
41
+ * Quote one argument for `cmd.exe`, or refuse it.
42
+ *
43
+ * `cmd.exe` has no escape that survives its own parser for `"` or `%` inside a
44
+ * quoted string: `%` starts an expansion at PARSE time (so `%foo%` silently
45
+ * becomes empty rather than staying literal), and `"` ends the quoted run with
46
+ * nothing that reliably re-opens it. `!` is only special under delayed expansion,
47
+ * which this launcher does not enable, but a consumer copying the line into one
48
+ * that does gets the same silent substitution — so all three are refused with the
49
+ * config key named, rather than emitted into a launcher that runs with the wrong
50
+ * argv.
51
+ */
52
+ function cmdQuote(value) {
53
+ const bad = /["%!\r\n]/.exec(value);
54
+ if (bad !== null) {
55
+ throw new Error(`gjsify ship: \`gjsify.ship.execArgs\` contains ${JSON.stringify(value)}, and cmd.exe has no way ` +
56
+ `to pass ${JSON.stringify(bad[0])} through a Windows launcher literally — \`%\` expands at ` +
57
+ 'parse time and `"` ends the quoted run. Move the value into the app (it can read it from its ' +
58
+ 'own config) or drop the character.');
59
+ }
60
+ return `"${value}"`;
61
+ }
62
+ /**
63
+ * Render the launcher for one layout.
64
+ *
65
+ * `bundleRelPath` is the bundle's path inside the layout's BUNDLE directory —
66
+ * `lib/<binaryName>/` on Linux, `Contents/Resources/lib/` in a `.app`, `app\` in
67
+ * a Windows program directory.
68
+ *
69
+ * `runtime` names what the stage CARRIES (`utils/ship/app-runtime.ts`), as
70
+ * stage-relative paths, and is empty when it carries nothing. The macOS and
71
+ * Windows forms read it; the Linux one does not and never will, because its
72
+ * interpreter is a package dependency. The default is what keeps a stage that
73
+ * carries nothing byte-identical to what M1 wrote.
74
+ */
75
+ export function renderLauncher(settings, bundleRelPath, layout, runtime = {}) {
76
+ switch (layout.name) {
77
+ case 'linux':
78
+ return renderPrefixLauncher(settings, bundleRelPath, layout);
79
+ case 'darwin':
80
+ return renderAppBundleLauncher(settings, bundleRelPath, layout, runtime);
81
+ case 'windows':
82
+ return renderWindowsLauncher(settings, bundleRelPath, layout, runtime);
83
+ default: {
84
+ const unhandled = layout.name;
85
+ throw new Error(`gjsify ship: no launcher is wired for the "${String(unhandled)}" layout.`);
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Linux: a `/bin/sh` script that walks up from its own RESOLVED path to the prefix.
20
91
  *
21
- * `bundleRelPath` is the bundle's path inside `lib/<binaryName>/`.
92
+ * `readlink -f` is what makes this correct when the package's `bin/` entry has
93
+ * been symlinked — `/usr/local/bin/<name>` → `/usr/lib/<pkg>/…` is a layout
94
+ * distributions produce — and it is GNU coreutils', which every Linux this
95
+ * packages for has.
22
96
  */
23
- export function renderLauncher(settings, bundleRelPath) {
24
- const lib = `"$prefix"/lib/${settings.binaryName}`;
25
- const args = settings.execArgs.map(shellQuote).join(' ');
97
+ function renderPrefixLauncher(settings, bundleRelPath, layout) {
98
+ const dirs = layout.dirs(settings);
26
99
  const lines = [
27
100
  '#!/bin/sh',
28
101
  '# Generated by `gjsify ship` — do not edit.',
29
102
  'set -e',
30
103
  'self=$(readlink -f "$0")',
31
104
  'prefix=$(dirname "$(dirname "$self")")',
32
- 'XDG_DATA_DIRS="$prefix/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"',
105
+ `XDG_DATA_DIRS="$prefix/${dirs.data}:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"`,
33
106
  'export XDG_DATA_DIRS',
34
107
  ];
35
108
  // Typelibs the package carries itself (see `gjsify.ship.bundledTypelibs`). PREPENDED, not
@@ -38,7 +111,7 @@ export function renderLauncher(settings, bundleRelPath) {
38
111
  // actually staged — an empty `GI_TYPELIB_PATH` entry makes GI search a directory that is not
39
112
  // there, on every import, forever.
40
113
  if (settings.typelibFiles.length > 0) {
41
- const giDir = '"$prefix"/lib/' + settings.binaryName + '/gi';
114
+ const giDir = `"$prefix"/${dirs.native}`;
42
115
  for (const variable of ['GI_TYPELIB_PATH', 'LD_LIBRARY_PATH']) {
43
116
  // `${VAR:+:$VAR}` appends the existing value with a separator ONLY when it is set —
44
117
  // a plain `:$VAR` leaves a trailing colon, which both loaders read as "and the current
@@ -53,8 +126,280 @@ export function renderLauncher(settings, bundleRelPath) {
53
126
  // above does for icons and schemas — a baked `/usr/share/locale` would be wrong in a Flatpak
54
127
  // and in any `--prefix` tree.
55
128
  if (settings.localeFiles.length > 0) {
56
- lines.push('GJSIFY_LOCALE_DIR="$prefix"/share/locale', 'export GJSIFY_LOCALE_DIR');
129
+ lines.push(`GJSIFY_LOCALE_DIR="$prefix"/${dirs.data}/locale`, 'export GJSIFY_LOCALE_DIR');
130
+ }
131
+ lines.push(`exec ${execLine(layout, `"$prefix"/${dirs.bundle}/${bundleRelPath}`, settings)} "$@"`, '');
132
+ return lines.join('\n');
133
+ }
134
+ /**
135
+ * macOS: `Contents/MacOS/<name>` — what a self-contained bundle's launcher says,
136
+ * and two things it must still NOT do.
137
+ *
138
+ * IT EXECS WHAT THE BUNDLE CARRIES. `runtime` comes from
139
+ * `utils/ship/app-runtime.ts` and names the interpreter, the relocated GTK
140
+ * closure and the node-gi addon staged inside `Contents/` — so the exec line is
141
+ * `"$here/node"` and the two locators below point into `Contents/Frameworks`.
142
+ * When the stage carries none of them every line here is byte-identical to what
143
+ * M2a wrote, which is what keeps a bundle assembled without the runtime packages
144
+ * exactly as (un)usable as it was rather than differently broken.
145
+ *
146
+ * 1. **No `readlink -f`.** That flag is GNU coreutils'; the BSD `readlink` macOS
147
+ * ships does not have it, and a launcher whose first command fails under
148
+ * `set -e` exits before it does anything. It needs none either: LaunchServices,
149
+ * `open(1)` and Finder all exec `Contents/MacOS/<name>` at its real path
150
+ * inside the bundle, and the staged tree holds no symlink to resolve.
151
+ * 2. **No `DYLD_*`.** THE RULE STANDS AND ITS OLD REASON DID NOT, so the reason
152
+ * is replaced rather than the rule. This comment used to say a wrapper
153
+ * "structurally cannot hand the loader a library path" because SIP strips an
154
+ * inherited `DYLD_*` at the `/bin/sh` exec — which is stronger than what was
155
+ * measured, and this repository depends on the difference in two places that
156
+ * are green on the darwin legs today (`utils/bin-shim.ts`'s
157
+ * `dyldFallbackPreamble`, `packages/node-gi`'s `maybeReexecForGtkRuntime`).
158
+ * The narrow fact is that an INHERITED `DYLD_*` is stripped when a PROTECTED
159
+ * binary is exec'd; what a shim exports ITSELF survives into an unprotected
160
+ * child.
161
+ *
162
+ * The reason that holds is one milestone away and points the same way: a
163
+ * hardened-runtime, Developer-ID-signed main executable IS restricted, so the
164
+ * variable is stripped there. A launcher depending on `DYLD_*` therefore
165
+ * works unsigned and breaks on the day the bundle is signed (#1354 M6,
166
+ * ADR 0024 § A4) — the worst possible day to find out. `GI_TYPELIB_PATH` is
167
+ * not a `DYLD_` variable and is stripped under neither rule, so the half that
168
+ * CAN be handed over is; the other half is
169
+ * `GIRepository.Repository.prepend_library_path` from inside the process,
170
+ * which is ADR 0021's decision for prebuilds applied to the app's own runtime.
171
+ *
172
+ * Corrected rather than deleted, because a rule whose stated reason is wrong
173
+ * gets "simplified" back into the bug the first time somebody checks it.
174
+ *
175
+ * What this function exports INSTEAD is the point of the distinction, not an
176
+ * exception to it: `GJSIFY_GTK_RUNTIME`, `NODE_GI_NATIVE`,
177
+ * `GJSIFY_GI_LIBRARY_PATH`, `GI_TYPELIB_PATH`. Every one is read by node-gi
178
+ * or by GLib in JS or in the process, none by dyld, so none is stripped from
179
+ * a restricted process and the bundle behaves the same signed and unsigned.
180
+ * The dylibs themselves need no variable at all: `install_name_tool` rewrote
181
+ * every install name to `@loader_path/<leaf>` when the closure was built, and
182
+ * the addon's `@rpath` is `@loader_path/gtk/lib` — which is exactly why the
183
+ * closure has to be staged as a TREE and not flattened.
184
+ */
185
+ function renderAppBundleLauncher(settings, bundleRelPath, layout, runtime) {
186
+ const root = layout.root(settings);
187
+ const contents = `${root}/Contents`;
188
+ const dirs = layout.dirs(settings);
189
+ // Every path is expressed relative to `Contents/`, which the launcher finds by
190
+ // going one directory up from `Contents/MacOS`. Deriving them from the
191
+ // layout's own directories is what keeps the two from drifting: they are the
192
+ // same strings the planner placed the files with.
193
+ const under = (dir) => posix.relative(contents, dir);
194
+ // …and the interpreter relative to `$here`, which is `Contents/MacOS` itself.
195
+ // Two anchors rather than one because the launcher already computes both, and
196
+ // `"$here/node"` is the shortest true expression for a file beside it.
197
+ const beside = (path) => posix.relative(dirs.launcher, path);
198
+ const lines = [
199
+ '#!/bin/sh',
200
+ '# Generated by `gjsify ship` — do not edit.',
201
+ 'set -e',
202
+ 'here=$(cd -- "$(dirname -- "$0")" && pwd)',
203
+ 'contents=$(dirname -- "$here")',
204
+ `XDG_DATA_DIRS="$contents/${under(dirs.data)}:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"`,
205
+ 'export XDG_DATA_DIRS',
206
+ ];
207
+ // The two locators a carried runtime needs, and both are read by node-gi ITSELF
208
+ // rather than by a loader — which is the whole reason they are allowed here at
209
+ // all (see this function's second numbered note).
210
+ //
211
+ // `GJSIFY_GTK_RUNTIME` is candidate 1 of `resolveGtkRuntimeBundle()`'s four.
212
+ // Candidates 2–4 walk from `@gjsify/node-gi`'s own package directory or through
213
+ // `node_modules`, and a shipped `.app` has neither: its JavaScript is one
214
+ // bundled file under `Contents/Resources/lib`, so `import.meta.url` there
215
+ // resolves node-gi's "package root" to the bundle's directory and every probed
216
+ // path lands beside the bundle instead of in `Contents/Frameworks`.
217
+ //
218
+ // `NODE_GI_NATIVE` is the same problem for the addon: `prebuildAddonPath()`
219
+ // joins `prebuilds/<target>/node_gi.node` onto that same wrong root
220
+ // (`packages/node-gi/node-gi/native-paths.js`). An absolute path is one of the
221
+ // three values that variable takes (`'build'`, `'prebuild'`, or a path), and
222
+ // `nativeCandidates()` resolves it and returns it ALONE — so the bundle's addon
223
+ // is not merely preferred, nothing else is tried.
224
+ if (runtime.gtkRuntimeDir !== undefined) {
225
+ lines.push(`GJSIFY_GTK_RUNTIME="$contents/${under(runtime.gtkRuntimeDir)}"`, 'export GJSIFY_GTK_RUNTIME');
226
+ }
227
+ if (runtime.nodeGiAddon !== undefined) {
228
+ lines.push(`NODE_GI_NATIVE="$contents/${under(runtime.nodeGiAddon)}"`, 'export NODE_GI_NATIVE');
229
+ }
230
+ if (settings.typelibFiles.length > 0) {
231
+ lines.push(`GI_TYPELIB_PATH="$contents/${under(dirs.native)}"\${GI_TYPELIB_PATH:+:$GI_TYPELIB_PATH}`, 'export GI_TYPELIB_PATH');
232
+ // The other half of the same pair, and the half no loader variable can
233
+ // carry here. GI resolves the typelib and then `g_module_open`s the bare
234
+ // leaf it records; `LD_LIBRARY_PATH` answers that on Linux and `DYLD_*`
235
+ // cannot answer it in a signed bundle. `GJSIFY_GI_LIBRARY_PATH` is read by
236
+ // node-gi in JS and handed to `gi_repository_prepend_library_path()`, so
237
+ // dyld never sees it (#1410). Written here for the first time: that fix
238
+ // shipped the READER with no writer anywhere in this pipeline.
239
+ lines.push(`GJSIFY_GI_LIBRARY_PATH="$contents/${under(dirs.native)}"\${GJSIFY_GI_LIBRARY_PATH:+:$GJSIFY_GI_LIBRARY_PATH}`, 'export GJSIFY_GI_LIBRARY_PATH');
240
+ }
241
+ if (settings.localeFiles.length > 0) {
242
+ lines.push(`GJSIFY_LOCALE_DIR="$contents/${under(dirs.data)}/locale"`, 'export GJSIFY_LOCALE_DIR');
57
243
  }
58
- lines.push(`exec gjs -m ${lib}/${bundleRelPath}${args ? ` ${args}` : ''} "$@"`, '');
244
+ // `"$here/node"`, not `node`. The bare name is true of a developer's machine
245
+ // and false of a `.app` a stranger downloads, and the difference is not a
246
+ // degraded experience: macOS ships no Node at all, so the launcher's first act
247
+ // would be `exec: node: not found`.
248
+ const interpreter = runtime.interpreter === undefined ? undefined : `"$here/${beside(runtime.interpreter)}"`;
249
+ lines.push(`exec ${execLine(layout, `"$contents/${under(dirs.bundle)}/${bundleRelPath}"`, settings, interpreter)} "$@"`, '');
59
250
  return lines.join('\n');
60
251
  }
252
+ /**
253
+ * Windows: a `.cmd` that derives the program directory from `%~dp0` — and execs
254
+ * the interpreter that directory carries.
255
+ *
256
+ * CRLF, and that is not cosmetic — `cmd.exe` reads a batch file in chunks and
257
+ * re-seeks by byte OFFSET while it runs, which is where the documented `goto` and
258
+ * block-parsing failures on LF-only files come from. It costs nothing to be right
259
+ * about, and the file is ASCII for the same reason: a batch file is read in the
260
+ * console's active code page, not as UTF-8.
261
+ *
262
+ * `PATH` carries the native directory because Windows has no rpath: a DLL is
263
+ * found on `PATH`, in the directory of the image that loaded it, or not at all.
264
+ * That makes `PATH` the load-bearing variable here — where `LD_LIBRARY_PATH` is
265
+ * on Linux, and where nothing can be on macOS. It is also the answer to the
266
+ * question `GJSIFY_GI_LIBRARY_PATH` answers in the `.app` form: GI resolves a
267
+ * typelib and then `g_module_open`s the bare leaf it records, and on Windows
268
+ * `PATH` reaches that leaf, so no second variable is needed.
269
+ *
270
+ * WHAT THIS DELIBERATELY DOES NOT PUT ON `PATH` is the carried GTK closure's own
271
+ * `bin/`, and the omission is the Windows counterpart of the `.app` form's "no
272
+ * `DYLD_*`" rule — with the opposite reason, so it is written down rather than
273
+ * inferred. node-gi does it ITSELF, in-process, before it `require`s the addon:
274
+ * `maybePrependGtkRuntimeDllPath()` runs at `packages/node-gi/node-gi/index.js`
275
+ * top level, above `loadNative()`, because "Windows re-reads the DLL search path
276
+ * at every LoadLibrary (unlike dyld's launch-time capture), so mutating
277
+ * process.env.PATH in-process before the require() below covers both the static
278
+ * DLL imports and the runtime g_module_open of typelib backers". What that
279
+ * function needs from the launcher is the LOCATOR — `GJSIFY_GTK_RUNTIME`,
280
+ * candidate 1 of `resolveGtkRuntimeBundle()`'s four — and a launcher-set `PATH`
281
+ * would be a second, silently diverging copy of a directory node-gi already
282
+ * derives from it.
283
+ *
284
+ * `runtime` comes from `utils/ship/app-runtime.ts` and names what the program
285
+ * directory CARRIES, as stage-relative paths. When it carries nothing every line
286
+ * here is byte-identical to what M1 wrote (#1354 M3).
287
+ */
288
+ function renderWindowsLauncher(settings, bundleRelPath, layout, runtime) {
289
+ const dirs = layout.dirs(settings);
290
+ // `%~dp0` already ends in a backslash, so every path below concatenates
291
+ // directly — which is the whole dialect difference from the `.app` form's
292
+ // `"$here/node"`, where the separator is written out. `utils/ship/payload.ts`
293
+ // reads this token back and has to know it (`readLauncherInterpreters`).
294
+ const at = (rel) => `%HERE%${windowsPath(rel)}`;
295
+ const lines = [
296
+ '@echo off',
297
+ 'rem Generated by "gjsify ship" - do not edit.',
298
+ 'setlocal',
299
+ 'set "HERE=%~dp0"',
300
+ // `if defined` rather than a bare `;%VAR%`, for the reason the sh form
301
+ // gives: an unset variable leaves a trailing separator, and an empty entry
302
+ // in a Windows search path is read as the current directory.
303
+ prependVar('XDG_DATA_DIRS', at(dirs.data)),
304
+ ];
305
+ // The two locators a carried runtime needs, identical in role to the `.app`
306
+ // form's and read by node-gi ITSELF rather than by a loader. `GJSIFY_GTK_RUNTIME`
307
+ // is candidate 1 of `resolveGtkRuntimeBundle()`'s four, and candidates 2-4 walk
308
+ // from `@gjsify/node-gi`'s own package directory — which, in a shipped program
309
+ // directory, resolves to wherever the staged JavaScript sits and not to `lib/`.
310
+ // `NODE_GI_NATIVE` pins the addon the same way, absolutely, so
311
+ // `nativeCandidates()` returns it ALONE.
312
+ //
313
+ // `set "NAME=value"`, quoted, because an unquoted `set` keeps trailing spaces
314
+ // and a program directory under `C:\Program Files\…` has spaces in it.
315
+ if (runtime.gtkRuntimeDir !== undefined) {
316
+ lines.push(`set "GJSIFY_GTK_RUNTIME=${at(runtime.gtkRuntimeDir)}"`);
317
+ }
318
+ if (runtime.nodeGiAddon !== undefined) {
319
+ lines.push(`set "NODE_GI_NATIVE=${at(runtime.nodeGiAddon)}"`);
320
+ }
321
+ if (settings.typelibFiles.length > 0) {
322
+ lines.push(prependVar('GI_TYPELIB_PATH', at(dirs.native)), prependVar('PATH', at(dirs.native)));
323
+ }
324
+ if (settings.localeFiles.length > 0) {
325
+ lines.push(`set "GJSIFY_LOCALE_DIR=${at(dirs.data)}\\locale"`);
326
+ }
327
+ // `"%HERE%node.exe"`, not `node`. The bare name is true of a developer's
328
+ // machine and false of a program directory a stranger unzips: Windows ships no
329
+ // Node at all, so the launcher's first act would be `'node' is not recognized
330
+ // as an internal or external command`.
331
+ const interpreter = runtime.interpreter === undefined ? undefined : `"${at(runtime.interpreter)}"`;
332
+ // No `exec` in batch. `cmd.exe` reports the last command's exit code when the
333
+ // script ends, so the interpreter's status is the launcher's status.
334
+ lines.push(`${execLine(layout, `"${at(dirs.bundle)}\\${windowsPath(bundleRelPath)}"`, settings, interpreter)} %*`, '');
335
+ return lines.join('\r\n');
336
+ }
337
+ /** `set "VAR=<value>;%VAR%"`, and just `<value>` when VAR is not set. */
338
+ function prependVar(variable, value) {
339
+ return `if defined ${variable} (set "${variable}=${value};%${variable}%") else (set "${variable}=${value}")`;
340
+ }
341
+ /** POSIX-separated → Windows-separated. An empty segment would produce `\\`, which is a UNC root. */
342
+ function windowsPath(rel) {
343
+ return rel
344
+ .split('/')
345
+ .filter((part) => part.length > 0)
346
+ .join('\\');
347
+ }
348
+ /**
349
+ * The interpreter, the bundle and the project's own `execArgs` — for every layout.
350
+ *
351
+ * ONE function, called by all three forms, because the answer is the same
352
+ * question everywhere and the launcher is where it is checkable:
353
+ * `assertLauncherMatchesInterpreter` reads this line back off the STAGED file
354
+ * and compares it with the dependency about to be declared.
355
+ *
356
+ * `settings.app`, NOT the layout. ADR 0024 § 4's runtime-per-OS table says what
357
+ * a shipped artifact CARRIES — that is `Layout.shippedRuntime` — and reading it
358
+ * as a per-layout launcher decision breaks both directions at once: it would
359
+ * refuse `gjsify.app: "gjs"` for the macOS layout, and it would put `exec node`
360
+ * in front of a bundle whose first line is
361
+ * `import Gtk from 'gi://Gtk?version=4.0'`. Measured before it was fixed: a
362
+ * project with no `gjsify.app` key staged
363
+ * `exec node "$contents/Resources/lib/gjs.js"` for macOS at exit 0. The only
364
+ * interpreter that can read the payload is the one the payload was BUILT for,
365
+ * and that is `settings.app` on all three.
366
+ *
367
+ * `Layout.runtimeGap` is where the honest half of that lives: it says, per OS,
368
+ * why the staged launcher does not yet name what § 4 derives, and `gjsify ship`
369
+ * prints it.
370
+ *
371
+ * `gjs` needs `-m` to treat the bundle as an ES module; `node` decides from the
372
+ * extension and rejects the flag.
373
+ *
374
+ * THE NAME OR A PATH, and which one is a fact about the STAGE. On Linux it stays
375
+ * the bare name, because the emitted dependency is what guarantees the
376
+ * interpreter is on `PATH` — and on Fedora `/usr/bin/node` is an alternatives
377
+ * symlink whose target is whichever stream package won, so hardcoding a path
378
+ * there would pin the launcher to a layout the dependency does not promise. macOS
379
+ * and Windows have no system Node and no dependency to declare, which is what
380
+ * `@gjsify/node-runtime-<target>` exists for; this comment used to say "the day a
381
+ * stage carries one, THIS is the line that names it by a layout-relative path
382
+ * instead", and #1354 M2b is that day — `utils/ship/app-runtime.ts` stages the
383
+ * interpreter into `Contents/MacOS/node` and the `.app` launcher execs
384
+ * `"$here/node"`.
385
+ *
386
+ * #1354 M3 is the same day one OS over: the Windows layout stages `node.exe` into
387
+ * the program directory and its `.cmd` runs `"%HERE%node.exe"`.
388
+ *
389
+ * `utils/ship/payload.ts`'s `readLauncherInterpreters` reads those quoted,
390
+ * variable-bearing tokens back to the bare name `node`, which is what keeps
391
+ * `assertLauncherMatchesInterpreter` a check rather than a vacuous pass. The two
392
+ * dialects differ in a way that reader has to know: `$here/` writes its separator
393
+ * and `%~dp0` already ends in one, so the macOS token splits on a `/` that is
394
+ * there and the Windows token has no separator to split on at all.
395
+ */
396
+ function execLine(layout, bundle, settings, interpreter) {
397
+ const quote = layout.name === 'windows' ? cmdQuote : shellQuote;
398
+ const args = settings.execArgs.map(quote).join(' ');
399
+ // The NAME when the stage carries no interpreter, the layout-relative PATH
400
+ // when it does. `-m` stays attached to `gjs` in both branches: it tells the
401
+ // interpreter to read the bundle as a module and has nothing to do with how
402
+ // the interpreter was found.
403
+ const program = interpreter ?? (settings.app === 'node' ? 'node' : 'gjs');
404
+ return `${settings.app === 'node' ? program : `${program} -m`} ${bundle}${args ? ` ${args}` : ''}`;
405
+ }