@dbx-tools/cli-service 0.9.36

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/cli.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Commander command group for a system-tray service lifecycle.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { Command } from "commander";
8
+
9
+ import type { CliServiceDefinition } from "./definition.ts";
10
+ import { CliService, type CliServiceLifecycle } from "./service.ts";
11
+
12
+ /** Static definition or action-time factory used by a service command group. */
13
+ export type CliServiceDefinitionSource = CliServiceDefinition | (() => CliServiceDefinition);
14
+
15
+ /** Injectable lifecycle and output boundary for service CLI tests and composition. */
16
+ export interface CliServiceCliDependencies {
17
+ /** Construct the lifecycle manager used by command actions. */
18
+ create(definition: CliServiceDefinition): CliServiceLifecycle;
19
+ /** Write the JSON result of the status command. */
20
+ write(value: string): void;
21
+ }
22
+
23
+ const DEFAULT_DEPENDENCIES: CliServiceCliDependencies = {
24
+ create: (definition) => new CliService(definition),
25
+ write: (value) => process.stdout.write(value),
26
+ };
27
+
28
+ /** Build install, start, stop, restart, status, and uninstall commands for a service. */
29
+ export function buildServiceCommand(
30
+ source: CliServiceDefinitionSource,
31
+ dependencies: CliServiceCliDependencies = DEFAULT_DEPENDENCIES,
32
+ ): Command {
33
+ const definition = resolveDefinition(source);
34
+ const service = () => dependencies.create(definition());
35
+ const command = new Command("service").description(
36
+ typeof source === "function"
37
+ ? "Install and manage the desktop service"
38
+ : `Install and manage the ${source.name} desktop service`,
39
+ );
40
+
41
+ command
42
+ .command("install")
43
+ .description("Install the service for the current user and start it")
44
+ .option("--no-start", "install without starting the service")
45
+ .action(async (options: { start: boolean }) => {
46
+ await service().install({ start: options.start });
47
+ });
48
+
49
+ command
50
+ .command("start")
51
+ .description("Start the installed service")
52
+ .action(async () => {
53
+ await service().start();
54
+ });
55
+
56
+ command
57
+ .command("stop")
58
+ .description("Stop the running service")
59
+ .action(async () => {
60
+ await service().stop();
61
+ });
62
+
63
+ command
64
+ .command("restart")
65
+ .description("Restart the installed service")
66
+ .action(async () => {
67
+ await service().restart();
68
+ });
69
+
70
+ command
71
+ .command("status")
72
+ .description("Print service installation and process state as JSON")
73
+ .action(async () => {
74
+ dependencies.write(`${JSON.stringify(await service().status(), null, 2)}\n`);
75
+ });
76
+
77
+ command
78
+ .command("uninstall")
79
+ .description("Stop and remove the service for the current user")
80
+ .action(async () => {
81
+ await service().uninstall();
82
+ });
83
+
84
+ return command;
85
+ }
86
+
87
+ function resolveDefinition(source: CliServiceDefinitionSource): () => CliServiceDefinition {
88
+ return typeof source === "function" ? source : () => source;
89
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Serializable configuration for a system-tray CLI service.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { z } from "zod";
8
+
9
+ import { resolveServicePackage, servicePackageDefaults } from "./_package.ts";
10
+
11
+ const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
12
+
13
+ function isSafeUrl(value: string): boolean {
14
+ try {
15
+ return SAFE_URL_PROTOCOLS.has(new URL(value).protocol);
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /** A process launched by the system-tray service host. */
22
+ export const CliServiceCommandSchema = z
23
+ .object({
24
+ executable: z.string().min(1).optional(),
25
+ entrypoint: z.string().min(1).optional(),
26
+ binName: z.string().min(1).optional(),
27
+ arguments: z.array(z.string()).readonly().optional(),
28
+ environment: z.record(z.string(), z.string()).readonly().optional(),
29
+ cwd: z.string().min(1).optional(),
30
+ })
31
+ .superRefine((command, context) => {
32
+ if (
33
+ Number(command.executable !== undefined) +
34
+ Number(command.entrypoint !== undefined) +
35
+ Number(command.binName !== undefined) >
36
+ 1
37
+ ) {
38
+ context.addIssue({
39
+ code: "custom",
40
+ message: "command accepts at most one of executable, entrypoint, or binName",
41
+ });
42
+ }
43
+ })
44
+ .readonly();
45
+
46
+ /** A process launched by the system-tray service host. */
47
+ export type CliServiceCommand = z.infer<typeof CliServiceCommandSchema>;
48
+
49
+ /** A typed custom item inserted between the default service title and Quit item. */
50
+ export const CliServiceMenuItemSchema = z.discriminatedUnion("type", [
51
+ z.object({ type: z.literal("separator") }).readonly(),
52
+ z
53
+ .object({
54
+ type: z.literal("url"),
55
+ label: z.string().min(1),
56
+ url: z.string().refine(isSafeUrl, "URL must use HTTP, HTTPS, or mailto"),
57
+ })
58
+ .readonly(),
59
+ z
60
+ .object({
61
+ type: z.literal("command"),
62
+ label: z.string().min(1),
63
+ command: CliServiceCommandSchema,
64
+ })
65
+ .readonly(),
66
+ ]);
67
+
68
+ /** A typed custom item inserted between the default service title and Quit item. */
69
+ export type CliServiceMenuItem = z.infer<typeof CliServiceMenuItemSchema>;
70
+
71
+ /** Complete serializable definition consumed by the lifecycle manager and tray host. */
72
+ export const CliServiceDefinitionSchema = z
73
+ .object({
74
+ id: z
75
+ .string()
76
+ .min(1)
77
+ .max(128)
78
+ .regex(/^[a-z0-9][a-z0-9._-]*$/, "service id must be filesystem safe"),
79
+ name: z.string().min(1),
80
+ packageName: z.string().min(1),
81
+ version: z.string().min(1).optional(),
82
+ icon: z.string().min(1),
83
+ isTemplateIcon: z.boolean().optional(),
84
+ dataDirectory: z.string().min(1).optional(),
85
+ command: CliServiceCommandSchema.optional(),
86
+ menu: z.array(CliServiceMenuItemSchema).readonly().optional(),
87
+ })
88
+ .readonly();
89
+
90
+ /** Complete serializable definition consumed by the lifecycle manager and tray host. */
91
+ export type CliServiceDefinition = z.infer<typeof CliServiceDefinitionSchema>;
92
+
93
+ /** Package-derived service fields plus caller-owned icon, process, and menu options. */
94
+ export type CliServiceDefinitionOptions = Omit<
95
+ CliServiceDefinition,
96
+ "packageName" | "id" | "name" | "version"
97
+ > &
98
+ Partial<Pick<CliServiceDefinition, "id" | "name" | "version">>;
99
+
100
+ /** Define a service from an owning module URL or installed package name. */
101
+ export function defineService(
102
+ packageReference: string,
103
+ options: CliServiceDefinitionOptions,
104
+ ): CliServiceDefinition {
105
+ const pkg = resolveServicePackage(packageReference);
106
+ const defaults = servicePackageDefaults(pkg);
107
+ return CliServiceDefinitionSchema.parse({
108
+ ...options,
109
+ packageName: pkg.name,
110
+ id: options.id ?? defaults.id,
111
+ name: options.name ?? defaults.name,
112
+ version: options.version ?? pkg.version,
113
+ });
114
+ }
115
+
116
+ /** Resolve a package's single or explicitly named executable entrypoint. */
117
+ export function resolveServicePackageBin(packageReference: string, binName?: string): string {
118
+ return resolveServicePackage(packageReference).bin(binName);
119
+ }
package/src/service.ts ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Install, start, stop, and inspect a system-tray user service.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import { existsSync } from "node:fs";
9
+ import { readdir, rm } from "node:fs/promises";
10
+ import { createRequire } from "node:module";
11
+ import { dirname, extname, join, resolve } from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+
14
+ import * as bin from "@dbx-tools/core/bin";
15
+ import * as exec from "@dbx-tools/core/exec";
16
+
17
+ import { readServiceDefinition, writeServiceDefinition } from "./_config.ts";
18
+ import { requestServiceControl } from "./_control.ts";
19
+ import { resolveServicePackage, type ServicePackage } from "./_package.ts";
20
+ import {
21
+ defaultRuntimeContext,
22
+ resolveServicePaths,
23
+ type ServiceRuntimeContext,
24
+ } from "./_paths.ts";
25
+ import { externalRuntimePackages, installServiceRuntime } from "./_runtime.ts";
26
+ import { installStartup, removeStartup, type ServiceLaunch } from "./_startup.ts";
27
+ import {
28
+ CliServiceDefinitionSchema,
29
+ type CliServiceCommand,
30
+ type CliServiceDefinition,
31
+ type CliServiceMenuItem,
32
+ } from "./definition.ts";
33
+
34
+ const DEFAULT_START_TIMEOUT_MILLISECONDS = 15_000;
35
+ const DEFAULT_STOP_TIMEOUT_MILLISECONDS = 10_000;
36
+ const POLL_INTERVAL_MILLISECONDS = 100;
37
+
38
+ /** Compile one TypeScript or JavaScript entrypoint into a standalone executable. */
39
+ export type CliServiceCompiler = (
40
+ entrypoint: string,
41
+ output: string,
42
+ workingDirectory: string,
43
+ external?: readonly string[],
44
+ ) => Promise<void>;
45
+
46
+ /** Install exact external runtime dependencies into the dbx-tools home. */
47
+ export type CliServiceRuntimeInstaller = (
48
+ bunExecutable: string,
49
+ directory: string,
50
+ dependencies: Readonly<Record<string, string>>,
51
+ ) => Promise<void>;
52
+
53
+ /** Host runtime overrides for tests, embedded distributions, and nonstandard homes. */
54
+ export interface CliServiceRuntimeOptions {
55
+ /** Operating system used for configuration and startup registration. */
56
+ readonly platform?: NodeJS.Platform;
57
+ /** Environment used to locate platform configuration directories. */
58
+ readonly environment?: Readonly<NodeJS.ProcessEnv>;
59
+ /** Home directory used when platform environment variables are absent. */
60
+ readonly homeDirectory?: string;
61
+ /** Temporary directory used for the local control socket. */
62
+ readonly temporaryDirectory?: string;
63
+ /** Shared dbx-tools home containing installed service binaries. */
64
+ readonly globalHomeDirectory?: string;
65
+ /** Package-provided Bun executable override. */
66
+ readonly bunExecutable?: string;
67
+ /** Standalone compiler override. */
68
+ readonly compile?: CliServiceCompiler;
69
+ /** External runtime dependency installer override. */
70
+ readonly installRuntime?: CliServiceRuntimeInstaller;
71
+ /** System-tray host source entrypoint override. */
72
+ readonly hostEntrypoint?: string;
73
+ /** systray2 native executable override. */
74
+ readonly trayExecutable?: string;
75
+ }
76
+
77
+ /** Options controlling service installation. */
78
+ export interface CliServiceInstallOptions {
79
+ /** Start the service immediately after writing its configuration and login entry. */
80
+ readonly start?: boolean;
81
+ }
82
+
83
+ /** Current installation and process state for a system-tray service. */
84
+ export interface CliServiceStatus {
85
+ /** Whether the serialized service definition exists. */
86
+ readonly installed: boolean;
87
+ /** Whether the tray host answered its local control socket. */
88
+ readonly running: boolean;
89
+ /** Tray host process ID when running. */
90
+ readonly pid?: number;
91
+ /** Managed child process ID when the definition includes a command. */
92
+ readonly servicePid?: number;
93
+ }
94
+
95
+ /** Lifecycle contract consumed by CLI command builders. */
96
+ export interface CliServiceLifecycle {
97
+ /** Install the login entry and optionally start the service. */
98
+ install(options?: CliServiceInstallOptions): Promise<void>;
99
+ /** Start the installed service when it is not already running. */
100
+ start(): Promise<void>;
101
+ /** Stop the running tray host and its managed child process. */
102
+ stop(): Promise<void>;
103
+ /** Stop and start the installed service. */
104
+ restart(): Promise<void>;
105
+ /** Stop the service and remove its login entry and package-owned state. */
106
+ uninstall(): Promise<void>;
107
+ /** Read the current installation and process state. */
108
+ status(): Promise<CliServiceStatus>;
109
+ }
110
+
111
+ /** Cross-platform lifecycle manager for one system-tray user service. */
112
+ export class CliService implements CliServiceLifecycle {
113
+ readonly definition: CliServiceDefinition;
114
+
115
+ private readonly runtime: ServiceRuntimeContext;
116
+ private readonly globalHomeDirectory: string;
117
+ private readonly bunExecutable: string;
118
+ private readonly compiler: CliServiceCompiler;
119
+ private readonly runtimeInstaller: CliServiceRuntimeInstaller;
120
+ private readonly hostEntrypoint: string;
121
+ private readonly trayExecutable: string;
122
+
123
+ /**
124
+ * Create a lifecycle manager from a serializable definition.
125
+ *
126
+ * @param definition Product-owned service metadata, process, and menu.
127
+ * @param options Host runtime overrides.
128
+ */
129
+ constructor(definition: CliServiceDefinition, options: CliServiceRuntimeOptions = {}) {
130
+ this.definition = CliServiceDefinitionSchema.parse(definition);
131
+ const defaults = defaultRuntimeContext();
132
+ this.runtime = {
133
+ platform: options.platform ?? defaults.platform,
134
+ environment: options.environment ?? defaults.environment,
135
+ homeDirectory: options.homeDirectory ?? defaults.homeDirectory,
136
+ temporaryDirectory: options.temporaryDirectory ?? defaults.temporaryDirectory,
137
+ };
138
+ this.globalHomeDirectory =
139
+ options.globalHomeDirectory ?? join(this.runtime.homeDirectory, ".dbx-tools");
140
+ this.bunExecutable = options.bunExecutable ?? resolveBunExecutable();
141
+ this.compiler =
142
+ options.compile ??
143
+ ((entrypoint, output, workingDirectory, external) =>
144
+ compileWithBun(this.bunExecutable, entrypoint, output, workingDirectory, external));
145
+ this.runtimeInstaller = options.installRuntime ?? installServiceRuntime;
146
+ this.hostEntrypoint = options.hostEntrypoint ?? resolveHostEntrypoint();
147
+ this.trayExecutable = options.trayExecutable ?? resolveTrayExecutable(this.runtime.platform);
148
+ }
149
+
150
+ /** Install the login entry and optionally start the service. */
151
+ async install(options: CliServiceInstallOptions = {}): Promise<void> {
152
+ const owner = resolveServicePackage(this.definition.packageName);
153
+ const dependencies = owner.dependencies();
154
+ const external = new Set(externalRuntimePackages(dependencies));
155
+ await this.runtimeInstaller(
156
+ this.bunExecutable,
157
+ this.globalHomeDirectory,
158
+ Object.fromEntries(Object.entries(dependencies).filter(([name]) => external.has(name))),
159
+ );
160
+ const paths = resolveServicePaths(this.definition, this.runtime);
161
+ const installedDefinition = await this.installDefinition(owner);
162
+ const host = await this.ensureCompiledBinary(
163
+ this.binaryName(installedDefinition, "service"),
164
+ this.hostEntrypoint,
165
+ resolve(dirname(this.hostEntrypoint), ".."),
166
+ );
167
+ await this.ensureTrayBinary();
168
+ await this.stop();
169
+ await writeServiceDefinition(paths.configFile, installedDefinition);
170
+ await installStartup(
171
+ installedDefinition,
172
+ paths,
173
+ this.runtime,
174
+ this.launch(host.path, paths.configFile),
175
+ );
176
+ if (options.start ?? true) await this.start();
177
+ }
178
+
179
+ /** Start the installed service when it is not already running. */
180
+ async start(): Promise<void> {
181
+ const paths = resolveServicePaths(this.definition, this.runtime);
182
+ if (!(await this.status()).installed) {
183
+ throw new Error(`${this.definition.name} is not installed`);
184
+ }
185
+ if ((await requestServiceControl(paths.controlAddress, "status")) !== undefined) return;
186
+
187
+ const installedDefinition = await readServiceDefinition(paths.configFile);
188
+ const launch = this.launch(
189
+ this.binaryContext(this.binaryName(installedDefinition, "service")).path,
190
+ paths.configFile,
191
+ );
192
+ const child = spawn(launch.executable, launch.arguments, {
193
+ detached: true,
194
+ stdio: "ignore",
195
+ });
196
+ child.unref();
197
+
198
+ await Promise.race([
199
+ waitForState(
200
+ async () => (await requestServiceControl(paths.controlAddress, "status")) !== undefined,
201
+ true,
202
+ DEFAULT_START_TIMEOUT_MILLISECONDS,
203
+ `${this.definition.name} did not start`,
204
+ ),
205
+ new Promise<never>((_resolve, reject) => {
206
+ child.once("error", reject);
207
+ child.once("exit", (code, signal) => {
208
+ reject(
209
+ new Error(
210
+ `${this.definition.name} host exited before startup (${signal ?? String(code)})`,
211
+ ),
212
+ );
213
+ });
214
+ }),
215
+ ]);
216
+ }
217
+
218
+ /** Stop the running tray host and its managed child process. */
219
+ async stop(): Promise<void> {
220
+ const paths = resolveServicePaths(this.definition, this.runtime);
221
+ const response = await requestServiceControl(paths.controlAddress, "stop");
222
+ if (!response) return;
223
+ await waitForState(
224
+ async () => (await requestServiceControl(paths.controlAddress, "status")) !== undefined,
225
+ false,
226
+ DEFAULT_STOP_TIMEOUT_MILLISECONDS,
227
+ `${this.definition.name} did not stop`,
228
+ );
229
+ }
230
+
231
+ /** Stop and start the installed service. */
232
+ async restart(): Promise<void> {
233
+ await this.stop();
234
+ await this.start();
235
+ }
236
+
237
+ /** Stop the service and remove its login entry and package-owned state. */
238
+ async uninstall(): Promise<void> {
239
+ const paths = resolveServicePaths(this.definition, this.runtime);
240
+ await this.stop();
241
+ await removeStartup(paths);
242
+ await rm(paths.directory, { recursive: true, force: true });
243
+ }
244
+
245
+ /** Read the current installation and process state. */
246
+ async status(): Promise<CliServiceStatus> {
247
+ const paths = resolveServicePaths(this.definition, this.runtime);
248
+ const installed = existsSync(paths.configFile);
249
+ const response = await requestServiceControl(paths.controlAddress, "status");
250
+ return {
251
+ installed,
252
+ running: response !== undefined,
253
+ ...(response
254
+ ? {
255
+ pid: response.pid,
256
+ ...(response.servicePid ? { servicePid: response.servicePid } : {}),
257
+ }
258
+ : {}),
259
+ };
260
+ }
261
+
262
+ private launch(executable: string, configFile: string): ServiceLaunch {
263
+ return {
264
+ executable,
265
+ arguments: ["--service-config", configFile],
266
+ };
267
+ }
268
+
269
+ private async installDefinition(owner: ServicePackage): Promise<CliServiceDefinition> {
270
+ const definition = CliServiceDefinitionSchema.parse({
271
+ ...this.definition,
272
+ version: this.definition.version ?? owner.version,
273
+ });
274
+ const command = definition.command
275
+ ? await this.installCommand(definition, owner, definition.command, "command")
276
+ : undefined;
277
+ const menu: CliServiceMenuItem[] = [];
278
+ for (const [index, item] of (definition.menu ?? []).entries()) {
279
+ menu.push(
280
+ item.type === "command"
281
+ ? {
282
+ ...item,
283
+ command: await this.installCommand(
284
+ definition,
285
+ owner,
286
+ item.command,
287
+ `menu-${index + 1}`,
288
+ ),
289
+ }
290
+ : item,
291
+ );
292
+ }
293
+ return CliServiceDefinitionSchema.parse({
294
+ ...definition,
295
+ ...(command ? { command } : {}),
296
+ ...(menu.length > 0 ? { menu } : {}),
297
+ });
298
+ }
299
+
300
+ private async installCommand(
301
+ definition: CliServiceDefinition,
302
+ owner: ServicePackage,
303
+ command: CliServiceCommand,
304
+ purpose: string,
305
+ ): Promise<CliServiceCommand> {
306
+ if (command.executable) return command;
307
+ const entrypoint = command.entrypoint ?? owner.bin(command.binName);
308
+ const installed = await this.ensureCompiledBinary(
309
+ this.binaryName(definition, purpose),
310
+ resolve(entrypoint),
311
+ owner.directory,
312
+ externalRuntimePackages(owner.dependencies()),
313
+ );
314
+ return {
315
+ executable: installed.path,
316
+ ...(command.arguments ? { arguments: command.arguments } : {}),
317
+ ...(command.environment ? { environment: command.environment } : {}),
318
+ cwd: command.cwd ?? this.globalHomeDirectory,
319
+ };
320
+ }
321
+
322
+ private async ensureCompiledBinary(
323
+ name: string,
324
+ entrypoint: string,
325
+ workingDirectory: string,
326
+ external: readonly string[] = [],
327
+ ): Promise<bin.BinContext> {
328
+ return bin.ensure(
329
+ name,
330
+ async ({ tempDir }) => {
331
+ const output = join(tempDir, executableName(name, this.runtime.platform));
332
+ await this.compiler(entrypoint, output, workingDirectory, external);
333
+ return pathToFileURL(output).href;
334
+ },
335
+ {
336
+ destination: this.binaryContext(name),
337
+ skipVersionCheck: true,
338
+ force: true,
339
+ },
340
+ );
341
+ }
342
+
343
+ private async ensureTrayBinary(): Promise<bin.BinContext> {
344
+ const name = trayExecutableName(this.runtime.platform);
345
+ const binDir = join(this.globalHomeDirectory, "bin", "traybin");
346
+ return bin.ensure(name, pathToFileURL(this.trayExecutable).href, {
347
+ destination: {
348
+ root: this.globalHomeDirectory,
349
+ binDir,
350
+ path: join(binDir, name),
351
+ },
352
+ skipVersionCheck: true,
353
+ });
354
+ }
355
+
356
+ private binaryContext(name: string): bin.BinContext {
357
+ const binDir = join(this.globalHomeDirectory, "bin");
358
+ return {
359
+ root: this.globalHomeDirectory,
360
+ binDir,
361
+ path: join(binDir, executableName(name, this.runtime.platform)),
362
+ };
363
+ }
364
+
365
+ private binaryName(definition: CliServiceDefinition, purpose: string): string {
366
+ const name = safeToken(definition.name.toLowerCase()).replace(/[._]+/g, "-");
367
+ return purpose === "service" ? name : `${name}-${purpose}`;
368
+ }
369
+ }
370
+
371
+ function resolveHostEntrypoint(): string {
372
+ const current = fileURLToPath(import.meta.url);
373
+ const source =
374
+ extname(current) === ".ts"
375
+ ? join(dirname(current), "_host.ts")
376
+ : resolve(dirname(current), "..", "..", "src", "_host.ts");
377
+ if (existsSync(source)) return source;
378
+ throw new Error(`could not resolve cli-service host source: ${source}`);
379
+ }
380
+
381
+ function resolveBunExecutable(): string {
382
+ return createRequire(import.meta.url).resolve("bun/bin/bun.exe");
383
+ }
384
+
385
+ function trayExecutableName(platform: NodeJS.Platform): string {
386
+ if (platform === "darwin") return "tray_darwin_release";
387
+ if (platform === "linux") return "tray_linux_release";
388
+ if (platform === "win32") return "tray_windows_release.exe";
389
+ throw new Error(`systray2 does not support ${platform}`);
390
+ }
391
+
392
+ function resolveTrayExecutable(platform: NodeJS.Platform): string {
393
+ const manifest = createRequire(import.meta.url).resolve("systray2/package.json");
394
+ return join(dirname(manifest), "traybin", trayExecutableName(platform));
395
+ }
396
+
397
+ function executableName(name: string, platform: NodeJS.Platform): string {
398
+ return platform === "win32" ? `${name}.exe` : name;
399
+ }
400
+
401
+ function safeToken(value: string): string {
402
+ const token = value.replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "");
403
+ if (!token) throw new TypeError(`invalid binary version: ${value}`);
404
+ return token;
405
+ }
406
+
407
+ async function compileWithBun(
408
+ bunExecutable: string,
409
+ entrypoint: string,
410
+ output: string,
411
+ workingDirectory: string,
412
+ external: readonly string[] = [],
413
+ ): Promise<void> {
414
+ const before = await bunBuildArtifacts(workingDirectory);
415
+ try {
416
+ await exec.spawn(
417
+ bunExecutable,
418
+ [
419
+ "build",
420
+ entrypoint,
421
+ "--compile",
422
+ "--compile-autoload-package-json",
423
+ "--outfile",
424
+ output,
425
+ ...external.flatMap((dependency) => ["--external", dependency]),
426
+ ],
427
+ {
428
+ check: true,
429
+ cwd: workingDirectory,
430
+ env: {
431
+ ...process.env,
432
+ BUN_TMPDIR: dirname(output),
433
+ TMPDIR: dirname(output),
434
+ },
435
+ stdin: "ignore",
436
+ stdout: "capture",
437
+ stderr: "capture",
438
+ },
439
+ );
440
+ } finally {
441
+ for (const artifact of await bunBuildArtifacts(workingDirectory)) {
442
+ if (!before.has(artifact)) {
443
+ await rm(join(workingDirectory, artifact), {
444
+ recursive: true,
445
+ force: true,
446
+ });
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ async function bunBuildArtifacts(directory: string): Promise<Set<string>> {
453
+ return new Set(
454
+ (await readdir(directory))
455
+ .filter((name) => name.startsWith(".") && name.endsWith(".bun-build"))
456
+ .sort(),
457
+ );
458
+ }
459
+
460
+ async function waitForState(
461
+ read: () => Promise<boolean>,
462
+ expected: boolean,
463
+ timeoutMilliseconds: number,
464
+ message: string,
465
+ ): Promise<void> {
466
+ const deadline = Date.now() + timeoutMilliseconds;
467
+ while (Date.now() < deadline) {
468
+ if ((await read()) === expected) return;
469
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MILLISECONDS));
470
+ }
471
+ throw new Error(message);
472
+ }