pi-vault-mind 0.16.8 → 0.16.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.10 / 0.6.14 — 2026-07-20
4
+
5
+ ### Added
6
+
7
+ - **First-run retry with notes.** After cancellation or a terminal personalization failure, the Personalize card retains an optional draft. A blank draft retries with `/vm personalize`; a nonblank trimmed draft is sent as explicit `@agent-personalize:` input and clears only after durable personalization completes.
8
+ - **Typed collection and injector management.** Vault Mind Settings now exposes authenticated collection and injector CRUD controls with validation for vault-relative paths, collection schemas, regex patterns, and referenced targets.
9
+
10
+ ### Fixed
11
+
12
+ - **First-run chat lifecycle.** The onboarding surface remains stable through configuration and personalization transitions. The standard message feed is deferred until a durable personalized state and is instantiated at most once, preventing duplicate first-run cards from reactive remounts.
13
+ - **Long diff readability.** Diff cards collapse long proposal sides by default while retaining access to the full edit.
14
+ - **Settings persistence and layout.** The setup wizard forwards Auto-start to `POST /vm/setup`; Settings categories write the canonical nested configuration shape, and the plugin Settings pane uses the available Obsidian width.
15
+ - **Tool-card escaped text.** JSON-decoded multiline tool output renders actual line breaks and indentation instead of literal escape sequences.
16
+
17
+ ### Verification
18
+
19
+ - Added focused coverage for retry-note dispatch, cancellation/failure retention, durable-success clearing, one-card first-run lifecycle transitions, long diff collapse, Settings persistence, and collection/injector contracts.
20
+ - The collection/injector CRUD and first-run retry flows remain pending published-artifact ReturnVape walkthroughs.
21
+
22
+
23
+ ## 0.6.12 — 2026-07-19
24
+
25
+ ### Fixed
26
+
27
+ - **Standardized ToolCard presentation.** Tool cards now use humanized tool labels, readable request summaries, structured result previews for text/object/array payloads, masked credential fields, file-path pills for file requests, and a chevron-backed Details disclosure for exact masked payloads.
28
+ - **Settings section persistence routing.** Editable Settings categories now map to the nested `PATCH /vm/config` shape instead of flat top-level patches, Embedding secret fields route through `PUT /vm/embedding/secrets`, and Automation now explains that tag-based auto-sync remains config-only until a dedicated editor ships.
29
+ - **Port-parity whitespace safety.** Canonical source hashing now treats interior Arrow template whitespace as byte-significant, keeps sandbox/plugin canonicalizers aligned, and guards ToolCard inline template markup against whitespace-text regressions.
30
+
31
+ ### Tests
32
+
33
+ - Added ToolCard regressions for text/object/array rendering, malformed JSON fallback, masked credential handling, file-pill request rendering, and raw-details preservation.
34
+ - Added parity regression coverage for interior template whitespace and a shared-corpus agreement check between the sandbox and plugin canonicalizers.
35
+
36
+
3
37
  ## 0.16.8 / 0.6.11 — 2026-07-19
4
38
 
5
39
  ### Added
@@ -6,6 +6,14 @@ const encodeWebSocketAuthProtocol = (token) => {
6
6
  const encoded = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
7
7
  return `pvm-auth.${encoded}`;
8
8
  };
