artifacty 0.6.0 → 0.7.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/README.md CHANGED
@@ -46,7 +46,7 @@ artifacty stop
46
46
  ```
47
47
 
48
48
  `artifacty start` and `artifacty serve --detach` use the same lifecycle path as `artifacty serve`. Logs are written under `~/.artifacty/logs/`.
49
- These lifecycle commands use Node's detached process support and work on macOS, Linux, and Windows. `artifacty stop` uses Windows `taskkill` on Windows and process-group signals on macOS/Linux.
49
+ These lifecycle commands use Node's detached process support and work on macOS, Linux, and Windows. `artifacty stop` uses process-group signals on macOS/Linux and Windows `taskkill`, falling back to `/F` when Windows requires forceful termination.
50
50
 
51
51
  For foreground debugging, keep the process attached:
52
52
 
@@ -199,6 +199,8 @@ artifacty start
199
199
  artifacty status
200
200
  artifacty stop
201
201
  artifacty service install --dry-run
202
+ artifacty service unit --dry-run
203
+ artifacty service task --dry-run
202
204
  ```
203
205
 
204
206
  When working from a source checkout without global installation, replace `artifacty` with `node src/cli.js` and `artifacty-mcp` with `node src/mcp-server.js`.
@@ -34,10 +34,10 @@ node src/cli.js stop
34
34
  The lifecycle commands are intended to be cross-platform:
35
35
 
36
36
  - macOS and Linux: `stop` signals the detached process group first, then falls back to the server process id.
37
- - Windows: `start` hides the child console window, and `stop` uses `taskkill /PID <pid> /T`; `--force` adds `/F`.
37
+ - Windows: `start` hides the child console window, and `stop` uses `taskkill /PID <pid> /T`, falling back to `/F` when Windows requires forceful termination. `--force` uses `/F` immediately.
38
38
  - All platforms: `status` combines the managed pid file with the HTTP `/health` endpoint, so a stale pid alone is not reported as healthy.
39
39
 
40
- For login/startup persistence, use the operating system's service manager. Artifacty's `service` command currently generates a macOS LaunchAgent plist; Linux systemd user units and Windows Task Scheduler/Service wrappers should be configured explicitly until first-class installers are added.
40
+ For login/startup persistence, use the operating system's service manager. Artifacty's `service` command can generate macOS LaunchAgent, Linux systemd user-unit, and Windows Task Scheduler definitions.
41
41
 
42
42
  Create artifacts directly in the browser at `http://127.0.0.1:8787/new`.
43
43
 
@@ -299,17 +299,33 @@ Renderer notes:
299
299
 
300
300
  ## Background Service
301
301
 
302
- Generate or install a macOS LaunchAgent plist:
302
+ Generate platform-specific service definitions:
303
+
304
+ ```bash
305
+ node src/cli.js service plist --dry-run
306
+ node src/cli.js service unit --dry-run
307
+ node src/cli.js service task --dry-run
308
+ ```
309
+
310
+ Install the definition for the current OS:
303
311
 
304
312
  ```bash
305
- node src/cli.js service plist
306
313
  node src/cli.js service install --dry-run
307
314
  node src/cli.js service install
315
+ node src/cli.js service uninstall --dry-run
308
316
  ```
309
317
 
310
- The generated service runs `src/server.js` with explicit `--host` and `--home` arguments. It includes `--port` only when you configure a port, which keeps the default port fallback available. Load or unload it manually with the `launchctl` commands returned by `service install`.
318
+ Use `--platform macos|linux|windows` when preparing a definition for another host. Use `--path` to choose the output path, or the platform-specific aliases `--plist`, `--unit`, and `--script`.
319
+
320
+ The generated service runs `src/server.js` with explicit `--host` and `--home` arguments. It includes `--port` only when you configure a port, which keeps the default port fallback available. `--api-token`, `--share-mode`, and `--allow-secrets` are preserved in the generated definition when explicitly provided.
321
+
322
+ Activation is still delegated to the OS service manager:
323
+
324
+ - macOS: `launchctl load ~/Library/LaunchAgents/com.artifacty.server.plist`
325
+ - Linux: `systemctl --user enable --now com.artifacty.server.service`
326
+ - Windows: run the generated PowerShell script, which registers and starts the `ArtifactyServer` scheduled task.
311
327
 
