btca-server 1.0.40 → 1.0.43
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/package.json +1 -1
- package/src/agent/service.ts +69 -8
- package/src/collections/service.ts +7 -4
- package/src/collections/types.ts +2 -1
- package/src/config/index.ts +49 -7
- package/src/index.ts +22 -5
- package/src/resources/helpers.ts +4 -0
- package/src/resources/impls/git.test.ts +9 -9
- package/src/resources/impls/git.ts +112 -26
- package/src/resources/schema.ts +21 -5
- package/src/resources/service.ts +12 -3
- package/src/resources/types.ts +3 -2
- package/src/validation/index.ts +140 -22
package/package.json
CHANGED
package/src/agent/service.ts
CHANGED
|
@@ -89,9 +89,15 @@ export namespace Agent {
|
|
|
89
89
|
url: string;
|
|
90
90
|
model: { provider: string; model: string };
|
|
91
91
|
}>;
|
|
92
|
+
|
|
93
|
+
listProviders: () => Promise<{ all: { id: string; models: Record<string, unknown> }[]; connected: string[] }>;
|
|
92
94
|
};
|
|
93
95
|
|
|
94
|
-
const buildOpenCodeConfig = (args: {
|
|
96
|
+
const buildOpenCodeConfig = (args: {
|
|
97
|
+
agentInstructions: string;
|
|
98
|
+
providerId?: string;
|
|
99
|
+
providerTimeoutMs?: number;
|
|
100
|
+
}): OpenCodeConfig => {
|
|
95
101
|
const prompt = [
|
|
96
102
|
'You are an expert internal agent whose job is to answer questions about the collection.',
|
|
97
103
|
'You operate inside a collection directory.',
|
|
@@ -99,6 +105,17 @@ export namespace Agent {
|
|
|
99
105
|
args.agentInstructions
|
|
100
106
|
].join('\n');
|
|
101
107
|
|
|
108
|
+
const providerConfig =
|
|
109
|
+
args.providerId && typeof args.providerTimeoutMs === 'number'
|
|
110
|
+
? {
|
|
111
|
+
[args.providerId]: {
|
|
112
|
+
options: {
|
|
113
|
+
timeout: args.providerTimeoutMs
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
102
119
|
return {
|
|
103
120
|
agent: {
|
|
104
121
|
build: { disable: true },
|
|
@@ -135,7 +152,8 @@ export namespace Agent {
|
|
|
135
152
|
edit: false
|
|
136
153
|
}
|
|
137
154
|
}
|
|
138
|
-
}
|
|
155
|
+
},
|
|
156
|
+
...(providerConfig ? { provider: providerConfig } : {})
|
|
139
157
|
};
|
|
140
158
|
};
|
|
141
159
|
|
|
@@ -164,7 +182,7 @@ export namespace Agent {
|
|
|
164
182
|
}
|
|
165
183
|
};
|
|
166
184
|
|
|
167
|
-
const
|
|
185
|
+
const createOpencodeInstance = async (args: {
|
|
168
186
|
collectionPath: string;
|
|
169
187
|
ocConfig: OpenCodeConfig;
|
|
170
188
|
}): Promise<{
|
|
@@ -248,8 +266,12 @@ export namespace Agent {
|
|
|
248
266
|
|
|
249
267
|
export const create = (config: Config.Service): Service => {
|
|
250
268
|
const askStream: Service['askStream'] = async ({ collection, question }) => {
|
|
251
|
-
const ocConfig = buildOpenCodeConfig({
|
|
252
|
-
|
|
269
|
+
const ocConfig = buildOpenCodeConfig({
|
|
270
|
+
agentInstructions: collection.agentInstructions,
|
|
271
|
+
providerId: config.provider,
|
|
272
|
+
providerTimeoutMs: config.providerTimeoutMs
|
|
273
|
+
});
|
|
274
|
+
const { client, server, baseUrl } = await createOpencodeInstance({
|
|
253
275
|
collectionPath: collection.path,
|
|
254
276
|
ocConfig
|
|
255
277
|
});
|
|
@@ -354,8 +376,12 @@ export namespace Agent {
|
|
|
354
376
|
};
|
|
355
377
|
|
|
356
378
|
const getOpencodeInstanceMethod: Service['getOpencodeInstance'] = async ({ collection }) => {
|
|
357
|
-
const ocConfig = buildOpenCodeConfig({
|
|
358
|
-
|
|
379
|
+
const ocConfig = buildOpenCodeConfig({
|
|
380
|
+
agentInstructions: collection.agentInstructions,
|
|
381
|
+
providerId: config.provider,
|
|
382
|
+
providerTimeoutMs: config.providerTimeoutMs
|
|
383
|
+
});
|
|
384
|
+
const { baseUrl } = await createOpencodeInstance({
|
|
359
385
|
collectionPath: collection.path,
|
|
360
386
|
ocConfig
|
|
361
387
|
});
|
|
@@ -371,6 +397,41 @@ export namespace Agent {
|
|
|
371
397
|
};
|
|
372
398
|
};
|
|
373
399
|
|
|
374
|
-
|
|
400
|
+
const listProviders: Service['listProviders'] = async () => {
|
|
401
|
+
const ocConfig = buildOpenCodeConfig({
|
|
402
|
+
agentInstructions: '',
|
|
403
|
+
providerId: config.provider,
|
|
404
|
+
providerTimeoutMs: config.providerTimeoutMs
|
|
405
|
+
});
|
|
406
|
+
const { client, server } = await createOpencodeInstance({
|
|
407
|
+
collectionPath: process.cwd(),
|
|
408
|
+
ocConfig
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const response = await client.provider.list().catch((cause: unknown) => {
|
|
413
|
+
throw new AgentError({
|
|
414
|
+
message: 'Failed to fetch provider list',
|
|
415
|
+
hint: CommonHints.RUN_AUTH,
|
|
416
|
+
cause
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
if (!response?.data) {
|
|
420
|
+
throw new AgentError({
|
|
421
|
+
message: 'Failed to fetch provider list',
|
|
422
|
+
hint: CommonHints.RUN_AUTH
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
const data = response.data as {
|
|
426
|
+
all: { id: string; models: Record<string, unknown> }[];
|
|
427
|
+
connected: string[];
|
|
428
|
+
};
|
|
429
|
+
return { all: data.all, connected: data.connected };
|
|
430
|
+
} finally {
|
|
431
|
+
server.close();
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return { askStream, ask, getOpencodeInstance: getOpencodeInstanceMethod, listProviders };
|
|
375
436
|
};
|
|
376
437
|
}
|
|
@@ -18,11 +18,14 @@ export namespace Collections {
|
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
const createCollectionInstructionBlock = (resource: BtcaFsResource): string => {
|
|
21
|
+
const focusLines = resource.repoSubPaths.map(
|
|
22
|
+
(subPath) => `Focus: ./${resource.fsName}/${subPath}`
|
|
23
|
+
);
|
|
21
24
|
const lines = [
|
|
22
25
|
`## Resource: ${resource.name}`,
|
|
23
26
|
FS_RESOURCE_SYSTEM_NOTE,
|
|
24
|
-
`Path: ./${resource.
|
|
25
|
-
|
|
27
|
+
`Path: ./${resource.fsName}`,
|
|
28
|
+
...focusLines,
|
|
26
29
|
resource.specialAgentInstructions ? `Notes: ${resource.specialAgentInstructions}` : ''
|
|
27
30
|
].filter(Boolean);
|
|
28
31
|
|
|
@@ -89,7 +92,7 @@ export namespace Collections {
|
|
|
89
92
|
});
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
const linkPath = path.join(collectionPath, resource.
|
|
95
|
+
const linkPath = path.join(collectionPath, resource.fsName);
|
|
93
96
|
try {
|
|
94
97
|
await fs.rm(linkPath, { recursive: true, force: true });
|
|
95
98
|
} catch {
|
|
@@ -97,7 +100,7 @@ export namespace Collections {
|
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
try {
|
|
100
|
-
await fs.symlink(resourcePath, linkPath);
|
|
103
|
+
await fs.symlink(resourcePath, linkPath, 'junction');
|
|
101
104
|
} catch (cause) {
|
|
102
105
|
throw new CollectionError({
|
|
103
106
|
message: `Failed to create symlink for resource "${resource.name}"`,
|
package/src/collections/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TaggedErrorOptions } from '../errors.ts';
|
|
2
|
+
import { resourceNameToKey } from '../resources/helpers.ts';
|
|
2
3
|
|
|
3
4
|
export type CollectionResult = {
|
|
4
5
|
path: string;
|
|
@@ -18,5 +19,5 @@ export class CollectionError extends Error {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const getCollectionKey = (resourceNames: readonly string[]): string => {
|
|
21
|
-
return [...resourceNames].sort().join('+');
|
|
22
|
+
return [...resourceNames].map(resourceNameToKey).sort().join('+');
|
|
22
23
|
};
|
package/src/config/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { CommonHints, type TaggedErrorOptions } from '../errors.ts';
|
|
@@ -10,11 +11,11 @@ export const GLOBAL_CONFIG_FILENAME = 'btca.config.jsonc';
|
|
|
10
11
|
export const LEGACY_CONFIG_FILENAME = 'btca.json';
|
|
11
12
|
export const GLOBAL_DATA_DIR = '~/.local/share/btca';
|
|
12
13
|
export const PROJECT_CONFIG_FILENAME = 'btca.config.jsonc';
|
|
13
|
-
export const PROJECT_DATA_DIR = '.btca';
|
|
14
14
|
export const CONFIG_SCHEMA_URL = 'https://btca.dev/btca.schema.json';
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_MODEL = 'claude-haiku-4-5';
|
|
17
17
|
export const DEFAULT_PROVIDER = 'opencode';
|
|
18
|
+
export const DEFAULT_PROVIDER_TIMEOUT_MS = 300_000;
|
|
18
19
|
|
|
19
20
|
export const DEFAULT_RESOURCES: ResourceDefinition[] = [
|
|
20
21
|
{
|
|
@@ -48,6 +49,8 @@ export const DEFAULT_RESOURCES: ResourceDefinition[] = [
|
|
|
48
49
|
|
|
49
50
|
const StoredConfigSchema = z.object({
|
|
50
51
|
$schema: z.string().optional(),
|
|
52
|
+
dataDirectory: z.string().optional(),
|
|
53
|
+
providerTimeoutMs: z.number().int().positive().optional(),
|
|
51
54
|
resources: z.array(ResourceDefinitionSchema),
|
|
52
55
|
model: z.string(),
|
|
53
56
|
provider: z.string()
|
|
@@ -113,6 +116,7 @@ export namespace Config {
|
|
|
113
116
|
resources: readonly ResourceDefinition[];
|
|
114
117
|
model: string;
|
|
115
118
|
provider: string;
|
|
119
|
+
providerTimeoutMs?: number;
|
|
116
120
|
configPath: string;
|
|
117
121
|
getResource: (name: string) => ResourceDefinition | undefined;
|
|
118
122
|
updateModel: (provider: string, model: string) => Promise<{ provider: string; model: string }>;
|
|
@@ -127,6 +131,12 @@ export namespace Config {
|
|
|
127
131
|
return path;
|
|
128
132
|
};
|
|
129
133
|
|
|
134
|
+
const resolveDataDirectory = (rawPath: string, baseDir: string): string => {
|
|
135
|
+
const expanded = expandHome(rawPath);
|
|
136
|
+
if (path.isAbsolute(expanded)) return expanded;
|
|
137
|
+
return path.resolve(baseDir, expanded);
|
|
138
|
+
};
|
|
139
|
+
|
|
130
140
|
const stripJsonc = (content: string): string => {
|
|
131
141
|
// Remove // and /* */ comments without touching strings.
|
|
132
142
|
let out = '';
|
|
@@ -427,7 +437,8 @@ export namespace Config {
|
|
|
427
437
|
$schema: CONFIG_SCHEMA_URL,
|
|
428
438
|
resources: DEFAULT_RESOURCES,
|
|
429
439
|
model: DEFAULT_MODEL,
|
|
430
|
-
provider: DEFAULT_PROVIDER
|
|
440
|
+
provider: DEFAULT_PROVIDER,
|
|
441
|
+
providerTimeoutMs: DEFAULT_PROVIDER_TIMEOUT_MS
|
|
431
442
|
};
|
|
432
443
|
|
|
433
444
|
try {
|
|
@@ -526,6 +537,9 @@ export namespace Config {
|
|
|
526
537
|
get provider() {
|
|
527
538
|
return getActiveConfig().provider;
|
|
528
539
|
},
|
|
540
|
+
get providerTimeoutMs() {
|
|
541
|
+
return getActiveConfig().providerTimeoutMs;
|
|
542
|
+
},
|
|
529
543
|
getResource: (name: string) => getMergedResources().find((r) => r.name === name),
|
|
530
544
|
|
|
531
545
|
updateModel: async (provider: string, model: string) => {
|
|
@@ -680,7 +694,7 @@ export namespace Config {
|
|
|
680
694
|
const projectExists = await Bun.file(projectConfigPath).exists();
|
|
681
695
|
if (projectExists) {
|
|
682
696
|
Metrics.info('config.load.project', { source: 'project', path: projectConfigPath });
|
|
683
|
-
|
|
697
|
+
let projectConfig = await loadConfigFromPath(projectConfigPath);
|
|
684
698
|
|
|
685
699
|
Metrics.info('config.load.merged', {
|
|
686
700
|
globalResources: globalConfig.resources.length,
|
|
@@ -689,22 +703,50 @@ export namespace Config {
|
|
|
689
703
|
|
|
690
704
|
// Use project paths for data storage when project config exists
|
|
691
705
|
// Pass both configs separately to avoid resource leakage on mutations
|
|
706
|
+
let projectDataDir =
|
|
707
|
+
projectConfig.dataDirectory ?? globalConfig.dataDirectory ?? expandHome(GLOBAL_DATA_DIR);
|
|
708
|
+
|
|
709
|
+
// Migration: if no dataDirectory is set and legacy .btca exists, use it and update config
|
|
710
|
+
if (!projectConfig.dataDirectory) {
|
|
711
|
+
const legacyProjectDataDir = `${cwd}/.btca`;
|
|
712
|
+
const legacyExists = await fs
|
|
713
|
+
.stat(legacyProjectDataDir)
|
|
714
|
+
.then(() => true)
|
|
715
|
+
.catch(() => false);
|
|
716
|
+
if (legacyExists) {
|
|
717
|
+
Metrics.info('config.project.legacy_data_dir', {
|
|
718
|
+
path: legacyProjectDataDir,
|
|
719
|
+
action: 'migrating'
|
|
720
|
+
});
|
|
721
|
+
projectDataDir = '.btca';
|
|
722
|
+
const updatedProjectConfig = { ...projectConfig, dataDirectory: '.btca' };
|
|
723
|
+
await saveConfig(projectConfigPath, updatedProjectConfig);
|
|
724
|
+
projectConfig = updatedProjectConfig;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const resolvedProjectDataDir = resolveDataDirectory(projectDataDir, cwd);
|
|
692
729
|
return makeService(
|
|
693
730
|
globalConfig,
|
|
694
731
|
projectConfig,
|
|
695
|
-
`${
|
|
696
|
-
`${
|
|
732
|
+
`${resolvedProjectDataDir}/resources`,
|
|
733
|
+
`${resolvedProjectDataDir}/collections`,
|
|
697
734
|
projectConfigPath
|
|
698
735
|
);
|
|
699
736
|
}
|
|
700
737
|
|
|
701
738
|
// No project config, use global only
|
|
702
739
|
Metrics.info('config.load.source', { source: 'global', path: globalConfigPath });
|
|
740
|
+
const globalDataDir = globalConfig.dataDirectory ?? expandHome(GLOBAL_DATA_DIR);
|
|
741
|
+
const resolvedGlobalDataDir = resolveDataDirectory(
|
|
742
|
+
globalDataDir,
|
|
743
|
+
expandHome(GLOBAL_CONFIG_DIR)
|
|
744
|
+
);
|
|
703
745
|
return makeService(
|
|
704
746
|
globalConfig,
|
|
705
747
|
null,
|
|
706
|
-
`${
|
|
707
|
-
`${
|
|
748
|
+
`${resolvedGlobalDataDir}/resources`,
|
|
749
|
+
`${resolvedGlobalDataDir}/collections`,
|
|
708
750
|
globalConfigPath
|
|
709
751
|
);
|
|
710
752
|
};
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { Resources } from './resources/service.ts';
|
|
|
13
13
|
import { GitResourceSchema, LocalResourceSchema } from './resources/schema.ts';
|
|
14
14
|
import { StreamService } from './stream/service.ts';
|
|
15
15
|
import type { BtcaStreamMetaEvent } from './stream/types.ts';
|
|
16
|
-
import { LIMITS } from './validation/index.ts';
|
|
16
|
+
import { LIMITS, normalizeGitHubUrl } from './validation/index.ts';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* BTCA Server API
|
|
@@ -42,7 +42,7 @@ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
|
|
|
42
42
|
/**
|
|
43
43
|
* Resource name pattern: must start with a letter, alphanumeric and hyphens only.
|
|
44
44
|
*/
|
|
45
|
-
const RESOURCE_NAME_REGEX =
|
|
45
|
+
const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Safe name pattern for provider/model names.
|
|
@@ -56,7 +56,10 @@ const ResourceNameField = z
|
|
|
56
56
|
.string()
|
|
57
57
|
.min(1, 'Resource name cannot be empty')
|
|
58
58
|
.max(LIMITS.RESOURCE_NAME_MAX)
|
|
59
|
-
.regex(RESOURCE_NAME_REGEX, 'Invalid resource name format')
|
|
59
|
+
.regex(RESOURCE_NAME_REGEX, 'Invalid resource name format')
|
|
60
|
+
.refine((name) => !name.includes('..'), 'Resource name must not contain ".."')
|
|
61
|
+
.refine((name) => !name.includes('//'), 'Resource name must not contain "//"')
|
|
62
|
+
.refine((name) => !name.endsWith('/'), 'Resource name must not end with "/"');
|
|
60
63
|
|
|
61
64
|
const QuestionRequestSchema = z.object({
|
|
62
65
|
question: z
|
|
@@ -110,6 +113,7 @@ const AddGitResourceRequestSchema = z.object({
|
|
|
110
113
|
url: GitResourceSchema.shape.url,
|
|
111
114
|
branch: GitResourceSchema.shape.branch.optional().default('main'),
|
|
112
115
|
searchPath: GitResourceSchema.shape.searchPath,
|
|
116
|
+
searchPaths: GitResourceSchema.shape.searchPaths,
|
|
113
117
|
specialNotes: GitResourceSchema.shape.specialNotes
|
|
114
118
|
});
|
|
115
119
|
|
|
@@ -220,6 +224,7 @@ const createApp = (deps: {
|
|
|
220
224
|
return c.json({
|
|
221
225
|
provider: config.provider,
|
|
222
226
|
model: config.model,
|
|
227
|
+
providerTimeoutMs: config.providerTimeoutMs ?? null,
|
|
223
228
|
resourcesDirectory: config.resourcesDirectory,
|
|
224
229
|
collectionsDirectory: config.collectionsDirectory,
|
|
225
230
|
resourceCount: config.resources.length
|
|
@@ -237,6 +242,7 @@ const createApp = (deps: {
|
|
|
237
242
|
url: r.url,
|
|
238
243
|
branch: r.branch,
|
|
239
244
|
searchPath: r.searchPath ?? null,
|
|
245
|
+
searchPaths: r.searchPaths ?? null,
|
|
240
246
|
specialNotes: r.specialNotes ?? null
|
|
241
247
|
};
|
|
242
248
|
} else {
|
|
@@ -251,6 +257,12 @@ const createApp = (deps: {
|
|
|
251
257
|
});
|
|
252
258
|
})
|
|
253
259
|
|
|
260
|
+
// GET /providers
|
|
261
|
+
.get('/providers', async (c: HonoContext) => {
|
|
262
|
+
const providers = await agent.listProviders();
|
|
263
|
+
return c.json(providers);
|
|
264
|
+
})
|
|
265
|
+
|
|
254
266
|
// POST /question
|
|
255
267
|
.post('/question', async (c: HonoContext) => {
|
|
256
268
|
const decoded = await decodeJson(c.req.raw, QuestionRequestSchema);
|
|
@@ -375,16 +387,20 @@ const createApp = (deps: {
|
|
|
375
387
|
|
|
376
388
|
// POST /config/resources - Add a new resource
|
|
377
389
|
// All validation (URL, branch, path traversal, etc.) is handled by the schema
|
|
390
|
+
// GitHub URLs are normalized to their base repository format
|
|
378
391
|
.post('/config/resources', async (c: HonoContext) => {
|
|
379
392
|
const decoded = await decodeJson(c.req.raw, AddResourceRequestSchema);
|
|
380
393
|
|
|
381
394
|
if (decoded.type === 'git') {
|
|
395
|
+
// Normalize GitHub URLs (e.g., /blob/main/file.txt → base repo URL)
|
|
396
|
+
const normalizedUrl = normalizeGitHubUrl(decoded.url);
|
|
382
397
|
const resource = {
|
|
383
398
|
type: 'git' as const,
|
|
384
399
|
name: decoded.name,
|
|
385
|
-
url:
|
|
400
|
+
url: normalizedUrl,
|
|
386
401
|
branch: decoded.branch ?? 'main',
|
|
387
402
|
...(decoded.searchPath && { searchPath: decoded.searchPath }),
|
|
403
|
+
...(decoded.searchPaths && { searchPaths: decoded.searchPaths }),
|
|
388
404
|
...(decoded.specialNotes && { specialNotes: decoded.specialNotes })
|
|
389
405
|
};
|
|
390
406
|
const added = await config.addResource(resource);
|
|
@@ -468,7 +484,8 @@ export const startServer = async (options: StartServerOptions = {}): Promise<Ser
|
|
|
468
484
|
|
|
469
485
|
const server = Bun.serve({
|
|
470
486
|
port: requestedPort,
|
|
471
|
-
fetch: app.fetch
|
|
487
|
+
fetch: app.fetch,
|
|
488
|
+
idleTimeout: 60
|
|
472
489
|
});
|
|
473
490
|
|
|
474
491
|
const actualPort = server.port ?? requestedPort;
|
package/src/resources/helpers.ts
CHANGED
|
@@ -25,7 +25,7 @@ describe('Git Resource', () => {
|
|
|
25
25
|
name: 'test-repo',
|
|
26
26
|
url: 'https://github.com/honojs/hono',
|
|
27
27
|
branch: 'main',
|
|
28
|
-
|
|
28
|
+
repoSubPaths: ['docs'],
|
|
29
29
|
resourcesDirectoryPath: testDir,
|
|
30
30
|
specialAgentInstructions: 'Test notes',
|
|
31
31
|
quiet: true
|
|
@@ -36,7 +36,7 @@ describe('Git Resource', () => {
|
|
|
36
36
|
expect(resource._tag).toBe('fs-based');
|
|
37
37
|
expect(resource.name).toBe('test-repo');
|
|
38
38
|
expect(resource.type).toBe('git');
|
|
39
|
-
expect(resource.
|
|
39
|
+
expect(resource.repoSubPaths).toEqual(['docs']);
|
|
40
40
|
expect(resource.specialAgentInstructions).toBe('Test notes');
|
|
41
41
|
|
|
42
42
|
const resourcePath = await resource.getAbsoluteDirectoryPath();
|
|
@@ -55,7 +55,7 @@ describe('Git Resource', () => {
|
|
|
55
55
|
name: 'update-test',
|
|
56
56
|
url: 'https://github.com/honojs/hono',
|
|
57
57
|
branch: 'main',
|
|
58
|
-
|
|
58
|
+
repoSubPaths: [],
|
|
59
59
|
resourcesDirectoryPath: testDir,
|
|
60
60
|
specialAgentInstructions: '',
|
|
61
61
|
quiet: true
|
|
@@ -77,13 +77,13 @@ describe('Git Resource', () => {
|
|
|
77
77
|
name: 'invalid-url',
|
|
78
78
|
url: 'not-a-valid-url',
|
|
79
79
|
branch: 'main',
|
|
80
|
-
|
|
80
|
+
repoSubPaths: [],
|
|
81
81
|
resourcesDirectoryPath: testDir,
|
|
82
82
|
specialAgentInstructions: '',
|
|
83
83
|
quiet: true
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
expect(loadGitResource(args)).rejects.toThrow('
|
|
86
|
+
expect(loadGitResource(args)).rejects.toThrow('Git URL must be a valid HTTPS URL');
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
it('throws error for invalid branch name', async () => {
|
|
@@ -92,13 +92,13 @@ describe('Git Resource', () => {
|
|
|
92
92
|
name: 'invalid-branch',
|
|
93
93
|
url: 'https://github.com/test/repo',
|
|
94
94
|
branch: 'invalid branch name!',
|
|
95
|
-
|
|
95
|
+
repoSubPaths: [],
|
|
96
96
|
resourcesDirectoryPath: testDir,
|
|
97
97
|
specialAgentInstructions: '',
|
|
98
98
|
quiet: true
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
-
expect(loadGitResource(args)).rejects.toThrow('
|
|
101
|
+
expect(loadGitResource(args)).rejects.toThrow('Branch name must contain only');
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it('throws error for path traversal attempt', async () => {
|
|
@@ -107,13 +107,13 @@ describe('Git Resource', () => {
|
|
|
107
107
|
name: 'path-traversal',
|
|
108
108
|
url: 'https://github.com/test/repo',
|
|
109
109
|
branch: 'main',
|
|
110
|
-
|
|
110
|
+
repoSubPaths: ['../../../etc'],
|
|
111
111
|
resourcesDirectoryPath: testDir,
|
|
112
112
|
specialAgentInstructions: '',
|
|
113
113
|
quiet: true
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
-
expect(loadGitResource(args)).rejects.toThrow('
|
|
116
|
+
expect(loadGitResource(args)).rejects.toThrow('path traversal');
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
119
|
});
|
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
|
|
3
4
|
import { Metrics } from '../../metrics/index.ts';
|
|
4
5
|
import { CommonHints } from '../../errors.ts';
|
|
5
|
-
import { ResourceError } from '../helpers.ts';
|
|
6
|
+
import { ResourceError, resourceNameToKey } from '../helpers.ts';
|
|
7
|
+
import { GitResourceSchema } from '../schema.ts';
|
|
6
8
|
import type { BtcaFsResource, BtcaGitResourceArgs } from '../types.ts';
|
|
7
9
|
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
10
|
+
const validateGitUrl = (url: string): { success: true } | { success: false; error: string } => {
|
|
11
|
+
const result = GitResourceSchema.shape.url.safeParse(url);
|
|
12
|
+
if (result.success) return { success: true };
|
|
13
|
+
return { success: false, error: result.error.errors[0]?.message ?? 'Invalid git URL' };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const validateBranch = (branch: string): { success: true } | { success: false; error: string } => {
|
|
17
|
+
const result = GitResourceSchema.shape.branch.safeParse(branch);
|
|
18
|
+
if (result.success) return { success: true };
|
|
19
|
+
return { success: false, error: result.error.errors[0]?.message ?? 'Invalid branch name' };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const validateSearchPath = (
|
|
23
|
+
searchPath: string
|
|
24
|
+
): { success: true } | { success: false; error: string } => {
|
|
25
|
+
const result = GitResourceSchema.shape.searchPath.safeParse(searchPath);
|
|
26
|
+
if (result.success) return { success: true };
|
|
27
|
+
return { success: false, error: result.error.errors[0]?.message ?? 'Invalid search path' };
|
|
28
|
+
};
|
|
11
29
|
|
|
12
30
|
const directoryExists = async (path: string): Promise<boolean> => {
|
|
13
31
|
try {
|
|
@@ -173,33 +191,38 @@ const runGit = async (
|
|
|
173
191
|
const gitClone = async (args: {
|
|
174
192
|
repoUrl: string;
|
|
175
193
|
repoBranch: string;
|
|
176
|
-
|
|
194
|
+
repoSubPaths: readonly string[];
|
|
177
195
|
localAbsolutePath: string;
|
|
178
196
|
quiet: boolean;
|
|
179
197
|
}) => {
|
|
180
|
-
|
|
198
|
+
const urlValidation = validateGitUrl(args.repoUrl);
|
|
199
|
+
if (!urlValidation.success) {
|
|
181
200
|
throw new ResourceError({
|
|
182
|
-
message:
|
|
183
|
-
hint: 'URLs must
|
|
201
|
+
message: urlValidation.error,
|
|
202
|
+
hint: 'URLs must be valid HTTPS URLs. Example: https://github.com/user/repo',
|
|
184
203
|
cause: new Error('URL validation failed')
|
|
185
204
|
});
|
|
186
205
|
}
|
|
187
|
-
|
|
206
|
+
const branchValidation = validateBranch(args.repoBranch);
|
|
207
|
+
if (!branchValidation.success) {
|
|
188
208
|
throw new ResourceError({
|
|
189
|
-
message:
|
|
209
|
+
message: branchValidation.error,
|
|
190
210
|
hint: 'Branch names can only contain letters, numbers, hyphens, underscores, dots, and forward slashes.',
|
|
191
211
|
cause: new Error('Branch validation failed')
|
|
192
212
|
});
|
|
193
213
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
214
|
+
for (const repoSubPath of args.repoSubPaths) {
|
|
215
|
+
const pathValidation = validateSearchPath(repoSubPath);
|
|
216
|
+
if (!pathValidation.success) {
|
|
217
|
+
throw new ResourceError({
|
|
218
|
+
message: pathValidation.error,
|
|
219
|
+
hint: 'Search paths cannot contain ".." (path traversal) and must use only safe characters.',
|
|
220
|
+
cause: new Error('Path validation failed')
|
|
221
|
+
});
|
|
222
|
+
}
|
|
200
223
|
}
|
|
201
224
|
|
|
202
|
-
const needsSparseCheckout = args.
|
|
225
|
+
const needsSparseCheckout = args.repoSubPaths.length > 0;
|
|
203
226
|
const cloneArgs = needsSparseCheckout
|
|
204
227
|
? [
|
|
205
228
|
'clone',
|
|
@@ -231,15 +254,15 @@ const gitClone = async (args: {
|
|
|
231
254
|
}
|
|
232
255
|
|
|
233
256
|
if (needsSparseCheckout) {
|
|
234
|
-
const sparseResult = await runGit(['sparse-checkout', 'set', args.
|
|
257
|
+
const sparseResult = await runGit(['sparse-checkout', 'set', ...args.repoSubPaths], {
|
|
235
258
|
cwd: args.localAbsolutePath,
|
|
236
259
|
quiet: args.quiet
|
|
237
260
|
});
|
|
238
261
|
|
|
239
262
|
if (sparseResult.exitCode !== 0) {
|
|
240
263
|
throw new ResourceError({
|
|
241
|
-
message: `Failed to set sparse-checkout path: "${args.
|
|
242
|
-
hint: 'Verify the search
|
|
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.',
|
|
243
266
|
cause: new Error(
|
|
244
267
|
`git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}`
|
|
245
268
|
)
|
|
@@ -263,7 +286,12 @@ const gitClone = async (args: {
|
|
|
263
286
|
}
|
|
264
287
|
};
|
|
265
288
|
|
|
266
|
-
const gitUpdate = async (args: {
|
|
289
|
+
const gitUpdate = async (args: {
|
|
290
|
+
localAbsolutePath: string;
|
|
291
|
+
branch: string;
|
|
292
|
+
repoSubPaths: readonly string[];
|
|
293
|
+
quiet: boolean;
|
|
294
|
+
}) => {
|
|
267
295
|
const fetchResult = await runGit(['fetch', '--depth', '1', 'origin', args.branch], {
|
|
268
296
|
cwd: args.localAbsolutePath,
|
|
269
297
|
quiet: args.quiet
|
|
@@ -299,10 +327,60 @@ const gitUpdate = async (args: { localAbsolutePath: string; branch: string; quie
|
|
|
299
327
|
)
|
|
300
328
|
});
|
|
301
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}`
|
|
343
|
+
)
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const checkoutResult = await runGit(['checkout'], {
|
|
348
|
+
cwd: args.localAbsolutePath,
|
|
349
|
+
quiet: args.quiet
|
|
350
|
+
});
|
|
351
|
+
|
|
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
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const ensureSearchPathsExist = async (
|
|
365
|
+
localPath: string,
|
|
366
|
+
repoSubPaths: readonly string[]
|
|
367
|
+
): Promise<void> => {
|
|
368
|
+
for (const repoSubPath of repoSubPaths) {
|
|
369
|
+
const subPath = path.join(localPath, repoSubPath);
|
|
370
|
+
const exists = await directoryExists(subPath);
|
|
371
|
+
if (!exists) {
|
|
372
|
+
throw new ResourceError({
|
|
373
|
+
message: `Search path does not exist: "${repoSubPath}"`,
|
|
374
|
+
hint: 'Check the repository structure and update the search path in your btca config.',
|
|
375
|
+
cause: new Error(`Missing search path: ${repoSubPath}`)
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
302
379
|
};
|
|
303
380
|
|
|
304
381
|
const ensureGitResource = async (config: BtcaGitResourceArgs): Promise<string> => {
|
|
305
|
-
const
|
|
382
|
+
const resourceKey = resourceNameToKey(config.name);
|
|
383
|
+
const localPath = path.join(config.resourcesDirectoryPath, resourceKey);
|
|
306
384
|
|
|
307
385
|
return Metrics.span(
|
|
308
386
|
'resource.git.ensure',
|
|
@@ -313,20 +391,24 @@ const ensureGitResource = async (config: BtcaGitResourceArgs): Promise<string> =
|
|
|
313
391
|
Metrics.info('resource.git.update', {
|
|
314
392
|
name: config.name,
|
|
315
393
|
branch: config.branch,
|
|
316
|
-
|
|
394
|
+
repoSubPaths: config.repoSubPaths
|
|
317
395
|
});
|
|
318
396
|
await gitUpdate({
|
|
319
397
|
localAbsolutePath: localPath,
|
|
320
398
|
branch: config.branch,
|
|
399
|
+
repoSubPaths: config.repoSubPaths,
|
|
321
400
|
quiet: config.quiet
|
|
322
401
|
});
|
|
402
|
+
if (config.repoSubPaths.length > 0) {
|
|
403
|
+
await ensureSearchPathsExist(localPath, config.repoSubPaths);
|
|
404
|
+
}
|
|
323
405
|
return localPath;
|
|
324
406
|
}
|
|
325
407
|
|
|
326
408
|
Metrics.info('resource.git.clone', {
|
|
327
409
|
name: config.name,
|
|
328
410
|
branch: config.branch,
|
|
329
|
-
|
|
411
|
+
repoSubPaths: config.repoSubPaths
|
|
330
412
|
});
|
|
331
413
|
|
|
332
414
|
try {
|
|
@@ -342,10 +424,13 @@ const ensureGitResource = async (config: BtcaGitResourceArgs): Promise<string> =
|
|
|
342
424
|
await gitClone({
|
|
343
425
|
repoUrl: config.url,
|
|
344
426
|
repoBranch: config.branch,
|
|
345
|
-
|
|
427
|
+
repoSubPaths: config.repoSubPaths,
|
|
346
428
|
localAbsolutePath: localPath,
|
|
347
429
|
quiet: config.quiet
|
|
348
430
|
});
|
|
431
|
+
if (config.repoSubPaths.length > 0) {
|
|
432
|
+
await ensureSearchPathsExist(localPath, config.repoSubPaths);
|
|
433
|
+
}
|
|
349
434
|
|
|
350
435
|
return localPath;
|
|
351
436
|
},
|
|
@@ -358,8 +443,9 @@ export const loadGitResource = async (config: BtcaGitResourceArgs): Promise<Btca
|
|
|
358
443
|
return {
|
|
359
444
|
_tag: 'fs-based',
|
|
360
445
|
name: config.name,
|
|
446
|
+
fsName: resourceNameToKey(config.name),
|
|
361
447
|
type: 'git',
|
|
362
|
-
|
|
448
|
+
repoSubPaths: config.repoSubPaths,
|
|
363
449
|
specialAgentInstructions: config.specialAgentInstructions,
|
|
364
450
|
getAbsoluteDirectoryPath: async () => localPath
|
|
365
451
|
};
|
package/src/resources/schema.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { LIMITS } from '../validation/index.ts';
|
|
|
10
10
|
* Resource name: must start with a letter, followed by alphanumeric and hyphens only.
|
|
11
11
|
* Prevents path traversal, git option injection, and shell metacharacters.
|
|
12
12
|
*/
|
|
13
|
-
const RESOURCE_NAME_REGEX =
|
|
13
|
+
const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens.
|
|
@@ -31,8 +31,17 @@ const ResourceNameSchema = z
|
|
|
31
31
|
.max(LIMITS.RESOURCE_NAME_MAX, `Resource name too long (max ${LIMITS.RESOURCE_NAME_MAX} chars)`)
|
|
32
32
|
.regex(
|
|
33
33
|
RESOURCE_NAME_REGEX,
|
|
34
|
-
'Resource name must start with a letter and contain only
|
|
35
|
-
)
|
|
34
|
+
'Resource name must start with a letter or @ and contain only letters, numbers, ., _, -, and /'
|
|
35
|
+
)
|
|
36
|
+
.refine((name) => !name.includes('..'), {
|
|
37
|
+
message: 'Resource name must not contain ".."'
|
|
38
|
+
})
|
|
39
|
+
.refine((name) => !name.includes('//'), {
|
|
40
|
+
message: 'Resource name must not contain "//"'
|
|
41
|
+
})
|
|
42
|
+
.refine((name) => !name.endsWith('/'), {
|
|
43
|
+
message: 'Resource name must not end with "/"'
|
|
44
|
+
});
|
|
36
45
|
|
|
37
46
|
/**
|
|
38
47
|
* Git URL field with security validation.
|
|
@@ -113,7 +122,13 @@ const SearchPathSchema = z
|
|
|
113
122
|
})
|
|
114
123
|
.refine((path) => !path.startsWith('/') && !path.match(/^[a-zA-Z]:\\/), {
|
|
115
124
|
message: 'Search path must not be an absolute path'
|
|
116
|
-
})
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const OptionalSearchPathSchema = SearchPathSchema.optional();
|
|
128
|
+
|
|
129
|
+
const SearchPathsSchema = z
|
|
130
|
+
.array(SearchPathSchema)
|
|
131
|
+
.refine((paths) => paths.length > 0, { message: 'searchPaths must include at least one path' })
|
|
117
132
|
.optional();
|
|
118
133
|
|
|
119
134
|
/**
|
|
@@ -151,7 +166,8 @@ export const GitResourceSchema = z.object({
|
|
|
151
166
|
name: ResourceNameSchema,
|
|
152
167
|
url: GitUrlSchema,
|
|
153
168
|
branch: BranchNameSchema,
|
|
154
|
-
searchPath:
|
|
169
|
+
searchPath: OptionalSearchPathSchema,
|
|
170
|
+
searchPaths: SearchPathsSchema,
|
|
155
171
|
specialNotes: SpecialNotesSchema
|
|
156
172
|
});
|
|
157
173
|
|
package/src/resources/service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Config } from '../config/index.ts';
|
|
2
2
|
|
|
3
|
-
import { ResourceError } from './helpers.ts';
|
|
3
|
+
import { ResourceError, resourceNameToKey } from './helpers.ts';
|
|
4
4
|
import { loadGitResource } from './impls/git.ts';
|
|
5
5
|
import {
|
|
6
6
|
isGitResource,
|
|
@@ -20,6 +20,14 @@ export namespace Resources {
|
|
|
20
20
|
) => Promise<BtcaFsResource>;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
const normalizeSearchPaths = (definition: GitResource): string[] => {
|
|
24
|
+
const paths = [
|
|
25
|
+
...(definition.searchPaths ?? []),
|
|
26
|
+
...(definition.searchPath ? [definition.searchPath] : [])
|
|
27
|
+
];
|
|
28
|
+
return paths.filter((path) => path.trim().length > 0);
|
|
29
|
+
};
|
|
30
|
+
|
|
23
31
|
const definitionToGitArgs = (
|
|
24
32
|
definition: GitResource,
|
|
25
33
|
resourcesDirectory: string,
|
|
@@ -29,7 +37,7 @@ export namespace Resources {
|
|
|
29
37
|
name: definition.name,
|
|
30
38
|
url: definition.url,
|
|
31
39
|
branch: definition.branch,
|
|
32
|
-
|
|
40
|
+
repoSubPaths: normalizeSearchPaths(definition),
|
|
33
41
|
resourcesDirectoryPath: resourcesDirectory,
|
|
34
42
|
specialAgentInstructions: definition.specialNotes ?? '',
|
|
35
43
|
quiet
|
|
@@ -45,8 +53,9 @@ export namespace Resources {
|
|
|
45
53
|
const loadLocalResource = (args: BtcaLocalResourceArgs): BtcaFsResource => ({
|
|
46
54
|
_tag: 'fs-based',
|
|
47
55
|
name: args.name,
|
|
56
|
+
fsName: resourceNameToKey(args.name),
|
|
48
57
|
type: 'local',
|
|
49
|
-
|
|
58
|
+
repoSubPaths: [],
|
|
50
59
|
specialAgentInstructions: args.specialAgentInstructions,
|
|
51
60
|
getAbsoluteDirectoryPath: async () => args.path
|
|
52
61
|
});
|
package/src/resources/types.ts
CHANGED
|
@@ -4,8 +4,9 @@ export const FS_RESOURCE_SYSTEM_NOTE =
|
|
|
4
4
|
export type BtcaFsResource = {
|
|
5
5
|
readonly _tag: 'fs-based';
|
|
6
6
|
readonly name: string;
|
|
7
|
+
readonly fsName: string;
|
|
7
8
|
readonly type: 'git' | 'local';
|
|
8
|
-
readonly
|
|
9
|
+
readonly repoSubPaths: readonly string[];
|
|
9
10
|
readonly specialAgentInstructions: string;
|
|
10
11
|
readonly getAbsoluteDirectoryPath: () => Promise<string>;
|
|
11
12
|
};
|
|
@@ -15,7 +16,7 @@ export type BtcaGitResourceArgs = {
|
|
|
15
16
|
readonly name: string;
|
|
16
17
|
readonly url: string;
|
|
17
18
|
readonly branch: string;
|
|
18
|
-
readonly
|
|
19
|
+
readonly repoSubPaths: readonly string[];
|
|
19
20
|
readonly resourcesDirectoryPath: string;
|
|
20
21
|
readonly specialAgentInstructions: string;
|
|
21
22
|
readonly quiet: boolean;
|
package/src/validation/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Resource name: must start with a letter, followed by alphanumeric and hyphens only.
|
|
17
17
|
* This prevents path traversal (../), git option injection (-), and shell metacharacters.
|
|
18
18
|
*/
|
|
19
|
-
const RESOURCE_NAME_REGEX =
|
|
19
|
+
const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens.
|
|
@@ -59,8 +59,17 @@ export const LIMITS = {
|
|
|
59
59
|
|
|
60
60
|
export type ValidationResult = { valid: true } | { valid: false; error: string };
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Validation result that includes a normalized value.
|
|
64
|
+
*/
|
|
65
|
+
export type ValidationResultWithValue<T> =
|
|
66
|
+
| { valid: true; value: T }
|
|
67
|
+
| { valid: false; error: string };
|
|
68
|
+
|
|
62
69
|
const ok = (): ValidationResult => ({ valid: true });
|
|
70
|
+
const okWithValue = <T>(value: T): ValidationResultWithValue<T> => ({ valid: true, value });
|
|
63
71
|
const fail = (error: string): ValidationResult => ({ valid: false, error });
|
|
72
|
+
const failWithValue = <T>(error: string): ValidationResultWithValue<T> => ({ valid: false, error });
|
|
64
73
|
|
|
65
74
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
75
|
// Validators
|
|
@@ -86,9 +95,18 @@ export const validateResourceName = (name: string): ValidationResult => {
|
|
|
86
95
|
|
|
87
96
|
if (!RESOURCE_NAME_REGEX.test(name)) {
|
|
88
97
|
return fail(
|
|
89
|
-
`Invalid resource name: "${name}". Must start with a letter and contain only
|
|
98
|
+
`Invalid resource name: "${name}". Must start with a letter or @ and contain only letters, numbers, ., _, -, and /`
|
|
90
99
|
);
|
|
91
100
|
}
|
|
101
|
+
if (name.includes('..')) {
|
|
102
|
+
return fail('Resource name must not contain ".."');
|
|
103
|
+
}
|
|
104
|
+
if (name.includes('//')) {
|
|
105
|
+
return fail('Resource name must not contain "//"');
|
|
106
|
+
}
|
|
107
|
+
if (name.endsWith('/')) {
|
|
108
|
+
return fail('Resource name must not end with "/"');
|
|
109
|
+
}
|
|
92
110
|
|
|
93
111
|
return ok();
|
|
94
112
|
};
|
|
@@ -126,37 +144,84 @@ export const validateBranchName = (branch: string): ValidationResult => {
|
|
|
126
144
|
return ok();
|
|
127
145
|
};
|
|
128
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Normalize a GitHub URL to its base repository format.
|
|
149
|
+
*
|
|
150
|
+
* Handles URLs like:
|
|
151
|
+
* - https://github.com/owner/repo/blob/main/README.md → https://github.com/owner/repo
|
|
152
|
+
* - https://github.com/owner/repo/tree/branch/path → https://github.com/owner/repo
|
|
153
|
+
* - https://github.com/owner/repo.git → https://github.com/owner/repo
|
|
154
|
+
* - https://github.com/owner/repo/ → https://github.com/owner/repo
|
|
155
|
+
*
|
|
156
|
+
* Non-GitHub URLs are returned unchanged.
|
|
157
|
+
*/
|
|
158
|
+
export const normalizeGitHubUrl = (url: string): string => {
|
|
159
|
+
let parsed: URL;
|
|
160
|
+
try {
|
|
161
|
+
parsed = new URL(url);
|
|
162
|
+
} catch {
|
|
163
|
+
return url; // Return as-is if not a valid URL
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
167
|
+
if (hostname !== 'github.com') {
|
|
168
|
+
return url; // Non-GitHub URLs pass through unchanged
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Extract path segments, filtering out empty strings
|
|
172
|
+
const segments = parsed.pathname.split('/').filter((s) => s.length > 0);
|
|
173
|
+
|
|
174
|
+
// Need at least owner and repo
|
|
175
|
+
if (segments.length < 2) {
|
|
176
|
+
return url;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Get owner and repo (first two segments)
|
|
180
|
+
const owner = segments[0];
|
|
181
|
+
let repo = segments[1]!;
|
|
182
|
+
|
|
183
|
+
// Remove .git suffix if present
|
|
184
|
+
if (repo.endsWith('.git')) {
|
|
185
|
+
repo = repo.slice(0, -4);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return `https://github.com/${owner}/${repo}`;
|
|
189
|
+
};
|
|
190
|
+
|
|
129
191
|
/**
|
|
130
192
|
* Validate a git URL to prevent unsafe git operations.
|
|
193
|
+
* Returns the normalized URL on success.
|
|
131
194
|
*
|
|
132
195
|
* Requirements:
|
|
133
196
|
* - Valid URL format
|
|
134
197
|
* - HTTPS protocol only (rejects file://, git://, ssh://, ext::, etc.)
|
|
135
198
|
* - No embedded credentials
|
|
136
199
|
* - No localhost or private IP addresses
|
|
200
|
+
*
|
|
201
|
+
* For GitHub URLs, the URL is normalized to the base repository format.
|
|
137
202
|
*/
|
|
138
|
-
export const validateGitUrl = (url: string):
|
|
203
|
+
export const validateGitUrl = (url: string): ValidationResultWithValue<string> => {
|
|
139
204
|
if (!url || url.trim().length === 0) {
|
|
140
|
-
return
|
|
205
|
+
return failWithValue('Git URL cannot be empty');
|
|
141
206
|
}
|
|
142
207
|
|
|
143
208
|
let parsed: URL;
|
|
144
209
|
try {
|
|
145
210
|
parsed = new URL(url);
|
|
146
211
|
} catch {
|
|
147
|
-
return
|
|
212
|
+
return failWithValue(`Invalid URL format: "${url}"`);
|
|
148
213
|
}
|
|
149
214
|
|
|
150
215
|
// Only allow HTTPS protocol
|
|
151
216
|
if (parsed.protocol !== 'https:') {
|
|
152
|
-
return
|
|
217
|
+
return failWithValue(
|
|
153
218
|
`Invalid URL protocol: ${parsed.protocol}. Only HTTPS URLs are allowed for security reasons`
|
|
154
219
|
);
|
|
155
220
|
}
|
|
156
221
|
|
|
157
222
|
// Reject embedded credentials
|
|
158
223
|
if (parsed.username || parsed.password) {
|
|
159
|
-
return
|
|
224
|
+
return failWithValue('URL must not contain embedded credentials');
|
|
160
225
|
}
|
|
161
226
|
|
|
162
227
|
// Reject localhost and private IP addresses
|
|
@@ -170,10 +235,12 @@ export const validateGitUrl = (url: string): ValidationResult => {
|
|
|
170
235
|
hostname === '::1' ||
|
|
171
236
|
hostname === '0.0.0.0'
|
|
172
237
|
) {
|
|
173
|
-
return
|
|
238
|
+
return failWithValue(`URL must not point to localhost or private IP addresses: ${hostname}`);
|
|
174
239
|
}
|
|
175
240
|
|
|
176
|
-
|
|
241
|
+
// Normalize GitHub URLs
|
|
242
|
+
const normalizedUrl = normalizeGitHubUrl(url);
|
|
243
|
+
return okWithValue(normalizedUrl);
|
|
177
244
|
};
|
|
178
245
|
|
|
179
246
|
/**
|
|
@@ -213,6 +280,20 @@ export const validateSearchPath = (searchPath: string | undefined): ValidationRe
|
|
|
213
280
|
return ok();
|
|
214
281
|
};
|
|
215
282
|
|
|
283
|
+
export const validateSearchPaths = (
|
|
284
|
+
searchPaths: string[] | undefined
|
|
285
|
+
): ValidationResult => {
|
|
286
|
+
if (!searchPaths) return ok();
|
|
287
|
+
if (searchPaths.length === 0) return fail('searchPaths must include at least one path');
|
|
288
|
+
|
|
289
|
+
for (const searchPath of searchPaths) {
|
|
290
|
+
const result = validateSearchPath(searchPath);
|
|
291
|
+
if (!result.valid) return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return ok();
|
|
295
|
+
};
|
|
296
|
+
|
|
216
297
|
/**
|
|
217
298
|
* Validate a local file path.
|
|
218
299
|
*
|
|
@@ -351,52 +432,89 @@ export const validateResourcesArray = (resources: string[] | undefined): Validat
|
|
|
351
432
|
// Composite Validators
|
|
352
433
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
353
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Validated git resource with normalized URL.
|
|
437
|
+
*/
|
|
438
|
+
export interface ValidatedGitResource {
|
|
439
|
+
name: string;
|
|
440
|
+
url: string;
|
|
441
|
+
branch: string;
|
|
442
|
+
searchPath?: string;
|
|
443
|
+
searchPaths?: string[];
|
|
444
|
+
specialNotes?: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Validated local resource.
|
|
449
|
+
*/
|
|
450
|
+
export interface ValidatedLocalResource {
|
|
451
|
+
name: string;
|
|
452
|
+
path: string;
|
|
453
|
+
specialNotes?: string;
|
|
454
|
+
}
|
|
455
|
+
|
|
354
456
|
/**
|
|
355
457
|
* Validate a complete git resource definition.
|
|
458
|
+
* Returns the resource with a normalized URL on success.
|
|
356
459
|
*/
|
|
357
460
|
export const validateGitResource = (resource: {
|
|
358
461
|
name: string;
|
|
359
462
|
url: string;
|
|
360
463
|
branch: string;
|
|
361
464
|
searchPath?: string;
|
|
465
|
+
searchPaths?: string[];
|
|
362
466
|
specialNotes?: string;
|
|
363
|
-
}):
|
|
467
|
+
}): ValidationResultWithValue<ValidatedGitResource> => {
|
|
364
468
|
const nameResult = validateResourceName(resource.name);
|
|
365
|
-
if (!nameResult.valid) return nameResult;
|
|
469
|
+
if (!nameResult.valid) return failWithValue(nameResult.error);
|
|
366
470
|
|
|
367
471
|
const urlResult = validateGitUrl(resource.url);
|
|
368
|
-
if (!urlResult.valid) return urlResult;
|
|
472
|
+
if (!urlResult.valid) return failWithValue(urlResult.error);
|
|
369
473
|
|
|
370
474
|
const branchResult = validateBranchName(resource.branch);
|
|
371
|
-
if (!branchResult.valid) return branchResult;
|
|
475
|
+
if (!branchResult.valid) return failWithValue(branchResult.error);
|
|
372
476
|
|
|
373
477
|
const searchPathResult = validateSearchPath(resource.searchPath);
|
|
374
|
-
if (!searchPathResult.valid) return searchPathResult;
|
|
478
|
+
if (!searchPathResult.valid) return failWithValue(searchPathResult.error);
|
|
479
|
+
const searchPathsResult = validateSearchPaths(resource.searchPaths);
|
|
480
|
+
if (!searchPathsResult.valid) return failWithValue(searchPathsResult.error);
|
|
375
481
|
|
|
376
482
|
const notesResult = validateNotes(resource.specialNotes);
|
|
377
|
-
if (!notesResult.valid) return notesResult;
|
|
378
|
-
|
|
379
|
-
return
|
|
483
|
+
if (!notesResult.valid) return failWithValue(notesResult.error);
|
|
484
|
+
|
|
485
|
+
return okWithValue({
|
|
486
|
+
name: resource.name,
|
|
487
|
+
url: urlResult.value, // Use the normalized URL
|
|
488
|
+
branch: resource.branch,
|
|
489
|
+
...(resource.searchPath && { searchPath: resource.searchPath }),
|
|
490
|
+
...(resource.searchPaths && { searchPaths: resource.searchPaths }),
|
|
491
|
+
...(resource.specialNotes && { specialNotes: resource.specialNotes })
|
|
492
|
+
});
|
|
380
493
|
};
|
|
381
494
|
|
|
382
495
|
/**
|
|
383
496
|
* Validate a complete local resource definition.
|
|
497
|
+
* Returns the validated resource on success.
|
|
384
498
|
*/
|
|
385
499
|
export const validateLocalResource = (resource: {
|
|
386
500
|
name: string;
|
|
387
501
|
path: string;
|
|
388
502
|
specialNotes?: string;
|
|
389
|
-
}):
|
|
503
|
+
}): ValidationResultWithValue<ValidatedLocalResource> => {
|
|
390
504
|
const nameResult = validateResourceName(resource.name);
|
|
391
|
-
if (!nameResult.valid) return nameResult;
|
|
505
|
+
if (!nameResult.valid) return failWithValue(nameResult.error);
|
|
392
506
|
|
|
393
507
|
const pathResult = validateLocalPath(resource.path);
|
|
394
|
-
if (!pathResult.valid) return pathResult;
|
|
508
|
+
if (!pathResult.valid) return failWithValue(pathResult.error);
|
|
395
509
|
|
|
396
510
|
const notesResult = validateNotes(resource.specialNotes);
|
|
397
|
-
if (!notesResult.valid) return notesResult;
|
|
511
|
+
if (!notesResult.valid) return failWithValue(notesResult.error);
|
|
398
512
|
|
|
399
|
-
return
|
|
513
|
+
return okWithValue({
|
|
514
|
+
name: resource.name,
|
|
515
|
+
path: resource.path,
|
|
516
|
+
...(resource.specialNotes && { specialNotes: resource.specialNotes })
|
|
517
|
+
});
|
|
400
518
|
};
|
|
401
519
|
|
|
402
520
|
/**
|