@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.
- package/dist/cli.gjs.mjs +453 -425
- package/lib/actions/build.js +2 -1
- package/lib/commands/affected-classify.js +7 -5
- package/lib/commands/build.js +6 -0
- package/lib/commands/flatpak/ci.js +10 -2
- package/lib/commands/flatpak/init.js +15 -16
- package/lib/commands/flatpak/utils.d.ts +0 -26
- package/lib/commands/flatpak/utils.js +4 -29
- package/lib/commands/gettext.d.ts +2 -1
- package/lib/commands/gettext.js +105 -47
- package/lib/commands/onboard.d.ts +7 -0
- package/lib/commands/onboard.js +349 -96
- package/lib/commands/ship.d.ts +3 -0
- package/lib/commands/ship.js +911 -70
- package/lib/commands/trust.d.ts +6 -0
- package/lib/commands/trust.js +1 -1
- package/lib/config.js +25 -0
- package/lib/types/cli-build-options.d.ts +11 -0
- package/lib/types/config-data.d.ts +137 -3
- package/lib/utils/flatpak-runtime.d.ts +41 -0
- package/lib/utils/flatpak-runtime.js +54 -0
- package/lib/utils/msgfmt-merge.d.ts +35 -0
- package/lib/utils/msgfmt-merge.js +102 -0
- package/lib/utils/npm-otp.d.ts +27 -2
- package/lib/utils/npm-otp.js +82 -7
- package/lib/utils/onboard-discovery.d.ts +68 -0
- package/lib/utils/onboard-discovery.js +178 -0
- package/lib/utils/onboard-probe.d.ts +118 -1
- package/lib/utils/onboard-probe.js +153 -4
- package/lib/utils/prompt-output.d.ts +12 -0
- package/lib/utils/prompt-output.js +54 -0
- package/lib/utils/prompt.js +22 -2
- package/lib/utils/publishable-packages.d.ts +1 -1
- package/lib/utils/publishable-packages.js +1 -0
- package/lib/utils/resolve-npm-package.d.ts +56 -0
- package/lib/utils/resolve-npm-package.js +69 -0
- package/lib/utils/ship/app-runtime.d.ts +214 -0
- package/lib/utils/ship/app-runtime.js +429 -0
- package/lib/utils/ship/depends.d.ts +54 -7
- package/lib/utils/ship/depends.js +120 -6
- package/lib/utils/ship/discover.js +18 -1
- package/lib/utils/ship/dmg.d.ts +71 -0
- package/lib/utils/ship/dmg.js +181 -0
- package/lib/utils/ship/flatpak-config.d.ts +66 -0
- package/lib/utils/ship/flatpak-config.js +132 -0
- package/lib/utils/ship/flatpak.d.ts +45 -0
- package/lib/utils/ship/flatpak.js +148 -0
- package/lib/utils/ship/formats.d.ts +116 -3
- package/lib/utils/ship/formats.js +869 -11
- package/lib/utils/ship/launcher.d.ts +13 -3
- package/lib/utils/ship/launcher.js +362 -17
- package/lib/utils/ship/layout.d.ts +268 -0
- package/lib/utils/ship/layout.js +351 -0
- package/lib/utils/ship/localize-metadata.d.ts +22 -0
- package/lib/utils/ship/localize-metadata.js +126 -0
- package/lib/utils/ship/mime.js +1 -9
- package/lib/utils/ship/msi.d.ts +126 -0
- package/lib/utils/ship/msi.js +417 -0
- package/lib/utils/ship/node-runtime.d.ts +71 -0
- package/lib/utils/ship/node-runtime.js +122 -0
- package/lib/utils/ship/payload.d.ts +156 -5
- package/lib/utils/ship/payload.js +580 -14
- package/lib/utils/ship/plan.d.ts +21 -0
- package/lib/utils/ship/plan.js +20 -7
- package/lib/utils/ship/plist.d.ts +58 -0
- package/lib/utils/ship/plist.js +198 -0
- package/lib/utils/ship/rpm.js +9 -3
- package/lib/utils/ship/schemas.d.ts +61 -0
- package/lib/utils/ship/schemas.js +118 -0
- package/lib/utils/ship/scripts.js +7 -3
- package/lib/utils/ship/settings.d.ts +19 -1
- package/lib/utils/ship/settings.js +19 -1
- package/lib/utils/ship/share-dirs.d.ts +23 -0
- package/lib/utils/ship/share-dirs.js +46 -0
- package/lib/utils/ship/signing.d.ts +195 -0
- package/lib/utils/ship/signing.js +317 -0
- package/lib/utils/ship/stage-manifest.d.ts +50 -13
- package/lib/utils/ship/stage-manifest.js +137 -14
- package/lib/utils/ship/stage-writer.d.ts +48 -0
- package/lib/utils/ship/stage-writer.js +71 -1
- package/lib/utils/ship/types.d.ts +264 -16
- package/lib/utils/ship/xml.d.ts +7 -0
- package/lib/utils/ship/xml.js +25 -0
- package/lib/utils/ship/zip.d.ts +69 -0
- package/lib/utils/ship/zip.js +245 -0
- package/lib/utils/trust-registry.js +5 -0
- 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
|
+
}
|