312
- For background services, prefer a stable `ARTIFACTY_API_TOKEN` in the service environment. `serve --generate-token` is intended for temporary interactive sessions; the parent CLI returns the generated token in JSON.
328
+ For background services, prefer a stable `ARTIFACTY_API_TOKEN` or `--api-token` value. `serve --generate-token` is intended for temporary interactive sessions; the parent CLI returns the generated token in JSON.
313
329
 
314
330
  ## Backup and Audit
315
331
 
@@ -25,6 +25,7 @@ This runs syntax checks, the full Node test suite, and a local smoke test that s
25
25
  - Require `ARTIFACTY_API_TOKEN` and `ARTIFACTY_SHARE_MODE=lan` or `team` before binding to `0.0.0.0`.
26
26
  - Confirm non-loopback startup output includes the LAN/team warning.
27
27
  - Confirm `artifacty serve` starts a managed background server and returns prompt-friendly JSON; use `artifacty serve --foreground` for attached log checks.
28
+ - Confirm `artifacty service plist|unit|task --dry-run` renders macOS, Linux, and Windows service definitions.
28
29
  - Prefer `x-artifacty-token` or `Authorization: Bearer <token>` over query tokens in scripts.
29
30
  - Review secret-scan bypasses. `--allow-secrets` and `ARTIFACTY_ALLOW_SECRETS=true` should be deliberate and temporary.
30
31
  - Review [../SECURITY.md](../SECURITY.md) and [threat-model.md](threat-model.md) when changing auth, rendering, MCP, or network-sharing behavior.
@@ -46,4 +47,4 @@ This runs syntax checks, the full Node test suite, and a local smoke test that s
46
47
 
47
48
  - Export a backup before upgrades: `artifacty backup`.
48
49
  - Confirm `artifacty audit --limit 20` shows recent create/update/read/archive events.
49
- - For macOS background service installs, dry-run first: `artifacty service install --dry-run`.
50
+ - For background service installs, dry-run first: `artifacty service install --dry-run`. Use `--platform macos|linux|windows` to review another OS definition.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -28,7 +28,7 @@
28
28
  "start": "node src/server.js",
29
29
  "mcp": "node src/mcp-server.js",
30
30
  "test": "node --test",
31
- "lint": "node --check src/*.js src/lib/*.js src/client/*.js test/*.test.js",
31
+ "lint": "node scripts/lint.mjs",
32
32
  "smoke": "bash scripts/smoke.sh",
33
33
  "release:check": "npm run lint && npm test && npm run smoke"
34
34
  },
@@ -42,6 +42,7 @@
42
42
  "docs/release-checklist.md",
43
43
  "docs/sarif-csv-artifact-plan.md",
44
44
  "docs/threat-model.md",
45
+ "scripts/lint.mjs",
45
46
  "scripts/smoke.sh",
46
47
  "README.md",
