btca-server 1.0.92 → 1.0.93
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 +7 -1
- package/package.json +1 -1
- package/src/agent/loop.ts +13 -4
- package/src/agent/service.ts +23 -23
- package/src/collections/service.ts +93 -3
- package/src/collections/virtual-metadata.ts +3 -1
- package/src/config/config.test.ts +28 -0
- package/src/config/index.ts +7 -2
- package/src/errors.test.ts +65 -0
- package/src/errors.ts +99 -6
- package/src/index.ts +47 -14
- package/src/providers/auth.ts +1 -1
- package/src/resources/impls/npm.test.ts +337 -0
- package/src/resources/impls/npm.ts +498 -0
- package/src/resources/index.ts +12 -1
- package/src/resources/schema.ts +38 -1
- package/src/resources/service.test.ts +26 -1
- package/src/resources/service.ts +59 -17
- package/src/resources/types.ts +12 -1
- package/src/stream/service.ts +5 -3
- package/src/stream/types.ts +2 -1
- package/src/validation/index.test.ts +29 -1
- package/src/validation/index.ts +139 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Result } from 'better-result';
|
|
7
|
+
|
|
8
|
+
import { loadNpmResource } from './npm.ts';
|
|
9
|
+
import type { BtcaNpmResourceArgs } from '../types.ts';
|
|
10
|
+
|
|
11
|
+
const streamFromString = (value: string) =>
|
|
12
|
+
new ReadableStream<Uint8Array>({
|
|
13
|
+
start(controller) {
|
|
14
|
+
if (value.length > 0) controller.enqueue(new TextEncoder().encode(value));
|
|
15
|
+
controller.close();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const parsePackageSpec = (value: string) => {
|
|
20
|
+
const splitIndex = value.lastIndexOf('@');
|
|
21
|
+
if (splitIndex <= 0 || splitIndex === value.length - 1) {
|
|
22
|
+
return { packageName: value, version: '0.0.0' };
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
packageName: value.slice(0, splitIndex),
|
|
26
|
+
version: value.slice(splitIndex + 1)
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const createInstallSpawnMock = (args?: { exitCode?: number; stdout?: string; stderr?: string }) =>
|
|
31
|
+
((...spawnArgs: Parameters<typeof Bun.spawn>) => {
|
|
32
|
+
const [command, options] = spawnArgs;
|
|
33
|
+
const commandArgs = Array.isArray(command) ? command : [command];
|
|
34
|
+
const packageSpec = commandArgs.at(-1) ?? '';
|
|
35
|
+
const { packageName, version } = parsePackageSpec(packageSpec);
|
|
36
|
+
const cwd = options?.cwd;
|
|
37
|
+
|
|
38
|
+
if ((args?.exitCode ?? 0) === 0 && cwd) {
|
|
39
|
+
const packageDirectory = path.join(cwd, 'node_modules', ...packageName.split('/'));
|
|
40
|
+
mkdirSync(path.join(packageDirectory, 'src'), { recursive: true });
|
|
41
|
+
const title = packageName === 'react' ? 'React' : packageName;
|
|
42
|
+
writeFileSync(path.join(packageDirectory, 'README.md'), `# ${title}\n\nInstalled for btca`);
|
|
43
|
+
writeFileSync(
|
|
44
|
+
path.join(packageDirectory, 'package.json'),
|
|
45
|
+
JSON.stringify({ name: packageName, version }, null, 2)
|
|
46
|
+
);
|
|
47
|
+
writeFileSync(
|
|
48
|
+
path.join(packageDirectory, 'src', 'runtime.js'),
|
|
49
|
+
`export const rune = '$state';`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
stdout: streamFromString(args?.stdout ?? ''),
|
|
55
|
+
stderr: streamFromString(args?.stderr ?? ''),
|
|
56
|
+
exited: Promise.resolve(args?.exitCode ?? 0)
|
|
57
|
+
} as unknown as ReturnType<typeof Bun.spawn>;
|
|
58
|
+
}) as typeof Bun.spawn;
|
|
59
|
+
|
|
60
|
+
describe('NPM Resource', () => {
|
|
61
|
+
let testDir: string;
|
|
62
|
+
let originalFetch: typeof fetch;
|
|
63
|
+
let originalSpawn: typeof Bun.spawn;
|
|
64
|
+
|
|
65
|
+
beforeEach(async () => {
|
|
66
|
+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-npm-test-'));
|
|
67
|
+
originalFetch = globalThis.fetch;
|
|
68
|
+
originalSpawn = Bun.spawn;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(async () => {
|
|
72
|
+
globalThis.fetch = originalFetch;
|
|
73
|
+
Bun.spawn = originalSpawn;
|
|
74
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('hydrates an npm package into a filesystem resource', async () => {
|
|
78
|
+
Bun.spawn = createInstallSpawnMock();
|
|
79
|
+
globalThis.fetch = (async (input) => {
|
|
80
|
+
const url = String(input);
|
|
81
|
+
if (url.startsWith('https://registry.npmjs.org/react')) {
|
|
82
|
+
return new Response(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
'dist-tags': { latest: '19.0.0' },
|
|
85
|
+
versions: {
|
|
86
|
+
'19.0.0': {
|
|
87
|
+
name: 'react',
|
|
88
|
+
version: '19.0.0',
|
|
89
|
+
description: 'React',
|
|
90
|
+
readme: '# React\n\nDocs'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
94
|
+
{ status: 200, headers: { 'content-type': 'application/json' } }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (url.startsWith('https://www.npmjs.com/package/react/v/19.0.0')) {
|
|
98
|
+
return new Response('<html><title>react</title></html>', { status: 200 });
|
|
99
|
+
}
|
|
100
|
+
return new Response('not found', { status: 404 });
|
|
101
|
+
}) as typeof fetch;
|
|
102
|
+
|
|
103
|
+
const args: BtcaNpmResourceArgs = {
|
|
104
|
+
type: 'npm',
|
|
105
|
+
name: 'react-docs',
|
|
106
|
+
package: 'react',
|
|
107
|
+
resourcesDirectoryPath: testDir,
|
|
108
|
+
specialAgentInstructions: 'Use this for React questions'
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const resource = await loadNpmResource(args);
|
|
112
|
+
expect(resource._tag).toBe('fs-based');
|
|
113
|
+
expect(resource.type).toBe('npm');
|
|
114
|
+
expect(resource.repoSubPaths).toEqual([]);
|
|
115
|
+
|
|
116
|
+
const resourcePath = await resource.getAbsoluteDirectoryPath();
|
|
117
|
+
expect(resourcePath).toBe(path.join(testDir, 'react-docs'));
|
|
118
|
+
|
|
119
|
+
const readme = await Bun.file(path.join(resourcePath, 'README.md')).text();
|
|
120
|
+
expect(readme).toContain('# React');
|
|
121
|
+
const runtimeFile = await Bun.file(path.join(resourcePath, 'src', 'runtime.js')).text();
|
|
122
|
+
expect(runtimeFile).toContain(`'$state'`);
|
|
123
|
+
|
|
124
|
+
const packagePage = await Bun.file(path.join(resourcePath, 'npm-package-page.html')).text();
|
|
125
|
+
expect(packagePage).toContain('<title>react</title>');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reuses cached pinned versions without refetching', async () => {
|
|
129
|
+
let fetchCalls = 0;
|
|
130
|
+
let spawnCalls = 0;
|
|
131
|
+
const installSpawnMock = createInstallSpawnMock();
|
|
132
|
+
Bun.spawn = ((...spawnArgs: Parameters<typeof Bun.spawn>) => {
|
|
133
|
+
spawnCalls += 1;
|
|
134
|
+
return installSpawnMock(...spawnArgs);
|
|
135
|
+
}) as typeof Bun.spawn;
|
|
136
|
+
globalThis.fetch = (async (input) => {
|
|
137
|
+
fetchCalls += 1;
|
|
138
|
+
const url = String(input);
|
|
139
|
+
if (url.startsWith('https://registry.npmjs.org/%40types%2Fnode')) {
|
|
140
|
+
return new Response(
|
|
141
|
+
JSON.stringify({
|
|
142
|
+
'dist-tags': { latest: '22.10.1' },
|
|
143
|
+
versions: {
|
|
144
|
+
'22.10.1': {
|
|
145
|
+
name: '@types/node',
|
|
146
|
+
version: '22.10.1',
|
|
147
|
+
readme: '# @types/node'
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
{ status: 200, headers: { 'content-type': 'application/json' } }
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (url.startsWith('https://www.npmjs.com/package/%40types/node/v/22.10.1')) {
|
|
155
|
+
return new Response('<html><title>@types/node</title></html>', { status: 200 });
|
|
156
|
+
}
|
|
157
|
+
return new Response('not found', { status: 404 });
|
|
158
|
+
}) as typeof fetch;
|
|
159
|
+
|
|
160
|
+
const args: BtcaNpmResourceArgs = {
|
|
161
|
+
type: 'npm',
|
|
162
|
+
name: 'node-types',
|
|
163
|
+
package: '@types/node',
|
|
164
|
+
version: '22.10.1',
|
|
165
|
+
resourcesDirectoryPath: testDir,
|
|
166
|
+
specialAgentInstructions: ''
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await loadNpmResource(args);
|
|
170
|
+
const firstFetchCalls = fetchCalls;
|
|
171
|
+
const firstSpawnCalls = spawnCalls;
|
|
172
|
+
await loadNpmResource(args);
|
|
173
|
+
expect(fetchCalls).toBe(firstFetchCalls);
|
|
174
|
+
expect(spawnCalls).toBe(firstSpawnCalls);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('adds cleanup for anonymous npm resources', async () => {
|
|
178
|
+
Bun.spawn = createInstallSpawnMock();
|
|
179
|
+
globalThis.fetch = (async (input) => {
|
|
180
|
+
const url = String(input);
|
|
181
|
+
if (url.startsWith('https://registry.npmjs.org/react')) {
|
|
182
|
+
return new Response(
|
|
183
|
+
JSON.stringify({
|
|
184
|
+
'dist-tags': { latest: '19.0.0' },
|
|
185
|
+
versions: {
|
|
186
|
+
'19.0.0': { name: 'react', version: '19.0.0', readme: '# React' }
|
|
187
|
+
}
|
|
188
|
+
}),
|
|
189
|
+
{ status: 200, headers: { 'content-type': 'application/json' } }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (url.startsWith('https://www.npmjs.com/package/react/v/19.0.0')) {
|
|
193
|
+
return new Response('<html><title>react</title></html>', { status: 200 });
|
|
194
|
+
}
|
|
195
|
+
return new Response('not found', { status: 404 });
|
|
196
|
+
}) as typeof fetch;
|
|
197
|
+
|
|
198
|
+
const args: BtcaNpmResourceArgs = {
|
|
199
|
+
type: 'npm',
|
|
200
|
+
name: 'anonymous:npm:react',
|
|
201
|
+
package: 'react',
|
|
202
|
+
resourcesDirectoryPath: testDir,
|
|
203
|
+
specialAgentInstructions: '',
|
|
204
|
+
ephemeral: true,
|
|
205
|
+
localDirectoryKey: 'anonymous-react'
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const resource = await loadNpmResource(args);
|
|
209
|
+
expect(resource.cleanup).toBeDefined();
|
|
210
|
+
|
|
211
|
+
const resourcePath = await resource.getAbsoluteDirectoryPath();
|
|
212
|
+
const existsBefore = await Result.tryPromise(() => fs.stat(resourcePath));
|
|
213
|
+
expect(existsBefore.isOk()).toBe(true);
|
|
214
|
+
|
|
215
|
+
await resource.cleanup?.();
|
|
216
|
+
const existsAfter = await Result.tryPromise(() => fs.stat(resourcePath));
|
|
217
|
+
expect(existsAfter.isOk()).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('uses readable fsName aliases for anonymous npm resources', async () => {
|
|
221
|
+
Bun.spawn = createInstallSpawnMock();
|
|
222
|
+
globalThis.fetch = (async (input) => {
|
|
223
|
+
const url = String(input);
|
|
224
|
+
if (url.startsWith('https://registry.npmjs.org/%40types%2Fnode')) {
|
|
225
|
+
return new Response(
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
'dist-tags': { latest: '22.10.1' },
|
|
228
|
+
versions: {
|
|
229
|
+
'22.10.1': { name: '@types/node', version: '22.10.1', readme: '# @types/node' }
|
|
230
|
+
}
|
|
231
|
+
}),
|
|
232
|
+
{ status: 200, headers: { 'content-type': 'application/json' } }
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (url.startsWith('https://www.npmjs.com/package/%40types/node/v/22.10.1')) {
|
|
236
|
+
return new Response('<html><title>@types/node</title></html>', { status: 200 });
|
|
237
|
+
}
|
|
238
|
+
return new Response('not found', { status: 404 });
|
|
239
|
+
}) as typeof fetch;
|
|
240
|
+
|
|
241
|
+
const args: BtcaNpmResourceArgs = {
|
|
242
|
+
type: 'npm',
|
|
243
|
+
name: 'anonymous:npm:@types/node@22.10.1',
|
|
244
|
+
package: '@types/node',
|
|
245
|
+
version: '22.10.1',
|
|
246
|
+
resourcesDirectoryPath: testDir,
|
|
247
|
+
specialAgentInstructions: '',
|
|
248
|
+
ephemeral: true,
|
|
249
|
+
localDirectoryKey: 'anonymous-types-node'
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const resource = await loadNpmResource(args);
|
|
253
|
+
expect(resource.fsName).toBe('npm:@types__node@22.10.1');
|
|
254
|
+
expect(resource.fsName.includes('%3A')).toBe(false);
|
|
255
|
+
expect(resource.fsName.includes('%2F')).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('continues when npm package page fetch is unavailable', async () => {
|
|
259
|
+
Bun.spawn = createInstallSpawnMock();
|
|
260
|
+
globalThis.fetch = (async (input) => {
|
|
261
|
+
const url = String(input);
|
|
262
|
+
if (url.startsWith('https://registry.npmjs.org/react')) {
|
|
263
|
+
return new Response(
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
'dist-tags': { latest: '19.0.0' },
|
|
266
|
+
versions: {
|
|
267
|
+
'19.0.0': {
|
|
268
|
+
name: 'react',
|
|
269
|
+
version: '19.0.0',
|
|
270
|
+
description: 'React'
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}),
|
|
274
|
+
{ status: 200, headers: { 'content-type': 'application/json' } }
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (url.startsWith('https://www.npmjs.com/package/react/v/19.0.0')) {
|
|
278
|
+
return new Response('blocked', { status: 403 });
|
|
279
|
+
}
|
|
280
|
+
return new Response('not found', { status: 404 });
|
|
281
|
+
}) as typeof fetch;
|
|
282
|
+
|
|
283
|
+
const args: BtcaNpmResourceArgs = {
|
|
284
|
+
type: 'npm',
|
|
285
|
+
name: 'react-docs',
|
|
286
|
+
package: 'react',
|
|
287
|
+
resourcesDirectoryPath: testDir,
|
|
288
|
+
specialAgentInstructions: ''
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const resource = await loadNpmResource(args);
|
|
292
|
+
const resourcePath = await resource.getAbsoluteDirectoryPath();
|
|
293
|
+
const packagePage = await Bun.file(path.join(resourcePath, 'npm-package-page.html')).text();
|
|
294
|
+
expect(packagePage).toContain('npm package page unavailable');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('returns a clear install error when bun install fails', async () => {
|
|
298
|
+
Bun.spawn = createInstallSpawnMock({
|
|
299
|
+
exitCode: 1,
|
|
300
|
+
stderr: 'error: package not found'
|
|
301
|
+
});
|
|
302
|
+
globalThis.fetch = (async (input) => {
|
|
303
|
+
const url = String(input);
|
|
304
|
+
if (url.startsWith('https://registry.npmjs.org/react')) {
|
|
305
|
+
return new Response(
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
'dist-tags': { latest: '19.0.0' },
|
|
308
|
+
versions: {
|
|
309
|
+
'19.0.0': {
|
|
310
|
+
name: 'react',
|
|
311
|
+
version: '19.0.0',
|
|
312
|
+
description: 'React'
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}),
|
|
316
|
+
{ status: 200, headers: { 'content-type': 'application/json' } }
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (url.startsWith('https://www.npmjs.com/package/react/v/19.0.0')) {
|
|
320
|
+
return new Response('<html><title>react</title></html>', { status: 200 });
|
|
321
|
+
}
|
|
322
|
+
return new Response('not found', { status: 404 });
|
|
323
|
+
}) as typeof fetch;
|
|
324
|
+
|
|
325
|
+
const args: BtcaNpmResourceArgs = {
|
|
326
|
+
type: 'npm',
|
|
327
|
+
name: 'react-docs',
|
|
328
|
+
package: 'react',
|
|
329
|
+
resourcesDirectoryPath: testDir,
|
|
330
|
+
specialAgentInstructions: ''
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
await expect(loadNpmResource(args)).rejects.toThrow(
|
|
334
|
+
'Failed to install npm package "react@19.0.0"'
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
});
|