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
@@ -1,6 +1,8 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import { Result } from 'better-result';
5
+
4
6
  import { Metrics } from '../../metrics/index.ts';
5
7
  import { CommonHints } from '../../errors.ts';
6
8
  import { ResourceError, resourceNameToKey } from '../helpers.ts';
@@ -28,12 +30,11 @@ const validateSearchPath = (
28
30
  };
29
31
 
30
32
  const directoryExists = async (path: string): Promise<boolean> => {
31
- try {
32
- const stat = await fs.stat(path);
33
- return stat.isDirectory();
34
- } catch {
35
- return false;
36
- }
33
+ const result = await Result.tryPromise(() => fs.stat(path));
34
+ return result.match({
35
+ ok: (stat) => stat.isDirectory(),
36
+ err: () => false
37
+ });
37
38
  };
38
39
 
39
40
  /**
@@ -146,6 +147,17 @@ interface GitRunResult {
146
147
  stderr: string;
147
148
  }
148
149
 
150
+ const runGitChecked = async (
151
+ args: string[],
152
+ options: { cwd?: string; quiet: boolean },
153
+ buildError: (result: GitRunResult) => ResourceError
154
+ ) => {
155
+ const result = await Result.tryPromise(() => runGit(args, options));
156
+ return result.andThen((runResult) =>
157
+ runResult.exitCode === 0 ? Result.ok(runResult) : Result.err(buildError(runResult))
158
+ );
159
+ };
160
+
149
161
  const runGit = async (
150
162
  args: string[],
151
163
  options: { cwd?: string; quiet: boolean }
@@ -236,54 +248,67 @@ const gitClone = async (args: {
236
248
  ]
237
249
  : ['clone', '--depth', '1', '-b', args.repoBranch, args.repoUrl, args.localAbsolutePath];
238
250
 
239
- const result = await runGit(cloneArgs, { quiet: args.quiet });
240
-
241
- if (result.exitCode !== 0) {
242
- const errorType = detectGitErrorType(result.stderr);
243
- const { message, hint } = getGitErrorDetails(errorType, {
244
- operation: 'clone',
245
- branch: args.repoBranch,
246
- url: args.repoUrl
247
- });
248
-
249
- throw new ResourceError({
250
- message,
251
- hint,
252
- cause: new Error(`git clone failed with exit code ${result.exitCode}: ${result.stderr}`)
253
- });
254
- }
255
-
256
- if (needsSparseCheckout) {
257
- const sparseResult = await runGit(['sparse-checkout', 'set', ...args.repoSubPaths], {
258
- cwd: args.localAbsolutePath,
259
- quiet: args.quiet
260
- });
251
+ const result = await Result.gen(async function* () {
252
+ yield* Result.await(
253
+ runGitChecked(cloneArgs, { quiet: args.quiet }, (cloneResult) => {
254
+ const errorType = detectGitErrorType(cloneResult.stderr);
255
+ const { message, hint } = getGitErrorDetails(errorType, {
256
+ operation: 'clone',
257
+ branch: args.repoBranch,
258
+ url: args.repoUrl
259
+ });
261
260
 
262
- if (sparseResult.exitCode !== 0) {
263
- throw new ResourceError({
264
- message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`,
265
- hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.',
266
- cause: new Error(
267
- `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}`
261
+ return new ResourceError({
262
+ message,
263
+ hint,
264
+ cause: new Error(
265
+ `git clone failed with exit code ${cloneResult.exitCode}: ${cloneResult.stderr}`
266
+ )
267
+ });
268
+ })
269
+ );
270
+
271
+ if (needsSparseCheckout) {
272
+ yield* Result.await(
273
+ runGitChecked(
274
+ ['sparse-checkout', 'set', ...args.repoSubPaths],
275
+ { cwd: args.localAbsolutePath, quiet: args.quiet },
276
+ (sparseResult) =>
277
+ new ResourceError({
278
+ message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`,
279
+ hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.',
280
+ cause: new Error(
281
+ `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}`
282
+ )
283
+ })
268
284
  )
269
- });
285
+ );
286
+
287
+ yield* Result.await(
288
+ runGitChecked(
289
+ ['checkout'],
290
+ { cwd: args.localAbsolutePath, quiet: args.quiet },
291
+ (checkout) =>
292
+ new ResourceError({
293
+ message: 'Failed to checkout repository',
294
+ hint: CommonHints.CLEAR_CACHE,
295
+ cause: new Error(
296
+ `git checkout failed with exit code ${checkout.exitCode}: ${checkout.stderr}`
297
+ )
298
+ })
299
+ )
300
+ );
270
301
  }
271
302
 
272
- const checkoutResult = await runGit(['checkout'], {
273
- cwd: args.localAbsolutePath,
274
- quiet: args.quiet
275
- });
303
+ return Result.ok(undefined);
304
+ });
276
305
 
277
- if (checkoutResult.exitCode !== 0) {
278
- throw new ResourceError({
279
- message: 'Failed to checkout repository',
280
- hint: CommonHints.CLEAR_CACHE,
281
- cause: new Error(
282
- `git checkout failed with exit code ${checkoutResult.exitCode}: ${checkoutResult.stderr}`
283
- )
284
- });
306
+ result.match({
307
+ ok: () => undefined,
308
+ err: (error) => {
309
+ throw error;
285
310
  }
286
- }
311
+ });
287
312
  };
288
313
 
289
314
  const gitUpdate = async (args: {
@@ -292,73 +317,85 @@ const gitUpdate = async (args: {
292
317
  repoSubPaths: readonly string[];
293
318
  quiet: boolean;
294
319
  }) => {
295
- const fetchResult = await runGit(['fetch', '--depth', '1', 'origin', args.branch], {
296
- cwd: args.localAbsolutePath,
297
- quiet: args.quiet
298
- });
299
-
300
- if (fetchResult.exitCode !== 0) {
301
- const errorType = detectGitErrorType(fetchResult.stderr);
302
- const { message, hint } = getGitErrorDetails(errorType, {
303
- operation: 'fetch',
304
- branch: args.branch
305
- });
306
-
307
- throw new ResourceError({
308
- message,
309
- hint,
310
- cause: new Error(
311
- `git fetch failed with exit code ${fetchResult.exitCode}: ${fetchResult.stderr}`
320
+ const result = await Result.gen(async function* () {
321
+ yield* Result.await(
322
+ runGitChecked(
323
+ ['fetch', '--depth', '1', 'origin', args.branch],
324
+ { cwd: args.localAbsolutePath, quiet: args.quiet },
325
+ (fetchResult) => {
326
+ const errorType = detectGitErrorType(fetchResult.stderr);
327
+ const { message, hint } = getGitErrorDetails(errorType, {
328
+ operation: 'fetch',
329
+ branch: args.branch
330
+ });
331
+
332
+ return new ResourceError({
333
+ message,
334
+ hint,
335
+ cause: new Error(
336
+ `git fetch failed with exit code ${fetchResult.exitCode}: ${fetchResult.stderr}`
337
+ )
338
+ });
339
+ }
312
340
  )
313
- });
314
- }
315
-
316
- const resetResult = await runGit(['reset', '--hard', `origin/${args.branch}`], {
317
- cwd: args.localAbsolutePath,
318
- quiet: args.quiet
319
- });
320
-
321
- if (resetResult.exitCode !== 0) {
322
- throw new ResourceError({
323
- message: 'Failed to update local repository',
324
- hint: `${CommonHints.CLEAR_CACHE} This will re-clone the repository from scratch.`,
325
- cause: new Error(
326
- `git reset failed with exit code ${resetResult.exitCode}: ${resetResult.stderr}`
341
+ );
342
+
343
+ yield* Result.await(
344
+ runGitChecked(
345
+ ['reset', '--hard', `origin/${args.branch}`],
346
+ { cwd: args.localAbsolutePath, quiet: args.quiet },
347
+ (resetResult) =>
348
+ new ResourceError({
349
+ message: 'Failed to update local repository',
350
+ hint: `${CommonHints.CLEAR_CACHE} This will re-clone the repository from scratch.`,
351
+ cause: new Error(
352
+ `git reset failed with exit code ${resetResult.exitCode}: ${resetResult.stderr}`
353
+ )
354
+ })
327
355
  )
328
- });
329
- }
330
-
331
- if (args.repoSubPaths.length > 0) {
332
- const sparseResult = await runGit(['sparse-checkout', 'set', ...args.repoSubPaths], {
333
- cwd: args.localAbsolutePath,
334
- quiet: args.quiet
335
- });
336
-
337
- if (sparseResult.exitCode !== 0) {
338
- throw new ResourceError({
339
- message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`,
340
- hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.',
341
- cause: new Error(
342
- `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}`
356
+ );
357
+
358
+ if (args.repoSubPaths.length > 0) {
359
+ yield* Result.await(
360
+ runGitChecked(
361
+ ['sparse-checkout', 'set', ...args.repoSubPaths],
362
+ { cwd: args.localAbsolutePath, quiet: args.quiet },
363
+ (sparseResult) =>
364
+ new ResourceError({
365
+ message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`,
366
+ hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.',
367
+ cause: new Error(
368
+ `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}`
369
+ )
370
+ })
343
371
  )
344
- });
372
+ );
373
+
374
+ yield* Result.await(
375
+ runGitChecked(
376
+ ['checkout'],
377
+ { cwd: args.localAbsolutePath, quiet: args.quiet },
378
+ (checkoutResult) =>
379
+ new ResourceError({
380
+ message: 'Failed to checkout repository',
381
+ hint: CommonHints.CLEAR_CACHE,
382
+ cause: new Error(
383
+ `git checkout failed with exit code ${checkoutResult.exitCode}: ${checkoutResult.stderr}`
384
+ )
385
+ })
386
+ )
387
+ );
345
388
  }
346
389
 
347
- const checkoutResult = await runGit(['checkout'], {
348
- cwd: args.localAbsolutePath,
349
- quiet: args.quiet
350
- });
390
+ return Result.ok(undefined);
391
+ });
351
392
 
352
- if (checkoutResult.exitCode !== 0) {
353
- throw new ResourceError({
354
- message: 'Failed to checkout repository',
355
- hint: CommonHints.CLEAR_CACHE,
356
- cause: new Error(
357
- `git checkout failed with exit code ${checkoutResult.exitCode}: ${checkoutResult.stderr}`
358
- )
359
- });
393
+ result.match({
394
+ ok: () => undefined,
395
+ err: (error) => {
396
+ throw error;
360
397
  }
361
- }
398
+ });
362
399
  };
363
400
 
364
401
  /**
@@ -438,15 +475,21 @@ const ensureGitResource = async (config: BtcaGitResourceArgs): Promise<string> =
438
475
  repoSubPaths: config.repoSubPaths
439
476
  });
440
477
 
441
- try {
442
- await fs.mkdir(config.resourcesDirectoryPath, { recursive: true });
443
- } catch (cause) {
444
- throw new ResourceError({
445
- message: 'Failed to create resources directory',
446
- hint: 'Check that you have write permissions to the btca data directory.',
447
- cause
448
- });
449
- }
478
+ const mkdirResult = await Result.tryPromise({
479
+ try: () => fs.mkdir(config.resourcesDirectoryPath, { recursive: true }),
480
+ catch: (cause) =>
481
+ new ResourceError({
482
+ message: 'Failed to create resources directory',
483
+ hint: 'Check that you have write permissions to the btca data directory.',
484
+ cause
485
+ })
486
+ });
487
+ mkdirResult.match({
488
+ ok: () => undefined,
489
+ err: (error) => {
490
+ throw error;
491
+ }
492
+ });
450
493
 
451
494
  await gitClone({
452
495
  repoUrl: config.url,
File without changes
@@ -1,3 +1,4 @@
1
+ import { Result } from 'better-result';
1
2
  import { z } from 'zod';
2
3
 
3
4
  import { LIMITS } from '../validation/index.ts';
@@ -18,6 +19,12 @@ const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0
18
19
  */
