artifacty 0.13.1 → 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.
@@ -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, filters, or saved views to find the artifact again.
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**. Administrators manage accounts at **/admin/users**. The Access page links to that page. Administrators manage teams, access mode, and network rules at **/admin/access**. 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.
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 &gt;=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>. Administrators manage accounts at <code>/admin/users</code>. The Access page links to that page. Administrators manage teams, access mode, and network rules at <code>/admin/access</code>. 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.</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 &lt;id&gt;</code>, and <code>artifacty backups restore &lt;id&gt; --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 &amp; 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 &lt;id&gt;</code>, and <code>artifacty backups restore &lt;id&gt; --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.
@@ -0,0 +1,351 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { access, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
4
+ import { constants } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { randomUUID } from "node:crypto";
9
+ import { createUpgradeBackup, verifyUpgradeBackup, restoreUpgradeBackup } from "./upgrade-backup.js";
10
+
11
+ const execute = promisify(execFile);
12
+ const UNIT = "com.artifacty.server.service";
13
+ const LAUNCHER_MARK = "// artifacty-managed-launcher";
14
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
15
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ const failure = (message, code = "SELF_UPDATE_FAILED") => Object.assign(new Error(message), { code });
17
+
18
+ function compareVersions(left, right) {
19
+ const parse = (value) => { const [core, ...pre] = value.split("+")[0].split("-"); return { core: core.split(".").map(Number), pre: pre.join("-") }; };
20
+ const a = parse(left); const b = parse(right);
21
+ for (let index = 0; index < 3; index++) if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
22
+ if (!a.pre || !b.pre) return a.pre === b.pre ? 0 : !a.pre ? 1 : -1;
23
+ const ap = a.pre.split("."); const bp = b.pre.split(".");
24
+ for (let index = 0; index < Math.max(ap.length, bp.length); index++) {
25
+ if (ap[index] === bp[index]) continue;
26
+ if (ap[index] === undefined) return -1;
27
+ if (bp[index] === undefined) return 1;
28
+ const an = /^\d+$/.test(ap[index]); const bn = /^\d+$/.test(bp[index]);
29
+ if (an && bn) return Number(ap[index]) > Number(bp[index]) ? 1 : -1;
30
+ if (an !== bn) return an ? -1 : 1;
31
+ return ap[index] > bp[index] ? 1 : -1;
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ async function readOptional(file) {
37
+ try { return await readFile(file, "utf8"); }
38
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
39
+ }
40
+
41
+ function quoteUnit(value) {
42
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
43
+ }
44
+
45
+ // Accept the simple ExecStart form produced by Artifacty's service installer.
46
+ // Reject specifiers, environment expansion, and ambiguous quoting before stopping it.
47
+ function unitArguments(text) {
48
+ const commands = [];
49
+ for (const line of text.replace(/\\\n\s*/g, " ").split("\n")) {
50
+ if (!line.startsWith("ExecStart=")) continue;
51
+ const command = line.slice(10).trim();
52
+ if (!command) commands.length = 0;
53
+ else commands.push(command);
54
+ }
55
+ if (commands.length !== 1 || /[$%\r\n]/.test(commands[0])) throw failure("Unsupported service ExecStart. No changes were made.", "UNSUPPORTED_SERVICE");
56
+ const args = [];
57
+ const expression = /\s*(?:"((?:[^"\\]|\\["\\])*)"|'([^']*)'|([^\s"'\\]+))/gy;
58
+ let offset = 0;
59
+ while (offset < commands[0].length) {
60
+ expression.lastIndex = offset;
61
+ const match = expression.exec(commands[0]);
62
+ if (!match) throw failure("Unsupported service argument quoting", "UNSUPPORTED_SERVICE");
63
+ args.push(match[1] !== undefined ? match[1].replace(/\\(["\\])/g, "$1") : match[2] ?? match[3]);
64
+ offset = expression.lastIndex;
65
+ }
66
+ if (args.length < 2 || !path.isAbsolute(args[0]) || !path.isAbsolute(args[1]) || !args[1].endsWith("/src/server.js")) {
67
+ throw failure("The user service does not run an identifiable Artifacty server", "UNSUPPORTED_SERVICE");
68
+ }
69
+ return args;
70
+ }
71
+
72
+ async function packageAt(root) {
73
+ const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
74
+ if (manifest.name !== "artifacty" || !VERSION.test(manifest.version)) throw failure("Invalid Artifacty package metadata");
75
+ return manifest;
76
+ }
77
+
78
+ async function discoverService(run, home) {
79
+ const result = await run("systemctl", ["--user", "show", UNIT, "--no-pager", "--property=LoadState,ActiveState,MainPID"]);
80
+ const properties = Object.fromEntries(result.stdout.trim().split("\n").map((line) => {
81
+ const index = line.indexOf("="); return [line.slice(0, index), line.slice(index + 1)];
82
+ }));
83
+ if (properties.LoadState !== "loaded" || !["active", "inactive", "failed"].includes(properties.ActiveState)) {
84
+ throw failure("Install a stable Artifacty systemd user service before automatic updates", "UNSUPPORTED_SERVICE");
85
+ }
86
+ const text = (await run("systemctl", ["--user", "cat", UNIT, "--no-pager"])).stdout;
87
+ const args = unitArguments(text);
88
+ if (args.filter((arg) => arg === "--home").length !== 1) throw failure("The service must have exactly one --home argument", "SERVICE_HOME_MISMATCH");
89
+ const homeIndex = args.indexOf("--home");
90
+ if (homeIndex < 0 || !args[homeIndex + 1] || path.resolve(args[homeIndex + 1]) !== home) {
91
+ throw failure("The service must explicitly use the selected --home directory", "SERVICE_HOME_MISMATCH");
92
+ }
93
+ const packageRoot = path.dirname(path.dirname(args[1]));
94
+ const manifest = await packageAt(packageRoot);
95
+ const pid = Number(properties.MainPID);
96
+ if (properties.ActiveState === "active" && (!Number.isInteger(pid) || pid <= 0)) throw failure("Cannot identify the running service process");
97
+ return { args, text, packageRoot, version: manifest.version, pid, active: properties.ActiveState === "active" };
98
+ }
99
+
100
+ async function assertNoOtherWriters({ home, allowedPid, ownPid = process.pid, processRoot = "/proc" }) {
101
+ const canonicalHome = await realpath(home);
102
+ for (const entry of await readdir(processRoot)) {
103
+ if (!/^\d+$/.test(entry) || Number(entry) === ownPid || Number(entry) === allowedPid) continue;
104
+ const directory = path.join(processRoot, entry);
105
+ let args;
106
+ try {
107
+ const owner = await lstat(directory);
108
+ if (process.getuid && owner.uid !== process.getuid()) continue;
109
+ args = (await readFile(`${directory}/cmdline`, "utf8")).split("\0").filter(Boolean);
110
+ } catch (error) {
111
+ if (["ENOENT", "ESRCH"].includes(error.code)) continue;
112
+ throw failure("Cannot inspect another user process; stop other clients before updating", "UNMANAGED_WRITER");
113
+ }
114
+ for (const argument of args.slice(1)) {
115
+ if (!/(?:server\.js|cli\.js|artifacty(?:-mcp)?)$/.test(argument)) continue;
116
+ let source;
117
+ let cwd;
118
+ try { cwd = await realpath(`${directory}/cwd`); source = await realpath(path.resolve(cwd, argument)); }
119
+ catch { continue; }
120
+ if (!/\/src\/(?:mcp-server|server|cli)\.js$/.test(source)) continue;
121
+ try { await packageAt(path.dirname(path.dirname(source))); } catch { continue; }
122
+ const indices = args.flatMap((arg, index) => arg === "--home" ? [index] : []);
123
+ if (indices.length > 1) throw failure("Stop Artifacty clients with ambiguous --home arguments", "UNMANAGED_WRITER");
124
+ let processHome = indices.length ? args[indices[0] + 1] : null;
125
+ if (!processHome) {
126
+ let environment;
127
+ try { environment = (await readFile(`${directory}/environ`, "utf8")).split("\0"); }
128
+ catch { throw failure("Cannot inspect another Artifacty process; stop it before updating", "UNMANAGED_WRITER"); }
129
+ processHome = environment.find((item) => item.startsWith("ARTIFACTY_HOME="))?.slice(15);
130
+ const userHome = environment.find((item) => item.startsWith("HOME="))?.slice(5);
131
+ processHome ||= userHome && path.join(userHome, ".artifacty");
132
+ }
133
+ let resolved;
134
+ try { resolved = processHome && await realpath(path.resolve(cwd, processHome)); } catch { /* Unknown homes fail closed. */ }
135
+ if (!resolved || resolved === canonicalHome) throw failure(`Stop the unmanaged Artifacty process (pid ${entry}) before updating`, "UNMANAGED_WRITER");
136
+ }
137
+ }
138
+ }
139
+
140
+ async function writableDirectory(directory) {
141
+ await mkdir(directory, { recursive: true, mode: 0o700 });
142
+ const info = await lstat(directory);
143
+ if (!info.isDirectory() || info.isSymbolicLink()) throw failure(`Expected a real directory: ${directory}`);
144
+ await access(directory, constants.W_OK);
145
+ }
146
+
147
+ async function originalFile(file, managedMark) {
148
+ const info = await lstat(file).catch((error) => { if (error.code === "ENOENT") return null; throw error; });
149
+ if (!info) return null;
150
+ if (!info.isFile() || info.isSymbolicLink()) throw failure(`Refusing to replace an unmanaged file: ${file}`, "UNMANAGED_FILE");
151
+ const content = await readFile(file, "utf8");
152
+ if (managedMark && !content.includes(managedMark)) throw failure(`Refusing to replace an unmanaged file: ${file}`, "UNMANAGED_FILE");
153
+ await access(file, constants.W_OK);
154
+ return { content, mode: info.mode & 0o777 };
155
+ }
156
+
157
+ async function replaceFile(file, content, mode = 0o600) {
158
+ const temporary = `${file}.${randomUUID()}.tmp`;
159
+ try {
160
+ await writeFile(temporary, content, { mode, flag: "wx" });
161
+ await rename(temporary, file);
162
+ } finally { await rm(temporary, { force: true }); }
163
+ }
164
+
165
+ function launcher(node, entry) {
166
+ return `#!/usr/bin/env node\n${LAUNCHER_MARK}\nconst { spawnSync } = require("node:child_process");\nconst result = spawnSync(${JSON.stringify(node)}, [${JSON.stringify(entry)}, ...process.argv.slice(2)], { stdio: "inherit", env: process.env });\nif (result.error) { console.error(result.error.message); process.exitCode = 1; } else { process.exitCode = result.status ?? 1; }\n`;
167
+ }
168
+
169
+ function validHealthUrl(value) {
170
+ try {
171
+ const url = new URL(value);
172
+ return url.protocol === "http:" && !url.username && !url.password && !url.search && !url.hash && url.pathname === "/" && Boolean(url.port);
173
+ } catch { return false; }
174
+ }
175
+
176
+ async function verifyServiceIdentity(pid, args) {
177
+ const executable = await realpath(`/proc/${pid}/exe`);
178
+ const expected = await realpath(args[0]);
179
+ const command = (await readFile(`/proc/${pid}/cmdline`, "utf8")).split("\0").filter(Boolean);
180
+ if (executable !== expected || JSON.stringify(command.slice(1)) !== JSON.stringify(args.slice(1))) {
181
+ throw failure("The service PID does not match its configured Node process", "SERVICE_IDENTITY_MISMATCH");
182
+ }
183
+ }
184
+
185
+ async function waitHealthy(run, home, health, sleep, identity, args) {
186
+ for (let attempt = 0; attempt < 30; attempt++) {
187
+ let state;
188
+ try { state = JSON.parse(await readOptional(path.join(home, "server.json")) || "null"); } catch { /* Server state may be mid-write. */ }
189
+ const result = await run("systemctl", ["--user", "show", UNIT, "--property=MainPID", "--value"]);
190
+ const pid = Number(result.stdout.trim());
191
+ if (pid > 0 && state?.pid === pid && validHealthUrl(state.url)) {
192
+ await identity(pid, args);
193
+ if (await health(state.url)) return pid;
194
+ }
195
+ await sleep(1000);
196
+ }
197
+ throw failure("The updated service did not pass its PID and HTTP health checks", "UPDATE_HEALTH_FAILED");
198
+ }
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
+
212
+ /** Update the managed Linux user service. Dependencies are injectable for offline tests. */
213
+ export async function updateApplication(options = {}, injected = {}) {
214
+ const env = injected.env || process.env;
215
+ const platform = injected.platform || process.platform;
216
+ const userHome = injected.userHome || homedir();
217
+ const home = path.resolve(options.home || env.ARTIFACTY_HOME || path.join(userHome, ".artifacty"));
218
+ const run = injected.run || ((command, args) => execute(command, args, { env, timeout: 180000, maxBuffer: 2 * 1024 * 1024 }));
219
+ const sleep = injected.sleep || pause;
220
+ const identity = injected.verifyServiceIdentity || verifyServiceIdentity;
221
+ const writers = injected.assertNoOtherWriters || ((context) => assertNoOtherWriters({ ...context, processRoot: injected.processRoot }));
222
+ const progress = (message) => { try { options.onProgress?.(message); } catch { /* Reporting cannot interrupt recovery. */ } };
223
+ const health = injected.health || (async (url) => {
224
+ try {
225
+ const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(2000), redirect: "error" });
226
+ const body = await response.json();
227
+ return response.ok && body.ok === true && body.name === "artifacty" && path.resolve(body.store) === home;
228
+ } catch { return false; }
229
+ });
230
+ const backups = injected.backups || { create: createUpgradeBackup, verify: verifyUpgradeBackup, restore: restoreUpgradeBackup };
231
+ const requested = options.targetVersion || "latest";
232
+ if (requested !== "latest" && !VERSION.test(requested)) throw failure("--target-version must be latest or an exact version", "INVALID_UPDATE_VERSION");
233
+ let service;
234
+ let unsupported;
235
+ if (platform === "linux") {
236
+ try { service = await discoverService(run, home); } catch (error) { unsupported = error.message; }
237
+ } else unsupported = "Automatic updates currently require a Linux systemd user service";
238
+ const current = service?.version || (await packageAt(options.packageRoot || path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))))).version;
239
+ const targetVersion = JSON.parse((await run("npm", ["view", `artifacty@${requested}`, "version", "--json"])).stdout);
240
+ if (typeof targetVersion !== "string" || !VERSION.test(targetVersion)) throw failure("npm did not resolve one exact Artifacty version");
241
+ const summary = { currentVersion: current, targetVersion, supported: Boolean(service), service: service ? UNIT : null };
242
+ if (options.check) return { ...summary, check: true, updateAvailable: compareVersions(current, targetVersion) < 0, ...(unsupported ? { reason: unsupported } : {}) };
243
+ if (!service) throw failure(unsupported, "UNSUPPORTED_SERVICE");
244
+ if ((injected.uid ?? process.geteuid?.()) === 0) throw failure("Run updates as the service user, without sudo", "ROOT_UPDATE_REFUSED");
245
+ if (/[\r\n$%]/.test(home + userHome)) throw failure("Store and user paths containing $, %, or newlines are unsupported", "UNSUPPORTED_PATH");
246
+ if (compareVersions(current, targetVersion) > 0) {
247
+ if (requested !== "latest") throw failure("Automatic package downgrades are not supported", "UPDATE_DOWNGRADE_REFUSED");
248
+ return { ...summary, updated: false, reason: "already-newer" };
249
+ }
250
+ if (current === targetVersion) return { ...summary, updated: false, reason: "already-current" };
251
+ if (service.active) {
252
+ await identity(service.pid, service.args);
253
+ let state;
254
+ try { state = JSON.parse(await readOptional(path.join(home, "server.json")) || "null"); } catch { /* Fail before stopping an uncheckable service. */ }
255
+ if (state?.pid !== service.pid || !validHealthUrl(state.url) || !await health(state.url)) {
256
+ throw failure("The current service cannot pass its HTTP health check. Check its bind address and IP policy before updating.", "UPDATE_PREFLIGHT_HEALTH");
257
+ }
258
+ }
259
+ await writers({ home, allowedPid: service.pid });
260
+
261
+ const stateRoot = path.join(userHome, ".local", "share", "artifacty");
262
+ const releases = path.join(stateRoot, "releases");
263
+ const bin = path.join(userHome, ".local", "bin");
264
+ const dropinDirectory = path.join(userHome, ".config", "systemd", "user", `${UNIT}.d`);
265
+ for (const directory of [stateRoot, releases, bin, dropinDirectory]) await writableDirectory(directory);
266
+ const dropin = path.join(dropinDirectory, "zz-artifacty-self-update.conf");
267
+ const destinations = [dropin, path.join(bin, "artifacty"), path.join(bin, "artifacty-mcp")];
268
+ const originals = await Promise.all(destinations.map((file, index) => originalFile(file, index ? LAUNCHER_MARK : "# artifacty-managed-update")));
269
+ const lockPath = path.join(stateRoot, "update.lock");
270
+ let lock;
271
+ try { lock = await open(lockPath, "wx", 0o600); }
272
+ catch (error) { if (error.code === "EEXIST") throw failure(`Another update or an interrupted update holds ${lockPath}`, "UPDATE_LOCKED"); throw error; }
273
+ const stage = path.join(releases, `${targetVersion}-${randomUUID()}`);
274
+ let snapshot;
275
+ let stopped = false;
276
+ let switched = false;
277
+ let succeeded = false;
278
+ try {
279
+ await lock.writeFile(JSON.stringify({ pid: process.pid, targetVersion }));
280
+ progress(`Staging Artifacty ${targetVersion} before stopping the service.`);
281
+ await run("npm", ["install", "--prefix", stage, "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund", "--package-lock=false", "--save-exact", `artifacty@${targetVersion}`]);
282
+ const stagedRoot = path.join(stage, "node_modules", "artifacty");
283
+ if ((await packageAt(stagedRoot)).version !== targetVersion) throw failure("Staged package version does not match the resolved version");
284
+ for (const entry of ["cli.js", "server.js", "mcp-server.js"]) await run(service.args[0], ["--check", path.join(stagedRoot, "src", entry)]);
285
+ const version = (await run(service.args[0], [path.join(stagedRoot, "src", "cli.js"), "--version"])).stdout.trim();
286
+ if (version !== targetVersion) throw failure("Staged CLI version check failed");
287
+ const refreshed = await discoverService(run, home);
288
+ if (refreshed.text !== service.text || refreshed.pid !== service.pid) throw failure("Service configuration changed while staging; retry the update");
289
+ await writers({ home, allowedPid: service.pid });
290
+ progress("Stopping the user service and verifying a recovery snapshot.");
291
+ stopped = true;
292
+ await run("systemctl", ["--user", "stop", UNIT]);
293
+ await writers({ home, allowedPid: 0 });
294
+ snapshot = await backups.create({ home }, { reason: "manual", targetAppVersion: targetVersion });
295
+ await backups.verify({ home }, snapshot.id);
296
+ const args = [service.args[0], path.join(stagedRoot, "src", "server.js"), ...service.args.slice(2)];
297
+ progress("Switching the user service to the staged release.");
298
+ switched = true;
299
+ await replaceFile(dropin, `# artifacty-managed-update\n[Service]\nExecStart=\nExecStart=${args.map(quoteUnit).join(" ")}\n`);
300
+ await replaceFile(destinations[1], launcher(service.args[0], path.join(stagedRoot, "src", "cli.js")), 0o755);
301
+ await replaceFile(destinations[2], launcher(service.args[0], path.join(stagedRoot, "src", "mcp-server.js")), 0o755);
302
+ await run("systemctl", ["--user", "daemon-reload"]);
303
+ const configured = await discoverService(run, home);
304
+ if (configured.args[1] !== args[1] || JSON.stringify(configured.args.slice(2)) !== JSON.stringify(args.slice(2))) throw failure("A service override prevented the staged package from becoming active");
305
+ if (service.active) {
306
+ await run("systemctl", ["--user", "start", UNIT]);
307
+ progress("Waiting for service health and process identity.");
308
+ await waitHealthy(run, home, health, sleep, identity, args);
309
+ }
310
+ succeeded = true;
311
+ let firstExecutable;
312
+ for (const directory of (env.PATH || "").split(path.delimiter)) {
313
+ const candidate = path.resolve(directory, "artifacty");
314
+ try { await access(candidate, constants.X_OK); firstExecutable = await realpath(candidate); break; } catch { /* Try the next PATH entry. */ }
315
+ }
316
+ const pathWarning = firstExecutable !== await realpath(destinations[1]) ? `Use ${destinations[1]} until ${bin} precedes other Artifacty installations in PATH.` : undefined;
317
+ const commandPath = /^[\w/.-]+$/.test(destinations[1]) ? destinations[1] : `'${destinations[1].replaceAll("'", "'\\''")}'`;
318
+ return { ...summary, updated: true, backupId: snapshot.id, backupPath: snapshot.path, packageRoot: stagedRoot, restarted: service.active, launcher: destinations[1], nextCommand: `${commandPath} update`, ...(pathWarning ? { pathWarning } : {}) };
319
+ } catch (cause) {
320
+ progress("Update failed. Restoring the previous service and store where safe.");
321
+ const recoveryErrors = [];
322
+ if (switched) {
323
+ try { await run("systemctl", ["--user", "stop", UNIT]); } catch { recoveryErrors.push("could not stop the updated service"); }
324
+ if (!recoveryErrors.length) {
325
+ try {
326
+ for (let index = 0; index < destinations.length; index++) {
327
+ const original = originals[index];
328
+ if (original) await replaceFile(destinations[index], original.content, original.mode);
329
+ else await rm(destinations[index], { force: true });
330
+ }
331
+ await run("systemctl", ["--user", "daemon-reload"]);
332
+ await writers({ home, allowedPid: 0 });
333
+ await backups.restore({ home }, snapshot.id, { confirm: "restore-store" });
334
+ } catch (error) { recoveryErrors.push(`could not restore the previous configuration and store (${/^[A-Z_]+$/.test(error.code || "") ? error.code : "restore failed"})`); }
335
+ }
336
+ }
337
+ if (stopped && service.active && !recoveryErrors.length) {
338
+ try { await run("systemctl", ["--user", "start", UNIT]); await waitHealthy(run, home, health, sleep, identity, service.args); }
339
+ catch { recoveryErrors.push("the previous service did not restart cleanly"); }
340
+ }
341
+ const error = failure(`Update failed: ${cause.code || "operation failed"}. ${recoveryErrors.length ? `Recovery incomplete: ${recoveryErrors.join("; ")}.` : stopped ? "The previous service configuration was restored." : "The running service was not changed."}${snapshot ? ` Backup: ${snapshot.id}.` : ""}`, recoveryErrors.length ? "UPDATE_RECOVERY_INCOMPLETE" : "SELF_UPDATE_FAILED");
342
+ error.backupId = snapshot?.id;
343
+ error.backupPath = snapshot?.path;
344
+ error.cause = cause;
345
+ throw error;
346
+ } finally {
347
+ await lock.close();
348
+ await rm(lockPath, { force: true });
349
+ if (!succeeded && !switched) await rm(stage, { recursive: true, force: true });
350
+ }
351
+ }
@@ -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
+ }