@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.
- package/LICENSE +23 -0
- package/README.md +27 -0
- package/dist/esm/index.js +13 -0
- package/dist/esm/lib/Archive.js +21 -0
- package/dist/esm/lib/Bundle.js +162 -0
- package/dist/esm/lib/Executable.js +221 -0
- package/dist/esm/lib/Icons.js +372 -0
- package/dist/esm/lib/Installer.js +158 -0
- package/dist/esm/lib/Native.js +533 -0
- package/dist/esm/lib/NativeRuntime.js +177 -0
- package/dist/esm/lib/Platform.js +9 -0
- package/dist/esm/lib/Processes.js +42 -0
- package/dist/esm/lib/Scripts.js +161 -0
- package/dist/esm/lib/Tools.js +109 -0
- package/dist/esm/lib/electron/Electron.js +366 -0
- package/dist/esm/lib/linux/Linux.js +361 -0
- package/dist/esm/lib/mac/Mac.js +270 -0
- package/dist/esm/lib/win/Windows.js +525 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/lib/Archive.d.ts +14 -0
- package/dist/types/lib/Bundle.d.ts +96 -0
- package/dist/types/lib/Executable.d.ts +50 -0
- package/dist/types/lib/Icons.d.ts +70 -0
- package/dist/types/lib/Installer.d.ts +242 -0
- package/dist/types/lib/Native.d.ts +196 -0
- package/dist/types/lib/NativeRuntime.d.ts +75 -0
- package/dist/types/lib/Platform.d.ts +4 -0
- package/dist/types/lib/Processes.d.ts +21 -0
- package/dist/types/lib/Scripts.d.ts +77 -0
- package/dist/types/lib/Tools.d.ts +32 -0
- package/dist/types/lib/electron/Electron.d.ts +13 -0
- package/dist/types/lib/linux/Linux.d.ts +29 -0
- package/dist/types/lib/mac/Mac.d.ts +19 -0
- package/dist/types/lib/win/Windows.d.ts +20 -0
- package/package.json +51 -0
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|