artifacty 0.5.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
@@ -27,37 +27,43 @@ artifacty --help
27
27
  Run it without a global install:
28
28
 
29
29
  ```bash
30
- npx artifacty@latest serve
30
+ npx artifacty@latest serve --foreground
31
31
  ```
32
32
 
33
- Start the local dashboard:
33
+ Start the local dashboard in the background:
34
34
 
35
35
  ```bash
36
36
  artifacty serve
37
37
  ```
38
38
 
39
- Open the URL printed by the server. Artifacty prefers `http://127.0.0.1:8787`; if that default port is busy and no explicit port was configured, it starts on the next available local port and records the actual URL for CLI and MCP responses.
39
+ Open the `url` printed in the JSON response. Artifacty prefers `http://127.0.0.1:8787`; if that default port is busy and no explicit port was configured, it starts on the next available local port and records the actual URL for CLI and MCP responses.
40
40
 
41
- Run it in the background and return to your prompt:
41
+ Manage the background server:
42
42
 
43
43
  ```bash
44
- artifacty start
45
44
  artifacty status
46
45
  artifacty stop
47
46
  ```
48
47
 
49
- `artifacty serve --detach` is equivalent to `artifacty start`. Logs are written under `~/.artifacty/logs/`.
50
- 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.
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 process-group signals on macOS/Linux and Windows `taskkill`, falling back to `/F` when Windows requires forceful termination.
50
+
51
+ For foreground debugging, keep the process attached:
52
+
53
+ ```bash
54
+ artifacty serve --foreground
55
+ npm start
56
+ ```
51
57
 
52
58
  Generate an API token at startup when you want to protect HTTP API and browser write routes:
53
59
 
54
60
  ```bash
55
61
  artifacty serve --generate-token
56
62
  artifacty serve --host 0.0.0.0 --share-mode lan --generate-token
57
- npm start -- --generate-token
63
+ artifacty serve --foreground --generate-token
58
64
  ```
59
65
 
60
- The server prints the generated token plus `/new?token=...` and `/import?token=...` URLs. For scripts or background services that need a stable token, generate one first:
66
+ Background `serve` returns the generated token and ready-to-open `/new?token=...` and `/import?token=...` URLs in JSON. Foreground `serve` prints the same values to stderr. For scripts or long-running services that need a stable token, generate one first:
61
67
 
62
68
  ```bash
63
69
  artifacty token
@@ -193,6 +199,8 @@ artifacty start
193
199
  artifacty status
194
200
  artifacty stop
195
201
  artifacty service install --dry-run
202
+ artifacty service unit --dry-run
203
+ artifacty service task --dry-run
196
204
  ```
197
205
 
198
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`.
@@ -216,11 +224,11 @@ artifacty integrity
216
224
 
217
225
  ## API Example
218
226
 
219
- Start a protected server in another terminal, or generate a reusable shell token first:
227
+ Start a protected server with a reusable shell token:
220
228
 
221
229
  ```bash
222
- artifacty serve --generate-token
223
230
  export ARTIFACTY_API_TOKEN="$(artifacty token --raw)"
231
+ artifacty serve --api-token "$ARTIFACTY_API_TOKEN"
224
232
  ```
225
233
 
226
234
  ```bash
@@ -13,30 +13,31 @@ The dashboard prefers `http://127.0.0.1:8787`. If that port is busy and no expli
13
13
  Use a generated startup token when running a protected foreground server:
14
14
 
15
15
  ```bash
16
- node src/cli.js serve --generate-token
17
- node src/cli.js serve --host 0.0.0.0 --share-mode lan --generate-token
16
+ node src/cli.js serve --foreground --generate-token
17
+ node src/cli.js serve --foreground --host 0.0.0.0 --share-mode lan --generate-token
18
18
  npm start -- --generate-token
19
19
  ```
20
20
 
21
- The generated token is printed with ready-to-open create and import URLs.
21
+ Foreground runs print the generated token with ready-to-open create and import URLs. Background runs return the same values in JSON.
22
22
 
