@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,361 @@
1
+ import { copyFileSync, lstatSync, mkdirSync, readdirSync, readlinkSync } from 'node:fs';
2
+ import { stat, mkdir, writeFile, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { archiveDirectory } from '../Archive.js';
5
+ import { runCommand } from '../Processes.js';
6
+ import { ensureTool } from '../Tools.js';
7
+ import { embeddedShell, INSTALLER_HOOKS, NATIVE_HOOKS } from '../Scripts.js';
8
+ /**
9
+ * The packages of Linux: the ones of nFPM, and the archive of the directory of the executable
10
+ */
11
+ export const LINUX_TARGETS = ['deb', 'rpm', 'apk', 'archlinux', 'tar.gz'];
12
+ /**
13
+ * The libraries of the system the executables load (Node.js and Bun are linked with the C++ library), by package
14
+ */
15
+ const RUNTIME_DEPENDS = {
16
+ deb: ['libstdc++6', 'libgcc-s1'],
17
+ rpm: ['libstdc++', 'libgcc'],
18
+ apk: ['libstdc++', 'libgcc'],
19
+ archlinux: ['gcc-libs']
20
+ };
21
+ /**
22
+ * The architecture of the packages (the one of Go, which nFPM translates for each package)
23
+ */
24
+ function packageArch() {
25
+ return { x64: 'amd64', arm64: 'arm64', ia32: '386', arm: 'arm7' }[process.arch] ?? process.arch;
26
+ }
27
+ /**
28
+ * The characters of the globs, which nFPM reads in the sources of the contents (the chunks of Turbopack are named
29
+ * [root-of-the-server]__…)
30
+ */
31
+ const GLOB_CHARACTERS = /[*?[\]{}\\]/;
32
+ /**
33
+ * The contents of a package installing a directory: its files, and its links as links (the links of the native tree,
34
+ * which may point to their parents: nFPM does not keep them in the trees it reads). The files named with the characters
35
+ * of the globs are given by copies named without them: nFPM reads its sources as globs, and installs the files their
36
+ * globs match into their destination as into a directory
37
+ * @param directory
38
+ * @param prefix where it is installed
39
+ * @param work where the copies are written
40
+ */
41
+ function packageContents(directory, prefix, work) {
42
+ const contents = [];
43
+ let copies = 0;
44
+ const source = (file) => {
45
+ if (!GLOB_CHARACTERS.test(file))
46
+ return file;
47
+ const copy = path.join(work, 'literal', String(copies++));
48
+ mkdirSync(path.dirname(copy), { recursive: true });
49
+ copyFileSync(file, copy);
50
+ return copy;
51
+ };
52
+ const walk = (current, at) => {
53
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
54
+ const file = path.join(current, entry.name);
55
+ const target = `${at}/${entry.name}`;
56
+ //The link to the installed path (nFPM makes directories of the links to their parents)
57
+ if (entry.isSymbolicLink())
58
+ contents.push({ src: path.posix.resolve(at, readlinkSync(file).split(path.sep).join('/')), dst: target, type: 'symlink' });
59
+ else if (entry.isDirectory()) {
60
+ //Owned by the package (rpm removes them with it)
61
+ contents.push({ dst: target, type: 'dir', file_info: { mode: lstatSync(file).mode & 0o777 } });
62
+ walk(file, target);
63
+ }
64
+ else
65
+ contents.push({ src: source(file), dst: target, file_info: { mode: lstatSync(file).mode & 0o777 } });
66
+ }
67
+ };
68
+ contents.push({ dst: prefix, type: 'dir', file_info: { mode: 0o755 } });
69
+ walk(directory, prefix);
70
+ return contents;
71
+ }
72
+ /**
73
+ * The file of a package, named as its system names them
74
+ * @param plan
75
+ * @param target
76
+ */
77
+ function packageFile(plan, target) {
78
+ const arch = { x64: 'x86_64', arm64: 'aarch64' }[process.arch] ?? process.arch;
79
+ switch (target) {
80
+ case 'deb':
81
+ return `${plan.name}_${plan.version}_${packageArch()}.deb`;
82
+ case 'rpm':
83
+ return `${plan.name}-${plan.version}-1.${arch}.rpm`;
84
+ case 'apk':
85
+ return `${plan.name}-${plan.version}-r0.${arch}.apk`;
86
+ case 'archlinux':
87
+ return `${plan.name}-${plan.version}-1-${arch}.pkg.tar.zst`;
88
+ }
89
+ }
90
+ /**
91
+ * A value in double quotes, as systemd and sh read them
92
+ * @param value
93
+ */
94
+ function doubleQuoted(value) {
95
+ return `"${value.replace(/[\\"$`]/g, '\\$&')}"`;
96
+ }
97
+ /**
98
+ * A value in single quotes, as sh reads them
99
+ * @param value
100
+ */
101
+ function singleQuoted(value) {
102
+ return `'${value.replace(/'/g, `'\\''`)}'`;
103
+ }
104
+ /**
105
+ * The environment file of the service: its variables, which the administrators change (kept by the upgrades)
106
+ * @param service
107
+ */
108
+ function environmentFile(service) {
109
+ return `# The environment of the service ${service.name}, read when it starts (systemctl restart ${service.name})\n${Object.entries(service.env).map(([name, value]) => `${name}=${doubleQuoted(value)}`).join('\n')}\n`;
110
+ }
111
+ /**
112
+ * The unit of systemd of the service
113
+ * @param plan
114
+ * @param service
115
+ */
116
+ function systemdUnit(plan, service) {
117
+ const root = service.user === 'root';
118
+ return [
119
+ '[Unit]',
120
+ `Description=${service.description}`,
121
+ 'After=network-online.target',
122
+ 'Wants=network-online.target',
123
+ '',
124
+ '[Service]',
125
+ 'Type=simple',
126
+ `ExecStart=${[`${plan.prefix}/${plan.program}`, ...service.args].map(doubleQuoted).join(' ')}`,
127
+ ...root ? [] : [`User=${service.user}`, `Group=${service.user}`],
128
+ `EnvironmentFile=-/etc/${plan.name}/${plan.name}.env`,
129
+ //Its data: /var/lib/<name>, owned by its user
130
+ `StateDirectory=${plan.name}`,
131
+ `WorkingDirectory=/var/lib/${plan.name}`,
132
+ `Restart=${service.restart}`,
133
+ 'RestartSec=5',
134
+ //The application ends gracefully on SIGTERM
135
+ 'KillSignal=SIGTERM',
136
+ 'TimeoutStopSec=30',
137
+ '',
138
+ '[Install]',
139
+ 'WantedBy=multi-user.target',
140
+ ''
141
+ ].join('\n');
142
+ }
143
+ /**
144
+ * The script of OpenRC of the service (Alpine)
145
+ * @param plan
146
+ * @param service
147
+ */
148
+ function openRCScript(plan, service) {
149
+ const root = service.user === 'root';
150
+ const environment = `/etc/${plan.name}/${plan.name}.env`;
151
+ return [
152
+ '#!/sbin/openrc-run',
153
+ `description=${doubleQuoted(service.description)}`,
154
+ `command=${doubleQuoted(`${plan.prefix}/${plan.program}`)}`,
155
+ `command_args=${doubleQuoted(service.args.map(singleQuoted).join(' '))}`,
156
+ ...root ? [] : [`command_user="${service.user}:${service.user}"`],
157
+ `directory="/var/lib/${plan.name}"`,
158
+ ...service.restart === 'no' ? ['command_background="yes"', 'pidfile="/run/${RC_SVCNAME}.pid"'] : ['supervisor="supervise-daemon"', 'respawn_delay=5'],
159
+ `output_log="/var/log/${plan.name}.log"`,
160
+ `error_log="/var/log/${plan.name}.log"`,
161
+ '',
162
+ 'depend() {',
163
+ ' need net',
164
+ '}',
165
+ '',
166
+ 'start_pre() {',
167
+ ` checkpath --directory${root ? '' : ` --owner ${service.user}:${service.user}`} --mode 0750 /var/lib/${plan.name}`,
168
+ ` checkpath --file${root ? '' : ` --owner ${service.user}:${service.user}`} --mode 0640 /var/log/${plan.name}.log`,
169
+ ` if [ -f ${environment} ]; then set -a; . ${environment}; set +a; fi`,
170
+ '}',
171
+ ''
172
+ ].join('\n');
173
+ }
174
+ /**
175
+ * The parts of the scripts of a package managing the service: its user created before the installation; the service
176
+ * enabled and started after it, restarted by the upgrades, stopped and disabled before the removal. The ones run before
177
+ * the scripts of the application (its user, the service stopped), and the ones run after them (the service started)
178
+ * @param plan
179
+ * @param target
180
+ */
181
+ function serviceScripts(plan, target) {
182
+ const service = plan.service;
183
+ const before = {};
184
+ const after = {};
185
+ if (!service)
186
+ return { before: before, after: after };
187
+ const unit = `${service.name}.service`;
188
+ if (service.user !== 'root') {
189
+ before.preinstall = [
190
+ `if ! id -u ${service.user} >/dev/null 2>&1; then`,
191
+ ' if command -v useradd >/dev/null 2>&1; then',
192
+ ` useradd --system --user-group --home-dir /var/lib/${plan.name} --no-create-home --shell "$(command -v nologin || echo /bin/false)" ${service.user}`,
193
+ ' else',
194
+ ` addgroup -S ${service.user} 2>/dev/null || true`,
195
+ ` adduser -S -D -H -h /var/lib/${plan.name} -s /sbin/nologin -G ${service.user} ${service.user}`,
196
+ ' fi',
197
+ 'fi'
198
+ ];
199
+ }
200
+ if (target === 'apk') {
201
+ after.postinstall = [
202
+ 'if command -v rc-update >/dev/null 2>&1; then',
203
+ ...service.autoStart ? [` rc-update add ${service.name} default`, ` [ -d /run/openrc ] && rc-service ${service.name} start || true`] : [' true'],
204
+ 'fi'
205
+ ];
206
+ after.postupgrade = [`[ -d /run/openrc ] && rc-service --ifstarted ${service.name} restart || true`];
207
+ before.preremove = [
208
+ 'if command -v rc-update >/dev/null 2>&1; then',
209
+ ` [ -d /run/openrc ] && rc-service --ifstarted ${service.name} stop || true`,
210
+ ` rc-update del ${service.name} default 2>/dev/null || true`,
211
+ 'fi'
212
+ ];
213
+ return { before: before, after: after };
214
+ }
215
+ //systemd: the upgrades restart the service (deb: postinst configure, rpm: %post 2, pacman: post_upgrade)
216
+ after.postinstall = [
217
+ 'if [ -d /run/systemd/system ]; then',
218
+ ' systemctl daemon-reload',
219
+ ...service.autoStart ? [` systemctl enable ${unit}`, ` systemctl restart ${unit}`] : [` systemctl try-restart ${unit}`],
220
+ 'fi'
221
+ ];
222
+ //Not before an upgrade (deb: prerm upgrade, rpm: %preun 1)
223
+ before.preremove = [
224
+ 'case "$1" in',
225
+ ' upgrade|failed-upgrade|1) ;;',
226
+ ` *) if [ -d /run/systemd/system ]; then systemctl disable --now ${unit} || true; fi ;;`,
227
+ 'esac'
228
+ ];
229
+ before.postremove = ['if [ -d /run/systemd/system ]; then', ' systemctl daemon-reload || true', 'fi'];
230
+ if (target === 'archlinux')
231
+ after.postupgrade = after.postinstall;
232
+ return { before: before, after: after };
233
+ }
234
+ /**
235
+ * What a script of a package does, as the scripts of the application are given it (lakutata_action: install, upgrade,
236
+ * remove, or none), from the arguments of its package manager: the upgrades of deb and rpm run their scripts of the
237
+ * installation (deb: preinst upgrade, postinst configure <version replaced>; rpm: $1 > 1) and their scripts of the
238
+ * removal of the version replaced (which run none); the ones of apk and pacman run their own scripts
239
+ * @param target
240
+ * @param hook
241
+ */
242
+ export function scriptAction(target, hook) {
243
+ switch (target) {
244
+ case 'deb':
245
+ return {
246
+ preinstall: 'case "$1" in install) lakutata_action=install ;; upgrade) lakutata_action=upgrade ;; *) lakutata_action= ;; esac',
247
+ postinstall: 'if [ "$1" = configure ]; then if [ -n "$2" ]; then lakutata_action=upgrade; else lakutata_action=install; fi; else lakutata_action=; fi',
248
+ preremove: 'if [ "$1" = remove ]; then lakutata_action=remove; else lakutata_action=; fi',
249
+ postremove: 'if [ "$1" = remove ]; then lakutata_action=remove; else lakutata_action=; fi'
250
+ }[hook];
251
+ case 'rpm':
252
+ return hook === 'preinstall' || hook === 'postinstall'
253
+ ? 'if [ "$1" -gt 1 ] 2>/dev/null; then lakutata_action=upgrade; else lakutata_action=install; fi'
254
+ : 'if [ "$1" = 0 ]; then lakutata_action=remove; else lakutata_action=; fi';
255
+ default:
256
+ return `lakutata_action=${hook.endsWith('upgrade') ? 'upgrade' : hook.endsWith('install') ? 'install' : 'remove'}`;
257
+ }
258
+ }
259
+ /**
260
+ * The scripts of a package: the parts managing the service, the scripts of the application (run before the service is
261
+ * started, after it is stopped), and its native ones, given the arguments of the package manager
262
+ * @param plan
263
+ * @param target
264
+ */
265
+ function packageScripts(plan, target) {
266
+ const service = serviceScripts(plan, target);
267
+ const user = plan.scripts[target] ?? { hooks: {}, native: {} };
268
+ const hooks = target === 'apk' || target === 'archlinux' ? NATIVE_HOOKS : INSTALLER_HOOKS;
269
+ const scripts = {};
270
+ for (const hook of hooks) {
271
+ //The script of the application of the moment: the upgrades of apk and pacman run the ones of the installation
272
+ const application = user.hooks[(hook === 'preupgrade' ? 'preinstall' : hook === 'postupgrade' ? 'postinstall' : hook)];
273
+ const native = user.native[hook];
274
+ const lines = [
275
+ ...service.before[hook] ?? [],
276
+ //Not indented: the lines of the script are the ones of its file
277
+ ...application ? [scriptAction(target, hook), 'if [ -n "$lakutata_action" ]; then', ...embeddedShell(application, plan.prefix, '"$lakutata_action"'), 'fi'] : [],
278
+ ...native ? embeddedShell(native, plan.prefix, '"$@"') : [],
279
+ ...service.after[hook] ?? []
280
+ ];
281
+ if (lines.length)
282
+ scripts[hook] = ['#!/bin/sh', 'set -e', ...lines, ''].join('\n');
283
+ }
284
+ return scripts;
285
+ }
286
+ /**
287
+ * Make the packages of Linux of an application: nFPM makes the deb, rpm, apk and archlinux packages, the tar.gz archive
288
+ * is the directory of the executable
289
+ * @param plan
290
+ * @param targets
291
+ * @param work
292
+ * @param outdir
293
+ */
294
+ export async function buildLinuxPackages(plan, targets, work, outdir) {
295
+ await mkdir(work, { recursive: true });
296
+ const files = [];
297
+ const service = plan.service;
298
+ for (const target of targets) {
299
+ if (target === 'tar.gz') {
300
+ files.push(await archiveDirectory(plan, packageArch(), work, outdir));
301
+ continue;
302
+ }
303
+ const directory = path.join(work, target);
304
+ await mkdir(directory, { recursive: true });
305
+ const contents = packageContents(plan.directory, plan.prefix, directory);
306
+ if (plan.addToPath)
307
+ contents.push({ src: `${plan.prefix}/${plan.program}`, dst: `/usr/bin/${plan.program}`, type: 'symlink' });
308
+ if (service) {
309
+ await writeFile(path.join(directory, 'environment'), environmentFile(service));
310
+ contents.push({ src: path.join(directory, 'environment'), dst: `/etc/${plan.name}/${plan.name}.env`, type: 'config|noreplace', file_info: { mode: 0o640 } });
311
+ if (target === 'apk') {
312
+ await writeFile(path.join(directory, 'openrc'), openRCScript(plan, service));
313
+ contents.push({ src: path.join(directory, 'openrc'), dst: `/etc/init.d/${service.name}`, file_info: { mode: 0o755 } });
314
+ }
315
+ else {
316
+ await writeFile(path.join(directory, 'unit'), systemdUnit(plan, service));
317
+ contents.push({ src: path.join(directory, 'unit'), dst: `/usr/lib/systemd/system/${service.name}.service`, file_info: { mode: 0o644 } });
318
+ }
319
+ }
320
+ const scripts = packageScripts(plan, target);
321
+ const common = {};
322
+ const specific = {};
323
+ for (const [name, content] of Object.entries(scripts)) {
324
+ const file = path.join(directory, name);
325
+ await writeFile(file, content, { mode: 0o755 });
326
+ //The upgrades of apk and pacman are their own scripts
327
+ if (name === 'preupgrade' || name === 'postupgrade')
328
+ specific[name] = file;
329
+ else
330
+ common[name] = file;
331
+ }
332
+ const config = {
333
+ name: plan.name,
334
+ arch: packageArch(),
335
+ platform: 'linux',
336
+ version: plan.version,
337
+ maintainer: plan.maintainer,
338
+ description: plan.description,
339
+ ...plan.vendor ? { vendor: plan.vendor } : {},
340
+ ...plan.homepage ? { homepage: plan.homepage } : {},
341
+ ...plan.license ? { license: plan.license } : {},
342
+ ...plan.section ? { section: plan.section } : {},
343
+ ...plan.priority ? { priority: plan.priority } : {},
344
+ depends: [...new Set([...RUNTIME_DEPENDS[target], ...plan.depends])],
345
+ ...plan.recommends.length ? { recommends: plan.recommends } : {},
346
+ contents: contents,
347
+ scripts: common,
348
+ ...Object.keys(specific).length ? { [target]: { scripts: specific } } : {}
349
+ };
350
+ //JSON is YAML
351
+ await writeFile(path.join(directory, 'nfpm.yaml'), JSON.stringify(config, null, 2));
352
+ const nfpm = await ensureTool('nfpm');
353
+ const file = path.join(outdir, packageFile(plan, target));
354
+ await rm(file, { force: true });
355
+ const packaged = await runCommand(nfpm, ['package', '--config', path.join(directory, 'nfpm.yaml'), '--packager', target, '--target', file], directory);
356
+ if (packaged.code !== 0)
357
+ throw new Error(`The ${target} package cannot be made: ${packaged.output.trim()}`);
358
+ files.push({ target: target, file: file, size: (await stat(file)).size });
359
+ }
360
+ return files;
361
+ }
@@ -0,0 +1,270 @@
1
+ import { chmod, cp, mkdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { archiveDirectory } from '../Archive.js';
4
+ import { runCommand } from '../Processes.js';
5
+ import { embeddedShell } from '../Scripts.js';
6
+ /**
7
+ * The installers of macOS: the package of its installer, and the archive of the directory of the executable
8
+ */
9
+ export const MAC_TARGETS = ['pkg', 'tar.gz'];
10
+ /**
11
+ * The architecture of the packages
12
+ */
13
+ function packageArch() {
14
+ return process.arch;
15
+ }
16
+ /**
17
+ * A text of a property list
18
+ * @param value
19
+ */
20
+ function xml(value) {
21
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
22
+ }
23
+ /**
24
+ * A value in double quotes, as sh reads them
25
+ * @param value
26
+ */
27
+ function doubleQuoted(value) {
28
+ return `"${value.replace(/[\\"$`]/g, '\\$&')}"`;
29
+ }
30
+ /**
31
+ * The paths of an application installed on macOS (the ones of Homebrew: /usr/local)
32
+ * @param plan
33
+ */
34
+ function installPaths(plan) {
35
+ return {
36
+ environment: `/usr/local/etc/${plan.name}/${plan.name}.env`,
37
+ data: `/usr/local/var/lib/${plan.name}`,
38
+ log: `/usr/local/var/log/${plan.name}.log`,
39
+ daemon: `/Library/LaunchDaemons/${plan.appId}.plist`
40
+ };
41
+ }
42
+ /**
43
+ * The job of launchd of the service: the application run by sh, which reads its environment file first (launchd has
44
+ * none), as its user, in its directory
45
+ * @param plan
46
+ * @param service
47
+ */
48
+ function launchDaemon(plan, service) {
49
+ const paths = installPaths(plan);
50
+ const keepAlive = service.restart === 'always' ? '<true/>' : service.restart === 'no' ? '<false/>' : '<dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>';
51
+ const args = ['/bin/sh', '-c', `set -a; [ -f ${paths.environment} ] && . ${paths.environment}; set +a; exec "$0" "$@"`, `${plan.prefix}/${plan.program}`, ...service.args];
52
+ return `<?xml version="1.0" encoding="UTF-8"?>
53
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
54
+ <plist version="1.0">
55
+ <dict>
56
+ <key>Label</key>
57
+ <string>${xml(plan.appId)}</string>
58
+ <key>ProgramArguments</key>
59
+ <array>
60
+ ${args.map((arg) => ` <string>${xml(arg)}</string>`).join('\n')}
61
+ </array>
62
+ ${service.user === 'root' ? '' : ` <key>UserName</key>
63
+ <string>${xml(service.user)}</string>
64
+ <key>GroupName</key>
65
+ <string>${xml(service.user)}</string>
66
+ `} <key>WorkingDirectory</key>
67
+ <string>${xml(paths.data)}</string>
68
+ <key>RunAtLoad</key>
69
+ ${service.autoStart ? '<true/>' : '<false/>'}
70
+ <key>KeepAlive</key>
71
+ ${keepAlive}
72
+ <key>ThrottleInterval</key>
73
+ <integer>5</integer>
74
+ <key>ExitTimeOut</key>
75
+ <integer>30</integer>
76
+ <key>StandardOutPath</key>
77
+ <string>${xml(paths.log)}</string>
78
+ <key>StandardErrorPath</key>
79
+ <string>${xml(paths.log)}</string>
80
+ </dict>
81
+ </plist>
82
+ `;
83
+ }
84
+ /**
85
+ * The scripts of the application the package runs
86
+ * @param plan
87
+ */
88
+ function installerHooks(plan) {
89
+ return plan.scripts.pkg?.hooks ?? {};
90
+ }
91
+ /**
92
+ * The file the preinstall script writes what the installation does to (install or upgrade), read by the postinstall
93
+ * script: in the temporary directory of the installation (INSTALLER_TEMP), given to both
94
+ */
95
+ const ACTION_FILE = '"$INSTALLER_TEMP/lakutata-action"';
96
+ /**
97
+ * The script run before the installation (and the upgrades), when the application has scripts: what the installation
98
+ * does (an upgrade replaces the program installed), the preinstall script of the application
99
+ * @param plan
100
+ */
101
+ function preInstall(plan) {
102
+ const hooks = installerHooks(plan);
103
+ return [
104
+ '#!/bin/sh',
105
+ 'set -e',
106
+ `if [ -e ${doubleQuoted(`${plan.prefix}/${plan.program}`)} ]; then lakutata_action=upgrade; else lakutata_action=install; fi`,
107
+ `if [ -n "$INSTALLER_TEMP" ]; then echo "$lakutata_action" > ${ACTION_FILE}; fi`,
108
+ ...hooks.preinstall ? embeddedShell(hooks.preinstall, plan.prefix, '"$lakutata_action"') : [],
109
+ ''
110
+ ].join('\n');
111
+ }
112
+ /**
113
+ * The script run after the installation (and the upgrades): the user of the service created (hidden, its identifier a
114
+ * free one of the system users), its directories, its environment file written once (the administrators change it),
115
+ * the postinstall script of the application, the service loaded again
116
+ * @param plan
117
+ */
118
+ function postInstall(plan) {
119
+ const service = plan.service;
120
+ const hooks = installerHooks(plan);
121
+ return [
122
+ '#!/bin/sh',
123
+ 'set -e',
124
+ ...service ? serviceInstallation(plan, service) : [],
125
+ ...hooks.postinstall ? [
126
+ `lakutata_action="$(cat ${ACTION_FILE} 2>/dev/null || echo install)"`,
127
+ ...embeddedShell(hooks.postinstall, plan.prefix, '"$lakutata_action"')
128
+ ] : [],
129
+ ...service ? serviceStart(plan) : [],
130
+ ''
131
+ ].join('\n');
132
+ }
133
+ /**
134
+ * The installation of the service: its hidden user, its directories, its environment file
135
+ * @param plan
136
+ * @param service
137
+ */
138
+ function serviceInstallation(plan, service) {
139
+ const paths = installPaths(plan);
140
+ const user = service.user;
141
+ const root = user === 'root';
142
+ return [
143
+ ...root ? [] : [
144
+ `if ! dscl . -read /Users/${user} >/dev/null 2>&1; then`,
145
+ ' taken="$(dscl . -list /Users UniqueID | awk \'{print $2}\') $(dscl . -list /Groups PrimaryGroupID | awk \'{print $2}\')"',
146
+ ' id=300',
147
+ ' while echo " $taken " | tr \'\\n\' \' \' | grep -q " $id "; do id=$((id + 1)); done',
148
+ ` dscl . -create /Groups/${user}`,
149
+ ` dscl . -create /Groups/${user} PrimaryGroupID "$id"`,
150
+ ` dscl . -create /Groups/${user} Password "*"`,
151
+ ` dscl . -create /Users/${user}`,
152
+ ` dscl . -create /Users/${user} UniqueID "$id"`,
153
+ ` dscl . -create /Users/${user} PrimaryGroupID "$id"`,
154
+ ` dscl . -create /Users/${user} UserShell /usr/bin/false`,
155
+ ` dscl . -create /Users/${user} NFSHomeDirectory /var/empty`,
156
+ ` dscl . -create /Users/${user} RealName ${doubleQuoted(service.description)}`,
157
+ ` dscl . -create /Users/${user} Password "*"`,
158
+ ` dscl . -create /Users/${user} IsHidden 1`,
159
+ 'fi'
160
+ ],
161
+ `mkdir -p ${paths.data} ${path.posix.dirname(paths.log)} ${path.posix.dirname(paths.environment)}`,
162
+ `touch ${paths.log}`,
163
+ ...root ? [] : [`chown ${user}:${user} ${paths.data} ${paths.log}`],
164
+ `chmod 750 ${paths.data}`,
165
+ //Kept by the upgrades
166
+ `if [ ! -f ${paths.environment} ]; then`,
167
+ ` cat > ${paths.environment} <<'LAKUTATA_ENVIRONMENT'`,
168
+ `# The environment of the service ${plan.appId}, read when it starts (sudo launchctl kickstart -k system/${plan.appId})`,
169
+ ...Object.entries(service.env).map(([name, value]) => `${name}=${doubleQuoted(value)}`),
170
+ 'LAKUTATA_ENVIRONMENT',
171
+ 'fi',
172
+ //Read by the user of the service
173
+ `chown root:${root ? 'wheel' : user} ${paths.environment}`,
174
+ `chmod 640 ${paths.environment}`
175
+ ];
176
+ }
177
+ /**
178
+ * The service loaded again: started (RunAtLoad), or stopped by the upgrade
179
+ * @param plan
180
+ */
181
+ function serviceStart(plan) {
182
+ const paths = installPaths(plan);
183
+ return [
184
+ //Loaded again: started (RunAtLoad), or stopped by the upgrade; bootout returns before the job of the version
185
+ //replaced is unloaded (the application ending gracefully, ExitTimeOut): bootstrap fails until it is
186
+ `launchctl bootout system/${plan.appId} 2>/dev/null || true`,
187
+ 'waited=0',
188
+ `while launchctl print system/${plan.appId} >/dev/null 2>&1 && [ "$waited" -lt 70 ]; do sleep 0.5; waited=$((waited + 1)); done`,
189
+ `launchctl bootstrap system ${paths.daemon}`
190
+ ];
191
+ }
192
+ /**
193
+ * The script removing the application (the packages of macOS are not removed by the system): its service stopped, the
194
+ * preremove script of the application, its files removed, the postremove script of the application; its data, its
195
+ * environment and its user kept
196
+ * @param plan
197
+ */
198
+ function uninstall(plan) {
199
+ const paths = installPaths(plan);
200
+ const hooks = installerHooks(plan);
201
+ return [
202
+ '#!/bin/sh',
203
+ 'set -e',
204
+ `[ "$(id -u)" = 0 ] || { echo "Remove ${plan.name} as root: sudo $0"; exit 1; }`,
205
+ ...plan.service ? [`launchctl bootout system/${plan.appId} 2>/dev/null || true`, `rm -f ${paths.daemon}`] : [],
206
+ ...hooks.preremove ? embeddedShell(hooks.preremove, plan.prefix, 'remove') : [],
207
+ ...plan.addToPath ? [`rm -f /usr/local/bin/${plan.program}`] : [],
208
+ `rm -rf ${plan.prefix}`,
209
+ `pkgutil --forget ${plan.appId} >/dev/null 2>&1 || true`,
210
+ ...hooks.postremove ? embeddedShell(hooks.postremove, plan.prefix, 'remove') : [],
211
+ `echo "${plan.name} removed${plan.service ? `, its data (${paths.data}), its environment (${paths.environment}) and its user (${plan.service.user}) kept` : ''}"`,
212
+ ''
213
+ ].join('\n');
214
+ }
215
+ /**
216
+ * Make the installers of macOS of an application: the package installed by its installer (pkgbuild, productbuild), the
217
+ * tar.gz archive of the directory of the executable
218
+ * @param plan
219
+ * @param targets
220
+ * @param work
221
+ * @param outdir
222
+ */
223
+ export async function buildMacPackages(plan, targets, work, outdir) {
224
+ await mkdir(work, { recursive: true });
225
+ const files = [];
226
+ for (const target of targets) {
227
+ if (target === 'tar.gz') {
228
+ files.push(await archiveDirectory(plan, packageArch(), work, outdir));
229
+ continue;
230
+ }
231
+ //The files installed, from the root of the system
232
+ const root = path.join(work, 'root');
233
+ const scripts = path.join(work, 'scripts');
234
+ await rm(root, { recursive: true, force: true });
235
+ await rm(scripts, { recursive: true, force: true });
236
+ const prefix = path.join(root, ...plan.prefix.split('/'));
237
+ await mkdir(path.dirname(prefix), { recursive: true });
238
+ await cp(plan.directory, prefix, { recursive: true, verbatimSymlinks: true });
239
+ await writeFile(path.join(prefix, 'uninstall'), uninstall(plan), { mode: 0o755 });
240
+ if (plan.addToPath) {
241
+ await mkdir(path.join(root, 'usr', 'local', 'bin'), { recursive: true });
242
+ await symlink(path.posix.relative('/usr/local/bin', `${plan.prefix}/${plan.program}`), path.join(root, 'usr', 'local', 'bin', plan.program));
243
+ }
244
+ await mkdir(scripts, { recursive: true });
245
+ if (plan.service) {
246
+ await mkdir(path.join(root, 'Library', 'LaunchDaemons'), { recursive: true });
247
+ await writeFile(path.join(root, 'Library', 'LaunchDaemons', `${plan.appId}.plist`), launchDaemon(plan, plan.service), { mode: 0o644 });
248
+ }
249
+ const hooks = installerHooks(plan);
250
+ if (hooks.preinstall || hooks.postinstall) {
251
+ await writeFile(path.join(scripts, 'preinstall'), preInstall(plan));
252
+ await chmod(path.join(scripts, 'preinstall'), 0o755);
253
+ }
254
+ if (plan.service || hooks.postinstall) {
255
+ await writeFile(path.join(scripts, 'postinstall'), postInstall(plan));
256
+ await chmod(path.join(scripts, 'postinstall'), 0o755);
257
+ }
258
+ const component = path.join(work, `${plan.name}-component.pkg`);
259
+ const built = await runCommand('pkgbuild', ['--root', root, '--identifier', plan.appId, '--version', plan.version, '--install-location', '/', '--scripts', scripts, '--ownership', 'recommended', component], work);
260
+ if (built.code !== 0)
261
+ throw new Error(`The pkg package cannot be made: ${built.output.trim()}`);
262
+ const file = path.join(outdir, `${plan.name}-${plan.version}-${packageArch()}.pkg`);
263
+ await rm(file, { force: true });
264
+ const product = await runCommand('productbuild', ['--identifier', plan.appId, '--version', plan.version, '--package', component, file], work);
265
+ if (product.code !== 0)
266
+ throw new Error(`The pkg package cannot be made: ${product.output.trim()}`);
267
+ files.push({ target: target, file: file, size: (await stat(file)).size });
268
+ }
269
+ return files;
270
+ }