@gjsify/cli 0.44.0 → 0.46.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 (81) hide show
  1. package/dist/cli.gjs.mjs +202 -167
  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 +850 -58
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/commands/upgrade.d.ts +23 -0
  11. package/lib/commands/upgrade.js +95 -12
  12. package/lib/templates/install.mjs.tmpl +2 -2
  13. package/lib/types/config-data.d.ts +127 -1
  14. package/lib/utils/gi-search-path.js +11 -3
  15. package/lib/utils/msgfmt-merge.d.ts +35 -0
  16. package/lib/utils/msgfmt-merge.js +102 -0
  17. package/lib/utils/npm-otp.d.ts +27 -2
  18. package/lib/utils/npm-otp.js +82 -7
  19. package/lib/utils/onboard-discovery.d.ts +68 -0
  20. package/lib/utils/onboard-discovery.js +178 -0
  21. package/lib/utils/onboard-probe.d.ts +118 -1
  22. package/lib/utils/onboard-probe.js +153 -4
  23. package/lib/utils/prompt-output.d.ts +12 -0
  24. package/lib/utils/prompt-output.js +54 -0
  25. package/lib/utils/prompt.js +22 -2
  26. package/lib/utils/publishable-packages.d.ts +1 -1
  27. package/lib/utils/publishable-packages.js +1 -0
  28. package/lib/utils/resolve-npm-package.d.ts +56 -0
  29. package/lib/utils/resolve-npm-package.js +69 -0
  30. package/lib/utils/ship/app-runtime.d.ts +214 -0
  31. package/lib/utils/ship/app-runtime.js +429 -0
  32. package/lib/utils/ship/depends.d.ts +50 -0
  33. package/lib/utils/ship/depends.js +117 -3
  34. package/lib/utils/ship/discover.js +103 -1
  35. package/lib/utils/ship/dmg.d.ts +71 -0
  36. package/lib/utils/ship/dmg.js +181 -0
  37. package/lib/utils/ship/flatpak.js +1 -1
  38. package/lib/utils/ship/formats.d.ts +108 -7
  39. package/lib/utils/ship/formats.js +778 -15
  40. package/lib/utils/ship/launcher.d.ts +13 -3
  41. package/lib/utils/ship/launcher.js +419 -17
  42. package/lib/utils/ship/layout.d.ts +330 -0
  43. package/lib/utils/ship/layout.js +504 -0
  44. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  45. package/lib/utils/ship/localize-metadata.js +126 -0
  46. package/lib/utils/ship/mime.js +1 -9
  47. package/lib/utils/ship/msi.d.ts +126 -0
  48. package/lib/utils/ship/msi.js +434 -0
  49. package/lib/utils/ship/node-runtime.d.ts +71 -0
  50. package/lib/utils/ship/node-runtime.js +122 -0
  51. package/lib/utils/ship/payload.d.ts +156 -5
  52. package/lib/utils/ship/payload.js +599 -14
  53. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  54. package/lib/utils/ship/pe-launcher.js +663 -0
  55. package/lib/utils/ship/plan.d.ts +21 -0
  56. package/lib/utils/ship/plan.js +55 -7
  57. package/lib/utils/ship/plist.d.ts +98 -0
  58. package/lib/utils/ship/plist.js +256 -0
  59. package/lib/utils/ship/rpm.js +16 -3
  60. package/lib/utils/ship/schemas.d.ts +61 -0
  61. package/lib/utils/ship/schemas.js +118 -0
  62. package/lib/utils/ship/scripts.js +7 -3
  63. package/lib/utils/ship/settings.d.ts +91 -2
  64. package/lib/utils/ship/settings.js +93 -1
  65. package/lib/utils/ship/share-dirs.d.ts +53 -0
  66. package/lib/utils/ship/share-dirs.js +76 -0
  67. package/lib/utils/ship/signing.d.ts +311 -0
  68. package/lib/utils/ship/signing.js +506 -0
  69. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  70. package/lib/utils/ship/stage-manifest.js +99 -13
  71. package/lib/utils/ship/stage-writer.d.ts +48 -0
  72. package/lib/utils/ship/stage-writer.js +91 -2
  73. package/lib/utils/ship/types.d.ts +191 -14
  74. package/lib/utils/ship/xml.d.ts +7 -0
  75. package/lib/utils/ship/xml.js +25 -0
  76. package/lib/utils/ship/zip.d.ts +69 -0
  77. package/lib/utils/ship/zip.js +245 -0
  78. package/lib/utils/system-gi.d.ts +20 -5
  79. package/lib/utils/system-gi.js +36 -13
  80. package/lib/utils/trust-registry.js +5 -0
  81. package/package.json +19 -19
@@ -9,15 +9,7 @@
9
9
  //