9
+ export class HttpStatusError extends Error {
10
+ status;
11
+ constructor(status, message) {
12
+ super(`${status} ${message}`);
13
+ this.name = "HttpStatusError";
14
+ this.status = status;
15
+ }
16
+ }
9
17
  /**
10
18
  * HTTP + WebSocket client for the pi-vault-mind extension.
11
19
  *
@@ -142,7 +150,7 @@ export class VaultMindClient {
142
150
  // keep raw text as the server message
143
151
  }
144
152
  // Never echo the request body or secret material in the thrown error.
145
- throw new Error(`${res.status} ${serverMessage}`);
153
+ throw new HttpStatusError(res.status, serverMessage);
146
154
  }
147
155
  return text ? JSON.parse(text) : undefined;
148
156
  }
@@ -285,6 +293,30 @@ export class VaultMindClient {
285
293
  async vmStats() {
286
294
  return (await this.httpJson("GET", "/vm/stats"));
287
295
  }
296
+ async listManagedCollections() {
297
+ return (await this.httpJson("GET", "/vm/collections"));
298
+ }
299
+ async upsertManagedCollection(name, input) {
300
+ return (await this.httpJson("PUT", `/vm/collections/${encodeURIComponent(name)}`, input));
301
+ }
302
+ async patchManagedCollection(name, input) {
303
+ return (await this.httpJson("PATCH", `/vm/collections/${encodeURIComponent(name)}`, input));
304
+ }
305
+ async deleteManagedCollection(name) {
306
+ await this.httpJson("DELETE", `/vm/collections/${encodeURIComponent(name)}`);
307
+ }
308
+ async listManagedInjectors() {
309
+ return (await this.httpJson("GET", "/vm/injectors"));
310
+ }
311
+ async upsertManagedInjector(name, input) {
312
+ return (await this.httpJson("PUT", `/vm/injectors/${encodeURIComponent(name)}`, input));
313
+ }
314
+ async patchManagedInjector(name, input) {
315
+ return (await this.httpJson("PATCH", `/vm/injectors/${encodeURIComponent(name)}`, input));
316
+ }
317
+ async deleteManagedInjector(name) {
318
+ await this.httpJson("DELETE", `/vm/injectors/${encodeURIComponent(name)}`);
319
+ }
288
320
  /** GET /vault-mind/config — full config + hasToken + remote block */
