artifacty 0.1.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/AGENTS.md +52 -0
- package/CLAUDE.md +64 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/docs/artifact-schema-v1.md +96 -0
- package/docs/assets/artifacty.png +0 -0
- package/docs/integrations.md +196 -0
- package/docs/release-checklist.md +30 -0
- package/package.json +55 -0
- package/scripts/smoke.sh +104 -0
- package/src/cli.js +348 -0
- package/src/client/editor.js +260 -0
- package/src/lib/backup.js +75 -0
- package/src/lib/check.js +124 -0
- package/src/lib/converters.js +586 -0
- package/src/lib/diff.js +69 -0
- package/src/lib/editor-assets.js +59 -0
- package/src/lib/i18n.js +175 -0
- package/src/lib/installer.js +187 -0
- package/src/lib/render.js +1181 -0
- package/src/lib/security.js +114 -0
- package/src/lib/server-state.js +53 -0
- package/src/lib/service.js +105 -0
- package/src/lib/storage.js +846 -0
- package/src/mcp-server.js +495 -0
- package/src/server.js +576 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { assertNoSecrets, securityConfig } from "./security.js";
|
|
8
|
+
|
|
9
|
+
export const STORE_VERSION = 3;
|
|
10
|
+
export const ARTIFACT_SCHEMA_VERSION = 1;
|
|
11
|
+
export const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
|
|
12
|
+
export const ARTIFACT_TYPES = [
|
|
13
|
+
"document",
|
|
14
|
+
"html-page",
|
|
15
|
+
"handoff",
|
|
16
|
+
"code-review",
|
|
17
|
+
"test-report",
|
|
18
|
+
"dashboard",
|
|
19
|
+
"design-option",
|
|
20
|
+
"diff-walkthrough",
|
|
21
|
+
"bundle",
|
|
22
|
+
"asset",
|
|
23
|
+
"unknown"
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const FORMAT_TO_EXTENSION = {
|
|
27
|
+
html: "html",
|
|
28
|
+
markdown: "md",
|
|
29
|
+
text: "txt",
|
|
30
|
+
json: "json"
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const FORMAT_TO_CONTENT_TYPE = {
|
|
34
|
+
html: "text/html; charset=utf-8",
|
|
35
|
+
markdown: "text/markdown; charset=utf-8",
|
|
36
|
+
text: "text/plain; charset=utf-8",
|
|
37
|
+
json: "application/json; charset=utf-8"
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function createStore(options = {}) {
|
|
41
|
+
const home =
|
|
42
|
+
options.home ||
|
|
43
|
+
process.env.ARTIFACTY_HOME ||
|
|
44
|
+
path.join(homedir(), ".artifacty");
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
home: path.resolve(home),
|
|
48
|
+
dbPath: path.resolve(home, "artifacty.sqlite"),
|
|
49
|
+
indexPath: path.resolve(home, "index.json"),
|
|
50
|
+
artifactsDir: path.resolve(home, "artifacts")
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function ensureStore(store = createStore()) {
|
|
55
|
+
const db = openDatabase(store);
|
|
56
|
+
db.close();
|
|
57
|
+
return store;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function loadIndex(store = createStore()) {
|
|
61
|
+
const db = openDatabase(store);
|
|
62
|
+
try {
|
|
63
|
+
return {
|
|
64
|
+
version: STORE_VERSION,
|
|
65
|
+
artifacts: loadArtifacts(db)
|
|
66
|
+
};
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function writeIndex(store, index) {
|
|
73
|
+
if (!index || !Array.isArray(index.artifacts)) {
|
|
74
|
+
throw new Error("Artifacty index must contain an artifacts array");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const db = openDatabase(store);
|
|
78
|
+
try {
|
|
79
|
+
transaction(db, () => {
|
|
80
|
+
db.prepare("DELETE FROM artifact_versions").run();
|
|
81
|
+
db.prepare("DELETE FROM artifacts").run();
|
|
82
|
+
for (const artifact of index.artifacts) {
|
|
83
|
+
insertArtifactRecord(db, artifact);
|
|
84
|
+
for (const version of artifact.versions || []) {
|
|
85
|
+
insertVersionRecord(db, artifact.id, version);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
} finally {
|
|
90
|
+
db.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function createArtifact(store = createStore(), input = {}) {
|
|
95
|
+
const secretScan = assertNoSecrets(input, securityConfig());
|
|
96
|
+
input = withSecretScan(input, secretScan);
|
|
97
|
+
const normalized = normalizeArtifactInput(input, { requireContent: true, requireTitle: true });
|
|
98
|
+
const db = openDatabase(store);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
let artifact;
|
|
102
|
+
transaction(db, () => {
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
const id = makeArtifactId(normalized.title);
|
|
105
|
+
const version = writeVersionFile(store, id, 1, normalized, now);
|
|
106
|
+
|
|
107
|
+
artifact = {
|
|
108
|
+
id,
|
|
109
|
+
title: normalized.title,
|
|
110
|
+
artifactType: normalized.artifactType,
|
|
111
|
+
schemaVersion: normalized.schemaVersion,
|
|
112
|
+
sourceAgent: normalized.sourceAgent,
|
|
113
|
+
tags: normalized.tags,
|
|
114
|
+
createdAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
archivedAt: null,
|
|
117
|
+
latestVersion: 1,
|
|
118
|
+
versions: [version]
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
insertArtifactRecord(db, artifact);
|
|
122
|
+
insertVersionRecord(db, id, version);
|
|
123
|
+
insertAuditRecord(db, {
|
|
124
|
+
action: input.auditAction || "create",
|
|
125
|
+
artifactId: id,
|
|
126
|
+
version: 1,
|
|
127
|
+
sourceAgent: normalized.sourceAgent,
|
|
128
|
+
audit: input.audit,
|
|
129
|
+
metadata: { title: normalized.title, artifactType: normalized.artifactType }
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return withLatestContent(store, artifact);
|
|
134
|
+
} finally {
|
|
135
|
+
db.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function updateArtifact(store = createStore(), id, input = {}) {
|
|
140
|
+
const secretScan = assertNoSecrets(input, securityConfig());
|
|
141
|
+
input = withSecretScan(input, secretScan);
|
|
142
|
+
const normalized = normalizeArtifactInput(input, { requireContent: true, requireTitle: false });
|
|
143
|
+
const db = openDatabase(store);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
let artifact;
|
|
147
|
+
transaction(db, () => {
|
|
148
|
+
artifact = findArtifactById(db, id);
|
|
149
|
+
const now = new Date().toISOString();
|
|
150
|
+
const nextVersion = artifact.latestVersion + 1;
|
|
151
|
+
const version = writeVersionFile(store, artifact.id, nextVersion, normalized, now);
|
|
152
|
+
|
|
153
|
+
artifact.title = normalized.title || artifact.title;
|
|
154
|
+
artifact.sourceAgent = normalized.sourceAgent || artifact.sourceAgent;
|
|
155
|
+
artifact.artifactType = normalized.artifactType || artifact.artifactType;
|
|
156
|
+
artifact.schemaVersion = normalized.schemaVersion || artifact.schemaVersion;
|
|
157
|
+
artifact.tags = normalized.tags.length > 0 ? normalized.tags : artifact.tags;
|
|
158
|
+
artifact.updatedAt = now;
|
|
159
|
+
artifact.latestVersion = nextVersion;
|
|
160
|
+
artifact.versions.push(version);
|
|
161
|
+
|
|
162
|
+
db.prepare(`
|
|
163
|
+
UPDATE artifacts
|
|
164
|
+
SET title = ?, source_agent = ?, artifact_type = ?, schema_version = ?, tags_json = ?, updated_at = ?, latest_version = ?
|
|
165
|
+
WHERE id = ?
|
|
166
|
+
`).run(
|
|
167
|
+
artifact.title,
|
|
168
|
+
artifact.sourceAgent,
|
|
169
|
+
artifact.artifactType,
|
|
170
|
+
artifact.schemaVersion,
|
|
171
|
+
JSON.stringify(artifact.tags),
|
|
172
|
+
artifact.updatedAt,
|
|
173
|
+
artifact.latestVersion,
|
|
174
|
+
artifact.id
|
|
175
|
+
);
|
|
176
|
+
insertVersionRecord(db, artifact.id, version);
|
|
177
|
+
insertAuditRecord(db, {
|
|
178
|
+
action: input.auditAction || "update",
|
|
179
|
+
artifactId: artifact.id,
|
|
180
|
+
version: nextVersion,
|
|
181
|
+
sourceAgent: normalized.sourceAgent,
|
|
182
|
+
audit: input.audit,
|
|
183
|
+
metadata: { title: artifact.title, artifactType: artifact.artifactType }
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return withLatestContent(store, artifact);
|
|
188
|
+
} finally {
|
|
189
|
+
db.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function archiveArtifact(store = createStore(), id, options = {}) {
|
|
194
|
+
const db = openDatabase(store);
|
|
195
|
+
try {
|
|
196
|
+
let artifact;
|
|
197
|
+
transaction(db, () => {
|
|
198
|
+
artifact = findArtifactById(db, id);
|
|
199
|
+
const archivedAt = options.archivedAt || new Date().toISOString();
|
|
200
|
+
db.prepare("UPDATE artifacts SET archived_at = ?, updated_at = ? WHERE id = ?").run(
|
|
201
|
+
archivedAt,
|
|
202
|
+
archivedAt,
|
|
203
|
+
id
|
|
204
|
+
);
|
|
205
|
+
artifact.archivedAt = archivedAt;
|
|
206
|
+
artifact.updatedAt = archivedAt;
|
|
207
|
+
insertAuditRecord(db, {
|
|
208
|
+
action: "archive",
|
|
209
|
+
artifactId: id,
|
|
210
|
+
version: artifact.latestVersion,
|
|
211
|
+
sourceAgent: artifact.sourceAgent,
|
|
212
|
+
audit: options.audit,
|
|
213
|
+
metadata: {}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
return withLatestContent(store, artifact);
|
|
217
|
+
} finally {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function restoreArtifact(store = createStore(), id, options = {}) {
|
|
223
|
+
const db = openDatabase(store);
|
|
224
|
+
try {
|
|
225
|
+
let artifact;
|
|
226
|
+
transaction(db, () => {
|
|
227
|
+
artifact = findArtifactById(db, id);
|
|
228
|
+
const now = new Date().toISOString();
|
|
229
|
+
db.prepare("UPDATE artifacts SET archived_at = NULL, updated_at = ? WHERE id = ?").run(now, id);
|
|
230
|
+
artifact.archivedAt = null;
|
|
231
|
+
artifact.updatedAt = now;
|
|
232
|
+
insertAuditRecord(db, {
|
|
233
|
+
action: "restore",
|
|
234
|
+
artifactId: id,
|
|
235
|
+
version: artifact.latestVersion,
|
|
236
|
+
sourceAgent: artifact.sourceAgent,
|
|
237
|
+
audit: options.audit,
|
|
238
|
+
metadata: {}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
return withLatestContent(store, artifact);
|
|
242
|
+
} finally {
|
|
243
|
+
db.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function listArtifacts(store = createStore(), filters = {}) {
|
|
248
|
+
const index = await loadIndex(store);
|
|
249
|
+
const limit = clampInteger(filters.limit, 1, 200, 50);
|
|
250
|
+
const query = normalizeOptionalString(filters.query).toLowerCase();
|
|
251
|
+
const tag = normalizeOptionalString(filters.tag).toLowerCase();
|
|
252
|
+
const sourceAgent = normalizeOptionalString(filters.sourceAgent).toLowerCase();
|
|
253
|
+
|
|
254
|
+
return index.artifacts
|
|
255
|
+
.filter((artifact) => {
|
|
256
|
+
if (!filters.includeArchived && artifact.archivedAt) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
if (query && !artifactMatchesQuery(artifact, query)) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
if (tag && !artifact.tags.some((item) => item.toLowerCase() === tag)) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
if (sourceAgent && artifact.sourceAgent.toLowerCase() !== sourceAgent) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
})
|
|
270
|
+
.slice(0, limit)
|
|
271
|
+
.map(toArtifactSummary);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function getArtifact(store = createStore(), id, options = {}) {
|
|
275
|
+
const db = openDatabase(store);
|
|
276
|
+
try {
|
|
277
|
+
const artifact = findArtifactById(db, id);
|
|
278
|
+
const versionNumber = options.version ? Number(options.version) : artifact.latestVersion;
|
|
279
|
+
const version = artifact.versions.find((item) => item.version === versionNumber);
|
|
280
|
+
|
|
281
|
+
if (!version) {
|
|
282
|
+
throw Object.assign(new Error(`Artifact version not found: ${id}@${versionNumber}`), {
|
|
283
|
+
code: "ARTIFACT_VERSION_NOT_FOUND",
|
|
284
|
+
statusCode: 404
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const content = await readFile(path.join(store.home, version.path), "utf8");
|
|
289
|
+
insertAuditRecord(db, {
|
|
290
|
+
action: "read",
|
|
291
|
+
artifactId: id,
|
|
292
|
+
version: version.version,
|
|
293
|
+
sourceAgent: artifact.sourceAgent,
|
|
294
|
+
audit: options.audit,
|
|
295
|
+
metadata: { format: version.format }
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
...artifact,
|
|
299
|
+
version,
|
|
300
|
+
content
|
|
301
|
+
};
|
|
302
|
+
} finally {
|
|
303
|
+
db.close();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function listAuditEvents(store = createStore(), filters = {}) {
|
|
308
|
+
const db = openDatabase(store);
|
|
309
|
+
try {
|
|
310
|
+
const limit = clampInteger(filters.limit, 1, 500, 100);
|
|
311
|
+
if (filters.artifactId) {
|
|
312
|
+
return db.prepare(`
|
|
313
|
+
SELECT id, created_at, action, artifact_id, version, source_agent, actor, surface, metadata_json
|
|
314
|
+
FROM audit_log
|
|
315
|
+
WHERE artifact_id = ?
|
|
316
|
+
ORDER BY created_at DESC
|
|
317
|
+
LIMIT ?
|
|
318
|
+
`).all(filters.artifactId, limit).map(auditFromRow);
|
|
319
|
+
}
|
|
320
|
+
return db.prepare(`
|
|
321
|
+
SELECT id, created_at, action, artifact_id, version, source_agent, actor, surface, metadata_json
|
|
322
|
+
FROM audit_log
|
|
323
|
+
ORDER BY created_at DESC
|
|
324
|
+
LIMIT ?
|
|
325
|
+
`).all(limit).map(auditFromRow);
|
|
326
|
+
} finally {
|
|
327
|
+
db.close();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function readArtifactVersion(store, artifact, versionNumber) {
|
|
332
|
+
const version = artifact.versions.find((item) => item.version === versionNumber);
|
|
333
|
+
if (!version) {
|
|
334
|
+
throw Object.assign(new Error(`Artifact version not found: ${artifact.id}@${versionNumber}`), {
|
|
335
|
+
code: "ARTIFACT_VERSION_NOT_FOUND",
|
|
336
|
+
statusCode: 404
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
version,
|
|
342
|
+
content: await readFile(path.join(store.home, version.path), "utf8")
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function toArtifactSummary(artifact) {
|
|
347
|
+
const latest = artifact.versions.find((version) => version.version === artifact.latestVersion);
|
|
348
|
+
return {
|
|
349
|
+
id: artifact.id,
|
|
350
|
+
title: artifact.title,
|
|
351
|
+
artifactType: artifact.artifactType,
|
|
352
|
+
schemaVersion: artifact.schemaVersion,
|
|
353
|
+
sourceAgent: artifact.sourceAgent,
|
|
354
|
+
tags: artifact.tags,
|
|
355
|
+
createdAt: artifact.createdAt,
|
|
356
|
+
updatedAt: artifact.updatedAt,
|
|
357
|
+
archivedAt: artifact.archivedAt,
|
|
358
|
+
latestVersion: artifact.latestVersion,
|
|
359
|
+
versionCount: artifact.versions.length,
|
|
360
|
+
format: latest?.format,
|
|
361
|
+
contentType: latest?.contentType,
|
|
362
|
+
sizeBytes: latest?.sizeBytes
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function normalizeFormat(value = "text") {
|
|
367
|
+
const normalized = String(value).trim().toLowerCase();
|
|
368
|
+
if (normalized === "md") {
|
|
369
|
+
return "markdown";
|
|
370
|
+
}
|
|
371
|
+
if (normalized === "html" || normalized === "markdown" || normalized === "text" || normalized === "json") {
|
|
372
|
+
return normalized;
|
|
373
|
+
}
|
|
374
|
+
throw Object.assign(new Error(`Unsupported artifact format: ${value}`), {
|
|
375
|
+
code: "INVALID_FORMAT",
|
|
376
|
+
statusCode: 400
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function contentTypeForFormat(format) {
|
|
381
|
+
return FORMAT_TO_CONTENT_TYPE[normalizeFormat(format)];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function extensionForFormat(format) {
|
|
385
|
+
return FORMAT_TO_EXTENSION[normalizeFormat(format)];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function normalizeArtifactType(value = "document") {
|
|
389
|
+
const normalized = normalizeOptionalString(value).toLowerCase();
|
|
390
|
+
if (!normalized) {
|
|
391
|
+
return "document";
|
|
392
|
+
}
|
|
393
|
+
if (ARTIFACT_TYPES.includes(normalized)) {
|
|
394
|
+
return normalized;
|
|
395
|
+
}
|
|
396
|
+
throw Object.assign(new Error(`Unsupported artifact type: ${value}`), {
|
|
397
|
+
code: "INVALID_ARTIFACT_TYPE",
|
|
398
|
+
statusCode: 400
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function openDatabase(store) {
|
|
403
|
+
mkdirSync(store.home, { recursive: true });
|
|
404
|
+
mkdirSync(store.artifactsDir, { recursive: true });
|
|
405
|
+
|
|
406
|
+
const db = new DatabaseSync(store.dbPath);
|
|
407
|
+
db.exec(`
|
|
408
|
+
PRAGMA journal_mode = WAL;
|
|
409
|
+
PRAGMA foreign_keys = ON;
|
|
410
|
+
PRAGMA busy_timeout = 5000;
|
|
411
|
+
`);
|
|
412
|
+
initializeSchema(db);
|
|
413
|
+
migrateJsonIndex(db, store);
|
|
414
|
+
return db;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function initializeSchema(db) {
|
|
418
|
+
db.exec(`
|
|
419
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
420
|
+
key TEXT PRIMARY KEY,
|
|
421
|
+
value TEXT NOT NULL
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
425
|
+
id TEXT PRIMARY KEY,
|
|
426
|
+
title TEXT NOT NULL,
|
|
427
|
+
artifact_type TEXT NOT NULL DEFAULT 'document',
|
|
428
|
+
schema_version INTEGER NOT NULL DEFAULT 1,
|
|
429
|
+
source_agent TEXT NOT NULL,
|
|
430
|
+
tags_json TEXT NOT NULL,
|
|
431
|
+
created_at TEXT NOT NULL,
|
|
432
|
+
updated_at TEXT NOT NULL,
|
|
433
|
+
latest_version INTEGER NOT NULL,
|
|
434
|
+
archived_at TEXT
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
CREATE TABLE IF NOT EXISTS artifact_versions (
|
|
438
|
+
artifact_id TEXT NOT NULL,
|
|
439
|
+
version INTEGER NOT NULL,
|
|
440
|
+
created_at TEXT NOT NULL,
|
|
441
|
+
format TEXT NOT NULL,
|
|
442
|
+
content_type TEXT NOT NULL,
|
|
443
|
+
path TEXT NOT NULL,
|
|
444
|
+
size_bytes INTEGER NOT NULL,
|
|
445
|
+
sha256 TEXT NOT NULL,
|
|
446
|
+
metadata_json TEXT NOT NULL,
|
|
447
|
+
PRIMARY KEY (artifact_id, version),
|
|
448
|
+
FOREIGN KEY (artifact_id) REFERENCES artifacts(id) ON DELETE CASCADE
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
452
|
+
id TEXT PRIMARY KEY,
|
|
453
|
+
created_at TEXT NOT NULL,
|
|
454
|
+
action TEXT NOT NULL,
|
|
455
|
+
artifact_id TEXT NOT NULL,
|
|
456
|
+
version INTEGER,
|
|
457
|
+
source_agent TEXT,
|
|
458
|
+
actor TEXT,
|
|
459
|
+
surface TEXT,
|
|
460
|
+
metadata_json TEXT NOT NULL
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_updated_at ON artifacts(updated_at DESC);
|
|
464
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_source_agent ON artifacts(source_agent);
|
|
465
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
|
466
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_artifact_id ON audit_log(artifact_id);
|
|
467
|
+
`);
|
|
468
|
+
ensureColumn(db, "artifacts", "artifact_type", "TEXT NOT NULL DEFAULT 'document'");
|
|
469
|
+
ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
|
|
470
|
+
ensureColumn(db, "artifacts", "archived_at", "TEXT");
|
|
471
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('store_version', ?)").run(String(STORE_VERSION));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function migrateJsonIndex(db, store) {
|
|
475
|
+
if (!existsSync(store.indexPath)) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const count = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get().count;
|
|
480
|
+
const migrated = db.prepare("SELECT value FROM meta WHERE key = 'json_index_migrated'").get();
|
|
481
|
+
if (count > 0 || migrated?.value === "true") {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let parsed;
|
|
486
|
+
try {
|
|
487
|
+
parsed = JSON.parse(readFileSync(store.indexPath, "utf8"));
|
|
488
|
+
} catch (error) {
|
|
489
|
+
throw new Error(`Failed to read legacy Artifacty index: ${error.message}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (!Array.isArray(parsed.artifacts)) {
|
|
493
|
+
throw new Error(`Unsupported legacy Artifacty index at ${store.indexPath}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
transaction(db, () => {
|
|
497
|
+
for (const artifact of parsed.artifacts) {
|
|
498
|
+
insertArtifactRecord(db, artifact);
|
|
499
|
+
for (const version of artifact.versions || []) {
|
|
500
|
+
insertVersionRecord(db, artifact.id, version);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('json_index_migrated', 'true')").run();
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function loadArtifacts(db) {
|
|
508
|
+
const rows = db.prepare(`
|
|
509
|
+
SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
|
|
510
|
+
FROM artifacts
|
|
511
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
512
|
+
`).all();
|
|
513
|
+
|
|
514
|
+
return rows.map((row) => ({
|
|
515
|
+
id: row.id,
|
|
516
|
+
title: row.title,
|
|
517
|
+
artifactType: row.artifact_type,
|
|
518
|
+
schemaVersion: row.schema_version,
|
|
519
|
+
sourceAgent: row.source_agent,
|
|
520
|
+
tags: parseJson(row.tags_json, []),
|
|
521
|
+
createdAt: row.created_at,
|
|
522
|
+
updatedAt: row.updated_at,
|
|
523
|
+
archivedAt: row.archived_at,
|
|
524
|
+
latestVersion: row.latest_version,
|
|
525
|
+
versions: loadVersions(db, row.id)
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function loadVersions(db, artifactId) {
|
|
530
|
+
return db.prepare(`
|
|
531
|
+
SELECT version, created_at, format, content_type, path, size_bytes, sha256, metadata_json
|
|
532
|
+
FROM artifact_versions
|
|
533
|
+
WHERE artifact_id = ?
|
|
534
|
+
ORDER BY version ASC
|
|
535
|
+
`).all(artifactId).map(versionFromRow);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function findArtifactById(db, id) {
|
|
539
|
+
const row = db.prepare(`
|
|
540
|
+
SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
|
|
541
|
+
FROM artifacts
|
|
542
|
+
WHERE id = ?
|
|
543
|
+
`).get(id);
|
|
544
|
+
|
|
545
|
+
if (!row) {
|
|
546
|
+
throw Object.assign(new Error(`Artifact not found: ${id}`), {
|
|
547
|
+
code: "ARTIFACT_NOT_FOUND",
|
|
548
|
+
statusCode: 404
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
id: row.id,
|
|
554
|
+
title: row.title,
|
|
555
|
+
artifactType: row.artifact_type,
|
|
556
|
+
schemaVersion: row.schema_version,
|
|
557
|
+
sourceAgent: row.source_agent,
|
|
558
|
+
tags: parseJson(row.tags_json, []),
|
|
559
|
+
createdAt: row.created_at,
|
|
560
|
+
updatedAt: row.updated_at,
|
|
561
|
+
archivedAt: row.archived_at,
|
|
562
|
+
latestVersion: row.latest_version,
|
|
563
|
+
versions: loadVersions(db, row.id)
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function insertArtifactRecord(db, artifact) {
|
|
568
|
+
db.prepare(`
|
|
569
|
+
INSERT INTO artifacts (
|
|
570
|
+
id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
|
|
571
|
+
)
|
|
572
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
573
|
+
`).run(
|
|
574
|
+
artifact.id,
|
|
575
|
+
artifact.title,
|
|
576
|
+
normalizeArtifactType(artifact.artifactType || artifact.artifact_type || "document"),
|
|
577
|
+
normalizeSchemaVersion(artifact.schemaVersion || artifact.schema_version),
|
|
578
|
+
artifact.sourceAgent || artifact.source_agent || "unknown",
|
|
579
|
+
JSON.stringify(artifact.tags || []),
|
|
580
|
+
artifact.createdAt || artifact.created_at,
|
|
581
|
+
artifact.updatedAt || artifact.updated_at,
|
|
582
|
+
artifact.latestVersion || artifact.latest_version || 1,
|
|
583
|
+
artifact.archivedAt || artifact.archived_at || null
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function insertVersionRecord(db, artifactId, version) {
|
|
588
|
+
db.prepare(`
|
|
589
|
+
INSERT INTO artifact_versions (
|
|
590
|
+
artifact_id, version, created_at, format, content_type, path, size_bytes, sha256, metadata_json
|
|
591
|
+
)
|
|
592
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
593
|
+
`).run(
|
|
594
|
+
artifactId,
|
|
595
|
+
version.version,
|
|
596
|
+
version.createdAt || version.created_at,
|
|
597
|
+
normalizeFormat(version.format),
|
|
598
|
+
version.contentType || version.content_type || contentTypeForFormat(version.format),
|
|
599
|
+
version.path,
|
|
600
|
+
version.sizeBytes || version.size_bytes || 0,
|
|
601
|
+
version.sha256,
|
|
602
|
+
JSON.stringify(version.metadata || {})
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function insertAuditRecord(db, { action, artifactId, version, sourceAgent, audit = {}, metadata = {} }) {
|
|
607
|
+
db.prepare(`
|
|
608
|
+
INSERT INTO audit_log (
|
|
609
|
+
id, created_at, action, artifact_id, version, source_agent, actor, surface, metadata_json
|
|
610
|
+
)
|
|
611
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
612
|
+
`).run(
|
|
613
|
+
randomUUID(),
|
|
614
|
+
new Date().toISOString(),
|
|
615
|
+
action,
|
|
616
|
+
artifactId,
|
|
617
|
+
version || null,
|
|
618
|
+
sourceAgent || null,
|
|
619
|
+
audit.actor || null,
|
|
620
|
+
audit.surface || null,
|
|
621
|
+
JSON.stringify(metadata || {})
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function auditFromRow(row) {
|
|
626
|
+
return {
|
|
627
|
+
id: row.id,
|
|
628
|
+
createdAt: row.created_at,
|
|
629
|
+
action: row.action,
|
|
630
|
+
artifactId: row.artifact_id,
|
|
631
|
+
version: row.version,
|
|
632
|
+
sourceAgent: row.source_agent,
|
|
633
|
+
actor: row.actor,
|
|
634
|
+
surface: row.surface,
|
|
635
|
+
metadata: parseJson(row.metadata_json, {})
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function versionFromRow(row) {
|
|
640
|
+
return {
|
|
641
|
+
version: row.version,
|
|
642
|
+
createdAt: row.created_at,
|
|
643
|
+
format: row.format,
|
|
644
|
+
contentType: row.content_type,
|
|
645
|
+
path: row.path,
|
|
646
|
+
sizeBytes: row.size_bytes,
|
|
647
|
+
sha256: row.sha256,
|
|
648
|
+
metadata: parseJson(row.metadata_json, {})
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function transaction(db, fn) {
|
|
653
|
+
db.exec("BEGIN IMMEDIATE");
|
|
654
|
+
try {
|
|
655
|
+
const result = fn();
|
|
656
|
+
db.exec("COMMIT");
|
|
657
|
+
return result;
|
|
658
|
+
} catch (error) {
|
|
659
|
+
db.exec("ROLLBACK");
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function writeVersionFile(store, id, versionNumber, input, createdAt) {
|
|
665
|
+
const contentBuffer = Buffer.from(input.content, "utf8");
|
|
666
|
+
if (contentBuffer.byteLength > MAX_ARTIFACT_BYTES) {
|
|
667
|
+
throw Object.assign(new Error(`Artifact exceeds ${MAX_ARTIFACT_BYTES} bytes`), {
|
|
668
|
+
code: "ARTIFACT_TOO_LARGE",
|
|
669
|
+
statusCode: 413
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const artifactDir = path.join(store.artifactsDir, id);
|
|
674
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
675
|
+
|
|
676
|
+
const format = normalizeFormat(input.format);
|
|
677
|
+
const relativePath = path.join("artifacts", id, `v${versionNumber}.${extensionForFormat(format)}`);
|
|
678
|
+
const absolutePath = path.join(store.home, relativePath);
|
|
679
|
+
const tempPath = `${absolutePath}.${process.pid}.${Date.now()}.tmp`;
|
|
680
|
+
writeFileSync(tempPath, input.content, "utf8");
|
|
681
|
+
renameSync(tempPath, absolutePath);
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
version: versionNumber,
|
|
685
|
+
createdAt,
|
|
686
|
+
format,
|
|
687
|
+
contentType: input.contentType || contentTypeForFormat(format),
|
|
688
|
+
path: relativePath,
|
|
689
|
+
sizeBytes: contentBuffer.byteLength,
|
|
690
|
+
sha256: createHash("sha256").update(contentBuffer).digest("hex"),
|
|
691
|
+
metadata: input.metadata
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function withLatestContent(store, artifact) {
|
|
696
|
+
const latest = await readArtifactVersion(store, artifact, artifact.latestVersion);
|
|
697
|
+
return {
|
|
698
|
+
...artifact,
|
|
699
|
+
version: latest.version,
|
|
700
|
+
content: latest.content
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function normalizeArtifactInput(input, options) {
|
|
705
|
+
const title = normalizeOptionalString(input.title);
|
|
706
|
+
const content = typeof input.content === "string" ? input.content : undefined;
|
|
707
|
+
|
|
708
|
+
if (!title && options.requireTitle) {
|
|
709
|
+
throw Object.assign(new Error("Artifact title is required"), {
|
|
710
|
+
code: "TITLE_REQUIRED",
|
|
711
|
+
statusCode: 400
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (options.requireContent && typeof content !== "string") {
|
|
716
|
+
throw Object.assign(new Error("Artifact content must be a string"), {
|
|
717
|
+
code: "CONTENT_REQUIRED",
|
|
718
|
+
statusCode: 400
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
title,
|
|
724
|
+
content,
|
|
725
|
+
format: normalizeFormat(input.format || inferFormat(input.contentType)),
|
|
726
|
+
contentType: normalizeOptionalString(input.contentType),
|
|
727
|
+
artifactType: normalizeArtifactType(input.artifactType || input.artifact_type || inferArtifactType(input)),
|
|
728
|
+
schemaVersion: normalizeSchemaVersion(input.schemaVersion || input.schema_version),
|
|
729
|
+
sourceAgent: normalizeOptionalString(input.sourceAgent || input.source_agent || input.agent) || "unknown",
|
|
730
|
+
tags: normalizeTags(input.tags),
|
|
731
|
+
metadata: normalizeMetadata(input.metadata)
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function withSecretScan(input, secretScan) {
|
|
736
|
+
return {
|
|
737
|
+
...input,
|
|
738
|
+
metadata: {
|
|
739
|
+
...normalizeMetadata(input.metadata),
|
|
740
|
+
secretScan: {
|
|
741
|
+
...secretScan,
|
|
742
|
+
scannedAt: new Date().toISOString()
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function normalizeSchemaVersion(value) {
|
|
749
|
+
const parsed = Number(value || ARTIFACT_SCHEMA_VERSION);
|
|
750
|
+
if (parsed !== ARTIFACT_SCHEMA_VERSION) {
|
|
751
|
+
throw Object.assign(new Error(`Unsupported artifact schema version: ${value}`), {
|
|
752
|
+
code: "INVALID_SCHEMA_VERSION",
|
|
753
|
+
statusCode: 400
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
return ARTIFACT_SCHEMA_VERSION;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function inferArtifactType(input) {
|
|
760
|
+
const format = normalizeOptionalString(input.format || inferFormat(input.contentType));
|
|
761
|
+
if (format === "html") {
|
|
762
|
+
return "html-page";
|
|
763
|
+
}
|
|
764
|
+
return "document";
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function normalizeTags(tags) {
|
|
768
|
+
if (!Array.isArray(tags)) {
|
|
769
|
+
return [];
|
|
770
|
+
}
|
|
771
|
+
return [...new Set(tags.map(normalizeOptionalString).filter(Boolean))].slice(0, 20);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function normalizeMetadata(metadata) {
|
|
775
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
776
|
+
return {};
|
|
777
|
+
}
|
|
778
|
+
return metadata;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function normalizeOptionalString(value) {
|
|
782
|
+
if (value === undefined || value === null) {
|
|
783
|
+
return "";
|
|
784
|
+
}
|
|
785
|
+
return String(value).trim();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function inferFormat(contentType) {
|
|
789
|
+
const value = normalizeOptionalString(contentType).toLowerCase();
|
|
790
|
+
if (value.includes("html")) {
|
|
791
|
+
return "html";
|
|
792
|
+
}
|
|
793
|
+
if (value.includes("markdown")) {
|
|
794
|
+
return "markdown";
|
|
795
|
+
}
|
|
796
|
+
if (value.includes("json")) {
|
|
797
|
+
return "json";
|
|
798
|
+
}
|
|
799
|
+
return "text";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function makeArtifactId(title) {
|
|
803
|
+
const slug = title
|
|
804
|
+
.toLowerCase()
|
|
805
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
806
|
+
.replace(/^-+|-+$/g, "")
|
|
807
|
+
.slice(0, 48) || "artifact";
|
|
808
|
+
|
|
809
|
+
return `${slug}-${randomUUID().slice(0, 8)}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function artifactMatchesQuery(artifact, query) {
|
|
813
|
+
const haystack = [
|
|
814
|
+
artifact.id,
|
|
815
|
+
artifact.title,
|
|
816
|
+
artifact.sourceAgent,
|
|
817
|
+
...artifact.tags
|
|
818
|
+
]
|
|
819
|
+
.join(" ")
|
|
820
|
+
.toLowerCase();
|
|
821
|
+
|
|
822
|
+
return haystack.includes(query);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function clampInteger(value, min, max, fallback) {
|
|
826
|
+
const parsed = Number.parseInt(value, 10);
|
|
827
|
+
if (Number.isNaN(parsed)) {
|
|
828
|
+
return fallback;
|
|
829
|
+
}
|
|
830
|
+
return Math.min(max, Math.max(min, parsed));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function ensureColumn(db, table, column, definition) {
|
|
834
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
835
|
+
if (!rows.some((row) => row.name === column)) {
|
|
836
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function parseJson(value, fallback) {
|
|
841
|
+
try {
|
|
842
|
+
return JSON.parse(value);
|
|
843
|
+
} catch {
|
|
844
|
+
return fallback;
|
|
845
|
+
}
|
|
846
|
+
}
|