ctxpkg 0.0.1

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.
Files changed (61) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +282 -0
  3. package/bin/cli.js +8 -0
  4. package/bin/daemon.js +7 -0
  5. package/package.json +70 -0
  6. package/src/agent/AGENTS.md +249 -0
  7. package/src/agent/agent.prompts.ts +66 -0
  8. package/src/agent/agent.test-runner.schemas.ts +158 -0
  9. package/src/agent/agent.test-runner.ts +436 -0
  10. package/src/agent/agent.ts +371 -0
  11. package/src/agent/agent.types.ts +94 -0
  12. package/src/backend/AGENTS.md +112 -0
  13. package/src/backend/backend.protocol.ts +95 -0
  14. package/src/backend/backend.schemas.ts +123 -0
  15. package/src/backend/backend.services.ts +151 -0
  16. package/src/backend/backend.ts +111 -0
  17. package/src/backend/backend.types.ts +34 -0
  18. package/src/cli/AGENTS.md +213 -0
  19. package/src/cli/cli.agent.ts +197 -0
  20. package/src/cli/cli.chat.ts +369 -0
  21. package/src/cli/cli.client.ts +55 -0
  22. package/src/cli/cli.collections.ts +491 -0
  23. package/src/cli/cli.config.ts +252 -0
  24. package/src/cli/cli.daemon.ts +160 -0
  25. package/src/cli/cli.documents.ts +413 -0
  26. package/src/cli/cli.mcp.ts +177 -0
  27. package/src/cli/cli.ts +28 -0
  28. package/src/cli/cli.utils.ts +122 -0
  29. package/src/client/AGENTS.md +135 -0
  30. package/src/client/client.adapters.ts +279 -0
  31. package/src/client/client.ts +86 -0
  32. package/src/client/client.types.ts +17 -0
  33. package/src/collections/AGENTS.md +185 -0
  34. package/src/collections/collections.schemas.ts +195 -0
  35. package/src/collections/collections.ts +1160 -0
  36. package/src/config/config.ts +118 -0
  37. package/src/daemon/AGENTS.md +168 -0
  38. package/src/daemon/daemon.config.ts +23 -0
  39. package/src/daemon/daemon.manager.ts +215 -0
  40. package/src/daemon/daemon.schemas.ts +22 -0
  41. package/src/daemon/daemon.ts +205 -0
  42. package/src/database/AGENTS.md +211 -0
  43. package/src/database/database.ts +64 -0
  44. package/src/database/migrations/migrations.001-init.ts +56 -0
  45. package/src/database/migrations/migrations.002-fts5.ts +32 -0
  46. package/src/database/migrations/migrations.ts +20 -0
  47. package/src/database/migrations/migrations.types.ts +9 -0
  48. package/src/documents/AGENTS.md +301 -0
  49. package/src/documents/documents.schemas.ts +190 -0
  50. package/src/documents/documents.ts +734 -0
  51. package/src/embedder/embedder.ts +53 -0
  52. package/src/exports.ts +0 -0
  53. package/src/mcp/AGENTS.md +264 -0
  54. package/src/mcp/mcp.ts +105 -0
  55. package/src/tools/AGENTS.md +228 -0
  56. package/src/tools/agent/agent.ts +45 -0
  57. package/src/tools/documents/documents.ts +401 -0
  58. package/src/tools/tools.langchain.ts +37 -0
  59. package/src/tools/tools.mcp.ts +46 -0
  60. package/src/tools/tools.types.ts +35 -0
  61. package/src/utils/utils.services.ts +46 -0
