@j0hanz/filesystem-mcp 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +32 -26
  2. package/dist/cli-help.d.ts.map +1 -1
  3. package/dist/cli-help.js +10 -1
  4. package/dist/cli-help.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +10 -3
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/concurrency.d.ts +4 -4
  9. package/dist/core/concurrency.d.ts.map +1 -1
  10. package/dist/core/concurrency.js +1 -1
  11. package/dist/core/concurrency.js.map +1 -1
  12. package/dist/core/config.d.ts +2 -0
  13. package/dist/core/config.d.ts.map +1 -1
  14. package/dist/core/config.js.map +1 -1
  15. package/dist/core/file-uri.d.ts +12 -3
  16. package/dist/core/file-uri.d.ts.map +1 -1
  17. package/dist/core/file-uri.js +35 -7
  18. package/dist/core/file-uri.js.map +1 -1
  19. package/dist/core/fmt.d.ts +2 -0
  20. package/dist/core/fmt.d.ts.map +1 -1
  21. package/dist/core/fmt.js +16 -0
  22. package/dist/core/fmt.js.map +1 -1
  23. package/dist/core/fs.d.ts +16 -1
  24. package/dist/core/fs.d.ts.map +1 -1
  25. package/dist/core/fs.js +127 -32
  26. package/dist/core/fs.js.map +1 -1
  27. package/dist/core/glob.d.ts +8 -0
  28. package/dist/core/glob.d.ts.map +1 -1
  29. package/dist/core/glob.js +84 -11
  30. package/dist/core/glob.js.map +1 -1
  31. package/dist/core/path-completer.d.ts.map +1 -1
  32. package/dist/core/path-completer.js +5 -3
  33. package/dist/core/path-completer.js.map +1 -1
  34. package/dist/core/path-utils.d.ts +42 -6
  35. package/dist/core/path-utils.d.ts.map +1 -1
  36. package/dist/core/path-utils.js +56 -10
  37. package/dist/core/path-utils.js.map +1 -1
  38. package/dist/core/path.d.ts +111 -7
  39. package/dist/core/path.d.ts.map +1 -1
  40. package/dist/core/path.js +453 -65
  41. package/dist/core/path.js.map +1 -1
  42. package/dist/core/read.d.ts +6 -0
  43. package/dist/core/read.d.ts.map +1 -1
  44. package/dist/core/read.js +26 -3
  45. package/dist/core/read.js.map +1 -1
  46. package/dist/core/schema.d.ts +9 -6
  47. package/dist/core/schema.d.ts.map +1 -1
  48. package/dist/core/schema.js +22 -19
  49. package/dist/core/schema.js.map +1 -1
  50. package/dist/core/search.d.ts +6 -0
  51. package/dist/core/search.d.ts.map +1 -1
  52. package/dist/core/search.js +159 -33
  53. package/dist/core/search.js.map +1 -1
  54. package/dist/core/sensitive.d.ts.map +1 -1
  55. package/dist/core/sensitive.js +64 -15
  56. package/dist/core/sensitive.js.map +1 -1
  57. package/dist/core/store.d.ts +1 -0
  58. package/dist/core/store.d.ts.map +1 -1
  59. package/dist/core/store.js +3 -0
  60. package/dist/core/store.js.map +1 -1
  61. package/dist/core/util.d.ts +6 -1
  62. package/dist/core/util.d.ts.map +1 -1
  63. package/dist/core/util.js +8 -1
  64. package/dist/core/util.js.map +1 -1
  65. package/dist/resources.d.ts.map +1 -1
  66. package/dist/resources.js +18 -9
  67. package/dist/resources.js.map +1 -1
  68. package/dist/server.js +1 -1
  69. package/dist/server.js.map +1 -1
  70. package/dist/tools/create.d.ts.map +1 -1
  71. package/dist/tools/create.js +121 -43
  72. package/dist/tools/create.js.map +1 -1
  73. package/dist/tools/define.d.ts +8 -4
  74. package/dist/tools/define.d.ts.map +1 -1
  75. package/dist/tools/define.js +36 -9
  76. package/dist/tools/define.js.map +1 -1
  77. package/dist/tools/delete.d.ts.map +1 -1
  78. package/dist/tools/delete.js +50 -11
  79. package/dist/tools/delete.js.map +1 -1
  80. package/dist/tools/diff.js +1 -1
  81. package/dist/tools/diff.js.map +1 -1
  82. package/dist/tools/edit.d.ts.map +1 -1
  83. package/dist/tools/edit.js +14 -12
  84. package/dist/tools/edit.js.map +1 -1
  85. package/dist/tools/find-files.d.ts.map +1 -1
  86. package/dist/tools/find-files.js +15 -9
  87. package/dist/tools/find-files.js.map +1 -1
  88. package/dist/tools/list.d.ts.map +1 -1
  89. package/dist/tools/list.js +138 -86
  90. package/dist/tools/list.js.map +1 -1
  91. package/dist/tools/move.d.ts.map +1 -1
  92. package/dist/tools/move.js +72 -26
  93. package/dist/tools/move.js.map +1 -1
  94. package/dist/tools/patch.js +13 -6
  95. package/dist/tools/patch.js.map +1 -1
  96. package/dist/tools/read.d.ts.map +1 -1
  97. package/dist/tools/read.js +114 -45
  98. package/dist/tools/read.js.map +1 -1
  99. package/dist/tools/replace-text.d.ts.map +1 -1
  100. package/dist/tools/replace-text.js +49 -35
  101. package/dist/tools/replace-text.js.map +1 -1
  102. package/dist/tools/search-text.d.ts.map +1 -1
  103. package/dist/tools/search-text.js +15 -33
  104. package/dist/tools/search-text.js.map +1 -1
  105. package/dist/tools/stat.d.ts.map +1 -1
  106. package/dist/tools/stat.js +26 -8
  107. package/dist/tools/stat.js.map +1 -1
  108. package/dist/transport/http.d.ts.map +1 -1
  109. package/dist/transport/http.js +1 -0
  110. package/dist/transport/http.js.map +1 -1
  111. package/dist/transport/stdio.js +1 -1
  112. package/dist/transport/stdio.js.map +1 -1
  113. package/package.json +5 -5
@@ -1,7 +1,9 @@
1
1
  import { basename } from 'node:path';
2
2
  import { detectMimeFromContent } from "./mime.js";
3
+ import { IS_WINDOWS, toPosixPath } from "./path-utils.js";
3
4
  import { countLines } from "./read.js";
4
- import { getMaxTextFileSize } from "./util.js";
5
+ import { getMaxTextFileSize, jsonBytesOver, MAX_OUTBOUND_RESULT_BYTES } from "./util.js";
6
+ const KIB = 1024;
5
7
  // Single owner of the `filesystem-mcp://file/` URI scheme — the template string,
6
8
  // the path→URI encoder, the URI→path decoder, the link blocks built from them,
7
9
  // and the post-write metadata block every write tool reports alongside them.
@@ -11,10 +13,12 @@ export const FILESYSTEM_FILE_URI_TEMPLATE = 'filesystem-mcp://file/{+path}';
11
13
  * must return, since a client expands a suggestion into the template verbatim.
12
14
  * Unescaped, a '#' or '?' in a filename truncates the URI into a fragment or
13
15
  * query (silently naming a different path) and a '%' makes the decode throw.
