@gjsify/cli 0.43.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 (87) hide show
  1. package/dist/cli.gjs.mjs +453 -425
  2. package/lib/actions/build.js +2 -1
  3. package/lib/commands/affected-classify.js +7 -5
  4. package/lib/commands/build.js +6 -0
  5. package/lib/commands/flatpak/ci.js +10 -2
  6. package/lib/commands/flatpak/init.js +15 -16
  7. package/lib/commands/flatpak/utils.d.ts +0 -26
  8. package/lib/commands/flatpak/utils.js +4 -29
  9. package/lib/commands/gettext.d.ts +2 -1
  10. package/lib/commands/gettext.js +105 -47
  11. package/lib/commands/onboard.d.ts +7 -0
  12. package/lib/commands/onboard.js +349 -96
  13. package/lib/commands/ship.d.ts +3 -0
  14. package/lib/commands/ship.js +911 -70
  15. package/lib/commands/trust.d.ts +6 -0
  16. package/lib/commands/trust.js +1 -1
  17. package/lib/config.js +25 -0
  18. package/lib/types/cli-build-options.d.ts +11 -0
  19. package/lib/types/config-data.d.ts +137 -3
  20. package/lib/utils/flatpak-runtime.d.ts +41 -0
  21. package/lib/utils/flatpak-runtime.js +54 -0
  22. package/lib/utils/msgfmt-merge.d.ts +35 -0
  23. package/lib/utils/msgfmt-merge.js +102 -0
  24. package/lib/utils/npm-otp.d.ts +27 -2
  25. package/lib/utils/npm-otp.js +82 -7
  26. package/lib/utils/onboard-discovery.d.ts +68 -0
  27. package/lib/utils/onboard-discovery.js +178 -0
  28. package/lib/utils/onboard-probe.d.ts +118 -1
  29. package/lib/utils/onboard-probe.js +153 -4
  30. package/lib/utils/prompt-output.d.ts +12 -0
  31. package/lib/utils/prompt-output.js +54 -0
  32. package/lib/utils/prompt.js +22 -2
  33. package/lib/utils/publishable-packages.d.ts +1 -1
  34. package/lib/utils/publishable-packages.js +1 -0
  35. package/lib/utils/resolve-npm-package.d.ts +56 -0
  36. package/lib/utils/resolve-npm-package.js +69 -0
  37. package/lib/utils/ship/app-runtime.d.ts +214 -0
  38. package/lib/utils/ship/app-runtime.js +429 -0
  39. package/lib/utils/ship/depends.d.ts +54 -7
  40. package/lib/utils/ship/depends.js +120 -6
  41. package/lib/utils/ship/discover.js +18 -1
  42. package/lib/utils/ship/dmg.d.ts +71 -0
  43. package/lib/utils/ship/dmg.js +181 -0
  44. package/lib/utils/ship/flatpak-config.d.ts +66 -0
  45. package/lib/utils/ship/flatpak-config.js +132 -0
  46. package/lib/utils/ship/flatpak.d.ts +45 -0
  47. package/lib/utils/ship/flatpak.js +148 -0
  48. package/lib/utils/ship/formats.d.ts +116 -3
  49. package/lib/utils/ship/formats.js +869 -11
  50. package/lib/utils/ship/launcher.d.ts +13 -3
  51. package/lib/utils/ship/launcher.js +362 -17
  52. package/lib/utils/ship/layout.d.ts +268 -0
  53. package/lib/utils/ship/layout.js +351 -0
  54. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  55. package/lib/utils/ship/localize-metadata.js +126 -0
  56. package/lib/utils/ship/mime.js +1 -9
  57. package/lib/utils/ship/msi.d.ts +126 -0
  58. package/lib/utils/ship/msi.js +417 -0
  59. package/lib/utils/ship/node-runtime.d.ts +71 -0
  60. package/lib/utils/ship/node-runtime.js +122 -0
  61. package/lib/utils/ship/payload.d.ts +156 -5
  62. package/lib/utils/ship/payload.js +580 -14
  63. package/lib/utils/ship/plan.d.ts +21 -0
  64. package/lib/utils/ship/plan.js +20 -7
  65. package/lib/utils/ship/plist.d.ts +58 -0
  66. package/lib/utils/ship/plist.js +198 -0
  67. package/lib/utils/ship/rpm.js +9 -3
  68. package/lib/utils/ship/schemas.d.ts +61 -0
  69. package/lib/utils/ship/schemas.js +118 -0
  70. package/lib/utils/ship/scripts.js +7 -3
  71. package/lib/utils/ship/settings.d.ts +19 -1
  72. package/lib/utils/ship/settings.js +19 -1
  73. package/lib/utils/ship/share-dirs.d.ts +23 -0
  74. package/lib/utils/ship/share-dirs.js +46 -0
  75. package/lib/utils/ship/signing.d.ts +195 -0
  76. package/lib/utils/ship/signing.js +317 -0
  77. package/lib/utils/ship/stage-manifest.d.ts +50 -13
  78. package/lib/utils/ship/stage-manifest.js +137 -14
  79. package/lib/utils/ship/stage-writer.d.ts +48 -0
  80. package/lib/utils/ship/stage-writer.js +71 -1
  81. package/lib/utils/ship/types.d.ts +264 -16
  82. package/lib/utils/ship/xml.d.ts +7 -0
  83. package/lib/utils/ship/xml.js +25 -0
  84. package/lib/utils/ship/zip.d.ts +69 -0
  85. package/lib/utils/ship/zip.js +245 -0
  86. package/lib/utils/trust-registry.js +5 -0
  87. package/package.json +19 -19
