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/README.md +195 -0
- package/package.json +56 -0
- package/src/agent/agent.test.ts +111 -0
- package/src/agent/index.ts +2 -0
- package/src/agent/service.ts +328 -0
- package/src/agent/types.ts +16 -0
- package/src/collections/index.ts +2 -0
- package/src/collections/service.ts +100 -0
- package/src/collections/types.ts +18 -0
- package/src/config/config.test.ts +119 -0
- package/src/config/index.ts +563 -0
- package/src/context/index.ts +24 -0
- package/src/context/transaction.ts +28 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +468 -0
- package/src/metrics/index.ts +60 -0
- package/src/resources/helpers.ts +10 -0
- package/src/resources/impls/git.test.ts +119 -0
- package/src/resources/impls/git.ts +156 -0
- package/src/resources/index.ts +10 -0
- package/src/resources/schema.ts +178 -0
- package/src/resources/service.ts +75 -0
- package/src/resources/types.ts +29 -0
- package/src/stream/index.ts +19 -0
- package/src/stream/service.ts +161 -0
- package/src/stream/types.ts +101 -0
- package/src/validation/index.ts +440 -0
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
|
+
});
|