23
23
  For prompt-friendly local background runs, use the lifecycle commands:
24
24
 
25
25
  ```bash
26
+ node src/cli.js serve --port 8787
26
27
  node src/cli.js start --port 8787
27
28
  node src/cli.js status
28
29
  node src/cli.js stop
29
30
  ```
30
31
 
31
- `serve --detach` uses the same detached-process path as `start`. It writes `server.pid`, `server.json`, and logs under `ARTIFACTY_HOME` (default `~/.artifacty`). Prefer `start --api-token "$(node src/cli.js token --raw)"` when a background server needs API protection, because generated startup tokens are only visible in the server log.
32
+ `serve`, `serve --detach`, and `start` use the same detached-process path. They write `server.pid`, `server.json`, and logs under `ARTIFACTY_HOME` (default `~/.artifacty`). `serve --generate-token` and `start --generate-token` generate the API token in the parent CLI process and return it in JSON along with ready-to-open create/import URLs. Use `serve --foreground` when you want attached logs for debugging.
32
33
 
33
34
  The lifecycle commands are intended to be cross-platform:
34
35
 
35
36
  - macOS and Linux: `stop` signals the detached process group first, then falls back to the server process id.
36
- - 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.
37
38
  - All platforms: `status` combines the managed pid file with the HTTP `/health` endpoint, so a stale pid alone is not reported as healthy.
38
39
 
39
- 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.
40
41
 
41
42
  Create artifacts directly in the browser at `http://127.0.0.1:8787/new`.
42
43
 
@@ -105,7 +106,7 @@ Generate a token for protected HTTP routes:
105
106
  node src/cli.js token
106
107
  node src/cli.js serve --generate-token
107
108
  npm start -- --generate-token
108
- ARTIFACTY_API_TOKEN="$(node src/cli.js token --raw)" node src/cli.js serve
109
+ ARTIFACTY_API_TOKEN="$(node src/cli.js token --raw)" node src/cli.js serve --foreground
109
110
  ```
110
111
 
111
112
  ## Claude Code
@@ -298,17 +299,33 @@ Renderer notes:
298
299
 
299
300
  ## Background Service
300
301
 
301
- 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:
302
311
 
303
312
  ```bash
304
- node src/cli.js service plist
305
313
  node src/cli.js service install --dry-run
306
314
  node src/cli.js service install