289
321
  async getConfig() {
290
322
  return (await this.httpJson("GET", "/vault-mind/config"));
@@ -0,0 +1,32 @@
1
+ export interface CollectionConfig {
2
+ name: string;
3
+ path: string;
4
+ schema: string[];
5
+ dedupField?: string;
6
+ dedupMode?: "exact" | "fuzzy";
7
+ dedupThreshold?: number;
8
+ }
9
+ export interface CollectionRecord extends CollectionConfig {
10
+ count: number;
11
+ malformed: number;
12
+ }
13
+ export interface InjectorConfig {
14
+ name: string;
15
+ regex: string;
16
+ collection: string;
17
+ captureGroup?: number;
18
+ filterField?: string;
19
+ artifactPath?: string;
20
+ template?: string;
21
+ }
22
+ export declare class CollectionInjectorManagerError extends Error {
23
+ readonly status: 400 | 404 | 409;
24
+ constructor(message: string, status: 400 | 404 | 409);
25
+ }
26
+ export declare function listCollections(vaultPath: string): CollectionRecord[];
27
+ export declare function upsertCollection(vaultPath: string, request: CollectionConfig): CollectionRecord;
28
+ export declare function patchCollection(vaultPath: string, name: string, patch: Record<string, unknown>): CollectionRecord;
29
+ export declare function deleteCollection(vaultPath: string, name: string): void;
30
+ export declare function listInjectors(vaultPath: string): InjectorConfig[];
31
+ export declare function upsertInjector(vaultPath: string, request: InjectorConfig): InjectorConfig;
32
+ export declare function deleteInjector(vaultPath: string, name: string): void;
@@ -0,0 +1,394 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getConfigPath } from "./utils.js";
5
+ export class CollectionInjectorManagerError extends Error {
6
+ status;
7
+ constructor(message, status) {
8
+ super(message);
9
+ this.status = status;
10
+ this.name = "CollectionInjectorManagerError";
11
+ }
12
+ }
13
+ function isRecord(value) {
14
+ return value !== null && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+ function requiredText(value, label) {
17
+ if (typeof value !== "string" || value.trim().length === 0) {
18
+ throw new CollectionInjectorManagerError(`${label} is required.`, 400);
19
+ }
20
+ return value.trim();
21
+ }
22
+ function readConfig(vaultPath) {
23
+ const configPath = getConfigPath(vaultPath);
24
+ if (!fs.existsSync(configPath)) {
25
+ throw new CollectionInjectorManagerError("No vault configuration found. Run setup first.", 404);
26
+ }
27
+ try {
28
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
29
+ if (!isRecord(parsed))
30
+ throw new Error("Configuration must be an object.");
31
+ return { configPath, config: parsed };
32
+ }
33
+ catch (error) {
34
+ if (error instanceof CollectionInjectorManagerError)
35
+ throw error;
36
+ throw new CollectionInjectorManagerError(`Could not read vault configuration: ${error instanceof Error ? error.message : String(error)}`, 400);
37
+ }
38
+ }
39
+ function writeConfig(configPath, config) {
40
+ const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
41
+ try {
42
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
43
+ fs.renameSync(temporaryPath, configPath);
44
+ }
45
+ catch (error) {
46
+ try {
47
+ fs.unlinkSync(temporaryPath);
48
+ }
49
+ catch { }
50
+ throw error;
51
+ }
52
+ }
53
+ function resolveVaultCollectionPath(vaultPath, collectionPath) {
54
+ if (path.isAbsolute(collectionPath)) {
55
+ throw new CollectionInjectorManagerError("Collection paths must be relative to the vault.", 400);
56
+ }
57
+ if (path.extname(collectionPath) !== ".jsonl") {
58
+ throw new CollectionInjectorManagerError("Collection paths must target a .jsonl file.", 400);
59
+ }
60
+ const absolutePath = path.resolve(vaultPath, collectionPath);
61
+ const relativePath = path.relative(vaultPath, absolutePath);
62
+ if (!relativePath ||
63
+ relativePath === ".." ||
64
+ relativePath.startsWith(`..${path.sep}`) ||
65
+ path.isAbsolute(relativePath)) {
66
+ throw new CollectionInjectorManagerError("Collection path must stay inside the vault.", 400);
67
+ }
68
+ const realVaultPath = fs.realpathSync.native(vaultPath);
69
+ let parentPath = vaultPath;
70
+ for (const segment of relativePath.split(path.sep).slice(0, -1)) {
71
+ parentPath = path.join(parentPath, segment);
72
+ if (!fs.existsSync(parentPath))
73
+ fs.mkdirSync(parentPath);
74
+ const realParentPath = fs.realpathSync.native(parentPath);
75
+ const parentRelativePath = path.relative(realVaultPath, realParentPath);
76
+ if (parentRelativePath === ".." ||
77
+ parentRelativePath.startsWith(`..${path.sep}`) ||
78
+ path.isAbsolute(parentRelativePath)) {
79
+ throw new CollectionInjectorManagerError("Collection path cannot traverse a linked directory outside the vault.", 400);
80
+ }
81
+ }
82
+ if (fs.existsSync(absolutePath) && fs.lstatSync(absolutePath).isSymbolicLink()) {
83
+ throw new CollectionInjectorManagerError("Collection path cannot be a symbolic link.", 400);
84
+ }
85
+ return absolutePath;
86
+ }
87
+ function resolveSchemaAlias(name, value, collections, visited = new Set()) {
88
+ if (!isRecord(value)) {
89
+ throw new CollectionInjectorManagerError(`Collection "${name}" has an invalid definition.`, 400);
90
+ }
91
+ if (Array.isArray(value.schema)) {
92
+ return value.schema.map((field) => requiredText(field, `Collection "${name}" schema field`));
93
+ }
94
+ if (typeof value.schema !== "string" || !value.schema.trim()) {
95
+ throw new CollectionInjectorManagerError(`Collection "${name}" requires a schema.`, 400);
96
+ }
97
+ const alias = value.schema.trim();
98
+ if (visited.has(alias)) {
99
+ throw new CollectionInjectorManagerError(`Collection "${name}" has a circular schema alias.`, 400);
100
+ }
101
+ const target = collections[alias];
102
+ if (target === undefined) {
103
+ throw new CollectionInjectorManagerError(`Collection "${name}" references missing schema alias "${alias}".`, 400);
104
+ }
105
+ visited.add(alias);
106
+ return resolveSchemaAlias(alias, target, collections, visited);
107
+ }
108
+ function collectionRecord(name, value, vaultPath, collections) {
109
+ if (!isRecord(value)) {
110
+ throw new CollectionInjectorManagerError(`Collection "${name}" has an invalid definition.`, 400);
111
+ }
112
+ const collectionPath = requiredText(value.path, `Collection "${name}" path`);
113
+ const schema = collections
114
+ ? resolveSchemaAlias(name, value, collections, new Set([name]))
115
+ : Array.isArray(value.schema)
116
+ ? value.schema.map((field) => requiredText(field, `Collection "${name}" schema field`))
117
+ : [];
118
+ if (schema.length === 0) {
119
+ throw new CollectionInjectorManagerError(`Collection "${name}" requires at least one schema field.`, 400);
120
+ }
121
+ const absolutePath = path.isAbsolute(collectionPath)
122
+ ? collectionPath
123
+ : path.join(vaultPath, collectionPath);
124
+ let count = 0;
125
+ let malformed = 0;
126
+ if (fs.existsSync(absolutePath)) {
127
+ for (const line of fs.readFileSync(absolutePath, "utf-8").split("\n")) {
128
+ if (!line.trim())
129
+ continue;
130
+ try {
131
+ JSON.parse(line);
132
+ count += 1;
133
+ }
134
+ catch {
135
+ malformed += 1;
136
+ }
137
+ }
138
+ }
139
+ const dedupMode = value.dedupMode;
140
+ const dedupThreshold = value.dedupThreshold;
141
+ return {
142
+ name,
143
+ path: collectionPath,
144
+ schema,
145
+ ...(typeof value.dedupField === "string" && value.dedupField.trim()
146
+ ? { dedupField: value.dedupField.trim() }
147
+ : {}),
148
+ ...(dedupMode === "exact" || dedupMode === "fuzzy" ? { dedupMode } : {}),
149
+ ...(typeof dedupThreshold === "number" && Number.isFinite(dedupThreshold)
150
+ ? { dedupThreshold }
151
+ : {}),
152
+ count,
153
+ malformed,
154
+ };
155
+ }
156
+ function assertVaultArtifactPath(vaultPath, artifactPath) {
157
+ if (path.isAbsolute(artifactPath)) {
158
+ throw new CollectionInjectorManagerError("Injector artifact paths must be relative to the vault.", 400);
159
+ }
160
+ const absolutePath = path.resolve(vaultPath, artifactPath);
161
+ const relativePath = path.relative(vaultPath, absolutePath);
162
+ if (!relativePath ||
163
+ relativePath === ".." ||
164
+ relativePath.startsWith(`..${path.sep}`) ||
165
+ path.isAbsolute(relativePath)) {
166
+ throw new CollectionInjectorManagerError("Injector artifact path must stay inside the vault.", 400);
167
+ }
168
+ let existingParentPath = path.dirname(absolutePath);
169
+ while (!fs.existsSync(existingParentPath)) {
170
+ const nextParentPath = path.dirname(existingParentPath);
171
+ if (nextParentPath === existingParentPath)
172
+ break;
173
+ existingParentPath = nextParentPath;
174
+ }
175
+ const realVaultPath = fs.realpathSync.native(vaultPath);
176
+ const realParentPath = fs.realpathSync.native(existingParentPath);
177
+ const parentRelativePath = path.relative(realVaultPath, realParentPath);
178
+ if (parentRelativePath === ".." ||
179
+ parentRelativePath.startsWith(`..${path.sep}`) ||
180
+ path.isAbsolute(parentRelativePath)) {
181
+ throw new CollectionInjectorManagerError("Injector artifact path cannot traverse a linked directory outside the vault.", 400);
182
+ }
183
+ if (fs.existsSync(absolutePath) && fs.lstatSync(absolutePath).isSymbolicLink()) {
184
+ throw new CollectionInjectorManagerError("Injector artifact path cannot be a symbolic link.", 400);
185
+ }
186
+ }
187
+ function injectorRecord(value) {
188
+ if (!isRecord(value))
189
+ throw new CollectionInjectorManagerError("Injector has an invalid definition.", 400);
190
+ return {
191
+ name: requiredText(value.name, "Injector name"),
192
+ regex: requiredText(value.regex, "Injector regex"),
193
+ collection: requiredText(value.collection, "Injector collection"),
194
+ ...(typeof value.captureGroup === "number" ? { captureGroup: value.captureGroup } : {}),
195
+ ...(typeof value.filterField === "string" && value.filterField.trim()
196
+ ? { filterField: value.filterField.trim() }
197
+ : {}),
198
+ ...(typeof value.artifactPath === "string" && value.artifactPath.trim()
199
+ ? { artifactPath: value.artifactPath.trim() }
200
+ : {}),
201
+ ...(typeof value.template === "string" && value.template.trim()
202
+ ? { template: value.template }
203
+ : {}),
204
+ };
205
+ }
206
+ function collectionReferences(config, name) {
207
+ const references = [];
208
+ for (const injector of config.injectors ?? []) {
209
+ if (isRecord(injector) && injector.collection === name) {
210
+ references.push(`injector "${String(injector.name ?? "unnamed")}"`);
211
+ }
212
+ }
213
+ for (const [collectionName, definition] of Object.entries(config.collections ?? {})) {
214
+ if (collectionName !== name && isRecord(definition) && definition.schema === name) {
215
+ references.push(`collection "${collectionName}" schema alias`);
216
+ }
217
+ }
218
+ const vaultMind = config.vaultMind;
219
+ const embedding = isRecord(vaultMind?.embedding) ? vaultMind.embedding : undefined;
220
+ const sync = isRecord(embedding?.sync) ? embedding.sync : undefined;
221
+ if (Array.isArray(sync?.collections) && sync.collections.includes(name)) {
222
+ references.push("embedding sync collections");
223
+ }
224
+ if (isRecord(embedding?.collectionModels) && Object.hasOwn(embedding.collectionModels, name)) {
225
+ references.push("embedding collection models");
226
+ }
227
+ const identities = isRecord(vaultMind?.identities) ? vaultMind.identities : undefined;
228
+ const roles = isRecord(identities?.roles) ? identities.roles : {};
229
+ for (const [role, profile] of Object.entries(roles)) {
230
+ if (!isRecord(profile))
231
+ continue;
232
+ for (const key of ["readCollections", "writeCollections"]) {
233
+ if (Array.isArray(profile[key]) && profile[key].includes(name)) {
234
+ references.push(`identity role "${role}" ${key}`);
235
+ }
236
+ }
237
+ }
238
+ return references;
239
+ }
240
+ export function listCollections(vaultPath) {
241
+ const { config } = readConfig(vaultPath);
242
+ const collections = isRecord(config.collections) ? config.collections : {};
243
+ return Object.entries(collections).map(([name, value]) => collectionRecord(name, value, vaultPath, collections));
244
+ }
245
+ export function upsertCollection(vaultPath, request) {
246
+ if (!isRecord(request) || !Array.isArray(request.schema)) {
247
+ throw new CollectionInjectorManagerError("Collection requires a schema array.", 400);
248
+ }
249
+ const name = requiredText(request.name, "Collection name");
250
+ const collectionPath = requiredText(request.path, "Collection path");
251
+ const schema = request.schema.map((field) => requiredText(field, "Collection schema field"));
252
+ if (schema.length === 0) {
253
+ throw new CollectionInjectorManagerError("Collection requires at least one schema field.", 400);
254
+ }
255
+ if (new Set(schema).size !== schema.length) {
256
+ throw new CollectionInjectorManagerError("Collection schema fields must be unique.", 400);
257
+ }
258
+ if (request.dedupMode && request.dedupMode !== "exact" && request.dedupMode !== "fuzzy") {
259
+ throw new CollectionInjectorManagerError("Dedup mode must be exact or fuzzy.", 400);
260
+ }
261
+ if (request.dedupThreshold !== undefined &&
262
+ (!Number.isFinite(request.dedupThreshold) ||
263
+ request.dedupThreshold < 0 ||
264
+ request.dedupThreshold > 1)) {
265
+ throw new CollectionInjectorManagerError("Dedup threshold must be between 0 and 1.", 400);
266
+ }
267
+ const { configPath, config } = readConfig(vaultPath);
268
+ const collections = isRecord(config.collections) ? config.collections : {};
269
+ const dedupField = typeof request.dedupField === "string" ? request.dedupField.trim() : undefined;
270
+ const definition = {
271
+ path: collectionPath,
272
+ schema,
273
+ ...(dedupField ? { dedupField } : {}),
274
+ ...(request.dedupMode ? { dedupMode: request.dedupMode } : {}),
275
+ ...(request.dedupThreshold !== undefined ? { dedupThreshold: request.dedupThreshold } : {}),
276
+ };
277
+ const absolutePath = resolveVaultCollectionPath(vaultPath, collectionPath);
278
+ config.collections = { ...collections, [name]: definition };
279
+ if (!fs.existsSync(absolutePath))
280
+ fs.writeFileSync(absolutePath, "", "utf-8");
281
+ writeConfig(configPath, config);
282
+ return collectionRecord(name, definition, vaultPath);
283
+ }
284
+ export function patchCollection(vaultPath, name, patch) {
285
+ const collectionName = requiredText(name, "Collection name");
286
+ const { configPath, config } = readConfig(vaultPath);
287
+ const collections = isRecord(config.collections) ? config.collections : {};
288
+ const current = collections[collectionName];
289
+ if (!isRecord(current)) {
290
+ throw new CollectionInjectorManagerError(`Collection "${collectionName}" was not found.`, 404);
291
+ }
292
+ const { name: _ignoredName, ...definitionPatch } = patch;
293
+ const definition = { ...current, ...definitionPatch };
294
+ if (Array.isArray(definition.schema)) {
295
+ return upsertCollection(vaultPath, {
296
+ name: collectionName,
297
+ path: requiredText(definition.path, "Collection path"),
298
+ schema: definition.schema.map((field) => requiredText(field, "Collection schema field")),
299
+ ...(typeof definition.dedupField === "string" ? { dedupField: definition.dedupField } : {}),
300
+ ...(definition.dedupMode === "exact" || definition.dedupMode === "fuzzy"
301
+ ? { dedupMode: definition.dedupMode }
302
+ : {}),
303
+ ...(typeof definition.dedupThreshold === "number"
304
+ ? { dedupThreshold: definition.dedupThreshold }
305
+ : {}),
306
+ });
307
+ }
308
+ if (typeof definition.schema !== "string" || !definition.schema.trim()) {
309
+ throw new CollectionInjectorManagerError("Collection requires a schema array or alias.", 400);
310
+ }
311
+ const collectionPath = requiredText(definition.path, "Collection path");
312
+ if (definition.dedupMode !== undefined &&
313
+ definition.dedupMode !== "exact" &&
314
+ definition.dedupMode !== "fuzzy") {
315
+ throw new CollectionInjectorManagerError("Dedup mode must be exact or fuzzy.", 400);
316
+ }
317
+ if (definition.dedupThreshold !== undefined &&
318
+ (typeof definition.dedupThreshold !== "number" ||
319
+ !Number.isFinite(definition.dedupThreshold) ||
320
+ definition.dedupThreshold < 0 ||
321
+ definition.dedupThreshold > 1)) {
322
+ throw new CollectionInjectorManagerError("Dedup threshold must be between 0 and 1.", 400);
323
+ }
324
+ const updatedCollections = { ...collections, [collectionName]: definition };
325
+ const schema = resolveSchemaAlias(collectionName, definition, updatedCollections, new Set([collectionName]));
326
+ if (schema.length === 0) {
327
+ throw new CollectionInjectorManagerError("Collection requires at least one schema field.", 400);
328
+ }
329
+ const absolutePath = resolveVaultCollectionPath(vaultPath, collectionPath);
330
+ if (!fs.existsSync(absolutePath))
331
+ fs.writeFileSync(absolutePath, "", "utf-8");
332
+ config.collections = updatedCollections;
333
+ writeConfig(configPath, config);
334
+ return collectionRecord(collectionName, definition, vaultPath, updatedCollections);
335
+ }
336
+ export function deleteCollection(vaultPath, name) {
337
+ const collectionName = requiredText(name, "Collection name");
338
+ const { configPath, config } = readConfig(vaultPath);
339
+ const collections = isRecord(config.collections) ? config.collections : {};
340
+ if (!Object.hasOwn(collections, collectionName)) {
341
+ throw new CollectionInjectorManagerError(`Collection "${collectionName}" was not found.`, 404);
342
+ }
343
+ const references = collectionReferences(config, collectionName);
344
+ if (references.length > 0) {
345
+ throw new CollectionInjectorManagerError(`Collection "${collectionName}" is referenced by ${references.join(", ")}. Remove those references before deleting it.`, 409);
346
+ }
347
+ delete collections[collectionName];
348
+ config.collections = collections;
349
+ writeConfig(configPath, config);
350
+ }
351
+ export function listInjectors(vaultPath) {
352
+ const { config } = readConfig(vaultPath);
353
+ return (config.injectors ?? []).map(injectorRecord);
354
+ }
355
+ export function upsertInjector(vaultPath, request) {
356
+ const injector = injectorRecord(request);
357
+ try {
358
+ new RegExp(injector.regex);
359
+ }
360
+ catch (error) {
361
+ throw new CollectionInjectorManagerError(`Invalid injector regex: ${error instanceof Error ? error.message : String(error)}`, 400);
362
+ }
363
+ if (injector.captureGroup !== undefined &&
364
+ (!Number.isInteger(injector.captureGroup) || injector.captureGroup < 0)) {
365
+ throw new CollectionInjectorManagerError("Capture group must be a non-negative integer.", 400);
366
+ }
367
+ if (injector.artifactPath)
368
+ assertVaultArtifactPath(vaultPath, injector.artifactPath);
369
+ const { configPath, config } = readConfig(vaultPath);
370
+ const collections = isRecord(config.collections) ? config.collections : {};
371
+ if (!Object.hasOwn(collections, injector.collection)) {
372
+ throw new CollectionInjectorManagerError(`Target collection "${injector.collection}" was not found.`, 400);
373
+ }
374
+ const injectors = (config.injectors ?? []).map(injectorRecord);
375
+ const existingIndex = injectors.findIndex((candidate) => candidate.name === injector.name);
376
+ if (existingIndex >= 0)
377
+ injectors[existingIndex] = injector;
378
+ else
379
+ injectors.push(injector);
380
+ config.injectors = injectors;
381
+ writeConfig(configPath, config);
382
+ return injector;
383
+ }
384
+ export function deleteInjector(vaultPath, name) {
385
+ const injectorName = requiredText(name, "Injector name");
386
+ const { configPath, config } = readConfig(vaultPath);
387
+ const injectors = (config.injectors ?? []).map(injectorRecord);
388
+ const remaining = injectors.filter((injector) => injector.name !== injectorName);
389
+ if (remaining.length === injectors.length) {
390
+ throw new CollectionInjectorManagerError(`Injector "${injectorName}" was not found.`, 404);
391
+ }
392
+ config.injectors = remaining;
393
+ writeConfig(configPath, config);
394
+ }