14
- * Separators are restored so the value still reads as a path.
16
+ * Separators are restored so the value still reads as a path. A backslash is
17
+ * a separator only on Windows; on POSIX it is part of a name and stays `%5C`,
18
+ * or `a\b.txt` and `a/b.txt` would share one URI.
15
19
  */
16
20
  export function encodeFileUriPath(validPath) {
17
- const posix = validPath.replace(/\\/g, '/');
21
+ const posix = IS_WINDOWS ? toPosixPath(validPath) : validPath;
18
22
  return encodeURIComponent(posix).replace(/%2F/gi, '/');
19
23
  }
20
24
  /** Inverse of {@link encodeFileUriPath}; `undefined` on invalid percent-encoding. */
@@ -60,6 +64,24 @@ export function extractPath(uri) {
60
64
  return undefined;
61
65
  return decodeFileUriPath(url.pathname.slice(1));
62
66
  }
67
+ /**
68
+ * Whether stdio's resources/read would refuse this file: it measures the
69
+ * JSON-encoded result, so escaping counts, and a file it reads as binary
70
+ * comes back base64. Without the content, assume the worst escape (6 bytes
71
+ * per byte) so a link is never one the read refuses.
72
+ */
73
+ function overStdioRead(validPath, mimeType, size, written) {
74
+ if (written === undefined)
75
+ return 6 * size > MAX_OUTBOUND_RESULT_BYTES - 64 * KIB;
76
+ // readRaw returns any non-text kind as base64 (`isBinary: kind !== 'text'`).
77
+ if (written.kind !== 'text') {
78
+ return 4 * Math.ceil(size / 3) > MAX_OUTBOUND_RESULT_BYTES - 64 * KIB;
79
+ }
80
+ const result = {
81
+ contents: [{ uri: buildFileResourceUri(validPath), mimeType, text: written.content }],
82
+ };
83
+ return jsonBytesOver(result, MAX_OUTBOUND_RESULT_BYTES) !== undefined;
84
+ }
63
85
  /**
64
86
  * The URI and link a write tool may advertise for the file it wrote. None on a
65
87
  * dry run — nothing was written, so the file on disk is still the one the
@@ -67,10 +89,16 @@ export function extractPath(uri) {
67
89
  * the store serves the URI via readRaw, which would reject it with TOO_LARGE.
68
90
  * File resources are read directly from disk, not from the result store.
69
91
  * `lastModified` is the post-write mtime when the caller stat'd the file; a
70
- * link without it is still valid.
92
+ * link without it is still valid. None over stdio when the read would refuse
93
+ * it: the JSON-encoded result past `MAX_OUTBOUND_RESULT_BYTES` (escaping
94
+ * counts, binary is base64), or, with no content to measure, any file whose
95
+ * worst-case escape could be.
71
96
  */