47
48
  "LICENSE",
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { readdir } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ const targets = [
7
+ { dir: "src", pattern: /\.js$/ },
8
+ { dir: path.join("src", "lib"), pattern: /\.js$/ },
9
+ { dir: path.join("src", "client"), pattern: /\.js$/ },
10
+ { dir: "test", pattern: /\.test\.js$/ }
11
+ ];
12
+
13
+ const files = [];
14
+
15
+ for (const target of targets) {
16
+ const entries = await readdir(target.dir, { withFileTypes: true });
17
+ for (const entry of entries) {
18
+ if (entry.isFile() && target.pattern.test(entry.name)) {
19
+ files.push(path.join(target.dir, entry.name));
20
+ }
21
+ }
22
+ }
23
+
24
+ files.sort();
25
+
26
+ for (const file of files) {
27
+ const result = spawnSync(process.execPath, ["--check", file], {
28
+ stdio: "inherit"
29
+ });
30
+ if (result.status !== 0) {
31
+ process.exit(result.status || 1);
32
+ }
33
+ }
package/src/cli.js CHANGED
@@ -280,6 +280,13 @@ async function main() {
280
280
  projectDir: options.projectDir || PACKAGE_ROOT,
281
281
  serverPath: options.serverPath,
282
282
  plistPath: options.plist,
283
+ unitPath: options.unit,
284
+ scriptPath: options.script,
285
+ servicePath: options.path,
286
+ platform: options.platform,
287
+ apiToken: options.apiToken,
288
+ shareMode: options.shareMode,
289
+ allowSecrets: options.allowSecrets,
283
290
  host: options.host,
284
291
  port: options.port,
285
292
  home: options.home,
@@ -418,7 +425,7 @@ Usage:
418
425
  artifacty export --file <path>
419
426
  artifacty backup [--file <path>]
420
427
  artifacty import-store --file <path>
421
- artifacty service plist|install|uninstall [--dry-run] [--plist <path>]
428
+ artifacty service plist|unit|task|install|uninstall [--platform macos|linux|windows] [--dry-run] [--path <path>]
422
429
  artifacty list [--query text] [--tag tag] [--source agent] [--limit 50] [--offset 0] [--include-archived]
423
430
  artifacty show <id> [--version n] [--raw]
424
431
 
@@ -248,10 +248,20 @@ function buildServerEnv(options) {
248
248
  async function terminatePid(pid, options = {}) {
249
249
  const command = stopCommandForPlatform(pid, options);
250
250
  if (command) {
251
- await execFileQuiet(command.command, command.args).catch((error) => {
251
+ await execFileQuiet(command.command, command.args).catch(async (error) => {
252
252
  if (!isPidRunning(pid)) {
253
253
  return;
254
254
  }
255
+ if (!options.force) {
256
+ const forced = stopCommandForPlatform(pid, { force: true });
257
+ await execFileQuiet(forced.command, forced.args).catch((forcedError) => {
258
+ if (!isPidRunning(pid)) {
259
+ return;
260
+ }
261
+ throw new Error(`Failed to stop Windows process ${pid}: ${forcedError.stderr || forcedError.message}`);
262
+ });
263
+ return;
264
+ }
255
265
  throw new Error(`Failed to stop Windows process ${pid}: ${error.stderr || error.message}`);
256
266
  });
257
267
  return;
@@ -1,67 +1,70 @@
1
1
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
- import { homedir } from "node:os";
3
+ import { homedir, platform as osPlatform } from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  const DEFAULT_LABEL = "com.artifacty.server";
7
+ const DEFAULT_TASK_NAME = "ArtifactyServer";
7
8
 
8
9
  export async function serviceCommand(action, options = {}) {
9
- const plistPath = path.resolve(options.plistPath || path.join(homedir(), "Library", "LaunchAgents", `${DEFAULT_LABEL}.plist`));
10
- const plist = createLaunchAgentPlist(options);
10
+ const definition = serviceDefinition(action, options);
11
11
 
12
- if (action === "plist") {
13
- return { action, path: plistPath, content: plist, dryRun: true };
12
+ if (definition.renderOnly) {
13
+ return {
14
+ action,
15
+ platform: definition.platform,
16
+ path: definition.path,
17
+ content: definition.content,
18
+ dryRun: true,
19
+ nextSteps: definition.nextSteps
20
+ };
14
21
  }
15
22
 
16
23
  if (action === "install") {
17
- const existing = await readFile(plistPath, "utf8").catch(() => "");
18
- const changed = existing !== plist;
24
+ const existing = await readFile(definition.path, "utf8").catch(() => "");
25
+ const changed = existing !== definition.content;
19
26
  if (!options.dryRun) {
20
- await mkdir(path.dirname(plistPath), { recursive: true });
21
- await writeFile(plistPath, plist, "utf8");
27
+ await mkdir(path.dirname(definition.path), { recursive: true });
28
+ await writeFile(definition.path, definition.content, "utf8");
22
29
  }
23
30
  return {
24
31
  action,
25
- path: plistPath,
32
+ platform: definition.platform,
33
+ path: definition.path,
26
34
  changed,
27
35
  dryRun: Boolean(options.dryRun),
28
- content: options.dryRun ? plist : undefined,
29
- nextSteps: [
30
- `launchctl load ${plistPath}`,
31
- `launchctl unload ${plistPath}`
32
- ]
36
+ content: options.dryRun ? definition.content : undefined,
37
+ nextSteps: definition.nextSteps
33
38
  };
34
39
  }
35
40
 
36
41
  if (action === "uninstall") {
37
- const existed = existsSync(plistPath);
42
+ const existed = existsSync(definition.path);
38
43
  if (!options.dryRun) {
39
- await rm(plistPath, { force: true });
44
+ await rm(definition.path, { force: true });
40
45
  }
41
46
  return {
42
47
  action,
43
- path: plistPath,
48
+ platform: definition.platform,
49
+ path: definition.path,
44
50
  changed: existed,
45
- dryRun: Boolean(options.dryRun)
51
+ dryRun: Boolean(options.dryRun),
52
+ nextSteps: definition.uninstallSteps
46
53
  };
47
54
  }
48
55
 
49
- throw new Error("service requires action: plist, install, or uninstall");
56
+ throw new Error("service requires action: plist, unit, task, install, or uninstall");
50
57
  }
51
58
 
52
59
  export function createLaunchAgentPlist(options = {}) {
53
- const projectDir = path.resolve(options.projectDir || process.cwd());
54
- const nodePath = process.execPath;
55
- const serverPath = path.resolve(options.serverPath || path.join(projectDir, "src", "server.js"));
56
- const host = options.host || process.env.ARTIFACTY_HOST || "127.0.0.1";
57
- const port = options.port || process.env.ARTIFACTY_PORT;
58
- const home = path.resolve(options.home || process.env.ARTIFACTY_HOME || path.join(homedir(), ".artifacty"));
59
- const logDir = path.join(home, "logs");
60
- const portArguments = port
61
- ? ` <string>--port</string>
62
- <string>${escapeXml(port)}</string>
63
- `
64
- : "";
60
+ const config = serviceConfig(options);
61
+ const programArguments = [config.nodePath, ...serverArgs(config)]
62
+ .map((argument) => ` <string>${escapeXml(argument)}</string>`)
63
+ .join("\n");
64
+ const environment = environmentEntries(config)
65
+ .map(([key, value]) => ` <key>${escapeXml(key)}</key>
66
+ <string>${escapeXml(value)}</string>`)
67
+ .join("\n");
65
68
 
66
69
  return `<?xml version="1.0" encoding="UTF-8"?>
67
70
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -71,31 +74,227 @@ export function createLaunchAgentPlist(options = {}) {
71
74
  <string>${DEFAULT_LABEL}</string>
72
75
  <key>ProgramArguments</key>
73
76
  <array>
74
- <string>${escapeXml(nodePath)}</string>
75
- <string>${escapeXml(serverPath)}</string>
76
- <string>--host</string>
77
- <string>${escapeXml(host)}</string>
78
- ${portArguments} <string>--home</string>
79
- <string>${escapeXml(home)}</string>
77
+ ${programArguments}
80
78
  </array>
81
79
  <key>EnvironmentVariables</key>
82
80
  <dict>
83
- <key>ARTIFACTY_HOME</key>
84
- <string>${escapeXml(home)}</string>
81
+ ${environment}
85
82
  </dict>
86
83
  <key>RunAtLoad</key>
87
84
  <true/>
88
85
  <key>KeepAlive</key>
89
86
  <true/>
90
87
  <key>StandardOutPath</key>
91
- <string>${escapeXml(path.join(logDir, "server.out.log"))}</string>
88
+ <string>${escapeXml(path.join(config.logDir, "server.out.log"))}</string>
92
89
  <key>StandardErrorPath</key>
93
- <string>${escapeXml(path.join(logDir, "server.err.log"))}</string>
90
+ <string>${escapeXml(path.join(config.logDir, "server.err.log"))}</string>
94
91
  </dict>
95
92
  </plist>
96
93
  `;
97
94
  }
98
95
 
96
+ export function createSystemdUserUnit(options = {}) {
97
+ const config = serviceConfig(options);
98
+ const environment = environmentEntries(config)
99
+ .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
100
+ .join("\n");
101
+ const execStart = [config.nodePath, ...serverArgs(config)]
102
+ .map(systemdQuote)
103
+ .join(" ");
104
+
105
+ return `[Unit]
106
+ Description=Artifacty local artifact server
107
+ After=network.target
108
+
109
+ [Service]
110
+ Type=simple
111
+ ${environment}
112
+ ExecStart=${execStart}
113
+ Restart=on-failure
114
+ RestartSec=2
115
+
116
+ [Install]
117
+ WantedBy=default.target
118
+ `;
119
+ }
120
+
121
+ export function createWindowsTaskScript(options = {}) {
122
+ const config = serviceConfig(options);
123
+ const argumentsLine = serverArgs(config, { includeApiTokenArg: true })
124
+ .map(quoteWindowsArg)
125
+ .join(" ");
126
+
127
+ return `$ErrorActionPreference = 'Stop'
128
+
129
+ $taskName = ${powershellString(config.taskName)}
130
+ $node = ${powershellString(config.nodePath)}
131
+ $arguments = ${powershellString(argumentsLine)}
132
+ $description = 'Artifacty local artifact server'
133
+
134
+ $action = New-ScheduledTaskAction -Execute $node -Argument $arguments
135
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
136
+ $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
137
+
138
+ Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Description $description -Force | Out-Null
139
+ Start-ScheduledTask -TaskName $taskName
140
+
141
+ Write-Host "Installed and started scheduled task '$taskName'."
142
+ Write-Host "Stop: schtasks /End /TN $taskName"
143
+ Write-Host "Remove: schtasks /Delete /TN $taskName /F"
144
+ `;
145
+ }
146
+
147
+ function serviceDefinition(action, options = {}) {
148
+ if (action === "plist") {
149
+ return definitionForPlatform("macos", options, true);
150
+ }
151
+ if (action === "unit") {
152
+ return definitionForPlatform("linux", options, true);
153
+ }
154
+ if (action === "task") {
155
+ return definitionForPlatform("windows", options, true);
156
+ }
157
+ if (action === "install" || action === "uninstall") {
158
+ return definitionForPlatform(normalizePlatform(options.platform), options, false);
159
+ }
160
+ throw new Error("service requires action: plist, unit, task, install, or uninstall");
161
+ }
162
+
163
+ function definitionForPlatform(platform, options = {}, renderOnly) {
164
+ const config = serviceConfig({ ...options, platform });
165
+ if (platform === "macos") {
166
+ return {
167
+ platform,
168
+ renderOnly,
169
+ path: servicePath(platform, options),
170
+ content: createLaunchAgentPlist(options),
171
+ nextSteps: [
172
+ `launchctl load ${shellQuote(servicePath(platform, options))}`,
173
+ `launchctl unload ${shellQuote(servicePath(platform, options))}`
174
+ ],
175
+ uninstallSteps: [
176
+ `launchctl unload ${shellQuote(servicePath(platform, options))}`
177
+ ]
178
+ };
179
+ }
180
+ if (platform === "linux") {
181
+ return {
182
+ platform,
183
+ renderOnly,
184
+ path: servicePath(platform, options),
185
+ content: createSystemdUserUnit(options),
186
+ nextSteps: [
187
+ "systemctl --user daemon-reload",
188
+ `systemctl --user enable --now ${path.basename(servicePath(platform, options))}`,
189
+ `systemctl --user status ${path.basename(servicePath(platform, options))}`,
190
+ `journalctl --user -u ${path.basename(servicePath(platform, options))} -f`,
191
+ `systemctl --user disable --now ${path.basename(servicePath(platform, options))}`
192
+ ],
193
+ uninstallSteps: [
194
+ "systemctl --user daemon-reload",
195
+ `systemctl --user disable --now ${path.basename(servicePath(platform, options))}`
196
+ ]
197
+ };
198
+ }
199
+ if (platform === "windows") {
200
+ return {
201
+ platform,
202
+ renderOnly,
203
+ path: servicePath(platform, options),
204
+ content: createWindowsTaskScript(options),
205
+ nextSteps: [
206
+ `powershell -ExecutionPolicy Bypass -File ${quoteWindowsArg(servicePath(platform, options))}`,
207
+ `schtasks /Query /TN ${config.taskName}`,
208
+ `schtasks /End /TN ${config.taskName}`,
209
+ `schtasks /Delete /TN ${config.taskName} /F`
210
+ ],
211
+ uninstallSteps: [
212
+ `schtasks /Delete /TN ${config.taskName} /F`
213
+ ]
214
+ };
215
+ }
216
+ throw new Error(`Unsupported service platform: ${platform}`);
217
+ }
218
+
219
+ function serviceConfig(options = {}) {
220
+ const projectDir = path.resolve(options.projectDir || process.cwd());
221
+ const home = path.resolve(options.home || process.env.ARTIFACTY_HOME || path.join(homedir(), ".artifacty"));
222
+ return {
223
+ projectDir,
224
+ nodePath: path.resolve(options.nodePath || process.execPath),
225
+ serverPath: path.resolve(options.serverPath || path.join(projectDir, "src", "server.js")),
226
+ host: options.host || process.env.ARTIFACTY_HOST || "127.0.0.1",
227
+ port: options.port !== undefined && options.port !== null ? String(options.port) : process.env.ARTIFACTY_PORT || "",
228
+ home,
229
+ logDir: path.join(home, "logs"),
230
+ apiToken: options.apiToken || "",
231
+ shareMode: options.shareMode || "",
232
+ allowSecrets: Boolean(options.allowSecrets),
233
+ taskName: options.taskName || DEFAULT_TASK_NAME
234
+ };
235
+ }
236
+
237
+ function serverArgs(config, options = {}) {
238
+ const args = [
239
+ config.serverPath,
240
+ "--host",
241
+ config.host,
242
+ "--home",
243
+ config.home
244
+ ];
245
+ if (config.port) {
246
+ args.push("--port", String(config.port));
247
+ }
248
+ if (config.shareMode) {
249
+ args.push("--share-mode", config.shareMode);
250
+ }
251
+ if (config.allowSecrets) {
252
+ args.push("--allow-secrets");
253
+ }
254
+ if (options.includeApiTokenArg && config.apiToken) {
255
+ args.push("--api-token", config.apiToken);
256
+ }
257
+ return args;
258
+ }
259
+
260
+ function environmentEntries(config) {
261
+ const entries = [["ARTIFACTY_HOME", config.home]];
262
+ if (config.apiToken) {
263
+ entries.push(["ARTIFACTY_API_TOKEN", config.apiToken]);
264
+ }
265
+ return entries;
266
+ }
267
+
268
+ function servicePath(platform, options = {}) {
269
+ if (options.servicePath || options.path) {
270
+ return path.resolve(options.servicePath || options.path);
271
+ }
272
+ if (platform === "macos") {
273
+ return path.resolve(options.plistPath || path.join(homedir(), "Library", "LaunchAgents", `${DEFAULT_LABEL}.plist`));
274
+ }
275
+ if (platform === "linux") {
276
+ return path.resolve(options.unitPath || path.join(homedir(), ".config", "systemd", "user", `${DEFAULT_LABEL}.service`));
277
+ }
278
+ if (platform === "windows") {
279
+ const localAppData = process.env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local");
280
+ return path.resolve(options.scriptPath || path.join(localAppData, "Artifacty", "install-artifacty-server-task.ps1"));
281
+ }
282
+ throw new Error(`Unsupported service platform: ${platform}`);
283
+ }
284
+
285
+ function normalizePlatform(value = osPlatform()) {
286
+ if (value === "darwin" || value === "mac" || value === "macos") {
287
+ return "macos";
288
+ }
289
+ if (value === "win32" || value === "windows" || value === "win") {
290
+ return "windows";
291
+ }
292
+ if (value === "linux") {
293
+ return "linux";
294
+ }
295
+ throw new Error("service platform must be macos, linux, or windows");
296
+ }
297
+
99
298
  function escapeXml(value) {
100
299
  return String(value)
101
300
  .replaceAll("&", "&amp;")
@@ -103,3 +302,23 @@ function escapeXml(value) {
103
302
  .replaceAll(">", "&gt;")
104
303
  .replaceAll('"', "&quot;");
105
304
  }
305
+
306
+ function systemdQuote(value) {
307
+ return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
308
+ }
309
+
310
+ function powershellString(value) {
311
+ return `'${String(value).replaceAll("'", "''")}'`;
312
+ }
313
+
314
+ function quoteWindowsArg(value) {
315
+ const text = String(value);
316
+ if (!/[\s"]/.test(text)) {
317
+ return text;
318
+ }
319
+ return `"${text.replaceAll('"', '\\"')}"`;
320
+ }
321
+
322
+ function shellQuote(value) {
323
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
324
+ }