artifacty 0.14.0 → 0.15.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 +6 -2
- package/README.md +6 -2
- package/docs/ko/upgrade-backups.md +16 -0
- package/docs/upgrade-backups.md +16 -0
- package/package.json +1 -1
- package/src/client/editor.js +75 -20
- package/src/client/update.js +33 -0
- package/src/lib/background.js +3 -1
- package/src/lib/i18n.js +106 -0
- package/src/lib/render.js +378 -272
- package/src/lib/sample-content.js +6 -6
- package/src/lib/self-update.js +12 -0
- package/src/lib/web-update.js +177 -0
- package/src/server.js +39 -4
- package/src/update-runner.js +7 -0
|
@@ -30,7 +30,7 @@ Open http://127.0.0.1:8787. The server binds to localhost by default. To start a
|
|
|
30
30
|
5. Inspect the version history.
|
|
31
31
|
6. Compare the versions with a diff.
|
|
32
32
|
7. Add tags.
|
|
33
|
-
8. Use search
|
|
33
|
+
8. Use search to find the artifact again. Open **Advanced filters** or **Saved views** in the main dashboard when needed. These sections start collapsed; closing them preserves your filters.
|
|
34
34
|
9. Add a comment.
|
|
35
35
|
10. Request a review.
|
|
36
36
|
11. As the owner or administrator, record an approved or changes-requested status.
|
|
@@ -42,7 +42,7 @@ Open http://127.0.0.1:8787. The server binds to localhost by default. To start a
|
|
|
42
42
|
|
|
43
43
|
## Choose your access model
|
|
44
44
|
|
|
45
|
-
Before accounts exist, Artifacty operates as a local single-user store. Once accounts exist, use personal accounts and tokens. Create the initial administrator at **/login**.
|
|
45
|
+
Before accounts exist, Artifacty operates as a local single-user store. Once accounts exist, use personal accounts and tokens. Create the initial administrator at **/login**. In the desktop navigation rail, open **Administration**. On narrower screens, open **Menu** first. Use **Users** (**/admin/users**) for accounts. Use **Access** (**/admin/access**) for teams, access mode, and network rules. Administration also contains Backup, Retention, Webhooks, and Server update. In private mode, use personal authentication for ordinary artifact reads and creation. Server-wide tokens do not authorize those operations. Local CLI operations remain trusted store administration.
|
|
46
46
|
|
|
47
47
|
A share link is a separate read-only capability with an expiry time. It does not grant account membership. Default expiry is seven days. Owners can share the latest version or pin one version. Share links while the server is in private mode require explicit administrator opt-in. In public mode, an owner can link a private-visibility artifact without that site toggle. When you no longer need a link, revoke it. An owner change invalidates existing links.
|
|
48
48
|
|
|
@@ -70,10 +70,10 @@ If the current store is corrupt, preserve it and copy the selected snapshot to a
|
|
|
70
70
|
<header><p>ARTIFACTY / REFERENCE</p><h1>From an agent output to a shared record</h1><p>Use this inventory to choose the right publishing, review, access, and maintenance workflow. Use Node.js 22.12.0 or newer. The current store schema is 10. Mermaid 12.1.0 keeps the classic look, dagre layout, and default theme. Use an ES2024-capable browser, such as Safari 17.4 or newer. The optional Babel 8 React renderer requires Node ^22.18.0 or >=24.11.0.</p></header>
|
|
71
71
|
<nav aria-label="Guide sections"><a href="#formats">Formats</a> · <a href="#history">History and discovery</a> · <a href="#collaboration">Collaboration</a> · <a href="#access">Access</a> · <a href="#operations">Operations</a></nav>
|
|
72
72
|
<section id="formats"><h2>Formats and imports</h2><p>The 13 formats are HTML, Markdown, text, JSON, code, SVG, Mermaid, React, SARIF, CSV, image, video, and notebook. Choose an artifact type separately: a document, handoff, code review, test report, dashboard, diagram, component, table, or another supported type.</p><p>Bundles group multiple files under one artifact. Supported binary bundle assets include PDF, DOCX, XLSX, and ZIP. Importers normalize outputs from Claude, Codex, Gemini, GitHub Copilot, Cursor, and generic inputs. Use the browser import screen or <code>artifacty import --agent codex --file handoff.md</code>.</p><p>Artifact content is untrusted. Browser previews use constrained rendering. A public HTML share preview is static. It blocks scripts and external resource loading. Share links show other active formats as source. Do not expect those previews to preserve interactive application behavior.</p></section>
|
|
73
|
-
<section id="history"><h2>Versions, discovery, and retrieval</h2><ul><li>Updates create versions with immutable content files. You can read a historical version and compare versions with a diff. To avoid overwriting a concurrent edit, use an expected-version check.</li><li>Archive and restore artifacts instead of losing their history. Integrity checks verify stored files, hashes, and orphaned files.</li><li>Search by keyword and filter by tags, source agent, artifact type, publisher, dates, review status, relationships, and archive state. Pagination and grouping help you use large lists. To reuse a set of filters, save it as a view.</li><li>When available, SQLite FTS5 accelerates keyword search. Otherwise, search uses metadata. Optional embeddings enable semantic and hybrid search. Without a configured provider, those modes use keyword search. Rebuild search or embedding indexes after configuration changes.</li></ul></section>
|
|
73
|
+
<section id="history"><h2>Versions, discovery, and retrieval</h2><ul><li>Updates create versions with immutable content files. You can read a historical version and compare versions with a diff. To avoid overwriting a concurrent edit, use an expected-version check.</li><li>Archive and restore artifacts instead of losing their history. Integrity checks verify stored files, hashes, and orphaned files.</li><li>Search by keyword and filter by tags, source agent, artifact type, publisher, dates, review status, relationships, and archive state. Pagination and grouping help you use large lists. To reuse a set of filters, save it as a view. Search stays visible in the main dashboard. Open Advanced filters or Saved views when needed. Both sections start collapsed; closing them preserves selected filters.</li><li>When available, SQLite FTS5 accelerates keyword search. Otherwise, search uses metadata. Optional embeddings enable semantic and hybrid search. Without a configured provider, those modes use keyword search. Rebuild search or embedding indexes after configuration changes.</li></ul></section>
|
|
74
74
|
<section id="collaboration"><h2>Relationships, comments, review, and notifications</h2><p>Connect artifacts using derived-from, supersedes, reviews, references, or part-of relations. Comments support threads and anchors. After you address a thread, resolve it. Review status progresses among none, pending, changes-requested, and approved.</p><p>The inbox at <code>/inbox</code> collects relevant shares, comments, review activity, and new versions. Notifications depend on the user and current artifact access. You can mark items read individually or together. A notification does not grant access.</p><p>Audit records support investigation. Event polling and server-sent events expose authorized activity streams. Webhooks deliver configured events to validated destinations. Use event filters, replay cursors, and webhook administration for integrations.</p></section>
|
|
75
|
-
<section id="access"><h2>Accounts, permissions, share links, and network rules</h2><p>Create the initial administrator at <code>/login</code>.
|
|
76
|
-
<section id="operations"><h2>Interfaces and maintenance</h2><ul><li>The browser supports creation, import, viewing, editing, discovery, review, inbox, and administration.</li><li>The CLI supports serve/start/stop/status, publish/import/update/read/list, history and diff, relations/comments/review, saved views, event watching, account import, backup, retention, indexes, integrity, and diagnostics. Run <code>artifacty help</code> for exact flags.</li><li>The JSON API exposes artifacts and operational endpoints under <code>/api/</code>. MCP exposes agent tools through stdio and optional HTTP transport. Use <code>artifacty install claude --dry-run</code> to inspect integration configuration and <code>artifacty check</code> to verify tool discovery.</li><li>Export and import JSON backups to move a store. Upgrade snapshots separately preserve raw SQLite, referenced versions, and any legacy index. Schema migrations always require a verified snapshot. Set <code>ARTIFACTY_BACKUP_ON_UPDATE=true</code> for application-version snapshots, including an unknown-version baseline. <code>ARTIFACTY_UPGRADE_BACKUP_KEEP</code> defaults to 3 and accepts 1–1000.</li><li>Use <code>artifacty backups list</code>, <code>artifacty backups verify <id></code>, and <code>artifacty backups restore <id> --confirm restore-store</code>. Stop every server and MCP process first. Restore saves a recovery snapshot and rolls back credentials and security state. Revoked sessions, tokens, or links can become valid again. For a corrupt store, copy the snapshot to a new empty home and restore there. Preserve interrupted <code>.restore-*</code> directories and use their <code>restore.json</code> recoveryBackupId. See <code>docs/upgrade-backups.md</code>.</li><li>Retention supports archive rules, archived-item purge, audit/event history limits, and protected tags. Before an intentional purge, inspect a dry run.</li><li><code>artifacty samples install</code> installs these manuals idempotently. <code>artifacty samples remove</code> archives only managed samples in the current scope. Reinstallation restores existing versions and user edits.</li></ul><p>Keep ARTIFACTY_HOME private. Older builds refuse schema-10 stores. Before changing builds, preserve a backup. Consult repository integration documentation for concrete agent and API examples.</p></section>
|
|
75
|
+
<section id="access"><h2>Accounts, permissions, share links, and network rules</h2><p>Create the initial administrator at <code>/login</code>. In the navigation, administrators open <strong>Administration</strong>. Use Users at <code>/admin/users</code> for accounts. Use Access at <code>/admin/access</code> for teams, access mode, and network rules. Backup, Retention, Webhooks, and Server update are in the same group. The artifact <strong>Sharing</strong> panel shows effective access and its sources. Direct-user and team grants support read, comment, and edit. Where grants overlap, the strongest permission wins. Ownership and administrator management are separate from grants. Token scopes limit token actions. They do not grant artifact access.</p><p>Private mode requires an active personal account. Server-wide tokens do not authorize ordinary artifact reads or creation in private mode. Administrator APIs have their own authorization rules. Personal tokens remain constrained by both their scopes and the user's artifact permissions. Trusted local CLI operations can administer the local store directly.</p><p>Read-only share links default to seven days and can target the latest version or a pinned version. Share links while the server is in private mode require explicit administrator opt-in. In public mode, owners may link private-visibility artifacts without that site toggle. Links can be revoked, and owner changes invalidate them. A valid capability permits only its read-only shared surface.</p><p>Network allow rules accept IP addresses and CIDRs. First, stage a change. Next, verify access from the intended peer. Then confirm the change within 120 seconds or roll it back. Pending state survives a restart. Before the next access, the server checks for expiry. If the change has expired, the server restores the previous rules. Rules use the socket peer address, not forwarded proxy headers.</p></section>
|
|
76
|
+
<section id="operations"><h2>Interfaces and maintenance</h2><ul><li>The browser supports creation, import, viewing, editing, discovery, review, inbox, and administration. On desktop, use the navigation rail. On narrower screens, open Menu. Artifacts and Inbox lead to your work; New and Import add content. Account contains Account & API tokens, password changes, and sign-out. API docs and language selection are also in the navigation. Administrators can open Server update at /admin/update. Checking the latest release does not install it. For a supported non-root Linux systemd user service, confirm the displayed version and backup/restart before starting. Keep the page open to reconnect and read the result.</li><li>The CLI supports serve/start/stop/status, publish/import/update/read/list, history and diff, relations/comments/review, saved views, event watching, account import, backup, retention, indexes, integrity, and diagnostics. Run <code>artifacty help</code> for exact flags.</li><li>The JSON API exposes artifacts and operational endpoints under <code>/api/</code>. MCP exposes agent tools through stdio and optional HTTP transport. Use <code>artifacty install claude --dry-run</code> to inspect integration configuration and <code>artifacty check</code> to verify tool discovery.</li><li>Export and import JSON backups to move a store. Upgrade snapshots separately preserve raw SQLite, referenced versions, and any legacy index. Schema migrations always require a verified snapshot. Set <code>ARTIFACTY_BACKUP_ON_UPDATE=true</code> for application-version snapshots, including an unknown-version baseline. <code>ARTIFACTY_UPGRADE_BACKUP_KEEP</code> defaults to 3 and accepts 1–1000.</li><li>Use <code>artifacty backups list</code>, <code>artifacty backups verify <id></code>, and <code>artifacty backups restore <id> --confirm restore-store</code>. Stop every server and MCP process first. Restore saves a recovery snapshot and rolls back credentials and security state. Revoked sessions, tokens, or links can become valid again. For a corrupt store, copy the snapshot to a new empty home and restore there. Preserve interrupted <code>.restore-*</code> directories and use their <code>restore.json</code> recoveryBackupId. See <code>docs/upgrade-backups.md</code>.</li><li>Retention supports archive rules, archived-item purge, audit/event history limits, and protected tags. Before an intentional purge, inspect a dry run.</li><li><code>artifacty samples install</code> installs these manuals idempotently. <code>artifacty samples remove</code> archives only managed samples in the current scope. Reinstallation restores existing versions and user edits.</li></ul><p>Keep ARTIFACTY_HOME private. Older builds refuse schema-10 stores. Before changing builds, preserve a backup. Consult repository integration documentation for concrete agent and API examples.</p></section>
|
|
77
77
|
<footer><p>This guide is a managed sample artifact. Editing it creates your own version. Reinstalling samples preserves that work.</p></footer>
|
|
78
78
|
</main></body></html>`
|
|
79
79
|
},
|
|
@@ -171,7 +171,7 @@ The four sample manuals are installed. You can open the browser dashboard. To pr
|
|
|
171
171
|
|
|
172
172
|
## Administration practice
|
|
173
173
|
|
|
174
|
-
As an administrator, open **/admin/access** to inspect access mode and network rules. You can switch access mode or stage a network change.
|
|
174
|
+
As an administrator, open **Administration → Access** (**/admin/access**) to inspect access mode and network rules. On narrower screens, open **Menu** first. You can switch access mode or stage a network change.
|
|
175
175
|
|
|
176
176
|
1. Before you stage a network change, keep your actual socket peer address in the allowed rules.
|
|
177
177
|
2. Stage the change.
|
package/src/lib/self-update.js
CHANGED
|
@@ -197,6 +197,18 @@ async function waitHealthy(run, home, health, sleep, identity, args) {
|
|
|
197
197
|
throw failure("The updated service did not pass its PID and HTTP health checks", "UPDATE_HEALTH_FAILED");
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/** Verify that a web administrator is operating the managed service itself. */
|
|
201
|
+
export async function inspectWebUpdateService({ home, pid = process.pid }, injected = {}) {
|
|
202
|
+
if ((injected.platform || process.platform) !== "linux" || (injected.uid ?? process.geteuid?.()) === 0) {
|
|
203
|
+
throw failure("Web updates require a non-root Linux systemd user service", "UNSUPPORTED_SERVICE");
|
|
204
|
+
}
|
|
205
|
+
const run = injected.run || ((command, args) => execute(command, args, { timeout: 10000, maxBuffer: 1024 * 1024 }));
|
|
206
|
+
const service = await discoverService(run, path.resolve(home));
|
|
207
|
+
if (!service.active || service.pid !== pid) throw failure("This server is not the managed Artifacty service", "SERVICE_IDENTITY_MISMATCH");
|
|
208
|
+
await (injected.verifyServiceIdentity || verifyServiceIdentity)(pid, service.args);
|
|
209
|
+
return { currentVersion: service.version };
|
|
210
|
+
}
|
|
211
|
+
|
|
200
212
|
/** Update the managed Linux user service. Dependencies are injectable for offline tests. */
|
|
201
213
|
export async function updateApplication(options = {}, injected = {}) {
|
|
202
214
|
const env = injected.env || process.env;
|
|
@@ -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/server.js
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
import { assertNetworkAccess, getNetworkPolicy, stageNetworkPolicy, getPendingNetworkPolicy, confirmNetworkPolicy, rollbackNetworkPolicy } from "./lib/network-policy.js";
|
|
78
78
|
import { getShareLinkPolicy, setShareLinkPolicy, createShareLink, listShareLinks, revokeShareLink, resolveShareLink } from "./lib/share-links.js";
|
|
79
79
|
import { installSamples, removeSamples } from "./lib/samples.js";
|
|
80
|
+
import { createWebUpdater } from "./lib/web-update.js";
|
|
80
81
|
import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
|
|
81
82
|
import { convertAgentArtifact, decodeMediaContent, mediaContentType } from "./lib/converters.js";
|
|
82
83
|
import { createLineDiff, createStructuredDiff, diffFormatFor, resolveDiffView } from "./lib/diff.js";
|
|
@@ -111,6 +112,7 @@ import {
|
|
|
111
112
|
renderAccountPage,
|
|
112
113
|
renderAdminBackupPage,
|
|
113
114
|
renderAdminAccessPage,
|
|
115
|
+
renderAdminUpdatePage,
|
|
114
116
|
renderArtifactSharingPage,
|
|
115
117
|
renderSharedArtifactPage,
|
|
116
118
|
renderInboxPage,
|
|
@@ -189,6 +191,7 @@ export async function startServer(options = {}) {
|
|
|
189
191
|
options.portFallback === true ||
|
|
190
192
|
(!explicitPort && options.portFallback !== false);
|
|
191
193
|
const store = createStore({ home: options.home });
|
|
194
|
+
const webUpdater = options.webUpdater || createWebUpdater({ home: store.home, packageRoot: PACKAGE_ROOT });
|
|
192
195
|
const security = securityConfig(options);
|
|
193
196
|
const mcpHttp = Boolean(options.mcpHttp || process.env.ARTIFACTY_MCP_HTTP === "true");
|
|
194
197
|
validateServerExposure({ host, config: security });
|
|
@@ -202,7 +205,7 @@ export async function startServer(options = {}) {
|
|
|
202
205
|
allowPortFallback,
|
|
203
206
|
createServer(port) {
|
|
204
207
|
const candidateServer = http.createServer((request, response) => {
|
|
205
|
-
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp }).catch((error) => {
|
|
208
|
+
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater }).catch((error) => {
|
|
206
209
|
sendError(response, error);
|
|
207
210
|
});
|
|
208
211
|
});
|
|
@@ -307,7 +310,7 @@ function closeServer(server) {
|
|
|
307
310
|
});
|
|
308
311
|
}
|
|
309
312
|
|
|
310
|
-
export async function handleRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false }) {
|
|
313
|
+
export async function handleRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater }) {
|
|
311
314
|
await assertNetworkAccess(store, request.socket.remoteAddress);
|
|
312
315
|
const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
|
|
313
316
|
const pathname = decodeURIComponent(url.pathname);
|
|
@@ -318,6 +321,10 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
318
321
|
const headOnly = request.method === "HEAD";
|
|
319
322
|
const method = headOnly ? "GET" : request.method;
|
|
320
323
|
|
|
324
|
+
if (method === "GET" && pathname === "/assets/update.js") {
|
|
325
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "update.js"), headOnly, request);
|
|
326
|
+
}
|
|
327
|
+
|
|
321
328
|
const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
|
|
322
329
|
if (publicShareMatch && method === "GET") {
|
|
323
330
|
const shared = await resolveShareLink(store, publicShareMatch[1]);
|
|
@@ -546,6 +553,31 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
546
553
|
}
|
|
547
554
|
|
|
548
555
|
const browserAccess = accessContext(request, currentUser);
|
|
556
|
+
if (["/admin/update", "/admin/update/status", "/admin/update/check", "/admin/update/start"].includes(pathname)) {
|
|
557
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
558
|
+
requireAdmin(currentUser);
|
|
559
|
+
response.setHeader("cache-control", "no-store");
|
|
560
|
+
const updater = webUpdater || createWebUpdater({ home: store.home, packageRoot: PACKAGE_ROOT });
|
|
561
|
+
const session = sessionTokenFromRequest(request);
|
|
562
|
+
if (method === "GET" && pathname === "/admin/update/status") return sendJson(response, await updater.status(session), 200, headOnly);
|
|
563
|
+
if (method === "GET" && pathname === "/admin/update") {
|
|
564
|
+
return sendHtml(response, renderAdminUpdatePage({ baseUrl, user: currentUser, locale, currentPath, status: await updater.status(session) }), 200, headOnly);
|
|
565
|
+
}
|
|
566
|
+
if (method === "POST" && ["/admin/update/check", "/admin/update/start"].includes(pathname)) {
|
|
567
|
+
assertLocalOrigin(request);
|
|
568
|
+
const expectedOrigin = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
|
|
569
|
+
if (request.headers.origin !== expectedOrigin) throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "UPDATE_ORIGIN_REQUIRED" });
|
|
570
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
571
|
+
const body = await readFormBody(request);
|
|
572
|
+
if (pathname.endsWith("/check")) {
|
|
573
|
+
const status = await updater.check(session);
|
|
574
|
+
return sendHtml(response, renderAdminUpdatePage({ baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/update", status }));
|
|
575
|
+
}
|
|
576
|
+
await updater.start({ session, confirmationId: body.confirmationId, targetVersion: body.targetVersion, confirmUpdate: body.confirmUpdate });
|
|
577
|
+
return sendRedirect(response, localizedHref("/admin/update", localeFromBodyOrUrl(body, url)));
|
|
578
|
+
}
|
|
579
|
+
return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
|
|
580
|
+
}
|
|
549
581
|
const browserAudit = { surface: "web", actor: currentUser?.email, userId: currentUser?.id };
|
|
550
582
|
const adminAccessPage = async (extra = {}) => renderAdminAccessPage({
|
|
551
583
|
baseUrl, user: currentUser, mode: await getAccessMode(store),
|
|
@@ -1048,7 +1080,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1048
1080
|
}
|
|
1049
1081
|
|
|
1050
1082
|
if (method === "GET" && pathname === "/new") {
|
|
1051
|
-
return sendHtml(response, renderNewArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
|
|
1083
|
+
return sendHtml(response, renderNewArtifactPage({ baseUrl, authToken, user: currentUser, locale, currentPath }), 200, headOnly);
|
|
1052
1084
|
}
|
|
1053
1085
|
|
|
1054
1086
|
if (method === "POST" && pathname === "/new") {
|
|
@@ -1075,7 +1107,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1075
1107
|
}
|
|
1076
1108
|
|
|
1077
1109
|
if (method === "GET" && pathname === "/import") {
|
|
1078
|
-
return sendHtml(response, renderImportArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
|
|
1110
|
+
return sendHtml(response, renderImportArtifactPage({ baseUrl, authToken, user: currentUser, locale, currentPath }), 200, headOnly);
|
|
1079
1111
|
}
|
|
1080
1112
|
|
|
1081
1113
|
if (method === "POST" && pathname === "/import") {
|
|
@@ -1342,6 +1374,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1342
1374
|
access: accessContext(request, currentUser)
|
|
1343
1375
|
});
|
|
1344
1376
|
return sendHtml(response, renderArtifactFormPage({
|
|
1377
|
+
user: currentUser,
|
|
1345
1378
|
mode: "edit",
|
|
1346
1379
|
baseUrl,
|
|
1347
1380
|
artifact,
|
|
@@ -1388,6 +1421,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1388
1421
|
tags: splitTags(body.tags)
|
|
1389
1422
|
};
|
|
1390
1423
|
return sendHtml(response, renderArtifactFormPage({
|
|
1424
|
+
user: currentUser,
|
|
1391
1425
|
mode: "edit",
|
|
1392
1426
|
baseUrl,
|
|
1393
1427
|
artifact: unsavedArtifact,
|
|
@@ -1419,6 +1453,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1419
1453
|
? createStructuredDiff(from.content, to.content, { format: diffFormat })
|
|
1420
1454
|
: null;
|
|
1421
1455
|
return sendHtml(response, renderDiffPage({
|
|
1456
|
+
user: currentUser,
|
|
1422
1457
|
artifact: latest,
|
|
1423
1458
|
fromVersion: from.version,
|
|
1424
1459
|
toVersion: to.version,
|