artifacty 0.6.0 → 0.8.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 +11 -1
- package/docs/integrations.md +32 -6
- package/docs/release-checklist.md +9 -1
- package/package.json +3 -2
- package/scripts/lint.mjs +33 -0
- package/src/cli.js +32 -2
- package/src/lib/background.js +11 -1
- package/src/lib/doctor.js +214 -0
- package/src/lib/service.js +261 -42
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`
|
|
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
|
|
|
@@ -82,6 +82,13 @@ artifacty install all
|
|
|
82
82
|
artifacty check
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Run diagnostics for the local runtime, store, server, service definitions, and MCP discovery:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
artifacty doctor
|
|
89
|
+
artifacty doctor --skip-mcp
|
|
90
|
+
```
|
|
91
|
+
|
|
85
92
|
Use `artifacty install codex --timeout 30000` or
|
|
86
93
|
`artifacty install gemini --timeout 30000` to tune supported MCP client timeouts.
|
|
87
94
|
|
|
@@ -190,6 +197,7 @@ Operational commands:
|
|
|
190
197
|
|
|
191
198
|
```bash
|
|
192
199
|
artifacty audit --limit 20
|
|
200
|
+
artifacty doctor
|
|
193
201
|
artifacty index rebuild
|
|
194
202
|
artifacty integrity
|
|
195
203
|
artifacty backup
|
|
@@ -199,6 +207,8 @@ artifacty start
|
|
|
199
207
|
artifacty status
|
|
200
208
|
artifacty stop
|
|
201
209
|
artifacty service install --dry-run
|
|
210
|
+
artifacty service unit --dry-run
|
|
211
|
+
artifacty service task --dry-run
|
|
202
212
|
```
|
|
203
213
|
|
|
204
214
|
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`.
|
package/docs/integrations.md
CHANGED
|
@@ -34,10 +34,19 @@ 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
|
|
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
|
-
|
|
40
|
+
Run diagnostics when setup behaves unexpectedly:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
node src/cli.js doctor
|
|
44
|
+
node src/cli.js doctor --skip-mcp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`doctor` checks Node version support, local exposure settings, store integrity, managed server health, service definition rendering, and MCP discovery. A stopped server is reported as a warning, not a failure.
|
|
48
|
+
|
|
49
|
+
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
50
|
|
|
42
51
|
Create artifacts directly in the browser at `http://127.0.0.1:8787/new`.
|
|
43
52
|
|
|
@@ -85,6 +94,7 @@ node src/cli.js check
|
|
|
85
94
|
- `--dry-run` returns the generated config without writing it.
|
|
86
95
|
- `--timeout <ms>` adjusts Codex `startup_timeout_sec` and Gemini `timeout`. It does not change Claude Code startup behavior; set `MCP_TIMEOUT` before launching Claude Code if you need a larger value there.
|
|
87
96
|
- `check` starts the local MCP server and verifies required tools, resources, and prompts through MCP discovery methods.
|
|
97
|
+
- `doctor` combines MCP discovery with runtime, storage, server, and service diagnostics.
|
|
88
98
|
|
|
89
99
|
## Client Compatibility Matrix
|
|
90
100
|
|
|
@@ -299,17 +309,33 @@ Renderer notes:
|
|
|
299
309
|
|
|
300
310
|
## Background Service
|
|
301
311
|
|
|
302
|
-
Generate
|
|
312
|
+
Generate platform-specific service definitions:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
node src/cli.js service plist --dry-run
|
|
316
|
+
node src/cli.js service unit --dry-run
|
|
317
|
+
node src/cli.js service task --dry-run
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Install the definition for the current OS:
|
|
303
321
|
|
|
304
322
|
```bash
|
|
305
|
-
node src/cli.js service plist
|
|
306
323
|
node src/cli.js service install --dry-run
|
|
307
324
|
node src/cli.js service install
|
|
325
|
+
node src/cli.js service uninstall --dry-run
|
|
308
326
|
```
|
|
309
327
|
|
|
310
|
-
|
|
328
|
+
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`.
|
|
329
|
+
|
|
330
|
+
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.
|
|
331
|
+
|
|
332
|
+
Activation is still delegated to the OS service manager:
|
|
333
|
+
|
|
334
|
+
- macOS: `launchctl load ~/Library/LaunchAgents/com.artifacty.server.plist`
|
|
335
|
+
- Linux: `systemctl --user enable --now com.artifacty.server.service`
|
|
336
|
+
- Windows: run the generated PowerShell script, which registers and starts the `ArtifactyServer` scheduled task.
|
|
311
337
|
|
|
312
|
-
For background services, prefer a stable `ARTIFACTY_API_TOKEN`
|
|
338
|
+
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
339
|
|
|
314
340
|
## Backup and Audit
|
|
315
341
|
|
|
@@ -10,6 +10,12 @@ npm run release:check
|
|
|
10
10
|
|
|
11
11
|
This runs syntax checks, the full Node test suite, and a local smoke test that starts the HTTP server with token auth enabled, creates an artifact, verifies secret blocking, reads audit logs, writes a backup, and checks MCP tool/resource/prompt discovery.
|
|
12
12
|
|
|
13
|
+
Run the local diagnostics pass:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
artifacty doctor
|
|
17
|
+
```
|
|
18
|
+
|
|
13
19
|
## Packaging
|
|
14
20
|
|
|
15
21
|
- Confirm `package.json` `version` and `files` are intentional.
|
|
@@ -25,6 +31,7 @@ This runs syntax checks, the full Node test suite, and a local smoke test that s
|
|
|
25
31
|
- Require `ARTIFACTY_API_TOKEN` and `ARTIFACTY_SHARE_MODE=lan` or `team` before binding to `0.0.0.0`.
|
|
26
32
|
- Confirm non-loopback startup output includes the LAN/team warning.
|
|
27
33
|
- Confirm `artifacty serve` starts a managed background server and returns prompt-friendly JSON; use `artifacty serve --foreground` for attached log checks.
|
|
34
|
+
- Confirm `artifacty service plist|unit|task --dry-run` renders macOS, Linux, and Windows service definitions.
|
|
28
35
|
- Prefer `x-artifacty-token` or `Authorization: Bearer <token>` over query tokens in scripts.
|
|
29
36
|
- Review secret-scan bypasses. `--allow-secrets` and `ARTIFACTY_ALLOW_SECRETS=true` should be deliberate and temporary.
|
|
30
37
|
- Review [../SECURITY.md](../SECURITY.md) and [threat-model.md](threat-model.md) when changing auth, rendering, MCP, or network-sharing behavior.
|
|
@@ -46,4 +53,5 @@ This runs syntax checks, the full Node test suite, and a local smoke test that s
|
|
|
46
53
|
|
|
47
54
|
- Export a backup before upgrades: `artifacty backup`.
|
|
48
55
|
- Confirm `artifacty audit --limit 20` shows recent create/update/read/archive events.
|
|
49
|
-
-
|
|
56
|
+
- Confirm `artifacty doctor` reports no failures. A stopped server warning is acceptable when intentionally checking an offline store.
|
|
57
|
+
- 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.
|
|
3
|
+
"version": "0.8.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
|
|
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",
|
package/scripts/lint.mjs
ADDED
|
@@ -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
|
@@ -22,6 +22,7 @@ import { serviceCommand } from "./lib/service.js";
|
|
|
22
22
|
import { backgroundStatus, startBackgroundServer, stopBackgroundServer } from "./lib/background.js";
|
|
23
23
|
import { resolvePublicBaseUrl } from "./lib/server-state.js";
|
|
24
24
|
import { generateToken } from "./lib/token.js";
|
|
25
|
+
import { runDoctor } from "./lib/doctor.js";
|
|
25
26
|
import { startServer } from "./server.js";
|
|
26
27
|
|
|
27
28
|
const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
@@ -105,6 +106,27 @@ async function main() {
|
|
|
105
106
|
return;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
if (command === "doctor") {
|
|
110
|
+
const result = await runDoctor({
|
|
111
|
+
packageRoot: PACKAGE_ROOT,
|
|
112
|
+
serverPath: options.serverPath,
|
|
113
|
+
url: options.url,
|
|
114
|
+
home: options.home,
|
|
115
|
+
host: options.host,
|
|
116
|
+
port: options.port,
|
|
117
|
+
apiToken: options.apiToken,
|
|
118
|
+
shareMode: options.shareMode,
|
|
119
|
+
allowSecrets: options.allowSecrets,
|
|
120
|
+
timeout: options.timeout,
|
|
121
|
+
skipMcp: options.skipMcp
|
|
122
|
+
});
|
|
123
|
+
printJson(result);
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
108
130
|
if (command === "publish") {
|
|
109
131
|
const content = await readContent(options);
|
|
110
132
|
const artifact = await createArtifact(store, {
|
|
@@ -280,6 +302,13 @@ async function main() {
|
|
|
280
302
|
projectDir: options.projectDir || PACKAGE_ROOT,
|
|
281
303
|
serverPath: options.serverPath,
|
|
282
304
|
plistPath: options.plist,
|
|
305
|
+
unitPath: options.unit,
|
|
306
|
+
scriptPath: options.script,
|
|
307
|
+
servicePath: options.path,
|
|
308
|
+
platform: options.platform,
|
|
309
|
+
apiToken: options.apiToken,
|
|
310
|
+
shareMode: options.shareMode,
|
|
311
|
+
allowSecrets: options.allowSecrets,
|
|
283
312
|
host: options.host,
|
|
284
313
|
port: options.port,
|
|
285
314
|
home: options.home,
|
|
@@ -317,7 +346,7 @@ function parseArgs(args) {
|
|
|
317
346
|
}
|
|
318
347
|
|
|
319
348
|
const key = arg.slice(2);
|
|
320
|
-
if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force") {
|
|
349
|
+
if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force" || key === "skip-mcp") {
|
|
321
350
|
options[toCamelCase(key)] = true;
|
|
322
351
|
continue;
|
|
323
352
|
}
|
|
@@ -405,6 +434,7 @@ Usage:
|
|
|
405
434
|
artifacty start [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty] [--api-token token] [--generate-token] [--timeout 30000]
|
|
406
435
|
artifacty status [--home ~/.artifacty]
|
|
407
436
|
artifacty stop [--home ~/.artifacty] [--timeout 30000] [--force]
|
|
437
|
+
artifacty doctor [--home ~/.artifacty] [--skip-mcp] [--timeout 5000]
|
|
408
438
|
artifacty publish --title <title> (--file <path> | --content <text>) [--format html|markdown|text|json|code|svg|mermaid|react] [--source agent] [--tag tag]
|
|
409
439
|
artifacty import --agent claude|codex|gemini|copilot|cursor|auto (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react] [--tag tag]
|
|
410
440
|
artifacty install claude|codex|gemini|copilot|cursor|all [--dry-run] [--config <path>] [--server-path <path>] [--url http://127.0.0.1:8787] [--timeout 30000]
|
|
@@ -418,7 +448,7 @@ Usage:
|
|
|
418
448
|
artifacty export --file <path>
|
|
419
449
|
artifacty backup [--file <path>]
|
|
420
450
|
artifacty import-store --file <path>
|
|
421
|
-
artifacty service plist|install|uninstall [--dry-run] [--
|
|
451
|
+
artifacty service plist|unit|task|install|uninstall [--platform macos|linux|windows] [--dry-run] [--path <path>]
|
|
422
452
|
artifacty list [--query text] [--tag tag] [--source agent] [--limit 50] [--offset 0] [--include-archived]
|
|
423
453
|
artifacty show <id> [--version n] [--raw]
|
|
424
454
|
|
package/src/lib/background.js
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { arch, platform } from "node:os";
|
|
4
|
+
import { backgroundStatus } from "./background.js";
|
|
5
|
+
import { checkMcpTools } from "./check.js";
|
|
6
|
+
import { securityConfig, validateServerExposure, exposureWarning } from "./security.js";
|
|
7
|
+
import { serviceCommand } from "./service.js";
|
|
8
|
+
import { checkStoreIntegrity, createStore } from "./storage.js";
|
|
9
|
+
|
|
10
|
+
const MIN_NODE_VERSION = "22.5.0";
|
|
11
|
+
|
|
12
|
+
export async function runDoctor(options = {}) {
|
|
13
|
+
const packageRoot = path.resolve(options.packageRoot || process.cwd());
|
|
14
|
+
const store = createStore({ home: options.home });
|
|
15
|
+
const checks = [];
|
|
16
|
+
|
|
17
|
+
await collect(checks, "runtime", () => runtimeCheck(packageRoot));
|
|
18
|
+
await collect(checks, "security", () => securityCheck(options));
|
|
19
|
+
await collect(checks, "storage", () => storageCheck(store));
|
|
20
|
+
await collect(checks, "server", () => serverCheck(store));
|
|
21
|
+
await collect(checks, "service", () => serviceCheck(options));
|
|
22
|
+
if (options.skipMcp) {
|
|
23
|
+
checks.push({
|
|
24
|
+
name: "mcp",
|
|
25
|
+
status: "skip",
|
|
26
|
+
message: "MCP discovery skipped by --skip-mcp"
|
|
27
|
+
});
|
|
28
|
+
} else {
|
|
29
|
+
await collect(checks, "mcp", () => mcpCheck(packageRoot, options));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const failures = checks.filter((check) => check.status === "fail");
|
|
33
|
+
const warnings = checks.filter((check) => check.status === "warn");
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
ok: failures.length === 0,
|
|
37
|
+
checkedAt: new Date().toISOString(),
|
|
38
|
+
version: await packageVersion(packageRoot),
|
|
39
|
+
home: store.home,
|
|
40
|
+
platform: process.platform,
|
|
41
|
+
checks,
|
|
42
|
+
failures: failures.map(({ name, message }) => ({ name, message })),
|
|
43
|
+
warnings: warnings.map(({ name, message }) => ({ name, message }))
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function collect(checks, name, fn) {
|
|
48
|
+
try {
|
|
49
|
+
checks.push({ name, ...(await fn()) });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
checks.push({
|
|
52
|
+
name,
|
|
53
|
+
status: "fail",
|
|
54
|
+
message: error.message,
|
|
55
|
+
error: {
|
|
56
|
+
name: error.name,
|
|
57
|
+
code: error.code
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runtimeCheck(packageRoot) {
|
|
64
|
+
const version = await packageVersion(packageRoot);
|
|
65
|
+
const supported = compareVersions(process.versions.node, MIN_NODE_VERSION) >= 0;
|
|
66
|
+
return {
|
|
67
|
+
status: supported ? "pass" : "fail",
|
|
68
|
+
message: supported
|
|
69
|
+
? `Node ${process.versions.node} satisfies >=${MIN_NODE_VERSION}`
|
|
70
|
+
: `Node ${process.versions.node} is below required >=${MIN_NODE_VERSION}`,
|
|
71
|
+
data: {
|
|
72
|
+
artifactyVersion: version,
|
|
73
|
+
nodeVersion: process.versions.node,
|
|
74
|
+
requiredNodeVersion: `>=${MIN_NODE_VERSION}`,
|
|
75
|
+
platform: platform(),
|
|
76
|
+
arch: arch()
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function securityCheck(options = {}) {
|
|
82
|
+
const host = options.host || process.env.ARTIFACTY_HOST || "127.0.0.1";
|
|
83
|
+
const config = securityConfig(options);
|
|
84
|
+
validateServerExposure({ host, config });
|
|
85
|
+
const warning = exposureWarning({ host, config });
|
|
86
|
+
if (warning) {
|
|
87
|
+
return {
|
|
88
|
+
status: "warn",
|
|
89
|
+
message: warning,
|
|
90
|
+
data: {
|
|
91
|
+
host,
|
|
92
|
+
shareMode: config.shareMode,
|
|
93
|
+
hasApiToken: Boolean(config.apiToken)
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
status: "pass",
|
|
99
|
+
message: "Server exposure settings are local-first",
|
|
100
|
+
data: {
|
|
101
|
+
host,
|
|
102
|
+
shareMode: config.shareMode,
|
|
103
|
+
hasApiToken: Boolean(config.apiToken)
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function storageCheck(store) {
|
|
109
|
+
const integrity = await checkStoreIntegrity(store);
|
|
110
|
+
return {
|
|
111
|
+
status: integrity.ok ? "pass" : "fail",
|
|
112
|
+
message: integrity.ok
|
|
113
|
+
? `Store is consistent with ${integrity.artifactCount} artifacts and ${integrity.versionCount} versions`
|
|
114
|
+
: "Store integrity check found missing, changed, orphaned, or inconsistent files",
|
|
115
|
+
data: integrity
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function serverCheck(store) {
|
|
120
|
+
const status = await backgroundStatus({ home: store.home });
|
|
121
|
+
if (status.running) {
|
|
122
|
+
return {
|
|
123
|
+
status: "pass",
|
|
124
|
+
message: `Managed server is healthy at ${status.url}`,
|
|
125
|
+
data: status
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (status.processRunning || status.pidFileExists) {
|
|
129
|
+
return {
|
|
130
|
+
status: "fail",
|
|
131
|
+
message: "Recorded server process or pid file exists but health check is not passing",
|
|
132
|
+
data: status
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
status: "warn",
|
|
137
|
+
message: "No managed Artifacty server is currently running",
|
|
138
|
+
data: status
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function serviceCheck(options = {}) {
|
|
143
|
+
const definitions = [];
|
|
144
|
+
for (const action of ["plist", "unit", "task"]) {
|
|
145
|
+
const result = await serviceCommand(action, {
|
|
146
|
+
projectDir: options.packageRoot || process.cwd(),
|
|
147
|
+
serverPath: options.serverPath,
|
|
148
|
+
host: options.host,
|
|
149
|
+
port: options.port,
|
|
150
|
+
home: options.home,
|
|
151
|
+
apiToken: options.apiToken,
|
|
152
|
+
shareMode: options.shareMode,
|
|
153
|
+
allowSecrets: options.allowSecrets,
|
|
154
|
+
dryRun: true
|
|
155
|
+
});
|
|
156
|
+
definitions.push({
|
|
157
|
+
action,
|
|
158
|
+
platform: result.platform,
|
|
159
|
+
path: result.path,
|
|
160
|
+
contentBytes: Buffer.byteLength(result.content || "", "utf8")
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status: "pass",
|
|
165
|
+
message: "Service definitions render for macOS, Linux, and Windows",
|
|
166
|
+
data: { definitions }
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function mcpCheck(packageRoot, options = {}) {
|
|
171
|
+
const result = await checkMcpTools({
|
|
172
|
+
projectDir: packageRoot,
|
|
173
|
+
serverPath: options.serverPath,
|
|
174
|
+
url: options.url,
|
|
175
|
+
home: options.home,
|
|
176
|
+
timeout: options.timeout
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
status: result.ok ? "pass" : "fail",
|
|
180
|
+
message: result.ok
|
|
181
|
+
? `MCP discovery found ${result.toolCount} tools, ${result.resourceCount} resources, and ${result.promptCount} prompts`
|
|
182
|
+
: "MCP discovery is missing required tools, resources, or prompts",
|
|
183
|
+
data: result
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function packageVersion(packageRoot) {
|
|
188
|
+
const file = path.join(packageRoot, "package.json");
|
|
189
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
190
|
+
return parsed.version;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function compareVersions(left, right) {
|
|
194
|
+
const leftParts = versionParts(left);
|
|
195
|
+
const rightParts = versionParts(right);
|
|
196
|
+
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
|
197
|
+
const leftPart = leftParts[index] || 0;
|
|
198
|
+
const rightPart = rightParts[index] || 0;
|
|
199
|
+
if (leftPart > rightPart) {
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
if (leftPart < rightPart) {
|
|
203
|
+
return -1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function versionParts(value) {
|
|
210
|
+
return String(value)
|
|
211
|
+
.split(".")
|
|
212
|
+
.map((part) => Number.parseInt(part, 10))
|
|
213
|
+
.filter((part) => Number.isFinite(part));
|
|
214
|
+
}
|
package/src/lib/service.js
CHANGED
|
@@ -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
|
|
10
|
-
const plist = createLaunchAgentPlist(options);
|
|
10
|
+
const definition = serviceDefinition(action, options);
|
|
11
11
|
|
|
12
|
-
if (
|
|
13
|
-
return {
|
|
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(
|
|
18
|
-
const changed = existing !==
|
|
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(
|
|
21
|
-
await writeFile(
|
|
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
|
-
|
|
32
|
+
platform: definition.platform,
|
|
33
|
+
path: definition.path,
|
|
26
34
|
changed,
|
|
27
35
|
dryRun: Boolean(options.dryRun),
|
|
28
|
-
content: options.dryRun ?
|
|
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(
|
|
42
|
+
const existed = existsSync(definition.path);
|
|
38
43
|
if (!options.dryRun) {
|
|
39
|
-
await rm(
|
|
44
|
+
await rm(definition.path, { force: true });
|
|
40
45
|
}
|
|
41
46
|
return {
|
|
42
47
|
action,
|
|
43
|
-
|
|
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
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("&", "&")
|
|
@@ -103,3 +302,23 @@ function escapeXml(value) {
|
|
|
103
302
|
.replaceAll(">", ">")
|
|
104
303
|
.replaceAll('"', """);
|
|
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
|
+
}
|