@@ -0,0 +1,417 @@
1
+ // The Windows installer — ADR 0024 stage 5, issue #1354 M5.
2
+ //
3
+ // ONE AUTHORED DOCUMENT, TWO COMPILERS. Everything below produces a `.wxs` in
4
+ // WiX v3's schema (`http://schemas.microsoft.com/wix/2006/wi`) and hands it to
5
+ // whichever compiler the host has: `wixl` from `msitools` on Linux, `candle` +
6
+ // `light` from WiX Toolset v3.14 on Windows. That is not two implementations of
7
+ // the same thing — the DOCUMENT is the implementation and it is written once;
8
+ // the backends are two readers of it, which is the entire point (§ A6). A
9
+ // wixl-only path would be this tree's writer read back by `msiinfo`, i.e. by
10
+ // wixl's own package.
11
+ //
12
+ // WIX v3 AND NOT v4/v5, and that is forced rather than nostalgic: `wixl`
13
+ // implements a subset of the 2006 schema, while WiX v4 moved to
14
+ // `http://wixtoolset.org/schemas/v4/wxs` with renamed elements. One document
15
+ // cannot be in two namespaces, so the shared one is v3 — which is also what
16
+ // `windows-latest` ships (measured: WiX Toolset 3.14.1.8722 in the
17
+ // `actions/runner-images` software list for Windows Server 2025).
18
+ //
19
+ // WHAT THE INSTALLER ACTUALLY DOES, because "an `.msi` exists" is not the claim:
20
+ //
21
+ // * lays the program directory `windows-dir` produces under
22
+ // `%ProgramFiles%\<App>`, overridable with `msiexec INSTALLDIR=…`;
23
+ // * gives the user something to start — one Start-Menu shortcut at the top
24
+ // level, aimed at the same `.cmd` launcher the zip's user double-clicks;
25
+ // * comes off again: Add/Remove Programs gets its entry from the MSI's own
26
+ // `ProductName`/`ProductVersion`/`Manufacturer`, and `msiexec /x` removes
27
+ // every file the package installed. The `windows-dir-selfcontained` leg's
28
+ // sibling asserts the install root is GONE afterwards, because an installer
29
+ // that cannot be removed is worse than none.
30
+ //
31
+ // FIVE THINGS WERE MEASURED against `msitools 0.106.58-1.fc44` (`wixl
32
+ // 0.106.58-a155`) before this file was written, each with a plausible wrong
33
+ // answer:
34
+ //
35
+ // 1. `wixl -a x64` sets Component `Attributes` to 256 — `msidbComponent-
36
+ // Attributes64bit` — on its own, so `Win64="yes"` is neither written here nor
37
+ // needed on either backend (`candle -arch x64` does the same).
38
+ // 2. An ABSOLUTE `Source=` resolves — and this file writes RELATIVE ones anyway,
39
+ // which is the measurement changing a decision rather than confirming it. The
40
+ // SAME document is compiled twice, on two machines: `wixl` here and WiX v3 on
41
+ // the `windows-latest` leg, out of a `.wxs` that travelled as an artifact. An
42
+ // absolute path is the build host's, so the second compile would fail on every
43
+ // file. `root/<path>`, POSIX-separated, resolves for both — Win32 accepts `/`
44
+ // — and it also keeps the document itself byte-identical between two builds of
45
+ // one project in different directories.
46
+ // 3. An explicit `Product/@Id` GUID is honoured (`msiinfo export … Property`
47
+ // prints it back verbatim), so the artifact can be DETERMINISTIC. `Id="*"`
48
+ // would give every rebuild a different `ProductCode`.
49
+ // 4. A `<Shortcut Advertise="yes">` nested inside `<File>` compiles, which is
50
+ // what keeps the shortcut in the launcher's own per-machine component instead
51
+ // of a second component with an HKCU key path — the shape ICE43/ICE57 argue
52
+ // about on `light`.
53
+ // 5. `Property/@Secure` is ACCEPTED and IGNORED by wixl (`SecureCustom-
54
+ // Properties` came back holding only the two `MajorUpgrade` values), so this
55
+ // file does not emit one: a `<Property Id="INSTALLDIR">` beside
56
+ // `<Directory Id="INSTALLDIR">` would be a divergence between the backends
57
+ // bought for nothing. A directory whose id is a public property is settable
58
+ // from a command line either way.
59
+ //
60
+ // AND ONE THING IT DOES NOT FIX. `node.exe` is a CONSOLE-subsystem image and the
61
+ // Node release ships no `nodew.exe` (`status/open-todos.md` carries the offsets),
62
+ // so the shortcut this installer writes pops a console window behind the GUI
63
+ // exactly as the `.cmd` does. Nothing here changes that, and no leg can observe
64
+ // it — every Windows CI leg starts the app from a shell and already has one.
65
+ import { createHash } from 'node:crypto';
66
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
67
+ import { join, posix } from 'node:path';
68
+ import { describeExit, spawnToCompletion } from '../spawn.js';
69
+ import { xmlEscape } from './xml.js';
70
+ /**
71
+ * The UUID namespace every GUID in a generated `.msi` is derived under.
72
+ *
73
+ * A CONSTANT, and changing it is a breaking change rather than a cosmetic one:
74
+ * the `UpgradeCode` derived from it is how Windows Installer recognises an
75
+ * already-installed copy of the same product, so a new namespace makes every
76
+ * future release a SECOND product that installs beside the old one instead of
77
+ * over it. Written down here rather than passed in for that reason — a knob would
78
+ * be a knob that silently forks a product line.
79
+ */
80
+ export const MSI_UUID_NAMESPACE = '7b3a1f6e-0c4d-4a2b-9e51-3d8c6f2a0b14';
81
+ /** RFC 4122 § 4.3 name-based UUID, SHA-1 flavour. */
82
+ export function uuid5(namespace, name) {
83
+ const ns = namespace.replace(/-/g, '');
84
+ const nsBytes = new Uint8Array(16);
85
+ for (let i = 0; i < 16; i += 1)
86
+ nsBytes[i] = Number.parseInt(ns.slice(i * 2, i * 2 + 2), 16);
87
+ const digest = createHash('sha1').update(nsBytes).update(new TextEncoder().encode(name)).digest();
88
+ const bytes = Uint8Array.from(digest.subarray(0, 16));
89
+ // Version 5 in the high nibble of byte 6, RFC 4122 variant in byte 8.
90
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
91
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
92
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
93
+ return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join('-');
94
+ }
95
+ /**
96
+ * The code that identifies the PRODUCT LINE, stable across every version.
97
+ *
98
+ * Derived from the app id alone. That is what `MajorUpgrade` compares, so it must
99
+ * NOT move when the version does — an `UpgradeCode` that changed per release would
100
+ * leave every old version installed beside the new one, silently, with two entries
101
+ * in Add/Remove Programs and two copies on disk.
102
+ */
103
+ export function msiUpgradeCode(settings) {
104
+ return uuid5(MSI_UUID_NAMESPACE, `upgrade\n${settings.appId}`).toUpperCase();
105
+ }
106
+ /**
107
+ * The code that identifies THIS build, and it has to move when the version does.
108
+ *
109
+ * The exact inverse of {@link msiUpgradeCode}: Windows Installer refuses to
110
+ * install a second package carrying a `ProductCode` it already has, so a code that
111
+ * did not change per version would make an upgrade a no-op. The arch is in the
112
+ * material because an x64 and a (future) arm64 build of one version are two
113
+ * products, not one.
114
+ */
115
+ export function msiProductCode(settings, archLabel) {
116
+ const material = `product\n${settings.appId}\n${settings.version}\n${settings.release}\n${archLabel}`;
117
+ return uuid5(MSI_UUID_NAMESPACE, material).toUpperCase();
118
+ }
119
+ /** A component's GUID, stable for as long as its path is. */
120
+ export function msiComponentGuid(settings, path) {
121
+ return uuid5(MSI_UUID_NAMESPACE, `component\n${settings.appId}\n${path}`).toUpperCase();
122
+ }
123
+ /**
124
+ * MSI's `ProductVersion`, which is a much smaller vocabulary than npm's.
125
+ *
126
+ * `major.minor.build`, with `major`/`minor` at most 255 and `build` at most 65535 —
127
+ * anything outside that is not a version Windows Installer will compare, and the
128
+ * fourth field it accepts is IGNORED by upgrade detection, which is why nothing is
129
+ * ever put there.
130
+ *
131
+ * A PRERELEASE IS REFUSED rather than truncated, and that is the interesting case.
132
+ * `normaliseVersion` turns `1.2.0-rc.1` into `1.2.0~rc.1` because dpkg and rpm sort
133
+ * a `~` before the release; MSI has no such spelling, so the only two options were
134
+ * to drop the suffix or to say no. Dropping it makes `1.2.0~rc.1` and `1.2.0` the
135
+ * same `ProductVersion` — two products `MajorUpgrade` cannot tell apart, so
136
+ * installing the release over the candidate leaves BOTH on the machine. That is
137
+ * the "installs cleanly, does the wrong thing" class, produced by the field meant
138
+ * to prevent it.
139
+ */
140
+ export function msiProductVersion(version) {
141
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?$/.exec(version);
142
+ if (match === null) {
143
+ throw new Error(`gjsify ship: "${version}" is not a version an \`.msi\` can carry. Windows Installer's ` +
144
+ '`ProductVersion` is `major.minor.build` and nothing else — no prerelease suffix, no build ' +
145
+ 'metadata.\n' +
146
+ ' Dropping the suffix is not an option this command will take silently: `1.2.0~rc.1` and ' +
147
+ '`1.2.0` would\n' +
148
+ ' become the same version, `MajorUpgrade` could not tell them apart, and installing one over ' +
149
+ 'the other\n' +
150
+ ' would leave both on the machine. Set `gjsify.ship.version` to a plain `x.y.z`, or drop ' +
151
+ '`msi` from the targets.');
152
+ }
153
+ const [major, minor, build] = [Number(match[1]), Number(match[2]), Number(match[3] ?? '0')];
154
+ if (major > 255 || minor > 255 || build > 65535) {
155
+ throw new Error(`gjsify ship: version "${version}" is outside what Windows Installer compares — \`major\` and ` +
156
+ '`minor` are at most 255 and `build` at most 65535. A field over the limit is not rejected by ' +
157
+ 'the installer, it is\n' +
158
+ ' TRUNCATED, so the artifact would carry a version that is not the one it was built from. ' +
159
+ 'Set `gjsify.ship.version`.');
160
+ }
161
+ return `${major}.${minor}.${build}`;
162
+ }
163
+ /**
164
+ * An MSI `Identifier`: `[A-Za-z_][A-Za-z0-9_.]{0,71}`.
165
+ *
166
+ * The readable tail is not decoration — `msiinfo export <msi> File` is the oracle,
167
+ * and an id of pure hash makes a mismatch unreadable. The digest is what makes it
168
+ * UNIQUE: it is taken over the full path, so two files whose last 57 characters
169
+ * agree still differ. `renderWxs` asserts uniqueness anyway rather than trusting
170
+ * 48 bits, because the failure of a collision is two files fighting over one row.
171
+ */
172
+ export function msiIdentifier(prefix, path) {
173
+ const digest = createHash('sha1').update(new TextEncoder().encode(path)).digest('hex').slice(0, 12);
174
+ const safe = path.replace(/[^A-Za-z0-9_.]/g, '_');
175
+ const room = 72 - prefix.length - 2 - digest.length;
176
+ return `${prefix}_${safe.length > room ? safe.slice(safe.length - room) : safe}_${digest}`;
177
+ }
178
+ /**
179
+ * What Windows Installer will not hold in a `FileName`/`DefaultDir` cell.
180
+ *
181
+ * A SUPERSET of `windowsProgramDirName`'s rules and a different question: that one
182
+ * asks what Win32 can create, this asks what the installer DATABASE can address.
183
+ * MSI adds `;` (its list separator inside `DefaultDir`) and a 255-byte ceiling per
184
+ * segment to the reserved characters, and refuses `.`/`..` outright.
185
+ */
186
+ const MSI_NAME_FORBIDDEN = /[<>:"/\\|?*;]/;
187
+ function assertNameIsAddressable(segment, path) {
188
+ if (segment === '.' || segment === '..' || segment.length === 0 || MSI_NAME_FORBIDDEN.test(segment)) {
189
+ throw new Error(`gjsify ship: the payload holds "${path}", and Windows Installer cannot address the path segment ` +
190
+ `"${segment}". A \`FileName\` or \`DefaultDir\` cell reserves < > : " / \\ | ? * and ; (its own ` +
191
+ 'list separator), and \n `.` and `..` are not names. The `.msi` would build and install the ' +
192
+ 'file somewhere else, or not at all.');
193
+ }
194
+ if (segment.length > 255) {
195
+ throw new Error(`gjsify ship: the payload holds "${path}", whose segment "${segment.slice(0, 40)}…" is ` +
196
+ `${segment.length} characters. Windows Installer's \`FileName\` column is 255.`);
197
+ }
198
+ }
199
+ /** Where {@link buildMsi} writes the payload, and therefore what `Source=` is relative to. */
200
+ export const MSI_PAYLOAD_DIR = 'root';
201
+ function directoryTree(files) {
202
+ const root = { path: '', name: '', children: new Map(), files: [] };
203
+ for (const file of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
204
+ const segments = file.path.split('/');
205
+ const leaf = segments.pop();
206
+ if (leaf === undefined)
207
+ throw new Error(`gjsify ship: the payload holds an empty path.`);
208
+ let node = root;
209
+ for (const segment of segments) {
210
+ assertNameIsAddressable(segment, file.path);
211
+ const next = node.children.get(segment) ?? {
212
+ path: node.path === '' ? segment : `${node.path}/${segment}`,
213
+ name: segment,
214
+ children: new Map(),
215
+ files: [],
216
+ };
217
+ node.children.set(segment, next);
218
+ node = next;
219
+ }
220
+ assertNameIsAddressable(leaf, file.path);
221
+ node.files.push(file);
222
+ }
223
+ return root;
224
+ }
225
+ /**
226
+ * Render the installer's source document.
227
+ *
228
+ * PURE — a string in, a string out, no filesystem and no compiler — which is what
229
+ * makes every decision above unit-testable on a machine with neither backend
230
+ * installed. The one thing it cannot answer is whether a compiler accepts what it
231
+ * wrote, and that is what the two legs are for.
232
+ */
233
+ export function renderWxs(input) {
234
+ const { settings, programDirName, archLabel } = input;
235
+ const productVersion = msiProductVersion(settings.version);
236
+ // `Name <email>` is `PackSettings.maintainer`'s shape; ARP shows `Publisher`
237
+ // to a human, and an address in that field reads as a mistake.
238
+ const manufacturer = settings.maintainer.replace(/\s*<[^>]*>\s*$/, '').trim() || settings.maintainer;
239
+ const launcher = `${settings.binaryName}.cmd`;
240
+ const identifiers = new Map();
241
+ const claim = (id, what) => {
242
+ const previous = identifiers.get(id);
243
+ if (previous !== undefined) {
244
+ throw new Error(`gjsify ship: the MSI identifier "${id}" would be shared by ${previous} and ${what}. ` +
245
+ 'Two rows cannot carry one id — one would silently replace the other.');
246
+ }
247
+ identifiers.set(id, what);
248
+ return id;
249
+ };
250
+ const lines = [];
251
+ const componentIds = [];
252
+ let launcherFound = false;
253
+ const emitDir = (node, indent) => {
254
+ for (const file of node.files) {
255
+ const componentId = claim(msiIdentifier('c', file.path), file.path);
256
+ const fileId = claim(msiIdentifier('f', file.path), file.path);
257
+ componentIds.push(componentId);
258
+ const guid = msiComponentGuid(settings, file.path);
259
+ // `posix.basename`, not `basename`: `MsiFile.path` is POSIX-separated
260
+ // by construction (it is the STAGED path), and the host's separator is
261
+ // not the authority for it — a Windows packing host would otherwise
262
+ // split a name containing a backslash.
263
+ const name = xmlEscape(posix.basename(file.path));
264
+ lines.push(`${indent}<Component Id="${componentId}" Guid="${guid}">`);
265
+ // THE SHORTCUT LIVES IN THE LAUNCHER'S OWN COMPONENT, nested in its
266
+ // `<File>` and advertised. The obvious alternative — a second component
267
+ // under `ProgramMenuFolder` whose key path is an HKCU registry value —
268
+ // is the shape `light`'s ICE43 asks for and ICE57 then objects to,
269
+ // because a per-machine install with a per-user key path is a component
270
+ // that is half in each world. Nesting it makes the key path the file, so
271
+ // the component is per-machine throughout and the shortcut is removed
272
+ // with it.
273
+ if (file.path === launcher) {
274
+ launcherFound = true;
275
+ lines.push(`${indent} <File Id="${fileId}" Name="${name}" ` +
276
+ `Source="${xmlEscape(file.source)}" KeyPath="yes">`);
277
+ lines.push(`${indent} <Shortcut Id="${claim('s_startmenu', 'the Start-Menu shortcut')}" ` +
278
+ `Directory="ProgramMenuFolder" Name="${xmlEscape(settings.name)}" ` +
279
+ `WorkingDirectory="INSTALLDIR" Description="${xmlEscape(settings.summary)}" ` +
280
+ `Advertise="yes" />`);
281
+ lines.push(`${indent} </File>`);
282
+ }
283
+ else {
284
+ lines.push(`${indent} <File Id="${fileId}" Name="${name}" ` +
285
+ `Source="${xmlEscape(file.source)}" KeyPath="yes" />`);
286
+ }
287
+ lines.push(`${indent}</Component>`);
288
+ }
289
+ for (const child of [...node.children.values()].sort((a, b) => (a.name < b.name ? -1 : 1))) {
290
+ const dirId = claim(msiIdentifier('d', child.path), `the directory ${child.path}`);
291
+ lines.push(`${indent}<Directory Id="${dirId}" Name="${xmlEscape(child.name)}">`);
292
+ emitDir(child, `${indent} `);
293
+ lines.push(`${indent}</Directory>`);
294
+ }
295
+ };
296
+ emitDir(directoryTree(input.files), ' ');
297
+ // EMPTINESS FIRST, and the order is load-bearing rather than tidy: an empty
298
+ // payload has no launcher either, so the launcher refusal below fires on it and
299
+ // sends the reader to look for a `.cmd` in a tree that has no files at all.
300
+ if (componentIds.length === 0) {
301
+ throw new Error('gjsify ship: the windows payload is empty, so there is nothing for an `.msi` to install.');
302
+ }
303
+ if (!launcherFound) {
304
+ throw new Error(`gjsify ship: the windows payload carries no "${launcher}" at the root of the program directory, ` +
305
+ 'so the installer would have nothing to point a Start-Menu shortcut at — an application a user ' +
306
+ 'installs and\n cannot start. `windows-dir` puts the launcher there; a payload without one ' +
307
+ 'is a stage assembled for another layout.');
308
+ }
309
+ const about = settings.homepage === undefined
310
+ ? []
311
+ : [` <Property Id="ARPURLINFOABOUT" Value="${xmlEscape(settings.homepage)}" />`];
312
+ return [
313
+ '<?xml version="1.0" encoding="utf-8"?>',
314
+ '<!-- Generated by `gjsify ship` (ADR 0024 stage 5). Do not edit; edit utils/ship/msi.ts. -->',
315
+ '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">',
316
+ ` <Product Id="${msiProductCode(settings, archLabel)}" Name="${xmlEscape(settings.name)}" ` +
317
+ `Language="1033" Version="${productVersion}" Manufacturer="${xmlEscape(manufacturer)}" ` +
318
+ `UpgradeCode="${msiUpgradeCode(settings)}">`,
319
+ ` <Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" ` +
320
+ `Manufacturer="${xmlEscape(manufacturer)}" Description="${xmlEscape(settings.summary)}" />`,
321
+ // The whole reason `UpgradeCode` is derived from the app id: this is what
322
+ // takes the previous version off before laying the new one down.
323
+ ` <MajorUpgrade DowngradeErrorMessage="A newer version of ${xmlEscape(settings.name)} is ` +
324
+ `already installed." />`,
325
+ // ONE embedded cabinet, so the artifact is the single file a browser
326
+ // downloads rather than an `.msi` plus loose `.cab` siblings that a user
327
+ // will separate from it.
328
+ ' <Media Id="1" Cabinet="app.cab" EmbedCab="yes" />',
329
+ ...about,
330
+ ' <Directory Id="TARGETDIR" Name="SourceDir">',
331
+ ' <Directory Id="ProgramFiles64Folder">',
332
+ // ONE LEVEL, not `%ProgramFiles%\<Publisher>\<App>`, and the reason is an
333
+ // assertion rather than taste: the tree this lays down is then byte-for-byte
334
+ // the tree `windows-dir-zip` expands to, so the two artifacts can be
335
+ // compared against each other instead of each against its own idea of the
336
+ // layout. A publisher level would also be a directory nothing owns, left
337
+ // behind empty by an uninstall.
338
+ ` <Directory Id="INSTALLDIR" Name="${xmlEscape(programDirName)}">`,
339
+ ...lines,
340
+ ' </Directory>',
341
+ ' </Directory>',
342
+ // Declared and left empty: the shortcut reaches it by `Directory=`, so this
343
+ // exists only to give that reference a row. No `<RemoveFolder>` on it — that
344
+ // would ask the installer to delete the Start Menu's Programs folder itself.
345
+ ' <Directory Id="ProgramMenuFolder" />',
346
+ ' </Directory>',
347
+ ` <Feature Id="Main" Title="${xmlEscape(settings.name)}" Level="1">`,
348
+ ...componentIds.map((id) => ` <ComponentRef Id="${id}" />`),
349
+ ' </Feature>',
350
+ ' </Product>',
351
+ '</Wix>',
352
+ '',
353
+ ].join('\n');
354
+ }
355
+ /**
356
+ * The backend for one host.
357
+ *
358
+ * A FUNCTION of the host and nothing else, so the choice is testable from either
359
+ * OS. `assertHostCanFinish` has already refused anything but these two by the time
360
+ * this runs — the throw is for the caller that forgets to.
361
+ */
362
+ export function msiBackendFor(host) {
363
+ if (host === 'linux')
364
+ return 'wixl';
365
+ if (host === 'win32')
366
+ return 'wix3';
367
+ throw new Error(`gjsify ship: no MSI compiler is wired for ${host}. \`wixl\` (msitools) is used on linux and WiX v3 ` +
368
+ 'on win32; assembly is not host-bound, so `gjsify ship windows --stage` here and ' +
369
+ '`gjsify ship --from-stage <dir> --target msi` there is the way across.');
370
+ }
371
+ /**
372
+ * Render the document and compile it into `input.target`.
373
+ *
374
+ * The `.wxs` is KEPT in `workDir` rather than written to a temporary file, and
375
+ * that is deliberate: it is the one artifact of this milestone a human can read,
376
+ * it is what a bug report should carry, and it is what the Windows leg compiles a
377
+ * SECOND time with WiX to produce the file the Linux oracle reads back.
378
+ */
379
+ export async function buildMsi(input) {
380
+ const host = input.host ?? process.platform;
381
+ const backend = msiBackendFor(host);
382
+ mkdirSync(input.workDir, { recursive: true });
383
+ const wxsPath = join(input.workDir, `${input.settings.binaryName}.wxs`);
384
+ writeFileSync(wxsPath, renderWxs(input));
385
+ if (backend === 'wixl') {
386
+ await run('wixl', ['-a', input.archLabel, '-o', input.target, wxsPath], input);
387
+ return wxsPath;
388
+ }
389
+ // WiX v3 is two passes, and the intermediate directory is wiped between runs
390
+ // for the reason the Flatpak packer wipes its repo: a `.wixobj` that survives a
391
+ // failed compile is one `light` will happily link into an artifact wearing the
392
+ // new version's filename.
393
+ const objDir = join(input.workDir, 'obj');
394
+ rmSync(objDir, { recursive: true, force: true, maxRetries: 5 });
395
+ mkdirSync(objDir, { recursive: true });
396
+ await run('candle.exe', ['-nologo', '-arch', input.archLabel, '-out', `${objDir}\\`, wxsPath], input);
397
+ await run('light.exe', ['-nologo', '-out', input.target, join(objDir, `${input.settings.binaryName}.wixobj`)], input);
398
+ return wxsPath;
399
+ }
400
+ async function run(cmd, args, input) {
401
+ if (input.verbose)
402
+ console.log(`[gjsify ship] ${cmd} ${args.join(' ')}`);
403
+ // `completion: 'return'`, as the Flatpak packer does and for the same reason:
404
+ // `ship` packs the remaining formats and prints the artifact list afterwards,
405
+ // so it cannot end in `process.exit()`.
406
+ const result = await spawnToCompletion(cmd, [...args], {
407
+ completion: 'return',
408
+ cwd: input.workDir,
409
+ stdio: 'inherit',
410
+ notFound: () => new Error(`gjsify ship: ${cmd} not found. It is the MSI compiler for this host — ` +
411
+ 'Fedora: `sudo dnf install msitools`, Debian/Ubuntu: `sudo apt install msitools`, ' +
412
+ 'Windows: WiX Toolset v3.14 with its `bin` directory on PATH.'),
413
+ });
414
+ if (result.code !== 0) {
415
+ throw new Error(`gjsify ship: ${cmd} failed with ${describeExit(result)}.`);
416
+ }
417
+ }
@@ -0,0 +1,71 @@
1
+ /** The targets a bundled interpreter is published for. */
2
+ export declare const NODE_RUNTIME_TARGETS: readonly ["darwin-arm64", "darwin-x64", "win32-x64"];
3
+ export type NodeRuntimeTarget = (typeof NODE_RUNTIME_TARGETS)[number];
4
+ /** Is this `<platform>-<arch>` one of the published targets? */
5
+ export declare function isNodeRuntimeTarget(target: string): target is NodeRuntimeTarget;
6
+ /** The npm name carrying the interpreter for a target. One spelling, derived. */
7
+ export declare function nodeRuntimePackageName(target: NodeRuntimeTarget): string;
8
+ /**
9
+ * The interpreter's filename inside the package, from the TARGET.
10
+ *
11
+ * Never from `process.platform`. Assembling a Windows artifact on Linux or macOS
12
+ * is a supported path (ADR 0024 § A1 — the packers are pure JavaScript and run
13
+ * anywhere), and a host-derived name would look for `node` inside the win32
14
+ * package, find nothing, and report it as a missing payload.
15
+ *
16
+ * `string`, not {@link NodeRuntimeTarget}, and the widening is what keeps this
17
+ * ONE rule: `utils/ship/app-runtime.ts` has to name the interpreter's file inside
18
+ * the ARTIFACT and holds a `GtkRuntimeTarget` when it does. Those two unions are
19
+ * deliberately separate tables (see `GTK_RUNTIME_TARGETS`), so a narrow parameter
20
+ * here would have forced a second `startsWith('win32-')` over there — and a stage
21
+ * whose source is `node.exe` and whose destination is `node` is a program
22
+ * directory whose launcher execs a file nothing staged.
23
+ */
24
+ export declare function nodeRuntimeBinaryName(target: string): string;
25
+ /** Where the interpreter and its licence are, once found. */
26
+ export interface ResolvedNodeRuntime {
27
+ /** The npm package it came from, or `'GJSIFY_NODE_RUNTIME'` for the override. */
28
+ source: string;
29
+ /** The payload directory holding both files. */
30
+ binDir: string;
31
+ /** Absolute path to the interpreter. */
32
+ nodePath: string;
33
+ /**
34
+ * Absolute path to Node's own LICENSE.
35
+ *
36
+ * Part of the RESULT rather than something a caller looks up afterwards: an
37
+ * interpreter copied into an artifact without its licence is redistribution
38
+ * with no terms attached, and a shape that hands out the binary alone makes
39
+ * forgetting the easier path.
40
+ */
41
+ licensePath: string;
42
+ }
43
+ export interface ResolveNodeRuntimeOptions {
44
+ /** Project directory to resolve from — the consumer's, not gjsify's. */
45
+ cwd?: string;
46
+ /** Environment to read `GJSIFY_NODE_RUNTIME` from. Injected so it is testable. */
47
+ env?: Record<string, string | undefined>;
48
+ }
49
+ /**
50
+ * Locate the bundled Node interpreter for `target`, or `null`.
51
+ *
52
+ * Search order, mirroring `resolveGtkRuntimeBundle()` in
53
+ * `packages/node-gi/node-gi/gtk-runtime.js` rather than inventing a second shape:
54
+ *
55
+ * 1. `GJSIFY_NODE_RUNTIME` — a `bin/` directory, for a maintainer holding an
56
+ * unpublished or patched interpreter.
57
+ * 2. `@gjsify/node-runtime-<target>` resolved by name through
58
+ * {@link resolveNpmPackage}, which walks the CONSUMER's `node_modules` chain
59
+ * as well as the CLI bundle's own. That multi-anchor walk is what makes the
60
+ * "only `@gjsify/cli` installed, no gjsify checkout" case work, and it is
61
+ * also why this does not use a bare `createRequire`: GJS's ESM loader has no
62
+ * node_modules walker at all.
63
+ *
64
+ * `null`, never a throw. Whether a missing interpreter is fatal depends on what
65
+ * the caller is building — and the caller is the only one that can say so
66
+ * usefully. A package resolved but not POPULATED (the payload is gitignored, so
67
+ * this is exactly what an in-repo checkout looks like) is also `null`: a path to
68
+ * a file that is not there would be the worse answer, since it fails later, in a
69
+ * copy, with the target's name nowhere in the message.
70
+ */
71
+ export declare function resolveNodeRuntime(target: NodeRuntimeTarget, options?: ResolveNodeRuntimeOptions): ResolvedNodeRuntime | null;
@@ -0,0 +1,122 @@
1
+ // Finding the bundled Node interpreter a macOS or Windows artifact carries.
2
+ //
3
+ // WHO DECLARES IT — the question this module answers, and it is settled the same
4
+ // way it was settled for GTK. `@gjsify/node-runtime-<target>` is resolved BY NAME
5
+ // by whoever SHIPS an application, with no `optionalDependencies` edge anywhere:
6
+ // not on `@gjsify/cli`, not on the app. Making a runtime bundle a dependency of
7
+ // the library that uses it was #910 — a from-source addon met a foreign GTK,
8
+ // produced wrong method entries and a 29-minute timeout, reverted in #920 — and
9
+ // `docs/publishing.md` records the rule that replaced it. The consequence worth
10
+ // stating: `verify-published-closure.mjs` has no edge to check here, so the
11
+ // package's own publish job is the guard.
12
+ //
13
+ // WHAT THE ABSENT EDGE DOES AND DOES NOT BUY, because this header used to claim
14
+ // the app author had "nothing to add to `package.json`" and the shipped behaviour
15
+ // says otherwise. What is absent is GJSIFY's edge: installing `@gjsify/cli` drags
16
+ // in no interpreter for a platform nobody is shipping to. Finding one is still the
17
+ // SHIPPER's job and it looks in the consumer's own `node_modules` — which is why
18
+ // `stageAppRuntime` names the package when there is none: "no bundled interpreter
19
+ // — install `@gjsify/node-runtime-<target>`". So an author who wants a
20
+ // self-contained artifact installs, and therefore declares, the runtime for the
21
+ // target they package; `GJSIFY_NODE_RUNTIME` is the override that skips it.
22
+ //
23
+ // LINUX IS ABSENT ON PURPOSE and is not an omission to be filled in later: a
24
+ // `.deb`/`.rpm` declares a dependency on the distribution's Node instead
25
+ // (`depends.ts` → `NODE_PACKAGE`), the way a `--app gjs` package declares `gjs`.
26
+ // Every Linux distribution ships a Node; macOS and Windows ship none.
27
+ //
28
+ // THE CALLER IS `utils/ship/app-runtime.ts` (#1354 M2b), and this paragraph used
29
+ // to say there was none — "⚠️ NOTHING CALLS THIS YET, outside its own spec … a
30
+ // promise to a caller that does not exist yet". It exists: the macOS layout stages
31
+ // `nodePath` into `Contents/MacOS/node` and `licensePath` beside it, and the
32
+ // `null`-not-throw contract is what lets that caller stage the GTK closure it DID
33
+ // find while naming the interpreter package it did not. #1354 M3 adds the second
34
+ // caller — the Windows program directory stages `node.exe` beside its `.cmd` — so
35
+ // both non-Linux rows of the table above are now used, and this paragraph no
36
+ // longer has a "still has none" half to record.
37
+ //
38
+ // The tests keep the by-name claim honest independently of any caller: they
39
+ // resolve a real installed package out of a throwaway consumer tree holding
40
+ // nothing of gjsify, so the resolution is checked against a stranger's layout
41
+ // rather than against this monorepo's.
42
+ import { existsSync } from 'node:fs';
43
+ import { dirname, join } from 'node:path';
44
+ import { resolveInstalledPackage } from '../resolve-npm-package.js';
45
+ /** The targets a bundled interpreter is published for. */
46
+ export const NODE_RUNTIME_TARGETS = ['darwin-arm64', 'darwin-x64', 'win32-x64'];
47
+ /** Is this `<platform>-<arch>` one of the published targets? */
48
+ export function isNodeRuntimeTarget(target) {
49
+ return NODE_RUNTIME_TARGETS.includes(target);
50
+ }
51
+ /** The npm name carrying the interpreter for a target. One spelling, derived. */
52
+ export function nodeRuntimePackageName(target) {
53
+ return `@gjsify/node-runtime-${target}`;
54
+ }
55
+ /**
56
+ * The interpreter's filename inside the package, from the TARGET.
57
+ *
58
+ * Never from `process.platform`. Assembling a Windows artifact on Linux or macOS
59
+ * is a supported path (ADR 0024 § A1 — the packers are pure JavaScript and run
60
+ * anywhere), and a host-derived name would look for `node` inside the win32
61
+ * package, find nothing, and report it as a missing payload.
62
+ *
63
+ * `string`, not {@link NodeRuntimeTarget}, and the widening is what keeps this
64
+ * ONE rule: `utils/ship/app-runtime.ts` has to name the interpreter's file inside
65
+ * the ARTIFACT and holds a `GtkRuntimeTarget` when it does. Those two unions are
66
+ * deliberately separate tables (see `GTK_RUNTIME_TARGETS`), so a narrow parameter
67
+ * here would have forced a second `startsWith('win32-')` over there — and a stage
68
+ * whose source is `node.exe` and whose destination is `node` is a program
69
+ * directory whose launcher execs a file nothing staged.
70
+ */
71
+ export function nodeRuntimeBinaryName(target) {
72
+ return target.startsWith('win32-') ? 'node.exe' : 'node';
73
+ }
74
+ /**
75
+ * Locate the bundled Node interpreter for `target`, or `null`.
76
+ *
77
+ * Search order, mirroring `resolveGtkRuntimeBundle()` in
78
+ * `packages/node-gi/node-gi/gtk-runtime.js` rather than inventing a second shape:
79
+ *
80
+ * 1. `GJSIFY_NODE_RUNTIME` — a `bin/` directory, for a maintainer holding an
81
+ * unpublished or patched interpreter.
82
+ * 2. `@gjsify/node-runtime-<target>` resolved by name through
83
+ * {@link resolveNpmPackage}, which walks the CONSUMER's `node_modules` chain
84
+ * as well as the CLI bundle's own. That multi-anchor walk is what makes the
85
+ * "only `@gjsify/cli` installed, no gjsify checkout" case work, and it is
86
+ * also why this does not use a bare `createRequire`: GJS's ESM loader has no
87
+ * node_modules walker at all.
88
+ *
89
+ * `null`, never a throw. Whether a missing interpreter is fatal depends on what
90
+ * the caller is building — and the caller is the only one that can say so
91
+ * usefully. A package resolved but not POPULATED (the payload is gitignored, so
92
+ * this is exactly what an in-repo checkout looks like) is also `null`: a path to
93
+ * a file that is not there would be the worse answer, since it fails later, in a
94
+ * copy, with the target's name nowhere in the message.
95
+ */
96
+ export function resolveNodeRuntime(target, options = {}) {
97
+ const env = options.env ?? process.env;
98
+ const binaryName = nodeRuntimeBinaryName(target);
99
+ const override = env['GJSIFY_NODE_RUNTIME'];
100
+ if (override) {
101
+ const found = complete('GJSIFY_NODE_RUNTIME', override, binaryName);
102
+ if (found)
103
+ return found;
104
+ }
105
+ const packageName = nodeRuntimePackageName(target);
106
+ // `resolveInstalledPackage`, because this binary is COPIED into a `.app` that
107
+ // gets redistributed: under Bun a bare `resolveNpmPackage` answers from the
108
+ // runtime's global install cache for any project without a `node_modules`, and
109
+ // an interpreter shipped out of a cache is one the author never declared.
110
+ const entry = resolveInstalledPackage(packageName, { cwd: options.cwd, bundleUrl: import.meta.url });
111
+ if (entry === null)
112
+ return null;
113
+ return complete(packageName, join(dirname(entry), 'bin'), binaryName);
114
+ }
115
+ /** Both files or nothing — see {@link ResolvedNodeRuntime.licensePath}. */
116
+ function complete(source, binDir, binaryName) {
117
+ const nodePath = join(binDir, binaryName);
118
+ const licensePath = join(binDir, 'LICENSE');
119
+ if (!existsSync(nodePath) || !existsSync(licensePath))
120
+ return null;
121
+ return { source, binDir, nodePath, licensePath };
122
+ }