btca-server 1.0.63 → 1.0.71

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 (42) hide show
  1. package/README.md +4 -1
  2. package/package.json +4 -2
  3. package/src/agent/agent.test.ts +114 -16
  4. package/src/agent/loop.ts +14 -11
  5. package/src/agent/service.ts +117 -86
  6. package/src/collections/index.ts +0 -0
  7. package/src/collections/service.ts +187 -57
  8. package/src/collections/types.ts +1 -0
  9. package/src/collections/virtual-metadata.ts +32 -0
  10. package/src/config/config.test.ts +0 -0
  11. package/src/config/index.ts +195 -127
  12. package/src/config/remote.ts +132 -79
  13. package/src/context/index.ts +0 -0
  14. package/src/context/transaction.ts +20 -15
  15. package/src/errors.ts +0 -0
  16. package/src/index.ts +29 -15
  17. package/src/metrics/index.ts +18 -13
  18. package/src/providers/auth.ts +38 -11
  19. package/src/providers/model.ts +3 -1
  20. package/src/providers/openrouter.ts +39 -0
  21. package/src/providers/registry.ts +2 -0
  22. package/src/resources/helpers.ts +0 -0
  23. package/src/resources/impls/git.test.ts +0 -0
  24. package/src/resources/impls/git.ts +160 -117
  25. package/src/resources/index.ts +0 -0
  26. package/src/resources/schema.ts +24 -27
  27. package/src/resources/service.ts +0 -0
  28. package/src/resources/types.ts +0 -0
  29. package/src/stream/index.ts +0 -0
  30. package/src/stream/service.ts +23 -14
  31. package/src/tools/context.ts +4 -0
  32. package/src/tools/glob.ts +72 -45
  33. package/src/tools/grep.ts +136 -57
  34. package/src/tools/index.ts +0 -2
  35. package/src/tools/list.ts +34 -53
  36. package/src/tools/read.ts +46 -32
  37. package/src/tools/virtual-sandbox.ts +103 -0
  38. package/src/validation/index.ts +12 -12
  39. package/src/vfs/virtual-fs.test.ts +107 -0
  40. package/src/vfs/virtual-fs.ts +273 -0
  41. package/src/tools/ripgrep.ts +0 -348
  42. package/src/tools/sandbox.ts +0 -164
package/src/tools/grep.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Grep Tool
3
- * Searches file contents using regular expressions via ripgrep
3
+ * Searches file contents using regular expressions in-memory
4
4
  */
5
- import * as fs from 'node:fs/promises';
6
5
  import * as path from 'node:path';
7
6
  import { z } from 'zod';
7
+ import { Result } from 'better-result';
8
8
 
9
- import { Ripgrep } from './ripgrep.ts';
10
- import { Sandbox } from './sandbox.ts';
9
+ import type { ToolContext } from './context.ts';
10
+ import { VirtualSandbox } from './virtual-sandbox.ts';
11
+ import { VirtualFs } from '../vfs/virtual-fs.ts';
11
12
 
12
13
  export namespace GrepTool {
13
14
  // Configuration
@@ -39,33 +40,40 @@ export namespace GrepTool {
39
40
  };
40
41
  };
41
42
 
43
+ const safeStat = async (filePath: string, vfsId?: string) => {
44
+ const result = await Result.tryPromise(() => VirtualFs.stat(filePath, vfsId));
45
+ return result.match({
46
+ ok: (value) => value,
47
+ err: () => null
48
+ });
49
+ };
50
+
51
+ const safeReadBuffer = async (filePath: string, vfsId?: string) => {
52
+ const result = await Result.tryPromise(() => VirtualFs.readFileBuffer(filePath, vfsId));
53
+ return result.match({
54
+ ok: (value) => value,
55
+ err: () => null
56
+ });
57
+ };
58
+
59
+ const compileRegex = (pattern: string) =>
60
+ Result.try(() => new RegExp(pattern)).match({
61
+ ok: (value) => value,
62
+ err: () => null
63
+ });
64
+
42
65
  /**
43
66
  * Execute the grep tool
44
67
  */
