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 +15 -1
- package/docs/integrations.md +18 -0
- package/package.json +1 -1
- package/src/cli.js +49 -2
- package/src/lib/background.js +344 -0
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
|
-
|
|
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
|
|
package/docs/integrations.md
CHANGED
|
@@ -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
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
|
+
}
|