artifacty 0.12.0 → 0.13.1
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/CLAUDE.md +5 -1
- package/README.md +16 -2
- package/docs/integrations.md +4 -0
- package/docs/release-checklist.md +5 -0
- package/docs/upgrade-backups.md +89 -0
- package/package.json +4 -3
- package/src/cli.js +34 -0
- package/src/lib/doctor.js +1 -1
- package/src/lib/render.js +8 -4
- package/src/lib/sample-content.js +10 -4
- package/src/lib/storage.js +3 -13
- package/src/lib/upgrade-backup.js +417 -0
- package/src/server.js +4 -0
package/CLAUDE.md
CHANGED
|
@@ -18,7 +18,7 @@ For English and Korean documentation, follow the [writing style guide](docs/writ
|
|
|
18
18
|
- `node src/mcp-server.js` or `npm run mcp` — run the MCP stdio server.
|
|
19
19
|
- `node src/cli.js help` — inspect CLI usage.
|
|
20
20
|
|
|
21
|
-
Run `npm run release:check` before claiming a release-ready change. There is no build step; this is plain ESM run directly by Node `>=22.
|
|
21
|
+
Run `npm run release:check` before claiming a release-ready change. There is no build step; this is plain ESM run directly by Node `>=22.12.0`.
|
|
22
22
|
|
|
23
23
|
## Architecture
|
|
24
24
|
|
|
@@ -43,6 +43,8 @@ The store lives at `ARTIFACTY_HOME` or `~/.artifacty` by default.
|
|
|
43
43
|
|
|
44
44
|
Versions are append-only. To correct a historical version, use the admin repair (`replaceArtifactVersion`) or delete (`deleteArtifactVersion`) path. These paths record every change in the audit log. Do not modify prior content files through other paths.
|
|
45
45
|
|
|
46
|
+
Before changing upgrade behavior, read [upgrade snapshots and recovery](docs/upgrade-backups.md). `src/lib/upgrade-backup.js` protects schema migrations with a mandatory verified snapshot. Optional application-update snapshots use `ARTIFACTY_BACKUP_ON_UPDATE=true`. Keep recovery commands independent of normal storage initialization so they cannot trigger migration. Stop every server and MCP process before restoring a store. Test recovery only with temporary stores.
|
|
47
|
+
|
|
46
48
|
### Security Model
|
|
47
49
|
|
|
48
50
|
- The HTTP server binds to `127.0.0.1` by default.
|
|
@@ -55,6 +57,8 @@ Versions are append-only. To correct a historical version, use the admin repair
|
|
|
55
57
|
|
|
56
58
|
The create/import/edit screens use CodeMirror 6 for Markdown, HTML, JSON, and text editing. CodeMirror assets are served locally through allowlisted `/vendor/npm/*` routes, not from a CDN. UI text defaults to English and supports Korean with `?lang=ko`. Keep each document in its existing language unless a translation is requested.
|
|
57
59
|
|
|
60
|
+
Mermaid uses version 12.1.0 with the existing `classic` look, `dagre` layout, and `default` theme. Its browser code requires ES2024 support, such as Safari 17.4 or newer. The optional Babel 8 React renderer requires Node `^22.18.0 || >=24.11.0`.
|
|
61
|
+
|
|
58
62
|
### MCP Server
|
|
59
63
|
|
|
60
64
|
The MCP server is a hand-rolled line-delimited JSON-RPC 2.0 implementation using protocol version `2025-06-18`. Tool results include both `content[].text` and `structuredContent`. Keep `artifacty_publish` as a backwards-compatible alias for `artifacty_create`.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/raeseoklee/artifacty/actions/workflows/publish.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/artifacty)
|
|
5
5
|
[](LICENSE)
|
|
6
|
-
[](package.json)
|
|
7
7
|
|
|
8
8
|
Artifacty gives AI-generated artifacts a local home that agents can share. Claude, Codex, Gemini, GitHub Copilot, Cursor, and other MCP tools can publish artifacts to the same store. Other agents can find, read, and update them without copying content through chat.
|
|
9
9
|
|
|
@@ -15,11 +15,13 @@ Artifacts turn session output into shareable, versioned pages. Artifacty keeps t
|
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
18
|
-
Artifacty requires Node.js 22.
|
|
18
|
+
Artifacty requires Node.js 22.12.0 or newer.
|
|
19
19
|
|
|
20
20
|
The optional React renderer uses Babel 8. Its supported Node.js versions are
|
|
21
21
|
`^22.18.0 || >=24.11.0`. Use a compatible version if you enable that renderer.
|
|
22
22
|
|
|
23
|
+
The browser renderer uses Mermaid 12.1.0. Use a current browser with ES2024 support, such as Safari 17.4 or newer. Mermaid keeps the `classic` look, `dagre` layout, and `default` theme.
|
|
24
|
+
|
|
23
25
|
Install the CLI globally from npm:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
@@ -298,6 +300,16 @@ artifacty index rebuild
|
|
|
298
300
|
artifacty integrity
|
|
299
301
|
```
|
|
300
302
|
|
|
303
|
+
### Upgrade Snapshots
|
|
304
|
+
|
|
305
|
+
Before an existing store needs a schema migration, Artifacty creates and verifies a mandatory local snapshot. The snapshot includes the raw SQLite database, referenced version files, and legacy index. If snapshot verification fails, migration stops.
|
|
306
|
+
|
|
307
|
+
Set `ARTIFACTY_BACKUP_ON_UPDATE=true` to also snapshot application-version changes. If an existing store has no recorded application version, its first open with this setting creates a baseline snapshot. `ARTIFACTY_UPGRADE_BACKUP_KEEP` retains the latest three automatic snapshots by default (valid range: 1–1000).
|
|
308
|
+
|
|
309
|
+
Use `artifacty backups list`, `artifacty backups verify <id>`, and `artifacty backups restore <id> --confirm restore-store`. Stop every server and MCP process before an upgrade or restore. Restore creates a recovery snapshot before replacing the store. It also restores credentials and security state; previously revoked sessions, tokens, and links can become valid again.
|
|
310
|
+
|
|
311
|
+
These local snapshots are separate from portable `artifacty backup`/`export` JSON bundles. See [upgrade snapshots and recovery](docs/upgrade-backups.md) for verification, corrupt-store recovery, and interrupted-restore procedures.
|
|
312
|
+
|
|
301
313
|
### Semantic Search
|
|
302
314
|
|
|
303
315
|
`?mode=keyword|semantic|hybrid` on `GET /api/artifacts`, the dashboard, `artifacty list --mode`, and the MCP `artifacty_list` tool add optional natural-language search on top of FTS5, without any bundled model or mandatory dependency:
|
|
@@ -705,6 +717,8 @@ The table lists every `ARTIFACTY_*` variable that Artifacty reads. `node src/cli
|
|
|
705
717
|
| Variable | Purpose | Default |
|
|
706
718
|
| --- | --- | --- |
|
|
707
719
|
| `ARTIFACTY_HOME` | Storage directory | `~/.artifacty` |
|
|
720
|
+
| `ARTIFACTY_BACKUP_ON_UPDATE` | Snapshot existing stores before application-version changes; schema-migration snapshots are always required | `false` |
|
|
721
|
+
| `ARTIFACTY_UPGRADE_BACKUP_KEEP` | Automatic upgrade snapshots to retain; integer from 1 to 1000 | `3` |
|
|
708
722
|
| `ARTIFACTY_URL` | Public URL override; otherwise CLI/MCP read the last running server URL | — |
|
|
709
723
|
| `ARTIFACTY_HOST` | Bind host for the HTTP server | `127.0.0.1` |
|
|
710
724
|
| `ARTIFACTY_PORT` | Bind port for the HTTP server | `8787` |
|
package/docs/integrations.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Artifacty exposes one shared MCP stdio server and one local browser server.
|
|
4
4
|
|
|
5
|
+
Use Node.js 22.12.0 or newer. The optional Babel 8 React renderer requires Node `^22.18.0 || >=24.11.0`. Mermaid 12.1.0 requires an ES2024-capable browser, such as Safari 17.4 or newer.
|
|
6
|
+
|
|
5
7
|
## Start the Browser Server
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -418,3 +420,5 @@ in one JSON bundle. It excludes users, sessions, API token records, and audit
|
|
|
418
420
|
logs. A store import replaces the target artifact records and removes
|
|
419
421
|
unreferenced version files. Before importing, select a new or intended
|
|
420
422
|
`ARTIFACTY_HOME`.
|
|
423
|
+
|
|
424
|
+
Upgrade snapshots protect schema migrations and optionally application updates. They preserve raw database security state and differ from JSON export backups. Stop all server and MCP processes before recovery. See [upgrade snapshots and recovery](upgrade-backups.md).
|
|
@@ -23,6 +23,11 @@ Run the local diagnostics pass:
|
|
|
23
23
|
artifacty doctor
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
- Verify recovery commands against temporary stores only. See [upgrade snapshots and recovery](upgrade-backups.md).
|
|
27
|
+
- Confirm mandatory schema snapshots complete before migrations change the store.
|
|
28
|
+
- Check the Node.js 22.12.0 minimum. The optional Babel 8 renderer requires Node `^22.18.0 || >=24.11.0`.
|
|
29
|
+
- Check Mermaid 12.1.0 in an ES2024-capable browser. Preserve its `classic` look, `dagre` layout, and `default` theme.
|
|
30
|
+
|
|
26
31
|
## Packaging
|
|
27
32
|
|
|
28
33
|
- Confirm `package.json` `version` and `files` are intentional.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Upgrade Snapshots and Recovery
|
|
2
|
+
|
|
3
|
+
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
|
+
|
|
5
|
+
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
|
+
|
|
7
|
+
## When Artifacty Creates a Snapshot
|
|
8
|
+
|
|
9
|
+
- **Schema migration:** Artifacty always snapshots an existing store before migration. This includes a legacy `index.json` store. A new empty store needs no snapshot.
|
|
10
|
+
- **Application update:** Set `ARTIFACTY_BACKUP_ON_UPDATE=true` to also snapshot an existing store when the application version changes. This setting is off by default.
|
|
11
|
+
- **Unknown application version:** With the update setting enabled, the first open of an existing store without an application-version marker creates a baseline snapshot. Its source application version is unknown. Later opens with the same version do not repeat it.
|
|
12
|
+
- **Restore:** Before replacing a store, Artifacty creates and verifies a recovery snapshot of its current state.
|
|
13
|
+
|
|
14
|
+
Stop every HTTP server and MCP process that uses the store before an upgrade or restore. This includes services, foreground servers, and local stdio MCP clients. Artifacty checks managed server PID markers, but these markers cannot identify every process with an open store.
|
|
15
|
+
|
|
16
|
+
Snapshots are stored at `ARTIFACTY_HOME/upgrade-backups/<id>/`. The default home is `~/.artifacty`.
|
|
17
|
+
|
|
18
|
+
## Snapshot Contents
|
|
19
|
+
|
|
20
|
+
Each snapshot contains a manifest with file sizes and SHA-256 hashes. It also contains:
|
|
21
|
+
|
|
22
|
+
- A consistent raw SQLite database, including all tables and security state.
|
|
23
|
+
- Every version file referenced by the database or an authoritative legacy index.
|
|
24
|
+
- The legacy `index.json`, if it exists.
|
|
25
|
+
|
|
26
|
+
Verification checks the file hashes, SQLite integrity, and version-file references. Snapshots do not include unrelated files, process markers, logs, or the complete installation directory. Keep snapshots private: they contain credentials and security records.
|
|
27
|
+
|
|
28
|
+
`ARTIFACTY_UPGRADE_BACKUP_KEEP` controls automatic snapshot retention. The default is `3`; valid values are integers from `1` through `1000`. Artifacty prunes older automatic schema/update snapshots after a successful upgrade. It does not prune manual or recovery snapshots with this setting.
|
|
29
|
+
|
|
30
|
+
## Inspect Snapshots
|
|
31
|
+
|
|
32
|
+
List snapshots without opening or migrating the store:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
artifacty backups list --home /path/to/store
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Copy an `id` from the output. Verify that snapshot:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
artifacty backups verify <id> --home /path/to/store
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A successful response includes `valid: true`. Listing checks manifests; it does not replace full verification. From a repository checkout, use `node src/cli.js` in place of `artifacty`.
|
|
45
|
+
|
|
46
|
+
## Restore a Snapshot
|
|
47
|
+
|
|
48
|
+
A restore replaces the current database, version files, and legacy index. Changes made after the snapshot are no longer current. Artifacty keeps the pre-restore state in a separate recovery snapshot.
|
|
49
|
+
|
|
50
|
+
**Security state also rolls back.** Old passwords, permissions, network rules, sessions, API tokens, and share-link records can become current again. A session, token, or link revoked after the snapshot can become valid again if it has not expired. Review the restored security state before allowing users to reconnect.
|
|
51
|
+
|
|
52
|
+
1. Stop all server, service, and MCP processes that use this store.
|
|
53
|
+
2. Select a snapshot from `backups list`.
|
|
54
|
+
3. Verify it with `backups verify`.
|
|
55
|
+
4. Restore it with explicit confirmation:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
artifacty backups restore <id> --confirm restore-store --home /path/to/store
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
5. Save the returned `recoveryBackupId`.
|
|
62
|
+
6. Review the restored security settings before reconnecting clients.
|
|
63
|
+
|
|
64
|
+
Restore does not migrate the selected snapshot. Opening it with the current application can trigger another protected migration. A build that does not support the restored schema rejects the store.
|
|
65
|
+
|
|
66
|
+
## Recover When the Current Store Is Corrupt
|
|
67
|
+
|
|
68
|
+
If Artifacty cannot verify the current store for its recovery snapshot, it returns `RECOVERY_BACKUP_FAILED`. It does not overwrite the damaged store.
|
|
69
|
+
|
|
70
|
+
1. Keep the damaged home and its snapshots unchanged.
|
|
71
|
+
2. Create a new empty home directory.
|
|
72
|
+
3. Copy the selected snapshot directory into the new home's `upgrade-backups/` directory. Preserve its original snapshot ID and directory contents.
|
|
73
|
+
4. Run `backups verify <id> --home /path/to/new-home`.
|
|
74
|
+
5. Run `backups restore <id> --confirm restore-store --home /path/to/new-home`.
|
|
75
|
+
6. Review the restored data and security settings before using the new home.
|
|
76
|
+
|
|
77
|
+
Do not copy the damaged database over the new home. The selected snapshot supplies the database and referenced version files.
|
|
78
|
+
|
|
79
|
+
## Recover an Interrupted Restore
|
|
80
|
+
|
|
81
|
+
If a `.restore-<uuid>` directory remains, Artifacty stops with `RESTORE_INCOMPLETE`.
|
|
82
|
+
|
|
83
|
+
1. Preserve that directory and the entire affected home.
|
|
84
|
+
2. Read `.restore-<uuid>/restore.json`.
|
|
85
|
+
3. Find its `recoveryBackupId`. This identifies the snapshot of the state before the interrupted restore.
|
|
86
|
+
4. Copy that recovery snapshot into a new empty home's `upgrade-backups/` directory.
|
|
87
|
+
5. Verify and restore it there with the commands above.
|
|
88
|
+
|
|
89
|
+
Do not remove the interrupted restore directory to bypass the check. It contains recovery evidence and can contain the previous database and files.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "artifacty",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"docs/roadmap-design.md",
|
|
45
45
|
"docs/sarif-csv-artifact-plan.md",
|
|
46
46
|
"docs/threat-model.md",
|
|
47
|
+
"docs/upgrade-backups.md",
|
|
47
48
|
"docs/writing-style.md",
|
|
48
49
|
"scripts/lint.mjs",
|
|
49
50
|
"scripts/smoke.sh",
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
"CLAUDE.md"
|
|
56
57
|
],
|
|
57
58
|
"engines": {
|
|
58
|
-
"node": ">=22.
|
|
59
|
+
"node": ">=22.12"
|
|
59
60
|
},
|
|
60
61
|
"license": "MIT",
|
|
61
62
|
"dependencies": {
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"@codemirror/lang-json": "^6.0.2",
|
|
65
66
|
"@codemirror/lang-markdown": "^6.5.0",
|
|
66
67
|
"codemirror": "^6.0.2",
|
|
67
|
-
"mermaid": "^
|
|
68
|
+
"mermaid": "^12.1.0"
|
|
68
69
|
},
|
|
69
70
|
"optionalDependencies": {
|
|
70
71
|
"@babel/standalone": "^8.0.4",
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { generateToken } from "./lib/token.js";
|
|
6
7
|
import { parseSseFrames } from "./lib/sse.js";
|
|
@@ -32,6 +33,27 @@ async function main() {
|
|
|
32
33
|
return;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// Recovery must work before loading storage or triggering a schema migration.
|
|
37
|
+
if (command === "backups") {
|
|
38
|
+
const { listUpgradeBackups, verifyUpgradeBackup, restoreUpgradeBackup } = await import("./lib/upgrade-backup.js");
|
|
39
|
+
const store = { home: path.resolve(options.home || process.env.ARTIFACTY_HOME || path.join(homedir(), ".artifacty")) };
|
|
40
|
+
const [action, id, ...extra] = options._;
|
|
41
|
+
if (extra.length || !["list", "verify", "restore"].includes(action) || (action === "list" ? Boolean(id) : !id)) {
|
|
42
|
+
throw new Error("Usage: artifacty backups list | verify <id> | restore <id> --confirm restore-store");
|
|
43
|
+
}
|
|
44
|
+
if (action === "list") {
|
|
45
|
+
printJson({ backups: listUpgradeBackups(store) });
|
|
46
|
+
} else if (action === "verify") {
|
|
47
|
+
printJson(verifyUpgradeBackup(store, id));
|
|
48
|
+
} else {
|
|
49
|
+
if (options.confirm !== "restore-store") {
|
|
50
|
+
throw new Error("Store recovery requires --confirm restore-store. Stop all server and MCP processes before restoring.");
|
|
51
|
+
}
|
|
52
|
+
printJson(restoreUpgradeBackup(store, id, { confirm: options.confirm }));
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
const {
|
|
36
58
|
addComment,
|
|
37
59
|
addRelation,
|
|
@@ -982,6 +1004,9 @@ Usage:
|
|
|
982
1004
|
artifacty retention run [--dry-run] [--allow-purge]
|
|
983
1005
|
artifacty export --file <path> [--full]
|
|
984
1006
|
artifacty backup [--file <path>] [--full]
|
|
1007
|
+
artifacty backups list [--home <path>]
|
|
1008
|
+
artifacty backups verify <id> [--home <path>]
|
|
1009
|
+
artifacty backups restore <id> --confirm restore-store [--home <path>]
|
|
985
1010
|
artifacty import-store --file <path> [--confirm replace-all] [--force-users]
|
|
986
1011
|
artifacty users import --file <path.csv> [--no-password-reset]
|
|
987
1012
|
artifacty service plist|unit|task|install|uninstall [--platform macos|linux|windows] [--dry-run] [--path <path>] [--mcp-http]
|
|
@@ -1000,8 +1025,17 @@ Usage:
|
|
|
1000
1025
|
artifacty diff <id> [--from N] [--to M] [--structured] [--json]
|
|
1001
1026
|
artifacty watch [--tag tag] [--artifact <id>] [--type artifact.updated] [--json] [--once] [--exec "<cmd>"]
|
|
1002
1027
|
|
|
1028
|
+
Upgrade recovery:
|
|
1029
|
+
Existing stores receive a mandatory snapshot before a schema migration.
|
|
1030
|
+
Set ARTIFACTY_BACKUP_ON_UPDATE=true to also snapshot before app-version changes.
|
|
1031
|
+
backups list and verify do not migrate the store.
|
|
1032
|
+
Stop all server and MCP processes before backups restore. Restore preserves a recovery snapshot.
|
|
1033
|
+
artifacty restore <id> restores an archived artifact, not a store backup.
|
|
1034
|
+
|
|
1003
1035
|
Environment:
|
|
1004
1036
|
ARTIFACTY_HOME Storage directory. Defaults to ~/.artifacty
|
|
1037
|
+
ARTIFACTY_BACKUP_ON_UPDATE Set true for optional app-version snapshots; schema backups are mandatory
|
|
1038
|
+
ARTIFACTY_UPGRADE_BACKUP_KEEP Automatic snapshots to retain after a successful upgrade (default: 3)
|
|
1005
1039
|
ARTIFACTY_URL Public URL override. Otherwise CLI/MCP read the last running server URL
|
|
1006
1040
|
ARTIFACTY_HOST Bind host for the HTTP server. Defaults to 127.0.0.1
|
|
1007
1041
|
ARTIFACTY_PORT Bind port for the HTTP server. Defaults to 8787
|
package/src/lib/doctor.js
CHANGED
|
@@ -9,7 +9,7 @@ import { securityConfig, validateServerExposure, exposureWarning } from "./secur
|
|
|
9
9
|
import { serviceCommand } from "./service.js";
|
|
10
10
|
import { checkStoreIntegrity, createStore } from "./storage.js";
|
|
11
11
|
|
|
12
|
-
const MIN_NODE_VERSION = "22.
|
|
12
|
+
const MIN_NODE_VERSION = "22.12.0";
|
|
13
13
|
|
|
14
14
|
export async function runDoctor(options = {}) {
|
|
15
15
|
const packageRoot = path.resolve(options.packageRoot || process.cwd());
|
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;
|
|
@@ -3346,7 +3350,7 @@ function mermaidFrameContent(content) {
|
|
|
3346
3350
|
parent.postMessage({ __artifactyHeight: height }, "*");
|
|
3347
3351
|
}
|
|
3348
3352
|
try {
|
|
3349
|
-
mermaid.initialize({ startOnLoad: false, securityLevel: "strict" });
|
|
3353
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", layout: "dagre", theme: "default", look: "classic" });
|
|
3350
3354
|
const result = await mermaid.render("artifacty-mermaid-svg", source);
|
|
3351
3355
|
target.innerHTML = result.svg;
|
|
3352
3356
|
} catch (error) {
|
|
@@ -12,7 +12,7 @@ Artifacty keeps agent outputs in one versioned store. You can use the store thro
|
|
|
12
12
|
|
|
13
13
|
## Start locally
|
|
14
14
|
|
|
15
|
-
Use Node.js 22.
|
|
15
|
+
Use Node.js 22.12.0 or newer. The optional Babel 8 React renderer requires Node 22.18.x or newer within Node 22, or Node 24.11.0 or newer. Use an ES2024-capable browser, such as Safari 17.4 or newer, for Mermaid 12.1.0 diagrams. The classic look, dagre layout, and default theme stay unchanged. From the repository, run:
|
|
16
16
|
|
|
17
17
|
\`\`\`sh
|
|
18
18
|
npm install
|
|
@@ -50,7 +50,13 @@ A share link is a separate read-only capability with an expiry time. It does not
|
|
|
50
50
|
|
|
51
51
|
\`node src/cli.js samples install\` installs these four manuals once per installation scope. Repeating installation skips active manuals. \`samples remove\` archives managed samples. It preserves their content and versions. Installing again restores them without replacing your edits. Matching titles or tags do not make your own artifacts managed samples.
|
|
52
52
|
|
|
53
|
-
Back up before maintenance. This build uses store schema 10. Older builds refuse newer stores. This manual describes the implemented repository behavior, not a package release announcement
|
|
53
|
+
Back up before maintenance. This build uses store schema 10. Older builds refuse newer stores. This manual describes the implemented repository behavior, not a package release announcement.
|
|
54
|
+
|
|
55
|
+
Before migrating an existing schema, Artifacty creates a mandatory verified snapshot of the raw SQLite database, referenced version files, and legacy index. Set ARTIFACTY_BACKUP_ON_UPDATE=true to also snapshot application-version changes. If the old application version is unknown, the first open creates a baseline snapshot. ARTIFACTY_UPGRADE_BACKUP_KEEP defaults to 3 automatic snapshots and accepts 1–1000.
|
|
56
|
+
|
|
57
|
+
Use \`artifacty backups list\`, \`artifacty backups verify <id>\`, and \`artifacty backups restore <id> --confirm restore-store\`. Stop every server and MCP process first. Restore creates a recovery snapshot before replacing the store. It also restores credentials and security state, so previously revoked sessions, tokens, and links can become valid again. These snapshots differ from portable JSON export backups.
|
|
58
|
+
|
|
59
|
+
If the current store is corrupt, preserve it and copy the selected snapshot to a new empty home before restoring there. If a restore leaves a .restore-* directory, preserve it. Read its restore.json recoveryBackupId and restore that recovery snapshot into a new empty home. See docs/upgrade-backups.md in the repository for the full procedure.`
|
|
54
60
|
},
|
|
55
61
|
{
|
|
56
62
|
key: "feature-guide",
|
|
@@ -61,13 +67,13 @@ Back up before maintenance. This build uses store schema 10. Older builds refuse
|
|
|
61
67
|
content: `<!doctype html>
|
|
62
68
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Artifacty feature guide</title>
|
|
63
69
|
<style>body{font-family:system-ui,sans-serif;line-height:1.65;color:#172b3a;background:#f5f7fa;margin:0}main{max-width:900px;margin:auto;padding:40px 24px}h1,h2{line-height:1.2}h1{font-size:2.4rem}section{background:white;border:1px solid #dce4ec;border-radius:12px;padding:24px;margin:20px 0}code{background:#edf2f7;padding:2px 5px;border-radius:4px}li{margin:6px 0}a{color:#175aa3}footer{color:#526475}</style></head><body><main>
|
|
64
|
-
<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.
|
|
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>
|
|
65
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>
|
|
66
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>
|
|
67
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>
|
|
68
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>
|
|
69
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>
|
|
70
|
-
<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 backups to move or
|
|
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>
|
|
71
77
|
<footer><p>This guide is a managed sample artifact. Editing it creates your own version. Reinstalling samples preserves that work.</p></footer>
|
|
72
78
|
</main></body></html>`
|
|
73
79
|
},
|
package/src/lib/storage.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, s
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { openProtectedDatabase } from "./upgrade-backup.js";
|
|
7
7
|
import { isUnknownSourceAgent, normalizeSourceAgent } from "./agents.js";
|
|
8
8
|
import { assertNoSecrets, normalizeTokenScopes, securityConfig } from "./security.js";
|
|
9
9
|
import { EVENT_TYPES, eventFromAudit, eventHistoryLimit, eventVisibleTo, matchesFilter, publish as publishEvent } from "./events.js";
|
|
@@ -3630,11 +3630,7 @@ export function normalizeArtifactType(value = "document") {
|
|
|
3630
3630
|
// same connection setup (PRAGMAs, schema init/migration) instead of opening
|
|
3631
3631
|
// a second, differently-configured DatabaseSync against the same file.
|
|
3632
3632
|
export function openDatabase(store) {
|
|
3633
|
-
|
|
3634
|
-
mkdirSync(store.artifactsDir, { recursive: true });
|
|
3635
|
-
|
|
3636
|
-
const db = new DatabaseSync(store.dbPath);
|
|
3637
|
-
try {
|
|
3633
|
+
return openProtectedDatabase(store, STORE_VERSION, (db) => {
|
|
3638
3634
|
db.exec(`
|
|
3639
3635
|
PRAGMA journal_mode = WAL;
|
|
3640
3636
|
PRAGMA foreign_keys = ON;
|
|
@@ -3644,13 +3640,7 @@ export function openDatabase(store) {
|
|
|
3644
3640
|
migrateJsonIndex(db, store);
|
|
3645
3641
|
normalizeStoredSourceAgents(db, store);
|
|
3646
3642
|
syncSearchIndexIfEmpty(db, store);
|
|
3647
|
-
}
|
|
3648
|
-
// Never leak an open handle when initialization fails (for example the
|
|
3649
|
-
// store_version guard); on Windows an open handle blocks file removal.
|
|
3650
|
-
db.close();
|
|
3651
|
-
throw error;
|
|
3652
|
-
}
|
|
3653
|
-
return db;
|
|
3643
|
+
});
|
|
3654
3644
|
}
|
|
3655
3645
|
|
|
3656
3646
|
function initializeSchema(db) {
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync,
|
|
4
|
+
readFileSync, readdirSync, renameSync, rmSync, writeFileSync, chmodSync
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
|
|
9
|
+
const APP_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
10
|
+
const KIND = "artifacty-upgrade-backup";
|
|
11
|
+
const ID_PATTERN = /^(auto|manual|recovery)-[0-9T-]+-[a-f0-9-]{36}$/;
|
|
12
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
13
|
+
|
|
14
|
+
function error(message, code = "UPGRADE_BACKUP_FAILED") {
|
|
15
|
+
return Object.assign(new Error(message), { code });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function paths(store) {
|
|
19
|
+
const home = path.resolve(store.home);
|
|
20
|
+
return { home, dbPath: path.join(home, "artifacty.sqlite"), indexPath: path.join(home, "index.json"), artifactsDir: path.join(home, "artifacts"), backups: path.join(home, "upgrade-backups") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safePath(root, relative) {
|
|
24
|
+
if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.includes("\\") || relative.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
25
|
+
throw error(`Unsafe snapshot path: ${relative}`);
|
|
26
|
+
}
|
|
27
|
+
const target = path.resolve(root, relative);
|
|
28
|
+
if (!target.startsWith(`${path.resolve(root)}${path.sep}`)) throw error("Snapshot path escapes its root");
|
|
29
|
+
let current = root;
|
|
30
|
+
for (const part of relative.split("/")) {
|
|
31
|
+
current = path.join(current, part);
|
|
32
|
+
try {
|
|
33
|
+
if (lstatSync(current).isSymbolicLink()) throw error(`Snapshot paths must not contain symbolic links: ${current}`);
|
|
34
|
+
} catch (cause) { if (cause.code !== "ENOENT") throw cause; }
|
|
35
|
+
}
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function privateDirectory(directory) {
|
|
40
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
41
|
+
const stat = lstatSync(directory);
|
|
42
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw error(`Expected a real directory: ${directory}`);
|
|
43
|
+
chmodSync(directory, 0o700);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function alive(pid) {
|
|
47
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
48
|
+
try { process.kill(pid, 0); return true; }
|
|
49
|
+
catch (cause) { return cause.code !== "ESRCH"; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertOffline(store, allowOwnPid = false) {
|
|
53
|
+
for (const filename of ["server.pid", "server.json"]) {
|
|
54
|
+
const file = safePath(store.home, filename);
|
|
55
|
+
if (!existsSync(file)) continue;
|
|
56
|
+
let pid;
|
|
57
|
+
try {
|
|
58
|
+
const text = readFileSync(file, "utf8");
|
|
59
|
+
pid = filename.endsWith(".json") ? JSON.parse(text).pid : Number(text.trim());
|
|
60
|
+
} catch { throw error(`Cannot inspect ${filename}; stop the server and repair this marker first`, "STORE_NOT_OFFLINE"); }
|
|
61
|
+
if (alive(pid) && !(allowOwnPid && pid === process.pid)) throw error("Stop the managed Artifacty server before upgrading or restoring this store", "STORE_NOT_OFFLINE");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function durableWrite(file, content) {
|
|
66
|
+
const fd = openSync(file, "wx", 0o600);
|
|
67
|
+
try { writeFileSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function syncDirectory(directory) {
|
|
71
|
+
if (process.platform === "win32") return;
|
|
72
|
+
const fd = openSync(directory, "r");
|
|
73
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function syncTree(directory) {
|
|
77
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
78
|
+
const file = path.join(directory, entry.name);
|
|
79
|
+
if (entry.isDirectory()) syncTree(file);
|
|
80
|
+
else {
|
|
81
|
+
const fd = openSync(file, "r+");
|
|
82
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
syncDirectory(directory);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A separate reaper guard serializes removal of dead-owner locks. A malformed
|
|
89
|
+
// lock or an abandoned reaper fails closed; it is never guessed to be stale.
|
|
90
|
+
function lockStore(store) {
|
|
91
|
+
privateDirectory(store.home);
|
|
92
|
+
const lock = safePath(store.home, ".upgrade.lock");
|
|
93
|
+
const reaper = safePath(store.home, ".upgrade-reaper");
|
|
94
|
+
const token = randomUUID();
|
|
95
|
+
const deadline = Date.now() + 5000;
|
|
96
|
+
const acquire = () => {
|
|
97
|
+
durableWrite(lock, JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }));
|
|
98
|
+
return () => {
|
|
99
|
+
const current = JSON.parse(readFileSync(lock, "utf8"));
|
|
100
|
+
if (current.token !== token) throw error("Store lock ownership changed");
|
|
101
|
+
rmSync(lock);
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
while (true) {
|
|
105
|
+
if (!existsSync(reaper)) {
|
|
106
|
+
try { return acquire(); } catch (cause) { if (cause.code !== "EEXIST") throw cause; }
|
|
107
|
+
let owner;
|
|
108
|
+
try { owner = JSON.parse(readFileSync(lock, "utf8")); }
|
|
109
|
+
catch (cause) {
|
|
110
|
+
if (cause.code === "ENOENT") continue;
|
|
111
|
+
if (Date.now() >= deadline) throw error("Unreadable upgrade lock; inspect .upgrade.lock before retrying", "STORE_LOCKED");
|
|
112
|
+
Atomics.wait(wait, 0, 0, 25);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isInteger(owner.pid) || !owner.token) throw error("Invalid upgrade lock; inspect .upgrade.lock before retrying", "STORE_LOCKED");
|
|
116
|
+
if (!alive(owner.pid)) {
|
|
117
|
+
try { mkdirSync(reaper, { mode: 0o700 }); }
|
|
118
|
+
catch (cause) { if (cause.code === "EEXIST") continue; throw cause; }
|
|
119
|
+
try {
|
|
120
|
+
if (existsSync(lock)) {
|
|
121
|
+
const current = JSON.parse(readFileSync(lock, "utf8"));
|
|
122
|
+
if (current.token === owner.token && !alive(current.pid)) rmSync(lock);
|
|
123
|
+
}
|
|
124
|
+
if (!existsSync(lock)) return acquire();
|
|
125
|
+
} finally { rmSync(reaper, { recursive: true }); }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (Date.now() >= deadline) throw error("Store upgrade or restore is already running; inspect .upgrade.lock and .upgrade-reaper if its owner has stopped", "STORE_LOCKED");
|
|
129
|
+
Atomics.wait(wait, 0, 0, 25);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rawState(store) {
|
|
134
|
+
safePath(store.home, "artifacty.sqlite");
|
|
135
|
+
safePath(store.home, "index.json");
|
|
136
|
+
if (!existsSync(store.dbPath) || lstatSync(store.dbPath).size === 0) return { schemaVersion: null, appVersion: null, hasDatabase: false, hasData: existsSync(store.indexPath) };
|
|
137
|
+
safePath(store.home, "artifacty.sqlite");
|
|
138
|
+
const db = new DatabaseSync(store.dbPath, { readOnly: true });
|
|
139
|
+
try {
|
|
140
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
141
|
+
const hasMeta = tables.some((table) => table.name === "meta");
|
|
142
|
+
const schema = hasMeta ? db.prepare("SELECT value FROM meta WHERE key='store_version'").get()?.value : null;
|
|
143
|
+
const app = hasMeta ? db.prepare("SELECT value FROM meta WHERE key='application_version'").get()?.value : null;
|
|
144
|
+
if (schema != null && (!/^\d+$/.test(String(schema)) || !Number.isSafeInteger(Number(schema)))) throw error("Invalid stored schema version");
|
|
145
|
+
return { schemaVersion: schema == null ? null : Number(schema), appVersion: app || null, hasDatabase: true, hasData: tables.length > 0 || existsSync(store.indexPath) };
|
|
146
|
+
} finally { db.close(); }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fileInfo(file) {
|
|
150
|
+
const stat = lstatSync(file);
|
|
151
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw error(`Expected a regular file: ${file}`);
|
|
152
|
+
return { sizeBytes: stat.size, sha256: createHash("sha256").update(readFileSync(file)).digest("hex") };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function references(database, indexFile) {
|
|
156
|
+
const refs = new Map();
|
|
157
|
+
const add = (row) => {
|
|
158
|
+
const versionPath = typeof row.path === "string" ? row.path.replaceAll("\\", "/") : "";
|
|
159
|
+
if (!versionPath.startsWith("artifacts/")) throw error("Version path must be inside artifacts/");
|
|
160
|
+
const expected = { path: versionPath, sizeBytes: row.size_bytes ?? row.sizeBytes, sha256: row.sha256 };
|
|
161
|
+
if (!Number.isSafeInteger(expected.sizeBytes) || expected.sizeBytes < 0 || !/^[a-f0-9]{64}$/i.test(expected.sha256 || "")) throw error(`Invalid version size or hash: ${row.path}`);
|
|
162
|
+
const previous = refs.get(versionPath);
|
|
163
|
+
if (previous && (previous.sha256 !== expected.sha256 || previous.sizeBytes !== expected.sizeBytes)) throw error("Conflicting version file references");
|
|
164
|
+
refs.set(versionPath, expected);
|
|
165
|
+
};
|
|
166
|
+
let legacyAlreadyImported = false;
|
|
167
|
+
if (database) {
|
|
168
|
+
const db = new DatabaseSync(database, { readOnly: true });
|
|
169
|
+
try {
|
|
170
|
+
const result = db.prepare("PRAGMA integrity_check").all();
|
|
171
|
+
if (result.length !== 1 || Object.values(result[0])[0] !== "ok") throw error("Snapshot SQLite integrity check failed");
|
|
172
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE name='artifact_versions'").get()) {
|
|
173
|
+
for (const row of db.prepare("SELECT path, size_bytes, sha256 FROM artifact_versions").all()) add(row);
|
|
174
|
+
}
|
|
175
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE name='artifacts'").get()) {
|
|
176
|
+
legacyAlreadyImported = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get().count > 0;
|
|
177
|
+
}
|
|
178
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE name='meta'").get()) {
|
|
179
|
+
legacyAlreadyImported ||= db.prepare("SELECT value FROM meta WHERE key='json_index_migrated'").get()?.value === "true";
|
|
180
|
+
}
|
|
181
|
+
} finally { db.close(); }
|
|
182
|
+
}
|
|
183
|
+
// A legacy index is authoritative only before its import into SQLite.
|
|
184
|
+
if (indexFile && !legacyAlreadyImported) {
|
|
185
|
+
const index = JSON.parse(readFileSync(indexFile, "utf8"));
|
|
186
|
+
if (!Array.isArray(index.artifacts)) throw error("Invalid legacy index");
|
|
187
|
+
for (const artifact of index.artifacts) for (const version of artifact.versions || []) add(version);
|
|
188
|
+
}
|
|
189
|
+
return [...refs.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function compareFile(root, expected) {
|
|
193
|
+
const actual = fileInfo(safePath(root, expected.path));
|
|
194
|
+
if (actual.sizeBytes !== expected.sizeBytes || actual.sha256 !== expected.sha256) throw error(`Snapshot file verification failed: ${expected.path}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function snapshotDirectory(store, id) {
|
|
198
|
+
if (!ID_PATTERN.test(id || "")) throw error("Invalid snapshot id");
|
|
199
|
+
return safePath(store.home, `upgrade-backups/${id}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function manifestAt(store, id) {
|
|
203
|
+
const directory = snapshotDirectory(store, id);
|
|
204
|
+
const manifest = JSON.parse(readFileSync(safePath(directory, "manifest.json"), "utf8"));
|
|
205
|
+
if (manifest.kind !== KIND || manifest.manifestVersion !== 1 || manifest.id !== id || !Array.isArray(manifest.files)) throw error("Invalid snapshot manifest");
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
for (const file of manifest.files) {
|
|
208
|
+
if (!file || (file.path !== "artifacty.sqlite" && file.path !== "index.json" && !file.path?.startsWith("artifacts/")) || seen.has(file.path)) throw error("Invalid snapshot file entry");
|
|
209
|
+
safePath(directory, file.path);
|
|
210
|
+
if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < 0 || !/^[a-f0-9]{64}$/i.test(file.sha256 || "")) throw error("Invalid snapshot file hash or size");
|
|
211
|
+
seen.add(file.path);
|
|
212
|
+
}
|
|
213
|
+
return { directory, manifest };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function verifyUpgradeBackup(input, id) {
|
|
217
|
+
const store = paths(input);
|
|
218
|
+
const { directory, manifest } = manifestAt(store, id);
|
|
219
|
+
for (const file of manifest.files) compareFile(directory, file);
|
|
220
|
+
const database = manifest.files.some((file) => file.path === "artifacty.sqlite") ? safePath(directory, "artifacty.sqlite") : null;
|
|
221
|
+
const index = manifest.files.some((file) => file.path === "index.json") ? safePath(directory, "index.json") : null;
|
|
222
|
+
if (!database && !index && !manifest.emptyStore) throw error("Snapshot has no database or legacy index");
|
|
223
|
+
const refs = references(database, index);
|
|
224
|
+
const listed = manifest.files.filter((file) => file.path.startsWith("artifacts/"));
|
|
225
|
+
if (JSON.stringify(refs) !== JSON.stringify(listed)) throw error("Snapshot manifest does not match database file references");
|
|
226
|
+
return { id, valid: true, filesVerified: manifest.files.length, ...manifest, path: directory };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function listUpgradeBackups(input) {
|
|
230
|
+
const store = paths(input);
|
|
231
|
+
safePath(store.home, "upgrade-backups");
|
|
232
|
+
if (!existsSync(store.backups)) return [];
|
|
233
|
+
return readdirSync(store.backups).filter((id) => ID_PATTERN.test(id)).map((id) => {
|
|
234
|
+
try { const { directory, manifest } = manifestAt(store, id); return { ...manifest, path: directory }; }
|
|
235
|
+
catch (cause) { return { id, valid: false, error: cause.message }; }
|
|
236
|
+
}).sort((a, b) => String(b.createdAt || b.id).localeCompare(String(a.createdAt || a.id)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function retentionCount() {
|
|
240
|
+
const value = process.env.ARTIFACTY_UPGRADE_BACKUP_KEEP || "3";
|
|
241
|
+
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 1000) throw error("ARTIFACTY_UPGRADE_BACKUP_KEEP must be from 1 to 1000");
|
|
242
|
+
return Number(value);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function pruneAutomatic(store) {
|
|
246
|
+
const keep = retentionCount();
|
|
247
|
+
const automatic = listUpgradeBackups(store).filter((item) => item.id.startsWith("auto-") && item.kind === KIND && ["schema-upgrade", "application-update"].includes(item.reason));
|
|
248
|
+
for (const item of automatic.slice(keep)) {
|
|
249
|
+
verifyUpgradeBackup(store, item.id);
|
|
250
|
+
rmSync(snapshotDirectory(store, item.id), { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function createSnapshot(store, options = {}) {
|
|
255
|
+
const state = rawState(store);
|
|
256
|
+
const reason = options.reason || "manual";
|
|
257
|
+
const prefix = reason === "recovery" ? "recovery" : ["schema-upgrade", "application-update"].includes(reason) ? "auto" : "manual";
|
|
258
|
+
const id = `${prefix}-${new Date().toISOString().replace(/[:.Z]/g, "-")}-${randomUUID()}`;
|
|
259
|
+
privateDirectory(safePath(store.home, "upgrade-backups"));
|
|
260
|
+
const pending = safePath(store.backups, `.pending-${randomUUID()}`);
|
|
261
|
+
privateDirectory(pending);
|
|
262
|
+
let guard;
|
|
263
|
+
let published;
|
|
264
|
+
let complete = false;
|
|
265
|
+
try {
|
|
266
|
+
const files = [];
|
|
267
|
+
if (state.hasDatabase) {
|
|
268
|
+
guard = new DatabaseSync(store.dbPath);
|
|
269
|
+
guard.exec("PRAGMA busy_timeout=5000; BEGIN IMMEDIATE");
|
|
270
|
+
const source = new DatabaseSync(store.dbPath, { readOnly: true });
|
|
271
|
+
try { source.prepare("VACUUM INTO ?").run(path.join(pending, "artifacty.sqlite")); }
|
|
272
|
+
finally { source.close(); }
|
|
273
|
+
chmodSync(path.join(pending, "artifacty.sqlite"), 0o600);
|
|
274
|
+
files.push({ path: "artifacty.sqlite", ...fileInfo(path.join(pending, "artifacty.sqlite")) });
|
|
275
|
+
}
|
|
276
|
+
if (existsSync(store.indexPath)) {
|
|
277
|
+
const original = safePath(store.home, "index.json");
|
|
278
|
+
copyFileSync(original, path.join(pending, "index.json"));
|
|
279
|
+
chmodSync(path.join(pending, "index.json"), 0o600);
|
|
280
|
+
files.push({ path: "index.json", ...fileInfo(path.join(pending, "index.json")) });
|
|
281
|
+
}
|
|
282
|
+
const refs = references(state.hasDatabase ? path.join(pending, "artifacty.sqlite") : null, existsSync(path.join(pending, "index.json")) ? path.join(pending, "index.json") : null);
|
|
283
|
+
for (const ref of refs) {
|
|
284
|
+
const source = safePath(store.home, ref.path);
|
|
285
|
+
const target = safePath(pending, ref.path);
|
|
286
|
+
privateDirectory(path.dirname(target));
|
|
287
|
+
copyFileSync(source, target);
|
|
288
|
+
chmodSync(target, 0o600);
|
|
289
|
+
compareFile(pending, ref);
|
|
290
|
+
files.push(ref);
|
|
291
|
+
}
|
|
292
|
+
if (prefix === "auto") {
|
|
293
|
+
const previous = listUpgradeBackups(store).find((item) => item.reason === reason
|
|
294
|
+
&& item.sourceSchemaVersion === state.schemaVersion && item.targetSchemaVersion === (options.targetSchemaVersion ?? null)
|
|
295
|
+
&& item.sourceAppVersion === state.appVersion && item.targetAppVersion === (options.targetAppVersion ?? null)
|
|
296
|
+
&& JSON.stringify(item.files) === JSON.stringify(files));
|
|
297
|
+
if (previous) {
|
|
298
|
+
try { return verifyUpgradeBackup(store, previous.id); } catch { /* Keep damaged backups for inspection; create a verified replacement. */ }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const manifest = { kind: KIND, manifestVersion: 1, id, reason, createdAt: new Date().toISOString(), sourceSchemaVersion: state.schemaVersion, targetSchemaVersion: options.targetSchemaVersion ?? null, sourceAppVersion: state.appVersion, targetAppVersion: options.targetAppVersion ?? null, emptyStore: !state.hasData && !state.hasDatabase, files };
|
|
302
|
+
durableWrite(path.join(pending, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
303
|
+
syncTree(pending);
|
|
304
|
+
const destination = snapshotDirectory(store, id);
|
|
305
|
+
renameSync(pending, destination);
|
|
306
|
+
published = destination;
|
|
307
|
+
syncDirectory(store.backups);
|
|
308
|
+
const verified = verifyUpgradeBackup(store, id);
|
|
309
|
+
complete = true;
|
|
310
|
+
return verified;
|
|
311
|
+
} finally {
|
|
312
|
+
if (guard) { try { guard.exec("ROLLBACK"); } finally { guard.close(); } }
|
|
313
|
+
if (existsSync(pending)) rmSync(pending, { recursive: true, force: true });
|
|
314
|
+
if (published && !complete) rmSync(published, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function assertNoInterruptedRestore(store) {
|
|
319
|
+
const pending = readdirSync(store.home).find((name) => /^\.restore-[a-f0-9-]{36}$/.test(name));
|
|
320
|
+
if (pending) throw error(`Interrupted restore found at ${path.join(store.home, pending)}. Preserve this directory and the damaged store. Use its restore.json recoveryBackupId to restore into a new empty home.`, "RESTORE_INCOMPLETE");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function createUpgradeBackup(input, options = {}) {
|
|
324
|
+
const store = paths(input);
|
|
325
|
+
const release = lockStore(store);
|
|
326
|
+
try { assertNoInterruptedRestore(store); assertOffline(store); return createSnapshot(store, options); }
|
|
327
|
+
finally { release(); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The lock spans the snapshot, migrations, and application-version marker.
|
|
331
|
+
// Existing application handles must be stopped separately before an upgrade.
|
|
332
|
+
export function openProtectedDatabase(input, targetVersion, initialize) {
|
|
333
|
+
const store = paths(input);
|
|
334
|
+
const release = lockStore(store);
|
|
335
|
+
let db;
|
|
336
|
+
try {
|
|
337
|
+
assertNoInterruptedRestore(store);
|
|
338
|
+
const state = rawState(store);
|
|
339
|
+
if (state.schemaVersion > targetVersion) throw error(`Artifacty store was written by a newer version (store_version ${state.schemaVersion}) than this build supports (STORE_VERSION ${targetVersion}). Upgrade artifacty before opening this store.`, "NEWER_STORE_VERSION");
|
|
340
|
+
const schemaUpgrade = state.hasData && (state.schemaVersion == null || state.schemaVersion < targetVersion);
|
|
341
|
+
const appUpdate = state.hasData && process.env.ARTIFACTY_BACKUP_ON_UPDATE === "true" && state.appVersion !== APP_VERSION;
|
|
342
|
+
if (schemaUpgrade || appUpdate) {
|
|
343
|
+
retentionCount();
|
|
344
|
+
assertOffline(store, true);
|
|
345
|
+
createSnapshot(store, { reason: schemaUpgrade ? "schema-upgrade" : "application-update", targetSchemaVersion: targetVersion, targetAppVersion: APP_VERSION });
|
|
346
|
+
}
|
|
347
|
+
privateDirectory(store.artifactsDir);
|
|
348
|
+
db = new DatabaseSync(store.dbPath);
|
|
349
|
+
initialize(db);
|
|
350
|
+
if (state.appVersion !== APP_VERSION) db.prepare("INSERT OR REPLACE INTO meta (key,value) VALUES ('application_version', ?)").run(APP_VERSION);
|
|
351
|
+
if (schemaUpgrade || appUpdate) pruneAutomatic(store);
|
|
352
|
+
return db;
|
|
353
|
+
} catch (cause) { db?.close(); throw cause; }
|
|
354
|
+
finally { release(); }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function restoreUpgradeBackup(input, id, { confirm } = {}) {
|
|
358
|
+
if (confirm !== "restore-store") throw error('Restore requires confirm: "restore-store"', "RESTORE_CONFIRMATION_REQUIRED");
|
|
359
|
+
const store = paths(input);
|
|
360
|
+
const release = lockStore(store);
|
|
361
|
+
let stage;
|
|
362
|
+
let preserveStage = false;
|
|
363
|
+
try {
|
|
364
|
+
assertNoInterruptedRestore(store);
|
|
365
|
+
assertOffline(store);
|
|
366
|
+
const snapshot = verifyUpgradeBackup(store, id);
|
|
367
|
+
let recovery;
|
|
368
|
+
try { recovery = createSnapshot(store, { reason: "recovery" }); }
|
|
369
|
+
catch (cause) {
|
|
370
|
+
throw error(`Cannot verify the current store for a recovery snapshot: ${cause.message}. Preserve the damaged store and copy the selected snapshot into a new empty home's upgrade-backups directory, then restore there.`, "RECOVERY_BACKUP_FAILED");
|
|
371
|
+
}
|
|
372
|
+
stage = safePath(store.home, `.restore-${randomUUID()}`);
|
|
373
|
+
privateDirectory(stage);
|
|
374
|
+
privateDirectory(path.join(stage, "artifacts"));
|
|
375
|
+
for (const file of snapshot.files) {
|
|
376
|
+
const target = safePath(stage, file.path);
|
|
377
|
+
privateDirectory(path.dirname(target));
|
|
378
|
+
copyFileSync(safePath(snapshot.path, file.path), target);
|
|
379
|
+
chmodSync(target, 0o600);
|
|
380
|
+
compareFile(stage, file);
|
|
381
|
+
}
|
|
382
|
+
const previous = path.join(stage, "previous");
|
|
383
|
+
privateDirectory(previous);
|
|
384
|
+
const names = ["artifacty.sqlite", "artifacty.sqlite-wal", "artifacty.sqlite-shm", "index.json", "artifacts"];
|
|
385
|
+
const moved = [];
|
|
386
|
+
const installed = [];
|
|
387
|
+
durableWrite(path.join(stage, "restore.json"), JSON.stringify({ snapshotId: id, recoveryBackupId: recovery.id, createdAt: new Date().toISOString() }));
|
|
388
|
+
syncTree(stage);
|
|
389
|
+
syncDirectory(store.home);
|
|
390
|
+
try {
|
|
391
|
+
for (const name of names) {
|
|
392
|
+
const current = safePath(store.home, name);
|
|
393
|
+
if (existsSync(current)) { renameSync(current, path.join(previous, name)); moved.push(name); }
|
|
394
|
+
}
|
|
395
|
+
for (const name of ["artifacty.sqlite", "index.json", "artifacts"]) {
|
|
396
|
+
const source = path.join(stage, name);
|
|
397
|
+
if (existsSync(source)) { renameSync(source, path.join(store.home, name)); installed.push(name); }
|
|
398
|
+
}
|
|
399
|
+
syncDirectory(previous);
|
|
400
|
+
syncDirectory(store.home);
|
|
401
|
+
} catch (cause) {
|
|
402
|
+
try {
|
|
403
|
+
for (const name of installed.reverse()) rmSync(path.join(store.home, name), { recursive: true, force: true });
|
|
404
|
+
for (const name of moved.reverse()) renameSync(path.join(previous, name), path.join(store.home, name));
|
|
405
|
+
syncDirectory(store.home);
|
|
406
|
+
} catch (rollbackError) {
|
|
407
|
+
preserveStage = true;
|
|
408
|
+
throw error(`Restore and rollback failed. Preserve ${stage}; recovery snapshot ${recovery.id}. Original error: ${cause.message}; rollback error: ${rollbackError.message}`, "RESTORE_INCOMPLETE");
|
|
409
|
+
}
|
|
410
|
+
throw cause;
|
|
411
|
+
}
|
|
412
|
+
return { restored: id, recoveryBackupId: recovery.id };
|
|
413
|
+
} finally {
|
|
414
|
+
if (stage && !preserveStage && existsSync(stage)) rmSync(stage, { recursive: true, force: true });
|
|
415
|
+
release();
|
|
416
|
+
}
|
|
417
|
+
}
|
package/src/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
deleteComment,
|
|
25
25
|
deleteSavedView,
|
|
26
26
|
deleteWebhook,
|
|
27
|
+
ensureStore,
|
|
27
28
|
getArtifact,
|
|
28
29
|
getAccessMode,
|
|
29
30
|
canReadArtifactEvent,
|
|
@@ -192,6 +193,9 @@ export async function startServer(options = {}) {
|
|
|
192
193
|
const mcpHttp = Boolean(options.mcpHttp || process.env.ARTIFACTY_MCP_HTTP === "true");
|
|
193
194
|
validateServerExposure({ host, config: security });
|
|
194
195
|
|
|
196
|
+
// Finish protected store upgrades before accepting requests or recording a live server.
|
|
197
|
+
await ensureStore(store);
|
|
198
|
+
|
|
195
199
|
const { server, actualPort } = await listenWithFallback({
|
|
196
200
|
host,
|
|
197
201
|
requestedPort,
|