315
+ node src/cli.js service uninstall --dry-run
307
316
  ```
308
317
 
309
- 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.
310
327
 
311
- For background services, prefer a stable `ARTIFACTY_API_TOKEN` in the service environment. `serve --generate-token` is intended for foreground runs where the operator can read the generated token from startup output.
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.
312
329
 
313
330
  ## Backup and Audit
314
331
 
@@ -30,7 +30,7 @@ The generated token protects HTTP API routes and browser write forms. Prefer the
30
30
 
31
31
  Artifacty does not terminate TLS. Do not expose it directly on the public internet. If a shared instance must cross an untrusted network, put it behind a TLS reverse proxy or a private VPN.
32
32
 
33
- When Artifacty binds outside loopback, startup output includes a warning that the server is reachable beyond the local machine and that TLS is not provided by Artifacty.
33
+ When Artifacty binds outside loopback, startup output includes a warning that the server is reachable beyond the local machine and that TLS is not provided by Artifacty. Background `serve` returns this warning in JSON; foreground `serve` and `src/server.js` also write it to stderr.
34
34
 
35
35
  ## Browser Write Behavior
36
36
 
@@ -24,6 +24,8 @@ This runs syntax checks, the full Node test suite, and a local smoke test that s
24
24
  - Keep the default HTTP bind address at `127.0.0.1`.
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
+ - 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.
27
29
  - Prefer `x-artifacty-token` or `Authorization: Bearer <token>` over query tokens in scripts.
28
30
  - Review secret-scan bypasses. `--allow-secrets` and `ARTIFACTY_ALLOW_SECRETS=true` should be deliberate and temporary.
29
31
  - Review [../SECURITY.md](../SECURITY.md) and [threat-model.md](threat-model.md) when changing auth, rendering, MCP, or network-sharing behavior.
@@ -45,4 +47,4 @@ This runs syntax checks, the full Node test suite, and a local smoke test that s
45
47
 
46
48
  - Export a backup before upgrades: `artifacty backup`.
47
49
  - Confirm `artifacty audit --limit 20` shows recent create/update/read/archive events.
48
- - 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.
@@ -51,8 +51,8 @@ Controls:
51
51
  - Browser form token URLs exist only for local convenience.
52
52
  - Token comparisons use timing-safe digest comparison.
53
53
 
54
- Guidance: rotate tokens after sharing sessions and prefer foreground generated
55
- tokens for temporary LAN use.
54
+ Guidance: rotate tokens after sharing sessions, prefer header-based tokens for
55
+ scripts, and use generated startup tokens only for temporary interactive shares.
56
56
 
57
57
  ### Cross-Site Request Forgery
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.5.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
@@ -47,7 +47,10 @@ async function main() {
47
47
  }
48
48
 
49
49
  if (command === "serve") {
50
- if (options.detach) {
50
+ if (options.detach && options.foreground) {
51
+ throw new Error("Use either --foreground or --detach, not both");
52
+ }
53
+ if (!options.foreground) {
51
54
  printJson(await startBackgroundServer({
52
55
  ...serverOptions(options),
53
56
  serverPath: path.join(PACKAGE_ROOT, "src", "server.js")
@@ -277,6 +280,13 @@ async function main() {
277
280
  projectDir: options.projectDir || PACKAGE_ROOT,
278
281
  serverPath: options.serverPath,
279
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,
280
290
  host: options.host,
281
291
  port: options.port,
282
292
  home: options.home,
@@ -314,7 +324,7 @@ function parseArgs(args) {
314
324
  }
315
325
 
316
326
  const key = arg.slice(2);
317
- if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "force") {
327
+ if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force") {
318
328
  options[toCamelCase(key)] = true;
319
329
  continue;
320
330
  }
@@ -397,10 +407,11 @@ function printHelp() {
397
407
 
398
408
  Usage:
399
409
  artifacty token [--bytes 32] [--raw]
400
- artifacty serve [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty] [--api-token token] [--generate-token] [--bytes 32] [--detach]
401
- artifacty start [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty] [--api-token token] [--timeout 5000]
410
+ artifacty serve [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty] [--api-token token] [--generate-token] [--bytes 32] [--foreground]
411
+ artifacty serve --foreground [--generate-token]
412
+ artifacty start [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty] [--api-token token] [--generate-token] [--timeout 30000]
402
413
  artifacty status [--home ~/.artifacty]
403
- artifacty stop [--home ~/.artifacty] [--timeout 5000] [--force]
414
+ artifacty stop [--home ~/.artifacty] [--timeout 30000] [--force]
404
415
  artifacty publish --title <title> (--file <path> | --content <text>) [--format html|markdown|text|json|code|svg|mermaid|react] [--source agent] [--tag tag]
405
416
  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]
406
417
  artifacty install claude|codex|gemini|copilot|cursor|all [--dry-run] [--config <path>] [--server-path <path>] [--url http://127.0.0.1:8787] [--timeout 30000]
@@ -414,7 +425,7 @@ Usage:
414
425
  artifacty export --file <path>
415
426
  artifacty backup [--file <path>]
416
427
  artifacty import-store --file <path>
417
- 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>]
418
429
  artifacty list [--query text] [--tag tag] [--source agent] [--limit 50] [--offset 0] [--include-archived]
419
430
  artifacty show <id> [--version n] [--raw]
420
431
 
@@ -4,14 +4,23 @@ import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { createStore } from "./storage.js";
6
6
  import { readServerState, serverStatePath } from "./server-state.js";
7
+ import { exposureWarning, securityConfig } from "./security.js";
8
+ import { generateToken } from "./token.js";
7
9
 
8
- const DEFAULT_READY_TIMEOUT_MS = 5000;
10
+ const DEFAULT_READY_TIMEOUT_MS = 30000;
11
+ const DEFAULT_HOST = "127.0.0.1";
9
12
 
10
13
  export async function startBackgroundServer(options = {}) {
11
14
  if (options.generateToken && options.apiToken) {
12
15
  throw new Error("Use either --api-token or --generate-token, not both");
13
16
  }
14
17
 
18
+ const generatedToken = options.generateToken ? generateToken(options) : null;
19
+ const serverOptions = {
20
+ ...options,
21
+ apiToken: generatedToken?.token || options.apiToken,
22
+ generateToken: false
23
+ };
15
24
  const store = createStore({ home: options.home });
16
25
  const paths = backgroundPaths(store);
17
26
  const current = await backgroundStatus({ home: store.home });
@@ -22,7 +31,7 @@ export async function startBackgroundServer(options = {}) {
22
31
  mkdirSync(paths.logDir, { recursive: true });
23
32
  mkdirSync(store.home, { recursive: true });
24
33
 
25
- const child = spawnDetachedServer(options, store, paths);
34
+ const child = spawnDetachedServer(serverOptions, store, paths);
26
35
 
27
36
  writeFileSync(paths.pidFile, `${child.pid}\n`, "utf8");
28
37
 
@@ -39,6 +48,11 @@ export async function startBackgroundServer(options = {}) {
39
48
  pid: child.pid,
40
49
  url: ready.url,
41
50
  home: store.home,
51
+ auth: authResponse(generatedToken, ready.url),
52
+ securityWarning: exposureWarning({
53
+ host: serverOptions.host || process.env.ARTIFACTY_HOST || DEFAULT_HOST,
54
+ config: securityConfig(serverOptions)
55
+ }) || undefined,
42
56
  logs: {
43
57
  stdout: paths.stdoutLog,
44
58
  stderr: paths.stderrLog
@@ -58,6 +72,20 @@ export async function startBackgroundServer(options = {}) {
58
72
  }
59
73
  }
60
74
 
75
+ function authResponse(generatedToken, url) {
76
+ if (!generatedToken) {
77
+ return null;
78
+ }
79
+ return {
80
+ token: generatedToken.token,
81
+ bytes: generatedToken.bytes,
82
+ header: generatedToken.header,
83
+ authorization: generatedToken.authorization,
84
+ createUrl: `${url}/new?token=${encodeURIComponent(generatedToken.token)}`,
85
+ importUrl: `${url}/import?token=${encodeURIComponent(generatedToken.token)}`
86
+ };
87
+ }
88
+
61
89
  export async function stopBackgroundServer(options = {}) {
62
90
  const store = createStore({ home: options.home });
63
91
  const paths = backgroundPaths(store);
@@ -68,7 +96,7 @@ export async function stopBackgroundServer(options = {}) {
68
96
  action: "stop",
69
97
  stopped: false,
70
98
  running: status.running,
71
- reason: status.pid ? "server was not started by artifacty start" : "server is not running",
99
+ reason: status.pid ? "server was not started by artifacty serve/start" : "server is not running",
72
100
  pid: status.pid || null,
73
101
  home: store.home
74
102
  };
@@ -220,10 +248,20 @@ function buildServerEnv(options) {
220
248
  async function terminatePid(pid, options = {}) {
221
249
  const command = stopCommandForPlatform(pid, options);
222
250
  if (command) {
223
- await execFileQuiet(command.command, command.args).catch((error) => {
251
+ await execFileQuiet(command.command, command.args).catch(async (error) => {
224
252
  if (!isPidRunning(pid)) {
225
253
  return;
226
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
+ }
227
265
  throw new Error(`Failed to stop Windows process ${pid}: ${error.stderr || error.message}`);
228
266
  });
229
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
+ }