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.
- package/LICENSE +661 -0
- package/README.md +282 -0
- package/bin/cli.js +8 -0
- package/bin/daemon.js +7 -0
- package/package.json +70 -0
- package/src/agent/AGENTS.md +249 -0
- package/src/agent/agent.prompts.ts +66 -0
- package/src/agent/agent.test-runner.schemas.ts +158 -0
- package/src/agent/agent.test-runner.ts +436 -0
- package/src/agent/agent.ts +371 -0
- package/src/agent/agent.types.ts +94 -0
- package/src/backend/AGENTS.md +112 -0
- package/src/backend/backend.protocol.ts +95 -0
- package/src/backend/backend.schemas.ts +123 -0
- package/src/backend/backend.services.ts +151 -0
- package/src/backend/backend.ts +111 -0
- package/src/backend/backend.types.ts +34 -0
- package/src/cli/AGENTS.md +213 -0
- package/src/cli/cli.agent.ts +197 -0
- package/src/cli/cli.chat.ts +369 -0
- package/src/cli/cli.client.ts +55 -0
- package/src/cli/cli.collections.ts +491 -0
- package/src/cli/cli.config.ts +252 -0
- package/src/cli/cli.daemon.ts +160 -0
- package/src/cli/cli.documents.ts +413 -0
- package/src/cli/cli.mcp.ts +177 -0
- package/src/cli/cli.ts +28 -0
- package/src/cli/cli.utils.ts +122 -0
- package/src/client/AGENTS.md +135 -0
- package/src/client/client.adapters.ts +279 -0
- package/src/client/client.ts +86 -0
- package/src/client/client.types.ts +17 -0
- package/src/collections/AGENTS.md +185 -0
- package/src/collections/collections.schemas.ts +195 -0
- package/src/collections/collections.ts +1160 -0
- package/src/config/config.ts +118 -0
- package/src/daemon/AGENTS.md +168 -0
- package/src/daemon/daemon.config.ts +23 -0
- package/src/daemon/daemon.manager.ts +215 -0
- package/src/daemon/daemon.schemas.ts +22 -0
- package/src/daemon/daemon.ts +205 -0
- package/src/database/AGENTS.md +211 -0
- package/src/database/database.ts +64 -0
- package/src/database/migrations/migrations.001-init.ts +56 -0
- package/src/database/migrations/migrations.002-fts5.ts +32 -0
- package/src/database/migrations/migrations.ts +20 -0
- package/src/database/migrations/migrations.types.ts +9 -0
- package/src/documents/AGENTS.md +301 -0
- package/src/documents/documents.schemas.ts +190 -0
- package/src/documents/documents.ts +734 -0
- package/src/embedder/embedder.ts +53 -0
- package/src/exports.ts +0 -0
- package/src/mcp/AGENTS.md +264 -0
- package/src/mcp/mcp.ts +105 -0
- package/src/tools/AGENTS.md +228 -0
- package/src/tools/agent/agent.ts +45 -0
- package/src/tools/documents/documents.ts +401 -0
- package/src/tools/tools.langchain.ts +37 -0
- package/src/tools/tools.mcp.ts +46 -0
- package/src/tools/tools.types.ts +35 -0
- 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 };
|