btca-server 2.0.1 → 2.0.3
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/agent.test.ts +25 -16
- package/src/agent/service.ts +100 -110
- package/src/collections/service.test.ts +119 -0
- package/src/collections/service.ts +73 -14
- package/src/config/config.test.ts +21 -16
- package/src/config/index.ts +179 -200
- package/src/effect/services.ts +15 -23
- package/src/index.ts +4 -1
- package/src/resources/service.ts +45 -44
- package/src/vfs/virtual-fs.ts +73 -0
package/package.json
CHANGED
package/src/agent/agent.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
+
import { Effect } from 'effect';
|
|
5
6
|
|
|
6
7
|
import { createAgentService } from './service.ts';
|
|
7
8
|
import { load as loadConfig } from '../config/index.ts';
|
|
@@ -71,10 +72,12 @@ describe('Agent', () => {
|
|
|
71
72
|
vfsId
|
|
72
73
|
};
|
|
73
74
|
|
|
74
|
-
const result = await
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
const result = await Effect.runPromise(
|
|
76
|
+
agent.ask({
|
|
77
|
+
collection,
|
|
78
|
+
question: 'What number is the answer to life according to the README?'
|
|
79
|
+
})
|
|
80
|
+
);
|
|
78
81
|
|
|
79
82
|
expect(result).toBeDefined();
|
|
80
83
|
expect(result.answer).toBeDefined();
|
|
@@ -102,10 +105,12 @@ describe('Agent', () => {
|
|
|
102
105
|
vfsId
|
|
103
106
|
};
|
|
104
107
|
|
|
105
|
-
const { stream, model } = await
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
const { stream, model } = await Effect.runPromise(
|
|
109
|
+
agent.askStream({
|
|
110
|
+
collection,
|
|
111
|
+
question: 'What is the capital of France according to the data file?'
|
|
112
|
+
})
|
|
113
|
+
);
|
|
109
114
|
|
|
110
115
|
expect(model).toBeDefined();
|
|
111
116
|
expect(model.provider).toBeDefined();
|
|
@@ -154,10 +159,12 @@ describe('Agent', () => {
|
|
|
154
159
|
vfsId
|
|
155
160
|
};
|
|
156
161
|
|
|
157
|
-
const result = await
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
const result = await Effect.runPromise(
|
|
163
|
+
agent.ask({
|
|
164
|
+
collection,
|
|
165
|
+
question: 'What number is the answer according to the README?'
|
|
166
|
+
})
|
|
167
|
+
);
|
|
161
168
|
|
|
162
169
|
expect(result).toBeDefined();
|
|
163
170
|
expect(hasVirtualFs(vfsId)).toBe(false);
|
|
@@ -200,10 +207,12 @@ describe('Agent', () => {
|
|
|
200
207
|
vfsId
|
|
201
208
|
};
|
|
202
209
|
|
|
203
|
-
const { stream } = await
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
210
|
+
const { stream } = await Effect.runPromise(
|
|
211
|
+
agent.askStream({
|
|
212
|
+
collection,
|
|
213
|
+
question: 'What is the capital of France according to the README?'
|
|
214
|
+
})
|
|
215
|
+
);
|
|
207
216
|
|
|
208
217
|
for await (const _event of stream) {
|
|
209
218
|
// drain stream to trigger cleanup
|
package/src/agent/service.ts
CHANGED
|
@@ -87,11 +87,7 @@ export class ProviderNotConnectedError extends Error {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export type AgentService = {
|
|
90
|
-
askStream: (args: { collection: CollectionResult; question: string }) =>
|
|
91
|
-
stream: AsyncIterable<AgentEvent>;
|
|
92
|
-
model: { provider: string; model: string };
|
|
93
|
-
}>;
|
|
94
|
-
askStreamEffect: (args: { collection: CollectionResult; question: string }) => Effect.Effect<
|
|
90
|
+
askStream: (args: { collection: CollectionResult; question: string }) => Effect.Effect<
|
|
95
91
|
{
|
|
96
92
|
stream: AsyncIterable<AgentEvent>;
|
|
97
93
|
model: { provider: string; model: string };
|
|
@@ -99,17 +95,12 @@ export type AgentService = {
|
|
|
99
95
|
unknown
|
|
100
96
|
>;
|
|
101
97
|
|
|
102
|
-
ask: (args: {
|
|
103
|
-
askEffect: (args: {
|
|
98
|
+
ask: (args: {
|
|
104
99
|
collection: CollectionResult;
|
|
105
100
|
question: string;
|
|
106
101
|
}) => Effect.Effect<AgentResult, unknown>;
|
|
107
102
|
|
|
108
|
-
listProviders: () =>
|
|
109
|
-
all: { id: string; models: Record<string, unknown> }[];
|
|
110
|
-
connected: string[];
|
|
111
|
-
}>;
|
|
112
|
-
listProvidersEffect: () => Effect.Effect<
|
|
103
|
+
listProviders: () => Effect.Effect<
|
|
113
104
|
{
|
|
114
105
|
all: { id: string; models: Record<string, unknown> }[];
|
|
115
106
|
connected: string[];
|
|
@@ -121,36 +112,39 @@ export type AgentService = {
|
|
|
121
112
|
export type Service = AgentService;
|
|
122
113
|
|
|
123
114
|
export const createAgentService = (config: ConfigServiceShape): AgentService => {
|
|
124
|
-
const cleanupCollection = (collection: CollectionResult) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
115
|
+
const cleanupCollection = async (collection: CollectionResult) => {
|
|
116
|
+
if (collection.vfsId) {
|
|
117
|
+
disposeVirtualFs(collection.vfsId);
|
|
118
|
+
clearVirtualCollectionMetadata(collection.vfsId);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await collection.cleanup?.();
|
|
122
|
+
} catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
136
126
|
|
|
137
|
-
const ensureProviderConnected =
|
|
138
|
-
const isAuthed =
|
|
127
|
+
const ensureProviderConnected = async () => {
|
|
128
|
+
const isAuthed = await isAuthenticated(config.provider);
|
|
139
129
|
const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
|
|
140
130
|
if (isAuthed || !requiresAuth) return;
|
|
141
|
-
const authenticated =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
);
|
|
148
|
-
});
|
|
131
|
+
const authenticated = await getAuthenticatedProviders();
|
|
132
|
+
throw new ProviderNotConnectedError({
|
|
133
|
+
providerId: config.provider,
|
|
134
|
+
connectedProviders: authenticated
|
|
135
|
+
});
|
|
136
|
+
};
|
|
149
137
|
|
|
150
138
|
/**
|
|
151
139
|
* Ask a question and stream the response using the new AI SDK loop
|
|
152
140
|
*/
|
|
153
|
-
const
|
|
141
|
+
const askStreamImpl = async ({
|
|
142
|
+
collection,
|
|
143
|
+
question
|
|
144
|
+
}: {
|
|
145
|
+
collection: CollectionResult;
|
|
146
|
+
question: string;
|
|
147
|
+
}) => {
|
|
154
148
|
metricsInfo('agent.ask.start', {
|
|
155
149
|
provider: config.provider,
|
|
156
150
|
model: config.model,
|
|
@@ -158,9 +152,9 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
|
|
|
158
152
|
});
|
|
159
153
|
|
|
160
154
|
try {
|
|
161
|
-
await
|
|
155
|
+
await ensureProviderConnected();
|
|
162
156
|
} catch (error) {
|
|
163
|
-
await
|
|
157
|
+
await cleanupCollection(collection);
|
|
164
158
|
throw error;
|
|
165
159
|
}
|
|
166
160
|
|
|
@@ -181,7 +175,7 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
|
|
|
181
175
|
yield event;
|
|
182
176
|
}
|
|
183
177
|
} finally {
|
|
184
|
-
await
|
|
178
|
+
await cleanupCollection(collection);
|
|
185
179
|
}
|
|
186
180
|
})();
|
|
187
181
|
|
|
@@ -194,103 +188,99 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
|
|
|
194
188
|
/**
|
|
195
189
|
* Ask a question and return the complete response
|
|
196
190
|
*/
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
191
|
+
const askImpl = async ({
|
|
192
|
+
collection,
|
|
193
|
+
question
|
|
194
|
+
}: {
|
|
195
|
+
collection: CollectionResult;
|
|
196
|
+
question: string;
|
|
197
|
+
}) => {
|
|
198
|
+
try {
|
|
199
|
+
metricsInfo('agent.ask.start', {
|
|
200
|
+
provider: config.provider,
|
|
201
|
+
model: config.model,
|
|
202
|
+
questionLength: question.length
|
|
203
|
+
});
|
|
205
204
|
|
|
206
|
-
|
|
205
|
+
await ensureProviderConnected();
|
|
207
206
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}),
|
|
220
|
-
catch: (cause) =>
|
|
221
|
-
new AgentError({
|
|
222
|
-
message: getErrorMessage(cause),
|
|
223
|
-
hint:
|
|
224
|
-
getErrorHint(cause) ??
|
|
225
|
-
'This may be a temporary issue. Try running the command again.',
|
|
226
|
-
cause
|
|
227
|
-
})
|
|
207
|
+
let result: Awaited<ReturnType<typeof runAgentLoop>>;
|
|
208
|
+
try {
|
|
209
|
+
result = await runAgentLoop({
|
|
210
|
+
providerId: config.provider,
|
|
211
|
+
modelId: config.model,
|
|
212
|
+
maxSteps: config.maxSteps,
|
|
213
|
+
collectionPath: collection.path,
|
|
214
|
+
vfsId: collection.vfsId,
|
|
215
|
+
agentInstructions: collection.agentInstructions,
|
|
216
|
+
question,
|
|
217
|
+
providerOptions: config.getProviderOptions(config.provider)
|
|
228
218
|
});
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
throw new AgentError({
|
|
221
|
+
message: getErrorMessage(cause),
|
|
222
|
+
hint:
|
|
223
|
+
getErrorHint(cause) ?? 'This may be a temporary issue. Try running the command again.',
|
|
224
|
+
cause
|
|
235
225
|
});
|
|
226
|
+
}
|
|
236
227
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
228
|
+
metricsInfo('agent.ask.complete', {
|
|
229
|
+
provider: config.provider,
|
|
230
|
+
model: config.model,
|
|
231
|
+
answerLength: result.answer.length,
|
|
232
|
+
eventCount: result.events.length
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
answer: result.answer,
|
|
237
|
+
model: result.model,
|
|
238
|
+
events: result.events
|
|
239
|
+
};
|
|
240
|
+
} catch (error) {
|
|
241
|
+
metricsError('agent.ask.error', { error: metricsErrorInfo(error) });
|
|
242
|
+
throw error;
|
|
243
|
+
} finally {
|
|
244
|
+
await cleanupCollection(collection);
|
|
245
|
+
}
|
|
249
246
|
};
|
|
250
247
|
|
|
251
248
|
/**
|
|
252
249
|
* List available providers using local auth data
|
|
253
250
|
*/
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
models: {} as Record<string, unknown>
|
|
262
|
-
}));
|
|
251
|
+
const listProvidersImpl = async () => {
|
|
252
|
+
const supportedProviders = getSupportedProviders();
|
|
253
|
+
const authenticatedProviders = await getAuthenticatedProviders();
|
|
254
|
+
const all = supportedProviders.map((id) => ({
|
|
255
|
+
id,
|
|
256
|
+
models: {} as Record<string, unknown>
|
|
257
|
+
}));
|
|
263
258
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
})
|
|
269
|
-
);
|
|
259
|
+
return {
|
|
260
|
+
all,
|
|
261
|
+
connected: authenticatedProviders
|
|
262
|
+
};
|
|
270
263
|
};
|
|
271
264
|
|
|
272
|
-
const
|
|
265
|
+
const askStream: AgentService['askStream'] = (args) =>
|
|
273
266
|
Effect.tryPromise({
|
|
274
|
-
try: () =>
|
|
267
|
+
try: () => askStreamImpl(args),
|
|
275
268
|
catch: (cause) => cause
|
|
276
269
|
});
|
|
277
|
-
const
|
|
270
|
+
const ask: AgentService['ask'] = (args) =>
|
|
278
271
|
Effect.tryPromise({
|
|
279
|
-
try: () =>
|
|
272
|
+
try: () => askImpl(args),
|
|
280
273
|
catch: (cause) => cause
|
|
281
274
|
});
|
|
282
|
-
const
|
|
275
|
+
const listProviders: AgentService['listProviders'] = () =>
|
|
283
276
|
Effect.tryPromise({
|
|
284
|
-
try: () =>
|
|
277
|
+
try: () => listProvidersImpl(),
|
|
285
278
|
catch: (cause) => cause
|
|
286
279
|
});
|
|
287
280
|
|
|
288
281
|
return {
|
|
289
282
|
askStream,
|
|
290
283
|
ask,
|
|
291
|
-
listProviders
|
|
292
|
-
askStreamEffect,
|
|
293
|
-
askEffect,
|
|
294
|
-
listProvidersEffect
|
|
284
|
+
listProviders
|
|
295
285
|
};
|
|
296
286
|
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import type { ConfigService } from '../config/index.ts';
|
|
7
|
+
import type { ResourcesService } from '../resources/service.ts';
|
|
8
|
+
import { createCollectionsService } from './service.ts';
|
|
9
|
+
import { disposeVirtualFs, existsInVirtualFs } from '../vfs/virtual-fs.ts';
|
|
10
|
+
|
|
11
|
+
const createLocalResource = (name: string, resourcePath: string) => ({
|
|
12
|
+
_tag: 'fs-based' as const,
|
|
13
|
+
name,
|
|
14
|
+
fsName: name,
|
|
15
|
+
type: 'local' as const,
|
|
16
|
+
repoSubPaths: [],
|
|
17
|
+
specialAgentInstructions: '',
|
|
18
|
+
getAbsoluteDirectoryPath: async () => resourcePath
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const createConfigMock = () =>
|
|
22
|
+
({
|
|
23
|
+
getResource: () => undefined
|
|
24
|
+
}) as unknown as ConfigService;
|
|
25
|
+
|
|
26
|
+
const createResourcesMock = (resourcePath: string) =>
|
|
27
|
+
({
|
|
28
|
+
load: () => {
|
|
29
|
+
throw new Error('Not implemented in test');
|
|
30
|
+
},
|
|
31
|
+
loadPromise: async () => createLocalResource('repo', resourcePath)
|
|
32
|
+
}) as unknown as ResourcesService;
|
|
33
|
+
|
|
34
|
+
const runGit = (cwd: string, args: string[]) => {
|
|
35
|
+
const result = Bun.spawnSync({
|
|
36
|
+
cmd: ['git', ...args],
|
|
37
|
+
cwd,
|
|
38
|
+
stdout: 'pipe',
|
|
39
|
+
stderr: 'pipe'
|
|
40
|
+
});
|
|
41
|
+
if (result.exitCode !== 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`git ${args.join(' ')} failed: ${new TextDecoder().decode(result.stderr).trim()}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const cleanupCollection = async (collection: { vfsId?: string; cleanup?: () => Promise<void> }) => {
|
|
49
|
+
await collection.cleanup?.();
|
|
50
|
+
if (collection.vfsId) disposeVirtualFs(collection.vfsId);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('createCollectionsService', () => {
|
|
54
|
+
it('imports git-backed local resources from tracked and unignored files only', async () => {
|
|
55
|
+
const resourcePath = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-collections-git-'));
|
|
56
|
+
const collections = createCollectionsService({
|
|
57
|
+
config: createConfigMock(),
|
|
58
|
+
resources: createResourcesMock(resourcePath)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await fs.mkdir(path.join(resourcePath, 'node_modules', 'pkg'), { recursive: true });
|
|
63
|
+
await fs.writeFile(path.join(resourcePath, '.gitignore'), 'node_modules\n');
|
|
64
|
+
await fs.writeFile(path.join(resourcePath, 'package.json'), '{"name":"repo"}\n');
|
|
65
|
+
await fs.writeFile(path.join(resourcePath, 'README.md'), 'local notes\n');
|
|
66
|
+
await fs.writeFile(path.join(resourcePath, 'node_modules', 'pkg', 'index.js'), 'ignored\n');
|
|
67
|
+
|
|
68
|
+
runGit(resourcePath, ['init', '-q']);
|
|
69
|
+
runGit(resourcePath, ['add', '.gitignore', 'package.json']);
|
|
70
|
+
|
|
71
|
+
const collection = await collections.loadPromise({ resourceNames: ['repo'] });
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
expect(await existsInVirtualFs('/repo/package.json', collection.vfsId)).toBe(true);
|
|
75
|
+
expect(await existsInVirtualFs('/repo/README.md', collection.vfsId)).toBe(true);
|
|
76
|
+
expect(await existsInVirtualFs('/repo/node_modules/pkg/index.js', collection.vfsId)).toBe(
|
|
77
|
+
false
|
|
78
|
+
);
|
|
79
|
+
expect(await existsInVirtualFs('/repo/.git/config', collection.vfsId)).toBe(false);
|
|
80
|
+
} finally {
|
|
81
|
+
await cleanupCollection(collection);
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
await fs.rm(resourcePath, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('falls back to directory import and still skips heavy local build directories', async () => {
|
|
89
|
+
const resourcePath = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-collections-local-'));
|
|
90
|
+
const collections = createCollectionsService({
|
|
91
|
+
config: createConfigMock(),
|
|
92
|
+
resources: createResourcesMock(resourcePath)
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await fs.mkdir(path.join(resourcePath, 'node_modules', 'pkg'), { recursive: true });
|
|
97
|
+
await fs.mkdir(path.join(resourcePath, 'dist'), { recursive: true });
|
|
98
|
+
await fs.writeFile(path.join(resourcePath, 'package.json'), '{"name":"repo"}\n');
|
|
99
|
+
await fs.writeFile(path.join(resourcePath, 'README.md'), 'hello\n');
|
|
100
|
+
await fs.writeFile(path.join(resourcePath, 'node_modules', 'pkg', 'index.js'), 'ignored\n');
|
|
101
|
+
await fs.writeFile(path.join(resourcePath, 'dist', 'bundle.js'), 'ignored\n');
|
|
102
|
+
|
|
103
|
+
const collection = await collections.loadPromise({ resourceNames: ['repo'] });
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
expect(await existsInVirtualFs('/repo/package.json', collection.vfsId)).toBe(true);
|
|
107
|
+
expect(await existsInVirtualFs('/repo/README.md', collection.vfsId)).toBe(true);
|
|
108
|
+
expect(await existsInVirtualFs('/repo/node_modules/pkg/index.js', collection.vfsId)).toBe(
|
|
109
|
+
false
|
|
110
|
+
);
|
|
111
|
+
expect(await existsInVirtualFs('/repo/dist/bundle.js', collection.vfsId)).toBe(false);
|
|
112
|
+
} finally {
|
|
113
|
+
await cleanupCollection(collection);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
await fs.rm(resourcePath, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
createVirtualFs,
|
|
16
16
|
disposeVirtualFs,
|
|
17
17
|
importDirectoryIntoVirtualFs,
|
|
18
|
+
importPathsIntoVirtualFs,
|
|
18
19
|
mkdirVirtualFs,
|
|
19
20
|
rmVirtualFs
|
|
20
21
|
} from '../vfs/virtual-fs.ts';
|
|
@@ -25,11 +26,14 @@ import {
|
|
|
25
26
|
} from './virtual-metadata.ts';
|
|
26
27
|
|
|
27
28
|
export type CollectionsService = {
|
|
28
|
-
load: (args: {
|
|
29
|
-
loadEffect: (args: {
|
|
29
|
+
load: (args: {
|
|
30
30
|
resourceNames: readonly string[];
|
|
31
31
|
quiet?: boolean;
|
|
32
|
-
}) => Effect.Effect<CollectionResult, CollectionError>;
|
|
32
|
+
}) => Effect.Effect<CollectionResult, CollectionError, never>;
|
|
33
|
+
loadPromise: (args: {
|
|
34
|
+
resourceNames: readonly string[];
|
|
35
|
+
quiet?: boolean;
|
|
36
|
+
}) => Promise<CollectionResult>;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
const encodePathSegments = (value: string) => value.split('/').map(encodeURIComponent).join('/');
|
|
@@ -92,6 +96,53 @@ const ignoreErrors = async (action: () => Promise<unknown>) => {
|
|
|
92
96
|
}
|
|
93
97
|
};
|
|
94
98
|
|
|
99
|
+
const LOCAL_RESOURCE_IGNORED_DIRECTORIES = new Set([
|
|
100
|
+
'.git',
|
|
101
|
+
'.turbo',
|
|
102
|
+
'.next',
|
|
103
|
+
'.svelte-kit',
|
|
104
|
+
'.vercel',
|
|
105
|
+
'.cache',
|
|
106
|
+
'coverage',
|
|
107
|
+
'dist',
|
|
108
|
+
'build',
|
|
109
|
+
'out',
|
|
110
|
+
'node_modules'
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const normalizeRelativePath = (value: string) => value.split(path.sep).join('/');
|
|
114
|
+
|
|
115
|
+
const shouldIgnoreImportedPath = (resource: BtcaFsResource, relativePath: string) => {
|
|
116
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
117
|
+
if (!normalized || normalized === '.') return false;
|
|
118
|
+
const segments = normalized.split('/');
|
|
119
|
+
if (segments.includes('.git')) return true;
|
|
120
|
+
if (resource.type !== 'local') return false;
|
|
121
|
+
return segments.some((segment) => LOCAL_RESOURCE_IGNORED_DIRECTORIES.has(segment));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const listGitVisiblePaths = async (resourcePath: string) => {
|
|
125
|
+
try {
|
|
126
|
+
const proc = Bun.spawn(
|
|
127
|
+
['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
|
|
128
|
+
{
|
|
129
|
+
cwd: resourcePath,
|
|
130
|
+
stdout: 'pipe',
|
|
131
|
+
stderr: 'ignore'
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
const stdout = await new Response(proc.stdout).text();
|
|
135
|
+
const exitCode = await proc.exited;
|
|
136
|
+
if (exitCode !== 0) return null;
|
|
137
|
+
return stdout
|
|
138
|
+
.split('\0')
|
|
139
|
+
.map((entry) => entry.trim())
|
|
140
|
+
.filter((entry) => entry.length > 0);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
95
146
|
const initVirtualRoot = async (collectionPath: string, vfsId: string) => {
|
|
96
147
|
try {
|
|
97
148
|
await mkdirVirtualFs(collectionPath, { recursive: true }, vfsId);
|
|
@@ -106,7 +157,7 @@ const initVirtualRoot = async (collectionPath: string, vfsId: string) => {
|
|
|
106
157
|
|
|
107
158
|
const loadResource = async (resources: ResourcesService, name: string, quiet: boolean) => {
|
|
108
159
|
try {
|
|
109
|
-
return await resources.
|
|
160
|
+
return await resources.loadPromise(name, { quiet });
|
|
110
161
|
} catch (cause) {
|
|
111
162
|
const underlyingHint = getErrorHint(cause);
|
|
112
163
|
const underlyingMessage = getErrorMessage(cause);
|
|
@@ -139,16 +190,24 @@ const virtualizeResource = async (args: {
|
|
|
139
190
|
vfsId: string;
|
|
140
191
|
}) => {
|
|
141
192
|
try {
|
|
193
|
+
if (args.resource.type === 'local') {
|
|
194
|
+
const gitVisiblePaths = await listGitVisiblePaths(args.resourcePath);
|
|
195
|
+
if (gitVisiblePaths) {
|
|
196
|
+
await importPathsIntoVirtualFs({
|
|
197
|
+
sourcePath: args.resourcePath,
|
|
198
|
+
destinationPath: args.virtualResourcePath,
|
|
199
|
+
relativePaths: gitVisiblePaths,
|
|
200
|
+
vfsId: args.vfsId
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
142
206
|
await importDirectoryIntoVirtualFs({
|
|
143
207
|
sourcePath: args.resourcePath,
|
|
144
208
|
destinationPath: args.virtualResourcePath,
|
|
145
209
|
vfsId: args.vfsId,
|
|
146
|
-
ignore: (relativePath) =>
|
|
147
|
-
const normalized = relativePath.split(path.sep).join('/');
|
|
148
|
-
return (
|
|
149
|
-
normalized === '.git' || normalized.startsWith('.git/') || normalized.includes('/.git/')
|
|
150
|
-
);
|
|
151
|
-
}
|
|
210
|
+
ignore: (relativePath) => shouldIgnoreImportedPath(args.resource, relativePath)
|
|
152
211
|
});
|
|
153
212
|
} catch (cause) {
|
|
154
213
|
throw new CollectionError({
|
|
@@ -270,7 +329,7 @@ export const createCollectionsService = (args: {
|
|
|
270
329
|
config: ConfigServiceShape;
|
|
271
330
|
resources: ResourcesService;
|
|
272
331
|
}): CollectionsService => {
|
|
273
|
-
const
|
|
332
|
+
const loadPromise: CollectionsService['loadPromise'] = ({ resourceNames, quiet = false }) =>
|
|
274
333
|
runTransaction('collections.load', async () => {
|
|
275
334
|
const uniqueNames = Array.from(new Set(resourceNames));
|
|
276
335
|
if (uniqueNames.length === 0)
|
|
@@ -368,9 +427,9 @@ export const createCollectionsService = (args: {
|
|
|
368
427
|
}
|
|
369
428
|
});
|
|
370
429
|
|
|
371
|
-
const
|
|
430
|
+
const load: CollectionsService['load'] = ({ resourceNames, quiet }) =>
|
|
372
431
|
Effect.tryPromise({
|
|
373
|
-
try: () =>
|
|
432
|
+
try: () => loadPromise({ resourceNames, quiet }),
|
|
374
433
|
catch: (cause) =>
|
|
375
434
|
cause instanceof CollectionError
|
|
376
435
|
? cause
|
|
@@ -383,6 +442,6 @@ export const createCollectionsService = (args: {
|
|
|
383
442
|
|
|
384
443
|
return {
|
|
385
444
|
load,
|
|
386
|
-
|
|
445
|
+
loadPromise
|
|
387
446
|
};
|
|
388
447
|
};
|