artifacty 0.1.1 → 0.1.2

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
@@ -38,6 +38,17 @@ artifacty serve
38
38
 
39
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.
40
40
 
41
+ Run it in the background and return to your prompt:
42
+
43
+ ```bash
44
+ artifacty start
45
+ artifacty status
46
+ artifacty stop
47
+ ```
48
+
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.
51
+
41
52
  Generate an API token at startup when you want to protect HTTP API and browser write routes:
42
53
 
43
54
  ```bash
@@ -50,7 +61,7 @@ The server prints the generated token plus `/new?token=...` and `/import?token=.
50
61
 
51
62
  ```bash
52
63
  artifacty token
53
- ARTIFACTY_API_TOKEN="$(artifacty token --raw)" artifacty serve
64
+ artifacty start --api-token "$(artifacty token --raw)"
54
65
  ```
55
66
 
56
67
  Install MCP configuration for local agents:
@@ -166,6 +177,9 @@ artifacty audit --limit 20
166
177
  artifacty backup
167
178
  artifacty export --file ./artifacty-backup.json
168
179
  artifacty import-store --file ./artifacty-backup.json
180
+ artifacty start
181
+ artifacty status
182
+ artifacty stop
169
183
  artifacty service install --dry-run
170
184
  ```
171
185
 
@@ -20,6 +20,24 @@ npm start -- --generate-token
20
20
 
21
21
  The generated token is printed with ready-to-open create and import URLs.
22
22
 
23
+ For prompt-friendly local background runs, use the lifecycle commands:
24
+
25
+ ```bash
26
+ node src/cli.js start --port 8787
27
+ node src/cli.js status
28
+ node src/cli.js stop
29
+ ```
30
+
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
+
33
+ The lifecycle commands are intended to be cross-platform:
34
+
35
+ - 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
+ - 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
+ 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
+
23
41
  Create artifacts directly in the browser at `http://127.0.0.1:8787/new`.
24
42
 
25
43
  For LAN or VPN sharing, keep the default local binding unless you intentionally need another machine to reach the server. See [network-sharing.md](network-sharing.md) before using `--host 0.0.0.0`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@ import { convertAgentArtifact } from "./lib/converters.js";
17
17
  import { checkMcpTools } from "./lib/check.js";
18
18
  import { installAgent } from "./lib/installer.js";
19
19
  import { serviceCommand } from "./lib/service.js";
20
+ import { backgroundStatus, startBackgroundServer, stopBackgroundServer } from "./lib/background.js";
20
21
  import { resolvePublicBaseUrl } from "./lib/server-state.js";
21
22
  import { generateToken } from "./lib/token.js";
22
23
  import { startServer } from "./server.js";
@@ -44,6 +45,13 @@ async function main() {
44
45
  }
45
46
 
