@dbx-tools/core 0.6.65 → 0.6.67

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/src/bin.ts ADDED
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Install and reuse executable binaries under a per-tool home directory.
3
+ *
4
+ * @module
5
+ */
6
+ import { execFile } from "node:child_process";
7
+ import { randomUUID } from "node:crypto";
8
+ import {
9
+ chmod,
10
+ copyFile,
11
+ mkdir,
12
+ mkdtemp,
13
+ readdir,
14
+ rename,
15
+ rm,
16
+ stat,
17
+ writeFile,
18
+ } from "node:fs/promises";
19
+ import { homedir, tmpdir } from "node:os";
20
+ import { basename, join } from "node:path";
21
+ import { promisify } from "node:util";
22
+
23
+ import extractZip from "extract-zip";
24
+ import { x as extractTar } from "tar";
25
+
26
+ import { error, log } from "@dbx-tools/shared-core";
27
+ import { withProcessLock } from "./process-lock.ts";
28
+
29
+ const execFileAsync = promisify(execFile);
30
+ const logger = log.logger("core:bin");
31
+
32
+ interface ParsedVersion {
33
+ raw: string;
34
+ parts: number[];
35
+ }
36
+
37
+ interface BinAccessContext {
38
+ file: boolean;
39
+ executable: boolean;
40
+ }
41
+
42
+ /** Stable paths for an installed binary. */
43
+ export interface BinContext {
44
+ root: string;
45
+ binDir: string;
46
+ path: string;
47
+ }
48
+
49
+ /** Temporary download and extraction paths supplied to a custom selector. */
50
+ export interface BinSelectionContext {
51
+ destination: BinContext;
52
+ downloadPath: string;
53
+ source: string;
54
+ }
55
+
56
+ /**
57
+ * Select the executable from a download or unpacked archive. A selector may
58
+ * also prepare the file, such as applying its executable mode.
59
+ */
60
+ export type BinSelector = (context: BinSelectionContext) => string | Promise<string>;
61
+
62
+ /** Captured output passed to a custom binary version parser. */
63
+ export interface BinVersionOutput {
64
+ stdout: string;
65
+ stderr: string;
66
+ }
67
+
68
+ /** Extract a version string from a successful version-command result. */
69
+ export type BinVersionParser = (output: BinVersionOutput) => string | undefined;
70
+
71
+ /** Options for {@link ensure}. */
72
+ export interface BinOptions {
73
+ autoUnpackage?: boolean;
74
+ selector?: BinSelector;
75
+ homeDir?: string;
76
+ /** Minimum accepted numeric version, with one to three components. */
77
+ minVersion?: string;
78
+ /** Argument passed to the binary for version detection. Defaults to `--version`. */
79
+ versionArgument?: string;
80
+ /** Version output parser. Defaults to {@link parseVersion}. */
81
+ versionParser?: BinVersionParser;
82
+ }
83
+
84
+ /** A URL resolved only when the executable is not already installed. */
85
+ export type BinUrl = string | (() => string | Promise<string>);
86
+
87
+ function context(name: string, homeDir: string): BinContext {
88
+ if (!name || basename(name) !== name || name === "." || name === "..") {
89
+ throw new TypeError(`invalid binary name: ${name}`);
90
+ }
91
+ const root = join(homeDir, `.${name}`);
92
+ const binDir = join(root, "bin");
93
+ return { root, binDir, path: join(binDir, name) };
94
+ }
95
+
96
+ function displayUrl(raw: string): string {
97
+ try {
98
+ const url = new URL(raw);
99
+ if (url.protocol === "data:") return "data:";
100
+ url.username = "";
101
+ url.password = "";
102
+ url.search = "";
103
+ url.hash = "";
104
+ return url.toString();
105
+ } catch {
106
+ return raw;
107
+ }
108
+ }
109
+
110
+ async function accessContext(path: string): Promise<BinAccessContext> {
111
+ try {
112
+ const entry = await stat(path);
113
+ const file = entry.isFile();
114
+ return {
115
+ file,
116
+ executable: file && (process.platform === "win32" || (entry.mode & 0o111) !== 0),
117
+ };
118
+ } catch {
119
+ return { file: false, executable: false };
120
+ }
121
+ }
122
+
123
+ function detectedVersions(output: string): ParsedVersion[] {
124
+ const versions: ParsedVersion[] = [];
125
+ const pattern = /\bv?(\d+(?:\.\d+){0,2})(?:[-+._]?[a-z][0-9a-z.+_-]*)?/gi;
126
+ for (const match of output.matchAll(pattern)) {
127
+ const raw = match[0].replace(/^v/i, "");
128
+ const parts = match[1]?.split(".").map(Number);
129
+ if (parts?.every(Number.isFinite)) versions.push({ raw, parts });
130
+ }
131
+ return versions.sort((a, b) => {
132
+ if (a.parts.length !== b.parts.length) return b.parts.length - a.parts.length;
133
+ for (let index = 0; index < a.parts.length; index += 1) {
134
+ const order = (b.parts[index] ?? 0) - (a.parts[index] ?? 0);
135
+ if (order !== 0) return order;
136
+ }
137
+ return 0;
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Parse the deepest, highest version from stdout, falling back to stderr only
143
+ * when stdout contains no version. Supports one to three numeric components
144
+ * and common suffixes such as `rc1`, `.post1`, and `-dev.2`.
145
+ */
146
+ export function parseVersion({ stdout, stderr }: BinVersionOutput): string | undefined {
147
+ return detectedVersions(stdout).at(0)?.raw ?? detectedVersions(stderr).at(0)?.raw;
148
+ }
149
+
150
+ function numericVersion(version: string, strict: boolean): number[] | undefined {
151
+ const pattern = strict ? /^\s*v?(\d+(?:\.\d+){0,2})\s*$/i : /\bv?(\d+(?:\.\d+){0,2})/i;
152
+ const match = pattern.exec(version);
153
+ return match?.[1]?.split(".").map(Number);
154
+ }
155
+
156
+ function meetsMinVersion(version: string, minVersion: string | undefined): boolean {
157
+ if (!minVersion) return true;
158
+ const actual = numericVersion(version, false);
159
+ const minimum = numericVersion(minVersion, true);
160
+ if (!minimum) {
161
+ throw new TypeError(`invalid minimum binary version: ${minVersion}`);
162
+ }
163
+ if (!actual) return false;
164
+ for (let index = 0; index < Math.max(actual.length, minimum.length); index += 1) {
165
+ const difference = (actual[index] ?? 0) - (minimum[index] ?? 0);
166
+ if (difference !== 0) return difference > 0;
167
+ }
168
+ return true;
169
+ }
170
+
171
+ async function isValidBin(path: string, options: BinOptions): Promise<boolean> {
172
+ logger.debug("checking binary", {
173
+ path,
174
+ minVersion: options.minVersion,
175
+ versionArgument: options.versionArgument ?? "--version",
176
+ });
177
+ const access = await accessContext(path);
178
+ if (!access.file || !access.executable) {
179
+ logger.debug("binary access check failed", { path, ...access });
180
+ return false;
181
+ }
182
+ let stdout: string;
183
+ let stderr: string;
184
+ try {
185
+ const result = await execFileAsync(path, [options.versionArgument ?? "--version"], {
186
+ encoding: "utf8",
187
+ });
188
+ stdout = result.stdout;
189
+ stderr = result.stderr;
190
+ } catch (cause) {
191
+ logger.debug("binary version command failed", {
192
+ path,
193
+ error: error.errorMessage(cause),
194
+ });
195
+ return false;
196
+ }
197
+ const version = (options.versionParser ?? parseVersion)({
198
+ stdout,
199
+ stderr,
200
+ });
201
+ const valid = version !== undefined && meetsMinVersion(version, options.minVersion);
202
+ logger.debug("binary version checked", {
203
+ path,
204
+ version,
205
+ minVersion: options.minVersion,
206
+ valid,
207
+ });
208
+ return valid;
209
+ }
210
+
211
+ function downloadName(url: string, name: string): string {
212
+ try {
213
+ const candidate = basename(decodeURIComponent(new URL(url).pathname));
214
+ return candidate && Buffer.byteLength(candidate) <= 200 ? candidate : name;
215
+ } catch {
216
+ return name;
217
+ }
218
+ }
219
+
220
+ async function unpack(archive: string, destination: string): Promise<void> {
221
+ const filename = archive.toLowerCase();
222
+ if (filename.endsWith(".zip")) {
223
+ await extractZip(archive, { dir: destination });
224
+ return;
225
+ }
226
+ if (filename.endsWith(".tar") || filename.endsWith(".tar.gz") || filename.endsWith(".tgz")) {
227
+ await extractTar({ file: archive, cwd: destination });
228
+ return;
229
+ }
230
+ throw new Error(`unsupported binary archive: ${basename(archive)}`);
231
+ }
232
+
233
+ async function filesUnder(root: string): Promise<string[]> {
234
+ const entries = await readdir(root, { withFileTypes: true });
235
+ const files: string[] = [];
236
+ for (const entry of entries) {
237
+ const path = join(root, entry.name);
238
+ if (entry.isDirectory()) {
239
+ files.push(...(await filesUnder(path)));
240
+ } else if (entry.isFile()) {
241
+ files.push(path);
242
+ }
243
+ }
244
+ return files;
245
+ }
246
+
247
+ async function selectSingleFile(source: string): Promise<string> {
248
+ const files = await filesUnder(source);
249
+ const selected = files.at(0);
250
+ if (files.length !== 1 || !selected) {
251
+ throw new Error(`binary archive must contain one file, found ${files.length}`);
252
+ }
253
+ return selected;
254
+ }
255
+
256
+ async function selectedBin(
257
+ destination: BinContext,
258
+ url: string,
259
+ temp: string,
260
+ options: BinOptions,
261
+ ): Promise<string> {
262
+ const name = downloadName(url, basename(destination.path));
263
+ const downloadPath = join(temp, name);
264
+ logger.debug("downloading binary", {
265
+ from: displayUrl(url),
266
+ to: downloadPath,
267
+ });
268
+ const response = await fetch(url);
269
+ if (!response.ok) {
270
+ throw new Error(`binary download failed (${response.status})`);
271
+ }
272
+ await writeFile(downloadPath, Buffer.from(await response.arrayBuffer()), { mode: 0o755 });
273
+
274
+ let source = downloadPath;
275
+ if (options.autoUnpackage) {
276
+ source = join(temp, `unpacked-${randomUUID()}`);
277
+ await mkdir(source);
278
+ logger.debug("unpacking binary archive", {
279
+ archive: downloadPath,
280
+ to: source,
281
+ });
282
+ await unpack(downloadPath, source);
283
+ }
284
+
285
+ if (options.selector) {
286
+ const selected = await options.selector({ destination, downloadPath, source });
287
+ logger.debug("selected binary", { path: selected });
288
+ return selected;
289
+ }
290
+ const selected = options.autoUnpackage ? await selectSingleFile(source) : source;
291
+ logger.debug("selected binary", { path: selected });
292
+ return selected;
293
+ }
294
+
295
+ /**
296
+ * Return an existing executable or install it atomically under
297
+ * `$HOME/.<name>/bin/<name>`. Installation uses a check-lock-check-load
298
+ * sequence so concurrent callers resolve and download the binary only once.
299
+ * The downloaded candidate and final renamed executable must both pass the
300
+ * same executable and version checks.
301
+ */
302
+ export async function ensure(
303
+ name: string,
304
+ url: BinUrl,
305
+ options: BinOptions = {},
306
+ ): Promise<BinContext> {
307
+ if (options.minVersion && !numericVersion(options.minVersion, true)) {
308
+ throw new TypeError(`invalid minimum binary version: ${options.minVersion}`);
309
+ }
310
+ const destination = context(name, options.homeDir ?? homedir());
311
+ if (await isValidBin(destination.path, options)) {
312
+ logger.debug("using installed binary", { name, path: destination.path });
313
+ return destination;
314
+ }
315
+
316
+ logger.debug("waiting for binary install lock", { name, path: destination.path });
317
+ return withProcessLock(["bin.ensure", destination.path], async () => {
318
+ if (await isValidBin(destination.path, options)) {
319
+ logger.debug("using binary installed by another caller", {
320
+ name,
321
+ path: destination.path,
322
+ });
323
+ return destination;
324
+ }
325
+
326
+ const resolvedUrl = typeof url === "function" ? await url() : url;
327
+ const from = displayUrl(resolvedUrl);
328
+ logger.debug("installing binary", {
329
+ name,
330
+ from,
331
+ to: destination.path,
332
+ minVersion: options.minVersion,
333
+ });
334
+ const temp = await mkdtemp(join(tmpdir(), `${name}-`));
335
+ let staged: string | undefined;
336
+ try {
337
+ const selected = await selectedBin(destination, resolvedUrl, temp, options);
338
+ await chmod(selected, 0o755);
339
+ if (!(await isValidBin(selected, options))) {
340
+ throw new Error(`selected binary has no acceptable version: ${selected}`);
341
+ }
342
+
343
+ await mkdir(destination.binDir, { recursive: true });
344
+ staged = join(destination.binDir, `.${name}-${randomUUID()}`);
345
+ await copyFile(selected, staged);
346
+ await chmod(staged, 0o755);
347
+ await rename(staged, destination.path);
348
+ staged = undefined;
349
+ if (!(await isValidBin(destination.path, options))) {
350
+ throw new Error(`installed binary is invalid after rename: ${destination.path}`);
351
+ }
352
+ logger.info("installed binary", {
353
+ name,
354
+ from,
355
+ to: destination.path,
356
+ });
357
+ return destination;
358
+ } finally {
359
+ if (staged) await rm(staged, { force: true });
360
+ await rm(temp, { recursive: true, force: true });
361
+ }
362
+ });
363
+ }