19
20
  const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/;
20
21
 
22
+ const parseUrl = (value: string) =>
23
+ Result.try(() => new URL(value)).match({
24
+ ok: (url) => url,
25
+ err: () => null
26
+ });
27
+
21
28
  // ─────────────────────────────────────────────────────────────────────────────
22
29
  // Field Schemas
23
30
  // ─────────────────────────────────────────────────────────────────────────────
@@ -52,43 +59,33 @@ const GitUrlSchema = z
52
59
  .min(1, 'Git URL cannot be empty')
53
60
  .refine(
54
61
  (url) => {
55
- try {
56
- const parsed = new URL(url);
57
- return parsed.protocol === 'https:';
58
- } catch {
59
- return false;
60
- }
62
+ const parsed = parseUrl(url);
63
+ return parsed ? parsed.protocol === 'https:' : false;
61
64
  },
62
65
  { message: 'Git URL must be a valid HTTPS URL' }
63
66
  )
64
67
  .refine(
65
68
  (url) => {
66
- try {
67
- const parsed = new URL(url);
68
- return !parsed.username && !parsed.password;
69
- } catch {
70
- return true; // Let the URL check above handle invalid URLs
71
- }
69
+ const parsed = parseUrl(url);
70
+ if (!parsed) return true;
71
+ return !parsed.username && !parsed.password;
72
72
  },
73
73
  { message: 'Git URL must not contain embedded credentials' }
74
74
  )