@@ -0,0 +1,1160 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync, realpathSync, createWriteStream, mkdirSync } from 'node:fs';
3
+ import { readFile, glob, mkdtemp, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { resolve, join, dirname } from 'node:path';
6
+ import { Readable } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+
9
+ import { simpleGit } from 'simple-git';
10
+ import * as tar from 'tar';
11
+
12
+ import {
13
+ projectConfigSchema,
14
+ collectionRecordSchema,
15
+ manifestSchema,
16
+ isGlobSources,
17
+ isFileSources,
18
+ isGitUrl,
19
+ parseGitUrl,
20
+ type ProjectConfig,
21
+ type CollectionSpec,
22
+ type CollectionRecord,
23
+ type Manifest,
24
+ type FileEntry,
25
+ type ResolvedFileEntry,
26
+ } from './collections.schemas.ts';
27
+
28
+ import type { Services } from '#root/utils/utils.services.ts';
29
+ import { DatabaseService, tableNames } from '#root/database/database.ts';
30
+ import { DocumentsService } from '#root/documents/documents.ts';
31
+ import { config } from '#root/config/config.ts';
32
+
33
+ /**
34
+ * Result of a sync operation.
35
+ */
36
+ type SyncResult = {
37
+ added: number;
38
+ updated: number;
39
+ removed: number;
40
+ total: number;
41
+ };
42
+
43
+ class CollectionsService {
44
+ #services: Services;
45
+
46
+ constructor(services: Services) {
47
+ this.#services = services;
48
+ }
49
+
50
+ // === Project Config ===
51
+
52
+ /**
53
+ * Get the project config file path for a given directory.
54
+ */
55
+ public getProjectConfigPath = (cwd: string = process.cwd()): string => {
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ const configFile = (config as any).get('project.configFile') as string;
58
+ return resolve(cwd, configFile);
59
+ };
60
+
61
+ /**
62
+ * Check if a project config file exists.
63
+ */
64
+ public projectConfigExists = (cwd: string = process.cwd()): boolean => {
65
+ return existsSync(this.getProjectConfigPath(cwd));
66
+ };
67
+
68
+ /**
69
+ * Read and parse the project config file.
70
+ */
71
+ public readProjectConfig = (cwd: string = process.cwd()): ProjectConfig => {
72
+ const configPath = this.getProjectConfigPath(cwd);
73
+ if (!existsSync(configPath)) {
74
+ return { collections: {} };
75
+ }
76
+ const content = readFileSync(configPath, 'utf-8');
77
+ const parsed = JSON.parse(content);
78
+ return projectConfigSchema.parse(parsed);
79
+ };
80
+
81
+ /**
82
+ * Write the project config file.
83
+ */
84
+ public writeProjectConfig = (projectConfig: ProjectConfig, cwd: string = process.cwd()): void => {
85
+ const configPath = this.getProjectConfigPath(cwd);
86
+ const content = JSON.stringify(projectConfig, null, 2);
87
+ writeFileSync(configPath, content, 'utf-8');
88
+ };
89
+
90
+ /**
91
+ * Initialize a new project config file.
92
+ */
93
+ public initProjectConfig = (cwd: string = process.cwd(), force = false): void => {
94
+ const configPath = this.getProjectConfigPath(cwd);
95
+ if (existsSync(configPath) && !force) {
96
+ throw new Error(`Project config already exists at ${configPath}`);
97
+ }
98
+ const initialConfig: ProjectConfig = { collections: {} };
99
+ this.writeProjectConfig(initialConfig, cwd);
100
+ };
101
+
102
+ // === Global Config ===
103
+
104
+ /**
105
+ * Get the global config file path.
106
+ */
107
+ public getGlobalConfigPath = (): string => {
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ return (config as any).get('global.configFile') as string;
110
+ };
111
+
112
+ /**
113
+ * Check if the global config file exists.
114
+ */
115
+ public globalConfigExists = (): boolean => {
116
+ return existsSync(this.getGlobalConfigPath());
117
+ };
118
+
119
+ /**
120
+ * Read and parse the global config file.
121
+ */
122
+ public readGlobalConfig = (): ProjectConfig => {
123
+ const configPath = this.getGlobalConfigPath();
124
+ if (!existsSync(configPath)) {
125
+ return { collections: {} };
126
+ }
127
+ const content = readFileSync(configPath, 'utf-8');
128
+ const parsed = JSON.parse(content);
129
+ return projectConfigSchema.parse(parsed);
130
+ };
131
+
132
+ /**
133
+ * Write the global config file. Auto-creates directory if needed.
134
+ */
135
+ public writeGlobalConfig = (globalConfig: ProjectConfig): void => {
136
+ const configPath = this.getGlobalConfigPath();
137
+ const configDir = dirname(configPath);
138
+
139
+ // Ensure directory exists
140
+ if (!existsSync(configDir)) {
141
+ mkdirSync(configDir, { recursive: true });
142
+ }
143
+
144
+ const content = JSON.stringify(globalConfig, null, 2);
145
+ writeFileSync(configPath, content, 'utf-8');
146
+ };
147
+
148
+ // === Unified Config Operations ===
149
+
150
+ /**
151
+ * Add a collection to project or global config.
152
+ */
153
+ public addToConfig = (name: string, spec: CollectionSpec, options: { global?: boolean; cwd?: string } = {}): void => {
154
+ const { global: isGlobal = false, cwd = process.cwd() } = options;
155
+
156
+ if (isGlobal) {
157
+ const globalConfig = this.readGlobalConfig();
158
+ if (name in globalConfig.collections) {
159
+ throw new Error(`Collection "${name}" already exists in global config`);
160
+ }
161
+ globalConfig.collections[name] = spec;
162
+ this.writeGlobalConfig(globalConfig);
163
+ } else {
164
+ this.addToProjectConfig(name, spec, cwd);
165
+ }
166
+ };
167
+
168
+ /**
169
+ * Remove a collection from project or global config.
170
+ */
171
+ public removeFromConfig = (name: string, options: { global?: boolean; cwd?: string } = {}): void => {
172
+ const { global: isGlobal = false, cwd = process.cwd() } = options;
173
+
174
+ if (isGlobal) {
175
+ const globalConfig = this.readGlobalConfig();
176
+ if (!(name in globalConfig.collections)) {
177
+ throw new Error(`Collection "${name}" not found in global config`);
178
+ }
179
+ const { [name]: _removed, ...rest } = globalConfig.collections;
180
+ void _removed;
181
+ globalConfig.collections = rest;
182
+ this.writeGlobalConfig(globalConfig);
183
+ } else {
184
+ this.removeFromProjectConfig(name, cwd);
185
+ }
186
+ };
187
+
188
+ /**
189
+ * Get a collection spec by name from project or global config.
190
+ * If global is not specified, searches local first then global.
191
+ */
192
+ public getFromConfig = (name: string, options: { global?: boolean; cwd?: string } = {}): CollectionSpec | null => {
193
+ const { global: isGlobal, cwd = process.cwd() } = options;
194
+
195
+ if (isGlobal === true) {
196
+ const globalConfig = this.readGlobalConfig();
197
+ return globalConfig.collections[name] || null;
198
+ }
199
+
200
+ if (isGlobal === false) {
201
+ return this.getFromProjectConfig(name, cwd);
202
+ }
203
+
204
+ // If global is undefined, search local first then global
205
+ const localSpec = this.getFromProjectConfig(name, cwd);
206
+ if (localSpec) {
207
+ return localSpec;
208
+ }
209
+
210
+ const globalConfig = this.readGlobalConfig();
211
+ return globalConfig.collections[name] || null;
212
+ };
213
+
214
+ /**
215
+ * Get all collections from both local and global configs.
216
+ * Returns a map with collection name as key and spec + source info as value.
217
+ * Local collections take precedence over global ones with the same name.
218
+ */
219
+ public getAllCollections = (
220
+ cwd: string = process.cwd(),
221
+ ): Map<string, { spec: CollectionSpec; source: 'local' | 'global' }> => {
222
+ const result = new Map<string, { spec: CollectionSpec; source: 'local' | 'global' }>();
223
+
224
+ // Add global collections first
225
+ const globalConfig = this.readGlobalConfig();
226
+ for (const [name, spec] of Object.entries(globalConfig.collections)) {
227
+ result.set(name, { spec, source: 'global' });
228
+ }
229
+
230
+ // Add local collections (will override global ones with same name)
231
+ if (this.projectConfigExists(cwd)) {
232
+ const projectConfig = this.readProjectConfig(cwd);
233
+ for (const [name, spec] of Object.entries(projectConfig.collections)) {
234
+ result.set(name, { spec, source: 'local' });
235
+ }
236
+ }
237
+
238
+ return result;
239
+ };
240
+
241
+ // === Collection ID Computation ===
242
+
243
+ /**
244
+ * Normalize a path to its canonical absolute form.
245
+ */
246
+ public normalizePath = (path: string, basePath: string = process.cwd()): string => {
247
+ const absolutePath = resolve(basePath, path);
248
+ // Resolve symlinks to canonical path
249
+ try {
250
+ return realpathSync(absolutePath);
251
+ } catch {
252
+ // Path doesn't exist yet, return resolved path
253
+ return absolutePath;
254
+ }
255
+ };
256
+
257
+ /**
258
+ * Compute the collection ID for a given spec.
259
+ * Format: pkg:{normalized_url}
260
+ */
261
+ public computeCollectionId = (spec: CollectionSpec): string => {
262
+ // Normalize URL (remove trailing slashes)
263
+ const normalizedUrl = spec.url.replace(/\/+$/, '');
264
+ return `pkg:${normalizedUrl}`;
265
+ };
266
+
267
+ // === Database Operations ===
268
+
269
+ /**
270
+ * Get a collection record by ID.
271
+ */
272
+ public getCollection = async (id: string): Promise<CollectionRecord | null> => {
273
+ const databaseService = this.#services.get(DatabaseService);
274
+ const database = await databaseService.getInstance();
275
+
276
+ const [record] = await database(tableNames.collections).where({ id }).limit(1);
277
+
278
+ if (!record) {
279
+ return null;
280
+ }
281
+
282
+ return collectionRecordSchema.parse(record);
283
+ };
284
+
285
+ /**
286
+ * List all collection records.
287
+ */
288
+ public listCollections = async (): Promise<CollectionRecord[]> => {
289
+ const databaseService = this.#services.get(DatabaseService);
290
+ const database = await databaseService.getInstance();
291
+
292
+ const records = await database(tableNames.collections).orderBy('created_at', 'asc');
293
+
294
+ return records.map((record) => collectionRecordSchema.parse(record));
295
+ };
296
+
297
+ /**
298
+ * Create or update a collection record.
299
+ */
300
+ public upsertCollection = async (
301
+ id: string,
302
+ data: Omit<CollectionRecord, 'id' | 'created_at' | 'updated_at'>,
303
+ ): Promise<void> => {
304
+ const databaseService = this.#services.get(DatabaseService);
305
+ const database = await databaseService.getInstance();
306
+
307
+ const now = new Date().toISOString();
308
+ const existing = await this.getCollection(id);
309
+
310
+ if (existing) {
311
+ await database(tableNames.collections)
312
+ .where({ id })
313
+ .update({
314
+ ...data,
315
+ updated_at: now,
316
+ });
317
+ } else {
318
+ await database(tableNames.collections).insert({
319
+ id,
320
+ ...data,
321
+ created_at: now,
322
+ updated_at: now,
323
+ });
324
+ }
325
+ };
326
+
327
+ /**
328
+ * Delete a collection record.
329
+ */
330
+ public deleteCollection = async (id: string): Promise<void> => {
331
+ const databaseService = this.#services.get(DatabaseService);
332
+ const database = await databaseService.getInstance();
333
+
334
+ await database(tableNames.collections).where({ id }).delete();
335
+ };
336
+
337
+ /**
338
+ * Update the last sync timestamp for a collection.
339
+ */
340
+ public updateLastSync = async (id: string): Promise<void> => {
341
+ const databaseService = this.#services.get(DatabaseService);
342
+ const database = await databaseService.getInstance();
343
+
344
+ const now = new Date().toISOString();
345
+ await database(tableNames.collections).where({ id }).update({
346
+ last_sync_at: now,
347
+ updated_at: now,
348
+ });
349
+ };
350
+
351
+ /**
352
+ * Update the manifest hash for a collection.
353
+ */
354
+ public updateManifestHash = async (id: string, hash: string): Promise<void> => {
355
+ const databaseService = this.#services.get(DatabaseService);
356
+ const database = await databaseService.getInstance();
357
+
358
+ const now = new Date().toISOString();
359
+ await database(tableNames.collections).where({ id }).update({
360
+ manifest_hash: hash,
361
+ updated_at: now,
362
+ });
363
+ };
364
+
365
+ // === Project Config Helpers ===
366
+
367
+ /**
368
+ * Add a collection to the project config.
369
+ */
370
+ public addToProjectConfig = (name: string, spec: CollectionSpec, cwd: string = process.cwd()): void => {
371
+ const projectConfig = this.readProjectConfig(cwd);
372
+
373
+ if (name in projectConfig.collections) {
374
+ throw new Error(`Collection "${name}" already exists in project config`);
375
+ }
376
+
377
+ projectConfig.collections[name] = spec;
378
+ this.writeProjectConfig(projectConfig, cwd);
379
+ };
380
+
381
+ /**
382
+ * Remove a collection from the project config.
383
+ */
384
+ public removeFromProjectConfig = (name: string, cwd: string = process.cwd()): void => {
385
+ const projectConfig = this.readProjectConfig(cwd);
386
+
387
+ if (!(name in projectConfig.collections)) {
388
+ throw new Error(`Collection "${name}" not found in project config`);
389
+ }
390
+
391
+ const { [name]: _removed, ...rest } = projectConfig.collections;
392
+ void _removed; // Intentionally unused
393
+ projectConfig.collections = rest;
394
+ this.writeProjectConfig(projectConfig, cwd);
395
+ };
396
+
397
+ /**
398
+ * Get a collection spec by name from the project config.
399
+ */
400
+ public getFromProjectConfig = (name: string, cwd: string = process.cwd()): CollectionSpec | null => {
401
+ const projectConfig = this.readProjectConfig(cwd);
402
+ return projectConfig.collections[name] || null;
403
+ };
404
+
405
+ // === Sync Status ===
406
+
407
+ /**
408
+ * Get sync status for a collection by computing its ID and checking the database.
409
+ */
410
+ public getSyncStatus = async (spec: CollectionSpec): Promise<'synced' | 'not_synced' | 'stale'> => {
411
+ const id = this.computeCollectionId(spec);
412
+ const record = await this.getCollection(id);
413
+
414
+ if (!record || !record.last_sync_at) {
415
+ return 'not_synced';
416
+ }
417
+
418
+ // For now, just check if it has ever been synced
419
+ // Future: compare manifest hashes for staleness
420
+ return 'synced';
421
+ };
422
+
423
+ // === Sync Operations ===
424
+
425
+ /**
426
+ * Sync a collection based on its spec.
427
+ */
428
+ public syncCollection = async (
429
+ name: string,
430
+ spec: CollectionSpec,
431
+ cwd: string = process.cwd(),
432
+ options: { force?: boolean; onProgress?: (message: string) => void } = {},
433
+ ): Promise<SyncResult> => {
434
+ // Check if it's a git URL
435
+ if (isGitUrl(spec.url)) {
436
+ return this.syncGitCollection(name, spec, cwd, options);
437
+ }
438
+ return this.syncPkgCollection(name, spec, cwd, options);
439
+ };
440
+
441
+ // === Manifest Handling ===
442
+
443
+ /**
444
+ * Parse a manifest URL and determine its protocol.
445
+ */
446
+ public parseManifestUrl = (
447
+ url: string,
448
+ cwd: string = process.cwd(),
449
+ ):
450
+ | { protocol: 'file' | 'https'; path: string; isBundle: boolean }
451
+ | { protocol: 'git'; cloneUrl: string; ref: string | null; manifestPath: string; isBundle: false } => {
452
+ // Check for git URLs first
453
+ if (isGitUrl(url)) {
454
+ const parsed = parseGitUrl(url);
455
+ return {
456
+ protocol: 'git',
457
+ cloneUrl: parsed.cloneUrl,
458
+ ref: parsed.ref,
459
+ manifestPath: parsed.manifestPath,
460
+ isBundle: false,
461
+ };
462
+ }
463
+
464
+ const isBundle = url.endsWith('.tar.gz') || url.endsWith('.tgz');
465
+
466
+ if (url.startsWith('file://')) {
467
+ const filePath = url.slice(7); // Remove 'file://'
468
+ const resolvedPath = this.normalizePath(filePath, cwd);
469
+ return { protocol: 'file', path: resolvedPath, isBundle };
470
+ }
471
+
472
+ if (url.startsWith('https://') || url.startsWith('http://')) {
473
+ return { protocol: 'https', path: url, isBundle };
474
+ }
475
+
476
+ // Assume it's a relative file path
477
+ const resolvedPath = this.normalizePath(url, cwd);
478
+ return { protocol: 'file', path: resolvedPath, isBundle };
479
+ };
480
+
481
+ /**
482
+ * Load a manifest from a file:// URL.
483
+ */
484
+ public loadLocalManifest = async (manifestPath: string): Promise<Manifest> => {
485
+ const content = await readFile(manifestPath, 'utf8');
486
+ const parsed = JSON.parse(content);
487
+ return manifestSchema.parse(parsed);
488
+ };
489
+
490
+ /**
491
+ * Resolve manifest sources to a list of file entries.
492
+ * For glob sources: expand globs relative to manifest directory.
493
+ * For files sources: resolve paths relative to manifest or baseUrl.
494
+ */
495
+ public resolveManifestSources = async (
496
+ manifest: Manifest,
497
+ manifestDir: string,
498
+ protocol: 'file' | 'https',
499
+ ): Promise<ResolvedFileEntry[]> => {
500
+ const sources = manifest.sources;
501
+ const baseUrl = manifest.baseUrl;
502
+
503
+ if (isGlobSources(sources)) {
504
+ if (protocol !== 'file') {
505
+ throw new Error('Glob sources are only supported for file:// manifests');
506
+ }
507
+
508
+ const entries: ResolvedFileEntry[] = [];
509
+ for (const pattern of sources.glob) {
510
+ for await (const file of glob(pattern, { cwd: manifestDir })) {
511
+ const fullPath = resolve(manifestDir, file);
512
+ entries.push({
513
+ id: file,
514
+ url: `file://${fullPath}`,
515
+ });
516
+ }
517
+ }
518
+ return entries;
519
+ }
520
+
521
+ if (isFileSources(sources)) {
522
+ return sources.files.map((entry) => this.resolveFileEntry(entry, manifestDir, baseUrl, protocol));
523
+ }
524
+
525
+ throw new Error('Unknown sources type in manifest');
526
+ };
527
+
528
+ /**
529
+ * Resolve a single file entry to its final URL.
530
+ */
531
+ public resolveFileEntry = (
532
+ entry: FileEntry,
533
+ manifestDir: string,
534
+ baseUrl: string | undefined,
535
+ protocol: 'file' | 'https',
536
+ ): ResolvedFileEntry => {
537
+ // String shorthand = relative path
538
+ if (typeof entry === 'string') {
539
+ return this.resolveFileEntry({ path: entry }, manifestDir, baseUrl, protocol);
540
+ }
541
+
542
+ // Fully qualified URL
543
+ if (entry.url) {
544
+ return {
545
+ id: entry.url,
546
+ url: entry.url,
547
+ hash: entry.hash,
548
+ };
549
+ }
550
+
551
+ // Relative path
552
+ if (entry.path) {
553
+ let resolvedUrl: string;
554
+
555
+ if (baseUrl) {
556
+ // Resolve relative to baseUrl
557
+ const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
558
+ resolvedUrl = `${base}${entry.path}`;
559
+ } else if (protocol === 'file') {
560
+ // Resolve relative to manifest directory
561
+ const fullPath = resolve(manifestDir, entry.path);
562
+ resolvedUrl = `file://${fullPath}`;
563
+ } else {
564
+ // For https, resolve relative to manifest URL directory
565
+ const base = manifestDir.endsWith('/') ? manifestDir : `${manifestDir}/`;
566
+ resolvedUrl = `${base}${entry.path}`;
567
+ }
568
+
569
+ return {
570
+ id: entry.path,
571
+ url: resolvedUrl,
572
+ hash: entry.hash,
573
+ };
574
+ }
575
+
576
+ throw new Error('File entry must have either path or url');
577
+ };
578
+
579
+ /**
580
+ * Fetch content from a URL (file:// or https://).
581
+ */
582
+ public fetchContent = async (url: string): Promise<string> => {
583
+ if (url.startsWith('file://')) {
584
+ const filePath = url.slice(7);
585
+ return readFile(filePath, 'utf8');
586
+ }
587
+
588
+ if (url.startsWith('https://') || url.startsWith('http://')) {
589
+ const response = await fetch(url);
590
+ if (!response.ok) {
591
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
592
+ }
593
+ return response.text();
594
+ }
595
+
596
+ throw new Error(`Unsupported URL protocol: ${url}`);
597
+ };
598
+
599
+ /**
600
+ * Load a manifest from a remote URL.
601
+ */
602
+ public loadRemoteManifest = async (manifestUrl: string): Promise<{ manifest: Manifest; content: string }> => {
603
+ const content = await this.fetchContent(manifestUrl);
604
+ const parsed = JSON.parse(content);
605
+ const manifest = manifestSchema.parse(parsed);
606
+ return { manifest, content };
607
+ };
608
+
609
+ /**
610
+ * Get the directory part of a URL.
611
+ */
612
+ public getUrlDirectory = (url: string): string => {
613
+ const lastSlash = url.lastIndexOf('/');
614
+ return lastSlash >= 0 ? url.substring(0, lastSlash) : url;
615
+ };
616
+
617
+ // === Bundle Handling ===
618
+
619
+ /**
620
+ * Download a bundle to a temporary file and extract it.
621
+ * Returns the path to the extracted directory.
622
+ */
623
+ public downloadAndExtractBundle = async (url: string, onProgress?: (message: string) => void): Promise<string> => {
624
+ const tempDir = await mkdtemp(join(tmpdir(), 'ai-assist-bundle-'));
625
+
626
+ try {
627
+ if (url.startsWith('file://')) {
628
+ // Local bundle - extract directly
629
+ const bundlePath = url.slice(7);
630
+ onProgress?.('Extracting local bundle...');
631
+ await tar.extract({
632
+ file: bundlePath,
633
+ cwd: tempDir,
634
+ });
635
+ } else {
636
+ // Remote bundle - download then extract
637
+ onProgress?.('Downloading bundle...');
638
+ const response = await fetch(url);
639
+ if (!response.ok) {
640
+ throw new Error(`Failed to download bundle: ${response.status} ${response.statusText}`);
641
+ }
642
+
643
+ const bundlePath = join(tempDir, 'bundle.tar.gz');
644
+
645
+ // Stream response to file
646
+ if (!response.body) {
647
+ throw new Error('Response body is empty');
648
+ }
649
+
650
+ const fileStream = createWriteStream(bundlePath);
651
+ await pipeline(Readable.fromWeb(response.body as import('stream/web').ReadableStream), fileStream);
652
+
653
+ onProgress?.('Extracting bundle...');
654
+ await tar.extract({
655
+ file: bundlePath,
656
+ cwd: tempDir,
657
+ });
658
+ }
659
+
660
+ // Find the extracted content - could be in root or a subdirectory
661
+ // Check if manifest.json exists at root or find it
662
+ const manifestAtRoot = join(tempDir, 'manifest.json');
663
+ if (existsSync(manifestAtRoot)) {
664
+ return tempDir;
665
+ }
666
+
667
+ // Look for manifest in immediate subdirectories (common for tarballs)
668
+ const { readdir } = await import('node:fs/promises');
669
+ const entries = await readdir(tempDir, { withFileTypes: true });
670
+ for (const entry of entries) {
671
+ if (entry.isDirectory()) {
672
+ const subManifest = join(tempDir, entry.name, 'manifest.json');
673
+ if (existsSync(subManifest)) {
674
+ return join(tempDir, entry.name);
675
+ }
676
+ }
677
+ }
678
+
679
+ throw new Error('Could not find manifest.json in bundle');
680
+ } catch (error) {
681
+ // Clean up on error
682
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
683
+ throw error;
684
+ }
685
+ };
686
+
687
+ /**
688
+ * Sync a bundle collection.
689
+ */
690
+ public syncBundleCollection = async (
691
+ name: string,
692
+ spec: CollectionSpec,
693
+ cwd: string = process.cwd(),
694
+ options: { force?: boolean; onProgress?: (message: string) => void } = {},
695
+ ): Promise<SyncResult> => {
696
+ const { force = false, onProgress } = options;
697
+ const collectionId = this.computeCollectionId(spec);
698
+ const parsed = this.parseManifestUrl(spec.url, cwd);
699
+
700
+ // This method only handles file/https bundles, not git URLs
701
+ if (parsed.protocol === 'git') {
702
+ throw new Error('syncBundleCollection does not support git URLs');
703
+ }
704
+
705
+ const { protocol, path: bundlePath } = parsed;
706
+
707
+ // Reconstruct URL with protocol for downloadAndExtractBundle
708
+ const bundleUrl = protocol === 'file' ? `file://${bundlePath}` : bundlePath;
709
+
710
+ let tempDir: string | null = null;
711
+
712
+ try {
713
+ // Download and extract bundle
714
+ tempDir = await this.downloadAndExtractBundle(bundleUrl, onProgress);
715
+ const manifestPath = join(tempDir, 'manifest.json');
716
+
717
+ onProgress?.('Reading manifest...');
718
+
719
+ // Load manifest
720
+ const manifest = await this.loadLocalManifest(manifestPath);
721
+ const manifestContent = await readFile(manifestPath, 'utf8');
722
+ const manifestHash = createHash('sha256').update(manifestContent).digest('hex');
723
+
724
+ // Check if we can skip sync
725
+ const existingCollection = await this.getCollection(collectionId);
726
+ if (!force && existingCollection?.manifest_hash === manifestHash) {
727
+ onProgress?.('Bundle unchanged, skipping sync');
728
+ return { added: 0, updated: 0, removed: 0, total: 0 };
729
+ }
730
+
731
+ onProgress?.('Resolving sources...');
732
+
733
+ // Resolve sources (always use 'file' protocol for extracted bundle)
734
+ const entries = await this.resolveManifestSources(manifest, tempDir, 'file');
735
+
736
+ // Get existing documents from database
737
+ const documentsService = this.#services.get(DocumentsService);
738
+ const existingDocs = await documentsService.getDocumentIds(collectionId);
739
+ const existingMap = new Map(existingDocs.map((doc) => [doc.id, doc.hash]));
740
+
741
+ // Compute changes
742
+ const toAdd: ResolvedFileEntry[] = [];
743
+ const toUpdate: ResolvedFileEntry[] = [];
744
+ const toRemove: string[] = [];
745
+
746
+ for (const entry of entries) {
747
+ const existingHash = existingMap.get(entry.id);
748
+
749
+ if (!existingHash) {
750
+ toAdd.push(entry);
751
+ } else if (force) {
752
+ toUpdate.push(entry);
753
+ } else if (entry.hash) {
754
+ if (existingHash !== entry.hash) {
755
+ toUpdate.push(entry);
756
+ }
757
+ } else {
758
+ toUpdate.push(entry);
759
+ }
760
+ }
761
+
762
+ const currentIds = new Set(entries.map((e) => e.id));
763
+ for (const [id] of existingMap) {
764
+ if (!currentIds.has(id)) {
765
+ toRemove.push(id);
766
+ }
767
+ }
768
+
769
+ // Apply changes
770
+ if (toRemove.length > 0) {
771
+ onProgress?.(`Removing ${toRemove.length} deleted documents...`);
772
+ await documentsService.deleteDocuments(collectionId, toRemove);
773
+ }
774
+
775
+ const toProcess = [...toAdd, ...toUpdate];
776
+ let actualUpdated = 0;
777
+
778
+ for (let i = 0; i < toProcess.length; i++) {
779
+ const entry = toProcess[i];
780
+ const isNew = toAdd.includes(entry);
781
+ onProgress?.(`${isNew ? 'Adding' : 'Checking'} ${entry.id} (${i + 1}/${toProcess.length})...`);
782
+
783
+ const content = await this.fetchContent(entry.url);
784
+ const contentHash = createHash('sha256').update(content).digest('hex');
785
+
786
+ if (!isNew && !force && existingMap.get(entry.id) === contentHash) {
787
+ continue;
788
+ }
789
+
790
+ if (!isNew) actualUpdated++;
791
+
792
+ await documentsService.updateDocument({
793
+ collection: collectionId,
794
+ id: entry.id,
795
+ content,
796
+ });
797
+ }
798
+
799
+ // Update collection record
800
+ await this.upsertCollection(collectionId, {
801
+ url: spec.url,
802
+ name: manifest.name,
803
+ version: manifest.version,
804
+ description: manifest.description ?? null,
805
+ manifest_hash: manifestHash,
806
+ last_sync_at: new Date().toISOString(),
807
+ });
808
+
809
+ return {
810
+ added: toAdd.length,
811
+ updated: actualUpdated,
812
+ removed: toRemove.length,
813
+ total: entries.length,
814
+ };
815
+ } finally {
816
+ // Clean up temp directory
817
+ if (tempDir) {
818
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
819
+ }
820
+ }
821
+ };
822
+
823
+ /**
824
+ * Sync a git collection.
825
+ * Clones the repository to a temp directory relative to cwd (to preserve includeIf git config),
826
+ * reads the manifest from the specified path, and syncs documents.
827
+ */
828
+ public syncGitCollection = async (
829
+ name: string,
830
+ spec: CollectionSpec,
831
+ cwd: string = process.cwd(),
832
+ options: { force?: boolean; onProgress?: (message: string) => void } = {},
833
+ ): Promise<SyncResult> => {
834
+ const { force = false, onProgress } = options;
835
+ const collectionId = this.computeCollectionId(spec);
836
+ const parsed = parseGitUrl(spec.url);
837
+
838
+ let tempDir: string | null = null;
839
+
840
+ try {
841
+ // Create temp directory relative to cwd (preserves includeIf git config)
842
+ const tmpBase = join(cwd, '.ctxpkg', 'tmp');
843
+ mkdirSync(tmpBase, { recursive: true });
844
+
845
+ // Create unique temp dir
846
+ const uniqueId = Math.random().toString(36).substring(2, 10);
847
+ tempDir = join(tmpBase, `git-${uniqueId}`);
848
+ mkdirSync(tempDir, { recursive: true });
849
+
850
+ // Clone the repository
851
+ const refDisplay = parsed.ref ? ` @ ${parsed.ref}` : '';
852
+ onProgress?.(`Cloning ${parsed.cloneUrl}${refDisplay}...`);
853
+
854
+ const git = simpleGit();
855
+
856
+ // Build clone options - disable hooks for security
857
+ const cloneOptions: string[] = ['--config', 'core.hooksPath=/dev/null'];
858
+
859
+ // Use shallow clone when possible (not for commit SHAs)
860
+ const isCommitSha = parsed.ref && /^[a-f0-9]{7,40}$/i.test(parsed.ref);
861
+ if (!isCommitSha) {
862
+ cloneOptions.push('--depth', '1');
863
+ if (parsed.ref) {
864
+ cloneOptions.push('--branch', parsed.ref);
865
+ }
866
+ }
867
+
868
+ await git.clone(parsed.cloneUrl, tempDir, cloneOptions);
869
+
870
+ // For commit SHAs, checkout the specific commit
871
+ if (isCommitSha && parsed.ref) {
872
+ onProgress?.(`Checking out ${parsed.ref}...`);
873
+ await simpleGit(tempDir).checkout(parsed.ref);
874
+ }
875
+
876
+ // Locate manifest
877
+ const manifestPath = join(tempDir, parsed.manifestPath);
878
+ if (!existsSync(manifestPath)) {
879
+ throw new Error(`Manifest not found at ${parsed.manifestPath} in repository`);
880
+ }
881
+
882
+ onProgress?.('Reading manifest...');
883
+
884
+ // Load manifest
885
+ const manifest = await this.loadLocalManifest(manifestPath);
886
+ const manifestContent = await readFile(manifestPath, 'utf8');
887
+ const manifestHash = createHash('sha256').update(manifestContent).digest('hex');
888
+
889
+ // Check if we can skip sync
890
+ const existingCollection = await this.getCollection(collectionId);
891
+ if (!force && existingCollection?.manifest_hash === manifestHash) {
892
+ onProgress?.('Repository unchanged, skipping sync');
893
+ return { added: 0, updated: 0, removed: 0, total: 0 };
894
+ }
895
+
896
+ onProgress?.('Resolving sources...');
897
+
898
+ // Get manifest directory for resolving relative paths
899
+ const manifestDir = dirname(manifestPath);
900
+
901
+ // Resolve sources (always use 'file' protocol for cloned repo)
902
+ const entries = await this.resolveManifestSources(manifest, manifestDir, 'file');
903
+
904
+ // Get existing documents from database
905
+ const documentsService = this.#services.get(DocumentsService);
906
+ const existingDocs = await documentsService.getDocumentIds(collectionId);
907
+ const existingMap = new Map(existingDocs.map((doc) => [doc.id, doc.hash]));
908
+
909
+ // Compute changes
910
+ const toAdd: ResolvedFileEntry[] = [];
911
+ const toUpdate: ResolvedFileEntry[] = [];
912
+ const toRemove: string[] = [];
913
+
914
+ for (const entry of entries) {
915
+ const existingHash = existingMap.get(entry.id);
916
+
917
+ if (!existingHash) {
918
+ toAdd.push(entry);
919
+ } else if (force) {
920
+ toUpdate.push(entry);
921
+ } else if (entry.hash) {
922
+ if (existingHash !== entry.hash) {
923
+ toUpdate.push(entry);
924
+ }
925
+ } else {
926
+ toUpdate.push(entry);
927
+ }
928
+ }
929
+
930
+ const currentIds = new Set(entries.map((e) => e.id));
931
+ for (const [id] of existingMap) {
932
+ if (!currentIds.has(id)) {
933
+ toRemove.push(id);
934
+ }
935
+ }
936
+
937
+ // Apply changes
938
+ if (toRemove.length > 0) {
939
+ onProgress?.(`Removing ${toRemove.length} deleted documents...`);
940
+ await documentsService.deleteDocuments(collectionId, toRemove);
941
+ }
942
+
943
+ const toProcess = [...toAdd, ...toUpdate];
944
+ let actualUpdated = 0;
945
+
946
+ for (let i = 0; i < toProcess.length; i++) {
947
+ const entry = toProcess[i];
948
+ const isNew = toAdd.includes(entry);
949
+ onProgress?.(`${isNew ? 'Adding' : 'Checking'} ${entry.id} (${i + 1}/${toProcess.length})...`);
950
+
951
+ const content = await this.fetchContent(entry.url);
952
+ const contentHash = createHash('sha256').update(content).digest('hex');
953
+
954
+ if (!isNew && !force && existingMap.get(entry.id) === contentHash) {
955
+ continue;
956
+ }
957
+
958
+ if (!isNew) actualUpdated++;
959
+
960
+ await documentsService.updateDocument({
961
+ collection: collectionId,
962
+ id: entry.id,
963
+ content,
964
+ });
965
+ }
966
+
967
+ // Update collection record
968
+ await this.upsertCollection(collectionId, {
969
+ url: spec.url,
970
+ name: manifest.name,
971
+ version: manifest.version,
972
+ description: manifest.description ?? null,
973
+ manifest_hash: manifestHash,
974
+ last_sync_at: new Date().toISOString(),
975
+ });
976
+
977
+ return {
978
+ added: toAdd.length,
979
+ updated: actualUpdated,
980
+ removed: toRemove.length,
981
+ total: entries.length,
982
+ };
983
+ } finally {
984
+ // Clean up temp directory
985
+ if (tempDir) {
986
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
987
+ }
988
+ }
989
+ };
990
+
991
+ /**
992
+ * Sync a pkg collection.
993
+ */
994
+ public syncPkgCollection = async (
995
+ name: string,
996
+ spec: CollectionSpec,
997
+ cwd: string = process.cwd(),
998
+ options: { force?: boolean; onProgress?: (message: string) => void } = {},
999
+ ): Promise<SyncResult> => {
1000
+ const { force = false, onProgress } = options;
1001
+ const collectionId = this.computeCollectionId(spec);
1002
+ const parsed = this.parseManifestUrl(spec.url, cwd);
1003
+
1004
+ // This method only handles file/https, not git URLs
1005
+ if (parsed.protocol === 'git') {
1006
+ throw new Error('syncPkgCollection does not support git URLs');
1007
+ }
1008
+
1009
+ const { protocol, path: manifestPath, isBundle } = parsed;
1010
+
1011
+ if (isBundle) {
1012
+ return this.syncBundleCollection(name, spec, cwd, options);
1013
+ }
1014
+
1015
+ onProgress?.(`Loading manifest from ${manifestPath}...`);
1016
+
1017
+ // Load and parse manifest based on protocol
1018
+ let manifest: Manifest;
1019
+ let manifestContent: string;
1020
+ let manifestDir: string;
1021
+
1022
+ if (protocol === 'file') {
1023
+ manifest = await this.loadLocalManifest(manifestPath);
1024
+ manifestContent = await readFile(manifestPath, 'utf8');
1025
+ manifestDir = manifestPath.substring(0, manifestPath.lastIndexOf('/'));
1026
+ } else {
1027
+ const result = await this.loadRemoteManifest(manifestPath);
1028
+ manifest = result.manifest;
1029
+ manifestContent = result.content;
1030
+ manifestDir = this.getUrlDirectory(manifestPath);
1031
+ }
1032
+
1033
+ // Check manifest hash to skip if unchanged
1034
+ const manifestHash = createHash('sha256').update(manifestContent).digest('hex');
1035
+ const existingCollection = await this.getCollection(collectionId);
1036
+
1037
+ if (!force && existingCollection?.manifest_hash === manifestHash) {
1038
+ onProgress?.('Manifest unchanged, skipping sync');
1039
+ return { added: 0, updated: 0, removed: 0, total: 0 };
1040
+ }
1041
+
1042
+ onProgress?.('Resolving sources...');
1043
+
1044
+ // Resolve sources to file entries
1045
+ const entries = await this.resolveManifestSources(manifest, manifestDir, protocol);
1046
+
1047
+ // Get existing documents from database
1048
+ const documentsService = this.#services.get(DocumentsService);
1049
+ const existingDocs = await documentsService.getDocumentIds(collectionId);
1050
+ const existingMap = new Map(existingDocs.map((doc) => [doc.id, doc.hash]));
1051
+
1052
+ // Compute changes
1053
+ const toAdd: ResolvedFileEntry[] = [];
1054
+ const toUpdate: ResolvedFileEntry[] = [];
1055
+ const toRemove: string[] = [];
1056
+
1057
+ for (const entry of entries) {
1058
+ const existingHash = existingMap.get(entry.id);
1059
+
1060
+ if (!existingHash) {
1061
+ toAdd.push(entry);
1062
+ } else if (force) {
1063
+ toUpdate.push(entry);
1064
+ } else if (entry.hash) {
1065
+ // Manifest provides hash, compare with stored hash
1066
+ if (existingHash !== entry.hash) {
1067
+ toUpdate.push(entry);
1068
+ }
1069
+ } else {
1070
+ // No manifest hash, need to fetch and compare
1071
+ toUpdate.push(entry);
1072
+ }
1073
+ }
1074
+
1075
+ const currentIds = new Set(entries.map((e) => e.id));
1076
+ for (const [id] of existingMap) {
1077
+ if (!currentIds.has(id)) {
1078
+ toRemove.push(id);
1079
+ }
1080
+ }
1081
+
1082
+ // Apply changes
1083
+ if (toRemove.length > 0) {
1084
+ onProgress?.(`Removing ${toRemove.length} deleted documents...`);
1085
+ await documentsService.deleteDocuments(collectionId, toRemove);
1086
+ }
1087
+
1088
+ const toProcess = [...toAdd, ...toUpdate];
1089
+ let actualUpdated = 0;
1090
+
1091
+ for (let i = 0; i < toProcess.length; i++) {
1092
+ const entry = toProcess[i];
1093
+ const isNew = toAdd.includes(entry);
1094
+ onProgress?.(`${isNew ? 'Adding' : 'Checking'} ${entry.id} (${i + 1}/${toProcess.length})...`);
1095
+
1096
+ try {
1097
+ const content = await this.fetchContent(entry.url);
1098
+ const contentHash = createHash('sha256').update(content).digest('hex');
1099
+
1100
+ // Skip if content hash matches (for entries without manifest hash)
1101
+ if (!isNew && !force && existingMap.get(entry.id) === contentHash) {
1102
+ continue;
1103
+ }
1104
+
1105
+ if (!isNew) actualUpdated++;
1106
+
1107
+ await documentsService.updateDocument({
1108
+ collection: collectionId,
1109
+ id: entry.id,
1110
+ content,
1111
+ });
1112
+ } catch (error) {
1113
+ // Log warning but don't fail the entire sync for individual file failures
1114
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
1115
+ onProgress?.(`Warning: Failed to fetch ${entry.id}: ${errorMsg}`);
1116
+ }
1117
+ }
1118
+
1119
+ // Update collection record
1120
+ await this.upsertCollection(collectionId, {
1121
+ url: spec.url,
1122
+ name: manifest.name,
1123
+ version: manifest.version,
1124
+ description: manifest.description ?? null,
1125
+ manifest_hash: manifestHash,
1126
+ last_sync_at: new Date().toISOString(),
1127
+ });
1128
+
1129
+ return {
1130
+ added: toAdd.length,
1131
+ updated: actualUpdated,
1132
+ removed: toRemove.length,
1133
+ total: entries.length,
1134
+ };
1135
+ };
1136
+
1137
+ /**
1138
+ * Sync all collections from project config.
1139
+ */
1140
+ public syncAllCollections = async (
1141
+ cwd: string = process.cwd(),
1142
+ options: { force?: boolean; onProgress?: (name: string, message: string) => void } = {},
1143
+ ): Promise<Map<string, SyncResult>> => {
1144
+ const projectConfig = this.readProjectConfig(cwd);
1145
+ const results = new Map<string, SyncResult>();
1146
+
1147
+ for (const [name, spec] of Object.entries(projectConfig.collections)) {
1148
+ const result = await this.syncCollection(name, spec, cwd, {
1149
+ force: options.force,
1150
+ onProgress: (message) => options.onProgress?.(name, message),
1151
+ });
1152
+ results.set(name, result);
1153
+ }
1154
+
1155
+ return results;
1156
+ };
1157
+ }
1158
+
1159
+ export { CollectionsService };
1160
+ export type { SyncResult };