artifacty 0.13.0 → 0.14.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 +744 -0
- package/README.md +34 -0
- package/SECURITY.ko.md +55 -0
- package/SECURITY.md +2 -0
- package/docs/README.md +39 -0
- package/docs/artifact-schema-v1.md +2 -0
- package/docs/claude-artifact-formats.md +652 -0
- package/docs/integrations.md +2 -0
- package/docs/ko/README.md +39 -0
- package/docs/ko/artifact-schema-v1.md +238 -0
- package/docs/ko/integrations.md +400 -0
- package/docs/ko/mcp-public-api.md +121 -0
- package/docs/ko/network-sharing.md +60 -0
- package/docs/ko/release-checklist.md +72 -0
- package/docs/ko/threat-model.md +275 -0
- package/docs/ko/upgrade-backups.md +136 -0
- package/docs/ko/writing-style.md +60 -0
- package/docs/mcp-public-api.md +2 -0
- package/docs/network-sharing.md +2 -0
- package/docs/release-checklist.md +2 -0
- package/docs/threat-model.md +2 -0
- package/docs/upgrade-backups.md +45 -0
- package/docs/writing-style.md +2 -0
- package/package.json +7 -2
- package/src/cli.js +22 -1
- package/src/lib/render.js +7 -3
- package/src/lib/self-update.js +339 -0
package/docs/upgrade-backups.md
CHANGED
|
@@ -1,9 +1,54 @@
|
|
|
1
1
|
# Upgrade Snapshots and Recovery
|
|
2
2
|
|
|
3
|
+
English | [한국어](ko/upgrade-backups.md)
|
|
4
|
+
|
|
3
5
|
Artifacty creates a local snapshot before it migrates an existing store to a newer schema. This snapshot is mandatory. If snapshot creation or verification fails, Artifacty stops before migration.
|
|
4
6
|
|
|
5
7
|
Use upgrade snapshots to recover the original store state. Use `artifacty backup` and `artifacty export` for portable JSON backups and store transfers. These are separate backup systems. `artifacty restore <id>` restores an archived artifact; it does not restore a store snapshot.
|
|
6
8
|
|
|
9
|
+
## Application Self-Update
|
|
10
|
+
|
|
11
|
+
The application updater and schema migration snapshots serve different purposes. `artifacty update` without an artifact ID updates the application. `artifacty upgrade` is an alias. `artifacty update <id>` continues to update an artifact.
|
|
12
|
+
|
|
13
|
+
### Check and select a release
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
artifacty update --check
|
|
17
|
+
artifacty upgrade --check --target-version <version> --home /path/to/store
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`--check` reports the release without changing the installation, service, or store. It works on other CLI platforms too and reports whether automatic installation is supported. It still needs network access to check the release. For the managed service, the updater compares the package version used by that service, not just the invoking CLI version. `--target-version` accepts an exact version. Without that option, the updater selects the latest release. `--home` selects the store to protect. Automatic downgrades are not supported. An explicitly selected older version is rejected; an older `latest` release leaves the newer installation unchanged.
|
|
21
|
+
|
|
22
|
+
For installed versions before 0.14.0, bootstrap the updater with:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx --yes artifacty@latest update --home /path/to/store && export PATH="$HOME/.local/bin:$PATH"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The command uses POSIX Linux shell syntax. After a successful update, it puts `~/.local/bin` first on `PATH` for the current session. For future sessions, add that directory to your shell's `PATH`.
|
|
29
|
+
|
|
30
|
+
The `latest` tag must resolve to version 0.14.0 or newer to include the updater.
|
|
31
|
+
|
|
32
|
+
### Automatic installation limits
|
|
33
|
+
|
|
34
|
+
Automatic installation and restart support Linux with the systemd user service `com.artifacty.server`. Run the updater as the service's user. The unit must use the simple `ExecStart` form produced by Artifacty's installer and must explicitly pass `--home` for the selected store. Unsupported command expansion or quoting stops the update before service shutdown. Automatic updates reject root execution. Do not run the updater as root to replace a root-owned global package.
|
|
35
|
+
|
|
36
|
+
The updater stages packages under `~/.local/share/artifacty/releases`. It changes the managed service's executable through a systemd drop-in and preserves its existing arguments and environment. User-owned launchers go under `~/.local/bin`. If that directory is not on `PATH`, add it before using those launchers. Existing unmanaged files in that directory are not overwritten. The existing root-owned global package remains unchanged. If `PATH` selects another command first, the result supplies an absolute `nextCommand`. The updater does not edit shell configuration.
|
|
37
|
+
|
|
38
|
+
Automatic switching does not support macOS, Windows, foreground servers, detached background servers, or arbitrary service units. Use the manual installation and snapshot procedures for those environments.
|
|
39
|
+
|
|
40
|
+
### Store protection and rollback
|
|
41
|
+
|
|
42
|
+
Before updating, stop other HTTP servers and local MCP clients that use the same store. The updater manages the supported systemd service itself. It cannot identify every other process with an open store. The service is unavailable during the update. A store rollback discards writes made after the snapshot; do not keep other writers active during this procedure.
|
|
43
|
+
|
|
44
|
+
For an active service, the updater checks current health before shutdown. If its bind address or IP policy prevents that check, it refuses the update before downtime. Unsupported service configurations also fail before shutdown.
|
|
45
|
+
|
|
46
|
+
The updater installs and verifies the exact target package in its staging directory. It then stops the managed service and creates and verifies a snapshot before switching code. If the service was active, it restarts the service and checks its health and PID. An inactive service remains inactive. It preserves the previous code and service configuration for rollback. If switching or startup fails, it attempts to restore the changed code, service configuration, and store, then restart the previous service. If service stop fails, it does not restore a store while the service may still be writing to it. A restored store includes the snapshot's credentials and security state. Review any recovery error before allowing clients to reconnect.
|
|
47
|
+
|
|
48
|
+
An abrupt process or host termination during the switch can leave `update.lock` or a stopped service. The next update reports the lock instead of retrying blindly. Preserve snapshots, staged releases, and recovery files. Inspect the service and recovery state before a manual retry. Automatic rollback handles caught failures; it does not guarantee recovery from power loss or forced process termination.
|
|
49
|
+
|
|
50
|
+
Keep the snapshot and the update result until the restarted service works. If automatic rollback cannot finish, the updater reports an explicit error and preserves the `backupId`. Use that snapshot with the recovery procedures below. Do not delete recovery files or force an older build to open an unsupported schema.
|
|
51
|
+
|
|
7
52
|
## When Artifacty Creates a Snapshot
|
|
8
53
|
|
|
9
54
|
- **Schema migration:** Artifacty always snapshots an existing store before migration. This includes a legacy `index.json` store. A new empty store needs no snapshot.
|
package/docs/writing-style.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Documentation writing style
|
|
2
2
|
|
|
3
|
+
English | [한국어](ko/writing-style.md)
|
|
4
|
+
|
|
3
5
|
Artifacty uses an ASD-STE100-inspired style for English documentation. Korean documentation follows the same clarity principles with natural Korean phrasing. This is an editorial guide, not a claim of compliance with ASD-STE100.
|
|
4
6
|
|
|
5
7
|
Apply the sentence and procedure rules consistently. Use technical vocabulary where it makes the meaning more precise. Keep introductions natural. The project's “75%” target describes this balance; it is not a compliance score.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "artifacty",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -53,7 +53,12 @@
|
|
|
53
53
|
"SECURITY.md",
|
|
54
54
|
"THIRD_PARTY_NOTICES.md",
|
|
55
55
|
"AGENTS.md",
|
|
56
|
-
"CLAUDE.md"
|
|
56
|
+
"CLAUDE.md",
|
|
57
|
+
"README.ko.md",
|
|
58
|
+
"SECURITY.ko.md",
|
|
59
|
+
"docs/README.md",
|
|
60
|
+
"docs/ko",
|
|
61
|
+
"docs/claude-artifact-formats.md"
|
|
57
62
|
],
|
|
58
63
|
"engines": {
|
|
59
64
|
"node": ">=22.12"
|
package/src/cli.js
CHANGED
|
@@ -33,6 +33,25 @@ async function main() {
|
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Application updates must not initialize or migrate the live store first.
|
|
37
|
+
// Keep `update <id>` as the existing artifact-content command.
|
|
38
|
+
if ((command === "update" && options._.length === 0) || command === "upgrade") {
|
|
39
|
+
const allowed = new Set(["_", "home", "targetVersion", "check"]);
|
|
40
|
+
const unknown = Object.keys(options).filter((key) => !allowed.has(key));
|
|
41
|
+
if (options._.length || unknown.length) {
|
|
42
|
+
throw new Error("Usage: artifacty update [--check] [--target-version <version>] [--home <path>]. To update content, use artifacty update <id> --file <path>.");
|
|
43
|
+
}
|
|
44
|
+
const { updateApplication } = await import("./lib/self-update.js");
|
|
45
|
+
printJson(await updateApplication({
|
|
46
|
+
packageRoot: PACKAGE_ROOT,
|
|
47
|
+
home: options.home,
|
|
48
|
+
targetVersion: options.targetVersion,
|
|
49
|
+
check: Boolean(options.check),
|
|
50
|
+
onProgress: (message) => process.stderr.write(`artifacty update: ${message}\n`)
|
|
51
|
+
}));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
// Recovery must work before loading storage or triggering a schema migration.
|
|
37
56
|
if (command === "backups") {
|
|
38
57
|
const { listUpgradeBackups, verifyUpgradeBackup, restoreUpgradeBackup } = await import("./lib/upgrade-backup.js");
|
|
@@ -898,7 +917,7 @@ function parseArgs(args) {
|
|
|
898
917
|
}
|
|
899
918
|
|
|
900
919
|
const key = arg.slice(2);
|
|
901
|
-
if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force" || key === "skip-mcp" || key === "mcp-http" || key === "no-password-reset" || key === "relations" || key === "structured" || key === "json" || key === "once" || key === "full" || key === "force-users" || key === "allow-purge" || key === "shared" || key === "embeddings") {
|
|
920
|
+
if (key === "check" || key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force" || key === "skip-mcp" || key === "mcp-http" || key === "no-password-reset" || key === "relations" || key === "structured" || key === "json" || key === "once" || key === "full" || key === "force-users" || key === "allow-purge" || key === "shared" || key === "embeddings") {
|
|
902
921
|
options[toCamelCase(key)] = true;
|
|
903
922
|
continue;
|
|
904
923
|
}
|
|
@@ -991,6 +1010,8 @@ Usage:
|
|
|
991
1010
|
artifacty import --agent claude|codex|gemini|copilot|cursor|auto (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react|notebook] [--tag tag]
|
|
992
1011
|
artifacty install claude|codex|gemini|copilot|cursor|all [--dry-run] [--config <path>] [--server-path <path>] [--url http://127.0.0.1:8787] [--mcp-url http://127.0.0.1:8787/mcp] [--api-token token] [--transport local|bridge] [--timeout 30000]
|
|
993
1012
|
artifacty check [--server-path <path>] [--timeout 5000]
|
|
1013
|
+
artifacty update [--check] [--target-version <version>] [--home <path>]
|
|
1014
|
+
artifacty upgrade [--check] [--target-version <version>] [--home <path>]
|
|
994
1015
|
artifacty update <id> (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react|notebook] [--expected-version N] [--visibility private|team]
|
|
995
1016
|
artifacty samples install|remove
|
|
996
1017
|
artifacty archive <id>
|
package/src/lib/render.js
CHANGED
|
@@ -2845,9 +2845,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
|
|
|
2845
2845
|
margin-left: -20px;
|
|
2846
2846
|
}
|
|
2847
2847
|
.task-list-item label {
|
|
2848
|
-
display:
|
|
2849
|
-
|
|
2850
|
-
|
|
2848
|
+
display: block;
|
|
2849
|
+
}
|
|
2850
|
+
.task-list-item input[type="checkbox"] {
|
|
2851
|
+
width: auto;
|
|
2852
|
+
min-height: 0;
|
|
2853
|
+
padding: 0;
|
|
2854
|
+
margin: 0 8px 0 0;
|
|
2851
2855
|
}
|
|
2852
2856
|
.artifact-mermaid-embed {
|
|
2853
2857
|
margin: 16px 0;
|
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
/** Update the managed Linux user service. Dependencies are injectable for offline tests. */
|
|
201
|
+
export async function updateApplication(options = {}, injected = {}) {
|
|
202
|
+
const env = injected.env || process.env;
|
|
203
|
+
const platform = injected.platform || process.platform;
|
|
204
|
+
const userHome = injected.userHome || homedir();
|
|
205
|
+
const home = path.resolve(options.home || env.ARTIFACTY_HOME || path.join(userHome, ".artifacty"));
|
|
206
|
+
const run = injected.run || ((command, args) => execute(command, args, { env, timeout: 180000, maxBuffer: 2 * 1024 * 1024 }));
|
|
207
|
+
const sleep = injected.sleep || pause;
|
|
208
|
+
const identity = injected.verifyServiceIdentity || verifyServiceIdentity;
|
|
209
|
+
const writers = injected.assertNoOtherWriters || ((context) => assertNoOtherWriters({ ...context, processRoot: injected.processRoot }));
|
|
210
|
+
const progress = (message) => { try { options.onProgress?.(message); } catch { /* Reporting cannot interrupt recovery. */ } };
|
|
211
|
+
const health = injected.health || (async (url) => {
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(2000), redirect: "error" });
|
|
214
|
+
const body = await response.json();
|
|
215
|
+
return response.ok && body.ok === true && body.name === "artifacty" && path.resolve(body.store) === home;
|
|
216
|
+
} catch { return false; }
|
|
217
|
+
});
|
|
218
|
+
const backups = injected.backups || { create: createUpgradeBackup, verify: verifyUpgradeBackup, restore: restoreUpgradeBackup };
|
|
219
|
+
const requested = options.targetVersion || "latest";
|
|
220
|
+
if (requested !== "latest" && !VERSION.test(requested)) throw failure("--target-version must be latest or an exact version", "INVALID_UPDATE_VERSION");
|
|
221
|
+
let service;
|
|
222
|
+
let unsupported;
|
|
223
|
+
if (platform === "linux") {
|
|
224
|
+
try { service = await discoverService(run, home); } catch (error) { unsupported = error.message; }
|
|
225
|
+
} else unsupported = "Automatic updates currently require a Linux systemd user service";
|
|
226
|
+
const current = service?.version || (await packageAt(options.packageRoot || path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))))).version;
|
|
227
|
+
const targetVersion = JSON.parse((await run("npm", ["view", `artifacty@${requested}`, "version", "--json"])).stdout);
|
|
228
|
+
if (typeof targetVersion !== "string" || !VERSION.test(targetVersion)) throw failure("npm did not resolve one exact Artifacty version");
|
|
229
|
+
const summary = { currentVersion: current, targetVersion, supported: Boolean(service), service: service ? UNIT : null };
|
|
230
|
+
if (options.check) return { ...summary, check: true, updateAvailable: compareVersions(current, targetVersion) < 0, ...(unsupported ? { reason: unsupported } : {}) };
|
|
231
|
+
if (!service) throw failure(unsupported, "UNSUPPORTED_SERVICE");
|
|
232
|
+
if ((injected.uid ?? process.geteuid?.()) === 0) throw failure("Run updates as the service user, without sudo", "ROOT_UPDATE_REFUSED");
|
|
233
|
+
if (/[\r\n$%]/.test(home + userHome)) throw failure("Store and user paths containing $, %, or newlines are unsupported", "UNSUPPORTED_PATH");
|
|
234
|
+
if (compareVersions(current, targetVersion) > 0) {
|
|
235
|
+
if (requested !== "latest") throw failure("Automatic package downgrades are not supported", "UPDATE_DOWNGRADE_REFUSED");
|
|
236
|
+
return { ...summary, updated: false, reason: "already-newer" };
|
|
237
|
+
}
|
|
238
|
+
if (current === targetVersion) return { ...summary, updated: false, reason: "already-current" };
|
|
239
|
+
if (service.active) {
|
|
240
|
+
await identity(service.pid, service.args);
|
|
241
|
+
let state;
|
|
242
|
+
try { state = JSON.parse(await readOptional(path.join(home, "server.json")) || "null"); } catch { /* Fail before stopping an uncheckable service. */ }
|
|
243
|
+
if (state?.pid !== service.pid || !validHealthUrl(state.url) || !await health(state.url)) {
|
|
244
|
+
throw failure("The current service cannot pass its HTTP health check. Check its bind address and IP policy before updating.", "UPDATE_PREFLIGHT_HEALTH");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
await writers({ home, allowedPid: service.pid });
|
|
248
|
+
|
|
249
|
+
const stateRoot = path.join(userHome, ".local", "share", "artifacty");
|
|
250
|
+
const releases = path.join(stateRoot, "releases");
|
|
251
|
+
const bin = path.join(userHome, ".local", "bin");
|
|
252
|
+
const dropinDirectory = path.join(userHome, ".config", "systemd", "user", `${UNIT}.d`);
|
|
253
|
+
for (const directory of [stateRoot, releases, bin, dropinDirectory]) await writableDirectory(directory);
|
|
254
|
+
const dropin = path.join(dropinDirectory, "zz-artifacty-self-update.conf");
|
|
255
|
+
const destinations = [dropin, path.join(bin, "artifacty"), path.join(bin, "artifacty-mcp")];
|
|
256
|
+
const originals = await Promise.all(destinations.map((file, index) => originalFile(file, index ? LAUNCHER_MARK : "# artifacty-managed-update")));
|
|
257
|
+
const lockPath = path.join(stateRoot, "update.lock");
|
|
258
|
+
let lock;
|
|
259
|
+
try { lock = await open(lockPath, "wx", 0o600); }
|
|
260
|
+
catch (error) { if (error.code === "EEXIST") throw failure(`Another update or an interrupted update holds ${lockPath}`, "UPDATE_LOCKED"); throw error; }
|
|
261
|
+
const stage = path.join(releases, `${targetVersion}-${randomUUID()}`);
|
|
262
|
+
let snapshot;
|
|
263
|
+
let stopped = false;
|
|
264
|
+
let switched = false;
|
|
265
|
+
let succeeded = false;
|
|
266
|
+
try {
|
|
267
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, targetVersion }));
|
|
268
|
+
progress(`Staging Artifacty ${targetVersion} before stopping the service.`);
|
|
269
|
+
await run("npm", ["install", "--prefix", stage, "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund", "--package-lock=false", "--save-exact", `artifacty@${targetVersion}`]);
|
|
270
|
+
const stagedRoot = path.join(stage, "node_modules", "artifacty");
|
|
271
|
+
if ((await packageAt(stagedRoot)).version !== targetVersion) throw failure("Staged package version does not match the resolved version");
|
|
272
|
+
for (const entry of ["cli.js", "server.js", "mcp-server.js"]) await run(service.args[0], ["--check", path.join(stagedRoot, "src", entry)]);
|
|
273
|
+
const version = (await run(service.args[0], [path.join(stagedRoot, "src", "cli.js"), "--version"])).stdout.trim();
|
|
274
|
+
if (version !== targetVersion) throw failure("Staged CLI version check failed");
|
|
275
|
+
const refreshed = await discoverService(run, home);
|
|
276
|
+
if (refreshed.text !== service.text || refreshed.pid !== service.pid) throw failure("Service configuration changed while staging; retry the update");
|
|
277
|
+
await writers({ home, allowedPid: service.pid });
|
|
278
|
+
progress("Stopping the user service and verifying a recovery snapshot.");
|
|
279
|
+
stopped = true;
|
|
280
|
+
await run("systemctl", ["--user", "stop", UNIT]);
|
|
281
|
+
await writers({ home, allowedPid: 0 });
|
|
282
|
+
snapshot = await backups.create({ home }, { reason: "manual", targetAppVersion: targetVersion });
|
|
283
|
+
await backups.verify({ home }, snapshot.id);
|
|
284
|
+
const args = [service.args[0], path.join(stagedRoot, "src", "server.js"), ...service.args.slice(2)];
|
|
285
|
+
progress("Switching the user service to the staged release.");
|
|
286
|
+
switched = true;
|
|
287
|
+
await replaceFile(dropin, `# artifacty-managed-update\n[Service]\nExecStart=\nExecStart=${args.map(quoteUnit).join(" ")}\n`);
|
|
288
|
+
await replaceFile(destinations[1], launcher(service.args[0], path.join(stagedRoot, "src", "cli.js")), 0o755);
|
|
289
|
+
await replaceFile(destinations[2], launcher(service.args[0], path.join(stagedRoot, "src", "mcp-server.js")), 0o755);
|
|
290
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
291
|
+
const configured = await discoverService(run, home);
|
|
292
|
+
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");
|
|
293
|
+
if (service.active) {
|
|
294
|
+
await run("systemctl", ["--user", "start", UNIT]);
|
|
295
|
+
progress("Waiting for service health and process identity.");
|
|
296
|
+
await waitHealthy(run, home, health, sleep, identity, args);
|
|
297
|
+
}
|
|
298
|
+
succeeded = true;
|
|
299
|
+
let firstExecutable;
|
|
300
|
+
for (const directory of (env.PATH || "").split(path.delimiter)) {
|
|
301
|
+
const candidate = path.resolve(directory, "artifacty");
|
|
302
|
+
try { await access(candidate, constants.X_OK); firstExecutable = await realpath(candidate); break; } catch { /* Try the next PATH entry. */ }
|
|
303
|
+
}
|
|
304
|
+
const pathWarning = firstExecutable !== await realpath(destinations[1]) ? `Use ${destinations[1]} until ${bin} precedes other Artifacty installations in PATH.` : undefined;
|
|
305
|
+
const commandPath = /^[\w/.-]+$/.test(destinations[1]) ? destinations[1] : `'${destinations[1].replaceAll("'", "'\\''")}'`;
|
|
306
|
+
return { ...summary, updated: true, backupId: snapshot.id, backupPath: snapshot.path, packageRoot: stagedRoot, restarted: service.active, launcher: destinations[1], nextCommand: `${commandPath} update`, ...(pathWarning ? { pathWarning } : {}) };
|
|
307
|
+
} catch (cause) {
|
|
308
|
+
progress("Update failed. Restoring the previous service and store where safe.");
|
|
309
|
+
const recoveryErrors = [];
|
|
310
|
+
if (switched) {
|
|
311
|
+
try { await run("systemctl", ["--user", "stop", UNIT]); } catch { recoveryErrors.push("could not stop the updated service"); }
|
|
312
|
+
if (!recoveryErrors.length) {
|
|
313
|
+
try {
|
|
314
|
+
for (let index = 0; index < destinations.length; index++) {
|
|
315
|
+
const original = originals[index];
|
|
316
|
+
if (original) await replaceFile(destinations[index], original.content, original.mode);
|
|
317
|
+
else await rm(destinations[index], { force: true });
|
|
318
|
+
}
|
|
319
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
320
|
+
await writers({ home, allowedPid: 0 });
|
|
321
|
+
await backups.restore({ home }, snapshot.id, { confirm: "restore-store" });
|
|
322
|
+
} catch (error) { recoveryErrors.push(`could not restore the previous configuration and store (${/^[A-Z_]+$/.test(error.code || "") ? error.code : "restore failed"})`); }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (stopped && service.active && !recoveryErrors.length) {
|
|
326
|
+
try { await run("systemctl", ["--user", "start", UNIT]); await waitHealthy(run, home, health, sleep, identity, service.args); }
|
|
327
|
+
catch { recoveryErrors.push("the previous service did not restart cleanly"); }
|
|
328
|
+
}
|
|
329
|
+
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");
|
|
330
|
+
error.backupId = snapshot?.id;
|
|
331
|
+
error.backupPath = snapshot?.path;
|
|
332
|
+
error.cause = cause;
|
|
333
|
+
throw error;
|
|
334
|
+
} finally {
|
|
335
|
+
await lock.close();
|
|
336
|
+
await rm(lockPath, { force: true });
|
|
337
|
+
if (!succeeded && !switched) await rm(stage, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
}
|