@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/_host.ts ADDED
@@ -0,0 +1,249 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { appendFile, mkdir } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import * as systrayModule from "systray2";
7
+ import type SysTrayType from "systray2";
8
+ import type { MenuItem } from "systray2";
9
+
10
+ import { readServiceDefinition } from "./_config.ts";
11
+ import {
12
+ closeServiceControl,
13
+ listenForServiceControl,
14
+ type ServiceControlResponse,
15
+ } from "./_control.ts";
16
+ import { defaultRuntimeContext, resolveServicePaths, type ServicePaths } from "./_paths.ts";
17
+ import type { CliServiceCommand, CliServiceDefinition, CliServiceMenuItem } from "./definition.ts";
18
+
19
+ const CHILD_STOP_TIMEOUT_MILLISECONDS = 5_000;
20
+ const SysTray = systrayConstructor();
21
+
22
+ interface ActionMenuItem extends MenuItem {
23
+ readonly click?: () => void;
24
+ }
25
+
26
+ function systrayConstructor(): typeof SysTrayType {
27
+ const loaded: unknown = systrayModule.default;
28
+ if (typeof loaded === "function") return loaded as typeof SysTrayType;
29
+ const nested =
30
+ loaded && typeof loaded === "object" ? (loaded as { default?: unknown }).default : undefined;
31
+ if (typeof nested === "function") return nested as typeof SysTrayType;
32
+ throw new TypeError("systray2 did not provide a constructor");
33
+ }
34
+
35
+ class CliServiceHost {
36
+ private readonly definition: CliServiceDefinition;
37
+ private readonly paths: ServicePaths;
38
+ private readonly requestStop: () => void;
39
+ private tray?: SysTrayType;
40
+ private service?: ChildProcess;
41
+ private control?: Awaited<ReturnType<typeof listenForServiceControl>>;
42
+ private stopping = false;
43
+
44
+ constructor(definition: CliServiceDefinition, paths: ServicePaths, requestStop: () => void) {
45
+ this.definition = definition;
46
+ this.paths = paths;
47
+ this.requestStop = requestStop;
48
+ }
49
+
50
+ async start(): Promise<void> {
51
+ await mkdir(this.paths.directory, { recursive: true });
52
+ this.service = this.definition.command
53
+ ? this.startProcess(this.definition.command, true)
54
+ : undefined;
55
+ this.tray = this.createTray();
56
+ await this.tray.ready();
57
+ this.tray.onError((error) => {
58
+ void logError(this.paths.hostLog, error);
59
+ this.requestStop();
60
+ });
61
+ this.tray.onExit((code, signal) => {
62
+ if (this.stopping) return;
63
+ void logMessage(this.paths.hostLog, `system tray exited (${signal ?? String(code)})`);
64
+ this.requestStop();
65
+ });
66
+ await this.tray.onClick((action) => {
67
+ const item = action.item as ActionMenuItem;
68
+ item.click?.();
69
+ });
70
+ this.control = await listenForServiceControl(
71
+ this.paths.controlAddress,
72
+ () => this.status(),
73
+ this.requestStop,
74
+ );
75
+ }
76
+
77
+ async stop(): Promise<void> {
78
+ if (this.stopping) return;
79
+ this.stopping = true;
80
+ if (this.control) {
81
+ await closeServiceControl(this.control, this.paths.controlAddress);
82
+ this.control = undefined;
83
+ }
84
+ if (this.tray) {
85
+ if (this.tray.process.exitCode === null && this.tray.process.signalCode === null) {
86
+ await this.tray.kill(false);
87
+ }
88
+ this.tray = undefined;
89
+ }
90
+ if (this.service) {
91
+ await stopProcess(this.service);
92
+ this.service = undefined;
93
+ }
94
+ }
95
+
96
+ private status(): ServiceControlResponse {
97
+ return {
98
+ running: true,
99
+ pid: process.pid,
100
+ ...(this.service?.pid ? { servicePid: this.service.pid } : {}),
101
+ };
102
+ }
103
+
104
+ private startProcess(command: CliServiceCommand, managed: boolean): ChildProcess {
105
+ if (!command.executable) {
106
+ throw new Error("installed service commands require an executable");
107
+ }
108
+ const logDescriptor = openSync(this.paths.processLog, "a");
109
+ const child = spawn(command.executable, command.arguments ?? [], {
110
+ cwd: command.cwd,
111
+ env: { ...process.env, ...command.environment },
112
+ stdio: ["ignore", logDescriptor, logDescriptor],
113
+ });
114
+ closeSync(logDescriptor);
115
+ child.once("error", (error) => {
116
+ void logError(this.paths.hostLog, error);
117
+ if (managed) this.requestStop();
118
+ });
119
+ child.once("exit", (code, signal) => {
120
+ if (!this.stopping && managed) {
121
+ void logMessage(this.paths.hostLog, `managed service exited (${signal ?? String(code)})`);
122
+ this.requestStop();
123
+ }
124
+ });
125
+ return child;
126
+ }
127
+
128
+ private createTray(): SysTrayType {
129
+ return new SysTray({
130
+ menu: {
131
+ icon: this.definition.icon,
132
+ title: "",
133
+ tooltip: `${this.definition.name} ${this.definition.version}`,
134
+ items: this.menuItems(),
135
+ isTemplateIcon: this.definition.isTemplateIcon,
136
+ },
137
+ debug: false,
138
+ copyDir: false,
139
+ });
140
+ }
141
+
142
+ private menuItems(): ActionMenuItem[] {
143
+ const custom = (this.definition.menu ?? []).map((item) => this.menuItem(item));
144
+ return [
145
+ {
146
+ title: `${this.definition.name} - ${this.definition.version}`,
147
+ tooltip: "",
148
+ enabled: false,
149
+ },
150
+ ...custom,
151
+ { ...SysTray.separator },
152
+ {
153
+ title: "Quit",
154
+ tooltip: `Stop ${this.definition.name}`,
155
+ click: this.requestStop,
156
+ },
157
+ ];
158
+ }
159
+
160
+ private menuItem(item: CliServiceMenuItem): ActionMenuItem {
161
+ if (item.type === "separator") return { ...SysTray.separator };
162
+ if (item.type === "url") {
163
+ return {
164
+ title: item.label,
165
+ tooltip: item.url,
166
+ click: () => {
167
+ this.openUrl(item.url);
168
+ },
169
+ };
170
+ }
171
+ return {
172
+ title: item.label,
173
+ tooltip: "",
174
+ click: () => {
175
+ const child = this.startProcess(item.command, false);
176
+ child.unref();
177
+ },
178
+ };
179
+ }
180
+
181
+ private openUrl(url: string): void {
182
+ const command =
183
+ process.platform === "darwin"
184
+ ? { executable: "open", arguments: [url] }
185
+ : process.platform === "win32"
186
+ ? {
187
+ executable: "rundll32.exe",
188
+ arguments: ["url.dll,FileProtocolHandler", url],
189
+ }
190
+ : { executable: "xdg-open", arguments: [url] };
191
+ const child = this.startProcess(command, false);
192
+ child.unref();
193
+ }
194
+ }
195
+
196
+ async function runCliServiceHost(configFile: string): Promise<void> {
197
+ process.chdir(dirname(process.execPath));
198
+ const definition = await readServiceDefinition(configFile);
199
+ const runtime = defaultRuntimeContext();
200
+ const paths = resolveServicePaths(definition, runtime);
201
+ let requestStop: () => void = () => {};
202
+ const stopped = new Promise<void>((resolve) => {
203
+ requestStop = resolve;
204
+ });
205
+ const host = new CliServiceHost(definition, paths, requestStop);
206
+ process.once("SIGINT", requestStop);
207
+ process.once("SIGTERM", requestStop);
208
+ try {
209
+ await host.start();
210
+ await stopped;
211
+ } finally {
212
+ await host.stop();
213
+ }
214
+ }
215
+
216
+ async function stopProcess(child: ChildProcess): Promise<void> {
217
+ if (child.exitCode !== null || child.signalCode !== null) return;
218
+ const exited = new Promise<void>((resolve) => child.once("exit", () => resolve()));
219
+ child.kill("SIGTERM");
220
+ const timedOut = new Promise<"timeout">((resolve) => {
221
+ setTimeout(() => resolve("timeout"), CHILD_STOP_TIMEOUT_MILLISECONDS);
222
+ });
223
+ if ((await Promise.race([exited, timedOut])) === "timeout") {
224
+ child.kill("SIGKILL");
225
+ await exited;
226
+ }
227
+ }
228
+
229
+ async function logError(path: string, error: unknown): Promise<void> {
230
+ await logMessage(path, error instanceof Error ? (error.stack ?? error.message) : String(error));
231
+ }
232
+
233
+ async function logMessage(path: string, message: string): Promise<void> {
234
+ await mkdir(dirname(path), { recursive: true });
235
+ await appendFile(path, `${new Date().toISOString()} ${message}\n`, "utf8");
236
+ }
237
+
238
+ function argumentValue(name: string): string | undefined {
239
+ const index = process.argv.indexOf(name);
240
+ return index >= 0 ? process.argv[index + 1] : undefined;
241
+ }
242
+
243
+ const configFile = argumentValue("--service-config");
244
+ if (configFile) {
245
+ void runCliServiceHost(configFile).catch(async (error: unknown) => {
246
+ await logError(join(dirname(configFile), "host.log"), error);
247
+ process.exitCode = 1;
248
+ });
249
+ }
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import * as projectUtils from "@dbx-tools/core/project-utils";
7
+
8
+ export interface ServicePackage {
9
+ readonly directory: string;
10
+ readonly name: string;
11
+ readonly version: string;
12
+ bin(name?: string): string;
13
+ dependencies(): Readonly<Record<string, string>>;
14
+ }
15
+
16
+ export function resolveServicePackage(
17
+ reference: string,
18
+ cwd: string = process.cwd(),
19
+ ): ServicePackage {
20
+ const local = localManifest(reference);
21
+ if (local) return servicePackage(local);
22
+
23
+ const name = reference;
24
+ const installed = installedManifest(name);
25
+ if (installed) return servicePackage(installed, name);
26
+
27
+ const root = projectUtils.root(cwd);
28
+ if (!root) throw new Error(`could not resolve package: ${name}`);
29
+ const manifest = readRecord(join(root, "package.json"));
30
+ const workspaces = Array.isArray(manifest.workspaces) ? manifest.workspaces : [];
31
+ for (const member of workspaces) {
32
+ if (typeof member !== "string" || /[*?[\]{}]/.test(member)) continue;
33
+ const path = join(root, member, "package.json");
34
+ if (!existsSync(path)) continue;
35
+ const candidate = readRecord(path);
36
+ if (candidate.name === name) return servicePackage(path, name);
37
+ }
38
+ throw new Error(`could not resolve installed or workspace package: ${name}`);
39
+ }
40
+
41
+ export function servicePackageDefaults(pkg: ServicePackage): {
42
+ id: string;
43
+ name: string;
44
+ } {
45
+ const segments = pkg.name.replace(/^@/, "").split("/");
46
+ const rawName = segments.at(-1)!;
47
+ const rawScope = segments.length > 1 ? segments[0]!.split("-")[0]! : "";
48
+ const name = rawName.replace(/^cli-/, "").replaceAll("-", " ");
49
+ return {
50
+ id: pkg.name
51
+ .replace(/^@/, "")
52
+ .replaceAll("/", ".")
53
+ .replace(/[^a-z0-9._-]+/gi, "-"),
54
+ name: [rawScope, name].filter(Boolean).join(" "),
55
+ };
56
+ }
57
+
58
+ function localManifest(reference: string): string | undefined {
59
+ let path: string;
60
+ if (reference.startsWith("file:")) {
61
+ path = fileURLToPath(reference);
62
+ } else if (isAbsolute(reference)) {
63
+ path = reference;
64
+ } else {
65
+ return undefined;
66
+ }
67
+ let current = existsSync(path) ? dirname(path) : path;
68
+ while (true) {
69
+ const manifest = join(current, "package.json");
70
+ if (existsSync(manifest)) return manifest;
71
+ const parent = dirname(current);
72
+ if (parent === current) return undefined;
73
+ current = parent;
74
+ }
75
+ }
76
+
77
+ function installedManifest(name: string): string | undefined {
78
+ try {
79
+ return createRequire(import.meta.url).resolve(`${name}/package.json`);
80
+ } catch (error) {
81
+ if ((error as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND") return undefined;
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ function servicePackage(manifestPath: string, expectedName?: string): ServicePackage {
87
+ const manifest = readRecord(manifestPath);
88
+ if (typeof manifest.name !== "string" || !manifest.name) {
89
+ throw new Error(`package has no name: ${manifestPath}`);
90
+ }
91
+ if (expectedName && manifest.name !== expectedName) {
92
+ throw new Error(`resolved package name does not match ${expectedName}: ${manifestPath}`);
93
+ }
94
+ if (typeof manifest.version !== "string" || !manifest.version) {
95
+ throw new Error(`package has no version: ${manifestPath}`);
96
+ }
97
+ const directory = dirname(manifestPath);
98
+ return {
99
+ directory,
100
+ name: manifest.name,
101
+ version: manifest.version,
102
+ bin(name) {
103
+ return resolveBin(directory, manifest.bin, name);
104
+ },
105
+ dependencies() {
106
+ const dependencies = isRecord(manifest.dependencies)
107
+ ? Object.keys(manifest.dependencies)
108
+ : [];
109
+ const require = createRequire(manifestPath);
110
+ return Object.fromEntries(
111
+ dependencies.map((dependency) => {
112
+ const path = require.resolve(`${dependency}/package.json`);
113
+ const resolved = readRecord(path);
114
+ if (typeof resolved.version !== "string" || !resolved.version) {
115
+ throw new Error(`dependency has no version: ${path}`);
116
+ }
117
+ return [dependency, resolved.version];
118
+ }),
119
+ );
120
+ },
121
+ };
122
+ }
123
+
124
+ function resolveBin(directory: string, value: unknown, name?: string): string {
125
+ if (typeof value === "string") {
126
+ if (name) throw new Error(`package exposes one unnamed bin, not ${name}`);
127
+ return resolve(directory, value);
128
+ }
129
+ if (!isRecord(value)) throw new Error("package has no bin");
130
+ if (name) {
131
+ const target = value[name];
132
+ if (typeof target !== "string") throw new Error(`package has no bin named ${name}`);
133
+ return resolve(directory, target);
134
+ }
135
+ const targets = Object.values(value).filter(
136
+ (target): target is string => typeof target === "string",
137
+ );
138
+ if (targets.length !== 1) {
139
+ throw new Error("package has multiple bins; command.binName is required");
140
+ }
141
+ return resolve(directory, targets[0]!);
142
+ }
143
+
144
+ function readRecord(path: string): Record<string, unknown> {
145
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"));
146
+ if (!isRecord(value)) throw new Error(`package manifest is not an object: ${path}`);
147
+ return value;
148
+ }
149
+
150
+ function isRecord(value: unknown): value is Record<string, unknown> {
151
+ return value !== null && typeof value === "object" && !Array.isArray(value);
152
+ }
package/src/_paths.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+
5
+ import type { CliServiceDefinition } from "./definition.ts";
6
+
7
+ export interface ServiceRuntimeContext {
8
+ readonly platform: NodeJS.Platform;
9
+ readonly environment: Readonly<NodeJS.ProcessEnv>;
10
+ readonly homeDirectory: string;
11
+ readonly temporaryDirectory: string;
12
+ }
13
+
14
+ export interface ServicePaths {
15
+ readonly directory: string;
16
+ readonly configFile: string;
17
+ readonly hostLog: string;
18
+ readonly processLog: string;
19
+ readonly controlAddress: string;
20
+ readonly startupFile: string;
21
+ }
22
+
23
+ export function defaultRuntimeContext(): ServiceRuntimeContext {
24
+ return {
25
+ platform: process.platform,
26
+ environment: process.env,
27
+ homeDirectory: homedir(),
28
+ temporaryDirectory: tmpdir(),
29
+ };
30
+ }
31
+
32
+ export function resolveServicePaths(
33
+ definition: CliServiceDefinition,
34
+ runtime: ServiceRuntimeContext,
35
+ ): ServicePaths {
36
+ const directory = definition.dataDirectory
37
+ ? resolve(definition.dataDirectory)
38
+ : join(configurationRoot(runtime), definition.id);
39
+ return {
40
+ directory,
41
+ configFile: join(directory, "service.json"),
42
+ hostLog: join(directory, "host.log"),
43
+ processLog: join(directory, "service.log"),
44
+ controlAddress: controlAddress(definition.id, runtime),
45
+ startupFile: startupFile(definition.id, runtime),
46
+ };
47
+ }
48
+
49
+ function configurationRoot(runtime: ServiceRuntimeContext): string {
50
+ if (runtime.platform === "darwin") {
51
+ return join(runtime.homeDirectory, "Library", "Application Support");
52
+ }
53
+ if (runtime.platform === "win32") {
54
+ return resolveEnvironmentPath(
55
+ runtime.environment.APPDATA,
56
+ join(runtime.homeDirectory, "AppData", "Roaming"),
57
+ );
58
+ }
59
+ return resolveEnvironmentPath(
60
+ runtime.environment.XDG_CONFIG_HOME,
61
+ join(runtime.homeDirectory, ".config"),
62
+ );
63
+ }
64
+
65
+ function controlAddress(id: string, runtime: ServiceRuntimeContext): string {
66
+ const token = createHash("sha256").update(id).digest("hex").slice(0, 16);
67
+ if (runtime.platform === "win32") {
68
+ return `\\\\.\\pipe\\dbx-tools-cli-service-${token}`;
69
+ }
70
+ return join(runtime.temporaryDirectory, `dbx-tools-cli-service-${token}.sock`);
71
+ }
72
+
73
+ function startupFile(id: string, runtime: ServiceRuntimeContext): string {
74
+ if (runtime.platform === "darwin") {
75
+ return join(runtime.homeDirectory, "Library", "LaunchAgents", `${id}.plist`);
76
+ }
77
+ if (runtime.platform === "win32") {
78
+ return join(
79
+ resolveEnvironmentPath(
80
+ runtime.environment.APPDATA,
81
+ join(runtime.homeDirectory, "AppData", "Roaming"),
82
+ ),
83
+ "Microsoft",
84
+ "Windows",
85
+ "Start Menu",
86
+ "Programs",
87
+ "Startup",
88
+ `${id}.cmd`,
89
+ );
90
+ }
91
+ return join(
92
+ resolveEnvironmentPath(
93
+ runtime.environment.XDG_CONFIG_HOME,
94
+ join(runtime.homeDirectory, ".config"),
95
+ ),
96
+ "autostart",
97
+ `${id}.desktop`,
98
+ );
99
+ }
100
+
101
+ function resolveEnvironmentPath(value: string | undefined, fallback: string): string {
102
+ if (!value) return fallback;
103
+ return isAbsolute(value) ? value : resolve(value);
104
+ }
@@ -0,0 +1,66 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import * as exec from "@dbx-tools/core/exec";
5
+
6
+ export function externalRuntimePackages(dependencies: Readonly<Record<string, string>>): string[] {
7
+ return Object.keys(dependencies).filter((name) => !name.startsWith("@dbx-tools/"));
8
+ }
9
+
10
+ export async function installServiceRuntime(
11
+ bunExecutable: string,
12
+ directory: string,
13
+ dependencies: Readonly<Record<string, string>>,
14
+ ): Promise<void> {
15
+ await mkdir(directory, { recursive: true });
16
+ const manifestPath = join(directory, "package.json");
17
+ const current = await readOptionalRecord(manifestPath);
18
+ await writeFile(
19
+ manifestPath,
20
+ `${JSON.stringify(
21
+ {
22
+ ...current,
23
+ name: "@dbx-tools/service-runtime",
24
+ private: true,
25
+ dependencies: {
26
+ ...stringRecord(current.dependencies),
27
+ ...dependencies,
28
+ },
29
+ },
30
+ null,
31
+ 2,
32
+ )}\n`,
33
+ "utf8",
34
+ );
35
+ await exec.spawn(bunExecutable, ["install", "--production", "--cwd", directory], {
36
+ check: true,
37
+ cwd: directory,
38
+ stdin: "ignore",
39
+ stdout: "capture",
40
+ stderr: "capture",
41
+ });
42
+ }
43
+
44
+ async function readOptionalRecord(path: string): Promise<Record<string, unknown>> {
45
+ try {
46
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
47
+ if (!isRecord(value)) throw new Error(`JSON file is not an object: ${path}`);
48
+ return value;
49
+ } catch (error) {
50
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ function stringRecord(value: unknown): Record<string, string> {
56
+ if (!isRecord(value)) return {};
57
+ return Object.fromEntries(
58
+ Object.entries(value).filter(
59
+ (entry): entry is [string, string] => typeof entry[1] === "string",
60
+ ),
61
+ );
62
+ }
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return value !== null && typeof value === "object" && !Array.isArray(value);
66
+ }
@@ -0,0 +1,106 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ import type { ServicePaths, ServiceRuntimeContext } from "./_paths.ts";
5
+ import type { CliServiceDefinition } from "./definition.ts";
6
+
7
+ export interface ServiceLaunch {
8
+ readonly executable: string;
9
+ readonly arguments: readonly string[];
10
+ }
11
+
12
+ export async function installStartup(
13
+ definition: CliServiceDefinition,
14
+ paths: ServicePaths,
15
+ runtime: ServiceRuntimeContext,
16
+ launch: ServiceLaunch,
17
+ ): Promise<void> {
18
+ await mkdir(dirname(paths.startupFile), { recursive: true });
19
+ const content =
20
+ runtime.platform === "darwin"
21
+ ? renderLaunchAgent(definition, launch)
22
+ : runtime.platform === "win32"
23
+ ? renderWindowsStartup(launch)
24
+ : renderDesktopEntry(definition, launch);
25
+ await writeFile(paths.startupFile, content, {
26
+ encoding: "utf8",
27
+ mode: runtime.platform === "win32" ? undefined : 0o644,
28
+ });
29
+ }
30
+
31
+ export async function removeStartup(paths: ServicePaths): Promise<void> {
32
+ await rm(paths.startupFile, { force: true });
33
+ }
34
+
35
+ export function renderLaunchAgent(definition: CliServiceDefinition, launch: ServiceLaunch): string {
36
+ const argumentsXml = [launch.executable, ...launch.arguments]
37
+ .map((argument) => ` <string>${escapeXml(argument)}</string>`)
38
+ .join("\n");
39
+ return [
40
+ '<?xml version="1.0" encoding="UTF-8"?>',
41
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
42
+ '<plist version="1.0">',
43
+ " <dict>",
44
+ " <key>Label</key>",
45
+ ` <string>${escapeXml(definition.id)}</string>`,
46
+ " <key>ProgramArguments</key>",
47
+ " <array>",
48
+ argumentsXml,
49
+ " </array>",
50
+ " <key>RunAtLoad</key>",
51
+ " <true/>",
52
+ " <key>ProcessType</key>",
53
+ " <string>Interactive</string>",
54
+ " </dict>",
55
+ "</plist>",
56
+ "",
57
+ ].join("\n");
58
+ }
59
+
60
+ export function renderDesktopEntry(
61
+ definition: CliServiceDefinition,
62
+ launch: ServiceLaunch,
63
+ ): string {
64
+ return [
65
+ "[Desktop Entry]",
66
+ "Type=Application",
67
+ `Name=${escapeDesktopValue(definition.name)}`,
68
+ `Exec=${[launch.executable, ...launch.arguments].map(quoteDesktopArgument).join(" ")}`,
69
+ "Terminal=false",
70
+ "X-GNOME-Autostart-enabled=true",
71
+ "",
72
+ ].join("\n");
73
+ }
74
+
75
+ export function renderWindowsStartup(launch: ServiceLaunch): string {
76
+ const command = [launch.executable, ...launch.arguments].map(quoteWindowsArgument).join(" ");
77
+ return `@echo off\r\nstart "" /b ${command}\r\n`;
78
+ }
79
+
80
+ function escapeDesktopValue(value: string): string {
81
+ return value.replaceAll("\\", "\\\\").replaceAll("\n", "\\n");
82
+ }
83
+
84
+ function escapeXml(value: string): string {
85
+ return value
86
+ .replaceAll("&", "&amp;")
87
+ .replaceAll("<", "&lt;")
88
+ .replaceAll(">", "&gt;")
89
+ .replaceAll('"', "&quot;")
90
+ .replaceAll("'", "&apos;");
91
+ }
92
+
93
+ function quoteDesktopArgument(value: string): string {
94
+ return `"${value
95
+ .replaceAll("\\", "\\\\")
96
+ .replaceAll('"', '\\"')
97
+ .replaceAll("`", "\\`")
98
+ .replaceAll("$", "\\$")}"`;
99
+ }
100
+
101
+ function quoteWindowsArgument(value: string): string {
102
+ const escapedPercent = value.replaceAll("%", "%%");
103
+ const escapedQuotes = escapedPercent.replace(/(\\*)"/g, '$1$1\\"');
104
+ const escapedTrailingSlashes = escapedQuotes.replace(/(\\+)$/, "$1$1");
105
+ return `"${escapedTrailingSlashes}"`;
106
+ }