75
75
  .refine(
76
76
  (url) => {
77
- try {
78
- const parsed = new URL(url);
79
- const hostname = parsed.hostname.toLowerCase();
80
- return !(
81
- hostname === 'localhost' ||
82
- hostname.startsWith('127.') ||
83
- hostname.startsWith('192.168.') ||
84
- hostname.startsWith('10.') ||
85
- hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) ||
86
- hostname === '::1' ||
87
- hostname === '0.0.0.0'
88
- );
89
- } catch {
90
- return true;
91
- }
77
+ const parsed = parseUrl(url);
78
+ if (!parsed) return true;
79
+ const hostname = parsed.hostname.toLowerCase();
80
+ return !(
81
+ hostname === 'localhost' ||
82
+ hostname.startsWith('127.') ||
83
+ hostname.startsWith('192.168.') ||
84
+ hostname.startsWith('10.') ||
85
+ hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) ||
86
+ hostname === '::1' ||
87
+ hostname === '0.0.0.0'
88
+ );
92
89
  },
93
90
  { message: 'Git URL must not point to localhost or private IP addresses' }
94
91
  );
File without changes
File without changes
File without changes
@@ -1,6 +1,8 @@
1
+ import { stripUserQuestionFromStart, extractCoreQuestion } from '@btca/shared';
2
+ import { Result } from 'better-result';
3
+
1
4
  import { getErrorMessage, getErrorTag } from '../errors.ts';
