artifacty 0.10.8 → 0.11.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/CLAUDE.md +1 -1
- package/README.md +260 -5
- package/docs/artifact-schema-v1.md +187 -1
- package/docs/central-team-deployment-design.md +3 -1
- package/docs/integrations.md +9 -1
- package/docs/mcp-public-api.md +103 -9
- package/docs/release-checklist.md +1 -1
- package/docs/roadmap-design.md +977 -0
- package/docs/sarif-csv-artifact-plan.md +42 -7
- package/docs/threat-model.md +217 -2
- package/package.json +2 -1
- package/src/cli.js +550 -43
- package/src/client/viewer.js +377 -0
- package/src/lib/backup.js +326 -15
- package/src/lib/converters.js +99 -5
- package/src/lib/csv.js +51 -0
- package/src/lib/diff.js +656 -0
- package/src/lib/doctor.js +88 -2
- package/src/lib/embeddings.js +407 -0
- package/src/lib/events.js +183 -0
- package/src/lib/i18n.js +290 -2
- package/src/lib/listing.js +46 -0
- package/src/lib/openapi.js +614 -0
- package/src/lib/render.js +1604 -160
- package/src/lib/retention-form.js +31 -0
- package/src/lib/retention.js +499 -0
- package/src/lib/sarif-csv-export.js +290 -0
- package/src/lib/schemas.js +461 -0
- package/src/lib/security.js +226 -1
- package/src/lib/sse.js +52 -0
- package/src/lib/storage.js +2935 -305
- package/src/lib/webhooks.js +356 -0
- package/src/mcp-server.js +756 -25
- package/src/server.js +1529 -98
package/src/lib/backup.js
CHANGED
|
@@ -1,11 +1,64 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
countUsers,
|
|
6
|
+
createStore,
|
|
7
|
+
exportFullStoreTables,
|
|
8
|
+
importFullStoreTables,
|
|
9
|
+
loadIndex,
|
|
10
|
+
openDatabase,
|
|
11
|
+
STORE_VERSION,
|
|
12
|
+
writeIndex
|
|
13
|
+
} from "./storage.js";
|
|
4
14
|
|
|
5
|
-
export
|
|
15
|
+
export const MAX_BACKUP_BYTES = 128 * 1024 * 1024;
|
|
16
|
+
const BUNDLE_VERSION = 2;
|
|
17
|
+
|
|
18
|
+
export async function exportStore(store = createStore(), outputPath, { scope = "artifacts" } = {}) {
|
|
6
19
|
if (!outputPath) {
|
|
7
20
|
throw new Error("export requires --file <path>");
|
|
8
21
|
}
|
|
22
|
+
const normalizedScope = scope === "full" ? "full" : "artifacts";
|
|
23
|
+
const bundle = await buildStoreBackup(store, { scope: normalizedScope });
|
|
24
|
+
const resolvedOutputPath = path.resolve(outputPath);
|
|
25
|
+
await mkdir(path.dirname(resolvedOutputPath), { recursive: true });
|
|
26
|
+
const content = `${JSON.stringify(bundle, null, 2)}\n`;
|
|
27
|
+
if (normalizedScope === "full") {
|
|
28
|
+
// The full scope carries password hashes and API token hashes. Write to
|
|
29
|
+
// a private temp file (mode 0o600 from creation, "wx" so it fails
|
|
30
|
+
// instead of following a symlink or clobbering something already
|
|
31
|
+
// there) and rename it into place, so the destination is never briefly
|
|
32
|
+
// readable with the process's default (typically 0o644) mode the way a
|
|
33
|
+
// plain writeFile-then-chmod leaves it for the duration of the write.
|
|
34
|
+
const tempPath = `${resolvedOutputPath}.${randomUUID()}.tmp`;
|
|
35
|
+
try {
|
|
36
|
+
await writeFile(tempPath, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
37
|
+
await rename(tempPath, resolvedOutputPath);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// Don't leave a stray 0o600 temp file containing password/API-token
|
|
40
|
+
// hashes behind if the rename (or the write itself) fails.
|
|
41
|
+
await rm(tempPath, { force: true });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
await writeFile(resolvedOutputPath, content, "utf8");
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
path: resolvedOutputPath,
|
|
49
|
+
artifactCount: bundle.artifacts.length,
|
|
50
|
+
scope: normalizedScope,
|
|
51
|
+
exportedAt: bundle.exportedAt
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function exportStoreToString(store = createStore(), { scope = "artifacts" } = {}) {
|
|
56
|
+
const bundle = await buildStoreBackup(store, { scope: scope === "full" ? "full" : "artifacts" });
|
|
57
|
+
return `${JSON.stringify(bundle, null, 2)}\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function buildStoreBackup(store = createStore(), { scope = "artifacts" } = {}) {
|
|
61
|
+
const normalizedScope = scope === "full" ? "full" : "artifacts";
|
|
9
62
|
const index = await loadIndex(store);
|
|
10
63
|
const artifacts = [];
|
|
11
64
|
for (const artifact of index.artifacts) {
|
|
@@ -13,6 +66,7 @@ export async function exportStore(store = createStore(), outputPath) {
|
|
|
13
66
|
for (const version of artifact.versions) {
|
|
14
67
|
versions.push({
|
|
15
68
|
...version,
|
|
69
|
+
path: normalizeBackupRelativePath(version.path),
|
|
16
70
|
content: await readFile(path.join(store.home, version.path), "utf8")
|
|
17
71
|
});
|
|
18
72
|
}
|
|
@@ -20,26 +74,121 @@ export async function exportStore(store = createStore(), outputPath) {
|
|
|
20
74
|
}
|
|
21
75
|
|
|
22
76
|
const bundle = {
|
|
77
|
+
bundleVersion: BUNDLE_VERSION,
|
|
78
|
+
scope: normalizedScope,
|
|
79
|
+
storeVersion: STORE_VERSION,
|
|
23
80
|
schemaVersion: 1,
|
|
24
81
|
exportedAt: new Date().toISOString(),
|
|
25
82
|
artifacts
|
|
26
83
|
};
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
};
|
|
84
|
+
|
|
85
|
+
if (normalizedScope === "full") {
|
|
86
|
+
bundle.full = await exportFullStoreTables(store);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return bundle;
|
|
34
90
|
}
|
|
35
91
|
|
|
36
|
-
export async function importStore(store = createStore(), inputPath) {
|
|
92
|
+
export async function importStore(store = createStore(), inputPath, options = {}) {
|
|
37
93
|
if (!inputPath) {
|
|
38
94
|
throw new Error("import-store requires --file <path>");
|
|
39
95
|
}
|
|
40
96
|
const bundle = JSON.parse(await readFile(inputPath, "utf8"));
|
|
97
|
+
const result = await importStoreBundle(store, bundle, options);
|
|
98
|
+
return {
|
|
99
|
+
path: path.resolve(inputPath),
|
|
100
|
+
...result
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function importStoreFromString(store = createStore(), content, options = {}) {
|
|
105
|
+
if (!String(content || "").trim()) {
|
|
106
|
+
throw new Error("Artifacty backup JSON is required");
|
|
107
|
+
}
|
|
108
|
+
let bundle;
|
|
109
|
+
try {
|
|
110
|
+
bundle = JSON.parse(content);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw Object.assign(new Error(`Invalid Artifacty backup JSON: ${error.message}`), {
|
|
113
|
+
statusCode: 400,
|
|
114
|
+
code: "INVALID_BACKUP_JSON"
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return importStoreBundle(store, bundle, options);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function importStoreBundle(store = createStore(), bundle, { confirm, forceUsers = false } = {}) {
|
|
121
|
+
if (!bundle || typeof bundle !== "object") {
|
|
122
|
+
throw Object.assign(new Error("Invalid Artifacty backup: JSON object expected"), {
|
|
123
|
+
statusCode: 400,
|
|
124
|
+
code: "INVALID_BACKUP"
|
|
125
|
+
});
|
|
126
|
+
}
|
|
41
127
|
if (!Array.isArray(bundle.artifacts)) {
|
|
42
|
-
throw new Error("Invalid Artifacty backup: artifacts array missing")
|
|
128
|
+
throw Object.assign(new Error("Invalid Artifacty backup: artifacts array missing"), {
|
|
129
|
+
statusCode: 400,
|
|
130
|
+
code: "INVALID_BACKUP"
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Bundles written before bundleVersion 2 (roadmap section 13) have no
|
|
135
|
+
// `scope` field; they were always artifacts-only.
|
|
136
|
+
const scope = bundle.scope === "full" ? "full" : "artifacts";
|
|
137
|
+
const warnings = [];
|
|
138
|
+
|
|
139
|
+
if (scope === "full") {
|
|
140
|
+
if (confirm !== "replace-all") {
|
|
141
|
+
throw Object.assign(new Error('Full-scope restore requires confirm: "replace-all"'), {
|
|
142
|
+
statusCode: 400,
|
|
143
|
+
code: "confirm_required"
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!forceUsers) {
|
|
147
|
+
const existingUsers = await countUsers(store);
|
|
148
|
+
if (existingUsers > 0) {
|
|
149
|
+
throw Object.assign(new Error("Target store already has users; pass forceUsers to overwrite them"), {
|
|
150
|
+
statusCode: 409,
|
|
151
|
+
code: "users_exist"
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Validate bundle.full's shape *before* writeIndex below replaces the
|
|
156
|
+
// artifacts table and deletes version files: importFullStoreTables runs
|
|
157
|
+
// after that point, and a malformed bundle.full throwing partway
|
|
158
|
+
// through it would otherwise leave the store with the backup's
|
|
159
|
+
// artifacts but the *target's* original users/tokens/comments — an
|
|
160
|
+
// unrecoverable half-restore. This check doesn't guarantee
|
|
161
|
+
// importFullStoreTables can't still fail for other reasons (disk
|
|
162
|
+
// errors, etc.), but it catches the common case of a malformed or
|
|
163
|
+
// hand-edited bundle up front.
|
|
164
|
+
assertValidFullBackupTables(bundle.full);
|
|
165
|
+
if (!Array.isArray(bundle.full?.embeddings)) {
|
|
166
|
+
warnings.push(
|
|
167
|
+
"Backup predates embeddings in full-scope backups; run `artifacty index rebuild --embeddings` after this restore to rebuild semantic search."
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
// writeIndex's DELETE FROM artifacts cascades to every existing
|
|
172
|
+
// artifact's comments/relations/embeddings (foreign keys, ON DELETE
|
|
173
|
+
// CASCADE) — but an artifacts-scope bundle never carries or restores
|
|
174
|
+
// those tables. Silently wiping a target store's comment/relation
|
|
175
|
+
// history because someone ran an artifacts-only restore would be a
|
|
176
|
+
// surprising, unrecoverable data loss, so refuse unless the caller
|
|
177
|
+
// explicitly opts in the same way a full-scope restore does.
|
|
178
|
+
const dependents = countArtifactDependents(store);
|
|
179
|
+
if (dependents.comments > 0 || dependents.relations > 0) {
|
|
180
|
+
if (confirm !== "replace-all") {
|
|
181
|
+
throw Object.assign(
|
|
182
|
+
new Error(
|
|
183
|
+
'Target store has comments and/or relations that this artifacts-scope restore would delete without restoring equivalents. Pass confirm: "replace-all" to proceed anyway, or use a full-scope backup.'
|
|
184
|
+
),
|
|
185
|
+
{ statusCode: 409, code: "dependents_exist" }
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
warnings.push(
|
|
189
|
+
`Target store's ${dependents.comments} comment(s) and ${dependents.relations} relation(s) were deleted by this artifacts-scope restore and are not restored (only a full-scope backup carries them).`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
43
192
|
}
|
|
44
193
|
|
|
45
194
|
const index = {
|
|
@@ -52,8 +201,9 @@ export async function importStore(store = createStore(), inputPath) {
|
|
|
52
201
|
for (const version of artifact.versions || []) {
|
|
53
202
|
const cleanVersion = { ...version };
|
|
54
203
|
delete cleanVersion.content;
|
|
204
|
+
cleanVersion.path = normalizeBackupRelativePath(cleanVersion.path);
|
|
55
205
|
const content = version.content || "";
|
|
56
|
-
const absolutePath =
|
|
206
|
+
const absolutePath = backupVersionPath(store, cleanVersion.path);
|
|
57
207
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
58
208
|
await writeFile(absolutePath, content, "utf8");
|
|
59
209
|
versions.push(cleanVersion);
|
|
@@ -62,14 +212,175 @@ export async function importStore(store = createStore(), inputPath) {
|
|
|
62
212
|
}
|
|
63
213
|
|
|
64
214
|
await writeIndex(store, index);
|
|
65
|
-
|
|
66
|
-
|
|
215
|
+
await pruneUnreferencedArtifactFiles(store, index);
|
|
216
|
+
|
|
217
|
+
const result = {
|
|
67
218
|
artifactCount: index.artifacts.length,
|
|
219
|
+
scope,
|
|
68
220
|
importedAt: new Date().toISOString()
|
|
69
221
|
};
|
|
222
|
+
if (warnings.length > 0) {
|
|
223
|
+
result.warnings = warnings;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (scope === "full") {
|
|
227
|
+
result.tableCounts = await importFullStoreTables(store, bundle.full || {}, { forceUsers });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Counts the rows an artifacts-scope restore would silently delete (via
|
|
234
|
+
// writeIndex's artifacts-table replace cascading through foreign keys) but
|
|
235
|
+
// never restores, so importStoreBundle can decide whether to warn/refuse.
|
|
236
|
+
function countArtifactDependents(store) {
|
|
237
|
+
const db = openDatabase(store);
|
|
238
|
+
try {
|
|
239
|
+
return {
|
|
240
|
+
comments: db.prepare("SELECT COUNT(*) AS count FROM artifact_comments").get().count,
|
|
241
|
+
relations: db.prepare("SELECT COUNT(*) AS count FROM artifact_relations").get().count
|
|
242
|
+
};
|
|
243
|
+
} finally {
|
|
244
|
+
db.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const FULL_BACKUP_TABLE_ARRAY_KEYS = [
|
|
249
|
+
"users",
|
|
250
|
+
"apiTokens",
|
|
251
|
+
"auditLog",
|
|
252
|
+
"relations",
|
|
253
|
+
"webhooks",
|
|
254
|
+
"comments",
|
|
255
|
+
"savedViews",
|
|
256
|
+
"embeddings",
|
|
257
|
+
"meta"
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
// Partial, fail-fast validation of a full-scope backup's `full` object,
|
|
261
|
+
// aimed at catching the common failure mode (a malformed or hand-edited
|
|
262
|
+
// bundle) before writeIndex below has made any changes. Not exhaustive:
|
|
263
|
+
// importFullStoreTables (storage.js) remains the source of truth for what
|
|
264
|
+
// is actually importable, and can still throw for reasons this doesn't
|
|
265
|
+
// check for.
|
|
266
|
+
function assertValidFullBackupTables(full) {
|
|
267
|
+
if (full === undefined || full === null) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (typeof full !== "object" || Array.isArray(full)) {
|
|
271
|
+
throw Object.assign(new Error("Invalid Artifacty backup: `full` must be an object"), {
|
|
272
|
+
statusCode: 400,
|
|
273
|
+
code: "INVALID_BACKUP"
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
for (const key of FULL_BACKUP_TABLE_ARRAY_KEYS) {
|
|
277
|
+
if (full[key] !== undefined && !Array.isArray(full[key])) {
|
|
278
|
+
throw Object.assign(new Error(`Invalid Artifacty backup: \`full.${key}\` must be an array`), {
|
|
279
|
+
statusCode: 400,
|
|
280
|
+
code: "INVALID_BACKUP"
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const row of full.users || []) {
|
|
285
|
+
if (!row || typeof row !== "object" || !String(row.id || "").trim() || !String(row.email || "").trim()) {
|
|
286
|
+
throw Object.assign(new Error("Invalid Artifacty backup: each `full.users` entry needs an id and email"), {
|
|
287
|
+
statusCode: 400,
|
|
288
|
+
code: "INVALID_BACKUP"
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const row of full.apiTokens || []) {
|
|
293
|
+
if (!row || typeof row !== "object" || !String(row.id || "").trim() || !String(row.userId || "").trim()) {
|
|
294
|
+
throw Object.assign(new Error("Invalid Artifacty backup: each `full.apiTokens` entry needs an id and userId"), {
|
|
295
|
+
statusCode: 400,
|
|
296
|
+
code: "INVALID_BACKUP"
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
70
300
|
}
|
|
71
301
|
|
|
72
302
|
export function defaultBackupPath(store = createStore()) {
|
|
73
303
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
74
304
|
return path.join(store.home, "backups", `artifacty-${stamp}.json`);
|
|
75
305
|
}
|
|
306
|
+
|
|
307
|
+
function backupVersionPath(store, relativePath) {
|
|
308
|
+
const normalized = normalizeBackupRelativePath(relativePath);
|
|
309
|
+
const root = path.resolve(store.home);
|
|
310
|
+
const absolute = path.resolve(root, ...normalized.split("/"));
|
|
311
|
+
if (absolute !== root && !absolute.startsWith(`${root}${path.sep}`)) {
|
|
312
|
+
throw Object.assign(new Error(`Invalid Artifacty backup version path: ${relativePath}`), {
|
|
313
|
+
statusCode: 400,
|
|
314
|
+
code: "INVALID_BACKUP_PATH"
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return absolute;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function normalizeBackupRelativePath(relativePath) {
|
|
321
|
+
const value = String(relativePath || "");
|
|
322
|
+
if (!value || path.isAbsolute(value)) {
|
|
323
|
+
throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
|
|
324
|
+
statusCode: 400,
|
|
325
|
+
code: "INVALID_BACKUP_PATH"
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const portable = value.replaceAll("\\", "/");
|
|
330
|
+
if (/^[A-Za-z]:\//.test(portable) || portable.startsWith("//")) {
|
|
331
|
+
throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
|
|
332
|
+
statusCode: 400,
|
|
333
|
+
code: "INVALID_BACKUP_PATH"
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const parts = portable
|
|
338
|
+
.split("/")
|
|
339
|
+
.filter((part) => part && part !== ".");
|
|
340
|
+
if (parts.length === 0 || parts.some((part) => part === "..")) {
|
|
341
|
+
throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
|
|
342
|
+
statusCode: 400,
|
|
343
|
+
code: "INVALID_BACKUP_PATH"
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return parts.join("/");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function pruneUnreferencedArtifactFiles(store, index) {
|
|
350
|
+
const referenced = new Set();
|
|
351
|
+
for (const artifact of index.artifacts) {
|
|
352
|
+
for (const version of artifact.versions || []) {
|
|
353
|
+
referenced.add(backupVersionPath(store, version.path));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const files = await listFiles(store.artifactsDir);
|
|
358
|
+
for (const filePath of files) {
|
|
359
|
+
if (!referenced.has(filePath)) {
|
|
360
|
+
await rm(filePath, { force: true });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function listFiles(root) {
|
|
366
|
+
let entries;
|
|
367
|
+
try {
|
|
368
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error.code === "ENOENT") {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const files = [];
|
|
377
|
+
for (const entry of entries) {
|
|
378
|
+
const fullPath = path.join(root, entry.name);
|
|
379
|
+
if (entry.isDirectory()) {
|
|
380
|
+
files.push(...await listFiles(fullPath));
|
|
381
|
+
} else if (entry.isFile()) {
|
|
382
|
+
files.push(path.resolve(fullPath));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return files;
|
|
386
|
+
}
|
package/src/lib/converters.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { normalizeSourceAgent } from "./agents.js";
|
|
4
|
-
import { ARTIFACT_TYPES, contentTypeForFormat, normalizeFormat } from "./storage.js";
|
|
4
|
+
import { ARTIFACT_TYPES, BUNDLE_BINARY_CONTENT_TYPES, contentTypeForFormat, normalizeFormat } from "./storage.js";
|
|
5
5
|
|
|
6
6
|
const CONTINUATION_AGENTS = new Set(["codex", "copilot", "cursor"]);
|
|
7
7
|
|
|
@@ -119,6 +119,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
|
|
|
119
119
|
if (type.includes("vnd.ant.react") || type.includes("jsx")) {
|
|
120
120
|
return "react";
|
|
121
121
|
}
|
|
122
|
+
if (type.includes("ipynb") || type.includes("notebook")) {
|
|
123
|
+
return "notebook";
|
|
124
|
+
}
|
|
122
125
|
if (type.includes("html")) {
|
|
123
126
|
return "html";
|
|
124
127
|
}
|
|
@@ -126,9 +129,12 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
|
|
|
126
129
|
return "markdown";
|
|
127
130
|
}
|
|
128
131
|
if (type.includes("json")) {
|
|
129
|
-
return "json";
|
|
132
|
+
return looksLikeNotebook(content) ? "notebook" : "json";
|
|
130
133
|
}
|
|
131
134
|
const lowerName = optionalString(fileName).toLowerCase();
|
|
135
|
+
if (lowerName.endsWith(".ipynb")) {
|
|
136
|
+
return "notebook";
|
|
137
|
+
}
|
|
132
138
|
if (lowerName.endsWith(".sarif") || lowerName.endsWith(".sarif.json")) {
|
|
133
139
|
return "sarif";
|
|
134
140
|
}
|
|
@@ -161,6 +167,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
|
|
|
161
167
|
if (extension === ".jsx" || extension === ".tsx") {
|
|
162
168
|
return "react";
|
|
163
169
|
}
|
|
170
|
+
if (extension === ".ipynb") {
|
|
171
|
+
return "notebook";
|
|
172
|
+
}
|
|
164
173
|
if (isCodeExtension(extension)) {
|
|
165
174
|
return "code";
|
|
166
175
|
}
|
|
@@ -190,6 +199,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
|
|
|
190
199
|
if (looksLikeSarif(trimmed)) {
|
|
191
200
|
return "sarif";
|
|
192
201
|
}
|
|
202
|
+
if (looksLikeNotebook(trimmed)) {
|
|
203
|
+
return "notebook";
|
|
204
|
+
}
|
|
193
205
|
if (looksLikeJson(trimmed)) {
|
|
194
206
|
return "json";
|
|
195
207
|
}
|
|
@@ -248,6 +260,21 @@ function decodeObjectPayload(value, agent) {
|
|
|
248
260
|
};
|
|
249
261
|
}
|
|
250
262
|
|
|
263
|
+
if (isNotebookObject(artifactObject)) {
|
|
264
|
+
return {
|
|
265
|
+
content: JSON.stringify(artifactObject, null, 2),
|
|
266
|
+
format: "notebook",
|
|
267
|
+
contentType: "application/x-ipynb+json; charset=utf-8",
|
|
268
|
+
title: artifactObject.title || artifactObject.name || "Notebook",
|
|
269
|
+
sourceAgent: artifactObject.sourceAgent || artifactObject.source_agent || artifactObject.agent || (agent === "auto" ? undefined : agent),
|
|
270
|
+
artifactType: artifactObject.artifactType || artifactObject.artifact_type || "analysis-report",
|
|
271
|
+
tags: artifactObject.tags,
|
|
272
|
+
metadata: {
|
|
273
|
+
originalPayloadShape: "notebook"
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
251
278
|
if (typeof artifactObject.content === "string" && !Array.isArray(artifactObject.content)) {
|
|
252
279
|
const sourceContentType = artifactObject.contentType ||
|
|
253
280
|
artifactObject.content_type ||
|
|
@@ -440,13 +467,30 @@ function decodeBundlePayload(object, agent) {
|
|
|
440
467
|
? collectContinuationMetadata(continuationAgent, { agent: sourceAgent }, bundle, object)
|
|
441
468
|
: {};
|
|
442
469
|
const normalizedFiles = files.map((file, index) => {
|
|
470
|
+
const filePath = optionalString(file.path || file.name || `file-${index + 1}`);
|
|
471
|
+
const declaredContentType = optionalString(file.contentType || file.mimeType).toLowerCase().split(";")[0].trim();
|
|
472
|
+
const isBinary = file.encoding === "base64" || BUNDLE_BINARY_CONTENT_TYPES.has(declaredContentType);
|
|
473
|
+
|
|
474
|
+
if (isBinary) {
|
|
475
|
+
const base64 = normalizeBase64(typeof file.content === "string" ? file.content : "");
|
|
476
|
+
const decoded = base64 ? Buffer.from(base64, "base64") : Buffer.alloc(0);
|
|
477
|
+
return {
|
|
478
|
+
path: filePath,
|
|
479
|
+
content: base64,
|
|
480
|
+
encoding: "base64",
|
|
481
|
+
contentType: declaredContentType || "application/octet-stream",
|
|
482
|
+
sizeBytes: decoded.byteLength,
|
|
483
|
+
sha256: createHash("sha256").update(decoded).digest("hex")
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
443
487
|
const content = typeof file.content === "string" ? file.content : "";
|
|
444
488
|
return {
|
|
445
|
-
path:
|
|
489
|
+
path: filePath,
|
|
446
490
|
content,
|
|
447
491
|
contentType: optionalString(file.contentType || file.mimeType) || contentTypeForFormat(detectFormat({
|
|
448
492
|
content,
|
|
449
|
-
fileName:
|
|
493
|
+
fileName: filePath
|
|
450
494
|
})),
|
|
451
495
|
sizeBytes: Buffer.byteLength(content, "utf8"),
|
|
452
496
|
sha256: createHash("sha256").update(content).digest("hex")
|
|
@@ -1098,7 +1142,7 @@ function inferArtifactType({ format, content, fileName, metadata }) {
|
|
|
1098
1142
|
if (format === "code") {
|
|
1099
1143
|
return "snippet";
|
|
1100
1144
|
}
|
|
1101
|
-
if (format === "sarif") {
|
|
1145
|
+
if (format === "sarif" || format === "notebook") {
|
|
1102
1146
|
return "analysis-report";
|
|
1103
1147
|
}
|
|
1104
1148
|
if (format === "csv") {
|
|
@@ -1190,6 +1234,27 @@ function isSarifObject(value) {
|
|
|
1190
1234
|
(typeof value.version === "string" || optionalString(value.$schema).toLowerCase().includes("sarif"));
|
|
1191
1235
|
}
|
|
1192
1236
|
|
|
1237
|
+
function looksLikeNotebook(value) {
|
|
1238
|
+
if (!looksLikeJson(value)) {
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
try {
|
|
1242
|
+
return isNotebookObject(JSON.parse(optionalString(value)));
|
|
1243
|
+
} catch {
|
|
1244
|
+
return false;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
export function isNotebookObject(value) {
|
|
1249
|
+
return Boolean(
|
|
1250
|
+
value &&
|
|
1251
|
+
typeof value === "object" &&
|
|
1252
|
+
!Array.isArray(value) &&
|
|
1253
|
+
value.nbformat !== undefined &&
|
|
1254
|
+
Array.isArray(value.cells)
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1193
1258
|
function looksLikeHtml(value) {
|
|
1194
1259
|
const trimmed = optionalString(value);
|
|
1195
1260
|
if (/^<!doctype html/i.test(trimmed) || /^<html[\s>]/i.test(trimmed)) {
|
|
@@ -1420,3 +1485,32 @@ function optionalString(value) {
|
|
|
1420
1485
|
}
|
|
1421
1486
|
return String(value).trim();
|
|
1422
1487
|
}
|
|
1488
|
+
|
|
1489
|
+
// Resolves the best available image/video content type for a stored
|
|
1490
|
+
// artifact: an explicit media MIME type in version metadata, then the
|
|
1491
|
+
// version's own contentType if it is itself image/video, then a
|
|
1492
|
+
// format-based fallback. Used when serving raw media bytes (e.g. GET
|
|
1493
|
+
// /artifacts/:id/raw) so a stale or missing contentType still renders.
|
|
1494
|
+
export function mediaContentType(artifact) {
|
|
1495
|
+
const metadataType = String(artifact.version.metadata?.mimeType || "").toLowerCase();
|
|
1496
|
+
if (metadataType.startsWith("image/") || metadataType.startsWith("video/")) {
|
|
1497
|
+
return metadataType;
|
|
1498
|
+
}
|
|
1499
|
+
const versionType = String(artifact.version.contentType || "").toLowerCase().split(";")[0];
|
|
1500
|
+
if (versionType.startsWith("image/") || versionType.startsWith("video/")) {
|
|
1501
|
+
return versionType;
|
|
1502
|
+
}
|
|
1503
|
+
return artifact.version.format === "video" ? "video/mp4" : "image/png";
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Decodes an image/video artifact's stored content (a data: URL or bare
|
|
1507
|
+
// base64 string) into raw bytes, or returns null if it isn't valid base64.
|
|
1508
|
+
export function decodeMediaContent(content) {
|
|
1509
|
+
const value = String(content || "").trim();
|
|
1510
|
+
const dataUrl = /^data:[^;,]+;base64,([A-Za-z0-9+/=_-\s]+)$/i.exec(value);
|
|
1511
|
+
const base64 = (dataUrl ? dataUrl[1] : value).replace(/\s+/g, "").replaceAll("-", "+").replaceAll("_", "/");
|
|
1512
|
+
if (!base64 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1515
|
+
return Buffer.from(base64, "base64");
|
|
1516
|
+
}
|
package/src/lib/csv.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Single shared CSV parser used by the CSV renderer (render.js), the CSV
|
|
2
|
+
// diff (diff.js), and the SARIF/CSV export route (sarif-csv-export.js). All
|
|
3
|
+
// three previously carried byte-identical copies of this state machine that
|
|
4
|
+
// had already diverged in behavior on malformed input (an unterminated
|
|
5
|
+
// quoted field): the render.js copy failed closed while the diff.js copy
|
|
6
|
+
// silently emitted a bogus single-cell row for the same input, so the same
|
|
7
|
+
// artifact rendered as an error but diffed as data. This module is the one
|
|
8
|
+
// source of truth: it always fails closed on an unterminated quoted field.
|
|
9
|
+
export function parseCsv(content) {
|
|
10
|
+
const text = String(content || "");
|
|
11
|
+
const rows = [];
|
|
12
|
+
let row = [];
|
|
13
|
+
let cell = "";
|
|
14
|
+
let inQuotes = false;
|
|
15
|
+
|
|
16
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
17
|
+
const char = text[index];
|
|
18
|
+
if (char === "\"") {
|
|
19
|
+
if (inQuotes && text[index + 1] === "\"") {
|
|
20
|
+
cell += "\"";
|
|
21
|
+
index += 1;
|
|
22
|
+
} else {
|
|
23
|
+
inQuotes = !inQuotes;
|
|
24
|
+
}
|
|
25
|
+
} else if (char === "," && !inQuotes) {
|
|
26
|
+
row.push(cell);
|
|
27
|
+
cell = "";
|
|
28
|
+
} else if ((char === "\n" || char === "\r") && !inQuotes) {
|
|
29
|
+
if (char === "\r" && text[index + 1] === "\n") {
|
|
30
|
+
index += 1;
|
|
31
|
+
}
|
|
32
|
+
row.push(cell);
|
|
33
|
+
if (row.length > 1 || row[0] !== "") {
|
|
34
|
+
rows.push(row);
|
|
35
|
+
}
|
|
36
|
+
row = [];
|
|
37
|
+
cell = "";
|
|
38
|
+
} else {
|
|
39
|
+
cell += char;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (inQuotes) {
|
|
44
|
+
return { ok: false, error: "unterminated quoted field", rows: [] };
|
|
45
|
+
}
|
|
46
|
+
row.push(cell);
|
|
47
|
+
if (row.length > 1 || row[0] !== "") {
|
|
48
|
+
rows.push(row);
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, rows };
|
|
51
|
+
}
|