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
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { Metrics } from '../metrics/index.ts';
|
|
5
|
+
import { ResourceDefinitionSchema, type ResourceDefinition } from '../resources/schema.ts';
|
|
6
|
+
|
|
7
|
+
export const GLOBAL_CONFIG_DIR = '~/.config/btca';
|
|
8
|
+
export const GLOBAL_CONFIG_FILENAME = 'btca.config.jsonc';
|
|
9
|
+
export const LEGACY_CONFIG_FILENAME = 'btca.json';
|
|
10
|
+
export const GLOBAL_DATA_DIR = '~/.local/share/btca';
|
|
11
|
+
export const PROJECT_CONFIG_FILENAME = 'btca.config.jsonc';
|
|
12
|
+
export const PROJECT_DATA_DIR = '.btca';
|
|
13
|
+
export const CONFIG_SCHEMA_URL = 'https://btca.dev/btca.schema.json';
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_MODEL = 'claude-haiku-4-5';
|
|
16
|
+
export const DEFAULT_PROVIDER = 'opencode';
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_RESOURCES: ResourceDefinition[] = [
|
|
19
|
+
{
|
|
20
|
+
name: 'svelte',
|
|
21
|
+
specialNotes:
|
|
22
|
+
'This is the svelte docs website repo, not the actual svelte repo. Focus on the content directory, it has all the markdown files for the docs.',
|
|
23
|
+
type: 'git',
|
|
24
|
+
url: 'https://github.com/sveltejs/svelte.dev',
|
|
25
|
+
branch: 'main',
|
|
26
|
+
searchPath: 'apps/svelte.dev'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'tailwindcss',
|
|
30
|
+
specialNotes:
|
|
31
|
+
'This is the tailwindcss docs website repo, not the actual tailwindcss repo. Use the docs to answer questions about tailwindcss.',
|
|
32
|
+
type: 'git',
|
|
33
|
+
url: 'https://github.com/tailwindlabs/tailwindcss.com',
|
|
34
|
+
searchPath: 'src/docs',
|
|
35
|
+
branch: 'main'
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: 'git',
|
|
39
|
+
name: 'nextjs',
|
|
40
|
+
url: 'https://github.com/vercel/next.js',
|
|
41
|
+
branch: 'canary',
|
|
42
|
+
searchPath: 'docs',
|
|
43
|
+
specialNotes:
|
|
44
|
+
'These are the docs for the next.js framework, not the actual next.js repo. Use the docs to answer questions about next.js.'
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const StoredConfigSchema = z.object({
|
|
49
|
+
$schema: z.string().optional(),
|
|
50
|
+
resources: z.array(ResourceDefinitionSchema),
|
|
51
|
+
model: z.string(),
|
|
52
|
+
provider: z.string()
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
type StoredConfig = z.infer<typeof StoredConfigSchema>;
|
|
56
|
+
|
|
57
|
+
// Legacy config schemas (btca.json format from old CLI)
|
|
58
|
+
// There are two legacy formats:
|
|
59
|
+
// 1. Very old: has "repos" array with git repos only
|
|
60
|
+
// 2. Intermediate: has "resources" array (already migrated repos->resources but different file name)
|
|
61
|
+
|
|
62
|
+
const LegacyRepoSchema = z.object({
|
|
63
|
+
name: z.string(),
|
|
64
|
+
url: z.string(),
|
|
65
|
+
branch: z.string(),
|
|
66
|
+
specialNotes: z.string().optional(),
|
|
67
|
+
searchPath: z.string().optional()
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Very old format with "repos"
|
|
71
|
+
const LegacyReposConfigSchema = z.object({
|
|
72
|
+
$schema: z.string().optional(),
|
|
73
|
+
reposDirectory: z.string().optional(),
|
|
74
|
+
workspacesDirectory: z.string().optional(),
|
|
75
|
+
dataDirectory: z.string().optional(),
|
|
76
|
+
port: z.number().optional(),
|
|
77
|
+
maxInstances: z.number().optional(),
|
|
78
|
+
repos: z.array(LegacyRepoSchema),
|
|
79
|
+
model: z.string(),
|
|
80
|
+
provider: z.string()
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Intermediate format with "resources" (same as new format, just different filename)
|
|
84
|
+
const LegacyResourcesConfigSchema = z.object({
|
|
85
|
+
$schema: z.string().optional(),
|
|
86
|
+
dataDirectory: z.string().optional(),
|
|
87
|
+
resources: z.array(ResourceDefinitionSchema),
|
|
88
|
+
model: z.string(),
|
|
89
|
+
provider: z.string()
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
type LegacyReposConfig = z.infer<typeof LegacyReposConfigSchema>;
|
|
93
|
+
type LegacyResourcesConfig = z.infer<typeof LegacyResourcesConfigSchema>;
|
|
94
|
+
type LegacyRepo = z.infer<typeof LegacyRepoSchema>;
|
|
95
|
+
|
|
96
|
+
export namespace Config {
|
|
97
|
+
export class ConfigError extends Error {
|
|
98
|
+
readonly _tag = 'ConfigError';
|
|
99
|
+
override readonly cause?: unknown;
|
|
100
|
+
|
|
101
|
+
constructor(args: { message: string; cause?: unknown }) {
|
|
102
|
+
super(args.message);
|
|
103
|
+
this.cause = args.cause;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type Service = {
|
|
108
|
+
resourcesDirectory: string;
|
|
109
|
+
collectionsDirectory: string;
|
|
110
|
+
resources: readonly ResourceDefinition[];
|
|
111
|
+
model: string;
|
|
112
|
+
provider: string;
|
|
113
|
+
configPath: string;
|
|
114
|
+
getResource: (name: string) => ResourceDefinition | undefined;
|
|
115
|
+
updateModel: (provider: string, model: string) => Promise<{ provider: string; model: string }>;
|
|
116
|
+
addResource: (resource: ResourceDefinition) => Promise<ResourceDefinition>;
|
|
117
|
+
removeResource: (name: string) => Promise<void>;
|
|
118
|
+
clearResources: () => Promise<{ cleared: number }>;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const expandHome = (path: string): string => {
|
|
122
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
123
|
+
if (path.startsWith('~/')) return home + path.slice(1);
|
|
124
|
+
return path;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const stripJsonc = (content: string): string => {
|
|
128
|
+
// Remove // and /* */ comments without touching strings.
|
|
129
|
+
let out = '';
|
|
130
|
+
let i = 0;
|
|
131
|
+
let inString = false;
|
|
132
|
+
let quote: '"' | "'" | null = null;
|
|
133
|
+
let escaped = false;
|
|
134
|
+
|
|
135
|
+
while (i < content.length) {
|
|
136
|
+
const ch = content[i] ?? '';
|
|
137
|
+
const next = content[i + 1] ?? '';
|
|
138
|
+
|
|
139
|
+
if (inString) {
|
|
140
|
+
out += ch;
|
|
141
|
+
if (escaped) escaped = false;
|
|
142
|
+
else if (ch === '\\') escaped = true;
|
|
143
|
+
else if (quote && ch === quote) {
|
|
144
|
+
inString = false;
|
|
145
|
+
quote = null;
|
|
146
|
+
}
|
|
147
|
+
i += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (ch === '/' && next === '/') {
|
|
152
|
+
i += 2;
|
|
153
|
+
while (i < content.length && content[i] !== '\n') i += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (ch === '/' && next === '*') {
|
|
158
|
+
i += 2;
|
|
159
|
+
while (i < content.length) {
|
|
160
|
+
if (content[i] === '*' && content[i + 1] === '/') {
|
|
161
|
+
i += 2;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
i += 1;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (ch === '"' || ch === "'") {
|
|
170
|
+
inString = true;
|
|
171
|
+
quote = ch;
|
|
172
|
+
out += ch;
|
|
173
|
+
i += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
out += ch;
|
|
178
|
+
i += 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Remove trailing commas (outside strings).
|
|
182
|
+
let normalized = '';
|
|
183
|
+
inString = false;
|
|
184
|
+
quote = null;
|
|
185
|
+
escaped = false;
|
|
186
|
+
i = 0;
|
|
187
|
+
|
|
188
|
+
while (i < out.length) {
|
|
189
|
+
const ch = out[i] ?? '';
|
|
190
|
+
|
|
191
|
+
if (inString) {
|
|
192
|
+
normalized += ch;
|
|
193
|
+
if (escaped) escaped = false;
|
|
194
|
+
else if (ch === '\\') escaped = true;
|
|
195
|
+
else if (quote && ch === quote) {
|
|
196
|
+
inString = false;
|
|
197
|
+
quote = null;
|
|
198
|
+
}
|
|
199
|
+
i += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (ch === '"' || ch === "'") {
|
|
204
|
+
inString = true;
|
|
205
|
+
quote = ch;
|
|
206
|
+
normalized += ch;
|
|
207
|
+
i += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (ch === ',') {
|
|
212
|
+
let j = i + 1;
|
|
213
|
+
while (j < out.length && /\s/.test(out[j] ?? '')) j += 1;
|
|
214
|
+
const nextNonWs = out[j] ?? '';
|
|
215
|
+
if (nextNonWs === ']' || nextNonWs === '}') {
|
|
216
|
+
i += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
normalized += ch;
|
|
222
|
+
i += 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return normalized.trim();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const parseJsonc = (content: string): unknown => JSON.parse(stripJsonc(content));
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Convert a legacy repo to a git resource
|
|
232
|
+
*/
|
|
233
|
+
const legacyRepoToResource = (repo: LegacyRepo): ResourceDefinition => ({
|
|
234
|
+
type: 'git',
|
|
235
|
+
name: repo.name,
|
|
236
|
+
url: repo.url,
|
|
237
|
+
branch: repo.branch,
|
|
238
|
+
...(repo.specialNotes && { specialNotes: repo.specialNotes }),
|
|
239
|
+
...(repo.searchPath && { searchPath: repo.searchPath })
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Check for and migrate legacy config (btca.json) to new format
|
|
244
|
+
* Supports two legacy formats:
|
|
245
|
+
* 1. Very old: has "repos" array with git repos only
|
|
246
|
+
* 2. Intermediate: has "resources" array (already migrated repos->resources)
|
|
247
|
+
*
|
|
248
|
+
* Returns migrated config if legacy exists, null otherwise
|
|
249
|
+
*/
|
|
250
|
+
const migrateLegacyConfig = async (
|
|
251
|
+
legacyPath: string,
|
|
252
|
+
newConfigPath: string
|
|
253
|
+
): Promise<StoredConfig | null> => {
|
|
254
|
+
const legacyExists = await Bun.file(legacyPath).exists();
|
|
255
|
+
if (!legacyExists) return null;
|
|
256
|
+
|
|
257
|
+
Metrics.info('config.legacy.found', { path: legacyPath });
|
|
258
|
+
|
|
259
|
+
let content: string;
|
|
260
|
+
try {
|
|
261
|
+
content = await Bun.file(legacyPath).text();
|
|
262
|
+
} catch (cause) {
|
|
263
|
+
Metrics.error('config.legacy.read_failed', { path: legacyPath, error: String(cause) });
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let parsed: unknown;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(content);
|
|
270
|
+
} catch (cause) {
|
|
271
|
+
Metrics.error('config.legacy.parse_failed', { path: legacyPath, error: String(cause) });
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Try the intermediate format first (has "resources" array)
|
|
276
|
+
const resourcesResult = LegacyResourcesConfigSchema.safeParse(parsed);
|
|
277
|
+
if (resourcesResult.success) {
|
|
278
|
+
const legacy = resourcesResult.data;
|
|
279
|
+
Metrics.info('config.legacy.parsed', {
|
|
280
|
+
format: 'resources',
|
|
281
|
+
resourceCount: legacy.resources.length,
|
|
282
|
+
model: legacy.model,
|
|
283
|
+
provider: legacy.provider
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Resources are already in the right format, just copy them over
|
|
287
|
+
const migrated: StoredConfig = {
|
|
288
|
+
$schema: CONFIG_SCHEMA_URL,
|
|
289
|
+
resources: legacy.resources,
|
|
290
|
+
model: legacy.model,
|
|
291
|
+
provider: legacy.provider
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return finalizeMigration(migrated, legacyPath, newConfigPath, legacy.resources.length);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Try the very old format (has "repos" array)
|
|
298
|
+
const reposResult = LegacyReposConfigSchema.safeParse(parsed);
|
|
299
|
+
if (reposResult.success) {
|
|
300
|
+
const legacy = reposResult.data;
|
|
301
|
+
Metrics.info('config.legacy.parsed', {
|
|
302
|
+
format: 'repos',
|
|
303
|
+
repoCount: legacy.repos.length,
|
|
304
|
+
model: legacy.model,
|
|
305
|
+
provider: legacy.provider
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Convert legacy repos to resources
|
|
309
|
+
const migratedResources = legacy.repos.map(legacyRepoToResource);
|
|
310
|
+
|
|
311
|
+
// Merge with default resources (legacy resources take precedence by name)
|
|
312
|
+
const migratedNames = new Set(migratedResources.map((r) => r.name));
|
|
313
|
+
const defaultsToAdd = DEFAULT_RESOURCES.filter((r) => !migratedNames.has(r.name));
|
|
314
|
+
const allResources = [...migratedResources, ...defaultsToAdd];
|
|
315
|
+
|
|
316
|
+
const migrated: StoredConfig = {
|
|
317
|
+
$schema: CONFIG_SCHEMA_URL,
|
|
318
|
+
resources: allResources,
|
|
319
|
+
model: legacy.model,
|
|
320
|
+
provider: legacy.provider
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
return finalizeMigration(migrated, legacyPath, newConfigPath, migratedResources.length);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Neither format matched
|
|
327
|
+
Metrics.error('config.legacy.invalid', {
|
|
328
|
+
path: legacyPath,
|
|
329
|
+
error: 'Config does not match any known legacy format'
|
|
330
|
+
});
|
|
331
|
+
return null;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Write migrated config and rename legacy file
|
|
336
|
+
*/
|
|
337
|
+
const finalizeMigration = async (
|
|
338
|
+
migrated: StoredConfig,
|
|
339
|
+
legacyPath: string,
|
|
340
|
+
newConfigPath: string,
|
|
341
|
+
migratedCount: number
|
|
342
|
+
): Promise<StoredConfig> => {
|
|
343
|
+
// Save the migrated config
|
|
344
|
+
const configDir = newConfigPath.slice(0, newConfigPath.lastIndexOf('/'));
|
|
345
|
+
try {
|
|
346
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
347
|
+
await Bun.write(newConfigPath, JSON.stringify(migrated, null, 2));
|
|
348
|
+
} catch (cause) {
|
|
349
|
+
throw new ConfigError({ message: 'Failed to write migrated config', cause });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
Metrics.info('config.legacy.migrated', {
|
|
353
|
+
newPath: newConfigPath,
|
|
354
|
+
resourceCount: migrated.resources.length,
|
|
355
|
+
migratedCount
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Rename the legacy file to mark it as migrated
|
|
359
|
+
try {
|
|
360
|
+
await fs.rename(legacyPath, `${legacyPath}.migrated`);
|
|
361
|
+
Metrics.info('config.legacy.renamed', { from: legacyPath, to: `${legacyPath}.migrated` });
|
|
362
|
+
} catch {
|
|
363
|
+
// Not critical if we can't rename
|
|
364
|
+
Metrics.info('config.legacy.rename_skipped', { path: legacyPath });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return migrated;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const loadConfigFromPath = async (configPath: string): Promise<StoredConfig> => {
|
|
371
|
+
let content: string;
|
|
372
|
+
try {
|
|
373
|
+
content = await Bun.file(configPath).text();
|
|
374
|
+
} catch (cause) {
|
|
375
|
+
throw new ConfigError({ message: 'Failed to read config', cause });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let parsed: unknown;
|
|
379
|
+
try {
|
|
380
|
+
parsed = parseJsonc(content);
|
|
381
|
+
} catch (cause) {
|
|
382
|
+
throw new ConfigError({ message: 'Failed to parse config JSONC', cause });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const result = StoredConfigSchema.safeParse(parsed);
|
|
386
|
+
if (!result.success) {
|
|
387
|
+
throw new ConfigError({ message: 'Invalid config', cause: result.error });
|
|
388
|
+
}
|
|
389
|
+
return result.data;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const createDefaultConfig = async (configPath: string): Promise<StoredConfig> => {
|
|
393
|
+
const configDir = configPath.slice(0, configPath.lastIndexOf('/'));
|
|
394
|
+
try {
|
|
395
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
396
|
+
} catch (cause) {
|
|
397
|
+
throw new ConfigError({ message: 'Failed to create config directory', cause });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const defaultStored: StoredConfig = {
|
|
401
|
+
$schema: CONFIG_SCHEMA_URL,
|
|
402
|
+
resources: DEFAULT_RESOURCES,
|
|
403
|
+
model: DEFAULT_MODEL,
|
|
404
|
+
provider: DEFAULT_PROVIDER
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
await Bun.write(configPath, JSON.stringify(defaultStored, null, 2));
|
|
409
|
+
} catch (cause) {
|
|
410
|
+
throw new ConfigError({ message: 'Failed to write default config', cause });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return defaultStored;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const saveConfig = async (configPath: string, stored: StoredConfig): Promise<void> => {
|
|
417
|
+
try {
|
|
418
|
+
await Bun.write(configPath, JSON.stringify(stored, null, 2));
|
|
419
|
+
} catch (cause) {
|
|
420
|
+
throw new ConfigError({ message: 'Failed to write config', cause });
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const makeService = (
|
|
425
|
+
stored: StoredConfig,
|
|
426
|
+
resourcesDirectory: string,
|
|
427
|
+
collectionsDirectory: string,
|
|
428
|
+
configPath: string
|
|
429
|
+
): Service => {
|
|
430
|
+
// Mutable state that tracks current config
|
|
431
|
+
let currentStored = stored;
|
|
432
|
+
|
|
433
|
+
const service: Service = {
|
|
434
|
+
resourcesDirectory,
|
|
435
|
+
collectionsDirectory,
|
|
436
|
+
configPath,
|
|
437
|
+
get resources() {
|
|
438
|
+
return currentStored.resources;
|
|
439
|
+
},
|
|
440
|
+
get model() {
|
|
441
|
+
return currentStored.model;
|
|
442
|
+
},
|
|
443
|
+
get provider() {
|
|
444
|
+
return currentStored.provider;
|
|
445
|
+
},
|
|
446
|
+
getResource: (name: string) => currentStored.resources.find((r) => r.name === name),
|
|
447
|
+
|
|
448
|
+
updateModel: async (provider: string, model: string) => {
|
|
449
|
+
currentStored = { ...currentStored, provider, model };
|
|
450
|
+
await saveConfig(configPath, currentStored);
|
|
451
|
+
Metrics.info('config.model.updated', { provider, model });
|
|
452
|
+
return { provider, model };
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
addResource: async (resource: ResourceDefinition) => {
|
|
456
|
+
// Check for duplicate name
|
|
457
|
+
if (currentStored.resources.some((r) => r.name === resource.name)) {
|
|
458
|
+
throw new ConfigError({ message: `Resource "${resource.name}" already exists` });
|
|
459
|
+
}
|
|
460
|
+
currentStored = {
|
|
461
|
+
...currentStored,
|
|
462
|
+
resources: [...currentStored.resources, resource]
|
|
463
|
+
};
|
|
464
|
+
await saveConfig(configPath, currentStored);
|
|
465
|
+
Metrics.info('config.resource.added', { name: resource.name, type: resource.type });
|
|
466
|
+
return resource;
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
removeResource: async (name: string) => {
|
|
470
|
+
const exists = currentStored.resources.some((r) => r.name === name);
|
|
471
|
+
if (!exists) {
|
|
472
|
+
throw new ConfigError({ message: `Resource "${name}" not found` });
|
|
473
|
+
}
|
|
474
|
+
currentStored = {
|
|
475
|
+
...currentStored,
|
|
476
|
+
resources: currentStored.resources.filter((r) => r.name !== name)
|
|
477
|
+
};
|
|
478
|
+
await saveConfig(configPath, currentStored);
|
|
479
|
+
Metrics.info('config.resource.removed', { name });
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
clearResources: async () => {
|
|
483
|
+
// Clear the resources and collections directories
|
|
484
|
+
let clearedCount = 0;
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const resourcesDir = await fs.readdir(resourcesDirectory).catch(() => []);
|
|
488
|
+
for (const item of resourcesDir) {
|
|
489
|
+
await fs.rm(`${resourcesDirectory}/${item}`, { recursive: true, force: true });
|
|
490
|
+
clearedCount++;
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
// Directory might not exist
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
const collectionsDir = await fs.readdir(collectionsDirectory).catch(() => []);
|
|
498
|
+
for (const item of collectionsDir) {
|
|
499
|
+
await fs.rm(`${collectionsDirectory}/${item}`, { recursive: true, force: true });
|
|
500
|
+
}
|
|
501
|
+
} catch {
|
|
502
|
+
// Directory might not exist
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
Metrics.info('config.resources.cleared', { count: clearedCount });
|
|
506
|
+
return { cleared: clearedCount };
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
return service;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
export const load = async (): Promise<Service> => {
|
|
514
|
+
const cwd = process.cwd();
|
|
515
|
+
Metrics.info('config.load.start', { cwd });
|
|
516
|
+
|
|
517
|
+
const projectConfigPath = `${cwd}/${PROJECT_CONFIG_FILENAME}`;
|
|
518
|
+
if (await Bun.file(projectConfigPath).exists()) {
|
|
519
|
+
Metrics.info('config.load.source', { source: 'project', path: projectConfigPath });
|
|
520
|
+
const stored = await loadConfigFromPath(projectConfigPath);
|
|
521
|
+
return makeService(
|
|
522
|
+
stored,
|
|
523
|
+
`${cwd}/${PROJECT_DATA_DIR}/resources`,
|
|
524
|
+
`${cwd}/${PROJECT_DATA_DIR}/collections`,
|
|
525
|
+
projectConfigPath
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const globalConfigPath = `${expandHome(GLOBAL_CONFIG_DIR)}/${GLOBAL_CONFIG_FILENAME}`;
|
|
530
|
+
const globalExists = await Bun.file(globalConfigPath).exists();
|
|
531
|
+
|
|
532
|
+
// If new config doesn't exist, check for legacy config to migrate
|
|
533
|
+
if (!globalExists) {
|
|
534
|
+
const legacyConfigPath = `${expandHome(GLOBAL_CONFIG_DIR)}/${LEGACY_CONFIG_FILENAME}`;
|
|
535
|
+
const migrated = await migrateLegacyConfig(legacyConfigPath, globalConfigPath);
|
|
536
|
+
if (migrated) {
|
|
537
|
+
Metrics.info('config.load.source', { source: 'migrated', path: globalConfigPath });
|
|
538
|
+
return makeService(
|
|
539
|
+
migrated,
|
|
540
|
+
`${expandHome(GLOBAL_DATA_DIR)}/resources`,
|
|
541
|
+
`${expandHome(GLOBAL_DATA_DIR)}/collections`,
|
|
542
|
+
globalConfigPath
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
Metrics.info('config.load.source', {
|
|
548
|
+
source: globalExists ? 'global' : 'default',
|
|
549
|
+
path: globalConfigPath
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const stored = globalExists
|
|
553
|
+
? await loadConfigFromPath(globalConfigPath)
|
|
554
|
+
: await createDefaultConfig(globalConfigPath);
|
|
555
|
+
|
|
556
|
+
return makeService(
|
|
557
|
+
stored,
|
|
558
|
+
`${expandHome(GLOBAL_DATA_DIR)}/resources`,
|
|
559
|
+
`${expandHome(GLOBAL_DATA_DIR)}/collections`,
|
|
560
|
+
globalConfigPath
|
|
561
|
+
);
|
|
562
|
+
};
|
|
563
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
export type ContextStore = {
|
|
4
|
+
requestId: string;
|
|
5
|
+
txDepth: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const storage = new AsyncLocalStorage<ContextStore>();
|
|
9
|
+
|
|
10
|
+
export namespace Context {
|
|
11
|
+
export const run = <T>(store: ContextStore, fn: () => Promise<T> | T): Promise<T> => {
|
|
12
|
+
return Promise.resolve(storage.run(store, fn));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const get = (): ContextStore | undefined => storage.getStore();
|
|
16
|
+
|
|
17
|
+
export const require = (): ContextStore => {
|
|
18
|
+
const store = storage.getStore();
|
|
19
|
+
if (!store) throw new Error('Missing AsyncLocalStorage context');
|
|
20
|
+
return store;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const requestId = (): string => storage.getStore()?.requestId ?? 'unknown';
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Metrics } from '../metrics/index.ts';
|
|
2
|
+
import { Context } from './index.ts';
|
|
3
|
+
|
|
4
|
+
export namespace Transaction {
|
|
5
|
+
export const run = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
|
|
6
|
+
const store = Context.require();
|
|
7
|
+
const depth = store.txDepth;
|
|
8
|
+
store.txDepth = depth + 1;
|
|
9
|
+
|
|
10
|
+
const start = performance.now();
|
|
11
|
+
Metrics.info('tx.start', { name, depth });
|
|
12
|
+
try {
|
|
13
|
+
const result = await fn();
|
|
14
|
+
Metrics.info('tx.commit', { name, depth, ms: Math.round(performance.now() - start) });
|
|
15
|
+
return result;
|
|
16
|
+
} catch (cause) {
|
|
17
|
+
Metrics.error('tx.rollback', {
|
|
18
|
+
name,
|
|
19
|
+
depth,
|
|
20
|
+
ms: Math.round(performance.now() - start),
|
|
21
|
+
error: Metrics.errorInfo(cause)
|
|
22
|
+
});
|
|
23
|
+
throw cause;
|
|
24
|
+
} finally {
|
|
25
|
+
store.txDepth = depth;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type TaggedErrorLike = {
|
|
2
|
+
readonly _tag: string;
|
|
3
|
+
readonly message: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const getErrorTag = (error: unknown): string => {
|
|
7
|
+
if (error && typeof error === 'object' && '_tag' in error) return String((error as any)._tag);
|
|
8
|
+
return 'UnknownError';
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const getErrorMessage = (error: unknown): string => {
|
|
12
|
+
if (error && typeof error === 'object' && 'message' in error)
|
|
13
|
+
return String((error as any).message);
|
|
14
|
+
return String(error);
|
|
15
|
+
};
|