45
- export async function execute(
46
- params: ParametersType,
47
- context: { basePath: string }
48
- ): Promise<Result> {
49
- const { basePath } = context;
68
+ export async function execute(params: ParametersType, context: ToolContext): Promise<Result> {
69
+ const { basePath, vfsId } = context;
50
70
 
51
71
  // Resolve search path within sandbox
52
- const searchPath = params.path ? Sandbox.resolvePath(basePath, params.path) : basePath;
72
+ const searchPath = params.path ? VirtualSandbox.resolvePath(basePath, params.path) : basePath;
53
73
 
54
74
  // Validate the search path exists and is a directory
55
- try {
56
- const stats = await fs.stat(searchPath);
57
- if (!stats.isDirectory()) {
58
- return {
59
- title: params.pattern,
60
- output: `Path is not a directory: ${params.path || '.'}`,
61
- metadata: {
62
- matchCount: 0,
63
- fileCount: 0,
64
- truncated: false
65
- }
66
- };
67
- }
68
- } catch {
75
+ const stats = await safeStat(searchPath, vfsId);
76
+ if (!stats) {
69
77
  return {
70
78
  title: params.pattern,
71
79
  output: `Directory not found: ${params.path || '.'}`,
@@ -76,15 +84,59 @@ export namespace GrepTool {
76
84
  }
77
85
  };
78
86
  }
87
+ if (!stats.isDirectory) {
88
+ return {
89
+ title: params.pattern,
90
+ output: `Path is not a directory: ${params.path || '.'}`,
91
+ metadata: {
92
+ matchCount: 0,
93
+ fileCount: 0,
94
+ truncated: false
95
+ }
96
+ };
97
+ }
79
98
 
80
- // Run ripgrep search
81
- const results = await Ripgrep.search({
82
- cwd: searchPath,
83
- pattern: params.pattern,
84
- glob: params.include,
85
- hidden: true,
86
- maxResults: MAX_RESULTS + 1 // Get one extra to check for truncation
87
- });
99
+ const regex = compileRegex(params.pattern);
100
+ if (!regex) {
101
+ return {
102
+ title: params.pattern,
103
+ output: 'Invalid regex pattern.',
104
+ metadata: {
105
+ matchCount: 0,
106
+ fileCount: 0,
107
+ truncated: false
108
+ }
109
+ };
110
+ }
111
+
112
+ const includeMatcher = params.include ? buildIncludeMatcher(params.include) : null;
113
+ const allFiles = await VirtualFs.listFilesRecursive(searchPath, vfsId);
114
+ const results: Array<{ path: string; lineNumber: number; lineText: string; mtime: number }> =
115
+ [];
116
+
117
+ for (const filePath of allFiles) {
118
+ if (results.length > MAX_RESULTS) break;
119
+ const relative = path.posix.relative(searchPath, filePath);
120
+ if (includeMatcher && !includeMatcher(relative)) continue;
121
+ const buffer = await safeReadBuffer(filePath, vfsId);
122
+ if (!buffer) continue;
123
+ if (isBinaryBuffer(buffer)) continue;
124
+ const text = await VirtualFs.readFile(filePath, vfsId);
125
+ const lines = text.split('\n');
126
+ const fileStats = await safeStat(filePath, vfsId);
127
+ const mtime = fileStats?.mtimeMs ?? 0;
128
+ for (let i = 0; i < lines.length; i++) {
129
+ const lineText = lines[i] ?? '';
130
+ if (!regex.test(lineText)) continue;
131
+ results.push({
132
+ path: filePath,
133
+ lineNumber: i + 1,
134
+ lineText,
135
+ mtime
136
+ });
137
+ if (results.length > MAX_RESULTS) break;
138
+ }
139
+ }
88
140
 
89
141
  if (results.length === 0) {
90
142
  return {
@@ -98,30 +150,13 @@ export namespace GrepTool {
98
150
  };
99
151
  }
100
152
 
101
- // Check for truncation
102
153
  const truncated = results.length > MAX_RESULTS;
103
154
  const displayResults = truncated ? results.slice(0, MAX_RESULTS) : results;
155
+ displayResults.sort((a, b) => b.mtime - a.mtime);
104
156
 
105
- // Sort by modification time (most recent first)
106
- // Get file modification times
107
- const filesWithMtime = await Promise.all(
108
- displayResults.map(async (result) => {
109
- try {
110
- const stats = await fs.stat(result.path);
111
- return { ...result, mtime: stats.mtime.getTime() };
112
- } catch {
113
- return { ...result, mtime: 0 };
114
- }
115
- })
116
- );
117
-
118
- filesWithMtime.sort((a, b) => b.mtime - a.mtime);
119
-
120
- // Group results by file
121
157
  const fileGroups = new Map<string, Array<{ lineNumber: number; lineText: string }>>();
122
-
123
- for (const result of filesWithMtime) {
124
- const relativePath = path.relative(basePath, result.path);
158
+ for (const result of displayResults) {
159
+ const relativePath = path.posix.relative(basePath, result.path);
125
160
  if (!fileGroups.has(relativePath)) {
126
161
  fileGroups.set(relativePath, []);
127
162
  }
@@ -131,21 +166,17 @@ export namespace GrepTool {
131
166
  });
132
167
  }
133
168
 
134
- // Format output
135
169
  const outputLines: string[] = [];
136
-
137
170
  for (const [filePath, matches] of fileGroups) {
138
171
  outputLines.push(`${filePath}:`);
139
172
  for (const match of matches) {
140
- // Truncate long lines
141
173
  const lineText =
142
174
  match.lineText.length > 200 ? match.lineText.substring(0, 200) + '...' : match.lineText;
143
175
  outputLines.push(` ${match.lineNumber}: ${lineText}`);
144
176
  }
145
- outputLines.push(''); // Empty line between files
177
+ outputLines.push('');
146
178
  }
147
179
 
148
- // Add truncation notice
149
180
  if (truncated) {
150
181
  outputLines.push(
151
182
  `[Truncated: Results limited to ${MAX_RESULTS} matches. Narrow your search pattern for more specific results.]`
@@ -162,4 +193,52 @@ export namespace GrepTool {
162
193
  }
163
194
  };
164
195
  }
196
+
197
+ function isBinaryBuffer(bytes: Uint8Array): boolean {
198
+ for (const byte of bytes) {
199
+ if (byte === 0) return true;
200
+ }
201
+ return false;
202
+ }
203
+
204
+ function globToRegExp(pattern: string): RegExp {
205
+ let regex = '^';
206
+ let i = 0;
207
+ while (i < pattern.length) {
208
+ const char = pattern[i] ?? '';
209
+ const next = pattern[i + 1] ?? '';
210
+ if (char === '*' && next === '*') {
211
+ regex += '.*';
212
+ i += 2;
213
+ continue;
214
+ }
215
+ if (char === '*') {
216
+ regex += '[^/]*';
217
+ i += 1;
218
+ continue;
219
+ }
220
+ if (char === '?') {
221
+ regex += '[^/]';
222
+ i += 1;
223
+ continue;
224
+ }
225
+ if ('\\.^$+{}()|[]'.includes(char)) {
226
+ regex += '\\' + char;
227
+ } else {
228
+ regex += char;
229
+ }
230
+ i += 1;
231
+ }
232
+ regex += '$';
233
+ return new RegExp(regex);
234
+ }
235
+
236
+ function buildIncludeMatcher(pattern: string): (relativePath: string) => boolean {
237
+ const regex = globToRegExp(pattern);
238
+ if (!pattern.includes('/')) {
239
+ return (relativePath) =>
240
+ regex.test(path.posix.basename(relativePath)) || regex.test(relativePath);
241
+ }
242
+ return (relativePath) => regex.test(relativePath);
243
+ }
165
244
  }
@@ -6,5 +6,3 @@ export { ReadTool } from './read.ts';
6
6
  export { GrepTool } from './grep.ts';
7
7
  export { GlobTool } from './glob.ts';
8
8
  export { ListTool } from './list.ts';
9
- export { Ripgrep } from './ripgrep.ts';
10
- export { Sandbox } from './sandbox.ts';
package/src/tools/list.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * List Tool
3
3
  * Lists directory contents with file types
4
4
  */
5
- import * as fs from 'node:fs/promises';
6
5
  import * as path from 'node:path';
7
6
  import { z } from 'zod';
7
+ import { Result } from 'better-result';
8
8
 
9
- import { Sandbox } from './sandbox.ts';
9
+ import type { ToolContext } from './context.ts';
10
+ import { VirtualSandbox } from './virtual-sandbox.ts';
11
+ import { VirtualFs } from '../vfs/virtual-fs.ts';
10
12
 
11
13
  export namespace ListTool {
12
14
  // Schema for tool parameters
@@ -19,7 +21,7 @@ export namespace ListTool {
19
21
  // Entry type
20
22
  export type Entry = {
21
23
  name: string;
22
- type: 'file' | 'directory' | 'symlink' | 'other';
24
+ type: 'file' | 'directory' | 'other';
23
25
  size?: number;
24
26
  };
25
27
 
@@ -34,33 +36,26 @@ export namespace ListTool {
34
36
  };
35
37
  };
36
38
 
39
+ const safeStat = async (filePath: string, vfsId?: string) => {
40
+ const result = await Result.tryPromise(() => VirtualFs.stat(filePath, vfsId));
41
+ return result.match({
42
+ ok: (value) => value,
43
+ err: () => null
44
+ });
45
+ };
46
+
37
47
  /**
38
48
  * Execute the list tool
39
49
  */
40
- export async function execute(
41
- params: ParametersType,
42
- context: { basePath: string }
43
- ): Promise<Result> {
44
- const { basePath } = context;
50
+ export async function execute(params: ParametersType, context: ToolContext): Promise<Result> {
51
+ const { basePath, vfsId } = context;
45
52
 
46
53
  // Resolve path within sandbox
47
- const resolvedPath = Sandbox.resolvePath(basePath, params.path);
54
+ const resolvedPath = VirtualSandbox.resolvePath(basePath, params.path);
48
55
 
49
56
  // Check if path exists
50
- try {
51
- const stats = await fs.stat(resolvedPath);
52
- if (!stats.isDirectory()) {
53
- return {
54
- title: params.path,
55
- output: `Path is not a directory: ${params.path}`,
56
- metadata: {
57
- entries: [],
58
- fileCount: 0,
59
- directoryCount: 0
60
- }
61
- };
62
- }
63
- } catch {
57
+ const stats = await safeStat(resolvedPath, vfsId);
58
+ if (!stats) {
64
59
  return {
65
60
  title: params.path,
66
61
  output: `Directory not found: ${params.path}`,
@@ -71,44 +66,32 @@ export namespace ListTool {
71
66
  }
72
67
  };
73
68
  }
69
+ if (!stats.isDirectory) {
70
+ return {
71
+ title: params.path,
72
+ output: `Path is not a directory: ${params.path}`,
73
+ metadata: {
74
+ entries: [],
75
+ fileCount: 0,
76
+ directoryCount: 0
77
+ }
78
+ };
79
+ }
74
80
 
75
81
  // Read directory contents
76
- const dirents = await fs.readdir(resolvedPath, { withFileTypes: true });
77
-
78
- // Process entries
79
82
  const entries: Entry[] = [];
80
83
 
84
+ const dirents = await VirtualFs.readdir(resolvedPath, vfsId);
81
85
  for (const dirent of dirents) {
82
86
  let type: Entry['type'] = 'other';
83
87
  let size: number | undefined;
84
-
85
- if (dirent.isDirectory()) {
88
+ if (dirent.isDirectory) {
86
89
  type = 'directory';
87
- } else if (dirent.isFile()) {
90
+ } else if (dirent.isFile) {
88
91
  type = 'file';
89
- try {
90
- const stats = await fs.stat(path.join(resolvedPath, dirent.name));
91
- size = stats.size;
92
- } catch {
93
- // Ignore stat errors
94
- }
95
- } else if (dirent.isSymbolicLink()) {
96
- type = 'symlink';
97
- // Try to determine if symlink points to file or directory
98
- try {
99
- const stats = await fs.stat(path.join(resolvedPath, dirent.name));
100
- if (stats.isDirectory()) {
101
- type = 'directory';
102
- } else if (stats.isFile()) {
103
- type = 'file';
104
- size = stats.size;
105
- }
106
- } catch {
107
- // Keep as symlink if we can't resolve
108
- type = 'symlink';
109
- }
92
+ const stats = await safeStat(path.posix.join(resolvedPath, dirent.name), vfsId);
93
+ size = stats?.size;
110
94
  }
111
-
112
95
  entries.push({
113
96
  name: dirent.name,
114
97
  type,
@@ -135,8 +118,6 @@ export namespace ListTool {
135
118
 
136
119
  if (entry.type === 'directory') {
137
120
  line = `[DIR] ${entry.name}/`;
138
- } else if (entry.type === 'symlink') {
139
- line = `[LNK] ${entry.name}`;
140
121
  } else if (entry.type === 'file') {
141
122
  const sizeStr = entry.size !== undefined ? formatSize(entry.size) : '';
142
123
  line = `[FILE] ${entry.name}${sizeStr ? ` (${sizeStr})` : ''}`;
package/src/tools/read.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * Read Tool
3
3
  * Reads file contents with line numbers, truncation, and special file handling
4
4
  */
5
- import * as fs from 'node:fs/promises';
6
5
  import * as path from 'node:path';
7
6
  import { z } from 'zod';
7
+ import { Result } from 'better-result';
8
8
 
9
- import { Sandbox } from './sandbox.ts';
9
+ import type { ToolContext } from './context.ts';
10
+ import { VirtualSandbox } from './virtual-sandbox.ts';
11
+ import { VirtualFs } from '../vfs/virtual-fs.ts';
10
12
 
11
13
  export namespace ReadTool {
12
14
  // Configuration
@@ -65,48 +67,38 @@ export namespace ReadTool {
65
67
  /**
66
68
  * Check if a file is binary by looking for null bytes
67
69
  */
68
- async function isBinaryFile(filepath: string): Promise<boolean> {
69
- const file = Bun.file(filepath);
70
- const chunk = await file.slice(0, 8192).arrayBuffer();
71
- const bytes = new Uint8Array(chunk);
72
-
70
+ function isBinaryBuffer(bytes: Uint8Array): boolean {
73
71
  for (const byte of bytes) {
74
- if (byte === 0) {
75
- return true;
76
- }
72
+ if (byte === 0) return true;
77
73
  }
78
-
79
74
  return false;
80
75
  }
81
76
 
82
77
  /**
83
78
  * Execute the read tool
84
79
  */
85
- export async function execute(
86
- params: ParametersType,
87
- context: { basePath: string }
88
- ): Promise<Result> {
89
- const { basePath } = context;
80
+ export async function execute(params: ParametersType, context: ToolContext): Promise<Result> {
81
+ const { basePath, vfsId } = context;
90
82
 
91
83
  // Validate and resolve path within sandbox
92
- const resolvedPath = await Sandbox.resolvePathWithSymlinks(basePath, params.path);
84
+ const resolvedPath = await VirtualSandbox.resolvePathWithSymlinks(basePath, params.path, vfsId);
93
85
 
94
86
  // Check if file exists
95
- const file = Bun.file(resolvedPath);
96
- if (!(await file.exists())) {
87
+ const exists = await VirtualFs.exists(resolvedPath, vfsId);
88
+ if (!exists) {
97
89
  // Try to provide suggestions
98
90
  const dir = path.dirname(resolvedPath);
99
91
  const filename = path.basename(resolvedPath);
100
92
  let suggestions: string[] = [];
101
93
 
102
- try {
103
- const files = await fs.readdir(dir);
104
- suggestions = files
105
- .filter((f) => f.toLowerCase().includes(filename.toLowerCase().slice(0, 3)))
106
- .slice(0, 5);
107
- } catch {
108
- // Directory doesn't exist
109
- }
94
+ const filesResult = await Result.tryPromise(() => VirtualFs.readdir(dir, vfsId));
95
+ const files = filesResult.match({
96
+ ok: (entries) => entries.map((entry) => entry.name),
97
+ err: () => []
98
+ });
99
+ suggestions = files
100
+ .filter((f) => f.toLowerCase().includes(filename.toLowerCase().slice(0, 3)))
101
+ .slice(0, 5);
110
102
 
111
103
  const suggestionText =
112
104
  suggestions.length > 0
@@ -127,9 +119,9 @@ export namespace ReadTool {
127
119
 
128
120
  // Handle images
129
121
  if (IMAGE_EXTENSIONS.has(ext)) {
130
- const bytes = await file.arrayBuffer();
122
+ const bytes = await VirtualFs.readFileBuffer(resolvedPath, vfsId);
131
123
  const base64 = Buffer.from(bytes).toString('base64');
132
- const mime = file.type || 'application/octet-stream';
124
+ const mime = getImageMime(ext);
133
125
 
134
126
  return {
135
127
  title: params.path,
@@ -151,7 +143,7 @@ export namespace ReadTool {
151
143
 
152
144
  // Handle PDFs
153
145
  if (PDF_EXTENSIONS.has(ext)) {
154
- const bytes = await file.arrayBuffer();
146
+ const bytes = await VirtualFs.readFileBuffer(resolvedPath, vfsId);
155
147
  const base64 = Buffer.from(bytes).toString('base64');
156
148
 
157
149
  return {
@@ -173,7 +165,7 @@ export namespace ReadTool {
173
165
  }
174
166
 
175
167
  // Check for binary files
176
- if (await isBinaryFile(resolvedPath)) {
168
+ if (isBinaryBuffer(await VirtualFs.readFileBuffer(resolvedPath, vfsId))) {
177
169
  return {
178
170
  title: params.path,
179
171
  output: `[Binary file: ${path.basename(resolvedPath)}]`,
@@ -186,7 +178,7 @@ export namespace ReadTool {
186
178
  }
187
179
 
188
180
  // Read text file
189
- const text = await file.text();
181
+ const text = await VirtualFs.readFile(resolvedPath, vfsId);
190
182
  const allLines = text.split('\n');
191
183
 
192
184
  const offset = params.offset ?? 0;
@@ -252,4 +244,26 @@ export namespace ReadTool {
252
244
  }
253
245
  };
254
246
  }
247
+
248
+ function getImageMime(ext: string): string {
249
+ switch (ext) {
250
+ case '.png':
251
+ return 'image/png';
252
+ case '.jpg':
253
+ case '.jpeg':
254
+ return 'image/jpeg';
255
+ case '.gif':
256
+ return 'image/gif';
257
+ case '.webp':
258
+ return 'image/webp';
259
+ case '.bmp':
260
+ return 'image/bmp';
261
+ case '.ico':
262
+ return 'image/x-icon';
263
+ case '.svg':
264
+ return 'image/svg+xml';
265
+ default:
266
+ return 'application/octet-stream';
267
+ }
268
+ }
255
269
  }
@@ -0,0 +1,103 @@
1
+ import * as path from 'node:path';
2
+
3
+ import { Result } from 'better-result';
4
+
5
+ import { VirtualFs } from '../vfs/virtual-fs.ts';
6
+
7
+ const posix = path.posix;
8
+
9
+ export namespace VirtualSandbox {
10
+ export class PathEscapeError extends Error {
11
+ readonly _tag = 'PathEscapeError';
12
+ readonly requestedPath: string;
13
+ readonly basePath: string;
14
+
15
+ constructor(requestedPath: string, basePath: string) {
16
+ super(
17
+ `Path "${requestedPath}" is outside the allowed directory "${basePath}". Access denied.`
18
+ );
19
+ this.requestedPath = requestedPath;
20
+ this.basePath = basePath;
21
+ }
22
+ }
23
+
24
+ export class PathNotFoundError extends Error {
25
+ readonly _tag = 'PathNotFoundError';
26
+ readonly requestedPath: string;
27
+
28
+ constructor(requestedPath: string) {
29
+ super(`Path "${requestedPath}" does not exist.`);
30
+ this.requestedPath = requestedPath;
31
+ }
32
+ }
33
+
34
+ export function resolvePath(basePath: string, requestedPath: string): string {
35
+ const normalizedBase = posix.resolve('/', basePath);
36
+ const resolved = posix.isAbsolute(requestedPath)
37
+ ? posix.resolve(requestedPath)
38
+ : posix.resolve(normalizedBase, requestedPath);
39
+ const normalized = posix.normalize(resolved);
40
+ const relative = posix.relative(normalizedBase, normalized);
41
+
42
+ if (relative.startsWith('..') || posix.isAbsolute(relative)) {
43
+ throw new PathEscapeError(requestedPath, basePath);
44
+ }
45
+
46
+ return normalized;
47
+ }
48
+
49
+ export async function resolvePathWithSymlinks(
50
+ basePath: string,
51
+ requestedPath: string,
52
+ vfsId?: string
53
+ ) {
54
+ const resolved = resolvePath(basePath, requestedPath);
55
+ const result = await Result.tryPromise(() => VirtualFs.realpath(resolved, vfsId));
56
+ return result.match({
57
+ ok: (value) => value,
58
+ err: () => resolved
59
+ });
60
+ }
61
+
62
+ export async function exists(basePath: string, requestedPath: string, vfsId?: string) {
63
+ const resolvedResult = Result.try(() => resolvePath(basePath, requestedPath));
64
+ if (!Result.isOk(resolvedResult)) return false;
65
+ const result = await Result.tryPromise(() => VirtualFs.exists(resolvedResult.value, vfsId));
66
+ return result.match({
67
+ ok: (value) => value,
68
+ err: () => false
69
+ });
70
+ }
71
+
72
+ export async function isDirectory(basePath: string, requestedPath: string, vfsId?: string) {
73
+ const resolvedResult = Result.try(() => resolvePath(basePath, requestedPath));
74
+ if (!Result.isOk(resolvedResult)) return false;
75
+ const result = await Result.tryPromise(() => VirtualFs.stat(resolvedResult.value, vfsId));
76
+ return result.match({
77
+ ok: (stats) => stats.isDirectory,
78
+ err: () => false
79
+ });
80
+ }
81
+
82
+ export async function isFile(basePath: string, requestedPath: string, vfsId?: string) {
83
+ const resolvedResult = Result.try(() => resolvePath(basePath, requestedPath));
84
+ if (!Result.isOk(resolvedResult)) return false;
85
+ const result = await Result.tryPromise(() => VirtualFs.stat(resolvedResult.value, vfsId));
86
+ return result.match({
87
+ ok: (stats) => stats.isFile,
88
+ err: () => false
89
+ });
90
+ }
91
+
92
+ export async function validatePath(basePath: string, requestedPath: string, vfsId?: string) {
93
+ const resolved = resolvePath(basePath, requestedPath);
94
+ if (!(await VirtualFs.exists(resolved, vfsId))) {
95
+ throw new PathNotFoundError(requestedPath);
96
+ }
97
+ return resolved;
98
+ }
99
+
100
+ export function getRelativePath(basePath: string, resolvedPath: string): string {
101
+ return posix.relative(basePath, resolvedPath);
102
+ }
103
+ }