@bitbonsai/mcpvault 0.18.0 → 0.18.2

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/README.md CHANGED
@@ -998,7 +998,7 @@ Only grant write access to clients and conversations you trust. Use `--read-only
998
998
  - `server.ts` - MCP server entry point
999
999
  - `src/cli.ts` - CLI option parsing for vault path, read-only mode, and default exclusions
1000
1000
  - `src/createServer.ts` - MCP tool registration, request routing, and discovery defaults
1001
- - `src/frontmatter.ts` - YAML frontmatter handling with gray-matter
1001
+ - `src/frontmatter.ts` - YAML frontmatter handling with gray-matter, preserving formatting and CRLF line endings; executable JavaScript frontmatter stays inert as note content
1002
1002
  - `src/filesystem.ts` - File operations with path validation
1003
1003
  - `src/pathfilter.ts` - Directory and file filtering
1004
1004
  - `src/search.ts` - Note search functionality with content and frontmatter support
@@ -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 {
@@ -1 +1 @@
1
- {"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/frontmatter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAkB1E;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAmB5E;AAED,qBAAa,kBAAkB;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CAkBjC;IAED,SAAS,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAWvE;IAED,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,2BAA2B,CAmB1E;IAED,OAAO,CAAC,yBAAyB;IAmDjC,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAuB1F;IAED,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAGvD;IAED,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAUvE;CACF"}
1
+ {"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/frontmatter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AA0C1E;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAmB5E;AAED,qBAAa,kBAAkB;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CAkBjC;IAED,SAAS,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAcvE;IAED,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,2BAA2B,CAmB1E;IAED,OAAO,CAAC,yBAAyB;IAmDjC,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAyB1F;IAED,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAGvD;IAED,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAUvE;CACF"}
@@ -8,11 +8,32 @@ const yamlEngine = {
8
8
  // YAML 1.2 schema (default). Dates stay as strings, but this avoids the
9
9
  // yaml-1.1 bug where single-letter keys like 'y'/'n' become booleans.
10
10
  // Merge keys (<<) are not resolved, which is fine for Obsidian frontmatter.
11
- parse: (str) => parse(str),
11
+ parse: (str) => parse(toLf(str)),
12
12
  stringify: (data) => stringify(data),
13
13
  };
14
14
  function withYamlEngine(options = {}) {
15
- return { ...options, engines: { yaml: yamlEngine } };
15
+ return {
16
+ ...options,
17
+ engines: {
18
+ yaml: yamlEngine,
19
+ // gray-matter merges default engines; override JavaScript to prevent eval, including its js alias.
20
+ javascript: {
21
+ parse: () => { throw new Error('JavaScript frontmatter is not supported'); },
22
+ },
23
+ },
24
+ };
25
+ }
26
+ /** Use CRLF when present, otherwise LF. */
27
+ function detectEol(text) {
28
+ return text.includes('\r\n') ? '\r\n' : '\n';
29
+ }
30
+ // gray-matter leaves a trailing CR before the closing delimiter; normalize before YAML parsing.
31
+ function toLf(text) {
32
+ return text.replace(/\r\n?/g, '\n');
33
+ }
34
+ /** Re-apply CRLF to LF-only text. */
35
+ function applyEol(text, eol) {
36
+ return eol === '\r\n' ? text.replace(/\n/g, '\r\n') : text;
16
37
  }
17
38
  /**
18
39
  * Parse a frontmatter value that may be a JSON string (LLM clients sometimes
@@ -67,7 +88,10 @@ export class FrontmatterHandler {
67
88
  if (!frontmatterData || Object.keys(frontmatterData).length === 0) {
68
89
  return content;
69
90
  }
70
- return matter.stringify(content, frontmatterData, withYamlEngine());
91
+ const eol = detectEol(content);
92
+ // Serialize only the header so body bytes stay untouched; drop gray-matter's empty-body newline.
93
+ const header = matter.stringify('', frontmatterData, withYamlEngine()).slice(0, -1);
94
+ return applyEol(header, eol) + content + (content.endsWith('\n') ? '' : eol);
71
95
  }
72
96
  catch (error) {
73
97
  throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -137,9 +161,11 @@ export class FrontmatterHandler {
137
161
  if (!updates || Object.keys(updates).length === 0) {
138
162
  return content;
139
163
  }
140
- return matter.stringify(content, updates, withYamlEngine());
164
+ return this.stringify(updates, content);
141
165
  }
142
- const doc = parseDocument(rawMatter.trimStart());
166
+ // Normalize only YAML; preserve the body's original bytes.
167
+ const eol = rawMatter.includes('\r\n') ? '\r\n' : detectEol(content);
168
+ const doc = parseDocument(toLf(rawMatter).trimStart());
143
169
  for (const [key, value] of Object.entries(updates)) {
144
170
  if (value === undefined) {
145
171
  doc.delete(key);
@@ -148,8 +174,8 @@ export class FrontmatterHandler {
148
174
  doc.set(key, value);
149
175
  }
150
176
  }
151
- const yamlContent = doc.toString();
152
- return `---\n${yamlContent}---\n${content}`;
177
+ const yamlContent = applyEol(doc.toString(), eol);
178
+ return `---${eol}${yamlContent}---${eol}${content}`;
153
179
  }
154
180
  catch (error) {
155
181
  throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbonsai/mcpvault",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
5
5
  "homepage": "https://mcpvault.org",
6
6
  "author": "bitbonsai",