@bitbonsai/mcpvault 0.16.1 → 0.17.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.
- package/README.md +17 -0
- package/dist/server.js +6 -3
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +19 -0
- package/dist/src/createServer.d.ts +2 -0
- package/dist/src/createServer.d.ts.map +1 -1
- package/dist/src/createServer.js +36 -5
- package/dist/src/filesystem.d.ts.map +1 -1
- package/dist/src/filesystem.js +78 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -258,6 +258,21 @@ Add `--read-only` after the vault path to expose only read tools. Mutating tools
|
|
|
258
258
|
|
|
259
259
|
The CLI also accepts `--read-only true` and `--read-only=true` for configuration systems that require explicit values. Omit the option, or set it to `false`, to keep normal read/write access.
|
|
260
260
|
|
|
261
|
+
**Default search and listing exclusions:**
|
|
262
|
+
|
|
263
|
+
Repeat `--exclude <subtree>` to hide vault-relative folders from `search_notes` and `list_directory` in every session. Per-call `excludePaths` add to these defaults. Direct read and write tools remain available for excluded paths.
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
"args": [
|
|
267
|
+
"@bitbonsai/mcpvault@latest",
|
|
268
|
+
"/Users/yourname/Documents/ResearchVault",
|
|
269
|
+
"--exclude", "Archive",
|
|
270
|
+
"--exclude", "Snapshots/Old"
|
|
271
|
+
]
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
MCPVault does not read Obsidian's `.obsidian/app.json`; exclusions stay explicit in the MCP server configuration.
|
|
275
|
+
|
|
261
276
|
**Configuration File Locations:**
|
|
262
277
|
|
|
263
278
|
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
@@ -981,6 +996,8 @@ Only grant write access to clients and conversations you trust. Use `--read-only
|
|
|
981
996
|
## Architecture
|
|
982
997
|
|
|
983
998
|
- `server.ts` - MCP server entry point
|
|
999
|
+
- `src/cli.ts` - CLI option parsing for vault path, read-only mode, and default exclusions
|
|
1000
|
+
- `src/createServer.ts` - MCP tool registration, request routing, and discovery defaults
|
|
984
1001
|
- `src/frontmatter.ts` - YAML frontmatter handling with gray-matter
|
|
985
1002
|
- `src/filesystem.ts` - File operations with path validation
|
|
986
1003
|
- `src/pathfilter.ts` - Directory and file filtering
|
package/dist/server.js
CHANGED
|
@@ -24,7 +24,7 @@ mcpvault v${VERSION}
|
|
|
24
24
|
Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant
|
|
25
25
|
|
|
26
26
|
Usage:
|
|
27
|
-
npx @bitbonsai/mcpvault [vault-path] [--read-only[=true|false]]
|
|
27
|
+
npx @bitbonsai/mcpvault [vault-path] [--read-only[=true|false]] [--exclude <subtree>]...
|
|
28
28
|
|
|
29
29
|
Arguments:
|
|
30
30
|
[vault-path] Optional path to your Obsidian vault directory
|
|
@@ -35,12 +35,15 @@ Options:
|
|
|
35
35
|
--help, -h Show this help message
|
|
36
36
|
--read-only Expose read tools only and reject all vault mutations
|
|
37
37
|
May be passed alone, with true/false, or as --read-only=true
|
|
38
|
+
--exclude PATH Hide a vault-relative subtree from search and directory listings
|
|
39
|
+
Repeat for multiple subtrees; direct read/write tools are unaffected
|
|
38
40
|
|
|
39
41
|
Examples:
|
|
40
42
|
npx @bitbonsai/mcpvault
|
|
41
43
|
npx @bitbonsai/mcpvault ~/Documents/MyVault
|
|
42
44
|
npx @bitbonsai/mcpvault ./Vault
|
|
43
45
|
npx @bitbonsai/mcpvault ./Vault --read-only
|
|
46
|
+
npx @bitbonsai/mcpvault ./Vault --exclude Archive --exclude "Snapshots/Old"
|
|
44
47
|
npx @bitbonsai/mcpvault /path/to/obsidian/vault
|
|
45
48
|
npx @bitbonsai/mcpvault "/path/with spaces/Obsidian Vault"
|
|
46
49
|
`);
|
|
@@ -48,11 +51,11 @@ Examples:
|
|
|
48
51
|
}
|
|
49
52
|
// Remove runtime options before joining trailing args, preserving support for
|
|
50
53
|
// unquoted vault paths with spaces. When omitted, use the current directory.
|
|
51
|
-
const { vaultPathArg, readOnly } = parseCliArgs(cliArgs);
|
|
54
|
+
const { vaultPathArg, readOnly, excludePaths } = parseCliArgs(cliArgs);
|
|
52
55
|
const vaultPath = resolve(vaultPathArg || process.cwd());
|
|
53
56
|
// Serve both the legacy handshake-based protocol and MCP 2026-07-28 from the
|
|
54
57
|
// same process. The opening exchange selects the era for this connection.
|
|
55
|
-
const serverHandle = serveStdio(() => createServer(vaultPath, { version: VERSION, readOnly }), { onerror: (error) => console.error(error) });
|
|
58
|
+
const serverHandle = serveStdio(() => createServer(vaultPath, { version: VERSION, readOnly, excludePaths }), { onerror: (error) => console.error(error) });
|
|
56
59
|
// Exit when the client disconnects (stdin EOF) or the process is asked to
|
|
57
60
|
// terminate. Hosts that don't send an MCP shutdown request otherwise leave
|
|
58
61
|
// this process running forever, orphaned once stdin closes (#159).
|
package/dist/src/cli.d.ts
CHANGED
package/dist/src/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,aAAa,CAuD1D"}
|
package/dist/src/cli.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export function parseCliArgs(args) {
|
|
7
7
|
const pathArgs = [];
|
|
8
|
+
const excludePaths = [];
|
|
8
9
|
let readOnly = false;
|
|
9
10
|
for (let index = 0; index < args.length; index += 1) {
|
|
10
11
|
const arg = args[index];
|
|
@@ -27,10 +28,28 @@ export function parseCliArgs(args) {
|
|
|
27
28
|
readOnly = value === "true";
|
|
28
29
|
continue;
|
|
29
30
|
}
|
|
31
|
+
if (arg === "--exclude") {
|
|
32
|
+
const value = args[index + 1]?.trim();
|
|
33
|
+
if (!value || value.startsWith("--")) {
|
|
34
|
+
throw new Error("--exclude requires a subtree path");
|
|
35
|
+
}
|
|
36
|
+
excludePaths.push(value);
|
|
37
|
+
index += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg.startsWith("--exclude=")) {
|
|
41
|
+
const value = arg.slice("--exclude=".length).trim();
|
|
42
|
+
if (!value) {
|
|
43
|
+
throw new Error("--exclude requires a subtree path");
|
|
44
|
+
}
|
|
45
|
+
excludePaths.push(value);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
30
48
|
pathArgs.push(arg);
|
|
31
49
|
}
|
|
32
50
|
return {
|
|
33
51
|
vaultPathArg: pathArgs.join(" ").trim(),
|
|
34
52
|
readOnly,
|
|
53
|
+
excludePaths,
|
|
35
54
|
};
|
|
36
55
|
}
|
|
@@ -8,6 +8,8 @@ export interface CreateServerOptions {
|
|
|
8
8
|
frontmatterHandler?: FrontmatterHandler;
|
|
9
9
|
/** Expose read tools only and reject direct calls to mutating tools. */
|
|
10
10
|
readOnly?: boolean;
|
|
11
|
+
/** Vault-relative subtrees hidden from search and directory listings. */
|
|
12
|
+
excludePaths?: string[];
|
|
11
13
|
}
|
|
12
14
|
export declare function createServer(vaultPath: string, options?: CreateServerOptions): Server;
|
|
13
15
|
//# sourceMappingURL=createServer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAa,MAAM,8BAA8B,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAK7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAa,MAAM,8BAA8B,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAK7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAoCD,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAofzF"}
|
package/dist/src/createServer.js
CHANGED
|
@@ -4,7 +4,7 @@ import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
|
|
|
4
4
|
import { PathFilter } from "./pathfilter.js";
|
|
5
5
|
import { SearchService } from "./search.js";
|
|
6
6
|
import { handleWikiLinkTool } from "./wikilink/index.js";
|
|
7
|
-
import { resolve } from "path";
|
|
7
|
+
import { posix, resolve } from "path";
|
|
8
8
|
const MUTATING_TOOLS = new Set([
|
|
9
9
|
"write_note",
|
|
10
10
|
"patch_note",
|
|
@@ -14,8 +14,29 @@ const MUTATING_TOOLS = new Set([
|
|
|
14
14
|
"update_frontmatter",
|
|
15
15
|
"manage_tags",
|
|
16
16
|
]);
|
|
17
|
+
function normalizeSubtree(path) {
|
|
18
|
+
let relativePath = path.trim().replace(/\\/g, "/");
|
|
19
|
+
while (relativePath.startsWith("/"))
|
|
20
|
+
relativePath = relativePath.slice(1);
|
|
21
|
+
const normalizedPath = posix.normalize(relativePath);
|
|
22
|
+
if (normalizedPath === ".")
|
|
23
|
+
return "";
|
|
24
|
+
return normalizedPath.endsWith("/") ? normalizedPath.slice(0, -1) : normalizedPath;
|
|
25
|
+
}
|
|
26
|
+
function mergeExcludePaths(defaults, requested) {
|
|
27
|
+
const provided = Array.isArray(requested) ? requested : [];
|
|
28
|
+
return [...new Set([...defaults, ...provided]
|
|
29
|
+
.filter((path) => typeof path === "string")
|
|
30
|
+
.map(normalizeSubtree)
|
|
31
|
+
.filter(Boolean))];
|
|
32
|
+
}
|
|
33
|
+
function isExcluded(path, excludePaths) {
|
|
34
|
+
const normalizedPath = normalizeSubtree(path);
|
|
35
|
+
return excludePaths.some((excluded) => normalizedPath === excluded || normalizedPath.startsWith(`${excluded}/`));
|
|
36
|
+
}
|
|
17
37
|
export function createServer(vaultPath, options = {}) {
|
|
18
|
-
const { name = "mcpvault", version = "0.0.0", pathFilter = new PathFilter(), frontmatterHandler = new FrontmatterHandler(), readOnly = false, } = options;
|
|
38
|
+
const { name = "mcpvault", version = "0.0.0", pathFilter = new PathFilter(), frontmatterHandler = new FrontmatterHandler(), readOnly = false, excludePaths = [], } = options;
|
|
39
|
+
const defaultExcludePaths = mergeExcludePaths([], excludePaths);
|
|
19
40
|
const resolvedVaultPath = resolve(vaultPath);
|
|
20
41
|
const fileSystem = new FileSystemService(resolvedVaultPath, pathFilter, frontmatterHandler);
|
|
21
42
|
const searchService = new SearchService(resolvedVaultPath, pathFilter);
|
|
@@ -317,10 +338,17 @@ export function createServer(vaultPath, options = {}) {
|
|
|
317
338
|
};
|
|
318
339
|
}
|
|
319
340
|
case "list_directory": {
|
|
320
|
-
const
|
|
341
|
+
const requestedPath = normalizeSubtree(trimmedArgs.path || "");
|
|
342
|
+
if (isExcluded(requestedPath, defaultExcludePaths)) {
|
|
343
|
+
throw new Error(`Directory excluded by server configuration: ${requestedPath}`);
|
|
344
|
+
}
|
|
345
|
+
const listing = await fileSystem.listDirectory(trimmedArgs.path || "");
|
|
346
|
+
const prefix = requestedPath ? `${requestedPath}/` : "";
|
|
347
|
+
const directories = listing.directories.filter((name) => !isExcluded(`${prefix}${name}`, defaultExcludePaths));
|
|
348
|
+
const files = listing.files.filter((name) => !isExcluded(`${prefix}${name}`, defaultExcludePaths));
|
|
321
349
|
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
|
|
322
350
|
return {
|
|
323
|
-
content: [{ type: "text", text: JSON.stringify({ dirs:
|
|
351
|
+
content: [{ type: "text", text: JSON.stringify({ dirs: directories, files }, null, indent) }]
|
|
324
352
|
};
|
|
325
353
|
}
|
|
326
354
|
case "delete_note": {
|
|
@@ -342,7 +370,7 @@ export function createServer(vaultPath, options = {}) {
|
|
|
342
370
|
searchFrontmatter: trimmedArgs.searchFrontmatter,
|
|
343
371
|
caseSensitive: trimmedArgs.caseSensitive,
|
|
344
372
|
pathPrefix: trimmedArgs.pathPrefix,
|
|
345
|
-
excludePaths: trimmedArgs.excludePaths
|
|
373
|
+
excludePaths: mergeExcludePaths(defaultExcludePaths, trimmedArgs.excludePaths)
|
|
346
374
|
});
|
|
347
375
|
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
|
|
348
376
|
return {
|
|
@@ -487,5 +515,8 @@ function trimPaths(args) {
|
|
|
487
515
|
if (trimmed.paths && Array.isArray(trimmed.paths)) {
|
|
488
516
|
trimmed.paths = trimmed.paths.map((p) => typeof p === 'string' ? p.trim() : p);
|
|
489
517
|
}
|
|
518
|
+
if (trimmed.excludePaths && Array.isArray(trimmed.excludePaths)) {
|
|
519
|
+
trimmed.excludePaths = trimmed.excludePaths.map((p) => typeof p === 'string' ? p.trim() : p);
|
|
520
|
+
}
|
|
490
521
|
return trimmed;
|
|
491
522
|
}
|
|
@@ -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;
|
|
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"}
|
package/dist/src/filesystem.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join, resolve, relative, dirname } from 'path';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
|
|
4
|
-
import { constants, realpathSync } from 'node:fs';
|
|
4
|
+
import { constants, lstatSync, realpathSync } from 'node:fs';
|
|
5
5
|
import trash from 'trash';
|
|
6
6
|
import { FrontmatterHandler } from './frontmatter.js';
|
|
7
7
|
import { PathFilter } from './pathfilter.js';
|
|
@@ -104,46 +104,45 @@ export class FileSystemService {
|
|
|
104
104
|
if (relativeToVault.startsWith('..')) {
|
|
105
105
|
throw new Error(`Path traversal not allowed: ${relativePath}. Paths must be within the vault directory.`);
|
|
106
106
|
}
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
// Walk missing parents to the nearest existing ancestor, without following dangling links.
|
|
108
|
+
let candidate = fullPath;
|
|
109
|
+
while (true) {
|
|
110
|
+
try {
|
|
111
|
+
const realPath = realpathSync(candidate);
|
|
112
|
+
if (relative(this.vaultPath, realPath).startsWith('..')) {
|
|
113
|
+
throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
113
116
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (code === 'ENOENT') {
|
|
119
|
-
// File doesn't exist yet (e.g. writing a new note). Verify the parent directory resolves inside vault.
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (!(err instanceof Error) || !('code' in err))
|
|
119
|
+
throw err;
|
|
120
|
+
if (err.code === 'ENOENT') {
|
|
120
121
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (parentRelative.startsWith('..')) {
|
|
124
|
-
throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
|
|
122
|
+
if (lstatSync(candidate).isSymbolicLink()) {
|
|
123
|
+
throw new Error(`Cannot resolve symlink: ${relativePath}. Symbolic links must point to an existing path within the vault.`);
|
|
125
124
|
}
|
|
126
125
|
}
|
|
127
|
-
catch (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
throw parentErr;
|
|
126
|
+
catch (linkError) {
|
|
127
|
+
if (!(linkError instanceof Error) || !('code' in linkError) || linkError.code !== 'ENOENT') {
|
|
128
|
+
throw linkError;
|
|
131
129
|
}
|
|
132
130
|
}
|
|
131
|
+
const parent = dirname(candidate);
|
|
132
|
+
if (candidate === this.vaultPath || parent === candidate)
|
|
133
|
+
throw err;
|
|
134
|
+
candidate = parent;
|
|
133
135
|
}
|
|
134
|
-
else if (code === 'ELOOP') {
|
|
136
|
+
else if (err.code === 'ELOOP') {
|
|
135
137
|
throw new Error(`Circular symlink detected: ${relativePath}. The symbolic link chain forms a loop.`);
|
|
136
138
|
}
|
|
137
|
-
else if (code === 'EACCES') {
|
|
139
|
+
else if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
138
140
|
throw new Error(`Permission denied resolving symlink: ${relativePath}. Cannot verify the symbolic link target is within the vault.`);
|
|
139
141
|
}
|
|
140
142
|
else {
|
|
141
143
|
throw err;
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
|
-
else {
|
|
145
|
-
throw err;
|
|
146
|
-
}
|
|
147
146
|
}
|
|
148
147
|
return fullPath;
|
|
149
148
|
}
|
|
@@ -527,20 +526,43 @@ export class FileSystemService {
|
|
|
527
526
|
}
|
|
528
527
|
const oldFullPath = this.resolvePath(oldPath);
|
|
529
528
|
const newFullPath = this.resolvePath(newPath);
|
|
529
|
+
if (oldFullPath === newFullPath) {
|
|
530
|
+
return {
|
|
531
|
+
success: false,
|
|
532
|
+
oldPath,
|
|
533
|
+
newPath,
|
|
534
|
+
message: `Source and target paths are the same: ${oldPath}. Please provide a different target path.`
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const sourceDirectoryResult = {
|
|
538
|
+
success: false,
|
|
539
|
+
oldPath,
|
|
540
|
+
newPath,
|
|
541
|
+
message: `Source path is a directory: ${oldPath}. Please provide a file path.`
|
|
542
|
+
};
|
|
543
|
+
const targetDirectoryResult = {
|
|
544
|
+
success: false,
|
|
545
|
+
oldPath,
|
|
546
|
+
newPath,
|
|
547
|
+
message: `Target path is a directory: ${newPath}. Please provide a file path.`
|
|
548
|
+
};
|
|
549
|
+
const sourceMissingResult = {
|
|
550
|
+
success: false,
|
|
551
|
+
oldPath,
|
|
552
|
+
newPath,
|
|
553
|
+
message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
|
|
554
|
+
};
|
|
530
555
|
try {
|
|
531
|
-
// Read source content (will throw ENOENT if not found)
|
|
532
556
|
let content;
|
|
533
557
|
try {
|
|
534
558
|
content = await readFile(oldFullPath, 'utf-8');
|
|
535
559
|
}
|
|
536
560
|
catch (error) {
|
|
537
561
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
538
|
-
return
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
|
|
543
|
-
};
|
|
562
|
+
return sourceMissingResult;
|
|
563
|
+
}
|
|
564
|
+
if (error instanceof Error && 'code' in error && error.code === 'EISDIR') {
|
|
565
|
+
return sourceDirectoryResult;
|
|
544
566
|
}
|
|
545
567
|
throw error;
|
|
546
568
|
}
|
|
@@ -557,13 +579,29 @@ export class FileSystemService {
|
|
|
557
579
|
}
|
|
558
580
|
}
|
|
559
581
|
catch (error) {
|
|
560
|
-
if (error instanceof Error && 'code' in error
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
582
|
+
if (error instanceof Error && 'code' in error) {
|
|
583
|
+
if (error.code === 'EISDIR') {
|
|
584
|
+
return targetDirectoryResult;
|
|
585
|
+
}
|
|
586
|
+
// Inspect only after a failed write; these codes also cover Windows directories.
|
|
587
|
+
if (error.code === 'EEXIST' || error.code === 'EPERM' || error.code === 'EACCES') {
|
|
588
|
+
try {
|
|
589
|
+
if ((await stat(newFullPath)).isDirectory()) {
|
|
590
|
+
return targetDirectoryResult;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Preserve the write error if the target changed or cannot be inspected.
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (error.code === 'EEXIST') {
|
|
598
|
+
return {
|
|
599
|
+
success: false,
|
|
600
|
+
oldPath,
|
|
601
|
+
newPath,
|
|
602
|
+
message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
|
|
603
|
+
};
|
|
604
|
+
}
|
|
567
605
|
}
|
|
568
606
|
throw error;
|
|
569
607
|
}
|
package/package.json
CHANGED