btca-server 1.0.92 → 1.0.931
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,498 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { Result } from 'better-result';
|
|
5
|
+
|
|
6
|
+
import { CommonHints } from '../../errors.ts';
|
|
7
|
+
import { ResourceError, resourceNameToKey } from '../helpers.ts';
|
|
8
|
+
import type { BtcaFsResource, BtcaNpmResourceArgs } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
const ANONYMOUS_CLONE_DIR = '.tmp';
|
|
11
|
+
const NPM_INSTALL_STAGING_DIR = '.btca-install';
|
|
12
|
+
const NPM_CACHE_META_FILE = '.btca-npm-meta.json';
|
|
13
|
+
const NPM_CONTENT_FILE = 'npm-package.md';
|
|
14
|
+
const NPM_PAGE_FILE = 'npm-package-page.html';
|
|
15
|
+
const NPM_REGISTRY_HOST = 'https://registry.npmjs.org';
|
|
16
|
+
|
|
17
|
+
type NpmCacheMeta = {
|
|
18
|
+
packageName: string;
|
|
19
|
+
requestedVersion?: string;
|
|
20
|
+
resolvedVersion: string;
|
|
21
|
+
packageUrl: string;
|
|
22
|
+
pageUrl: string;
|
|
23
|
+
fetchedAt: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type NpmPackument = {
|
|
27
|
+
readonly 'dist-tags'?: Record<string, string | undefined>;
|
|
28
|
+
readonly versions?: Record<string, NpmPackageVersion | undefined>;
|
|
29
|
+
readonly readme?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type NpmPackageVersion = {
|
|
33
|
+
readonly name?: string;
|
|
34
|
+
readonly version?: string;
|
|
35
|
+
readonly description?: string;
|
|
36
|
+
readonly homepage?: string;
|
|
37
|
+
readonly repository?: { url?: string } | string;
|
|
38
|
+
readonly license?: string;
|
|
39
|
+
readonly keywords?: readonly string[];
|
|
40
|
+
readonly dependencies?: Record<string, string | undefined>;
|
|
41
|
+
readonly peerDependencies?: Record<string, string | undefined>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const cleanupDirectory = async (pathToRemove: string) => {
|
|
45
|
+
await Result.tryPromise(() => fs.rm(pathToRemove, { recursive: true, force: true }));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const directoryExists = async (directoryPath: string) => {
|
|
49
|
+
const result = await Result.tryPromise(() => fs.stat(directoryPath));
|
|
50
|
+
return result.match({
|
|
51
|
+
ok: (stat) => stat.isDirectory(),
|
|
52
|
+
err: () => false
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const encodePackagePath = (packageName: string) =>
|
|
57
|
+
packageName.split('/').map(encodeURIComponent).join('/');
|
|
58
|
+
|
|
59
|
+
const formatRepositoryUrl = (repository: NpmPackageVersion['repository']) => {
|
|
60
|
+
if (!repository) return undefined;
|
|
61
|
+
if (typeof repository === 'string') return repository;
|
|
62
|
+
return repository.url;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const resolveRequestedVersion = (packument: NpmPackument, requestedVersion?: string) => {
|
|
66
|
+
const versions = packument.versions ?? {};
|
|
67
|
+
const distTags = packument['dist-tags'] ?? {};
|
|
68
|
+
const requested = requestedVersion?.trim();
|
|
69
|
+
|
|
70
|
+
if (!requested) {
|
|
71
|
+
const latest = distTags.latest;
|
|
72
|
+
if (latest && versions[latest]) return latest;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (versions[requested]) return requested;
|
|
77
|
+
const tagged = distTags[requested];
|
|
78
|
+
if (tagged && versions[tagged]) return tagged;
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const fetchJson = async <T>(url: string, resourceName: string): Promise<T> => {
|
|
83
|
+
const response = await Result.tryPromise(() =>
|
|
84
|
+
fetch(url, {
|
|
85
|
+
headers: {
|
|
86
|
+
accept: 'application/json'
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!Result.isOk(response)) {
|
|
92
|
+
throw new ResourceError({
|
|
93
|
+
message: `Failed to fetch npm metadata for "${resourceName}"`,
|
|
94
|
+
hint: CommonHints.CHECK_NETWORK,
|
|
95
|
+
cause: response.error
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!response.value.ok) {
|
|
100
|
+
throw new ResourceError({
|
|
101
|
+
message: `Failed to fetch npm metadata for "${resourceName}" (${response.value.status})`,
|
|
102
|
+
hint:
|
|
103
|
+
response.value.status === 404
|
|
104
|
+
? 'Check that the npm package exists.'
|
|
105
|
+
: CommonHints.CHECK_NETWORK,
|
|
106
|
+
cause: new Error(`Unexpected status ${response.value.status}`)
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const parsed = await Result.tryPromise(() => response.value.json() as Promise<T>);
|
|
111
|
+
if (!Result.isOk(parsed)) {
|
|
112
|
+
throw new ResourceError({
|
|
113
|
+
message: `Failed to parse npm metadata for "${resourceName}"`,
|
|
114
|
+
hint: 'Try again. If the issue persists, the npm registry may be returning malformed data.',
|
|
115
|
+
cause: parsed.error
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return parsed.value;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const fetchText = async (url: string, resourceName: string) => {
|
|
123
|
+
const fallbackContent = (reason: string) =>
|
|
124
|
+
`<!-- npm package page unavailable for "${resourceName}" (${reason}) -->`;
|
|
125
|
+
|
|
126
|
+
const response = await Result.tryPromise(() => fetch(url));
|
|
127
|
+
if (!Result.isOk(response)) {
|
|
128
|
+
return fallbackContent('request failed');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!response.value.ok) {
|
|
132
|
+
return fallbackContent(`status ${response.value.status}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const textResult = await Result.tryPromise(() => response.value.text());
|
|
136
|
+
if (!Result.isOk(textResult)) {
|
|
137
|
+
return fallbackContent('response read failed');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return textResult.value;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const readProcessOutput = async (stream: ReadableStream<Uint8Array> | null) => {
|
|
144
|
+
if (!stream) return '';
|
|
145
|
+
const reader = stream.getReader();
|
|
146
|
+
const chunks: Uint8Array[] = [];
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
while (true) {
|
|
150
|
+
const { done, value } = await reader.read();
|
|
151
|
+
if (done) break;
|
|
152
|
+
if (value) chunks.push(value);
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
reader.releaseLock();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
159
|
+
const merged = new Uint8Array(totalLength);
|
|
160
|
+
let offset = 0;
|
|
161
|
+
for (const chunk of chunks) {
|
|
162
|
+
merged.set(chunk, offset);
|
|
163
|
+
offset += chunk.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return new TextDecoder().decode(merged);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const runBunInstall = async (args: {
|
|
170
|
+
installDirectory: string;
|
|
171
|
+
packageName: string;
|
|
172
|
+
resolvedVersion: string;
|
|
173
|
+
}) => {
|
|
174
|
+
const packageSpec = `${args.packageName}@${args.resolvedVersion}`;
|
|
175
|
+
const command = ['bun', 'add', '--exact', '--ignore-scripts', packageSpec];
|
|
176
|
+
const process = Bun.spawn(command, {
|
|
177
|
+
cwd: args.installDirectory,
|
|
178
|
+
stdout: 'pipe',
|
|
179
|
+
stderr: 'pipe'
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
183
|
+
readProcessOutput(process.stdout),
|
|
184
|
+
readProcessOutput(process.stderr),
|
|
185
|
+
process.exited
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
if (exitCode !== 0) {
|
|
189
|
+
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n');
|
|
190
|
+
throw new ResourceError({
|
|
191
|
+
message: `Failed to install npm package "${packageSpec}"`,
|
|
192
|
+
hint: 'Check that the package/version exists and your network can reach npm. Try "btca clear" and run again.',
|
|
193
|
+
cause: new Error(
|
|
194
|
+
details.length > 0
|
|
195
|
+
? `bun add exited ${exitCode}: ${details}`
|
|
196
|
+
: `bun add exited ${exitCode} with no output`
|
|
197
|
+
)
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const formatResourceOverview = (args: {
|
|
203
|
+
packageName: string;
|
|
204
|
+
resolvedVersion: string;
|
|
205
|
+
requestedVersion?: string;
|
|
206
|
+
packageUrl: string;
|
|
207
|
+
pageUrl: string;
|
|
208
|
+
versionData: NpmPackageVersion;
|
|
209
|
+
}) => {
|
|
210
|
+
const dependencies = Object.entries(args.versionData.dependencies ?? {})
|
|
211
|
+
.slice(0, 100)
|
|
212
|
+
.map(([name, version]) => `- ${name}: ${version ?? 'unknown'}`)
|
|
213
|
+
.join('\n');
|
|
214
|
+
const peerDependencies = Object.entries(args.versionData.peerDependencies ?? {})
|
|
215
|
+
.slice(0, 100)
|
|
216
|
+
.map(([name, version]) => `- ${name}: ${version ?? 'unknown'}`)
|
|
217
|
+
.join('\n');
|
|
218
|
+
const repositoryUrl = formatRepositoryUrl(args.versionData.repository);
|
|
219
|
+
|
|
220
|
+
return [
|
|
221
|
+
`# npm package: ${args.packageName}`,
|
|
222
|
+
'',
|
|
223
|
+
`- Package URL: ${args.packageUrl}`,
|
|
224
|
+
`- npm page: ${args.pageUrl}`,
|
|
225
|
+
`- Version: ${args.resolvedVersion}`,
|
|
226
|
+
args.requestedVersion
|
|
227
|
+
? `- Requested version/tag: ${args.requestedVersion}`
|
|
228
|
+
: '- Requested version/tag: latest',
|
|
229
|
+
args.versionData.description ? `- Description: ${args.versionData.description}` : '',
|
|
230
|
+
args.versionData.homepage ? `- Homepage: ${args.versionData.homepage}` : '',
|
|
231
|
+
repositoryUrl ? `- Repository: ${repositoryUrl}` : '',
|
|
232
|
+
args.versionData.license ? `- License: ${args.versionData.license}` : '',
|
|
233
|
+
args.versionData.keywords?.length ? `- Keywords: ${args.versionData.keywords.join(', ')}` : '',
|
|
234
|
+
'',
|
|
235
|
+
'## Dependencies',
|
|
236
|
+
dependencies || 'No dependencies listed.',
|
|
237
|
+
'',
|
|
238
|
+
'## Peer Dependencies',
|
|
239
|
+
peerDependencies || 'No peer dependencies listed.'
|
|
240
|
+
]
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
.join('\n');
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const readCacheMeta = async (localPath: string): Promise<NpmCacheMeta | null> => {
|
|
246
|
+
const result = await Result.gen(async function* () {
|
|
247
|
+
const content = yield* Result.await(
|
|
248
|
+
Result.tryPromise(() => Bun.file(path.join(localPath, NPM_CACHE_META_FILE)).text())
|
|
249
|
+
);
|
|
250
|
+
const parsed = yield* Result.try(() => JSON.parse(content) as NpmCacheMeta);
|
|
251
|
+
return Result.ok(parsed);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return result.match({
|
|
255
|
+
ok: (value) => value,
|
|
256
|
+
err: () => null
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const shouldReuseCache = async (
|
|
261
|
+
config: BtcaNpmResourceArgs,
|
|
262
|
+
localPath: string
|
|
263
|
+
): Promise<boolean> => {
|
|
264
|
+
if (!config.version || config.ephemeral) return false;
|
|
265
|
+
const exists = await directoryExists(localPath);
|
|
266
|
+
if (!exists) return false;
|
|
267
|
+
|
|
268
|
+
const cached = await readCacheMeta(localPath);
|
|
269
|
+
if (!cached) return false;
|
|
270
|
+
return (
|
|
271
|
+
cached.packageName === config.package &&
|
|
272
|
+
cached.requestedVersion === config.version &&
|
|
273
|
+
cached.resolvedVersion.length > 0
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const installPackageFiles = async (args: {
|
|
278
|
+
localPath: string;
|
|
279
|
+
packageName: string;
|
|
280
|
+
resolvedVersion: string;
|
|
281
|
+
}) => {
|
|
282
|
+
const installDirectory = path.join(args.localPath, NPM_INSTALL_STAGING_DIR);
|
|
283
|
+
const packagePath = path.join(installDirectory, 'node_modules', ...args.packageName.split('/'));
|
|
284
|
+
|
|
285
|
+
const createInstallDirectory = await Result.tryPromise(() =>
|
|
286
|
+
fs.mkdir(installDirectory, { recursive: true })
|
|
287
|
+
);
|
|
288
|
+
if (!Result.isOk(createInstallDirectory)) {
|
|
289
|
+
throw new ResourceError({
|
|
290
|
+
message: `Failed to prepare npm install workspace for "${args.packageName}"`,
|
|
291
|
+
hint: 'Check that the btca data directory is writable.',
|
|
292
|
+
cause: createInstallDirectory.error
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const writeManifest = await Result.tryPromise(() =>
|
|
297
|
+
Bun.write(
|
|
298
|
+
path.join(installDirectory, 'package.json'),
|
|
299
|
+
JSON.stringify(
|
|
300
|
+
{
|
|
301
|
+
name: 'btca-npm-resource-install',
|
|
302
|
+
private: true
|
|
303
|
+
},
|
|
304
|
+
null,
|
|
305
|
+
2
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
);
|
|
309
|
+
if (!Result.isOk(writeManifest)) {
|
|
310
|
+
throw new ResourceError({
|
|
311
|
+
message: `Failed to prepare npm install workspace for "${args.packageName}"`,
|
|
312
|
+
hint: 'Check that the btca data directory is writable.',
|
|
313
|
+
cause: writeManifest.error
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await runBunInstall({
|
|
319
|
+
installDirectory,
|
|
320
|
+
packageName: args.packageName,
|
|
321
|
+
resolvedVersion: args.resolvedVersion
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const hasInstalledPackage = await directoryExists(packagePath);
|
|
325
|
+
if (!hasInstalledPackage) {
|
|
326
|
+
throw new ResourceError({
|
|
327
|
+
message: `Installed npm package directory is missing for "${args.packageName}@${args.resolvedVersion}"`,
|
|
328
|
+
hint: 'Try again. If this keeps happening, the package may not publish source files.'
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const copyResult = await Result.tryPromise(() =>
|
|
333
|
+
fs.cp(packagePath, args.localPath, { recursive: true, force: true })
|
|
334
|
+
);
|
|
335
|
+
if (!Result.isOk(copyResult)) {
|
|
336
|
+
throw new ResourceError({
|
|
337
|
+
message: `Failed to copy installed npm package files for "${args.packageName}"`,
|
|
338
|
+
hint: 'Check filesystem permissions and available disk space.',
|
|
339
|
+
cause: copyResult.error
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
} finally {
|
|
343
|
+
await cleanupDirectory(installDirectory);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const writeNpmMetadataFiles = async (args: {
|
|
348
|
+
localPath: string;
|
|
349
|
+
packageName: string;
|
|
350
|
+
requestedVersion?: string;
|
|
351
|
+
resolvedVersion: string;
|
|
352
|
+
versionData: NpmPackageVersion;
|
|
353
|
+
packageUrl: string;
|
|
354
|
+
pageUrl: string;
|
|
355
|
+
pageHtml: string;
|
|
356
|
+
}) => {
|
|
357
|
+
const overview = formatResourceOverview({
|
|
358
|
+
packageName: args.packageName,
|
|
359
|
+
resolvedVersion: args.resolvedVersion,
|
|
360
|
+
...(args.requestedVersion ? { requestedVersion: args.requestedVersion } : {}),
|
|
361
|
+
packageUrl: args.packageUrl,
|
|
362
|
+
pageUrl: args.pageUrl,
|
|
363
|
+
versionData: args.versionData
|
|
364
|
+
});
|
|
365
|
+
const meta: NpmCacheMeta = {
|
|
366
|
+
packageName: args.packageName,
|
|
367
|
+
...(args.requestedVersion ? { requestedVersion: args.requestedVersion } : {}),
|
|
368
|
+
resolvedVersion: args.resolvedVersion,
|
|
369
|
+
packageUrl: args.packageUrl,
|
|
370
|
+
pageUrl: args.pageUrl,
|
|
371
|
+
fetchedAt: new Date().toISOString()
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
await Promise.all([
|
|
375
|
+
Bun.write(path.join(args.localPath, NPM_CONTENT_FILE), overview),
|
|
376
|
+
Bun.write(path.join(args.localPath, NPM_PAGE_FILE), args.pageHtml),
|
|
377
|
+
Bun.write(path.join(args.localPath, NPM_CACHE_META_FILE), JSON.stringify(meta, null, 2))
|
|
378
|
+
]);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const hydrateNpmResource = async (config: BtcaNpmResourceArgs, localPath: string) => {
|
|
382
|
+
const packagePath = encodePackagePath(config.package);
|
|
383
|
+
const registryUrl = `${NPM_REGISTRY_HOST}/${encodeURIComponent(config.package)}`;
|
|
384
|
+
const requestedVersion = config.version?.trim();
|
|
385
|
+
const packument = await fetchJson<NpmPackument>(registryUrl, config.name);
|
|
386
|
+
const resolvedVersion = resolveRequestedVersion(packument, requestedVersion);
|
|
387
|
+
|
|
388
|
+
if (!resolvedVersion) {
|
|
389
|
+
throw new ResourceError({
|
|
390
|
+
message: `Unable to resolve npm version for package "${config.package}"`,
|
|
391
|
+
hint: requestedVersion
|
|
392
|
+
? `Version/tag "${requestedVersion}" was not found. Try a valid version or tag like "latest".`
|
|
393
|
+
: 'The package does not expose a resolvable latest version.'
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const versionData = packument.versions?.[resolvedVersion];
|
|
398
|
+
if (!versionData) {
|
|
399
|
+
throw new ResourceError({
|
|
400
|
+
message: `NPM package metadata for "${config.package}@${resolvedVersion}" is missing`,
|
|
401
|
+
hint: 'Try another version or run the command again.'
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const packageUrl = `https://www.npmjs.com/package/${packagePath}`;
|
|
406
|
+
const pageUrl = `${packageUrl}/v/${encodeURIComponent(resolvedVersion)}`;
|
|
407
|
+
const pageHtml = await fetchText(pageUrl, config.name);
|
|
408
|
+
|
|
409
|
+
await installPackageFiles({
|
|
410
|
+
localPath,
|
|
411
|
+
packageName: config.package,
|
|
412
|
+
resolvedVersion
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await writeNpmMetadataFiles({
|
|
416
|
+
localPath,
|
|
417
|
+
packageName: config.package,
|
|
418
|
+
...(requestedVersion ? { requestedVersion } : {}),
|
|
419
|
+
resolvedVersion,
|
|
420
|
+
versionData,
|
|
421
|
+
packageUrl,
|
|
422
|
+
pageUrl,
|
|
423
|
+
pageHtml
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const ensureNpmResource = async (config: BtcaNpmResourceArgs): Promise<string> => {
|
|
428
|
+
const resourceKey = config.localDirectoryKey ?? resourceNameToKey(config.name);
|
|
429
|
+
const basePath = config.ephemeral
|
|
430
|
+
? path.join(config.resourcesDirectoryPath, ANONYMOUS_CLONE_DIR)
|
|
431
|
+
: config.resourcesDirectoryPath;
|
|
432
|
+
const localPath = path.join(basePath, resourceKey);
|
|
433
|
+
|
|
434
|
+
const mkdirResult = await Result.tryPromise({
|
|
435
|
+
try: () => fs.mkdir(basePath, { recursive: true }),
|
|
436
|
+
catch: (cause) =>
|
|
437
|
+
new ResourceError({
|
|
438
|
+
message: 'Failed to create resources directory',
|
|
439
|
+
hint: 'Check that you have write permissions to the btca data directory.',
|
|
440
|
+
cause
|
|
441
|
+
})
|
|
442
|
+
});
|
|
443
|
+
if (!Result.isOk(mkdirResult)) throw mkdirResult.error;
|
|
444
|
+
|
|
445
|
+
const canReuse = await shouldReuseCache(config, localPath);
|
|
446
|
+
if (canReuse) return localPath;
|
|
447
|
+
|
|
448
|
+
await cleanupDirectory(localPath);
|
|
449
|
+
const createResult = await Result.tryPromise(() => fs.mkdir(localPath, { recursive: true }));
|
|
450
|
+
if (!Result.isOk(createResult)) {
|
|
451
|
+
throw new ResourceError({
|
|
452
|
+
message: `Failed to prepare npm resource directory for "${config.name}"`,
|
|
453
|
+
hint: 'Check that the btca data directory is writable.',
|
|
454
|
+
cause: createResult.error
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
await hydrateNpmResource(config, localPath);
|
|
459
|
+
return localPath;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const sanitizeNpmCitationSegment = (value: string) =>
|
|
463
|
+
value
|
|
464
|
+
.trim()
|
|
465
|
+
.replace(/\//g, '__')
|
|
466
|
+
.replace(/[^a-zA-Z0-9._@+-]+/g, '-')
|
|
467
|
+
.replace(/-+/g, '-')
|
|
468
|
+
.replace(/^-|-$/g, '');
|
|
469
|
+
|
|
470
|
+
const getNpmFsName = (config: BtcaNpmResourceArgs) => {
|
|
471
|
+
if (!config.ephemeral || !config.name.startsWith('anonymous:npm:')) {
|
|
472
|
+
return resourceNameToKey(config.name);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const packageSegment = sanitizeNpmCitationSegment(config.package);
|
|
476
|
+
const versionSegment = sanitizeNpmCitationSegment(config.version ?? 'latest');
|
|
477
|
+
return `npm:${packageSegment}@${versionSegment}`;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export const loadNpmResource = async (config: BtcaNpmResourceArgs): Promise<BtcaFsResource> => {
|
|
481
|
+
const localPath = await ensureNpmResource(config);
|
|
482
|
+
const cleanup = config.ephemeral
|
|
483
|
+
? async () => {
|
|
484
|
+
await cleanupDirectory(localPath);
|
|
485
|
+
}
|
|
486
|
+
: undefined;
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
_tag: 'fs-based',
|
|
490
|
+
name: config.name,
|
|
491
|
+
fsName: getNpmFsName(config),
|
|
492
|
+
type: 'npm',
|
|
493
|
+
repoSubPaths: [],
|
|
494
|
+
specialAgentInstructions: config.specialAgentInstructions,
|
|
495
|
+
getAbsoluteDirectoryPath: async () => localPath,
|
|
496
|
+
...(cleanup ? { cleanup } : {})
|
|
497
|
+
};
|
|
498
|
+
};
|
package/src/resources/index.ts
CHANGED
|
@@ -2,9 +2,20 @@ export { ResourceError } from './helpers.ts';
|
|
|
2
2
|
export { Resources } from './service.ts';
|
|
3
3
|
export {
|
|
4
4
|
GitResourceSchema,
|
|
5
|
+
LocalResourceSchema,
|
|
6
|
+
NpmResourceSchema,
|
|
5
7
|
ResourceDefinitionSchema,
|
|
6
8
|
isGitResource,
|
|
9
|
+
isLocalResource,
|
|
10
|
+
isNpmResource,
|
|
7
11
|
type GitResource,
|
|
12
|
+
type LocalResource,
|
|
13
|
+
type NpmResource,
|
|
8
14
|
type ResourceDefinition
|
|
9
15
|
} from './schema.ts';
|
|
10
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
FS_RESOURCE_SYSTEM_NOTE,
|
|
18
|
+
type BtcaFsResource,
|
|
19
|
+
type BtcaGitResourceArgs,
|
|
20
|
+
type BtcaNpmResourceArgs
|
|
21
|
+
} from './types.ts';
|
package/src/resources/schema.ts
CHANGED
|
@@ -18,6 +18,8 @@ const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0
|
|
|
18
18
|
* Must not start with hyphen to prevent git option injection.
|
|
19
19
|
*/
|
|
20
20
|
const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/;
|
|
21
|
+
const NPM_PACKAGE_SEGMENT_REGEX = /^[a-z0-9][a-z0-9._-]*$/;
|
|
22
|
+
const NPM_VERSION_OR_TAG_REGEX = /^[^\s/]+$/;
|
|
21
23
|
|
|
22
24
|
const parseUrl = (value: string) =>
|
|
23
25
|
Result.try(() => new URL(value)).match({
|
|
@@ -128,6 +130,28 @@ const SearchPathsSchema = z
|
|
|
128
130
|
.refine((paths) => paths.length > 0, { message: 'searchPaths must include at least one path' })
|
|
129
131
|
.optional();
|
|
130
132
|
|
|
133
|
+
const NpmPackageSchema = z
|
|
134
|
+
.string()
|
|
135
|
+
.min(1, 'NPM package cannot be empty')
|
|
136
|
+
.refine((name) => {
|
|
137
|
+
if (name.startsWith('@')) {
|
|
138
|
+
const parts = name.split('/');
|
|
139
|
+
return (
|
|
140
|
+
parts.length === 2 &&
|
|
141
|
+
parts[0] !== '@' &&
|
|
142
|
+
NPM_PACKAGE_SEGMENT_REGEX.test(parts[0]!.slice(1)) &&
|
|
143
|
+
NPM_PACKAGE_SEGMENT_REGEX.test(parts[1]!)
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return !name.includes('/') && NPM_PACKAGE_SEGMENT_REGEX.test(name);
|
|
147
|
+
}, 'NPM package must be a valid npm package name (e.g. react or @types/node)');
|
|
148
|
+
|
|
149
|
+
const NpmVersionSchema = z
|
|
150
|
+
.string()
|
|
151
|
+
.max(LIMITS.BRANCH_NAME_MAX, `Version/tag too long (max ${LIMITS.BRANCH_NAME_MAX} chars)`)
|
|
152
|
+
.regex(NPM_VERSION_OR_TAG_REGEX, 'Version/tag must not contain spaces or "/"')
|
|
153
|
+
.optional();
|
|
154
|
+
|
|
131
155
|
/**
|
|
132
156
|
* Local path field with basic validation.
|
|
133
157
|
*/
|
|
@@ -175,13 +199,23 @@ export const LocalResourceSchema = z.object({
|
|
|
175
199
|
specialNotes: SpecialNotesSchema
|
|
176
200
|
});
|
|
177
201
|
|
|
202
|
+
export const NpmResourceSchema = z.object({
|
|
203
|
+
type: z.literal('npm'),
|
|
204
|
+
name: ResourceNameSchema,
|
|
205
|
+
package: NpmPackageSchema,
|
|
206
|
+
version: NpmVersionSchema,
|
|
207
|
+
specialNotes: SpecialNotesSchema
|
|
208
|
+
});
|
|
209
|
+
|
|
178
210
|
export const ResourceDefinitionSchema = z.discriminatedUnion('type', [
|
|
179
211
|
GitResourceSchema,
|
|
180
|
-
LocalResourceSchema
|
|
212
|
+
LocalResourceSchema,
|
|
213
|
+
NpmResourceSchema
|
|
181
214
|
]);
|
|
182
215
|
|
|
183
216
|
export type GitResource = z.infer<typeof GitResourceSchema>;
|
|
184
217
|
export type LocalResource = z.infer<typeof LocalResourceSchema>;
|
|
218
|
+
export type NpmResource = z.infer<typeof NpmResourceSchema>;
|
|
185
219
|
export type ResourceDefinition = z.infer<typeof ResourceDefinitionSchema>;
|
|
186
220
|
|
|
187
221
|
export const isGitResource = (value: ResourceDefinition): value is GitResource =>
|
|
@@ -189,3 +223,6 @@ export const isGitResource = (value: ResourceDefinition): value is GitResource =
|
|
|
189
223
|
|
|
190
224
|
export const isLocalResource = (value: ResourceDefinition): value is LocalResource =>
|
|
191
225
|
value.type === 'local';
|
|
226
|
+
|
|
227
|
+
export const isNpmResource = (value: ResourceDefinition): value is NpmResource =>
|
|
228
|
+
value.type === 'npm';
|
|
@@ -34,6 +34,31 @@ describe('Resources.resolveResourceDefinition', () => {
|
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it('creates anonymous npm resources from npm references', () => {
|
|
38
|
+
const definition = Resources.resolveResourceDefinition(
|
|
39
|
+
'npm:@types/node@22.10.1',
|
|
40
|
+
() => undefined
|
|
41
|
+
);
|
|
42
|
+
expect(definition.type).toBe('npm');
|
|
43
|
+
if (definition.type === 'npm') {
|
|
44
|
+
expect(definition.package).toBe('@types/node');
|
|
45
|
+
expect(definition.version).toBe('22.10.1');
|
|
46
|
+
expect(definition.name).toBe('anonymous:npm:@types/node@22.10.1');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('creates anonymous npm resources from npm package URLs', () => {
|
|
51
|
+
const definition = Resources.resolveResourceDefinition(
|
|
52
|
+
'https://www.npmjs.com/package/react/v/19.0.0',
|
|
53
|
+
() => undefined
|
|
54
|
+
);
|
|
55
|
+
expect(definition.type).toBe('npm');
|
|
56
|
+
if (definition.type === 'npm') {
|
|
57
|
+
expect(definition.package).toBe('react');
|
|
58
|
+
expect(definition.version).toBe('19.0.0');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
37
62
|
it('reuses the same cache key for repeated normalized URLs', () => {
|
|
38
63
|
const first = Resources.createAnonymousResource('https://github.com/sveltejs/svelte.dev');
|
|
39
64
|
const second = Resources.createAnonymousResource(
|
|
@@ -53,7 +78,7 @@ describe('Resources.resolveResourceDefinition', () => {
|
|
|
53
78
|
);
|
|
54
79
|
expect(main).not.toBeNull();
|
|
55
80
|
expect(withPath).not.toBeNull();
|
|
56
|
-
if (main && withPath) {
|
|
81
|
+
if (main && withPath && main.type === 'git' && withPath.type === 'git') {
|
|
57
82
|
expect(createAnonymousDirectoryKey(main.url)).toBe(createAnonymousDirectoryKey(withPath.url));
|
|
58
83
|
}
|
|
59
84
|
if (main) {
|