10
10
  // So a project that invents a type also ships a shared-mime-info document, and the package refreshes
11
11
  // the MIME cache on install (see `scripts.ts`) — an unrefreshed cache is the same silence again.
12
- /** XML text escaping. Only the five that matter in element content and attributes. */
13
- function esc(value) {
14
- return value
15
- .replace(/&/g, '&')
16
- .replace(/</g, '&lt;')
17
- .replace(/>/g, '&gt;')
18
- .replace(/"/g, '&quot;')
19
- .replace(/'/g, '&apos;');
20
- }
12
+ import { xmlEscape as esc } from './xml.js';
21
13
  const TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
22
14
  /**
23
15
  * Validate the declared types, throwing on anything that would install and then not work.
@@ -0,0 +1,126 @@
1
+ import type { HostOs, PackSettings } from './types.js';
2
+ /**
3
+ * The UUID namespace every GUID in a generated `.msi` is derived under.
4
+ *
5
+ * A CONSTANT, and changing it is a breaking change rather than a cosmetic one:
6
+ * the `UpgradeCode` derived from it is how Windows Installer recognises an
7
+ * already-installed copy of the same product, so a new namespace makes every
8
+ * future release a SECOND product that installs beside the old one instead of
9
+ * over it. Written down here rather than passed in for that reason — a knob would
10
+ * be a knob that silently forks a product line.
11
+ */
12
+ export declare const MSI_UUID_NAMESPACE = "7b3a1f6e-0c4d-4a2b-9e51-3d8c6f2a0b14";
13
+ /** RFC 4122 § 4.3 name-based UUID, SHA-1 flavour. */
14
+ export declare function uuid5(namespace: string, name: string): string;
15
+ /**
16
+ * The code that identifies the PRODUCT LINE, stable across every version.
17
+ *
18
+ * Derived from the app id alone. That is what `MajorUpgrade` compares, so it must
19
+ * NOT move when the version does — an `UpgradeCode` that changed per release would
20
+ * leave every old version installed beside the new one, silently, with two entries
21
+ * in Add/Remove Programs and two copies on disk.
22
+ */
23
+ export declare function msiUpgradeCode(settings: PackSettings): string;
24
+ /**
25
+ * The code that identifies THIS build, and it has to move when the version does.
26
+ *
27
+ * The exact inverse of {@link msiUpgradeCode}: Windows Installer refuses to
28
+ * install a second package carrying a `ProductCode` it already has, so a code that
29
+ * did not change per version would make an upgrade a no-op. The arch is in the
30
+ * material because an x64 and a (future) arm64 build of one version are two
31
+ * products, not one.
32
+ */
33
+ export declare function msiProductCode(settings: PackSettings, archLabel: string): string;
34
+ /** A component's GUID, stable for as long as its path is. */
35
+ export declare function msiComponentGuid(settings: PackSettings, path: string): string;
36
+ /**
37
+ * MSI's `ProductVersion`, which is a much smaller vocabulary than npm's.
38
+ *
39
+ * `major.minor.build`, with `major`/`minor` at most 255 and `build` at most 65535 —
40
+ * anything outside that is not a version Windows Installer will compare, and the
41
+ * fourth field it accepts is IGNORED by upgrade detection, which is why nothing is
42
+ * ever put there.
43
+ *
44
+ * A PRERELEASE IS REFUSED rather than truncated, and that is the interesting case.
45
+ * `normaliseVersion` turns `1.2.0-rc.1` into `1.2.0~rc.1` because dpkg and rpm sort
46
+ * a `~` before the release; MSI has no such spelling, so the only two options were
47
+ * to drop the suffix or to say no. Dropping it makes `1.2.0~rc.1` and `1.2.0` the
48
+ * same `ProductVersion` — two products `MajorUpgrade` cannot tell apart, so
49
+ * installing the release over the candidate leaves BOTH on the machine. That is
50
+ * the "installs cleanly, does the wrong thing" class, produced by the field meant
51
+ * to prevent it.
52
+ */
53
+ export declare function msiProductVersion(version: string): string;
54
+ /**
55
+ * An MSI `Identifier`: `[A-Za-z_][A-Za-z0-9_.]{0,71}`.
56
+ *
57
+ * The readable tail is not decoration — `msiinfo export <msi> File` is the oracle,
58
+ * and an id of pure hash makes a mismatch unreadable. The digest is what makes it
59
+ * UNIQUE: it is taken over the full path, so two files whose last 57 characters
60
+ * agree still differ. `renderWxs` asserts uniqueness anyway rather than trusting
61
+ * 48 bits, because the failure of a collision is two files fighting over one row.
62
+ */
63
+ export declare function msiIdentifier(prefix: string, path: string): string;
64
+ /** One payload file, as the installer will address it. */
65
+ export interface MsiFile {
66
+ /** Program-directory-relative path, POSIX-separated. */
67
+ path: string;
68
+ /**
69
+ * Where the bytes are, RELATIVE to the directory holding the `.wxs` and
70
+ * POSIX-separated — see measurement 2 in this file's header. An absolute path
71
+ * would make the document unusable on the second machine that compiles it.
72
+ */
73
+ source: string;
74
+ }
75
+ /** Where {@link buildMsi} writes the payload, and therefore what `Source=` is relative to. */
76
+ export declare const MSI_PAYLOAD_DIR = "root";
77
+ export interface WxsInput {
78
+ settings: PackSettings;
79
+ /** Everything the program directory carries, in any order. */
80
+ files: readonly MsiFile[];
81
+ /** The directory the installer lays down — `windowsProgramDirName(settings)`. */
82
+ programDirName: string;
83
+ /** The format's spelling of the architecture, e.g. `x64`. */
84
+ archLabel: string;
85
+ }
86
+ /**
87
+ * Render the installer's source document.
88
+ *
89
+ * PURE — a string in, a string out, no filesystem and no compiler — which is what
90
+ * makes every decision above unit-testable on a machine with neither backend
91
+ * installed. The one thing it cannot answer is whether a compiler accepts what it
92
+ * wrote, and that is what the two legs are for.
93
+ */
94
+ export declare function renderWxs(input: WxsInput): string;
95
+ /** Which compiler a host has. */
96
+ export type MsiBackend = 'wixl' | 'wix3';
97
+ /**
98
+ * The backend for one host.
99
+ *
100
+ * A FUNCTION of the host and nothing else, so the choice is testable from either
101
+ * OS. `assertHostCanFinish` has already refused anything but these two by the time
102
+ * this runs — the throw is for the caller that forgets to.
103
+ */
104
+ export declare function msiBackendFor(host: string): MsiBackend;
105
+ export interface MsiPackInput {
106
+ settings: PackSettings;
107
+ files: readonly MsiFile[];
108
+ programDirName: string;
109
+ archLabel: string;
110
+ /** Working root for the rendered document and the compiler's intermediates. */
111
+ workDir: string;
112
+ /** Absolute path the finished `.msi` is written to. */
113
+ target: string;
114
+ verbose: boolean;
115
+ /** Injected so the dispatch is testable off the host it selects for. */
116
+ host?: HostOs;
117
+ }
118
+ /**
119
+ * Render the document and compile it into `input.target`.
120
+ *
121
+ * The `.wxs` is KEPT in `workDir` rather than written to a temporary file, and
122
+ * that is deliberate: it is the one artifact of this milestone a human can read,
123
+ * it is what a bug report should carry, and it is what the Windows leg compiles a
124
+ * SECOND time with WiX to produce the file the Linux oracle reads back.
125
+ */
126
+ export declare function buildMsi(input: MsiPackInput): Promise<string>;
@@ -0,0 +1,434 @@
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 GUI-subsystem `.exe` launcher the zip's user
25
+ // double-clicks (see the last paragraph of this header);
26
+ // * comes off again: Add/Remove Programs gets its entry from the MSI's own
27
+ // `ProductName`/`ProductVersion`/`Manufacturer`, and `msiexec /x` removes
28
+ // every file the package installed. The `windows-dir-selfcontained` leg's
29
+ // sibling asserts the install root is GONE afterwards, because an installer
30
+ // that cannot be removed is worse than none.
31
+ //
32
+ // FIVE THINGS WERE MEASURED against `msitools 0.106.58-1.fc44` (`wixl
33
+ // 0.106.58-a155`) before this file was written, each with a plausible wrong
34
+ // answer:
35
+ //
36
+ // 1. `wixl -a x64` sets Component `Attributes` to 256 — `msidbComponent-
37
+ // Attributes64bit` — on its own, so `Win64="yes"` is neither written here nor
38
+ // needed on either backend (`candle -arch x64` does the same).
39
+ // 2. An ABSOLUTE `Source=` resolves — and this file writes RELATIVE ones anyway,
40
+ // which is the measurement changing a decision rather than confirming it. The
41
+ // SAME document is compiled twice, on two machines: `wixl` here and WiX v3 on
42
+ // the `windows-latest` leg, out of a `.wxs` that travelled as an artifact. An
43
+ // absolute path is the build host's, so the second compile would fail on every
44
+ // file. `root/<path>`, POSIX-separated, resolves for both — Win32 accepts `/`
45
+ // — and it also keeps the document itself byte-identical between two builds of
46
+ // one project in different directories.
47
+ // 3. An explicit `Product/@Id` GUID is honoured (`msiinfo export … Property`
48
+ // prints it back verbatim), so the artifact can be DETERMINISTIC. `Id="*"`
49
+ // would give every rebuild a different `ProductCode`.
50
+ // 4. A `<Shortcut Advertise="yes">` nested inside `<File>` compiles, which is
51
+ // what keeps the shortcut in the launcher's own per-machine component instead
52
+ // of a second component with an HKCU key path — the shape ICE43/ICE57 argue
53
+ // about on `light`.
54
+ // 5. `Property/@Secure` is ACCEPTED and IGNORED by wixl (`SecureCustom-
55
+ // Properties` came back holding only the two `MajorUpgrade` values), so this
56
+ // file does not emit one: a `<Property Id="INSTALLDIR">` beside
57
+ // `<Directory Id="INSTALLDIR">` would be a divergence between the backends
58
+ // bought for nothing. A directory whose id is a public property is settable
59
+ // from a command line either way.
60
+ //
61
+ // AND ONE THING IT USED NOT TO FIX. `node.exe` is a CONSOLE-subsystem image and
62
+ // the Node release ships no `nodew.exe`, so a Start-Menu shortcut to the `.cmd`
63
+ // popped a console window behind the GUI exactly as a double-click did. The
64
+ // shortcut now points at the GUI-subsystem launcher the windows LAYOUT stages
65
+ // (`utils/ship/pe-launcher.ts`), which runs the same `.cmd` with no window —
66
+ // measured on `win11-gjsify` in session 1, where the `.cmd` produces two new
67
+ // visible console-host windows and the `.exe` produces none. Still no CI leg can
68
+ // observe it: every Windows job starts the app from a shell and already has a
69
+ // console.
70
+ import { createHash } from 'node:crypto';
71
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
72
+ import { join, posix } from 'node:path';
73
+ import { describeExit, spawnToCompletion } from '../spawn.js';
74
+ import { windowsGuiLauncherPath } from './layout.js';
75
+ import { xmlEscape } from './xml.js';
76
+ /**
77
+ * The UUID namespace every GUID in a generated `.msi` is derived under.
78
+ *
79
+ * A CONSTANT, and changing it is a breaking change rather than a cosmetic one:
80
+ * the `UpgradeCode` derived from it is how Windows Installer recognises an
81
+ * already-installed copy of the same product, so a new namespace makes every
82
+ * future release a SECOND product that installs beside the old one instead of
83
+ * over it. Written down here rather than passed in for that reason — a knob would
84
+ * be a knob that silently forks a product line.
85
+ */
86
+ export const MSI_UUID_NAMESPACE = '7b3a1f6e-0c4d-4a2b-9e51-3d8c6f2a0b14';
87
+ /** RFC 4122 § 4.3 name-based UUID, SHA-1 flavour. */
88
+ export function uuid5(namespace, name) {
89
+ const ns = namespace.replace(/-/g, '');
90
+ const nsBytes = new Uint8Array(16);
91
+ for (let i = 0; i < 16; i += 1)
92
+ nsBytes[i] = Number.parseInt(ns.slice(i * 2, i * 2 + 2), 16);
93
+ const digest = createHash('sha1').update(nsBytes).update(new TextEncoder().encode(name)).digest();
94
+ const bytes = Uint8Array.from(digest.subarray(0, 16));
95
+ // Version 5 in the high nibble of byte 6, RFC 4122 variant in byte 8.
96
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
97
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
98
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
99
+ return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join('-');
100
+ }
101
+ /**
102
+ * The code that identifies the PRODUCT LINE, stable across every version.
103
+ *
104
+ * Derived from the app id alone. That is what `MajorUpgrade` compares, so it must
105
+ * NOT move when the version does — an `UpgradeCode` that changed per release would
106
+ * leave every old version installed beside the new one, silently, with two entries
107
+ * in Add/Remove Programs and two copies on disk.
108
+ */
109
+ export function msiUpgradeCode(settings) {
110
+ return uuid5(MSI_UUID_NAMESPACE, `upgrade\n${settings.appId}`).toUpperCase();
111
+ }
112
+ /**
113
+ * The code that identifies THIS build, and it has to move when the version does.
114
+ *
115
+ * The exact inverse of {@link msiUpgradeCode}: Windows Installer refuses to
116
+ * install a second package carrying a `ProductCode` it already has, so a code that
117
+ * did not change per version would make an upgrade a no-op. The arch is in the
118
+ * material because an x64 and a (future) arm64 build of one version are two
119
+ * products, not one.
120
+ */
121
+ export function msiProductCode(settings, archLabel) {
122
+ const material = `product\n${settings.appId}\n${settings.version}\n${settings.release}\n${archLabel}`;
123
+ return uuid5(MSI_UUID_NAMESPACE, material).toUpperCase();
124
+ }
125
+ /** A component's GUID, stable for as long as its path is. */
126
+ export function msiComponentGuid(settings, path) {
127
+ return uuid5(MSI_UUID_NAMESPACE, `component\n${settings.appId}\n${path}`).toUpperCase();
128
+ }
129
+ /**
130
+ * MSI's `ProductVersion`, which is a much smaller vocabulary than npm's.
131
+ *
132
+ * `major.minor.build`, with `major`/`minor` at most 255 and `build` at most 65535 —
133
+ * anything outside that is not a version Windows Installer will compare, and the
134
+ * fourth field it accepts is IGNORED by upgrade detection, which is why nothing is
135
+ * ever put there.
136
+ *
137
+ * A PRERELEASE IS REFUSED rather than truncated, and that is the interesting case.
138
+ * `normaliseVersion` turns `1.2.0-rc.1` into `1.2.0~rc.1` because dpkg and rpm sort
139
+ * a `~` before the release; MSI has no such spelling, so the only two options were
140
+ * to drop the suffix or to say no. Dropping it makes `1.2.0~rc.1` and `1.2.0` the
141
+ * same `ProductVersion` — two products `MajorUpgrade` cannot tell apart, so
142
+ * installing the release over the candidate leaves BOTH on the machine. That is
143
+ * the "installs cleanly, does the wrong thing" class, produced by the field meant
144
+ * to prevent it.
145
+ */
146
+ export function msiProductVersion(version) {
147
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?$/.exec(version);
148
+ if (match === null) {
149
+ throw new Error(`gjsify ship: "${version}" is not a version an \`.msi\` can carry. Windows Installer's ` +
150
+ '`ProductVersion` is `major.minor.build` and nothing else — no prerelease suffix, no build ' +
151
+ 'metadata.\n' +
152
+ ' Dropping the suffix is not an option this command will take silently: `1.2.0~rc.1` and ' +
153
+ '`1.2.0` would\n' +
154
+ ' become the same version, `MajorUpgrade` could not tell them apart, and installing one over ' +
155
+ 'the other\n' +
156
+ ' would leave both on the machine. Set `gjsify.ship.version` to a plain `x.y.z`, or drop ' +
157
+ '`msi` from the targets.');
158
+ }
159
+ const [major, minor, build] = [Number(match[1]), Number(match[2]), Number(match[3] ?? '0')];
160
+ if (major > 255 || minor > 255 || build > 65535) {
161
+ throw new Error(`gjsify ship: version "${version}" is outside what Windows Installer compares — \`major\` and ` +
162
+ '`minor` are at most 255 and `build` at most 65535. A field over the limit is not rejected by ' +
163
+ 'the installer, it is\n' +
164
+ ' TRUNCATED, so the artifact would carry a version that is not the one it was built from. ' +
165
+ 'Set `gjsify.ship.version`.');
166
+ }
167
+ return `${major}.${minor}.${build}`;
168
+ }
169
+ /**
170
+ * An MSI `Identifier`: `[A-Za-z_][A-Za-z0-9_.]{0,71}`.
171
+ *
172
+ * The readable tail is not decoration — `msiinfo export <msi> File` is the oracle,
173
+ * and an id of pure hash makes a mismatch unreadable. The digest is what makes it
174
+ * UNIQUE: it is taken over the full path, so two files whose last 57 characters
175
+ * agree still differ. `renderWxs` asserts uniqueness anyway rather than trusting
176
+ * 48 bits, because the failure of a collision is two files fighting over one row.
177
+ */
178
+ export function msiIdentifier(prefix, path) {
179
+ const digest = createHash('sha1').update(new TextEncoder().encode(path)).digest('hex').slice(0, 12);
180
+ const safe = path.replace(/[^A-Za-z0-9_.]/g, '_');
181
+ const room = 72 - prefix.length - 2 - digest.length;
182
+ return `${prefix}_${safe.length > room ? safe.slice(safe.length - room) : safe}_${digest}`;
183
+ }
184
+ /**
185
+ * What Windows Installer will not hold in a `FileName`/`DefaultDir` cell.
186
+ *
187
+ * A SUPERSET of `windowsProgramDirName`'s rules and a different question: that one
188
+ * asks what Win32 can create, this asks what the installer DATABASE can address.
189
+ * MSI adds `;` (its list separator inside `DefaultDir`) and a 255-byte ceiling per
190
+ * segment to the reserved characters, and refuses `.`/`..` outright.
191
+ */
192
+ const MSI_NAME_FORBIDDEN = /[<>:"/\\|?*;]/;
193
+ function assertNameIsAddressable(segment, path) {
194
+ if (segment === '.' || segment === '..' || segment.length === 0 || MSI_NAME_FORBIDDEN.test(segment)) {
195
+ throw new Error(`gjsify ship: the payload holds "${path}", and Windows Installer cannot address the path segment ` +
196
+ `"${segment}". A \`FileName\` or \`DefaultDir\` cell reserves < > : " / \\ | ? * and ; (its own ` +
197
+ 'list separator), and \n `.` and `..` are not names. The `.msi` would build and install the ' +
198
+ 'file somewhere else, or not at all.');
199
+ }
200
+ if (segment.length > 255) {
201
+ throw new Error(`gjsify ship: the payload holds "${path}", whose segment "${segment.slice(0, 40)}…" is ` +
202
+ `${segment.length} characters. Windows Installer's \`FileName\` column is 255.`);
203
+ }
204
+ }
205
+ /** Where {@link buildMsi} writes the payload, and therefore what `Source=` is relative to. */
206
+ export const MSI_PAYLOAD_DIR = 'root';
207
+ function directoryTree(files) {
208
+ const root = { path: '', name: '', children: new Map(), files: [] };
209
+ for (const file of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
210
+ const segments = file.path.split('/');
211
+ const leaf = segments.pop();
212
+ if (leaf === undefined)
213
+ throw new Error(`gjsify ship: the payload holds an empty path.`);
214
+ let node = root;
215
+ for (const segment of segments) {
216
+ assertNameIsAddressable(segment, file.path);
217
+ const next = node.children.get(segment) ?? {
218
+ path: node.path === '' ? segment : `${node.path}/${segment}`,
219
+ name: segment,
220
+ children: new Map(),
221
+ files: [],
222
+ };
223
+ node.children.set(segment, next);
224
+ node = next;
225
+ }
226
+ assertNameIsAddressable(leaf, file.path);
227
+ node.files.push(file);
228
+ }
229
+ return root;
230
+ }
231
+ /**
232
+ * Render the installer's source document.
233
+ *
234
+ * PURE — a string in, a string out, no filesystem and no compiler — which is what
235
+ * makes every decision above unit-testable on a machine with neither backend
236
+ * installed. The one thing it cannot answer is whether a compiler accepts what it
237
+ * wrote, and that is what the two legs are for.
238
+ */
239
+ export function renderWxs(input) {
240
+ const { settings, programDirName, archLabel } = input;
241
+ const productVersion = msiProductVersion(settings.version);
242
+ // `Name <email>` is `PackSettings.maintainer`'s shape; ARP shows `Publisher`
243
+ // to a human, and an address in that field reads as a mistake.
244
+ const manufacturer = settings.maintainer.replace(/\s*<[^>]*>\s*$/, '').trim() || settings.maintainer;
245
+ // THE SHORTCUT POINTS AT THE `.exe`, NOT THE `.cmd` — this installer's half of
246
+ // the console-window fix. A Start-Menu shortcut to a batch file starts
247
+ // `cmd.exe`, a console-subsystem image, so Windows allocates a console for it
248
+ // and a black window sits behind the app (ADR 0024 § M3). The GUI-subsystem
249
+ // launcher beside it — `utils/ship/pe-launcher.ts`, staged by the windows
250
+ // LAYOUT — runs the same `.cmd` and has no window of its own.
251
+ //
252
+ // Derived from `windowsGuiLauncherPath` and never spelled again here: the stub
253
+ // finds its `.cmd` by rewriting the last three characters of its own filename,
254
+ // so the two names are ONE decision, and a second literal is where they would
255
+ // come apart.
256
+ const launcher = windowsGuiLauncherPath(settings);
257
+ const identifiers = new Map();
258
+ const claim = (id, what) => {
259
+ const previous = identifiers.get(id);
260
+ if (previous !== undefined) {
261
+ throw new Error(`gjsify ship: the MSI identifier "${id}" would be shared by ${previous} and ${what}. ` +
262
+ 'Two rows cannot carry one id — one would silently replace the other.');
263
+ }
264
+ identifiers.set(id, what);
265
+ return id;
266
+ };
267
+ const lines = [];
268
+ const componentIds = [];
269
+ let launcherFound = false;
270
+ const emitDir = (node, indent) => {
271
+ for (const file of node.files) {
272
+ const componentId = claim(msiIdentifier('c', file.path), file.path);
273
+ const fileId = claim(msiIdentifier('f', file.path), file.path);
274
+ componentIds.push(componentId);
275
+ const guid = msiComponentGuid(settings, file.path);
276
+ // `posix.basename`, not `basename`: `MsiFile.path` is POSIX-separated
277
+ // by construction (it is the STAGED path), and the host's separator is
278
+ // not the authority for it — a Windows packing host would otherwise
279
+ // split a name containing a backslash.
280
+ const name = xmlEscape(posix.basename(file.path));
281
+ lines.push(`${indent}<Component Id="${componentId}" Guid="${guid}">`);
282
+ // THE SHORTCUT LIVES IN THE LAUNCHER'S OWN COMPONENT, nested in its
283
+ // `<File>` and advertised. The obvious alternative — a second component
284
+ // under `ProgramMenuFolder` whose key path is an HKCU registry value —
285
+ // is the shape `light`'s ICE43 asks for and ICE57 then objects to,
286
+ // because a per-machine install with a per-user key path is a component
287
+ // that is half in each world. Nesting it makes the key path the file, so
288
+ // the component is per-machine throughout and the shortcut is removed
289
+ // with it.
290
+ if (file.path === launcher) {
291
+ launcherFound = true;
292
+ lines.push(`${indent} <File Id="${fileId}" Name="${name}" ` +
293
+ `Source="${xmlEscape(file.source)}" KeyPath="yes">`);
294
+ lines.push(`${indent} <Shortcut Id="${claim('s_startmenu', 'the Start-Menu shortcut')}" ` +
295
+ `Directory="ProgramMenuFolder" Name="${xmlEscape(settings.name)}" ` +
296
+ `WorkingDirectory="INSTALLDIR" Description="${xmlEscape(settings.summary)}" ` +
297
+ `Advertise="yes" />`);
298
+ lines.push(`${indent} </File>`);
299
+ }
300
+ else {
301
+ lines.push(`${indent} <File Id="${fileId}" Name="${name}" ` +
302
+ `Source="${xmlEscape(file.source)}" KeyPath="yes" />`);
303
+ }
304
+ lines.push(`${indent}</Component>`);
305
+ }
306
+ for (const child of [...node.children.values()].sort((a, b) => (a.name < b.name ? -1 : 1))) {
307
+ const dirId = claim(msiIdentifier('d', child.path), `the directory ${child.path}`);
308
+ lines.push(`${indent}<Directory Id="${dirId}" Name="${xmlEscape(child.name)}">`);
309
+ emitDir(child, `${indent} `);
310
+ lines.push(`${indent}</Directory>`);
311
+ }
312
+ };
313
+ emitDir(directoryTree(input.files), ' ');
314
+ // EMPTINESS FIRST, and the order is load-bearing rather than tidy: an empty
315
+ // payload has no launcher either, so the launcher refusal below fires on it and
316
+ // sends the reader to look for a `.cmd` in a tree that has no files at all.
317
+ if (componentIds.length === 0) {
318
+ throw new Error('gjsify ship: the windows payload is empty, so there is nothing for an `.msi` to install.');
319
+ }
320
+ if (!launcherFound) {
321
+ throw new Error(`gjsify ship: the windows payload carries no "${launcher}" at the root of the program directory, ` +
322
+ 'so the installer would have nothing to point a Start-Menu shortcut at — an application a user ' +
323
+ 'installs and\n cannot start. `windows-dir` puts the launcher there; a payload without one ' +
324
+ 'is a stage assembled for another layout.');
325
+ }
326
+ const about = settings.homepage === undefined
327
+ ? []
328
+ : [` <Property Id="ARPURLINFOABOUT" Value="${xmlEscape(settings.homepage)}" />`];
329
+ return [
330
+ '<?xml version="1.0" encoding="utf-8"?>',
331
+ '<!-- Generated by `gjsify ship` (ADR 0024 stage 5). Do not edit; edit utils/ship/msi.ts. -->',
332
+ '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">',
333
+ ` <Product Id="${msiProductCode(settings, archLabel)}" Name="${xmlEscape(settings.name)}" ` +
334
+ `Language="1033" Version="${productVersion}" Manufacturer="${xmlEscape(manufacturer)}" ` +
335
+ `UpgradeCode="${msiUpgradeCode(settings)}">`,
336
+ ` <Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" ` +
337
+ `Manufacturer="${xmlEscape(manufacturer)}" Description="${xmlEscape(settings.summary)}" />`,
338
+ // The whole reason `UpgradeCode` is derived from the app id: this is what
339
+ // takes the previous version off before laying the new one down.
340
+ ` <MajorUpgrade DowngradeErrorMessage="A newer version of ${xmlEscape(settings.name)} is ` +
341
+ `already installed." />`,
342
+ // ONE embedded cabinet, so the artifact is the single file a browser
343
+ // downloads rather than an `.msi` plus loose `.cab` siblings that a user
344
+ // will separate from it.
345
+ ' <Media Id="1" Cabinet="app.cab" EmbedCab="yes" />',
346
+ ...about,
347
+ ' <Directory Id="TARGETDIR" Name="SourceDir">',
348
+ ' <Directory Id="ProgramFiles64Folder">',
349
+ // ONE LEVEL, not `%ProgramFiles%\<Publisher>\<App>`, and the reason is an
350
+ // assertion rather than taste: the tree this lays down is then byte-for-byte
351
+ // the tree `windows-dir-zip` expands to, so the two artifacts can be
352
+ // compared against each other instead of each against its own idea of the
353
+ // layout. A publisher level would also be a directory nothing owns, left
354
+ // behind empty by an uninstall.
355
+ ` <Directory Id="INSTALLDIR" Name="${xmlEscape(programDirName)}">`,
356
+ ...lines,
357
+ ' </Directory>',
358
+ ' </Directory>',
359
+ // Declared and left empty: the shortcut reaches it by `Directory=`, so this
360
+ // exists only to give that reference a row. No `<RemoveFolder>` on it — that
361
+ // would ask the installer to delete the Start Menu's Programs folder itself.
362
+ ' <Directory Id="ProgramMenuFolder" />',
363
+ ' </Directory>',
364
+ ` <Feature Id="Main" Title="${xmlEscape(settings.name)}" Level="1">`,
365
+ ...componentIds.map((id) => ` <ComponentRef Id="${id}" />`),
366
+ ' </Feature>',
367
+ ' </Product>',
368
+ '</Wix>',
369
+ '',
370
+ ].join('\n');
371
+ }
372
+ /**
373
+ * The backend for one host.
374
+ *
375
+ * A FUNCTION of the host and nothing else, so the choice is testable from either
376
+ * OS. `assertHostCanFinish` has already refused anything but these two by the time
377
+ * this runs — the throw is for the caller that forgets to.
378
+ */
379
+ export function msiBackendFor(host) {
380
+ if (host === 'linux')
381
+ return 'wixl';
382
+ if (host === 'win32')
383
+ return 'wix3';
384
+ throw new Error(`gjsify ship: no MSI compiler is wired for ${host}. \`wixl\` (msitools) is used on linux and WiX v3 ` +
385
+ 'on win32; assembly is not host-bound, so `gjsify ship windows --stage` here and ' +
386
+ '`gjsify ship --from-stage <dir> --target msi` there is the way across.');
387
+ }
388
+ /**
389
+ * Render the document and compile it into `input.target`.
390
+ *
391
+ * The `.wxs` is KEPT in `workDir` rather than written to a temporary file, and
392
+ * that is deliberate: it is the one artifact of this milestone a human can read,
393
+ * it is what a bug report should carry, and it is what the Windows leg compiles a
394
+ * SECOND time with WiX to produce the file the Linux oracle reads back.
395
+ */
396
+ export async function buildMsi(input) {
397
+ const host = input.host ?? process.platform;
398
+ const backend = msiBackendFor(host);
399
+ mkdirSync(input.workDir, { recursive: true });
400
+ const wxsPath = join(input.workDir, `${input.settings.binaryName}.wxs`);
401
+ writeFileSync(wxsPath, renderWxs(input));
402
+ if (backend === 'wixl') {
403
+ await run('wixl', ['-a', input.archLabel, '-o', input.target, wxsPath], input);
404
+ return wxsPath;
405
+ }
406
+ // WiX v3 is two passes, and the intermediate directory is wiped between runs
407
+ // for the reason the Flatpak packer wipes its repo: a `.wixobj` that survives a
408
+ // failed compile is one `light` will happily link into an artifact wearing the
409
+ // new version's filename.
410
+ const objDir = join(input.workDir, 'obj');
411
+ rmSync(objDir, { recursive: true, force: true, maxRetries: 5 });
412
+ mkdirSync(objDir, { recursive: true });
413
+ await run('candle.exe', ['-nologo', '-arch', input.archLabel, '-out', `${objDir}\\`, wxsPath], input);
414
+ await run('light.exe', ['-nologo', '-out', input.target, join(objDir, `${input.settings.binaryName}.wixobj`)], input);
415
+ return wxsPath;
416
+ }
417
+ async function run(cmd, args, input) {
418
+ if (input.verbose)
419
+ console.log(`[gjsify ship] ${cmd} ${args.join(' ')}`);
420
+ // `completion: 'return'`, as the Flatpak packer does and for the same reason:
421
+ // `ship` packs the remaining formats and prints the artifact list afterwards,
422
+ // so it cannot end in `process.exit()`.
423
+ const result = await spawnToCompletion(cmd, [...args], {
424
+ completion: 'return',
425
+ cwd: input.workDir,
426
+ stdio: 'inherit',
427
+ notFound: () => new Error(`gjsify ship: ${cmd} not found. It is the MSI compiler for this host — ` +
428
+ 'Fedora: `sudo dnf install msitools`, Debian/Ubuntu: `sudo apt install msitools`, ' +
429
+ 'Windows: WiX Toolset v3.14 with its `bin` directory on PATH.'),
430
+ });
431
+ if (result.code !== 0) {
432
+ throw new Error(`gjsify ship: ${cmd} failed with ${describeExit(result)}.`);
433
+ }
434
+ }
@@ -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;