72
- export function writtenFileLinks(validPath, mimeType, size, dryRun = false, lastModified) {
73
- if (dryRun || size === undefined || size > getMaxTextFileSize()) {
97
+ export function writtenFileLinks(validPath, mimeType, size, dryRun = false, lastModified, transport, written) {
98
+ if (dryRun ||
99
+ size === undefined ||
100
+ size > getMaxTextFileSize() ||
101
+ (transport === 'stdio' && overStdioRead(validPath, mimeType, size, written))) {
74
102
  return { resourceUri: undefined, resourceLink: undefined };
75
103
  }
76
104
  return {
@@ -93,7 +121,7 @@ export function buildWrittenFileMeta(options) {
93
121
  lineCount: countLines(content),
94
122
  mimeType,
95
123
  kind,
96
- ...writtenFileLinks(validPath, mimeType, size, options.dryRun, options.lastModified),
124
+ ...writtenFileLinks(validPath, mimeType, size, options.dryRun, options.lastModified, options.transport, { content, kind }),
97
125
  };
98
126
  }
99
127
  //# sourceMappingURL=file-uri.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"file-uri.js","sourceRoot":"","sources":["../../src/core/file-uri.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ContentBlock } from '@modelcontextprotocol/server';\n\nimport { basename } from 'node:path';\n\nimport { detectMimeFromContent } from './mime.ts';\nimport type { FileKind } from './mime.ts';\nimport { countLines } from './read.ts';\nimport { getMaxTextFileSize } from './util.ts';\n\n// Single owner of the `filesystem-mcp://file/` URI scheme — the template string,\n// the path→URI encoder, the URI→path decoder, the link blocks built from them,\n// and the post-write metadata block every write tool reports alongside them.\n\nexport const FILESYSTEM_FILE_URI_TEMPLATE = 'filesystem-mcp://file/{+path}';\n\n/**\n * The `{+path}` template-variable form of a path — what `completion/complete`\n * must return, since a client expands a suggestion into the template verbatim.\n * Unescaped, a '#' or '?' in a filename truncates the URI into a fragment or\n * query (silently naming a different path) and a '%' makes the decode throw.\n * Separators are restored so the value still reads as a path.\n */\nexport function encodeFileUriPath(validPath: string): string {\n const posix = validPath.replace(/\\\\/g, '/');\n return encodeURIComponent(posix).replace(/%2F/gi, '/');\n}\n\n/** Inverse of {@link encodeFileUriPath}; `undefined` on invalid percent-encoding. */\nexport function decodeFileUriPath(encoded: string): string | undefined {\n try {\n return decodeURIComponent(encoded);\n } catch {\n return undefined;\n }\n}\n\nexport function buildFileResourceUri(validPath: string): string {\n return `filesystem-mcp://file/${encodeFileUriPath(validPath)}`;\n}\n\n/**\n * The link block for a URI already built by `buildFileResourceUri`. Callers that\n * hold the URI must use this rather than rebuilding from a path: a path that\n * differs only in case (the drive letter, on Windows) yields a *different* URI\n * string, and watchers key on that string — a link and a `resourceUri` that\n * disagree hand the client two subscriptions for one file.\n */\nexport function buildFileResourceLinkFor(\n uri: string,\n name: string,\n mimeType: string,\n size: number,\n /** ISO-8601 mtime of the file the link names, when the caller has one. */\n lastModified?: string,\n): ContentBlock {\n return {\n type: 'resource_link',\n uri,\n name,\n mimeType,\n size,\n annotations: {\n audience: ['user', 'assistant'],\n ...(lastModified !== undefined ? { lastModified } : {}),\n },\n };\n}\n\nfunction buildFileResourceLink(\n validPath: string,\n mimeType: string,\n size: number,\n lastModified?: string,\n): ContentBlock {\n return buildFileResourceLinkFor(\n buildFileResourceUri(validPath),\n basename(validPath),\n mimeType,\n size,\n lastModified,\n );\n}\n\nexport function extractPath(uri: string): string | undefined {\n const url = URL.parse(uri);\n if (url?.protocol !== 'filesystem-mcp:' || url.host !== 'file') return undefined;\n return decodeFileUriPath(url.pathname.slice(1));\n}\n\nexport interface WrittenFileMeta {\n size: number;\n lineCount: number;\n mimeType: string;\n kind: FileKind;\n /** Undefined unless {@link writtenFileLinks} allows it: see there for when. */\n resourceUri: string | undefined;\n /** Undefined unless {@link writtenFileLinks} allows it: see there for when. */\n resourceLink: ContentBlock | undefined;\n}\n\n/**\n * The URI and link a write tool may advertise for the file it wrote. None on a\n * dry run — nothing was written, so the file on disk is still the one the\n * caller already has. None when the size is unknown or over the text-size cap —\n * the store serves the URI via readRaw, which would reject it with TOO_LARGE.\n * File resources are read directly from disk, not from the result store.\n * `lastModified` is the post-write mtime when the caller stat'd the file; a\n * link without it is still valid.\n */\nexport function writtenFileLinks(\n validPath: string,\n mimeType: string,\n size: number | undefined,\n dryRun = false,\n lastModified?: string,\n): Pick<WrittenFileMeta, 'resourceUri' | 'resourceLink'> {\n if (dryRun || size === undefined || size > getMaxTextFileSize()) {\n return { resourceUri: undefined, resourceLink: undefined };\n }\n return {\n resourceUri: buildFileResourceUri(validPath),\n resourceLink: buildFileResourceLink(validPath, mimeType, size, lastModified),\n };\n}\n\n/**\n * The block every write tool reports for the content it wrote (or, with\n * `dryRun`, would have written): size, line count, MIME, and whatever\n * {@link writtenFileLinks} allows it to advertise. Named fields, because\n * `validPath` and `content` are both strings and a positional swap type-checks.\n */\nexport function buildWrittenFileMeta(options: {\n validPath: string;\n content: string;\n dryRun?: boolean | undefined;\n lastModified?: string | undefined;\n}): WrittenFileMeta {\n const { validPath, content } = options;\n const size = Buffer.byteLength(content, 'utf-8');\n const { mimeType, kind } = detectMimeFromContent(validPath, content);\n return {\n size,\n lineCount: countLines(content),\n mimeType,\n kind,\n ...writtenFileLinks(validPath, mimeType, size, options.dryRun, options.lastModified),\n };\n}\n"]}
1
+ {"version":3,"file":"file-uri.js","sourceRoot":"","sources":["../../src/core/file-uri.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ContentBlock } from '@modelcontextprotocol/server';\n\nimport { basename } from 'node:path';\n\nimport { detectMimeFromContent } from './mime.ts';\nimport type { FileKind } from './mime.ts';\nimport { IS_WINDOWS, toPosixPath } from './path-utils.ts';\nimport { countLines } from './read.ts';\nimport { getMaxTextFileSize, jsonBytesOver, MAX_OUTBOUND_RESULT_BYTES } from './util.ts';\n\nconst KIB = 1024;\n\n// Single owner of the `filesystem-mcp://file/` URI scheme — the template string,\n// the path→URI encoder, the URI→path decoder, the link blocks built from them,\n// and the post-write metadata block every write tool reports alongside them.\n\nexport const FILESYSTEM_FILE_URI_TEMPLATE = 'filesystem-mcp://file/{+path}';\n\n/**\n * The `{+path}` template-variable form of a path — what `completion/complete`\n * must return, since a client expands a suggestion into the template verbatim.\n * Unescaped, a '#' or '?' in a filename truncates the URI into a fragment or\n * query (silently naming a different path) and a '%' makes the decode throw.\n * Separators are restored so the value still reads as a path. A backslash is\n * a separator only on Windows; on POSIX it is part of a name and stays `%5C`,\n * or `a\\b.txt` and `a/b.txt` would share one URI.\n */\nexport function encodeFileUriPath(validPath: string): string {\n const posix = IS_WINDOWS ? toPosixPath(validPath) : validPath;\n return encodeURIComponent(posix).replace(/%2F/gi, '/');\n}\n\n/** Inverse of {@link encodeFileUriPath}; `undefined` on invalid percent-encoding. */\nexport function decodeFileUriPath(encoded: string): string | undefined {\n try {\n return decodeURIComponent(encoded);\n } catch {\n return undefined;\n }\n}\n\nexport function buildFileResourceUri(validPath: string): string {\n return `filesystem-mcp://file/${encodeFileUriPath(validPath)}`;\n}\n\n/**\n * The link block for a URI already built by `buildFileResourceUri`. Callers that\n * hold the URI must use this rather than rebuilding from a path: a path that\n * differs only in case (the drive letter, on Windows) yields a *different* URI\n * string, and watchers key on that string — a link and a `resourceUri` that\n * disagree hand the client two subscriptions for one file.\n */\nexport function buildFileResourceLinkFor(\n uri: string,\n name: string,\n mimeType: string,\n size: number,\n /** ISO-8601 mtime of the file the link names, when the caller has one. */\n lastModified?: string,\n): ContentBlock {\n return {\n type: 'resource_link',\n uri,\n name,\n mimeType,\n size,\n annotations: {\n audience: ['user', 'assistant'],\n ...(lastModified !== undefined ? { lastModified } : {}),\n },\n };\n}\n\nfunction buildFileResourceLink(\n validPath: string,\n mimeType: string,\n size: number,\n lastModified?: string,\n): ContentBlock {\n return buildFileResourceLinkFor(\n buildFileResourceUri(validPath),\n basename(validPath),\n mimeType,\n size,\n lastModified,\n );\n}\n\nexport function extractPath(uri: string): string | undefined {\n const url = URL.parse(uri);\n if (url?.protocol !== 'filesystem-mcp:' || url.host !== 'file') return undefined;\n return decodeFileUriPath(url.pathname.slice(1));\n}\n\nexport interface WrittenFileMeta {\n size: number;\n lineCount: number;\n mimeType: string;\n kind: FileKind;\n /** Undefined unless {@link writtenFileLinks} allows it: see there for when. */\n resourceUri: string | undefined;\n /** Undefined unless {@link writtenFileLinks} allows it: see there for when. */\n resourceLink: ContentBlock | undefined;\n}\n\n/**\n * Whether stdio's resources/read would refuse this file: it measures the\n * JSON-encoded result, so escaping counts, and a file it reads as binary\n * comes back base64. Without the content, assume the worst escape (6 bytes\n * per byte) so a link is never one the read refuses.\n */\nfunction overStdioRead(\n validPath: string,\n mimeType: string,\n size: number,\n written: { content: string; kind: FileKind } | undefined,\n): boolean {\n if (written === undefined) return 6 * size > MAX_OUTBOUND_RESULT_BYTES - 64 * KIB;\n // readRaw returns any non-text kind as base64 (`isBinary: kind !== 'text'`).\n if (written.kind !== 'text') {\n return 4 * Math.ceil(size / 3) > MAX_OUTBOUND_RESULT_BYTES - 64 * KIB;\n }\n const result = {\n contents: [{ uri: buildFileResourceUri(validPath), mimeType, text: written.content }],\n };\n return jsonBytesOver(result, MAX_OUTBOUND_RESULT_BYTES) !== undefined;\n}\n\n/**\n * The URI and link a write tool may advertise for the file it wrote. None on a\n * dry run — nothing was written, so the file on disk is still the one the\n * caller already has. None when the size is unknown or over the text-size cap —\n * the store serves the URI via readRaw, which would reject it with TOO_LARGE.\n * File resources are read directly from disk, not from the result store.\n * `lastModified` is the post-write mtime when the caller stat'd the file; a\n * link without it is still valid. None over stdio when the read would refuse\n * it: the JSON-encoded result past `MAX_OUTBOUND_RESULT_BYTES` (escaping\n * counts, binary is base64), or, with no content to measure, any file whose\n * worst-case escape could be.\n */\nexport function writtenFileLinks(\n validPath: string,\n mimeType: string,\n size: number | undefined,\n dryRun = false,\n lastModified?: string,\n transport?: 'stdio' | 'http',\n written?: { content: string; kind: FileKind },\n): Pick<WrittenFileMeta, 'resourceUri' | 'resourceLink'> {\n if (\n dryRun ||\n size === undefined ||\n size > getMaxTextFileSize() ||\n (transport === 'stdio' && overStdioRead(validPath, mimeType, size, written))\n ) {\n return { resourceUri: undefined, resourceLink: undefined };\n }\n return {\n resourceUri: buildFileResourceUri(validPath),\n resourceLink: buildFileResourceLink(validPath, mimeType, size, lastModified),\n };\n}\n\n/**\n * The block every write tool reports for the content it wrote (or, with\n * `dryRun`, would have written): size, line count, MIME, and whatever\n * {@link writtenFileLinks} allows it to advertise. Named fields, because\n * `validPath` and `content` are both strings and a positional swap type-checks.\n */\nexport function buildWrittenFileMeta(options: {\n validPath: string;\n content: string;\n dryRun?: boolean | undefined;\n lastModified?: string | undefined;\n transport?: 'stdio' | 'http' | undefined;\n}): WrittenFileMeta {\n const { validPath, content } = options;\n const size = Buffer.byteLength(content, 'utf-8');\n const { mimeType, kind } = detectMimeFromContent(validPath, content);\n return {\n size,\n lineCount: countLines(content),\n mimeType,\n kind,\n ...writtenFileLinks(\n validPath,\n mimeType,\n size,\n options.dryRun,\n options.lastModified,\n options.transport,\n { content, kind },\n ),\n };\n}\n"]}
@@ -10,6 +10,8 @@ export interface ProgressCtx {
10
10
  type Phase = 'tick' | 'done' | 'fail';
11
11
  export declare function plainMessage(phase: Phase, ctx: ProgressCtx): string;
12
12
  export declare function formatCount(count: number, singular: string, plural?: string): string;
13
+ /** Human-readable byte size for tree lines and summary text: `5 B`, `1 KiB`, `1.5 KiB`, `20 KiB`. */
14
+ export declare function formatByteSize(bytes: number): string;
13
15
  /**
14
16
  * Short label for a path in summary text. `basename` returns '' for a root
15
17
  * (`C:\`, `/`) and for a trailing-slash path, which rendered as a bare
@@ -1 +1 @@
1
- {"version":3,"file":"fmt.d.ts","sourceRoot":"","sources":["../../src/core/fmt.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAkCtC,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,WAAW,GAAG,MAAM,CAGnE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,SAAiB,GAAG,MAAM,CAE5F;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAKD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,SAAS,SAAQ,GAAG,MAAM,CAI9E;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE/D;AAYD;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,8FAA8F;IAC9F,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAC/C,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1C,GAAG,MAAM,CAsBT;AAED,iDAAiD;AACjD,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAG9D;AASD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,OAAO,CAAC;CACrB,GAAG,MAAM,GAAG,SAAS,CAWrB"}
1
+ {"version":3,"file":"fmt.d.ts","sourceRoot":"","sources":["../../src/core/fmt.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAkCtC,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,WAAW,GAAG,MAAM,CAGnE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,SAAiB,GAAG,MAAM,CAE5F;AAID,qGAAqG;AACrG,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAapD;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAKD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,SAAS,SAAQ,GAAG,MAAM,CAI9E;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE/D;AAYD;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,8FAA8F;IAC9F,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAC/C,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1C,GAAG,MAAM,CAsBT;AAED,iDAAiD;AACjD,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAG9D;AASD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,OAAO,CAAC;CACrB,GAAG,MAAM,GAAG,SAAS,CAWrB"}
package/dist/core/fmt.js CHANGED
@@ -37,6 +37,22 @@ export function plainMessage(phase, ctx) {
37
37
  export function formatCount(count, singular, plural = `${singular}s`) {
38
38
  return `${String(count)} ${count === 1 ? singular : plural}`;
39
39
  }
40
+ const SIZE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
41
+ /** Human-readable byte size for tree lines and summary text: `5 B`, `1 KiB`, `1.5 KiB`, `20 KiB`. */
42
+ export function formatByteSize(bytes) {
43
+ let value = bytes;
44
+ let unit = 0;
45
+ while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
46
+ value /= 1024;
47
+ unit++;
48
+ }
49
+ let rounded = unit === 0 || value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
50
+ if (rounded >= 1024 && unit < SIZE_UNITS.length - 1) {
51
+ rounded = 1;
52
+ unit++;
53
+ }
54
+ return `${String(rounded)} ${SIZE_UNITS[unit]}`;
55
+ }
40
56
  /**
41
57
  * Short label for a path in summary text. `basename` returns '' for a root
42
58
  * (`C:\`, `/`) and for a trailing-slash path, which rendered as a bare
@@ -1 +1 @@
1
- {"version":3,"file":"fmt.js","sourceRoot":"","sources":["../../src/core/fmt.ts"],"names":[],"mappings":"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","sourcesContent":["import { basename } from 'node:path';\n\nimport { MAX_SEARCH_RESULTS } from './util.ts';\n\nexport interface ProgressCtx {\n label: string;\n subject?: string;\n scope?: string;\n current?: number;\n total?: number;\n detail?: string;\n error?: string;\n}\n\ntype Phase = 'tick' | 'done' | 'fail';\n\nfunction buildBody(ctx: ProgressCtx, phase: Phase): string {\n const items: string[] = [];\n if (ctx.subject) {\n items.push(ctx.subject);\n }\n\n switch (phase) {\n case 'tick':\n if (ctx.current !== undefined && ctx.total !== undefined) {\n items.push(`${ctx.current}/${ctx.total}`);\n } else if (ctx.current !== undefined) {\n items.push(String(ctx.current));\n }\n break;\n case 'done':\n if (ctx.scope) {\n items.push(ctx.scope);\n }\n if (ctx.detail) {\n items.push(ctx.detail);\n }\n break;\n case 'fail':\n if (ctx.error) {\n items.push(ctx.error);\n }\n break;\n }\n\n return items.join(' · ');\n}\n\nexport function plainMessage(phase: Phase, ctx: ProgressCtx): string {\n const body = buildBody(ctx, phase);\n return body ? `${ctx.label}: ${body}` : `${ctx.label}:`;\n}\n\nexport function formatCount(count: number, singular: string, plural = `${singular}s`): string {\n return `${String(count)} ${count === 1 ? singular : plural}`;\n}\n\n/**\n * Short label for a path in summary text. `basename` returns '' for a root\n * (`C:\\`, `/`) and for a trailing-slash path, which rendered as a bare\n * separator in the per-path summaries, so fall back to the full path.\n */\nexport function pathLabel(path: string): string {\n return basename(path) || path;\n}\n\n/** Names past this point are noise in a summary line; structuredContent holds them all. */\nconst MAX_ROSTER_ITEMS = 20;\n\n/**\n * Join a per-path summary roster, capped. A 1000-path delete would otherwise\n * name every entry in the text block that `structuredContent` already carries\n * in full.\n */\nexport function joinRoster(items: readonly string[], separator = ' · '): string {\n if (items.length <= MAX_ROSTER_ITEMS) return items.join(separator);\n const shown = items.slice(0, MAX_ROSTER_ITEMS).join(separator);\n return `${shown}${separator}+${String(items.length - MAX_ROSTER_ITEMS)} more`;\n}\n\nexport function truncateProgressPattern(pattern: string): string {\n return pattern.length <= 40 ? pattern : `${pattern.slice(0, 40)}…`;\n}\n\n/**\n * `stoppedReason` names the engine's own cap, and `maxResults` is also the name\n * of the caller's page-size argument — spelling the cap out keeps a caller who\n * passed `maxResults: 5` from reading their own argument back.\n */\nconst STOP_REASON_TEXT: Record<string, string> = {\n maxResults: `hit the server's ${String(MAX_SEARCH_RESULTS)}-result scan cap, not your maxResults`,\n timeout: 'hit the time limit, or the request was cancelled',\n};\n\n/**\n * The `//` lines a paged search appends to its text block. Paging, the\n * engine's stop state and the files it skipped live in the structured half,\n * which `defineTool` ships under `_meta` for a tool that authors its own text —\n * and no client renders that. These lines are the only place a caller learns\n * that more remains, that the scan was cut, or that files went unexamined (a\n * lone match in an oversized file otherwise reads as \"No matches\"). They are\n * independent: a scan can stop early with too few results to page, so a\n * truncation with no cursor must still say so. Prefixed `//` so none can be\n * mistaken for a result row.\n */\nexport function pageTrailer(p: {\n offset: number;\n shown: number;\n total: number;\n noun: string;\n tool: string;\n nextCursor?: string | undefined;\n /** The call's own arguments; printed merged with the cursor so the hint is sendable as-is. */\n nextArgs?: Record<string, unknown> | undefined;\n stoppedReason?: string | undefined;\n skippedTooLarge?: number | undefined;\n skippedInaccessible?: number | undefined;\n}): string {\n const lines: string[] = [];\n // Position is owed on every page of a split set, including the last one —\n // which has no cursor and would otherwise read as the whole answer.\n if (p.offset > 0 || p.total > p.shown) {\n const next =\n p.nextCursor === undefined\n ? ''\n : ` Next page: ${p.tool} ${JSON.stringify({ ...p.nextArgs, cursor: p.nextCursor })}`;\n lines.push(\n `// showing ${String(p.offset + 1)}-${String(p.offset + p.shown)} of ${String(p.total)} ${p.noun}.${next}`,\n );\n }\n if (p.stoppedReason !== undefined) lines.push(stoppedEarlyLine(p.stoppedReason));\n const skipped = [\n p.skippedTooLarge ? `${formatCount(p.skippedTooLarge, 'file')} over the text size limit` : '',\n p.skippedInaccessible ? formatCount(p.skippedInaccessible, 'inaccessible file') : '',\n ].filter(Boolean);\n if (skipped.length > 0) {\n lines.push(`// skipped ${skipped.join(', ')}; results may be incomplete.`);\n }\n return lines.length > 0 ? `\\n\\n${lines.join('\\n')}` : '';\n}\n\n/** The `//` line saying a scan was cut short. */\nexport function stoppedEarlyLine(stoppedReason: string): string {\n const why = STOP_REASON_TEXT[stoppedReason] ?? `stopped (${stoppedReason})`;\n return `// scan stopped early: ${why}. That total is a floor, not the count. Narrow path or pattern for the rest.`;\n}\n\n// Regex syntax literal code rarely holds: unspaced alternation (`a|b`, not\n// `||` or a `string | null` union), class escapes, and `.*` / `.+`. Bare `(`,\n// `.`, `[` stay out — `foo(` and `a.b` are ordinary literal needles. A Windows\n// path like `src\\db` can still trip `\\d`; the hint only shows on zero\n// matches, so a stray one costs one line.\nconst REGEX_META = /(?<=[^\\s|])\\|(?=[^\\s|])|\\\\[dDwWsSbB]|\\.[*+]/;\n\n/**\n * The `//` line explaining a zero-match result, or undefined. Without it a\n * regex matched literally, or a glob that selected no files, reads exactly\n * like a genuine miss (issue #55). An `incomplete` scan (files skipped or\n * failed, scan stopped) already has its own line, which explains the zero\n * better, so it gets no hint.\n */\nexport function zeroMatchHint(p: {\n searchPattern: string;\n isRegex: boolean;\n pattern?: string | undefined;\n filesScanned: number;\n incomplete: boolean;\n}): string | undefined {\n if (p.incomplete) return undefined;\n if (p.filesScanned === 0) {\n return p.pattern === undefined\n ? '// no files were searched: nothing under path passed the hidden/ignored/maxDepth filters.'\n : `// no files were searched: nothing under path matched pattern '${p.pattern}' after the hidden/ignored/maxDepth filters. pattern is a file-name glob; the text to find goes in searchPattern.`;\n }\n if (!p.isRegex && REGEX_META.test(p.searchPattern)) {\n return '// searchPattern was matched as literal text but looks like a regex; pass isRegex=true to match it as one.';\n }\n return undefined;\n}\n"]}
1
+ {"version":3,"file":"fmt.js","sourceRoot":"","sources":["../../src/core/fmt.ts"],"names":[],"mappings":"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","sourcesContent":["import { basename } from 'node:path';\n\nimport { MAX_SEARCH_RESULTS } from './util.ts';\n\nexport interface ProgressCtx {\n label: string;\n subject?: string;\n scope?: string;\n current?: number;\n total?: number;\n detail?: string;\n error?: string;\n}\n\ntype Phase = 'tick' | 'done' | 'fail';\n\nfunction buildBody(ctx: ProgressCtx, phase: Phase): string {\n const items: string[] = [];\n if (ctx.subject) {\n items.push(ctx.subject);\n }\n\n switch (phase) {\n case 'tick':\n if (ctx.current !== undefined && ctx.total !== undefined) {\n items.push(`${ctx.current}/${ctx.total}`);\n } else if (ctx.current !== undefined) {\n items.push(String(ctx.current));\n }\n break;\n case 'done':\n if (ctx.scope) {\n items.push(ctx.scope);\n }\n if (ctx.detail) {\n items.push(ctx.detail);\n }\n break;\n case 'fail':\n if (ctx.error) {\n items.push(ctx.error);\n }\n break;\n }\n\n return items.join(' · ');\n}\n\nexport function plainMessage(phase: Phase, ctx: ProgressCtx): string {\n const body = buildBody(ctx, phase);\n return body ? `${ctx.label}: ${body}` : `${ctx.label}:`;\n}\n\nexport function formatCount(count: number, singular: string, plural = `${singular}s`): string {\n return `${String(count)} ${count === 1 ? singular : plural}`;\n}\n\nconst SIZE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];\n\n/** Human-readable byte size for tree lines and summary text: `5 B`, `1 KiB`, `1.5 KiB`, `20 KiB`. */\nexport function formatByteSize(bytes: number): string {\n let value = bytes;\n let unit = 0;\n while (value >= 1024 && unit < SIZE_UNITS.length - 1) {\n value /= 1024;\n unit++;\n }\n let rounded = unit === 0 || value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;\n if (rounded >= 1024 && unit < SIZE_UNITS.length - 1) {\n rounded = 1;\n unit++;\n }\n return `${String(rounded)} ${SIZE_UNITS[unit]}`;\n}\n\n/**\n * Short label for a path in summary text. `basename` returns '' for a root\n * (`C:\\`, `/`) and for a trailing-slash path, which rendered as a bare\n * separator in the per-path summaries, so fall back to the full path.\n */\nexport function pathLabel(path: string): string {\n return basename(path) || path;\n}\n\n/** Names past this point are noise in a summary line; structuredContent holds them all. */\nconst MAX_ROSTER_ITEMS = 20;\n\n/**\n * Join a per-path summary roster, capped. A 1000-path delete would otherwise\n * name every entry in the text block that `structuredContent` already carries\n * in full.\n */\nexport function joinRoster(items: readonly string[], separator = ' · '): string {\n if (items.length <= MAX_ROSTER_ITEMS) return items.join(separator);\n const shown = items.slice(0, MAX_ROSTER_ITEMS).join(separator);\n return `${shown}${separator}+${String(items.length - MAX_ROSTER_ITEMS)} more`;\n}\n\nexport function truncateProgressPattern(pattern: string): string {\n return pattern.length <= 40 ? pattern : `${pattern.slice(0, 40)}…`;\n}\n\n/**\n * `stoppedReason` names the engine's own cap, and `maxResults` is also the name\n * of the caller's page-size argument — spelling the cap out keeps a caller who\n * passed `maxResults: 5` from reading their own argument back.\n */\nconst STOP_REASON_TEXT: Record<string, string> = {\n maxResults: `hit the server's ${String(MAX_SEARCH_RESULTS)}-result scan cap, not your maxResults`,\n timeout: 'hit the time limit, or the request was cancelled',\n};\n\n/**\n * The `//` lines a paged search appends to its text block. Paging, the\n * engine's stop state and the files it skipped live in the structured half,\n * which `defineTool` ships under `_meta` for a tool that authors its own text —\n * and no client renders that. These lines are the only place a caller learns\n * that more remains, that the scan was cut, or that files went unexamined (a\n * lone match in an oversized file otherwise reads as \"No matches\"). They are\n * independent: a scan can stop early with too few results to page, so a\n * truncation with no cursor must still say so. Prefixed `//` so none can be\n * mistaken for a result row.\n */\nexport function pageTrailer(p: {\n offset: number;\n shown: number;\n total: number;\n noun: string;\n tool: string;\n nextCursor?: string | undefined;\n /** The call's own arguments; printed merged with the cursor so the hint is sendable as-is. */\n nextArgs?: Record<string, unknown> | undefined;\n stoppedReason?: string | undefined;\n skippedTooLarge?: number | undefined;\n skippedInaccessible?: number | undefined;\n}): string {\n const lines: string[] = [];\n // Position is owed on every page of a split set, including the last one —\n // which has no cursor and would otherwise read as the whole answer.\n if (p.offset > 0 || p.total > p.shown) {\n const next =\n p.nextCursor === undefined\n ? ''\n : ` Next page: ${p.tool} ${JSON.stringify({ ...p.nextArgs, cursor: p.nextCursor })}`;\n lines.push(\n `// showing ${String(p.offset + 1)}-${String(p.offset + p.shown)} of ${String(p.total)} ${p.noun}.${next}`,\n );\n }\n if (p.stoppedReason !== undefined) lines.push(stoppedEarlyLine(p.stoppedReason));\n const skipped = [\n p.skippedTooLarge ? `${formatCount(p.skippedTooLarge, 'file')} over the text size limit` : '',\n p.skippedInaccessible ? formatCount(p.skippedInaccessible, 'inaccessible file') : '',\n ].filter(Boolean);\n if (skipped.length > 0) {\n lines.push(`// skipped ${skipped.join(', ')}; results may be incomplete.`);\n }\n return lines.length > 0 ? `\\n\\n${lines.join('\\n')}` : '';\n}\n\n/** The `//` line saying a scan was cut short. */\nexport function stoppedEarlyLine(stoppedReason: string): string {\n const why = STOP_REASON_TEXT[stoppedReason] ?? `stopped (${stoppedReason})`;\n return `// scan stopped early: ${why}. That total is a floor, not the count. Narrow path or pattern for the rest.`;\n}\n\n// Regex syntax literal code rarely holds: unspaced alternation (`a|b`, not\n// `||` or a `string | null` union), class escapes, and `.*` / `.+`. Bare `(`,\n// `.`, `[` stay out — `foo(` and `a.b` are ordinary literal needles. A Windows\n// path like `src\\db` can still trip `\\d`; the hint only shows on zero\n// matches, so a stray one costs one line.\nconst REGEX_META = /(?<=[^\\s|])\\|(?=[^\\s|])|\\\\[dDwWsSbB]|\\.[*+]/;\n\n/**\n * The `//` line explaining a zero-match result, or undefined. Without it a\n * regex matched literally, or a glob that selected no files, reads exactly\n * like a genuine miss (issue #55). An `incomplete` scan (files skipped or\n * failed, scan stopped) already has its own line, which explains the zero\n * better, so it gets no hint.\n */\nexport function zeroMatchHint(p: {\n searchPattern: string;\n isRegex: boolean;\n pattern?: string | undefined;\n filesScanned: number;\n incomplete: boolean;\n}): string | undefined {\n if (p.incomplete) return undefined;\n if (p.filesScanned === 0) {\n return p.pattern === undefined\n ? '// no files were searched: nothing under path passed the hidden/ignored/maxDepth filters.'\n : `// no files were searched: nothing under path matched pattern '${p.pattern}' after the hidden/ignored/maxDepth filters. pattern is a file-name glob; the text to find goes in searchPattern.`;\n }\n if (!p.isRegex && REGEX_META.test(p.searchPattern)) {\n return '// searchPattern was matched as literal text but looks like a regex; pass isRegex=true to match it as one.';\n }\n return undefined;\n}\n"]}
package/dist/core/fs.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Stats } from 'node:fs';
2
2
  import type { FileHandle } from 'node:fs/promises';
3
3
  import { cp as fsCp, mkdir as fsMkdir, readlink as fsReadlink, rm as fsRm, rmdir as fsRmdir } from 'node:fs/promises';
4
+ import { type FileKind } from './mime.ts';
4
5
  import type { EntryType as FileType } from './path-utils.ts';
5
6
  import type { PathGuard } from './path.ts';
6
7
  import type { ReadFileResult, ReadSpec } from './read.ts';
@@ -24,7 +25,7 @@ export declare class GuardedFileSystem {
24
25
  validPath: string;
25
26
  }>;
26
27
  lstat(filePath: string, options?: {
27
- signal?: AbortSignal;
28
+ signal?: AbortSignal | undefined;
28
29
  }): Promise<{
29
30
  stats: Stats;
30
31
  validPath: string;
@@ -84,7 +85,10 @@ export declare class GuardedFileSystem {
84
85
  }): Promise<{
85
86
  content: Buffer;
86
87
  mimeType: string;
88
+ kind: FileKind;
87
89
  isBinary: boolean;
90
+ validPath: string;
91
+ modified: string;
88
92
  }>;
89
93
  open(filePath: string): Promise<FileHandle>;
90
94
  statDetailed(filePath: string, options?: {
@@ -95,6 +99,17 @@ export declare class GuardedFileSystem {
95
99
  stats: Stats;
96
100
  }>;
97
101
  hasChildrenUnchecked(dirPath: string): Promise<boolean>;
102
+ /**
103
+ * Direct children of a directory, leaving out entries the guard treats as
104
+ * sensitive so the count never reveals what listings hide. Hidden and
105
+ * ignored entries count: this answers "is it empty?", not "what would list show?".
106
+ * A symlinked child counts only when list would show it. The path validation, open, opendir,
107
+ * each read and each link check race the signal, so a stalled mount ends the call at its
108
+ * deadline.
109
+ */
110
+ countEntries(dirPath: string, options?: {
111
+ signal?: AbortSignal;
112
+ }): Promise<number>;
98
113
  }
99
114
  /**
100
115
  * Returns whether `path` exists, treating "not found" as absent and logging any
@@ -1 +1 @@
1
- {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../../src/core/fs.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../../src/core/fs.ts"],"names":[],"mappings":"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"}
package/dist/core/fs.js CHANGED
@@ -51,38 +51,23 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
53
  import { randomUUID } from 'node:crypto';
54
+ import { constants, existsSync } from 'node:fs';
54
55
  import { chmod as fsChmod, cp as fsCp, lstat as fsLstat, mkdir as fsMkdir, open as fsOpen, opendir as fsOpendir, readFile as fsReadFile, readlink as fsReadlink, rename as fsRename, rm as fsRm, rmdir as fsRmdir, stat as fsStat, unlink as fsUnlink, writeFile as fsWriteFile, } from 'node:fs/promises';
55
- import { dirname, isAbsolute, resolve } from 'node:path';
56
+ import { isAbsolute, join } from 'node:path';
56
57
  import { runExclusive, withAbort } from "./concurrency.js";
57
58
  import { ErrorCode, formatUnknownErrorMessage, FsError, isFsError, isNodeError, isNotFoundErrno, } from "./errors.js";
58
59
  import { detectMimeFromContent, MIME_SAMPLE_SIZE } from "./mime.js";
59
60
  import { Logger } from "./observability.js";
60
- import { normalizePath, respellCaseOnlyTarget } from "./path-utils.js";
61
- import { APPEND_CREATE_NOFOLLOW, APPEND_NOFOLLOW, assertFileStats, createTooLargeError, READ_NOFOLLOW, readFileWithStats, } from "./read.js";
61
+ import { isSamePath, normalizePath, respellSpellingOnlyTarget } from "./path-utils.js";
62
+ import { APPEND_CREATE_NOFOLLOW, APPEND_NOFOLLOW, assertFileStats, createTooLargeError, O_NOFOLLOW, READ_NOFOLLOW, readFileWithStats, } from "./read.js";
62
63
  import { getMaxTextFileSize } from "./util.js";
64
+ // Directory open for countEntries. O_NOFOLLOW refuses a symlink named as the
65
+ // directory; used only under COUNT_THROUGH_FD, like the re-check it feeds.
66
+ const DIR_NOFOLLOW = constants.O_RDONLY | constants.O_DIRECTORY | O_NOFOLLOW;
67
+ // The descriptor re-check and the read through /proc/self/fd need Linux with procfs mounted,
68
+ // as the read path's re-check does; elsewhere the count reads the directory by path.
69
+ const COUNT_THROUGH_FD = process.platform === 'linux' && existsSync('/proc/self/fd');
63
70
  // ─── Domain primitives ────────────────────────────────────────────────────────
64
- /**
65
- * validatePathForWrite + symlink resolution: a write aimed at a link must land
66
- * on its revalidated target, not create a new file beside the link. ENOENT is
67
- * the normal new-file case, so it falls through with the original path.
68
- */
69
- async function resolveForWrite(pathGuard, filePath) {
70
- let validPath = await pathGuard.validatePathForWrite(filePath);
71
- try {
72
- const stats = await fsLstat(validPath);
73
- if (stats.isSymbolicLink()) {
74
- const target = await fsReadlink(validPath);
75
- const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(validPath), target);
76
- validPath = await pathGuard.validatePathForWrite(resolvedTarget);
77
- }
78
- }
79
- catch (error) {
80
- if (!isNotFoundErrno(error)) {
81
- throw error;
82
- }
83
- }
84
- return validPath;
85
- }
86
71
  const CHAR_LF = 10;
87
72
  const LINE_COUNT_CHUNK_BYTES = 64 * 1024;
88
73
  export class GuardedFileSystem {
@@ -95,6 +80,7 @@ export class GuardedFileSystem {
95
80
  * for a symlink after validation still passes. Re-read the open descriptor's
96
81
  * real path (Linux `/proc/self/fd`; skipped elsewhere) and require it to stay
97
82
  * inside the roots. Run before any byte is read or written through `handle`.
83
+ * Resolves to the normalized real path checked, or undefined when /proc is unreadable.
98
84
  */
99
85
  async #assertFdWithinRoots(handle, requestedPath) {
100
86
  let fdPath;
@@ -102,7 +88,7 @@ export class GuardedFileSystem {
102
88
  fdPath = await fsReadlink(`/proc/self/fd/${String(handle.fd)}`);
103
89
  }
104
90
  catch {
105
- return;
91
+ return undefined;
106
92
  }
107
93
  // A replaced target reads back as "<path> (deleted)".
108
94
  const inRoots = isAbsolute(fdPath) &&
@@ -112,6 +98,7 @@ export class GuardedFileSystem {
112
98
  if (!inRoots) {
113
99
  throw new FsError(ErrorCode.ACCESS_DENIED, 'Path changed under validation and now resolves outside the allowed roots (TOCTOU).', requestedPath);
114
100
  }
101
+ return normalizePath(fdPath);
115
102
  }
116
103
  /** The one read open: O_NOFOLLOW, then the descriptor re-check above. */
117
104
  async #openVerified(validPath, requestedPath, signal) {
@@ -138,6 +125,23 @@ export class GuardedFileSystem {
138
125
  throw error;
139
126
  }
140
127
  }
128
+ /**
129
+ * Waits for `pending` until `signal` fires. A result that lands after the abort goes to
130
+ * `release` without anyone waiting on it, so a stalled open or read never holds the caller.
131
+ */
132
+ async #abandonOnAbort(pending, signal, release, label) {
133
+ try {
134
+ return await withAbort(pending, signal);
135
+ }
136
+ catch (error) {
137
+ void pending
138
+ .then(release, () => undefined)
139
+ .catch((releaseErr) => {
140
+ Logger.warn(`Failed to release ${label} after abort: ${formatUnknownErrorMessage(releaseErr)}`);
141
+ });
142
+ throw error;
143
+ }
144
+ }
141
145
  /**
142
146
  * Serialize a read-modify-write on the resolved write target, so two writers
143
147
  * of one file (any spelling, including via a symlinked parent) cannot both
@@ -145,7 +149,7 @@ export class GuardedFileSystem {
145
149
  * do not take the lock, so `fn` may call them.
146
150
  */
147
151
  async lockForWrite(filePath, fn) {
148
- const key = await resolveForWrite(this.pathGuard, filePath);
152
+ const key = await this.pathGuard.validatePathForWrite(filePath);
149
153
  return runExclusive(key, () => fn(key));
150
154
  }
151
155
  async stat(filePath, options) {
@@ -176,14 +180,19 @@ export class GuardedFileSystem {
176
180
  // link would rename its target and leave the link dangling.
177
181
  const validOld = await this.pathGuard.validatePathForDelete(oldPath);
178
182
  let validNew = await this.pathGuard.validatePathForWrite(newPath);
179
- // Renaming a path onto itself is only meaningful as a case-only rename.
180
- if (validOld === validNew)
181
- validNew = respellCaseOnlyTarget(newPath, validNew);
183
+ // A new path naming the old entry itself, in another case or Unicode
184
+ // spelling, renames that entry. Probed without following a final link:
185
+ // validatePathForWrite resolved a link to its target, and renaming onto
186
+ // that would replace the target with the link.
187
+ const sameEntry = await this.pathGuard.validatePathForDelete(newPath).then((entry) => isSamePath(entry, validOld), () => false);
188
+ if (sameEntry || validOld === validNew) {
189
+ validNew = respellSpellingOnlyTarget(newPath, sameEntry ? validOld : validNew);
190
+ }
182
191
  await fsRename(validOld, validNew);
183
192
  }
184
193
  async writeFile(filePath, content, options = {}) {
185
194
  const { signal } = options;
186
- const validPath = await resolveForWrite(this.pathGuard, filePath);
195
+ const validPath = await this.pathGuard.validatePathForWrite(filePath);
187
196
  const tempSuffix = randomUUID().replace(/-/g, '').slice(0, 12);
188
197
  const tempPath = `${validPath}.${tempSuffix}.tmp`;
189
198
  // The rename below swaps in the temp file's inode, so the target would
@@ -242,7 +251,7 @@ export class GuardedFileSystem {
242
251
  */
243
252
  async appendFile(filePath, content, options = {}) {
244
253
  const { signal } = options;
245
- const validPath = await resolveForWrite(this.pathGuard, filePath);
254
+ const validPath = await this.pathGuard.validatePathForWrite(filePath);
246
255
  // Non-regular targets must be rejected before the open: 'a' on a FIFO
247
256
  // (POSIX) blocks until a reader appears, and fsOpen takes no signal, so
248
257
  // the call would hang unabortable and pin its concurrency slot. The
@@ -443,7 +452,10 @@ export class GuardedFileSystem {
443
452
  return {
444
453
  content,
445
454
  mimeType: mimeInfo.mimeType,
455
+ kind: mimeInfo.kind,
446
456
  isBinary: mimeInfo.kind !== 'text',
457
+ validPath,
458
+ modified: stats.mtime.toISOString(),
447
459
  };
448
460
  }
449
461
  catch (e_3) {
@@ -480,6 +492,89 @@ export class GuardedFileSystem {
480
492
  });
481
493
  }
482
494
  }
495
+ /**
496
+ * Direct children of a directory, leaving out entries the guard treats as
497
+ * sensitive so the count never reveals what listings hide. Hidden and
498
+ * ignored entries count: this answers "is it empty?", not "what would list show?".
499
+ * A symlinked child counts only when list would show it. The path validation, open, opendir,
500
+ * each read and each link check race the signal, so a stalled mount ends the call at its
501
+ * deadline.
502
+ */
503
+ async countEntries(dirPath, options) {
504
+ const signal = options?.signal;
505
+ // Refuse an already-cancelled call before any validation or I/O.
506
+ signal?.throwIfAborted();
507
+ // A late validation result is a plain object, so a stalled realpath needs no release.
508
+ const { resolvedPath } = await withAbort(this.pathGuard.validateExistingPathDetailed(dirPath), signal);
509
+ // With procfs (COUNT_THROUGH_FD), open the directory itself with O_NOFOLLOW and check where
510
+ // the open descriptor really is, so a directory or ancestor swapped for a symlink after
511
+ // validation is refused. Then read entries through the descriptor, which a later swap cannot
512
+ // redirect.
513
+ const handle = COUNT_THROUGH_FD
514
+ ? await this.#abandonOnAbort(fsOpen(resolvedPath, DIR_NOFOLLOW), signal, (late) => late.close(), `directory descriptor for ${resolvedPath}`)
515
+ : undefined;
516
+ let dir;
517
+ let pendingRead;
518
+ try {
519
+ const openedPath = handle ? await this.#assertFdWithinRoots(handle, dirPath) : undefined;
520
+ // An in-root swap passes the roots check, but the children would be filtered under the
521
+ // validated directory's spelling, not the one actually opened.
522
+ if (openedPath !== undefined && !isSamePath(openedPath, resolvedPath)) {
523
+ throw new FsError(ErrorCode.ACCESS_DENIED, 'Path changed under validation and now resolves to another directory (TOCTOU).', dirPath);
524
+ }
525
+ const readPath = handle ? `/proc/self/fd/${String(handle.fd)}` : resolvedPath;
526
+ dir = await this.#abandonOnAbort(fsOpendir(readPath), signal, (late) => late.close(), `directory stream for ${resolvedPath}`);
527
+ let count = 0;
528
+ for (;;) {
529
+ // Each read races the signal, so a read that stalls ends the call at its deadline.
530
+ const read = dir.read();
531
+ pendingRead = read;
532
+ const entry = await withAbort(read, signal);
533
+ pendingRead = undefined;
534
+ if (entry === null)
535
+ break;
536
+ if (this.pathGuard.isSensitive(join(dirPath, entry.name)))
537
+ continue;
538
+ // list walks the real directory, so it checks every child, links
539
+ // included, under its real spelling: a deny rule naming the real
540
+ // directory hides the child under an alias too.
541
+ if (this.pathGuard.isSensitive(join(resolvedPath, entry.name)))
542
+ continue;
543
+ // A link counts only when list would show it: same rule, same error policy.
544
+ if (entry.isSymbolicLink() &&
545
+ !(await withAbort(this.pathGuard.isEntryAccessible(join(resolvedPath, entry.name)), signal))) {
546
+ continue;
547
+ }
548
+ count += 1;
549
+ }
550
+ // A deadline that fired during the last read still ends the call, even with no entries left.
551
+ signal?.throwIfAborted();
552
+ return count;
553
+ }
554
+ finally {
555
+ if (dir) {
556
+ const stream = dir;
557
+ if (pendingRead) {
558
+ // close() queues behind a read still in flight, so awaiting it would hold the TIMEOUT
559
+ // response on a stalled mount. Close once that read settles, without waiting for it.
560
+ void pendingRead
561
+ .then(() => stream.close(), () => stream.close())
562
+ .catch((closeErr) => {
563
+ Logger.warn(`Failed to close dir handle for ${resolvedPath} after abort: ${formatUnknownErrorMessage(closeErr)}`);
564
+ });
565
+ }
566
+ else {
567
+ await stream.close().catch((closeErr) => {
568
+ Logger.warn(`Failed to close dir handle for ${resolvedPath}: ${formatUnknownErrorMessage(closeErr)}`);
569
+ });
570
+ }
571
+ }
572
+ // A failed close must not replace the error the count threw, so it is logged, not thrown.
573
+ await handle?.close().catch((closeErr) => {
574
+ Logger.warn(`Failed to close dir descriptor for ${resolvedPath}: ${formatUnknownErrorMessage(closeErr)}`);
575
+ });
576
+ }
577
+ }
483
578
  }
484
579
  /**
485
580
  * Returns whether `path` exists, treating "not found" as absent and logging any