@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,177 @@
1
+ /**
2
+ * The global symbol of the packed applications giving the directory of their resources (resourcesPath of lakutata)
3
+ */
4
+ export const PACKAGED = 'lakutata.packaged';
5
+ /**
6
+ * The directory of the resources of the embedded trees
7
+ */
8
+ export const EMBEDDED_RESOURCES = '.lakutata/resources';
9
+ /**
10
+ * The asset of the executables holding the files of the native tree (gzipped), and the one of its index
11
+ */
12
+ export const NATIVE_DATA_ASSET = 'lakutata-native';
13
+ export const NATIVE_INDEX_ASSET = 'lakutata-native.json';
14
+ /**
15
+ * The global symbol of the executables of Bun giving the files of the native tree they embed: {data, index}, their paths
16
+ * in the executable
17
+ */
18
+ export const BUN_NATIVE_ASSETS = 'lakutata.native';
19
+ /**
20
+ * The require of a bundle having native addons, which loads the native packages from the native tree: next to the bundle,
21
+ * or, in an executable, extracted from its assets once into the cache directory of the program (a directory named after
22
+ * the hash of the tree: the versions of the program do not share it). The assets are the ones of a single executable
23
+ * application of Node.js, or the files embedded in an executable of Bun. The other modules are loaded by the require of node.
24
+ * Its source is written at the top of the bundle: it uses nothing but its arguments and the modules of node
25
+ * @param nodeRequire
26
+ * @param config
27
+ */
28
+ export function lakutataNativeRequire(nodeRequire, config) {
29
+ const path = nodeRequire('node:path');
30
+ const fs = nodeRequire('node:fs');
31
+ const os = nodeRequire('node:os');
32
+ const { createRequire } = nodeRequire('node:module');
33
+ let sea;
34
+ try {
35
+ sea = nodeRequire('node:sea');
36
+ }
37
+ catch {
38
+ sea = undefined;
39
+ }
40
+ //The files of the tree embedded in an executable of Bun (BUN_NATIVE_ASSETS)
41
+ const bun = globalThis[Symbol.for('lakutata.native')];
42
+ const asset = (name) => bun ? fs.readFileSync(bun[name]) : Buffer.from(sea.getAsset(name === 'data' ? 'lakutata-native' : 'lakutata-native.json'));
43
+ const modules = new Set(config.modules);
44
+ let root;
45
+ let treeRequire;
46
+ //The cache directory of the program: LAKUTATA_NATIVE_DIR, else the one of the platform
47
+ const cacheDirectory = () => {
48
+ if (process.env.LAKUTATA_NATIVE_DIR)
49
+ return process.env.LAKUTATA_NATIVE_DIR;
50
+ const home = os.homedir();
51
+ if (process.platform === 'darwin')
52
+ return path.join(home, 'Library', 'Caches', config.name);
53
+ if (process.platform === 'win32')
54
+ return path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), config.name, 'Cache');
55
+ return path.join(process.env.XDG_CACHE_HOME || path.join(home, '.cache'), config.name);
56
+ };
57
+ //Extract the tree into a directory named after its hash: written aside, then renamed (the processes starting together
58
+ //do not see a tree partly written)
59
+ const extract = (base) => {
60
+ const directory = path.join(base, 'native', config.hash);
61
+ if (fs.existsSync(path.join(directory, '.complete')))
62
+ return directory;
63
+ const index = JSON.parse(asset('index').toString('utf8'));
64
+ const data = nodeRequire('node:zlib').gunzipSync(asset('data'));
65
+ const temporary = `${directory}.${process.pid}.${Date.now()}`;
66
+ const local = (root, file) => path.join(root, ...file.split('/'));
67
+ try {
68
+ for (const file of index.files) {
69
+ const target = local(temporary, file.path);
70
+ fs.mkdirSync(path.dirname(target), { recursive: true });
71
+ fs.writeFileSync(target, data.subarray(file.offset, file.offset + file.size), { mode: file.mode });
72
+ }
73
+ for (const link of index.links) {
74
+ const at = local(temporary, link.path);
75
+ fs.mkdirSync(path.dirname(at), { recursive: true });
76
+ //A junction of Windows points to its absolute path, the one of the tree once renamed
77
+ if (process.platform === 'win32')
78
+ fs.symlinkSync(local(directory, link.target), at, 'junction');
79
+ else
80
+ fs.symlinkSync(path.relative(path.dirname(at), local(temporary, link.target)), at, 'dir');
81
+ }
82
+ fs.writeFileSync(path.join(temporary, '.complete'), '');
83
+ try {
84
+ fs.renameSync(temporary, directory);
85
+ }
86
+ catch (error) {
87
+ //Another process extracted it
88
+ if (!fs.existsSync(path.join(directory, '.complete')))
89
+ throw error;
90
+ }
91
+ }
92
+ finally {
93
+ fs.rmSync(temporary, { recursive: true, force: true });
94
+ }
95
+ return directory;
96
+ };
97
+ const locate = () => {
98
+ if (root)
99
+ return root;
100
+ if (config.embedded) {
101
+ try {
102
+ root = extract(cacheDirectory());
103
+ }
104
+ catch (error) {
105
+ //The cache directory cannot be written: the temporary directory
106
+ if (process.env.LAKUTATA_NATIVE_DIR)
107
+ throw error;
108
+ root = extract(path.join(os.tmpdir(), config.name));
109
+ }
110
+ }
111
+ else {
112
+ //Next to the bundle, or in the resources of a packaged Electron application (outside of its archive)
113
+ const resources = process.resourcesPath;
114
+ const local = path.join(__dirname, config.directory);
115
+ root = fs.existsSync(local) || !resources ? local : path.join(resources, config.directory);
116
+ }
117
+ return root;
118
+ };
119
+ const fromTree = (id) => {
120
+ const parts = id.split('/');
121
+ return modules.has(id.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]);
122
+ };
123
+ const tree = () => treeRequire ??= createRequire(path.join(locate(), 'index.js'));
124
+ const require = ((id) => fromTree(id) ? tree()(id) : nodeRequire(id));
125
+ require.resolve = ((id, options) => fromTree(id) ? tree().resolve(id, options) : nodeRequire.resolve(id, options));
126
+ require.cache = nodeRequire.cache;
127
+ require.main = nodeRequire.main;
128
+ require.extensions = nodeRequire.extensions;
129
+ //The addons of the application itself
130
+ require.lakutataNative = (key) => {
131
+ const addon = { exports: {} };
132
+ process.dlopen(addon, path.join(locate(), ...key.split('/')));
133
+ return addon.exports;
134
+ };
135
+ //The resources of an executable are extracted with the tree (PACKAGED, EMBEDDED_RESOURCES)
136
+ if (config.embedded)
137
+ globalThis[Symbol.for('lakutata.packaged')] = { resources: () => path.join(locate(), '.lakutata', 'resources') };
138
+ return require;
139
+ }
140
+ /**
141
+ * Load the environment file of the application into its environment, before it starts: the file given by
142
+ * LAKUTATA_ENV_FILE (the services of Windows, whose wrapper has none), its lines NAME=value or NAME="value" (the
143
+ * quoted values with their \\ escapes, as the installers write them), its comments (#). Its source is written at the top
144
+ * of the bundle: it uses nothing but its argument and the modules of node
145
+ * @param nodeRequire
146
+ */
147
+ export function lakutataEnvironmentFile(nodeRequire) {
148
+ const file = process.env.LAKUTATA_ENV_FILE;
149
+ if (!file)
150
+ return;
151
+ const fs = nodeRequire('node:fs');
152
+ if (!fs.existsSync(file))
153
+ return;
154
+ for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
155
+ const matched = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
156
+ if (!matched || line.trimStart().startsWith('#'))
157
+ continue;
158
+ const raw = matched[2];
159
+ process.env[matched[1]] = raw.startsWith('"') && raw.endsWith('"') && raw.length > 1 ? raw.slice(1, -1).replace(/\\(.)/g, '$1') : raw;
160
+ }
161
+ }
162
+ /**
163
+ * The source given to the top of every bundle: it is packed (isPackaged of lakutata), its resources are in a directory
164
+ * next to it, or in the resources of a packaged Electron application (outside of its archive); its environment file is
165
+ * loaded (LAKUTATA_ENV_FILE)
166
+ * @param directory the directory of the resources, relative to the bundle
167
+ */
168
+ export function packagedBanner(directory) {
169
+ return `globalThis[Symbol.for(${JSON.stringify(PACKAGED)})]={resources:function(){var p=require('node:path'),l=p.join(__dirname,${JSON.stringify(directory)}),r=process.resourcesPath;return require('node:fs').existsSync(l)||!r?l:p.join(r,${JSON.stringify(directory)})}};(${lakutataEnvironmentFile.toString()})(require);\n`;
170
+ }
171
+ /**
172
+ * The source given to the top of a bundle: its require loads the native packages from the tree
173
+ * @param config
174
+ */
175
+ export function nativeBanner(config) {
176
+ return `var require = (${lakutataNativeRequire.toString()})(require, ${JSON.stringify(config)});\n`;
177
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The C library of Linux: the executables made on it run on the systems of the same library (undefined out of Linux)
3
+ */
4
+ export function detectLibc() {
5
+ if (process.platform !== 'linux')
6
+ return undefined;
7
+ const report = process.report?.getReport();
8
+ return report?.header?.glibcVersionRuntime ? 'glibc' : 'musl';
9
+ }
@@ -0,0 +1,42 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ /**
4
+ * An argument of a command run by cmd.exe (Windows): quoted as the programs read their arguments
5
+ * @param arg
6
+ */
7
+ function quoted(arg) {
8
+ if (/^[\w\-.:/\\=@,+]+$/.test(arg))
9
+ return arg;
10
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
11
+ }
12
+ /**
13
+ * Spawn a command: on Windows, the commands found in the PATH by their names (the package managers, the commands of npm:
14
+ * npm.cmd, bun.cmd) are scripts, run by cmd.exe with their arguments quoted; the executables given by their paths (the
15
+ * ones of spaces: C:\Program Files\nodejs\node.exe) are run as they are
16
+ * @param command
17
+ * @param args
18
+ * @param options
19
+ */
20
+ export function spawnCommand(command, args, options) {
21
+ if (process.platform === 'win32' && !path.isAbsolute(command))
22
+ return spawn([command, ...args].map(quoted).join(' '), { ...options, shell: true });
23
+ return spawn(command, args, options);
24
+ }
25
+ /**
26
+ * Run a command, its output is given back
27
+ * @param command
28
+ * @param args
29
+ * @param cwd
30
+ * @return the exit code and the output
31
+ */
32
+ export function runCommand(command, args, cwd) {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawnCommand(command, args, { cwd: cwd });
35
+ let output = '';
36
+ //Piped (the default stdio)
37
+ child.stdout.on('data', (chunk) => output += chunk.toString());
38
+ child.stderr.on('data', (chunk) => output += chunk.toString());
39
+ child.once('error', reject);
40
+ child.once('close', (code) => resolve({ code: code ?? 1, output: output }));
41
+ });
42
+ }
@@ -0,0 +1,161 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * The moments of the installers the scripts of the applications are run at, the same on every system: before and after
6
+ * the installation (and the upgrades), before and after the removal (not the one of an upgrade)
7
+ */
8
+ export const INSTALLER_HOOKS = ['preinstall', 'postinstall', 'preremove', 'postremove'];
9
+ /**
10
+ * The scripts of the packages of Linux, as nFPM runs them: the ones of each package, given its own arguments (the ones
11
+ * of dpkg, of rpm, of apk, of pacman); the upgrades of apk and pacman have their own ones
12
+ */
13
+ export const NATIVE_HOOKS = ['preinstall', 'postinstall', 'preremove', 'postremove', 'preupgrade', 'postupgrade'];
14
+ /**
15
+ * The installers which run no script (archives, copied by their users)
16
+ */
17
+ const ARCHIVES = ['tar.gz', 'zip'];
18
+ /**
19
+ * The moments the installers of lakutata run the scripts at: all of them, but the archives
20
+ * @param target
21
+ */
22
+ function lakutataHooks(target) {
23
+ return ARCHIVES.includes(target) ? [] : INSTALLER_HOOKS;
24
+ }
25
+ /**
26
+ * A script of the project, read
27
+ * @param project
28
+ * @param file
29
+ * @param where its place in the configuration
30
+ */
31
+ function read(project, file, where) {
32
+ if (typeof file !== 'string' || !file)
33
+ throw new Error(`The script ${where} must be a file of the project`);
34
+ const absolute = path.resolve(project, file);
35
+ if (!existsSync(absolute) || !statSync(absolute).isFile())
36
+ throw new Error(`The script ${where} (${file}) does not exist`);
37
+ //Without the mark of UTF-8, which the scripts are given with when they need it
38
+ return { file: file, content: readFileSync(absolute, 'utf8').replace(/^\ufeff/, '') };
39
+ }
40
+ /**
41
+ * The scripts of a moment, checked
42
+ * @param project
43
+ * @param scripts
44
+ * @param where
45
+ * @param windows
46
+ */
47
+ function installerHooks(project, scripts, where, windows) {
48
+ if (scripts === undefined)
49
+ return {};
50
+ if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts))
51
+ throw new Error(`The scripts ${where} must be {"preinstall": "<file>", "postinstall": "<file>", "preremove": "<file>", "postremove": "<file>"}`);
52
+ const hooks = {};
53
+ for (const [hook, file] of Object.entries(scripts)) {
54
+ if (!INSTALLER_HOOKS.includes(hook))
55
+ throw new Error(`The script ${where}.${hook} is not one of ${INSTALLER_HOOKS.join(', ')}`);
56
+ if (windows && typeof file === 'string' && !/\.ps1$/i.test(file))
57
+ throw new Error(`The script ${where}.${hook} (${file}) must be a script of PowerShell (.ps1)`);
58
+ hooks[hook] = read(project, file, `${where}.${hook}`);
59
+ }
60
+ return hooks;
61
+ }
62
+ /**
63
+ * The scripts of the installers made on a platform: the ones of the platform (lakutata.installer.<platform>.scripts),
64
+ * replaced by the ones of each installer (lakutata.installer.<platform>.<installer>.scripts), the native ones of the
65
+ * packages of Linux (nativeScripts), the file of NSIS included by its installer (win.nsis.include). The ones an
66
+ * installer does not run are told (warnings)
67
+ * @param project
68
+ * @param platform its name in the configuration: linux, mac or win
69
+ * @param config its configuration
70
+ * @param targets the installers made
71
+ * @param warnings
72
+ * @param hooks the moments each installer runs the scripts at (the installers of lakutata by default)
73
+ * @param native whether the packages of Linux run native scripts (the ones of lakutata)
74
+ */
75
+ export function resolveScripts(project, platform, config, targets, warnings, hooks = lakutataHooks, native = true) {
76
+ const windows = platform === 'win';
77
+ const where = `lakutata.installer.${platform}`;
78
+ const common = installerHooks(project, config?.scripts, `${where}.scripts`, windows);
79
+ const resolved = {};
80
+ const unrun = [];
81
+ for (const target of targets) {
82
+ const options = config?.[target];
83
+ if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options)))
84
+ throw new Error(`${where}.${target} must be an object`);
85
+ const own = (options ?? {});
86
+ const scripts = { hooks: { ...common, ...installerHooks(project, own.scripts, `${where}.${target}.scripts`, windows) }, native: {} };
87
+ if (own.nativeScripts !== undefined) {
88
+ if (!native || platform !== 'linux' || ARCHIVES.includes(target))
89
+ throw new Error(`${where}.${target}.nativeScripts: the native scripts are the ones of the packages of Linux lakutata makes (deb, rpm, apk, archlinux), not the ones of Electron`);
90
+ if (!own.nativeScripts || typeof own.nativeScripts !== 'object' || Array.isArray(own.nativeScripts))
91
+ throw new Error(`${where}.${target}.nativeScripts must be {"<script>": "<file>"}`);
92
+ //The upgrades of deb and rpm run their scripts of the installation and of the removal
93
+ const names = target === 'apk' || target === 'archlinux' ? NATIVE_HOOKS : INSTALLER_HOOKS;
94
+ for (const [hook, file] of Object.entries(own.nativeScripts)) {
95
+ if (!names.includes(hook))
96
+ throw new Error(`${where}.${target}.nativeScripts.${hook} is not a script of ${target} (${names.join(', ')})`);
97
+ scripts.native[hook] = read(project, file, `${where}.${target}.nativeScripts.${hook}`);
98
+ }
99
+ }
100
+ if (own.include !== undefined) {
101
+ if (!windows || target !== 'nsis')
102
+ throw new Error(`${where}.${target}.include: the file of NSIS is included by the installer of NSIS (win.nsis.include)`);
103
+ if (typeof own.include === 'string' && !/\.nsh$/i.test(own.include))
104
+ throw new Error(`${where}.nsis.include (${own.include}) must be a file of NSIS (.nsh)`);
105
+ scripts.include = read(project, own.include, `${where}.nsis.include`);
106
+ }
107
+ //The ones the installer does not run
108
+ const run = hooks(target);
109
+ const skipped = INSTALLER_HOOKS.filter((hook) => !!scripts.hooks[hook] && !run.includes(hook));
110
+ for (const hook of skipped)
111
+ delete scripts.hooks[hook];
112
+ if (skipped.length)
113
+ unrun.push(`${target} (${skipped.join(', ')})`);
114
+ resolved[target] = scripts;
115
+ }
116
+ if (unrun.length)
117
+ warnings.push(`Some scripts of the application are not run by their installers: ${unrun.join(', ')}`);
118
+ return resolved;
119
+ }
120
+ /**
121
+ * The lines of sh running a script embedded in them (the scripts run before the files of the application are installed,
122
+ * or after they are removed): written to a temporary file, run by the interpreter of its first line (a temporary file
123
+ * system may not run its files), its failure the one of the script running it
124
+ * @param script
125
+ * @param prefix the directory of the application (LAKUTATA_INSTALL_DIR)
126
+ * @param args the arguments given to it (sh)
127
+ */
128
+ export function embeddedShell(script, prefix, args) {
129
+ const first = script.content.split('\n', 1)[0];
130
+ const interpreter = first.startsWith('#!') ? first.slice(2).trim() : '/bin/sh';
131
+ //Not a line of the script
132
+ const tag = `LAKUTATA_SCRIPT_${createHash('sha1').update(script.content).digest('hex').slice(0, 12).toUpperCase()}`;
133
+ const content = script.content.replace(/\r\n/g, '\n');
134
+ return [
135
+ `# ${script.file}`,
136
+ 'lakutata_script="$(mktemp)"',
137
+ `cat > "$lakutata_script" <<'${tag}'`,
138
+ ...content.endsWith('\n') ? content.slice(0, -1).split('\n') : content.split('\n'),
139
+ tag,
140
+ `LAKUTATA_INSTALL_DIR='${prefix.replace(/'/g, `'\\''`)}' ${interpreter} "$lakutata_script" ${args} || { lakutata_status=$?; rm -f "$lakutata_script"; exit $lakutata_status; }`,
141
+ 'rm -f "$lakutata_script"'
142
+ ];
143
+ }
144
+ /**
145
+ * The lines of sh running a script encoded in them (base64): the scripts of the packages of electron-builder are
146
+ * templates, which replace their ${...}
147
+ * @param script
148
+ * @param prefix the directory of the application (LAKUTATA_INSTALL_DIR)
149
+ * @param args the arguments given to it (sh)
150
+ */
151
+ export function encodedShell(script, prefix, args) {
152
+ const first = script.content.split('\n', 1)[0];
153
+ const interpreter = first.startsWith('#!') ? first.slice(2).trim() : '/bin/sh';
154
+ return [
155
+ `# ${script.file}`,
156
+ 'lakutata_script="$(mktemp)"',
157
+ `printf '%s' '${Buffer.from(script.content.replace(/\r\n/g, '\n'), 'utf8').toString('base64')}' | base64 -d > "$lakutata_script"`,
158
+ `LAKUTATA_INSTALL_DIR='${prefix.replace(/'/g, `'\\''`)}' ${interpreter} "$lakutata_script" ${args} || { lakutata_status=$?; rm -f "$lakutata_script"; exit $lakutata_status; }`,
159
+ 'rm -f "$lakutata_script"'
160
+ ];
161
+ }
@@ -0,0 +1,109 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { runCommand } from './Processes.js';
7
+ /**
8
+ * The tools of Windows: the same files on x64 and arm64 (x86 and .NET, emulated or AnyCPU)
9
+ * @param archive
10
+ * @param sha256
11
+ */
12
+ function windows(archive, sha256) {
13
+ return { 'win32-x64': { archive: archive, sha256: sha256 }, 'win32-arm64': { archive: archive, sha256: sha256 } };
14
+ }
15
+ /**
16
+ * The tools of the builder
17
+ */
18
+ const TOOLS = {
19
+ //The packages of Linux (deb, rpm, apk, archlinux), made on Linux: https://nfpm.goreleaser.com
20
+ nfpm: {
21
+ version: '2.47.0',
22
+ executable: 'nfpm',
23
+ url: (archive) => `https://github.com/goreleaser/nfpm/releases/download/v2.47.0/${archive}`,
24
+ archives: {
25
+ 'linux-x64': { archive: 'nfpm_2.47.0_Linux_x86_64.tar.gz', sha256: '0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783' },
26
+ 'linux-arm64': { archive: 'nfpm_2.47.0_Linux_arm64.tar.gz', sha256: '1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c' }
27
+ }
28
+ },
29
+ //The wrapper of the services of Windows (.NET Framework, AnyCPU): https://github.com/winsw/winsw
30
+ winsw: {
31
+ version: '2.12.0',
32
+ executable: 'WinSW.NET461.exe',
33
+ url: (archive) => `https://github.com/winsw/winsw/releases/download/v2.12.0/${archive}`,
34
+ archives: windows('WinSW.NET461.exe', 'b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f')
35
+ },
36
+ //The installers of Windows (.exe): https://nsis.sourceforge.io
37
+ nsis: {
38
+ version: '3.13',
39
+ executable: 'nsis-3.13/makensis.exe',
40
+ url: (archive) => `https://sourceforge.net/projects/nsis/files/NSIS%203/3.13/${archive}/download`,
41
+ archives: windows('nsis-3.13.zip', 'ba63dffc4410ee89193e1cb5a41989991bd77c61068da17e3156d136b7b0b3d8')
42
+ },
43
+ //The packages of Windows Installer (.msi), candle and light: https://github.com/wixtoolset/wix3
44
+ wix: {
45
+ version: '3.14.1',
46
+ executable: 'candle.exe',
47
+ url: (archive) => `https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/${archive}`,
48
+ archives: windows('wix314-binaries.zip', '6ac824e1642d6f7277d0ed7ea09411a508f6116ba6fae0aa5f2c7daa2ff43d31')
49
+ }
50
+ };
51
+ /**
52
+ * The directory of the cache of lakutata (the one of its tool): LAKUTATA_CACHE_DIR, else ~/.cache/lakutata
53
+ */
54
+ export function cacheDirectory() {
55
+ return process.env.LAKUTATA_CACHE_DIR ?? path.join(process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache'), 'lakutata');
56
+ }
57
+ /**
58
+ * A tool of the builder: the one given by its environment variable (LAKUTATA_NFPM, LAKUTATA_WINSW, LAKUTATA_NSIS:
59
+ * makensis.exe, LAKUTATA_WIX: candle.exe, next to light.exe), else the version of the builder, downloaded once into the
60
+ * cache and checked with its sha256
61
+ * @param name
62
+ */
63
+ export async function ensureTool(name) {
64
+ const given = process.env[`LAKUTATA_${name.toUpperCase()}`];
65
+ if (given)
66
+ return given;
67
+ const tool = TOOLS[name];
68
+ const platform = `${process.platform}-${process.arch}`;
69
+ const release = tool.archives[platform];
70
+ if (!release)
71
+ throw new Error(`${name} ${tool.version} has no release for ${platform}: give its executable (LAKUTATA_${name.toUpperCase()})`);
72
+ const directory = path.join(cacheDirectory(), 'tools', `${name}-${tool.version}-${platform}`);
73
+ const executable = path.join(directory, ...tool.executable.split('/'));
74
+ if (existsSync(executable))
75
+ return executable;
76
+ //SourceForge answers the browsers with its page, the tools with the file
77
+ const response = await fetch(tool.url(release.archive), { headers: { 'user-agent': 'Wget' } }).catch((error) => {
78
+ throw new Error(`${name} cannot be downloaded (${error.message}): give its executable (LAKUTATA_${name.toUpperCase()})`);
79
+ });
80
+ if (!response.ok)
81
+ throw new Error(`${name} cannot be downloaded (${tool.url(release.archive)}: ${response.status}): give its executable (LAKUTATA_${name.toUpperCase()})`);
82
+ const archive = Buffer.from(await response.arrayBuffer());
83
+ const sha256 = createHash('sha256').update(archive).digest('hex');
84
+ if (sha256 !== release.sha256)
85
+ throw new Error(`The archive of ${name} ${tool.version} downloaded is not the one of its release (sha256 ${sha256})`);
86
+ //Extracted aside, then renamed: the builds running together do not see it partly written
87
+ await mkdir(path.dirname(directory), { recursive: true });
88
+ const temporary = await mkdtemp(`${directory}.`);
89
+ try {
90
+ await writeFile(path.join(temporary, release.archive), archive);
91
+ //An archive (tar.gz, zip: the tar of Windows reads them), or the executable itself
92
+ if (/\.(tar\.gz|zip)$/.test(release.archive)) {
93
+ const extracted = await runCommand('tar', ['-xf', release.archive], temporary);
94
+ if (extracted.code !== 0)
95
+ throw new Error(`The archive of ${name} cannot be extracted: ${extracted.output.trim()}`);
96
+ await rm(path.join(temporary, release.archive));
97
+ }
98
+ await chmod(path.join(temporary, ...tool.executable.split('/')), 0o755);
99
+ await rename(temporary, directory).catch((error) => {
100
+ //Extracted by another build
101
+ if (!existsSync(executable))
102
+ throw error;
103
+ });
104
+ }
105
+ finally {
106
+ await rm(temporary, { recursive: true, force: true });
107
+ }
108
+ return executable;
109
+ }