artifacty 0.14.0 → 0.16.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.ko.md +54 -8
- package/README.md +56 -10
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/docs/README.md +6 -0
- package/docs/ko/README.md +6 -0
- package/docs/ko/language-packs.md +40 -0
- package/docs/ko/smtp.md +27 -0
- package/docs/ko/upgrade-backups.md +16 -0
- package/docs/language-packs.md +38 -0
- package/docs/roadmap-design.md +31 -2
- package/docs/smtp.md +25 -0
- package/docs/upgrade-backups.md +16 -0
- package/package.json +10 -1
- package/scripts/build-ui.mjs +16 -0
- package/src/cli.js +4 -4
- package/src/client/api-docs.js +32 -0
- package/src/client/auto-apply.js +20 -0
- package/src/client/editor.js +75 -20
- package/src/client/sidebar.js +31 -0
- package/src/client/theme.js +42 -0
- package/src/client/ui-controls.css +2 -0
- package/src/client/update.js +33 -0
- package/src/client/viewer.js +17 -6
- package/src/lib/background.js +3 -1
- package/src/lib/events.js +4 -4
- package/src/lib/i18n.js +601 -9
- package/src/lib/language-settings.js +114 -0
- package/src/lib/openapi.js +93 -74
- package/src/lib/render.js +888 -628
- package/src/lib/sample-content.js +6 -6
- package/src/lib/self-update.js +12 -0
- package/src/lib/smtp-client.js +206 -0
- package/src/lib/smtp-settings.js +109 -0
- package/src/lib/storage.js +71 -17
- package/src/lib/ui-icons.js +32 -0
- package/src/lib/web-update.js +177 -0
- package/src/mcp-server.js +3 -3
- package/src/server.js +250 -18
- package/src/styles/ui-controls.scss +63 -0
- package/src/update-runner.js +7 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdir, readFile, writeFile, rename, open, rm, lstat, realpath } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { updateApplication, inspectWebUpdateService } from "./self-update.js";
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
12
|
+
const ACTIVE = new Set(["queued", "running"]);
|
|
13
|
+
const needsRecovery = (run) => run?.state === "interrupted" || run?.errorCode === "UPDATE_RECOVERY_INCOMPLETE";
|
|
14
|
+
const fail = (message, code, statusCode = 409) => Object.assign(new Error(message), { code, statusCode });
|
|
15
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
16
|
+
const unitName = (id) => `artifacty-update-${id}.service`;
|
|
17
|
+
const runnerStateRoot = (userHome) => path.join(userHome, ".local", "share", "artifacty", "update-jobs");
|
|
18
|
+
|
|
19
|
+
async function readJson(file) {
|
|
20
|
+
try { return JSON.parse(await readFile(file, "utf8")); }
|
|
21
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
22
|
+
}
|
|
23
|
+
async function writeJson(file, value) {
|
|
24
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
25
|
+
try {
|
|
26
|
+
await writeFile(temporary, JSON.stringify(value), { flag: "wx", mode: 0o600 });
|
|
27
|
+
await rename(temporary, file);
|
|
28
|
+
} finally { await rm(temporary, { force: true }); }
|
|
29
|
+
}
|
|
30
|
+
async function privateDirectory(directory) {
|
|
31
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
32
|
+
const info = await lstat(directory);
|
|
33
|
+
if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid()) || (process.platform !== "win32" && (info.mode & 0o077))) {
|
|
34
|
+
throw fail("Update state directory must be private and owned by the service user", "UNSAFE_UPDATE_STATE");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The controller does no work on construction. Read-only pages never contact npm. */
|
|
39
|
+
export function createWebUpdater({ home, packageRoot }, injected = {}) {
|
|
40
|
+
home = path.resolve(home);
|
|
41
|
+
const root = runnerStateRoot(injected.userHome || homedir());
|
|
42
|
+
const stateInsideStore = root === home || root.startsWith(`${home}${path.sep}`);
|
|
43
|
+
const directory = path.join(root, hash(home));
|
|
44
|
+
const file = path.join(directory, "status.json");
|
|
45
|
+
const now = injected.now || Date.now;
|
|
46
|
+
const run = injected.run || ((command, args) => execute(command, args, { timeout: 15000, maxBuffer: 1024 * 1024 }));
|
|
47
|
+
const inspect = injected.inspect || ((options) => inspectWebUpdateService(options));
|
|
48
|
+
const update = injected.update || updateApplication;
|
|
49
|
+
const read = async () => await readJson(file) || {};
|
|
50
|
+
const currentVersion = async () => JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")).version;
|
|
51
|
+
const publicState = async (state, session) => ({
|
|
52
|
+
currentVersion: await currentVersion(),
|
|
53
|
+
check: state.check ? { ...state.check, confirmationId: state.sessionHash === hash(session || "") ? state.check.confirmationId : null } : null,
|
|
54
|
+
run: state.run || null
|
|
55
|
+
});
|
|
56
|
+
async function locked(action) {
|
|
57
|
+
if (stateInsideStore) throw fail("Update status must be outside the artifact store", "UNSAFE_UPDATE_STATE");
|
|
58
|
+
await privateDirectory(root);
|
|
59
|
+
const [resolvedRoot, resolvedHome] = await Promise.all([realpath(root), realpath(home)]);
|
|
60
|
+
if (resolvedRoot === resolvedHome || resolvedRoot.startsWith(`${resolvedHome}${path.sep}`)) throw fail("Update status must be outside the artifact store", "UNSAFE_UPDATE_STATE");
|
|
61
|
+
await privateDirectory(directory);
|
|
62
|
+
const lockPath = path.join(directory, "dispatch.lock");
|
|
63
|
+
let lock;
|
|
64
|
+
try { lock = await open(lockPath, "wx", 0o600); }
|
|
65
|
+
catch (error) { if (error.code === "EEXIST") throw fail("Another update request is in progress", "UPDATE_BUSY"); throw error; }
|
|
66
|
+
try { await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date(now()).toISOString() })); return await action(); }
|
|
67
|
+
finally { await lock.close(); await rm(lockPath, { force: true }); }
|
|
68
|
+
}
|
|
69
|
+
async function status(session) {
|
|
70
|
+
if (stateInsideStore) return { currentVersion: await currentVersion(), supported: false, reason: "Update state must be outside the artifact store.", check: null, run: null };
|
|
71
|
+
const state = await read();
|
|
72
|
+
// An unfinished job is never inferred successful. Keep uncertain jobs locked.
|
|
73
|
+
if (ACTIVE.has(state.run?.state) && now() - Date.parse(state.run.startedAt) > 30000) {
|
|
74
|
+
try {
|
|
75
|
+
const result = await run("systemctl", ["--user", "show", unitName(state.run.id), "--property=ActiveState", "--value"]).catch((error) => {
|
|
76
|
+
// systemctl may exit nonzero for a collected unit while still
|
|
77
|
+
// returning its authoritative inactive state. Bus failures do not.
|
|
78
|
+
if (["inactive", "failed"].includes(error.stdout?.trim())) return { stdout: error.stdout };
|
|
79
|
+
throw error;
|
|
80
|
+
});
|
|
81
|
+
if (["inactive", "failed"].includes(result.stdout.trim())) {
|
|
82
|
+
return await locked(async () => {
|
|
83
|
+
const latest = await read();
|
|
84
|
+
if (latest.run?.id === state.run.id && ACTIVE.has(latest.run.state)) {
|
|
85
|
+
latest.run = { ...latest.run, state: "interrupted", finishedAt: new Date(now()).toISOString(), errorCode: "UPDATE_INTERRUPTED", error: "The update worker stopped without a final result. Inspect the retained update backup and service before retrying." };
|
|
86
|
+
await writeJson(file, latest);
|
|
87
|
+
}
|
|
88
|
+
return publicState(latest, session);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
} catch { /* Unavailable user manager is not evidence that a job has stopped. */ }
|
|
92
|
+
}
|
|
93
|
+
return publicState(state, session);
|
|
94
|
+
}
|
|
95
|
+
async function check(session) {
|
|
96
|
+
return locked(async () => {
|
|
97
|
+
const state = await read();
|
|
98
|
+
if (ACTIVE.has(state.run?.state) || needsRecovery(state.run)) throw fail("Resolve the existing update before checking again", "UPDATE_BUSY");
|
|
99
|
+
let eligible = false;
|
|
100
|
+
try { await inspect({ home, pid: process.pid }); eligible = true; } catch { /* Read-only version checks also support unmanaged installations. */ }
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
const checked = await update({ home, packageRoot, check: true });
|
|
104
|
+
result = { currentVersion: await currentVersion(), targetVersion: checked.targetVersion, supported: eligible && checked.supported, updateAvailable: checked.updateAvailable };
|
|
105
|
+
if (!result.supported) result.reason = "Web updates require this server to be the non-root Linux systemd user service.";
|
|
106
|
+
} catch (error) {
|
|
107
|
+
result = { currentVersion: await currentVersion(), supported: eligible, updateAvailable: false, reason: "The latest release could not be checked. Try again later.", errorCode: "UPDATE_CHECK_FAILED" };
|
|
108
|
+
}
|
|
109
|
+
state.check = { ...result, check: undefined, confirmationId: randomUUID(), checkedAt: new Date(now()).toISOString(), expiresAt: new Date(now() + 10 * 60 * 1000).toISOString() };
|
|
110
|
+
state.sessionHash = hash(session);
|
|
111
|
+
await writeJson(file, state);
|
|
112
|
+
return publicState(state, session);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function start({ session, confirmationId, targetVersion, confirmUpdate }) {
|
|
116
|
+
return locked(async () => {
|
|
117
|
+
const state = await read();
|
|
118
|
+
if (ACTIVE.has(state.run?.state) || needsRecovery(state.run)) throw fail("An update is already running or needs recovery", "UPDATE_BUSY");
|
|
119
|
+
const checked = state.check;
|
|
120
|
+
if (confirmUpdate !== "true" || !checked?.supported || !checked.updateAvailable || checked.confirmationId !== confirmationId || state.sessionHash !== hash(session) || checked.targetVersion !== targetVersion || Date.parse(checked.expiresAt) <= now()) {
|
|
121
|
+
throw fail("Check for updates again and confirm the displayed version", "UPDATE_CONFIRMATION_REQUIRED", 400);
|
|
122
|
+
}
|
|
123
|
+
await inspect({ home, pid: process.pid });
|
|
124
|
+
if (checked.currentVersion !== await currentVersion()) throw fail("The installed version changed; check again", "UPDATE_CONFIRMATION_REQUIRED", 400);
|
|
125
|
+
const id = randomUUID();
|
|
126
|
+
const requestFile = path.join(root, `${id}.json`);
|
|
127
|
+
await writeJson(requestFile, { id, home, packageRoot, targetVersion, statusFile: file });
|
|
128
|
+
state.check.confirmationId = null;
|
|
129
|
+
state.run = { id, state: "queued", targetVersion, startedAt: new Date(now()).toISOString(), phase: "Starting an independent update worker." };
|
|
130
|
+
await writeJson(file, state);
|
|
131
|
+
try {
|
|
132
|
+
await run("systemd-run", ["--user", `--unit=${unitName(id)}`, "--collect", "--property=Type=exec", "--", process.execPath, path.join(packageRoot, "src", "update-runner.js"), id]);
|
|
133
|
+
} catch {
|
|
134
|
+
// A timeout can occur after systemd accepted the unit. Preserve the job
|
|
135
|
+
// and its request; polling will resolve a stopped worker as interrupted.
|
|
136
|
+
// Never overwrite progress or completion written by that worker.
|
|
137
|
+
return publicState(await read(), session);
|
|
138
|
+
}
|
|
139
|
+
return publicState(state, session);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return { status, check, start };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Invoked only by the fixed systemd transient service, never the web server. */
|
|
146
|
+
export async function runWebUpdate(id, injected = {}) {
|
|
147
|
+
if (!UUID.test(id || "")) throw fail("Invalid update job", "INVALID_UPDATE_JOB", 400);
|
|
148
|
+
const root = runnerStateRoot(injected.userHome || homedir());
|
|
149
|
+
await privateDirectory(root);
|
|
150
|
+
const requestFile = path.join(root, `${id}.json`);
|
|
151
|
+
const info = await lstat(requestFile);
|
|
152
|
+
if (!info.isFile() || info.isSymbolicLink() || (process.platform !== "win32" && (info.mode & 0o077)) || (process.getuid && info.uid !== process.getuid())) throw fail("Unsafe update request", "UNSAFE_UPDATE_STATE");
|
|
153
|
+
const request = await readJson(requestFile);
|
|
154
|
+
const expectedFile = path.join(root, hash(path.resolve(request.home)), "status.json");
|
|
155
|
+
if (request.id !== id || request.statusFile !== expectedFile || !path.isAbsolute(request.packageRoot)) throw fail("Invalid update request", "INVALID_UPDATE_JOB");
|
|
156
|
+
const sourceRoot = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
157
|
+
if (await realpath(request.packageRoot) !== await realpath(injected.packageRoot || sourceRoot) || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(request.targetVersion)) throw fail("Update package identity does not match the worker", "INVALID_UPDATE_JOB");
|
|
158
|
+
await privateDirectory(path.dirname(expectedFile));
|
|
159
|
+
// A durable claim makes replays fail closed, including after a worker crash.
|
|
160
|
+
const claim = await open(path.join(root, `${id}.claimed`), "wx", 0o600);
|
|
161
|
+
await claim.close();
|
|
162
|
+
const state = await readJson(expectedFile);
|
|
163
|
+
if (state?.run?.id !== id || state.run.state !== "queued") throw fail("Update job was already consumed", "INVALID_UPDATE_JOB");
|
|
164
|
+
const save = async (changes) => { state.run = { ...state.run, ...changes }; await writeJson(expectedFile, state); };
|
|
165
|
+
await save({ state: "running", phase: "Preparing update." });
|
|
166
|
+
let progressQueue = Promise.resolve();
|
|
167
|
+
try {
|
|
168
|
+
const result = await (injected.update || updateApplication)({ home: request.home, packageRoot: request.packageRoot, targetVersion: request.targetVersion,
|
|
169
|
+
onProgress: (phase) => { progressQueue = progressQueue.then(() => save({ phase })); }
|
|
170
|
+
});
|
|
171
|
+
await progressQueue;
|
|
172
|
+
await save({ state: "succeeded", phase: "Update completed.", finishedAt: new Date().toISOString(), result });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
await progressQueue.catch(() => {});
|
|
175
|
+
await save({ state: "failed", finishedAt: new Date().toISOString(), errorCode: /^[A-Z_]+$/.test(error.code || "") ? error.code : "SELF_UPDATE_FAILED", error: "Update failed. Inspect the service and retained recovery backup before retrying.", result: { backupId: error.backupId, backupPath: error.backupPath } });
|
|
176
|
+
} finally { await rm(requestFile, { force: true }); }
|
|
177
|
+
}
|
package/src/mcp-server.js
CHANGED
|
@@ -100,7 +100,7 @@ const nativeArtifactInputSchema = {
|
|
|
100
100
|
visibility: {
|
|
101
101
|
type: "string",
|
|
102
102
|
enum: VISIBILITY_VALUES,
|
|
103
|
-
description: "'team' (default)
|
|
103
|
+
description: "In public server mode, 'public' allows anonymous reading, 'team' (default) requires a signed-in user, and 'private' is owner/admin only. Private server mode always requires owner/admin or explicit sharing."
|
|
104
104
|
},
|
|
105
105
|
relations: relationsInputSchema
|
|
106
106
|
},
|
|
@@ -264,7 +264,7 @@ const tools = [
|
|
|
264
264
|
visibility: {
|
|
265
265
|
type: "string",
|
|
266
266
|
enum: VISIBILITY_VALUES,
|
|
267
|
-
description: "'team' (default)
|
|
267
|
+
description: "In public server mode, 'public' allows anonymous reading, 'team' (default) requires a signed-in user, and 'private' is owner/admin only. Private server mode always requires owner/admin or explicit sharing. Prefer artifacty_set_visibility to change visibility on its own."
|
|
268
268
|
},
|
|
269
269
|
relations: relationsInputSchema
|
|
270
270
|
},
|
|
@@ -280,7 +280,7 @@ const tools = [
|
|
|
280
280
|
{
|
|
281
281
|
name: "artifacty_set_visibility",
|
|
282
282
|
title: "Set Artifact Visibility",
|
|
283
|
-
description: "Change an artifact's visibility
|
|
283
|
+
description: "Change an artifact's visibility to 'public', 'team', or 'private'. Requires the artifact's owner or an admin.",
|
|
284
284
|
inputSchema: {
|
|
285
285
|
type: "object",
|
|
286
286
|
properties: {
|