btca-server 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,468 @@
1
+ import { Hono } from 'hono';
2
+ import type { Context as HonoContext, Next } from 'hono';
3
+ import { z } from 'zod';
4
+
5
+ import { Agent } from './agent/service.ts';
6
+ import { Collections } from './collections/service.ts';
7
+ import { getCollectionKey } from './collections/types.ts';
8
+ import { Config } from './config/index.ts';
9
+ import { Context } from './context/index.ts';
10
+ import { getErrorMessage, getErrorTag } from './errors.ts';
11
+ import { Metrics } from './metrics/index.ts';
12
+ import { Resources } from './resources/service.ts';
13
+ import { GitResourceSchema, LocalResourceSchema } from './resources/schema.ts';
14
+ import { StreamService } from './stream/service.ts';
15
+ import type { BtcaStreamMetaEvent } from './stream/types.ts';
16
+ import { LIMITS } from './validation/index.ts';
17
+
18
+ /**
19
+ * BTCA Server API
20
+ *
21
+ * Endpoints:
22
+ *
23
+ * GET / - Health check, returns { ok, service, version }
24
+ * GET /config - Returns current configuration (provider, model, directories)
25
+ * GET /resources - Lists all configured resources
26
+ * POST /question - Ask a question (non-streaming)
27
+ * POST /question/stream - Ask a question (streaming SSE response)
28
+ * POST /opencode - Get OpenCode instance URL for a collection
29
+ */
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Configuration
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ const DEFAULT_PORT = 8080;
36
+ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
37
+
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // Request Schemas
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Resource name pattern: must start with a letter, alphanumeric and hyphens only.
44
+ */
45
+ const RESOURCE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9-]*$/;
46
+
47
+ /**
48
+ * Safe name pattern for provider/model names.
49
+ */
50
+ const SAFE_NAME_REGEX = /^[a-zA-Z0-9._+\-/:]+$/;
51
+
52
+ /**
53
+ * Validated resource name field for request schemas.
54
+ */
55
+ const ResourceNameField = z
56
+ .string()
57
+ .min(1, 'Resource name cannot be empty')
58
+ .max(LIMITS.RESOURCE_NAME_MAX)
59
+ .regex(RESOURCE_NAME_REGEX, 'Invalid resource name format');
60
+
61
+ const QuestionRequestSchema = z.object({
62
+ question: z
63
+ .string()
64
+ .min(1, 'Question cannot be empty')
65
+ .max(LIMITS.QUESTION_MAX, `Question too long (max ${LIMITS.QUESTION_MAX} chars)`),
66
+ resources: z
67
+ .array(ResourceNameField)
68
+ .max(LIMITS.MAX_RESOURCES_PER_REQUEST, `Too many resources (max ${LIMITS.MAX_RESOURCES_PER_REQUEST})`)
69
+ .optional(),
70
+ quiet: z.boolean().optional()
71
+ });
72
+
73
+ const OpencodeRequestSchema = z.object({
74
+ resources: z
75
+ .array(ResourceNameField)
76
+ .max(LIMITS.MAX_RESOURCES_PER_REQUEST, `Too many resources (max ${LIMITS.MAX_RESOURCES_PER_REQUEST})`)
77
+ .optional(),
78
+ quiet: z.boolean().optional()
79
+ });
80
+
81
+ const UpdateModelRequestSchema = z.object({
82
+ provider: z
83
+ .string()
84
+ .min(1, 'Provider name cannot be empty')
85
+ .max(LIMITS.PROVIDER_NAME_MAX)
86
+ .regex(SAFE_NAME_REGEX, 'Invalid provider name format'),
87
+ model: z
88
+ .string()
89
+ .min(1, 'Model name cannot be empty')
90
+ .max(LIMITS.MODEL_NAME_MAX)
91
+ .regex(SAFE_NAME_REGEX, 'Invalid model name format')
92
+ });
93
+
94
+ /**
95
+ * Add resource request - uses the full resource schemas for validation.
96
+ * This ensures all security checks (URL, branch, path traversal) are applied.
97
+ */
98
+ const AddGitResourceRequestSchema = z.object({
99
+ type: z.literal('git'),
100
+ name: GitResourceSchema.shape.name,
101
+ url: GitResourceSchema.shape.url,
102
+ branch: GitResourceSchema.shape.branch.optional().default('main'),
103
+ searchPath: GitResourceSchema.shape.searchPath,
104
+ specialNotes: GitResourceSchema.shape.specialNotes
105
+ });
106
+
107
+ const AddLocalResourceRequestSchema = z.object({
108
+ type: z.literal('local'),
109
+ name: LocalResourceSchema.shape.name,
110
+ path: LocalResourceSchema.shape.path,
111
+ specialNotes: LocalResourceSchema.shape.specialNotes
112
+ });
113
+
114
+ const AddResourceRequestSchema = z.discriminatedUnion('type', [
115
+ AddGitResourceRequestSchema,
116
+ AddLocalResourceRequestSchema
117
+ ]);
118
+
119
+ const RemoveResourceRequestSchema = z.object({
120
+ name: ResourceNameField
121
+ });
122
+
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ // Errors & Helpers
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ class RequestError extends Error {
128
+ readonly _tag = 'RequestError';
129
+
130
+ constructor(message: string, cause?: unknown) {
131
+ super(message, cause ? { cause } : undefined);
132
+ }
133
+ }
134
+
135
+ const decodeJson = async <T>(req: Request, schema: z.ZodType<T>): Promise<T> => {
136
+ let body: unknown;
137
+ try {
138
+ body = await req.json();
139
+ } catch (cause) {
140
+ throw new RequestError('Failed to parse request JSON', cause);
141
+ }
142
+
143
+ const parsed = schema.safeParse(body);
144
+ if (!parsed.success) throw new RequestError('Invalid request body', parsed.error);
145
+ return parsed.data;
146
+ };
147
+
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+ // App Factory
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+
152
+ const createApp = (deps: {
153
+ config: Config.Service;
154
+ resources: Resources.Service;
155
+ collections: Collections.Service;
156
+ agent: Agent.Service;
157
+ }) => {
158
+ const { config, collections, agent } = deps;
159
+
160
+ const app = new Hono()
161
+ // ─────────────────────────────────────────────────────────────────────
162
+ // Middleware
163
+ // ─────────────────────────────────────────────────────────────────────
164
+ .use('*', async (c: HonoContext, next: Next) => {
165
+ const requestId = crypto.randomUUID();
166
+ return Context.run({ requestId, txDepth: 0 }, async () => {
167
+ Metrics.info('http.request', { method: c.req.method, path: c.req.path });
168
+ try {
169
+ await next();
170
+ } finally {
171
+ Metrics.info('http.response', {
172
+ path: c.req.path,
173
+ status: c.res.status
174
+ });
175
+ }
176
+ });
177
+ })
178
+ .onError((err: Error, c: HonoContext) => {
179
+ Metrics.error('http.error', { error: Metrics.errorInfo(err) });
180
+ const tag = getErrorTag(err);
181
+ const message = getErrorMessage(err);
182
+ const status = tag === 'CollectionError' || tag === 'ResourceError' ? 400 : 500;
183
+ return c.json({ error: message, tag }, status);
184
+ })
185
+
186
+ // ─────────────────────────────────────────────────────────────────────
187
+ // Routes
188
+ // ─────────────────────────────────────────────────────────────────────
189
+
190
+ // GET / - Health check
191
+ .get('/', (c: HonoContext) => {
192
+ return c.json({
193
+ ok: true,
194
+ service: 'btca-server',
195
+ version: '0.0.1'
196
+ });
197
+ })
198
+
199
+ // GET /config
200
+ .get('/config', (c: HonoContext) => {
201
+ return c.json({
202
+ provider: config.provider,
203
+ model: config.model,
204
+ resourcesDirectory: config.resourcesDirectory,
205
+ collectionsDirectory: config.collectionsDirectory,
206
+ resourceCount: config.resources.length
207
+ });
208
+ })
209
+
210
+ // GET /resources
211
+ .get('/resources', (c: HonoContext) => {
212
+ return c.json({
213
+ resources: config.resources.map((r) => {
214
+ if (r.type === 'git') {
215
+ return {
216
+ name: r.name,
217
+ type: r.type,
218
+ url: r.url,
219
+ branch: r.branch,
220
+ searchPath: r.searchPath ?? null,
221
+ specialNotes: r.specialNotes ?? null
222
+ };
223
+ } else {
224
+ return {
225
+ name: r.name,
226
+ type: r.type,
227
+ path: r.path,
228
+ specialNotes: r.specialNotes ?? null
229
+ };
230
+ }
231
+ })
232
+ });
233
+ })
234
+
235
+ // POST /question
236
+ .post('/question', async (c: HonoContext) => {
237
+ const decoded = await decodeJson(c.req.raw, QuestionRequestSchema);
238
+ const resourceNames =
239
+ decoded.resources && decoded.resources.length > 0
240
+ ? decoded.resources
241
+ : config.resources.map((r) => r.name);
242
+
243
+ const collectionKey = getCollectionKey(resourceNames);
244
+ Metrics.info('question.received', {
245
+ stream: false,
246
+ quiet: decoded.quiet ?? false,
247
+ questionLength: decoded.question.length,
248
+ resources: resourceNames,
249
+ collectionKey
250
+ });
251
+
252
+ const collection = await collections.load({ resourceNames, quiet: decoded.quiet });
253
+ Metrics.info('collection.ready', { collectionKey, path: collection.path });
254
+
255
+ const result = await agent.ask({ collection, question: decoded.question });
256
+ Metrics.info('question.done', {
257
+ collectionKey,
258
+ answerLength: result.answer.length,
259
+ model: result.model
260
+ });
261
+
262
+ return c.json({
263
+ answer: result.answer,
264
+ model: result.model,
265
+ resources: resourceNames,
266
+ collection: { key: collectionKey, path: collection.path }
267
+ });
268
+ })
269
+
270
+ // POST /question/stream
271
+ .post('/question/stream', async (c: HonoContext) => {
272
+ const decoded = await decodeJson(c.req.raw, QuestionRequestSchema);
273
+ const resourceNames =
274
+ decoded.resources && decoded.resources.length > 0
275
+ ? decoded.resources
276
+ : config.resources.map((r) => r.name);
277
+
278
+ const collectionKey = getCollectionKey(resourceNames);
279
+ Metrics.info('question.received', {
280
+ stream: true,
281
+ quiet: decoded.quiet ?? false,
282
+ questionLength: decoded.question.length,
283
+ resources: resourceNames,
284
+ collectionKey
285
+ });
286
+
287
+ const collection = await collections.load({ resourceNames, quiet: decoded.quiet });
288
+ Metrics.info('collection.ready', { collectionKey, path: collection.path });
289
+
290
+ const { stream: eventStream, model } = await agent.askStream({
291
+ collection,
292
+ question: decoded.question
293
+ });
294
+
295
+ const meta = {
296
+ type: 'meta',
297
+ model,
298
+ resources: resourceNames,
299
+ collection: {
300
+ key: collectionKey,
301
+ path: collection.path
302
+ }
303
+ } satisfies BtcaStreamMetaEvent;
304
+
305
+ Metrics.info('question.stream.start', { collectionKey });
306
+ const stream = StreamService.createSseStream({ meta, eventStream });
307
+
308
+ return new Response(stream, {
309
+ headers: {
310
+ 'content-type': 'text/event-stream',
311
+ 'cache-control': 'no-cache',
312
+ connection: 'keep-alive'
313
+ }
314
+ });
315
+ })
316
+
317
+ // POST /opencode - Get OpenCode instance URL for a collection
318
+ .post('/opencode', async (c: HonoContext) => {
319
+ const decoded = await decodeJson(c.req.raw, OpencodeRequestSchema);
320
+ const resourceNames =
321
+ decoded.resources && decoded.resources.length > 0
322
+ ? decoded.resources
323
+ : config.resources.map((r) => r.name);
324
+
325
+ const collectionKey = getCollectionKey(resourceNames);
326
+ Metrics.info('opencode.requested', {
327
+ quiet: decoded.quiet ?? false,
328
+ resources: resourceNames,
329
+ collectionKey
330
+ });
331
+
332
+ const collection = await collections.load({ resourceNames, quiet: decoded.quiet });
333
+ Metrics.info('collection.ready', { collectionKey, path: collection.path });
334
+
335
+ const { url, model } = await agent.getOpencodeInstance({ collection });
336
+ Metrics.info('opencode.ready', { collectionKey, url });
337
+
338
+ return c.json({
339
+ url,
340
+ model,
341
+ resources: resourceNames,
342
+ collection: { key: collectionKey, path: collection.path }
343
+ });
344
+ })
345
+
346
+ // PUT /config/model - Update model configuration
347
+ .put('/config/model', async (c: HonoContext) => {
348
+ const decoded = await decodeJson(c.req.raw, UpdateModelRequestSchema);
349
+ const result = await config.updateModel(decoded.provider, decoded.model);
350
+ return c.json(result);
351
+ })
352
+
353
+ // POST /config/resources - Add a new resource
354
+ // All validation (URL, branch, path traversal, etc.) is handled by the schema
355
+ .post('/config/resources', async (c: HonoContext) => {
356
+ const decoded = await decodeJson(c.req.raw, AddResourceRequestSchema);
357
+
358
+ if (decoded.type === 'git') {
359
+ const resource = {
360
+ type: 'git' as const,
361
+ name: decoded.name,
362
+ url: decoded.url,
363
+ branch: decoded.branch ?? 'main',
364
+ ...(decoded.searchPath && { searchPath: decoded.searchPath }),
365
+ ...(decoded.specialNotes && { specialNotes: decoded.specialNotes })
366
+ };
367
+ const added = await config.addResource(resource);
368
+ return c.json(added, 201);
369
+ } else {
370
+ const resource = {
371
+ type: 'local' as const,
372
+ name: decoded.name,
373
+ path: decoded.path,
374
+ ...(decoded.specialNotes && { specialNotes: decoded.specialNotes })
375
+ };
376
+ const added = await config.addResource(resource);
377
+ return c.json(added, 201);
378
+ }
379
+ })
380
+
381
+ // DELETE /config/resources - Remove a resource
382
+ .delete('/config/resources', async (c: HonoContext) => {
383
+ const decoded = await decodeJson(c.req.raw, RemoveResourceRequestSchema);
384
+ await config.removeResource(decoded.name);
385
+ return c.json({ success: true, name: decoded.name });
386
+ })
387
+
388
+ // POST /clear - Clear all locally cloned resources
389
+ .post('/clear', async (c: HonoContext) => {
390
+ const result = await config.clearResources();
391
+ return c.json(result);
392
+ });
393
+
394
+ return app;
395
+ };
396
+
397
+ // Export app type for Hono RPC client
398
+ // We create a dummy app with null deps just to get the type
399
+ type AppType = ReturnType<typeof createApp>;
400
+ export type { AppType };
401
+
402
+ // ─────────────────────────────────────────────────────────────────────────────
403
+ // Server
404
+ // ─────────────────────────────────────────────────────────────────────────────
405
+
406
+ export interface ServerInstance {
407
+ port: number;
408
+ url: string;
409
+ stop: () => void;
410
+ }
411
+
412
+ export interface StartServerOptions {
413
+ port?: number;
414
+ quiet?: boolean;
415
+ }
416
+
417
+ /**
418
+ * Start the btca server programmatically.
419
+ * Returns a ServerInstance with the port, url, and stop function.
420
+ *
421
+ * If port is 0, a random available port will be assigned by the OS.
422
+ */
423
+ export const startServer = async (options: StartServerOptions = {}): Promise<ServerInstance> => {
424
+ if (options.quiet) {
425
+ Metrics.setQuiet(true);
426
+ }
427
+
428
+ const requestedPort = options.port ?? PORT;
429
+ Metrics.info('server.starting', { port: requestedPort });
430
+
431
+ const config = await Config.load();
432
+ Metrics.info('config.ready', {
433
+ provider: config.provider,
434
+ model: config.model,
435
+ resources: config.resources.map((r) => r.name),
436
+ resourcesDirectory: config.resourcesDirectory,
437
+ collectionsDirectory: config.collectionsDirectory
438
+ });
439
+
440
+ const resources = Resources.create(config);
441
+ const collections = Collections.create({ config, resources });
442
+ const agent = Agent.create(config);
443
+
444
+ const app = createApp({ config, resources, collections, agent });
445
+
446
+ const server = Bun.serve({
447
+ port: requestedPort,
448
+ fetch: app.fetch
449
+ });
450
+
451
+ const actualPort = server.port ?? requestedPort;
452
+ Metrics.info('server.started', { port: actualPort });
453
+
454
+ return {
455
+ port: actualPort,
456
+ url: `http://localhost:${actualPort}`,
457
+ stop: () => server.stop()
458
+ };
459
+ };
460
+
461
+ // Export all public types and interfaces for consumers
462
+ export type { BtcaStreamEvent, BtcaStreamMetaEvent } from './stream/types.ts';
463
+
464
+ // Auto-start when run directly (not imported)
465
+ const isMainModule = import.meta.main;
466
+ if (isMainModule) {
467
+ await startServer({ port: PORT });
468
+ }
@@ -0,0 +1,60 @@
1
+ import { Context } from '../context/index.ts';
2
+ import { getErrorMessage, getErrorTag } from '../errors.ts';
3
+
4
+ type LogLevel = 'info' | 'error';
5
+
6
+ let quietMode = false;
7
+
8
+ export namespace Metrics {
9
+ export type Fields = Record<string, unknown>;
10
+
11
+ export const setQuiet = (quiet: boolean) => {
12
+ quietMode = quiet;
13
+ };
14
+
15
+ export const isQuiet = () => quietMode;
16
+
17
+ export const errorInfo = (cause: unknown) => ({
18
+ tag: getErrorTag(cause),
19
+ message: getErrorMessage(cause)
20
+ });
21
+
22
+ const emit = (level: LogLevel, event: string, fields?: Fields) => {
23
+ if (quietMode) return;
24
+
25
+ const payload = {
26
+ ts: new Date().toISOString(),
27
+ level,
28
+ event,
29
+ requestId: Context.requestId(),
30
+ ...fields
31
+ };
32
+ const line = JSON.stringify(payload);
33
+ if (level === 'error') console.error(line);
34
+ else console.log(line);
35
+ };
36
+
37
+ export const info = (event: string, fields?: Fields) => emit('info', event, fields);
38
+ export const error = (event: string, fields?: Fields) => emit('error', event, fields);
39
+
40
+ export const span = async <T>(
41
+ name: string,
42
+ fn: () => Promise<T>,
43
+ fields?: Fields
44
+ ): Promise<T> => {
45
+ const start = performance.now();
46
+ try {
47
+ const result = await fn();
48
+ info('span.ok', { name, ms: Math.round(performance.now() - start), ...fields });
49
+ return result;
50
+ } catch (cause) {
51
+ error('span.err', {
52
+ name,
53
+ ms: Math.round(performance.now() - start),
54
+ ...fields,
55
+ error: errorInfo(cause)
56
+ });
57
+ throw cause;
58
+ }
59
+ };
60
+ }
@@ -0,0 +1,10 @@
1
+ export class ResourceError extends Error {
2
+ readonly _tag = 'ResourceError';
3
+ override readonly cause?: unknown;
4
+
5
+ constructor(args: { message: string; cause?: unknown; stack?: string }) {
6
+ super(args.message);
7
+ this.cause = args.cause;
8
+ if (args.stack) this.stack = args.stack;
9
+ }
10
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ import { loadGitResource } from './git.ts';
7
+ import type { BtcaGitResourceArgs } from '../types.ts';
8
+
9
+ describe('Git Resource', () => {
10
+ let testDir: string;
11
+
12
+ beforeEach(async () => {
13
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-git-test-'));
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await fs.rm(testDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe('loadGitResource', () => {
21
+ describe.skipIf(!process.env.BTCA_RUN_INTEGRATION_TESTS)('integration (network)', () => {
22
+ it('clones a git repository', async () => {
23
+ const args: BtcaGitResourceArgs = {
24
+ type: 'git',
25
+ name: 'test-repo',
26
+ url: 'https://github.com/honojs/hono',
27
+ branch: 'main',
28
+ repoSubPath: 'docs',
29
+ resourcesDirectoryPath: testDir,
30
+ specialAgentInstructions: 'Test notes',
31
+ quiet: true
32
+ };
33
+
34
+ const resource = await loadGitResource(args);
35
+
36
+ expect(resource._tag).toBe('fs-based');
37
+ expect(resource.name).toBe('test-repo');
38
+ expect(resource.type).toBe('git');
39
+ expect(resource.repoSubPath).toBe('docs');
40
+ expect(resource.specialAgentInstructions).toBe('Test notes');
41
+
42
+ const resourcePath = await resource.getAbsoluteDirectoryPath();
43
+ expect(resourcePath).toBe(path.join(testDir, 'test-repo'));
44
+
45
+ const stat = await fs.stat(resourcePath);
46
+ expect(stat.isDirectory()).toBe(true);
47
+
48
+ const gitDir = await fs.stat(path.join(resourcePath, '.git'));
49
+ expect(gitDir.isDirectory()).toBe(true);
50
+ }, 30000);
51
+
52
+ it('updates an existing git repository', async () => {
53
+ const args: BtcaGitResourceArgs = {
54
+ type: 'git',
55
+ name: 'update-test',
56
+ url: 'https://github.com/honojs/hono',
57
+ branch: 'main',
58
+ repoSubPath: '',
59
+ resourcesDirectoryPath: testDir,
60
+ specialAgentInstructions: '',
61
+ quiet: true
62
+ };
63
+
64
+ await loadGitResource(args);
65
+ const resource = await loadGitResource(args);
66
+
67
+ expect(resource.name).toBe('update-test');
68
+ const resourcePath = await resource.getAbsoluteDirectoryPath();
69
+ const stat = await fs.stat(resourcePath);
70
+ expect(stat.isDirectory()).toBe(true);
71
+ }, 60000);
72
+ });
73
+
74
+ it('throws error for invalid git URL', async () => {
75
+ const args: BtcaGitResourceArgs = {
76
+ type: 'git',
77
+ name: 'invalid-url',
78
+ url: 'not-a-valid-url',
79
+ branch: 'main',
80
+ repoSubPath: '',
81
+ resourcesDirectoryPath: testDir,
82
+ specialAgentInstructions: '',
83
+ quiet: true
84
+ };
85
+
86
+ expect(loadGitResource(args)).rejects.toThrow('Invalid git URL');
87
+ });
88
+
89
+ it('throws error for invalid branch name', async () => {
90
+ const args: BtcaGitResourceArgs = {
91
+ type: 'git',
92
+ name: 'invalid-branch',
93
+ url: 'https://github.com/test/repo',
94
+ branch: 'invalid branch name!',
95
+ repoSubPath: '',
96
+ resourcesDirectoryPath: testDir,
97
+ specialAgentInstructions: '',
98
+ quiet: true
99
+ };
100
+
101
+ expect(loadGitResource(args)).rejects.toThrow('Invalid branch name');
102
+ });
103
+
104
+ it('throws error for path traversal attempt', async () => {
105
+ const args: BtcaGitResourceArgs = {
106
+ type: 'git',
107
+ name: 'path-traversal',
108
+ url: 'https://github.com/test/repo',
109
+ branch: 'main',
110
+ repoSubPath: '../../../etc',
111
+ resourcesDirectoryPath: testDir,
112
+ specialAgentInstructions: '',
113
+ quiet: true
114
+ };
115
+
116
+ expect(loadGitResource(args)).rejects.toThrow('Invalid path');
117
+ });
118
+ });
119
+ });