@bitbonsai/mcpvault 0.18.0 → 0.18.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.
@@ -15,6 +15,7 @@ export declare class FileSystemService {
15
15
  private vaultPath;
16
16
  private frontmatterHandler;
17
17
  private pathFilter;
18
+ private writeChains;
18
19
  constructor(vaultPath: string, pathFilter?: PathFilter, frontmatterHandler?: FrontmatterHandler);
19
20
  /**
20
21
  * Normalize an incoming path to be vault-relative. Strips leading slashes
@@ -23,6 +24,8 @@ export declare class FileSystemService {
23
24
  */
24
25
  private normalizePath;
25
26
  private resolvePath;
27
+ private withPathLock;
28
+ private atomicWrite;
26
29
  readNote(path: string): Promise<ParsedNote>;
27
30
  writeNote(params: NoteWriteParams): Promise<void>;
28
31
  patchNote(params: PatchNoteParams): Promise<PatchNoteResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB,EAAE,QAAQ,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAE3V;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAiBtE;AAoBD,qBAAa,iBAAiB;IAK1B,OAAO,CAAC,SAAS;IAJnB,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,UAAU,CAAa;IAE/B,YACU,SAAS,EAAE,MAAM,EACzB,UAAU,CAAC,EAAE,UAAU,EACvB,kBAAkB,CAAC,EAAE,kBAAkB,EAWxC;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAsBrB,OAAO,CAAC,WAAW;IAgDb,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA+BhD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA0EtD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA4FjE;IAEK,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA4DhE;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAEK,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAchD;IAEK,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC,CAyGhE;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA4H1D;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA+H1D;IAEK,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA+CzE;IAEK,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoCtE;IAEK,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CA0CvD;IAEK,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqG1E;IAED,YAAY,IAAI,MAAM,CAErB;IAED;;;;;;;;;;;;;;OAcG;IACG,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA+CjE;IAEK,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA+EzD;IAEK,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAehE;IAEK,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAuDhE;IAEK,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAiDlE;CACF"}
1
+ {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB,EAAE,QAAQ,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAE3V;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAiBtE;AAoBD,qBAAa,iBAAiB;IAO1B,OAAO,CAAC,SAAS;IANnB,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,UAAU,CAAa;IAE/B,OAAO,CAAC,WAAW,CAAoC;IAEvD,YACU,SAAS,EAAE,MAAM,EACzB,UAAU,CAAC,EAAE,UAAU,EACvB,kBAAkB,CAAC,EAAE,kBAAkB,EAWxC;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAsBrB,OAAO,CAAC,WAAW;YAuDL,YAAY;YAiBZ,WAAW;IAgCnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA+BhD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA4EtD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA2FjE;IAEK,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA4DhE;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAEK,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAchD;IAEK,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC,CAyGhE;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA4H1D;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA+H1D;IAEK,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA+CzE;IAEK,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBtE;IAEK,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CA0CvD;IAEK,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqG1E;IAED,YAAY,IAAI,MAAM,CAErB;IAED;;;;;;;;;;;;;;OAcG;IACG,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA+CjE;IAEK,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA+EzD;IAEK,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAehE;IAEK,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAuDhE;IAEK,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAiDlE;CACF"}
@@ -1,6 +1,7 @@
1
1
  import { join, resolve, relative, dirname } from 'path';
2
2
  import { homedir } from 'os';
3
- import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
3
+ import { readdir, stat, readFile, writeFile, open, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { constants, lstatSync, realpathSync } from 'node:fs';
5
6
  import trash from 'trash';
6
7
  import { FrontmatterHandler } from './frontmatter.js';
@@ -54,6 +55,8 @@ export class FileSystemService {
54
55
  vaultPath;
55
56
  frontmatterHandler;
56
57
  pathFilter;
58
+ // shortcut: instance-local locks only, add cross-process coordination if shared-vault writers need it.
59
+ writeChains = new Map();
57
60
  constructor(vaultPath, pathFilter, frontmatterHandler) {
58
61
  this.vaultPath = vaultPath;
59
62
  const resolved = resolve(vaultPath);
@@ -96,7 +99,7 @@ export class FileSystemService {
96
99
  }
97
100
  return p;
98
101
  }
99
- resolvePath(relativePath) {
102
+ resolvePath(relativePath, followSymlinks = false) {
100
103
  const normalizedPath = this.normalizePath(relativePath);
101
104
  const fullPath = resolve(join(this.vaultPath, normalizedPath));
102
105
  // Security check: ensure path is within vault (lexical)
@@ -112,6 +115,13 @@ export class FileSystemService {
112
115
  if (relative(this.vaultPath, realPath).startsWith('..')) {
113
116
  throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
114
117
  }
118
+ if (followSymlinks) {
119
+ const target = resolve(realPath, relative(candidate, fullPath));
120
+ if (!this.pathFilter.isAllowed(relative(this.vaultPath, target))) {
121
+ throw new Error(`Access denied: ${relativePath}. The resolved target is restricted.`);
122
+ }
123
+ return target;
124
+ }
115
125
  break;
116
126
  }
117
127
  catch (err) {
@@ -146,6 +156,61 @@ export class FileSystemService {
146
156
  }
147
157
  return fullPath;
148
158
  }
159
+ async withPathLock(path, fn) {
160
+ const fullPath = this.resolvePath(path, true);
161
+ const previous = this.writeChains.get(fullPath) ?? Promise.resolve();
162
+ let release;
163
+ const current = new Promise((resolve) => { release = resolve; });
164
+ this.writeChains.set(fullPath, current);
165
+ await previous;
166
+ try {
167
+ return await fn(fullPath);
168
+ }
169
+ finally {
170
+ release();
171
+ if (this.writeChains.get(fullPath) === current) {
172
+ this.writeChains.delete(fullPath);
173
+ }
174
+ }
175
+ }
176
+ async atomicWrite(fullPath, content) {
177
+ let mode;
178
+ try {
179
+ const target = await stat(fullPath);
180
+ if (!target.isFile()) {
181
+ throw new Error(`Cannot write non-regular file: ${relative(this.vaultPath, fullPath) || '/'}`);
182
+ }
183
+ mode = target.mode & 0o777;
184
+ await access(fullPath, constants.W_OK);
185
+ }
186
+ catch (error) {
187
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT')
188
+ throw error;
189
+ }
190
+ await mkdir(dirname(fullPath), { recursive: true });
191
+ const tmpPath = join(dirname(fullPath), `.mcpvault-${randomUUID()}.tmp`);
192
+ const file = await open(tmpPath, 'wx', mode ?? 0o666);
193
+ try {
194
+ try {
195
+ await file.writeFile(content, 'utf-8');
196
+ // Creation applies umask; existing notes must retain their exact permissions.
197
+ if (mode !== undefined)
198
+ await file.chmod(mode);
199
+ await file.sync();
200
+ }
201
+ finally {
202
+ await file.close();
203
+ }
204
+ await rename(tmpPath, fullPath);
205
+ }
206
+ catch (error) {
207
+ try {
208
+ await unlink(tmpPath);
209
+ }
210
+ catch { /* best effort */ }
211
+ throw error;
212
+ }
213
+ }
149
214
  async readNote(path) {
150
215
  path = this.normalizePath(path);
151
216
  const fullPath = this.resolvePath(path);
@@ -179,7 +244,6 @@ export class FileSystemService {
179
244
  async writeNote(params) {
180
245
  const { content, frontmatter, mode = 'overwrite' } = params;
181
246
  const path = this.normalizePath(params.path);
182
- const fullPath = this.resolvePath(path);
183
247
  if (!this.pathFilter.isAllowed(path)) {
184
248
  throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
185
249
  }
@@ -194,50 +258,54 @@ export class FileSystemService {
194
258
  throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
195
259
  }
196
260
  }
197
- try {
198
- let finalContent;
199
- if (mode === 'overwrite') {
200
- // Original behavior - replace entire content
201
- finalContent = frontmatter
202
- ? this.frontmatterHandler.stringify(frontmatter, content)
203
- : content;
204
- }
205
- else {
206
- // For append/prepend, we need to read existing content
207
- let existingNote;
208
- try {
209
- existingNote = await this.readNote(path);
210
- }
211
- catch (error) {
212
- // File doesn't exist, treat as overwrite
261
+ // Serialize the read-modify-write against concurrent writers of the same file,
262
+ // and replace the file atomically so a torn/partial file is never observed.
263
+ return this.withPathLock(path, async (fullPath) => {
264
+ try {
265
+ let finalContent;
266
+ if (mode === 'overwrite') {
267
+ // Original behavior - replace entire content
213
268
  finalContent = frontmatter
214
269
  ? this.frontmatterHandler.stringify(frontmatter, content)
215
270
  : content;
216
271
  }
217
- if (existingNote) {
218
- // Merge frontmatter if provided
219
- const mergedFrontmatter = frontmatter
220
- ? { ...existingNote.frontmatter, ...frontmatter }
221
- : existingNote.frontmatter;
222
- const mergedContent = mode === 'append'
223
- ? existingNote.content + content
224
- : content + existingNote.content;
225
- if (existingNote.matter && existingNote.matter.trim() !== '') {
226
- // Preserve raw formatting for unmodified fields by only applying explicit updates
227
- finalContent = this.frontmatterHandler.preserveStringify(existingNote.matter, frontmatter || {}, mergedContent);
272
+ else {
273
+ // For append/prepend, we need to read existing content
274
+ let existingNote;
275
+ try {
276
+ existingNote = this.frontmatterHandler.parse(await readFile(fullPath, 'utf-8'));
228
277
  }
229
- else {
230
- finalContent = this.frontmatterHandler.stringify(mergedFrontmatter, mergedContent);
278
+ catch (error) {
279
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT')
280
+ throw error;
281
+ // File doesn't exist, treat as overwrite
282
+ finalContent = frontmatter
283
+ ? this.frontmatterHandler.stringify(frontmatter, content)
284
+ : content;
285
+ }
286
+ if (existingNote) {
287
+ // Merge frontmatter if provided
288
+ const mergedFrontmatter = frontmatter
289
+ ? { ...existingNote.frontmatter, ...frontmatter }
290
+ : existingNote.frontmatter;
291
+ const mergedContent = mode === 'append'
292
+ ? existingNote.content + content
293
+ : content + existingNote.content;
294
+ if (existingNote.matter && existingNote.matter.trim() !== '') {
295
+ // Preserve raw formatting for unmodified fields by only applying explicit updates
296
+ finalContent = this.frontmatterHandler.preserveStringify(existingNote.matter, frontmatter || {}, mergedContent);
297
+ }
298
+ else {
299
+ finalContent = this.frontmatterHandler.stringify(mergedFrontmatter, mergedContent);
300
+ }
231
301
  }
232
302
  }
303
+ await this.atomicWrite(fullPath, finalContent);
233
304
  }
234
- // Create directories if they don't exist
235
- await mkdir(dirname(fullPath), { recursive: true });
236
- await writeFile(fullPath, finalContent, 'utf-8');
237
- }
238
- catch (error) {
239
- throw classifyWriteError(error, path);
240
- }
305
+ catch (error) {
306
+ throw classifyWriteError(error, path);
307
+ }
308
+ });
241
309
  }
242
310
  async patchNote(params) {
243
311
  const { oldString, newString, replaceAll = false } = params;
@@ -273,44 +341,44 @@ export class FileSystemService {
273
341
  };
274
342
  }
275
343
  try {
276
- // Read the existing note
277
- const note = await this.readNote(path);
278
- // Get the full content with frontmatter
279
- const fullContent = note.originalContent;
280
- // Count occurrences of oldString
281
- const occurrences = fullContent.split(oldString).length - 1;
282
- if (occurrences === 0) {
283
- return {
284
- success: false,
285
- path,
286
- message: `String not found in note: "${oldString.substring(0, 50)}${oldString.length > 50 ? '...' : ''}"`,
287
- matchCount: 0
288
- };
289
- }
290
- // If not replaceAll and multiple occurrences exist, fail
291
- if (!replaceAll && occurrences > 1) {
344
+ return await this.withPathLock(path, async (fullPath) => {
345
+ const note = await this.readNote(fullPath);
346
+ // Get the full content with frontmatter
347
+ const fullContent = note.originalContent;
348
+ // Count occurrences of oldString
349
+ const occurrences = fullContent.split(oldString).length - 1;
350
+ if (occurrences === 0) {
351
+ return {
352
+ success: false,
353
+ path,
354
+ message: `String not found in note: "${oldString.substring(0, 50)}${oldString.length > 50 ? '...' : ''}"`,
355
+ matchCount: 0
356
+ };
357
+ }
358
+ // If not replaceAll and multiple occurrences exist, fail
359
+ if (!replaceAll && occurrences > 1) {
360
+ return {
361
+ success: false,
362
+ path,
363
+ message: `Found ${occurrences} occurrences of the string. Use replaceAll=true to replace all occurrences, or provide a more specific string to match exactly one occurrence.`,
364
+ matchCount: occurrences
365
+ };
366
+ }
367
+ // Perform the replacement
368
+ // Use a replacer function so newString is inserted literally,
369
+ // without $ replacement pattern expansion ($$, $&, $`, $')
370
+ const updatedContent = replaceAll
371
+ ? fullContent.split(oldString).join(newString)
372
+ : fullContent.replace(oldString, () => newString);
373
+ // Write the updated content atomically
374
+ await this.atomicWrite(fullPath, updatedContent);
292
375
  return {
293
- success: false,
376
+ success: true,
294
377
  path,
295
- message: `Found ${occurrences} occurrences of the string. Use replaceAll=true to replace all occurrences, or provide a more specific string to match exactly one occurrence.`,
378
+ message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
296
379
  matchCount: occurrences
297
380
  };
298
- }
299
- // Perform the replacement
300
- // Use a replacer function so newString is inserted literally,
301
- // without $ replacement pattern expansion ($$, $&, $`, $')
302
- const updatedContent = replaceAll
303
- ? fullContent.split(oldString).join(newString)
304
- : fullContent.replace(oldString, () => newString);
305
- // Write the updated content
306
- const fullPath = this.resolvePath(path);
307
- await writeFile(fullPath, updatedContent, 'utf-8');
308
- return {
309
- success: true,
310
- path,
311
- message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
312
- matchCount: occurrences
313
- };
381
+ });
314
382
  }
315
383
  catch (error) {
316
384
  return {
@@ -791,31 +859,20 @@ export class FileSystemService {
791
859
  if (!this.pathFilter.isAllowed(path)) {
792
860
  throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
793
861
  }
794
- // Read the existing note
795
- const note = await this.readNote(path);
796
- // Prepare new frontmatter
797
- const newFrontmatter = merge
798
- ? { ...note.frontmatter, ...frontmatter }
799
- : frontmatter;
800
- // Validate the new frontmatter
801
- const validation = this.frontmatterHandler.validate(newFrontmatter);
802
- if (!validation.isValid) {
803
- throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
804
- }
805
- const fullPath = this.resolvePath(path);
806
- if (merge && note.matter && note.matter.trim() !== '') {
807
- // Preserve raw formatting for unmodified fields
808
- const updatedContent = this.frontmatterHandler.preserveStringify(note.matter, frontmatter, note.content);
809
- await writeFile(fullPath, updatedContent, 'utf-8');
810
- }
811
- else {
812
- // Replace frontmatter entirely (or no existing matter to preserve)
813
- await this.writeNote({
814
- path,
815
- content: note.content,
816
- frontmatter: newFrontmatter
817
- });
818
- }
862
+ return this.withPathLock(path, async (fullPath) => {
863
+ const note = await this.readNote(fullPath);
864
+ const newFrontmatter = merge
865
+ ? { ...note.frontmatter, ...frontmatter }
866
+ : frontmatter;
867
+ const validation = this.frontmatterHandler.validate(newFrontmatter);
868
+ if (!validation.isValid) {
869
+ throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
870
+ }
871
+ const updatedContent = merge && note.matter && note.matter.trim() !== ''
872
+ ? this.frontmatterHandler.preserveStringify(note.matter, frontmatter, note.content)
873
+ : this.frontmatterHandler.stringify(newFrontmatter, note.content);
874
+ await this.atomicWrite(fullPath, updatedContent);
875
+ });
819
876
  }
820
877
  async getNotesInfo(paths) {
821
878
  const results = await Promise.allSettled(paths.map(async (rawPath) => {
@@ -866,72 +923,73 @@ export class FileSystemService {
866
923
  };
867
924
  }
868
925
  try {
869
- const note = await this.readNote(path);
870
- let currentTags = [];
871
- // Extract tags from frontmatter
872
- if (note.frontmatter.tags) {
873
- if (Array.isArray(note.frontmatter.tags)) {
874
- currentTags = note.frontmatter.tags;
926
+ return await this.withPathLock(path, async (fullPath) => {
927
+ const note = await this.readNote(fullPath);
928
+ let currentTags = [];
929
+ // Extract tags from frontmatter
930
+ if (note.frontmatter.tags) {
931
+ if (Array.isArray(note.frontmatter.tags)) {
932
+ currentTags = note.frontmatter.tags;
933
+ }
934
+ else if (typeof note.frontmatter.tags === 'string') {
935
+ currentTags = [note.frontmatter.tags];
936
+ }
875
937
  }
876
- else if (typeof note.frontmatter.tags === 'string') {
877
- currentTags = [note.frontmatter.tags];
938
+ // Also extract inline tags from content
939
+ const inlineTagMatches = note.content.match(/#[a-zA-Z0-9_-]+/g) || [];
940
+ const inlineTags = inlineTagMatches.map(tag => tag.slice(1)); // Remove #
941
+ currentTags = [...new Set([...currentTags, ...inlineTags])]; // Deduplicate
942
+ if (operation === 'list') {
943
+ return {
944
+ path,
945
+ operation,
946
+ tags: currentTags,
947
+ success: true
948
+ };
878
949
  }
879
- }
880
- // Also extract inline tags from content
881
- const inlineTagMatches = note.content.match(/#[a-zA-Z0-9_-]+/g) || [];
882
- const inlineTags = inlineTagMatches.map(tag => tag.slice(1)); // Remove #
883
- currentTags = [...new Set([...currentTags, ...inlineTags])]; // Deduplicate
884
- if (operation === 'list') {
885
- return {
886
- path,
887
- operation,
888
- tags: currentTags,
889
- success: true
890
- };
891
- }
892
- let newTags = [...currentTags];
893
- if (operation === 'add') {
894
- for (const tag of tags) {
895
- if (!newTags.includes(tag)) {
896
- newTags.push(tag);
950
+ let newTags = [...currentTags];
951
+ if (operation === 'add') {
952
+ for (const tag of tags) {
953
+ if (!newTags.includes(tag)) {
954
+ newTags.push(tag);
955
+ }
897
956
  }
898
957
  }
899
- }
900
- else if (operation === 'remove') {
901
- newTags = newTags.filter(tag => !tags.includes(tag));
902
- }
903
- // Build tag updates for preserveStringify
904
- const tagUpdates = {};
905
- if (newTags.length > 0) {
906
- tagUpdates.tags = newTags;
907
- }
908
- else {
909
- tagUpdates.tags = undefined;
910
- }
911
- // Write back the note with updated frontmatter, preserving raw formatting for unmodified fields
912
- let updatedContent;
913
- if (note.matter && note.matter.trim() !== '') {
914
- updatedContent = this.frontmatterHandler.preserveStringify(note.matter, tagUpdates, note.content);
915
- }
916
- else {
917
- const updatedFrontmatter = { ...note.frontmatter };
958
+ else if (operation === 'remove') {
959
+ newTags = newTags.filter(tag => !tags.includes(tag));
960
+ }
961
+ // Build tag updates for preserveStringify
962
+ const tagUpdates = {};
918
963
  if (newTags.length > 0) {
919
- updatedFrontmatter.tags = newTags;
964
+ tagUpdates.tags = newTags;
920
965
  }
921
966
  else {
922
- delete updatedFrontmatter.tags;
967
+ tagUpdates.tags = undefined;
923
968
  }
924
- updatedContent = this.frontmatterHandler.stringify(updatedFrontmatter, note.content);
925
- }
926
- const fullPath = this.resolvePath(path);
927
- await writeFile(fullPath, updatedContent, 'utf-8');
928
- return {
929
- path,
930
- operation,
931
- tags: newTags,
932
- success: true,
933
- message: `Successfully ${operation === 'add' ? 'added' : 'removed'} tags`
934
- };
969
+ // Write back the note with updated frontmatter, preserving raw formatting for unmodified fields
970
+ let updatedContent;
971
+ if (note.matter && note.matter.trim() !== '') {
972
+ updatedContent = this.frontmatterHandler.preserveStringify(note.matter, tagUpdates, note.content);
973
+ }
974
+ else {
975
+ const updatedFrontmatter = { ...note.frontmatter };
976
+ if (newTags.length > 0) {
977
+ updatedFrontmatter.tags = newTags;
978
+ }
979
+ else {
980
+ delete updatedFrontmatter.tags;
981
+ }
982
+ updatedContent = this.frontmatterHandler.stringify(updatedFrontmatter, note.content);
983
+ }
984
+ await this.atomicWrite(fullPath, updatedContent);
985
+ return {
986
+ path,
987
+ operation,
988
+ tags: newTags,
989
+ success: true,
990
+ message: `Successfully ${operation === 'add' ? 'added' : 'removed'} tags`
991
+ };
992
+ });
935
993
  }
936
994
  catch (error) {
937
995
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbonsai/mcpvault",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
5
5
  "homepage": "https://mcpvault.org",
6
6
  "author": "bitbonsai",