46
47
  if (command === "serve") {
48
+ if (options.detach) {
49
+ printJson(await startBackgroundServer({
50
+ ...serverOptions(options),
51
+ serverPath: path.join(PACKAGE_ROOT, "src", "server.js")
52
+ }));
53
+ return;
54
+ }
47
55
  if (options.generateToken && options.apiToken) {
48
56
  throw new Error("Use either --api-token or --generate-token, not both");
49
57
  }
@@ -67,6 +75,28 @@ async function main() {
67
75
  return;
68
76
  }
69
77
 
78
+ if (command === "start") {
79
+ printJson(await startBackgroundServer({
80
+ ...serverOptions(options),
81
+ serverPath: path.join(PACKAGE_ROOT, "src", "server.js")
82
+ }));
83
+ return;
84
+ }
85
+
86
+ if (command === "stop") {
87
+ printJson(await stopBackgroundServer({
88
+ home: options.home,
89
+ timeout: options.timeout,
90
+ force: options.force
91
+ }));
92
+ return;
93
+ }
94
+
95
+ if (command === "status") {
96
+ printJson(await backgroundStatus({ home: options.home }));
97
+ return;
98
+ }
99
+
70
100
  if (command === "publish") {
71
101
  const content = await readContent(options);
72
102
  const artifact = await createArtifact(store, {
@@ -256,7 +286,7 @@ function parseArgs(args) {
256
286
  }
257
287
 
258
288
  const key = arg.slice(2);
259
- if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token") {
289
+ if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "force") {
260
290
  options[toCamelCase(key)] = true;
261
291
  continue;
262
292
  }
@@ -314,7 +344,10 @@ function printHelp() {
314
344
 
315
345
  Usage:
316
346
  artifacty token [--bytes 32] [--raw]
317
- artifacty serve [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty] [--generate-token] [--bytes 32]
347
+ artifacty serve [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty] [--api-token token] [--generate-token] [--bytes 32] [--detach]
348
+ artifacty start [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty] [--api-token token] [--timeout 5000]
349
+ artifacty status [--home ~/.artifacty]
350
+ artifacty stop [--home ~/.artifacty] [--timeout 5000] [--force]
318
351
  artifacty publish --title <title> (--file <path> | --content <text>) [--format html|markdown|text|json|code|svg|mermaid|react] [--source agent] [--tag tag]
319
352
  artifacty import --agent claude|codex|gemini|auto (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react] [--tag tag]
320
353
  artifacty install claude|codex|gemini|all [--dry-run] [--config <path>] [--server-path <path>] [--url http://127.0.0.1:8787] [--timeout 30000]
@@ -353,6 +386,20 @@ function stripInstallContentUnlessDryRun(result) {
353
386
  return rest;
354
387
  }
355
388
 
389
+ function serverOptions(options) {
390
+ return {
391
+ host: options.host,
392
+ port: options.port,
393
+ home: options.home,
394
+ apiToken: options.apiToken,
395
+ shareMode: options.shareMode,
396
+ allowSecrets: options.allowSecrets,
397
+ generateToken: options.generateToken,
398
+ bytes: options.bytes,
399
+ timeout: options.timeout
400
+ };
401
+ }
402
+
356
403
  function toCamelCase(value) {
357
404
  return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
358
405
  }
@@ -0,0 +1,344 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { createStore } from "./storage.js";
6
+ import { readServerState, serverStatePath } from "./server-state.js";
7
+
8
+ const DEFAULT_READY_TIMEOUT_MS = 5000;
9
+
10
+ export async function startBackgroundServer(options = {}) {
11
+ if (options.generateToken && options.apiToken) {
12
+ throw new Error("Use either --api-token or --generate-token, not both");
13
+ }
14
+
15
+ const store = createStore({ home: options.home });
16
+ const paths = backgroundPaths(store);
17
+ const current = await backgroundStatus({ home: store.home });
18
+ if (current.running) {
19
+ throw new Error(`Artifacty server is already running on ${current.url || "unknown URL"} (pid ${current.pid})`);
20
+ }
21
+
22
+ mkdirSync(paths.logDir, { recursive: true });
23
+ mkdirSync(store.home, { recursive: true });
24
+
25
+ const child = spawnDetachedServer(options, store, paths);
26
+
27
+ writeFileSync(paths.pidFile, `${child.pid}\n`, "utf8");
28
+
29
+ try {
30
+ const ready = await waitForReady({
31
+ store,
32
+ pid: child.pid,
33
+ timeoutMs: Number(options.timeout || DEFAULT_READY_TIMEOUT_MS)
34
+ });
35
+ child.unref();
36
+ return {
37
+ action: "start",
38
+ running: true,
39
+ pid: child.pid,
40
+ url: ready.url,
41
+ home: store.home,
42
+ logs: {
43
+ stdout: paths.stdoutLog,
44
+ stderr: paths.stderrLog
45
+ },
46
+ statePath: serverStatePath(store)
47
+ };
48
+ } catch (error) {
49
+ try {
50
+ await terminatePid(child.pid);
51
+ } catch {
52
+ // Process may already have exited during startup.
53
+ }
54
+ rmSync(paths.pidFile, { force: true });
55
+ const logTail = await tailFile(paths.stderrLog);
56
+ const message = logTail ? `${error.message}\n${logTail}` : error.message;
57
+ throw new Error(message);
58
+ }
59
+ }
60
+
61
+ export async function stopBackgroundServer(options = {}) {
62
+ const store = createStore({ home: options.home });
63
+ const paths = backgroundPaths(store);
64
+ const status = await backgroundStatus({ home: store.home });
65
+
66
+ if (!status.pid || !status.pidFileExists) {
67
+ return {
68
+ action: "stop",
69
+ stopped: false,
70
+ running: status.running,
71
+ reason: status.pid ? "server was not started by artifacty start" : "server is not running",
72
+ pid: status.pid || null,
73
+ home: store.home
74
+ };
75
+ }
76
+
77
+ if (!status.processRunning) {
78
+ rmSync(paths.pidFile, { force: true });
79
+ return {
80
+ action: "stop",
81
+ stopped: false,
82
+ running: false,
83
+ reason: "removed stale pid file",
84
+ pid: status.pid,
85
+ home: store.home
86
+ };
87
+ }
88
+
89
+ if (!options.force && !status.stateMatchesPid && !status.healthy) {
90
+ return {
91
+ action: "stop",
92
+ stopped: false,
93
+ running: status.running,
94
+ reason: "pid file does not match a healthy Artifacty server; retry with --force to stop the recorded pid",
95
+ pid: status.pid,
96
+ home: store.home
97
+ };
98
+ }
99
+
100
+ await terminatePid(status.pid);
101
+ let stopped = await waitForStop(status.pid, Number(options.timeout || DEFAULT_READY_TIMEOUT_MS));
102
+ if (!stopped && options.force) {
103
+ await terminatePid(status.pid, { force: true });
104
+ stopped = await waitForStop(status.pid, 1000);
105
+ }
106
+ if (!stopped) {
107
+ return {
108
+ action: "stop",
109
+ stopped: false,
110
+ running: true,
111
+ reason: "server did not stop before timeout; retry with --force",
112
+ pid: status.pid,
113
+ home: store.home
114
+ };
115
+ }
116
+ rmSync(paths.pidFile, { force: true });
117
+
118
+ return {
119
+ action: "stop",
120
+ stopped: true,
121
+ running: false,
122
+ pid: status.pid,
123
+ home: store.home
124
+ };
125
+ }
126
+
127
+ export async function backgroundStatus(options = {}) {
128
+ const store = createStore({ home: options.home });
129
+ const paths = backgroundPaths(store);
130
+ const state = await readServerState(store);
131
+ const pidFromFile = readPidFile(paths.pidFile);
132
+ const pid = pidFromFile || state?.pid || null;
133
+ const processRunning = pid ? isPidRunning(pid) : false;
134
+ const health = state?.url ? await fetchHealth(state.url) : { ok: false };
135
+ const stateMatchesPid = Boolean(pid && state?.pid === pid);
136
+
137
+ return {
138
+ action: "status",
139
+ running: Boolean(processRunning && health.ok),
140
+ processRunning,
141
+ healthy: Boolean(health.ok),
142
+ platform: process.platform,
143
+ pid,
144
+ statePid: state?.pid || null,
145
+ stateMatchesPid,
146
+ pidFileExists: existsSync(paths.pidFile),
147
+ managed: Boolean(pidFromFile),
148
+ url: state?.url || null,
149
+ home: store.home,
150
+ logs: {
151
+ stdout: paths.stdoutLog,
152
+ stderr: paths.stderrLog
153
+ },
154
+ statePath: serverStatePath(store)
155
+ };
156
+ }
157
+
158
+ export function backgroundPaths(store) {
159
+ const logDir = path.join(store.home, "logs");
160
+ return {
161
+ logDir,
162
+ pidFile: path.join(store.home, "server.pid"),
163
+ stdoutLog: path.join(logDir, "server.out.log"),
164
+ stderrLog: path.join(logDir, "server.err.log")
165
+ };
166
+ }
167
+
168
+ export function stopCommandForPlatform(pid, options = {}, platform = process.platform) {
169
+ if (platform !== "win32") {
170
+ return null;
171
+ }
172
+ const args = ["/PID", String(pid), "/T"];
173
+ if (options.force) {
174
+ args.push("/F");
175
+ }
176
+ return { command: "taskkill", args };
177
+ }
178
+
179
+ function spawnDetachedServer(options, store, paths) {
180
+ let stdoutFd;
181
+ let stderrFd;
182
+ try {
183
+ stdoutFd = openSync(paths.stdoutLog, "a");
184
+ stderrFd = openSync(paths.stderrLog, "a");
185
+ return spawn(process.execPath, buildServerArgs(options, store), {
186
+ detached: true,
187
+ env: buildServerEnv(options),
188
+ stdio: ["ignore", stdoutFd, stderrFd],
189
+ windowsHide: true
190
+ });
191
+ } finally {
192
+ closeFd(stdoutFd);
193
+ closeFd(stderrFd);
194
+ }
195
+ }
196
+
197
+ export function buildServerArgs(options, store) {
198
+ const args = [options.serverPath];
199
+ addValueArg(args, "--host", options.host);
200
+ addValueArg(args, "--port", options.port);
201
+ addValueArg(args, "--home", store.home);
202
+ addValueArg(args, "--share-mode", options.shareMode);
203
+ addValueArg(args, "--bytes", options.bytes);
204
+ if (options.generateToken) {
205
+ args.push("--generate-token");
206
+ }
207
+ if (options.allowSecrets) {
208
+ args.push("--allow-secrets");
209
+ }
210
+ return args;
211
+ }
212
+
213
+ function buildServerEnv(options) {
214
+ return {
215
+ ...process.env,
216
+ ...(options.apiToken ? { ARTIFACTY_API_TOKEN: options.apiToken } : {})
217
+ };
218
+ }
219
+
220
+ async function terminatePid(pid, options = {}) {
221
+ const command = stopCommandForPlatform(pid, options);
222
+ if (command) {
223
+ await execFileQuiet(command.command, command.args).catch((error) => {
224
+ if (!isPidRunning(pid)) {
225
+ return;
226
+ }
227
+ throw new Error(`Failed to stop Windows process ${pid}: ${error.stderr || error.message}`);
228
+ });
229
+ return;
230
+ }
231
+
232
+ const signal = options.force ? "SIGKILL" : "SIGTERM";
233
+ try {
234
+ process.kill(-pid, signal);
235
+ } catch {
236
+ try {
237
+ process.kill(pid, signal);
238
+ } catch (error) {
239
+ if (error.code !== "ESRCH") {
240
+ throw error;
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ function execFileQuiet(command, args) {
247
+ return new Promise((resolve, reject) => {
248
+ execFile(command, args, { windowsHide: true }, (error, stdout, stderr) => {
249
+ if (error) {
250
+ reject(Object.assign(error, { stdout, stderr }));
251
+ return;
252
+ }
253
+ resolve({ stdout, stderr });
254
+ });
255
+ });
256
+ }
257
+
258
+ function addValueArg(args, name, value) {
259
+ if (value !== undefined && value !== null && value !== "") {
260
+ args.push(name, String(value));
261
+ }
262
+ }
263
+
264
+ async function waitForReady({ store, pid, timeoutMs }) {
265
+ const deadline = Date.now() + timeoutMs;
266
+ while (Date.now() < deadline) {
267
+ if (!isPidRunning(pid)) {
268
+ throw new Error("Artifacty server exited before it became ready");
269
+ }
270
+ const state = await readServerState(store);
271
+ if (state?.pid === pid && state.url) {
272
+ const health = await fetchHealth(state.url);
273
+ if (health.ok) {
274
+ return state;
275
+ }
276
+ }
277
+ await delay(100);
278
+ }
279
+ throw new Error(`Timed out waiting for Artifacty server to become ready after ${timeoutMs}ms`);
280
+ }
281
+
282
+ async function waitForStop(pid, timeoutMs) {
283
+ const deadline = Date.now() + timeoutMs;
284
+ while (Date.now() < deadline) {
285
+ if (!isPidRunning(pid)) {
286
+ return true;
287
+ }
288
+ await delay(100);
289
+ }
290
+ return !isPidRunning(pid);
291
+ }
292
+
293
+ function readPidFile(pidFile) {
294
+ try {
295
+ const pid = Number(readFileSync(pidFile, "utf8").trim());
296
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
301
+
302
+ function isPidRunning(pid) {
303
+ try {
304
+ process.kill(pid, 0);
305
+ return true;
306
+ } catch (error) {
307
+ return error.code === "EPERM";
308
+ }
309
+ }
310
+
311
+ async function fetchHealth(url) {
312
+ try {
313
+ const response = await fetch(`${url}/health`, {
314
+ signal: AbortSignal.timeout(500)
315
+ });
316
+ return { ok: response.ok, status: response.status };
317
+ } catch {
318
+ return { ok: false };
319
+ }
320
+ }
321
+
322
+ async function tailFile(file, maxBytes = 2000) {
323
+ try {
324
+ const content = await readFile(file, "utf8");
325
+ return content.slice(-maxBytes).trim();
326
+ } catch {
327
+ return "";
328
+ }
329
+ }
330
+
331
+ function delay(ms) {
332
+ return new Promise((resolve) => setTimeout(resolve, ms));
333
+ }
334
+
335
+ function closeFd(fd) {
336
+ if (typeof fd !== "number") {
337
+ return;
338
+ }
339
+ try {
340
+ closeSync(fd);
341
+ } catch {
342
+ // The child inherited the descriptor; parent cleanup is best effort.
343
+ }
344
+ }