artifacty 0.1.0 → 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/scripts/smoke.sh CHANGED
@@ -74,6 +74,7 @@ assert(response.status === 201, `expected create status 201, got ${response.stat
74
74
  const created = await response.json();
75
75
  assert(created.id && created.rawUrl, "create response missing artifact URLs");
76
76
 
77
+ const fakeGithubToken = ["ghp", "abcdefghijklmnopqrstuvwxyz123456"].join("_");
77
78
  response = await fetch(`${url}/api/artifacts`, {
78
79
  method: "POST",
79
80
  headers: {
@@ -82,7 +83,7 @@ response = await fetch(`${url}/api/artifacts`, {
82
83
  },
83
84
  body: JSON.stringify({
84
85
  title: "Blocked Secret",
85
- content: "ghp_abcdefghijklmnopqrstuvwxyz123456",
86
+ content: fakeGithubToken,
86
87
  format: "text"
87
88
  })
88
89
  });
package/src/cli.js CHANGED
@@ -17,7 +17,9 @@ 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";
22
+ import { generateToken } from "./lib/token.js";
21
23
  import { startServer } from "./server.js";
22
24
 
23
25
  const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
@@ -32,17 +34,66 @@ async function main() {
32
34
  return;
33
35
  }
34
36
 
37
+ if (command === "token" || command === "generate-token") {
38
+ const token = generateToken(options);
39
+ if (options.raw) {
40
+ process.stdout.write(`${token.token}\n`);
41
+ return;
42
+ }
43
+ printJson(token);
44
+ return;
45
+ }
46
+
35
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
+ }
55
+ if (options.generateToken && options.apiToken) {
56
+ throw new Error("Use either --api-token or --generate-token, not both");
57
+ }
58
+ const generatedToken = options.generateToken ? generateToken(options) : null;
36
59
  const server = await startServer({
37
60
  host: options.host,
38
61
  port: options.port,
39
62
  home: options.home,
40
- apiToken: options.apiToken,
63
+ apiToken: generatedToken?.token || options.apiToken,
41
64
  shareMode: options.shareMode,
42
65
  allowSecrets: options.allowSecrets
43
66
  });
44
67
  process.stderr.write(`Artifacty listening on ${server.url}\n`);
45
68
  process.stderr.write(`Store: ${server.store.home}\n`);
69
+ if (generatedToken) {
70
+ process.stderr.write(`API token: ${generatedToken.token}\n`);
71
+ process.stderr.write(`HTTP header: ${generatedToken.header}\n`);
72
+ process.stderr.write(`Create URL: ${server.url}/new?token=${encodeURIComponent(generatedToken.token)}\n`);
73
+ process.stderr.write(`Import URL: ${server.url}/import?token=${encodeURIComponent(generatedToken.token)}\n`);
74
+ }
75
+ return;
76
+ }
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 }));
46
97
  return;
47
98
  }
48
99
 
@@ -235,7 +286,7 @@ function parseArgs(args) {
235
286
  }
236
287
 
237
288
  const key = arg.slice(2);
238
- if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets") {
289
+ if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "force") {
239
290
  options[toCamelCase(key)] = true;
240
291
  continue;
241
292
  }
@@ -247,7 +298,7 @@ function parseArgs(args) {
247
298
 
248
299
  if (key === "tag") {
249
300
  options.tag = [...(options.tag || []), value];
250
- } else if (key === "port" || key === "limit" || key === "version" || key === "schema-version" || key === "timeout") {
301
+ } else if (key === "port" || key === "limit" || key === "version" || key === "schema-version" || key === "timeout" || key === "bytes") {
251
302
  options[toCamelCase(key)] = Number(value);
252
303
  } else {
253
304
  options[toCamelCase(key)] = value;
@@ -292,12 +343,16 @@ function printHelp() {
292
343
  process.stdout.write(`Artifacty
293
344
 
294
345
  Usage:
295
- artifacty serve [--host 127.0.0.1] [--port 8787] [--home ~/.artifacty]
296
- artifacty publish --title <title> (--file <path> | --content <text>) [--format html|markdown|text|json] [--source agent] [--tag tag]
297
- artifacty import --agent claude|codex|gemini|auto (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json] [--tag tag]
298
- artifacty install claude|codex|gemini|all [--dry-run] [--config <path>] [--server-path <path>] [--url http://127.0.0.1:8787]
346
+ artifacty token [--bytes 32] [--raw]
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]
351
+ artifacty publish --title <title> (--file <path> | --content <text>) [--format html|markdown|text|json|code|svg|mermaid|react] [--source agent] [--tag tag]
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]
353
+ artifacty install claude|codex|gemini|all [--dry-run] [--config <path>] [--server-path <path>] [--url http://127.0.0.1:8787] [--timeout 30000]
299
354
  artifacty check [--server-path <path>] [--timeout 5000]
300
- artifacty update <id> (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json]
355
+ artifacty update <id> (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react]
301
356
  artifacty archive <id>
302
357
  artifacty restore <id>
303
358
  artifacty audit [--artifact <id>] [--limit 100]
@@ -331,6 +386,20 @@ function stripInstallContentUnlessDryRun(result) {
331
386
  return rest;
332
387
  }
333
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
+
334
403
  function toCamelCase(value) {
335
404
  return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
336
405
  }
@@ -15,6 +15,8 @@ const messages = {
15
15
  ...(globalThis.ARTIFACTY_I18N || {})
16
16
  };
17
17
 
18
+ const SUPPORTED_FORMATS = ["markdown", "html", "json", "text", "code", "svg", "mermaid", "react"];
19
+
18
20
  const editorTheme = EditorView.theme({
19
21
  "&": {
20
22
  minHeight: "52vh",
@@ -146,6 +148,16 @@ function enhanceTextarea(textarea) {
146
148
  return;
147
149
  }
148
150
 
151
+ if (format === "svg") {
152
+ const frame = document.createElement("iframe");
153
+ frame.className = "editor-preview-frame";
154
+ frame.setAttribute("sandbox", "");
155
+ frame.srcdoc = content;
156
+ preview.append(frame);
157
+ status.textContent = formatLabel(format);
158
+ return;
159
+ }
160
+
149
161
  if (format === "json") {
150
162
  const pre = document.createElement("pre");
151
163
  try {
@@ -176,7 +188,7 @@ function enhanceTextarea(textarea) {
176
188
  }
177
189
 
178
190
  function languageExtension(format) {
179
- if (format === "html") {
191
+ if (format === "html" || format === "svg") {
180
192
  return html();
181
193
  }
182
194
  if (format === "json") {
@@ -189,7 +201,7 @@ function languageExtension(format) {
189
201
  }
190
202
 
191
203
  function detectFormat({ explicit, fileName, content }) {
192
- if (["markdown", "html", "json", "text"].includes(explicit)) {
204
+ if (SUPPORTED_FORMATS.includes(explicit)) {
193
205
  return explicit;
194
206
  }
195
207
 
@@ -203,8 +215,29 @@ function detectFormat({ explicit, fileName, content }) {
203
215
  if (lowerName.endsWith(".json")) {
204
216
  return "json";
205
217
  }
218
+ if (lowerName.endsWith(".svg")) {
219
+ return "svg";
220
+ }
221
+ if (lowerName.endsWith(".mmd") || lowerName.endsWith(".mermaid")) {
222
+ return "mermaid";
223
+ }
224
+ if (lowerName.endsWith(".jsx") || lowerName.endsWith(".tsx")) {
225
+ return "react";
226
+ }
227
+ if (/\.(js|ts|py|rb|go|rs|java|c|cc|cpp|cs|php|swift|kt|sh|bash|zsh)$/.test(lowerName)) {
228
+ return "code";
229
+ }
206
230
 
207
231
  const trimmed = String(content || "").trimStart();
232
+ if (/^(?:<\?xml[\s\S]*?\?>\s*)?<svg[\s>]/i.test(trimmed)) {
233
+ return "svg";
234
+ }
235
+ if (/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|stateDiagram-v2|erDiagram|gantt|pie|mindmap|journey)\b/m.test(trimmed)) {
236
+ return "mermaid";
237
+ }
238
+ if (/\b(import\s+React|from\s+['"]react['"]|export\s+default\s+function|export\s+default\s+\()/m.test(trimmed) || /<[A-Z][A-Za-z0-9]*[\s/>]/.test(trimmed)) {
239
+ return "react";
240
+ }
208
241
  if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html") || trimmed.startsWith("<")) {
209
242
  return "html";
210
243
  }
@@ -0,0 +1,67 @@
1
+ import { EditorView, basicSetup } from "codemirror";
2
+ import { EditorState } from "@codemirror/state";
3
+ import { javascript } from "@codemirror/lang-javascript";
4
+ import { html } from "@codemirror/lang-html";
5
+ import { json } from "@codemirror/lang-json";
6
+ import { markdown } from "@codemirror/lang-markdown";
7
+
8
+ const viewerTheme = EditorView.theme({
9
+ "&": {
10
+ minHeight: "50vh"
11
+ },
12
+ ".cm-content": {
13
+ minHeight: "50vh"
14
+ },
15
+ ".cm-scroller": {
16
+ overflow: "auto"
17
+ }
18
+ });
19
+
20
+ for (const container of document.querySelectorAll("[data-artifacty-code-viewer]")) {
21
+ enhanceCodeViewer(container);
22
+ }
23
+
24
+ function enhanceCodeViewer(container) {
25
+ const source = container.querySelector("textarea")?.value || "";
26
+ const fallback = container.querySelector(".artifact-code-fallback");
27
+ const language = container.dataset.language || "";
28
+
29
+ const mount = document.createElement("div");
30
+ mount.className = "artifact-codemirror-mount";
31
+ container.append(mount);
32
+
33
+ new EditorView({
34
+ doc: source,
35
+ parent: mount,
36
+ extensions: [
37
+ basicSetup,
38
+ EditorState.readOnly.of(true),
39
+ EditorView.editable.of(false),
40
+ EditorView.lineWrapping,
41
+ viewerTheme,
42
+ languageExtension(language)
43
+ ]
44
+ });
45
+
46
+ fallback?.remove();
47
+ }
48
+
49
+ function languageExtension(language) {
50
+ const normalized = String(language || "").trim().toLowerCase();
51
+ if (["js", "javascript", "jsx"].includes(normalized)) {
52
+ return javascript({ jsx: true });
53
+ }
54
+ if (["ts", "typescript", "tsx"].includes(normalized)) {
55
+ return javascript({ typescript: true, jsx: normalized === "tsx" });
56
+ }
57
+ if (["html", "xml", "svg"].includes(normalized)) {
58
+ return html();
59
+ }
60
+ if (normalized === "json") {
61
+ return json();
62
+ }
63
+ if (["md", "markdown"].includes(normalized)) {
64
+ return markdown();
65
+ }
66
+ return [];
67
+ }
@@ -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
+ }