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/README.md +142 -33
- package/docs/artifact-schema-v1.md +26 -1
- package/docs/integrations.md +84 -8
- package/docs/network-sharing.md +41 -0
- package/docs/release-checklist.md +13 -0
- package/docs/sarif-csv-artifact-plan.md +65 -0
- package/package.json +15 -3
- package/scripts/smoke.sh +2 -1
- package/src/cli.js +77 -8
- package/src/client/editor.js +35 -2
- package/src/client/viewer.js +67 -0
- package/src/lib/background.js +344 -0
- package/src/lib/converters.js +448 -10
- package/src/lib/editor-assets.js +45 -2
- package/src/lib/installer.js +13 -4
- package/src/lib/render.js +348 -8
- package/src/lib/storage.js +60 -4
- package/src/lib/token.js +25 -0
- package/src/mcp-server.js +8 -6
- package/src/server.js +104 -25
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:
|
|
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
|
|
296
|
-
artifacty
|
|
297
|
-
artifacty
|
|
298
|
-
artifacty
|
|
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
|
}
|
package/src/client/editor.js
CHANGED
|
@@ -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 (
|
|
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
|
+
}
|