@lakutata/builder 3.0.0-beta.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.
@@ -0,0 +1,525 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readdirSync } from 'node:fs';
3
+ import { copyFile, cp, mkdir, rm, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { runCommand } from '../Processes.js';
6
+ import { ensureTool } from '../Tools.js';
7
+ /**
8
+ * The installers of Windows: the installer of NSIS (.exe), the package of Windows Installer (.msi), the zip archive of
9
+ * the directory of the executable
10
+ */
11
+ export const WINDOWS_TARGETS = ['nsis', 'msi', 'zip'];
12
+ /**
13
+ * The architecture of the packages (the one of the executable)
14
+ */
15
+ function packageArch() {
16
+ return process.arch === 'arm64' ? 'arm64' : 'x64';
17
+ }
18
+ /**
19
+ * A text of XML
20
+ * @param value
21
+ */
22
+ function xml(value) {
23
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
+ }
25
+ /**
26
+ * A value in double quotes, as the environment file of the application gives them (LAKUTATA_ENV_FILE)
27
+ * @param value
28
+ */
29
+ function doubleQuoted(value) {
30
+ return `"${value.replace(/[\\"$`]/g, '\\$&')}"`;
31
+ }
32
+ /**
33
+ * An argument of a command line of Windows
34
+ * @param arg
35
+ */
36
+ function argument(arg) {
37
+ if (arg && !/[\s"]/.test(arg))
38
+ return arg;
39
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
40
+ }
41
+ /**
42
+ * The name of the account of a service: a virtual account (NT SERVICE\<service>) by default, LocalService,
43
+ * NetworkService, LocalSystem
44
+ * @param service
45
+ */
46
+ function serviceAccount(service) {
47
+ const user = service.user;
48
+ if (/^(LocalSystem|root)$/i.test(user))
49
+ return undefined;
50
+ if (/^(LocalService|NetworkService)$/i.test(user))
51
+ return { domain: 'NT AUTHORITY', user: user };
52
+ const [domain, name] = user.includes('\\') ? user.split('\\') : ['NT SERVICE', user];
53
+ return { domain: domain, user: name };
54
+ }
55
+ /**
56
+ * The paths of an application installed on Windows: in Program Files, its data in ProgramData
57
+ * @param plan
58
+ */
59
+ function installPaths(plan) {
60
+ return {
61
+ wrapper: `${plan.name}-service`,
62
+ environment: `%ProgramData%\\${plan.name}\\${plan.name}.env`
63
+ };
64
+ }
65
+ /**
66
+ * The environment file of the service: its variables, which the administrators change (kept by the upgrades), read by
67
+ * the application at its start (LAKUTATA_ENV_FILE)
68
+ * @param service
69
+ */
70
+ function environmentFile(service) {
71
+ return `# The environment of the service ${service.name}, read when it starts (Restart-Service ${service.name})\r\n${Object.entries(service.env).map(([name, value]) => `${name}=${doubleQuoted(value)}`).join('\r\n')}\r\n`;
72
+ }
73
+ /**
74
+ * The configuration of WinSW, the wrapper running the application as a service: stopped by Ctrl+C (SIGINT for node,
75
+ * the application ends gracefully), its environment file, its directory, its account, its logs
76
+ * @param plan
77
+ * @param service
78
+ */
79
+ function serviceConfiguration(plan, service) {
80
+ const account = serviceAccount(service);
81
+ return [
82
+ '<service>',
83
+ ` <id>${xml(service.name)}</id>`,
84
+ ` <name>${xml(plan.productName)}</name>`,
85
+ ` <description>${xml(service.description)}</description>`,
86
+ ` <executable>%BASE%\\${xml(plan.program)}.exe</executable>`,
87
+ ...service.args.length ? [` <arguments>${xml(service.args.map(argument).join(' '))}</arguments>`] : [],
88
+ ` <workingdirectory>%ProgramData%\\${xml(plan.name)}</workingdirectory>`,
89
+ ` <env name="LAKUTATA_ENV_FILE" value="${xml(installPaths(plan).environment)}"/>`,
90
+ ` <startmode>${service.autoStart ? 'Automatic' : 'Manual'}</startmode>`,
91
+ ' <stoptimeout>30 sec</stoptimeout>',
92
+ ...service.restart === 'no' ? [] : [' <onfailure action="restart" delay="5 sec"/>'],
93
+ ...account ? [' <serviceaccount>', ` <domain>${xml(account.domain)}</domain>`, ` <user>${xml(account.user)}</user>`, ' </serviceaccount>'] : [],
94
+ ` <logpath>%ProgramData%\\${xml(plan.name)}\\logs</logpath>`,
95
+ ' <log mode="roll-by-size">',
96
+ ' <sizeThreshold>10240</sizeThreshold>',
97
+ ' <keepFiles>8</keepFiles>',
98
+ ' </log>',
99
+ '</service>',
100
+ ''
101
+ ].join('\r\n');
102
+ }
103
+ /**
104
+ * The script of the installer of NSIS (Windows PowerShell): install (the data, the environment file written once, the
105
+ * PATH, the service registered), start (the service), stop (the service of the version replaced), uninstall
106
+ * @param plan
107
+ */
108
+ function installerScript(plan) {
109
+ const service = plan.service;
110
+ const account = service && serviceAccount(service);
111
+ const quote = (value) => `'${value.replace(/'/g, "''")}'`;
112
+ return [
113
+ '# The installation of the application by its installer (NSIS): install, start, stop (before an upgrade), uninstall',
114
+ 'param([string]$Action, [string]$InstallDir)',
115
+ '$ErrorActionPreference = \'Stop\'',
116
+ `$data = Join-Path $env:ProgramData ${quote(plan.name)}`,
117
+ `$wrapper = Join-Path $InstallDir ${quote(`${installPaths(plan).wrapper}.exe`)}`,
118
+ 'function Invoke-Wrapper([string]$command) { if (Test-Path $wrapper) { & $wrapper $command | Out-Null } }',
119
+ 'if ($Action -eq \'install\') {',
120
+ ' New-Item -ItemType Directory -Force $data, (Join-Path $data \'logs\') | Out-Null',
121
+ ...service ? [
122
+ ` $environment = Join-Path $data ${quote(`${plan.name}.env`)}`,
123
+ ' #Kept by the upgrades: the administrators change it',
124
+ ` if (-not (Test-Path $environment)) { [IO.File]::WriteAllText($environment, ${quote(environmentFile(service))}) }`
125
+ ] : [],
126
+ ...plan.addToPath ? [
127
+ ' $path = [Environment]::GetEnvironmentVariable(\'Path\', \'Machine\')',
128
+ ' if (($path -split \';\') -notcontains $InstallDir) { [Environment]::SetEnvironmentVariable(\'Path\', ($path.TrimEnd(\';\') + \';\' + $InstallDir), \'Machine\') }'
129
+ ] : [],
130
+ ...service ? [
131
+ ' Invoke-Wrapper install',
132
+ ...account ? [` icacls $data /grant ${quote(`${account.domain}\\${account.user}:(OI)(CI)M`)} /T /Q | Out-Null`] : []
133
+ ] : [],
134
+ //After the postinstall script of the application
135
+ '} elseif ($Action -eq \'start\') {',
136
+ ...service?.autoStart ? [' Invoke-Wrapper start'] : [],
137
+ '} else {',
138
+ ...service ? [' try { Invoke-Wrapper stop; Invoke-Wrapper uninstall } catch { }'] : [],
139
+ ...plan.addToPath ? [
140
+ ' if ($Action -eq \'uninstall\') {',
141
+ ' $path = [Environment]::GetEnvironmentVariable(\'Path\', \'Machine\')',
142
+ ' [Environment]::SetEnvironmentVariable(\'Path\', (($path -split \';\' | Where-Object { $_ -and $_ -ne $InstallDir }) -join \';\'), \'Machine\')',
143
+ ' }'
144
+ ] : [],
145
+ '}',
146
+ ''
147
+ ].join('\r\n');
148
+ }
149
+ /**
150
+ * The version of the files of Windows (four numbers)
151
+ * @param version
152
+ */
153
+ function numericVersion(version) {
154
+ const [major, minor, patch] = version.split(/[.+-]/).map((part) => Number.parseInt(part, 10) || 0);
155
+ return `${major}.${minor}.${patch}`;
156
+ }
157
+ /**
158
+ * The script of NSIS of the installer: its files in Program Files, its uninstaller registered, its script run (the
159
+ * service of the version it replaces stopped first, its files being replaced); the scripts of the application run from
160
+ * its temporary directory (the preinstall one before its files are installed, the postinstall one before the service
161
+ * starts, the preremove one once it is stopped, the postremove one once the files are removed), the macros of the file
162
+ * of NSIS it includes inserted
163
+ * @param plan
164
+ * @param stage the files it installs
165
+ * @param file
166
+ * @param scripts the scripts of the application (written with the mark of UTF-8, which Windows PowerShell reads them by)
167
+ */
168
+ function nsisScript(plan, stage, file, scripts) {
169
+ const uninstallKey = `Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${plan.appId}`;
170
+ //A text given (its $ are not variables of NSIS), a path of NSIS (its variables: $INSTDIR)
171
+ const string = (value) => `"${value.replace(/\$/g, '$$$$').replace(/"/g, '$\\"')}"`;
172
+ const variables = (value) => `"${value.replace(/"/g, '$\\"')}"`;
173
+ const script = (action) => `nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\\installer.ps1" -Action ${action} -InstallDir "$INSTDIR"'`;
174
+ //A script of the application, extracted to the temporary directory of the installer, given what it does ($R9)
175
+ const hook = (name) => {
176
+ const source = scripts.hooks[name];
177
+ return source ? [
178
+ ` ; The ${name} script of the application`,
179
+ ' InitPluginsDir',
180
+ ` File ${variables(`/oname=$PLUGINSDIR\\${name}.ps1`)} ${string(source)}`,
181
+ ` nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\\${name}.ps1" $R9'`,
182
+ ' Pop $0',
183
+ ' StrCmp $0 "0" +2',
184
+ ` Abort "The ${name} script failed ($0)"`
185
+ ] : [];
186
+ };
187
+ const macro = (name) => scripts.include ? [` !ifmacrodef ${name}`, ` !insertmacro ${name}`, ' !endif'] : [];
188
+ //The directory of the application, given to its scripts
189
+ const environment = ' System::Call \'Kernel32::SetEnvironmentVariable(t "LAKUTATA_INSTALL_DIR", t "$INSTDIR")i\'';
190
+ return [
191
+ 'Unicode true',
192
+ 'ManifestDPIAware true',
193
+ '!include "x64.nsh"',
194
+ `Name ${string(plan.productName)}`,
195
+ `OutFile ${string(file)}`,
196
+ //The icon of the application
197
+ ...plan.icon ? [`Icon ${string(plan.icon)}`, `UninstallIcon ${string(plan.icon)}`] : [],
198
+ `InstallDir ${variables(`$PROGRAMFILES64\\${plan.name}`)}`,
199
+ 'RequestExecutionLevel admin',
200
+ 'SetCompressor /SOLID lzma',
201
+ 'ShowInstDetails show',
202
+ 'ShowUninstDetails show',
203
+ `VIProductVersion "${numericVersion(plan.version)}.0"`,
204
+ `VIAddVersionKey "ProductName" ${string(plan.productName)}`,
205
+ `VIAddVersionKey "ProductVersion" ${string(plan.version)}`,
206
+ `VIAddVersionKey "FileVersion" ${string(plan.version)}`,
207
+ `VIAddVersionKey "FileDescription" ${string(plan.description)}`,
208
+ `VIAddVersionKey "CompanyName" ${string(plan.vendor ?? plan.maintainer)}`,
209
+ ...scripts.include ? ['', '; The file of NSIS of the application (win.nsis.include)', `!include ${string(scripts.include)}`, ...macro('customHeader').map((line) => line.trim())] : [],
210
+ '',
211
+ '; The 64-bit registry and system (the installer is a 32-bit program)',
212
+ 'Function .onInit',
213
+ ' SetRegView 64',
214
+ ' ${DisableX64FSRedirection}',
215
+ ...macro('customInit'),
216
+ 'FunctionEnd',
217
+ 'Function un.onInit',
218
+ ' SetRegView 64',
219
+ ' ${DisableX64FSRedirection}',
220
+ ...macro('customUnInit'),
221
+ 'FunctionEnd',
222
+ '',
223
+ 'Section "Install"',
224
+ ' SetShellVarContext all',
225
+ environment,
226
+ ' ; An upgrade replaces the application installed',
227
+ ' StrCpy $R9 "install"',
228
+ ' IfFileExists "$INSTDIR\\installer.ps1" 0 +2',
229
+ ' StrCpy $R9 "upgrade"',
230
+ ...hook('preinstall'),
231
+ ' ; The service of the version replaced is stopped: its files are replaced',
232
+ ' IfFileExists "$INSTDIR\\installer.ps1" 0 +3',
233
+ ` ${script('stop')}`,
234
+ ' Pop $0',
235
+ ' SetOutPath "$INSTDIR"',
236
+ ` File /r ${string(`${stage}\\*.*`)}`,
237
+ ' WriteUninstaller "$INSTDIR\\uninstall.exe"',
238
+ ` WriteRegStr HKLM ${string(uninstallKey)} "DisplayName" ${string(plan.productName)}`,
239
+ ` WriteRegStr HKLM ${string(uninstallKey)} "DisplayVersion" ${string(plan.version)}`,
240
+ ` WriteRegStr HKLM ${string(uninstallKey)} "Publisher" ${string(plan.vendor ?? plan.maintainer)}`,
241
+ ` WriteRegStr HKLM ${string(uninstallKey)} "InstallLocation" "$INSTDIR"`,
242
+ ` WriteRegStr HKLM ${string(uninstallKey)} "DisplayIcon" ${variables(`$INSTDIR\\${plan.program}.exe`)}`,
243
+ ` WriteRegStr HKLM ${string(uninstallKey)} "UninstallString" '"$INSTDIR\\uninstall.exe"'`,
244
+ ` WriteRegStr HKLM ${string(uninstallKey)} "QuietUninstallString" '"$INSTDIR\\uninstall.exe" /S'`,
245
+ ` WriteRegDWORD HKLM ${string(uninstallKey)} "NoModify" 1`,
246
+ ` WriteRegDWORD HKLM ${string(uninstallKey)} "NoRepair" 1`,
247
+ ` ${script('install')}`,
248
+ ' Pop $0',
249
+ ' StrCmp $0 "0" +2',
250
+ ' Abort "The installation failed ($0)"',
251
+ ...hook('postinstall'),
252
+ ` ${script('start')}`,
253
+ ' Pop $0',
254
+ ...macro('customInstall'),
255
+ 'SectionEnd',
256
+ '',
257
+ 'Section "Uninstall"',
258
+ ' SetShellVarContext all',
259
+ environment,
260
+ ' StrCpy $R9 "remove"',
261
+ ...macro('customUnInstall'),
262
+ ` ${script('uninstall')}`,
263
+ ' Pop $0',
264
+ ...hook('preremove'),
265
+ ' RMDir /r "$INSTDIR"',
266
+ ` DeleteRegKey HKLM ${string(uninstallKey)}`,
267
+ ...hook('postremove'),
268
+ 'SectionEnd',
269
+ ''
270
+ ].join('\r\n');
271
+ }
272
+ /**
273
+ * A UUID named after a text (version 5): the upgrade code of the packages of an application, the same for its versions
274
+ * @param name
275
+ */
276
+ function namedUuid(name) {
277
+ //The namespace of the URLs (RFC 4122)
278
+ const namespace = Buffer.from('6ba7b8119dad11d180b400c04fd430c8', 'hex');
279
+ const hash = createHash('sha1').update(Buffer.concat([namespace, Buffer.from(`lakutata:${name}`)])).digest();
280
+ hash[6] = (hash[6] & 0x0f) | 0x50;
281
+ hash[8] = (hash[8] & 0x3f) | 0x80;
282
+ const hex = hash.subarray(0, 16).toString('hex').toUpperCase();
283
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
284
+ }
285
+ /**
286
+ * The moments of Windows Installer the scripts of the application are run at (deferred, as the system): the preinstall
287
+ * one before the files are installed (an upgrade has removed the version it replaces), the postinstall one before the
288
+ * services start, the preremove one once they are stopped, the postremove one once the files are removed; not the ones
289
+ * of the removal of the version an upgrade replaces
290
+ */
291
+ const MSI_HOOKS = {
292
+ preinstall: { id: 'LakutataPreinstall', at: 'Before="InstallFiles"', condition: 'NOT REMOVE' },
293
+ postinstall: { id: 'LakutataPostinstall', at: 'Before="StartServices"', condition: 'NOT REMOVE' },
294
+ preremove: { id: 'LakutataPreremove', at: 'After="StopServices"', condition: 'REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE' },
295
+ postremove: { id: 'LakutataPostremove', at: 'After="RemoveFiles"', condition: 'REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE' }
296
+ };
297
+ /**
298
+ * The longest command line of Windows
299
+ */
300
+ const COMMAND_LINE_LIMIT = 32000;
301
+ /**
302
+ * The command running a script of the application from Windows Installer (a formatted text of it: its brackets are
303
+ * escaped, but the ones of its properties): the script, embedded, written to a temporary file and run by Windows
304
+ * PowerShell as the installer of NSIS runs it (given what it does, the directory of the application)
305
+ * @param script
306
+ * @param action
307
+ */
308
+ function msiCommand(script, action) {
309
+ const encoded = Buffer.from(script.content, 'utf8').toString('base64');
310
+ const formatted = (text) => text.replace(/[[\]]/g, (bracket) => bracket === '[' ? '[\\[]' : '[\\]]');
311
+ const command = `"[System64Folder]WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$env:LAKUTATA_INSTALL_DIR='[INSTALLDIR]'.TrimEnd('\\'); ${formatted(`$f = [IO.Path]::Combine([IO.Path]::GetTempPath(), [Guid]::NewGuid().ToString() + '.ps1'); [IO.File]::WriteAllText($f, [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')), (New-Object Text.UTF8Encoding $true)); & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $f ${action}; $c = $LASTEXITCODE; Remove-Item -Force $f; exit $c`)}"`;
312
+ if (command.length > COMMAND_LINE_LIMIT)
313
+ throw new Error(`The script ${script.file} is too long for Windows Installer, which runs it by a command line: make it shorter (the one it embeds is ${command.length} characters long, ${COMMAND_LINE_LIMIT} at most)`);
314
+ return command;
315
+ }
316
+ /**
317
+ * The custom actions of Windows Installer running the scripts of the application: the command of each (install or
318
+ * upgrade: an upgrade has found the version it replaces), and when it is run
319
+ * @param hooks
320
+ */
321
+ function msiScripts(hooks) {
322
+ const actions = [];
323
+ const sequence = [];
324
+ for (const [hook, script] of Object.entries(hooks)) {
325
+ const { id, at, condition } = MSI_HOOKS[hook];
326
+ const variants = hook === 'preinstall' || hook === 'postinstall'
327
+ ? [['Install', 'install', `${condition} AND NOT WIX_UPGRADE_DETECTED`], ['Upgrade', 'upgrade', `${condition} AND WIX_UPGRADE_DETECTED`]]
328
+ : [['Remove', 'remove', condition]];
329
+ for (const [name, action, when] of variants)
330
+ actions.push(` <SetProperty Id="${id}" Action="Set${id}${name}" Value="${xml(msiCommand(script, action))}" Before="${id}" Sequence="execute">${xml(when)}</SetProperty>`);
331
+ actions.push(` <CustomAction Id="${id}" BinaryKey="WixCA" DllEntry="WixQuietExec64" Execute="deferred" Return="check" Impersonate="no"/>`);
332
+ sequence.push(` <Custom Action="${id}" ${at}>${xml(condition)}</Custom>`);
333
+ }
334
+ return { actions: actions, sequence: sequence };
335
+ }
336
+ /**
337
+ * The source of WiX of the package: its files (a component each, their directories), its service (WinSW) installed,
338
+ * started, stopped and removed by Windows Installer, its PATH, its data in ProgramData (its environment file kept by
339
+ * the upgrades and the removals), the versions replaced by the upgrades
340
+ * @param plan
341
+ * @param stage the files it installs
342
+ * @param serviceDirectory the files of its service (WinSW and its configuration)
343
+ * @param environment the environment file
344
+ */
345
+ function wixSource(plan, stage, serviceDirectory, environment) {
346
+ const scripts = msiScripts(plan.scripts.msi?.hooks ?? {});
347
+ const id = (prefix, value) => `${prefix}${createHash('sha1').update(value).digest('hex').slice(0, 30)}`;
348
+ const components = [];
349
+ const directory = (absolute, relative, depth) => {
350
+ const indent = ' '.repeat(depth);
351
+ const lines = [];
352
+ for (const entry of readdirSync(absolute, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
353
+ const file = path.join(absolute, entry.name);
354
+ const name = relative ? `${relative}/${entry.name}` : entry.name;
355
+ if (entry.isDirectory()) {
356
+ lines.push(`${indent}<Directory Id="${id('d', name)}" Name="${xml(entry.name)}">`, ...directory(file, name, depth + 1), `${indent}</Directory>`);
357
+ }
358
+ else if (entry.isFile()) {
359
+ const component = id('c', name);
360
+ components.push(component);
361
+ lines.push(`${indent}<Component Id="${component}" Guid="*"><File Id="${id('f', name)}" Source="${xml(file)}" KeyPath="yes"/></Component>`);
362
+ }
363
+ }
364
+ return lines;
365
+ };
366
+ const files = directory(stage, '', 6);
367
+ const service = plan.service;
368
+ const account = service && serviceAccount(service);
369
+ const wrapper = `${installPaths(plan).wrapper}.exe`;
370
+ const restart = service?.restart === 'no' ? 'none' : 'restart';
371
+ return [
372
+ '<?xml version="1.0" encoding="UTF-8"?>',
373
+ '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">',
374
+ ` <Product Id="*" Name="${xml(plan.productName)}" Language="1033" Version="${numericVersion(plan.version)}" Manufacturer="${xml(plan.vendor ?? plan.maintainer)}" UpgradeCode="${namedUuid(plan.appId)}">`,
375
+ ` <Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" Description="${xml(plan.description)}"/>`,
376
+ ' <MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is installed."/>',
377
+ ' <MediaTemplate EmbedCab="yes"/>',
378
+ //The icon of the application in the programs of Windows
379
+ ...plan.icon ? [` <Icon Id="ProductIcon.ico" SourceFile="${xml(plan.icon)}"/>`, ' <Property Id="ARPPRODUCTICON" Value="ProductIcon.ico"/>'] : [],
380
+ ` <Property Id="ARPINSTALLLOCATION" Value="[INSTALLDIR]"/>`,
381
+ ' <Directory Id="TARGETDIR" Name="SourceDir">',
382
+ ' <Directory Id="ProgramFiles64Folder">',
383
+ ` <Directory Id="INSTALLDIR" Name="${xml(plan.name)}">`,
384
+ ...files,
385
+ ' </Directory>',
386
+ ' </Directory>',
387
+ ' <Directory Id="CommonAppDataFolder">',
388
+ ` <Directory Id="DATADIR" Name="${xml(plan.name)}">`,
389
+ ' <Directory Id="LOGSDIR" Name="logs"/>',
390
+ ' </Directory>',
391
+ ' </Directory>',
392
+ ' </Directory>',
393
+ //The data, the logs and the environment file are kept by the removals
394
+ ` <Component Id="Data" Directory="DATADIR" Guid="${namedUuid(`${plan.appId}:data`)}" Permanent="yes" KeyPath="yes"><CreateFolder/></Component>`,
395
+ ` <Component Id="Logs" Directory="LOGSDIR" Guid="${namedUuid(`${plan.appId}:logs`)}" Permanent="yes" KeyPath="yes"><CreateFolder/></Component>`,
396
+ ...environment ? [` <Component Id="Environment" Directory="DATADIR" Guid="${namedUuid(`${plan.appId}:environment`)}" Permanent="yes" NeverOverwrite="yes"><File Id="EnvironmentFile" Name="${xml(`${plan.name}.env`)}" Source="${xml(environment)}" KeyPath="yes"/></Component>`] : [],
397
+ ...plan.addToPath ? [` <Component Id="Path" Directory="INSTALLDIR" Guid="${namedUuid(`${plan.appId}:path`)}" KeyPath="yes"><Environment Id="Path" Name="PATH" Value="[INSTALLDIR]" Permanent="no" Part="last" Action="set" System="yes"/></Component>`] : [],
398
+ ...service ? [
399
+ ` <Component Id="Service" Directory="INSTALLDIR" Guid="${namedUuid(`${plan.appId}:service`)}">`,
400
+ ` <File Id="ServiceWrapper" Source="${xml(path.join(serviceDirectory, wrapper))}" KeyPath="yes"/>`,
401
+ ` <File Id="ServiceConfiguration" Source="${xml(path.join(serviceDirectory, `${installPaths(plan).wrapper}.xml`))}"/>`,
402
+ ` <ServiceInstall Id="ServiceInstall" Name="${xml(service.name)}" DisplayName="${xml(plan.productName)}" Description="${xml(service.description)}" Type="ownProcess" Start="${service.autoStart ? 'auto' : 'demand'}" ErrorControl="normal"${account ? ` Account="${xml(`${account.domain}\\${account.user}`)}"` : ''}>`,
403
+ ` <util:ServiceConfig FirstFailureActionType="${restart}" SecondFailureActionType="${restart}" ThirdFailureActionType="${restart}" RestartServiceDelayInSeconds="5" ResetPeriodInDays="1"/>`,
404
+ ' </ServiceInstall>',
405
+ ` <ServiceControl Id="ServiceControl" Name="${xml(service.name)}"${service.autoStart ? ' Start="install"' : ''} Stop="both" Remove="uninstall" Wait="yes"/>`,
406
+ ' </Component>',
407
+ ...account ? [
408
+ //The data of the service written by its account, once it exists (the service installed), before it starts
409
+ ` <SetProperty Id="GrantData" Value="&quot;[System64Folder]icacls.exe&quot; &quot;[DATADIR].&quot; /grant &quot;${xml(`${account.domain}\\${account.user}`)}:(OI)(CI)M&quot; /T /Q" Before="GrantData" Sequence="execute"/>`,
410
+ ' <CustomAction Id="GrantData" BinaryKey="WixCA" DllEntry="WixQuietExec64" Execute="deferred" Return="check" Impersonate="no"/>'
411
+ ] : []
412
+ ] : [],
413
+ ...scripts.actions,
414
+ ...account || scripts.sequence.length ? [
415
+ ' <InstallExecuteSequence>',
416
+ ...account ? [' <Custom Action="GrantData" After="InstallServices">NOT REMOVE</Custom>'] : [],
417
+ ...scripts.sequence,
418
+ ' </InstallExecuteSequence>'
419
+ ] : [],
420
+ ' <Feature Id="Application" Level="1">',
421
+ ...components.map((component) => ` <ComponentRef Id="${component}"/>`),
422
+ ' <ComponentRef Id="Data"/>',
423
+ ' <ComponentRef Id="Logs"/>',
424
+ ...environment ? [' <ComponentRef Id="Environment"/>'] : [],
425
+ ...plan.addToPath ? [' <ComponentRef Id="Path"/>'] : [],
426
+ ...service ? [' <ComponentRef Id="Service"/>'] : [],
427
+ ' </Feature>',
428
+ ' </Product>',
429
+ '</Wix>',
430
+ ''
431
+ ].join('\r\n');
432
+ }
433
+ /**
434
+ * Run a tool of the builder, which must succeed
435
+ * @param command
436
+ * @param args
437
+ * @param cwd
438
+ * @param what
439
+ */
440
+ async function must(command, args, cwd, what) {
441
+ const result = await runCommand(command, args, cwd);
442
+ if (result.code !== 0)
443
+ throw new Error(`${what} cannot be made: ${result.output.trim()}`);
444
+ }
445
+ /**
446
+ * Make the installers of Windows of an application: the installer of NSIS, the package of Windows Installer (WiX), the
447
+ * zip archive of the directory of the executable; its service run by WinSW
448
+ * @param plan
449
+ * @param targets
450
+ * @param work
451
+ * @param outdir
452
+ */
453
+ export async function buildWindowsPackages(plan, targets, work, outdir) {
454
+ await mkdir(work, { recursive: true });
455
+ const files = [];
456
+ const service = plan.service;
457
+ //The files of the service: WinSW named after the application, its configuration next to it
458
+ const serviceDirectory = path.join(work, 'service');
459
+ await rm(serviceDirectory, { recursive: true, force: true });
460
+ await mkdir(serviceDirectory, { recursive: true });
461
+ let environment;
462
+ if (service) {
463
+ await copyFile(await ensureTool('winsw'), path.join(serviceDirectory, `${installPaths(plan).wrapper}.exe`));
464
+ await writeFile(path.join(serviceDirectory, `${installPaths(plan).wrapper}.xml`), serviceConfiguration(plan, service));
465
+ environment = path.join(serviceDirectory, `${plan.name}.env`);
466
+ await writeFile(environment, environmentFile(service));
467
+ }
468
+ for (const target of targets) {
469
+ if (target === 'zip') {
470
+ const base = `${plan.name}-${plan.version}-win-${packageArch()}`;
471
+ await rm(path.join(work, base), { recursive: true, force: true });
472
+ await cp(plan.directory, path.join(work, base), { recursive: true });
473
+ const file = path.join(outdir, `${base}.zip`);
474
+ await rm(file, { force: true });
475
+ //The tar of Windows writes zip archives (-a)
476
+ await must('tar', ['-a', '-c', '-f', file, base], work, 'The zip archive');
477
+ files.push({ target: target, file: file, size: (await stat(file)).size });
478
+ continue;
479
+ }
480
+ if (target === 'nsis') {
481
+ //The files installed: the application, its service, its installation script
482
+ const stage = path.join(work, 'nsis');
483
+ await rm(stage, { recursive: true, force: true });
484
+ await cp(plan.directory, stage, { recursive: true });
485
+ if (service)
486
+ for (const name of readdirSync(serviceDirectory).filter((name) => !name.endsWith('.env')))
487
+ await copyFile(path.join(serviceDirectory, name), path.join(stage, name));
488
+ //Windows PowerShell reads the scripts of UTF-8 by their mark
489
+ await writeFile(path.join(stage, 'installer.ps1'), `\ufeff${installerScript(plan)}`);
490
+ //The scripts of the application, extracted by the installer when it runs them
491
+ const own = plan.scripts.nsis;
492
+ const hooks = {};
493
+ const directory = path.join(work, 'nsis-scripts');
494
+ await rm(directory, { recursive: true, force: true });
495
+ await mkdir(directory, { recursive: true });
496
+ for (const [hook, script] of Object.entries(own?.hooks ?? {})) {
497
+ hooks[hook] = path.join(directory, `${hook}.ps1`);
498
+ await writeFile(hooks[hook], `\ufeff${script.content}`);
499
+ }
500
+ let include;
501
+ if (own?.include) {
502
+ include = path.join(directory, 'include.nsh');
503
+ await writeFile(include, own.include.content);
504
+ }
505
+ const file = path.join(outdir, `${plan.name}-${plan.version}-${packageArch()}-setup.exe`);
506
+ await rm(file, { force: true });
507
+ const script = path.join(work, 'installer.nsi');
508
+ await writeFile(script, `\ufeff${nsisScript(plan, stage, file, { hooks: hooks, include: include })}`);
509
+ await must(await ensureTool('nsis'), ['/V2', '/INPUTCHARSET', 'UTF8', script], work, 'The installer of NSIS');
510
+ files.push({ target: target, file: file, size: (await stat(file)).size });
511
+ continue;
512
+ }
513
+ //msi: compiled by candle, linked by light
514
+ const source = path.join(work, 'package.wxs');
515
+ await writeFile(source, wixSource(plan, plan.directory, serviceDirectory, environment));
516
+ const candle = await ensureTool('wix');
517
+ const light = path.join(path.dirname(candle), 'light.exe');
518
+ const file = path.join(outdir, `${plan.name}-${plan.version}-${packageArch()}.msi`);
519
+ await rm(file, { force: true });
520
+ await must(candle, ['-nologo', '-arch', packageArch(), '-ext', 'WixUtilExtension', '-out', path.join(work, 'package.wixobj'), source], work, 'The package of Windows Installer');
521
+ await must(light, ['-nologo', '-ext', 'WixUtilExtension', '-spdb', '-out', file, path.join(work, 'package.wixobj')], work, 'The package of Windows Installer');
522
+ files.push({ target: target, file: file, size: (await stat(file)).size });
523
+ }
524
+ return files;
525
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The builder of the applications of lakutata: their bundles (one CommonJS file run by node) and their executables (the
3
+ * single executable applications of Node.js, or Bun), with their native addons and their resources
4
+ */
5
+ export { bundleProject, programName, resourceFiles, type BundleOptions, type BundleResult, type ResourceDeclaration } from './lib/Bundle.js';
6
+ export { buildExecutable, type ExecutableOptions, type ExecutableResult } from './lib/Executable.js';
7
+ export { NativeCollector, packNativeTree, writeNativeTree, type NativeFile, type NativeIndex, type NativeLink, type NativeTree } from './lib/Native.js';
8
+ export { buildInstaller, type InstallerConfig, type InstallerOptions, type InstallerResult, type ServiceOptions } from './lib/Installer.js';
9
+ export { LINUX_TARGETS, type LinuxTarget } from './lib/linux/Linux.js';
10
+ export { INSTALLER_HOOKS, NATIVE_HOOKS, type InstallerHook, type InstallerScripts, type NativeHook, type NativeScripts } from './lib/Scripts.js';
11
+ export { detectLibc } from './lib/Platform.js';
12
+ export { WINDOWS_TARGETS, type WindowsTarget } from './lib/win/Windows.js';
13
+ export { MAC_TARGETS, type MacTarget } from './lib/mac/Mac.js';
@@ -0,0 +1,14 @@
1
+ import type { InstallerPlan } from './Installer.js';
2
+ /**
3
+ * The tar.gz archive of the directory of the executable (its native addons and its resources next to it), named after
4
+ * the package, its version, its platform and its architecture
5
+ * @param plan
6
+ * @param arch
7
+ * @param work
8
+ * @param outdir
9
+ */
10
+ export declare function archiveDirectory(plan: InstallerPlan, arch: string, work: string, outdir: string): Promise<{
11
+ target: string;
12
+ file: string;
13
+ size: number;
14
+ }>;
@@ -0,0 +1,96 @@
1
+ import { type NativeFile, type NativeTree } from './Native.js';
2
+ /**
3
+ * The application bundled into one file
4
+ */
5
+ export type BundleOptions = {
6
+ /**
7
+ * The directory of the project (its package.json)
8
+ */
9
+ project: string;
10
+ /**
11
+ * The compiled entry of the application, the "main" of the package.json by default
12
+ */
13
+ entry?: string;
14
+ /**
15
+ * The bundle, dist/<name>.cjs by default
16
+ */
17
+ outfile?: string;
18
+ /**
19
+ * Minify the bundle (the names of the classes and of the functions are kept)
20
+ */
21
+ minify?: boolean;
22
+ /**
23
+ * The modules left out of the bundle, loaded at run time
24
+ */
25
+ external?: string[];
26
+ /**
27
+ * Where the native tree goes: next to the bundle (the bundles run by node, by default), or given back (the executables
28
+ * embed it)
29
+ */
30
+ native?: 'directory' | 'embedded';
31
+ /**
32
+ * The runtime running the bundle: node (by default), or bun (the executables of Bun, which bundle it again)
33
+ */
34
+ runtime?: 'node' | 'bun';
35
+ /**
36
+ * The directories of the native tree and of the resources (when they are not embedded), next to the bundle by
37
+ * default (<name>.native and <name>.resources): the bundle loads them by their names from its directory
38
+ */
39
+ directories?: {
40
+ native?: string;
41
+ resources?: string;
42
+ };
43
+ };
44
+ /**
45
+ * A resource of the application, declared by the "lakutata.resources" of its package.json (as the extraResources of
46
+ * electron-builder): a file or a directory of the project, and its path in the resources
47
+ */
48
+ export type ResourceDeclaration = {
49
+ from: string;
50
+ to: string;
51
+ };
52
+ export type BundleResult = {
53
+ /**
54
+ * The name of the program: its bin, or the name of the package without its scope
55
+ */
56
+ name: string;
57
+ entry: string;
58
+ outfile: string;
59
+ size: number;
60
+ /**
61
+ * The native addons of the application and what they load, undefined without addon
62
+ */
63
+ native?: NativeTree;
64
+ /**
65
+ * The directory of the native tree next to the bundle
66
+ */
67
+ nativeDirectory?: string;
68
+ /**
69
+ * The resources of the application (their paths in the resources), and their directory next to the bundle
70
+ */
71
+ resources: string[];
72
+ resourcesDirectory?: string;
73
+ };
74
+ /**
75
+ * The name of the program of a package: its first bin, or the name of the package without its scope
76
+ * @param manifest
77
+ */
78
+ export declare function programName(manifest: Record<string, any>): string;
79
+ /**
80
+ * The files of the resources of an application, declared by the "lakutata.resources" of its package.json: their paths in
81
+ * the resources ("/" separated) and their sources
82
+ * @param project
83
+ * @param manifest
84
+ */
85
+ export declare function resourceFiles(project: string, manifest: Record<string, any>): {
86
+ declared: string[];
87
+ files: NativeFile[];
88
+ };
89
+ /**
90
+ * Bundle the compiled application and its dependencies into one CommonJS file, run by node and embedded in the executables.
91
+ * The sources are compiled by tsc first: the metadata of the decorators (the injections, the DTOs) are emitted by it.
92
+ * The native addons cannot be bundled: their packages (and what they load) are gathered into a native tree, which the
93
+ * require of the bundle loads them from
94
+ * @param options
95
+ */
96
+ export declare function bundleProject(options: BundleOptions): Promise<BundleResult>;