2
5
  import { Metrics } from '../metrics/index.ts';
3
- import { stripUserQuestionFromStart, extractCoreQuestion } from '@btca/shared';
4
6
  import type { AgentLoop } from '../agent/loop.ts';
5
7
 
6
8
  import type {
@@ -52,7 +54,7 @@ export namespace StreamService {
52
54
  emit(controller, args.meta);
53
55
 
54
56
  (async () => {
55
- try {
57
+ const result = await Result.tryPromise(async () => {
56
58
  for await (const event of args.eventStream) {
57
59
  switch (event.type) {
58
60
  case 'text-delta': {
@@ -157,18 +159,25 @@ export namespace StreamService {
157
159
  }
158
160
  }
159
161
  }
160
- } catch (cause) {
161
- Metrics.error('stream.error', {
162
- collectionKey: args.meta.collection.key,
163
- error: Metrics.errorInfo(cause)
164
- });
165
- const err: BtcaStreamErrorEvent = {
166
- type: 'error',
167
- tag: getErrorTag(cause),
168
- message: getErrorMessage(cause)
169
- };
170
- emit(controller, err);
171
- } finally {
162
+ });
163
+
164
+ result.match({
165
+ ok: () => undefined,
166
+ err: (cause) => {
167
+ Metrics.error('stream.error', {
168
+ collectionKey: args.meta.collection.key,
169
+ error: Metrics.errorInfo(cause)
170
+ });
171
+ const err: BtcaStreamErrorEvent = {
172
+ type: 'error',
173
+ tag: getErrorTag(cause),
174
+ message: getErrorMessage(cause)
175
+ };
176
+ emit(controller, err);
177
+ }
178
+ });
179
+
180
+ {
172
181
  Metrics.info('stream.closed', { collectionKey: args.meta.collection.key });
173
182
  controller.close();
174
183
  }
@@ -0,0 +1,4 @@
1
+ export type ToolContext = {
2
+ basePath: string;
3
+ vfsId?: string;
4
+ };
package/src/tools/glob.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Glob Tool
3
- * Fast file pattern matching using ripgrep
3
+ * Fast file pattern matching 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 GlobTool {
13
14
  // Configuration
@@ -36,32 +37,26 @@ export namespace GlobTool {
36
37
  };
37
38
  };
38
39
 
40
+ const safeStat = async (filePath: string, vfsId?: string) => {
41
+ const result = await Result.tryPromise(() => VirtualFs.stat(filePath, vfsId));
42
+ return result.match({
43
+ ok: (value) => value,
44
+ err: () => null
45
+ });
46
+ };
47
+
39
48
  /**
40
49
  * Execute the glob tool
41
50
  */
42
- export async function execute(
43
- params: ParametersType,
44
- context: { basePath: string }
45
- ): Promise<Result> {
46
- const { basePath } = context;
51
+ export async function execute(params: ParametersType, context: ToolContext): Promise<Result> {
52
+ const { basePath, vfsId } = context;
47
53
 
48
54
  // Resolve search path within sandbox
49
- const searchPath = params.path ? Sandbox.resolvePath(basePath, params.path) : basePath;
55
+ const searchPath = params.path ? VirtualSandbox.resolvePath(basePath, params.path) : basePath;
50
56
 
51
57
  // Validate the search path exists and is a directory
52
- try {
53
- const stats = await fs.stat(searchPath);
54
- if (!stats.isDirectory()) {
55
- return {
56
- title: params.pattern,
57
- output: `Path is not a directory: ${params.path || '.'}`,
58
- metadata: {
59
- count: 0,
60
- truncated: false
61
- }
62
- };
63
- }
64
- } catch {
58
+ const stats = await safeStat(searchPath, vfsId);
59
+ if (!stats) {
65
60
  return {
66
61
  title: params.pattern,
67
62
  output: `Directory not found: ${params.path || '.'}`,
@@ -71,36 +66,32 @@ export namespace GlobTool {
71
66
  }
72
67
  };
73
68
  }
69
+ if (!stats.isDirectory) {
70
+ return {
71
+ title: params.pattern,
72
+ output: `Path is not a directory: ${params.path || '.'}`,
73
+ metadata: {
74
+ count: 0,
75
+ truncated: false
76
+ }
77
+ };
78
+ }
74
79
 
75
80
  // Collect files matching the pattern
76
81
  const files: Array<{ path: string; mtime: number }> = [];
77
82
  let truncated = false;
78
83
 
79
- for await (const file of Ripgrep.files({
80
- cwd: searchPath,
81
- glob: [params.pattern],
82
- hidden: true
83
- })) {
84
+ const patternRegex = globToRegExp(params.pattern);
85
+ const allFiles = await VirtualFs.listFilesRecursive(searchPath, vfsId);
86
+ for (const file of allFiles) {
84
87
  if (files.length >= MAX_RESULTS) {
85
88
  truncated = true;
86
89
  break;
87
90
  }
88
-
89
- const fullPath = path.resolve(searchPath, file);
90
-
91
- try {
92
- const stats = await fs.stat(fullPath);
93
- files.push({
94
- path: fullPath,
95
- mtime: stats.mtime.getTime()
96
- });
97
- } catch {
98
- // Skip files we can't stat
99
- files.push({
100
- path: fullPath,
101
- mtime: 0
102
- });
103
- }
91
+ const relative = path.posix.relative(searchPath, file);
92
+ if (!patternRegex.test(relative)) continue;
93
+ const fileStats = await safeStat(file, vfsId);
94
+ files.push({ path: file, mtime: fileStats?.mtimeMs ?? 0 });
104
95
  }
105
96
 
106
97
  if (files.length === 0) {
@@ -118,7 +109,7 @@ export namespace GlobTool {
118
109
  files.sort((a, b) => b.mtime - a.mtime);
119
110
 
120
111
  // Format output with relative paths
121
- const outputLines = files.map((f) => path.relative(basePath, f.path));
112
+ const outputLines = files.map((f) => path.posix.relative(basePath, f.path));
122
113
 
123
114
  // Add truncation notice
124
115
  if (truncated) {
@@ -137,4 +128,40 @@ export namespace GlobTool {
137
128
  }
138
129
  };
139
130
  }
131
+
132
+ function globToRegExp(pattern: string): RegExp {
133
+ let regex = '^';
134
+ let i = 0;
135
+ while (i < pattern.length) {
136
+ const char = pattern[i] ?? '';
137
+ const next = pattern[i + 1] ?? '';
138
+ if (char === '*' && next === '*') {
139
+ regexAdd('.*');
140
+ i += 2;
141
+ continue;
142
+ }
143
+ if (char === '*') {
144
+ regexAdd('[^/]*');
145
+ i += 1;
146
+ continue;
147
+ }
148
+ if (char === '?') {
149
+ regexAdd('[^/]');
150
+ i += 1;
151
+ continue;
152
+ }
153
+ if ('\\.^$+{}()|[]'.includes(char)) {
154
+ regexAdd('\\' + char);
155
+ } else {
156
+ regexAdd(char);
157
+ }
158
+ i += 1;
159
+ }
160
+ regex += '$';
161
+ return new RegExp(regex);
162
+
163
+ function regexAdd(value: string) {
164
+ regex += value;
165
+ }
166
+ }
140
167
  }