bluera-knowledge 0.10.0 → 0.11.0
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +98 -2
- package/commands/sync.md +96 -0
- package/dist/{chunk-ITH6FWQY.js → chunk-2WBITQWZ.js} +24 -3
- package/dist/{chunk-ITH6FWQY.js.map → chunk-2WBITQWZ.js.map} +1 -1
- package/dist/{chunk-CUHYSPRV.js → chunk-565OVW3C.js} +999 -2
- package/dist/chunk-565OVW3C.js.map +1 -0
- package/dist/{chunk-DWAIT2OD.js → chunk-TRDMYKGC.js} +190 -5
- package/dist/chunk-TRDMYKGC.js.map +1 -0
- package/dist/index.js +217 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +2 -2
- package/dist/workers/background-worker-cli.js +2 -2
- package/package.json +1 -1
- package/src/analysis/adapter-registry.test.ts +211 -0
- package/src/analysis/adapter-registry.ts +155 -0
- package/src/analysis/language-adapter.ts +127 -0
- package/src/analysis/parser-factory.test.ts +79 -1
- package/src/analysis/parser-factory.ts +8 -0
- package/src/analysis/zil/index.ts +34 -0
- package/src/analysis/zil/zil-adapter.test.ts +187 -0
- package/src/analysis/zil/zil-adapter.ts +121 -0
- package/src/analysis/zil/zil-lexer.test.ts +222 -0
- package/src/analysis/zil/zil-lexer.ts +239 -0
- package/src/analysis/zil/zil-parser.test.ts +210 -0
- package/src/analysis/zil/zil-parser.ts +360 -0
- package/src/analysis/zil/zil-special-forms.ts +193 -0
- package/src/cli/commands/sync.test.ts +54 -0
- package/src/cli/commands/sync.ts +264 -0
- package/src/cli/index.ts +1 -0
- package/src/crawl/claude-client.test.ts +56 -0
- package/src/crawl/claude-client.ts +27 -1
- package/src/index.ts +8 -0
- package/src/mcp/commands/index.ts +2 -0
- package/src/mcp/commands/sync.commands.test.ts +283 -0
- package/src/mcp/commands/sync.commands.ts +233 -0
- package/src/mcp/server.ts +9 -1
- package/src/services/gitignore.service.test.ts +157 -0
- package/src/services/gitignore.service.ts +132 -0
- package/src/services/store-definition.service.test.ts +440 -0
- package/src/services/store-definition.service.ts +198 -0
- package/src/services/store.service.test.ts +279 -1
- package/src/services/store.service.ts +101 -4
- package/src/types/index.ts +18 -0
- package/src/types/store-definition.test.ts +492 -0
- package/src/types/store-definition.ts +129 -0
- package/dist/chunk-CUHYSPRV.js.map +0 -1
- package/dist/chunk-DWAIT2OD.js.map +0 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { createServices, destroyServices } from '../../services/index.js';
|
|
3
|
+
import { StoreDefinitionService } from '../../services/store-definition.service.js';
|
|
4
|
+
import {
|
|
5
|
+
isFileStoreDefinition,
|
|
6
|
+
isRepoStoreDefinition,
|
|
7
|
+
isWebStoreDefinition,
|
|
8
|
+
} from '../../types/store-definition.js';
|
|
9
|
+
import type { StoreService } from '../../services/store.service.js';
|
|
10
|
+
import type { StoreDefinition } from '../../types/store-definition.js';
|
|
11
|
+
import type { GlobalOptions } from '../program.js';
|
|
12
|
+
|
|
13
|
+
interface SyncResult {
|
|
14
|
+
created: string[];
|
|
15
|
+
skipped: string[];
|
|
16
|
+
failed: Array<{ name: string; error: string }>;
|
|
17
|
+
orphans: string[];
|
|
18
|
+
pruned: string[];
|
|
19
|
+
dryRun: boolean;
|
|
20
|
+
wouldCreate: string[];
|
|
21
|
+
wouldPrune: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a store from a definition
|
|
26
|
+
*/
|
|
27
|
+
async function createStoreFromDefinition(
|
|
28
|
+
def: StoreDefinition,
|
|
29
|
+
defService: StoreDefinitionService,
|
|
30
|
+
storeService: StoreService
|
|
31
|
+
): Promise<{ success: true } | { success: false; error: string }> {
|
|
32
|
+
try {
|
|
33
|
+
if (isFileStoreDefinition(def)) {
|
|
34
|
+
const resolvedPath = defService.resolvePath(def.path);
|
|
35
|
+
const createResult = await storeService.create(
|
|
36
|
+
{
|
|
37
|
+
name: def.name,
|
|
38
|
+
type: 'file',
|
|
39
|
+
path: resolvedPath,
|
|
40
|
+
description: def.description,
|
|
41
|
+
tags: def.tags,
|
|
42
|
+
},
|
|
43
|
+
{ skipDefinitionSync: true }
|
|
44
|
+
);
|
|
45
|
+
if (!createResult.success) {
|
|
46
|
+
return { success: false, error: createResult.error.message };
|
|
47
|
+
}
|
|
48
|
+
return { success: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (isRepoStoreDefinition(def)) {
|
|
52
|
+
const createResult = await storeService.create(
|
|
53
|
+
{
|
|
54
|
+
name: def.name,
|
|
55
|
+
type: 'repo',
|
|
56
|
+
url: def.url,
|
|
57
|
+
branch: def.branch,
|
|
58
|
+
depth: def.depth,
|
|
59
|
+
description: def.description,
|
|
60
|
+
tags: def.tags,
|
|
61
|
+
},
|
|
62
|
+
{ skipDefinitionSync: true }
|
|
63
|
+
);
|
|
64
|
+
if (!createResult.success) {
|
|
65
|
+
return { success: false, error: createResult.error.message };
|
|
66
|
+
}
|
|
67
|
+
return { success: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (isWebStoreDefinition(def)) {
|
|
71
|
+
const createResult = await storeService.create(
|
|
72
|
+
{
|
|
73
|
+
name: def.name,
|
|
74
|
+
type: 'web',
|
|
75
|
+
url: def.url,
|
|
76
|
+
depth: def.depth,
|
|
77
|
+
description: def.description,
|
|
78
|
+
tags: def.tags,
|
|
79
|
+
},
|
|
80
|
+
{ skipDefinitionSync: true }
|
|
81
|
+
);
|
|
82
|
+
if (!createResult.success) {
|
|
83
|
+
return { success: false, error: createResult.error.message };
|
|
84
|
+
}
|
|
85
|
+
return { success: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { success: false, error: 'Unknown store definition type' };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: error instanceof Error ? error.message : String(error),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createSyncCommand(getOptions: () => GlobalOptions): Command {
|
|
98
|
+
const sync = new Command('sync').description(
|
|
99
|
+
'Sync stores from definitions config (bootstrap on fresh clone)'
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
sync
|
|
103
|
+
.option('--dry-run', 'Show what would happen without making changes')
|
|
104
|
+
.option('--prune', 'Remove stores not in definitions')
|
|
105
|
+
.option('--reindex', 'Re-index existing stores after sync')
|
|
106
|
+
.action(async (options: { dryRun?: boolean; prune?: boolean; reindex?: boolean }) => {
|
|
107
|
+
const globalOpts = getOptions();
|
|
108
|
+
const projectRoot = globalOpts.projectRoot ?? process.cwd();
|
|
109
|
+
|
|
110
|
+
const defService = new StoreDefinitionService(projectRoot);
|
|
111
|
+
const services = await createServices(globalOpts.config, globalOpts.dataDir, projectRoot);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const config = await defService.load();
|
|
115
|
+
const existingStores = await services.store.list();
|
|
116
|
+
const existingNames = new Set(existingStores.map((s) => s.name));
|
|
117
|
+
const definedNames = new Set(config.stores.map((d) => d.name));
|
|
118
|
+
|
|
119
|
+
const result: SyncResult = {
|
|
120
|
+
created: [],
|
|
121
|
+
skipped: [],
|
|
122
|
+
failed: [],
|
|
123
|
+
orphans: [],
|
|
124
|
+
pruned: [],
|
|
125
|
+
dryRun: options.dryRun === true,
|
|
126
|
+
wouldCreate: [],
|
|
127
|
+
wouldPrune: [],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Process each definition
|
|
131
|
+
for (const def of config.stores) {
|
|
132
|
+
if (existingNames.has(def.name)) {
|
|
133
|
+
result.skipped.push(def.name);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (options.dryRun === true) {
|
|
138
|
+
result.wouldCreate.push(def.name);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const createResult = await createStoreFromDefinition(def, defService, services.store);
|
|
143
|
+
if (createResult.success) {
|
|
144
|
+
result.created.push(def.name);
|
|
145
|
+
} else {
|
|
146
|
+
result.failed.push({ name: def.name, error: createResult.error });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Find orphans
|
|
151
|
+
for (const store of existingStores) {
|
|
152
|
+
if (!definedNames.has(store.name)) {
|
|
153
|
+
result.orphans.push(store.name);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Prune orphans if requested
|
|
158
|
+
if (options.prune === true && result.orphans.length > 0) {
|
|
159
|
+
if (options.dryRun === true) {
|
|
160
|
+
result.wouldPrune = [...result.orphans];
|
|
161
|
+
} else {
|
|
162
|
+
for (const orphanName of result.orphans) {
|
|
163
|
+
const store = await services.store.getByName(orphanName);
|
|
164
|
+
if (store !== undefined) {
|
|
165
|
+
const deleteResult = await services.store.delete(store.id, {
|
|
166
|
+
skipDefinitionSync: true,
|
|
167
|
+
});
|
|
168
|
+
if (deleteResult.success) {
|
|
169
|
+
result.pruned.push(orphanName);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Output result
|
|
177
|
+
if (globalOpts.format === 'json') {
|
|
178
|
+
console.log(JSON.stringify(result, null, 2));
|
|
179
|
+
} else {
|
|
180
|
+
printHumanReadable(result, globalOpts.quiet === true);
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
await destroyServices(services);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return sync;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function printHumanReadable(result: SyncResult, quiet: boolean): void {
|
|
191
|
+
if (quiet) {
|
|
192
|
+
// Just print created/pruned store names
|
|
193
|
+
for (const name of result.created) {
|
|
194
|
+
console.log(`created: ${name}`);
|
|
195
|
+
}
|
|
196
|
+
for (const name of result.pruned) {
|
|
197
|
+
console.log(`pruned: ${name}`);
|
|
198
|
+
}
|
|
199
|
+
for (const name of result.wouldCreate) {
|
|
200
|
+
console.log(`would create: ${name}`);
|
|
201
|
+
}
|
|
202
|
+
for (const name of result.wouldPrune) {
|
|
203
|
+
console.log(`would prune: ${name}`);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (result.dryRun) {
|
|
209
|
+
console.log('\n[DRY RUN] No changes made.\n');
|
|
210
|
+
} else {
|
|
211
|
+
console.log('\nSync completed.\n');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (result.created.length > 0) {
|
|
215
|
+
console.log(`Created (${String(result.created.length)}):`);
|
|
216
|
+
for (const name of result.created) {
|
|
217
|
+
console.log(` + ${name}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (result.wouldCreate.length > 0) {
|
|
222
|
+
console.log(`Would create (${String(result.wouldCreate.length)}):`);
|
|
223
|
+
for (const name of result.wouldCreate) {
|
|
224
|
+
console.log(` + ${name}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (result.skipped.length > 0) {
|
|
229
|
+
console.log(`Skipped (already exist) (${String(result.skipped.length)}):`);
|
|
230
|
+
for (const name of result.skipped) {
|
|
231
|
+
console.log(` - ${name}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (result.failed.length > 0) {
|
|
236
|
+
console.log(`Failed (${String(result.failed.length)}):`);
|
|
237
|
+
for (const { name, error } of result.failed) {
|
|
238
|
+
console.log(` ! ${name}: ${error}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (result.orphans.length > 0) {
|
|
243
|
+
console.log(`Orphans (not in definitions) (${String(result.orphans.length)}):`);
|
|
244
|
+
for (const name of result.orphans) {
|
|
245
|
+
console.log(` ? ${name}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (result.pruned.length > 0) {
|
|
250
|
+
console.log(`Pruned (${String(result.pruned.length)}):`);
|
|
251
|
+
for (const name of result.pruned) {
|
|
252
|
+
console.log(` x ${name}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (result.wouldPrune.length > 0) {
|
|
257
|
+
console.log(`Would prune (${String(result.wouldPrune.length)}):`);
|
|
258
|
+
for (const name of result.wouldPrune) {
|
|
259
|
+
console.log(` x ${name}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
console.log('');
|
|
264
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -5,3 +5,4 @@ export { createIndexCommand } from './commands/index-cmd.js';
|
|
|
5
5
|
export { createServeCommand } from './commands/serve.js';
|
|
6
6
|
export { createCrawlCommand } from './commands/crawl.js';
|
|
7
7
|
export { createSetupCommand } from './commands/setup.js';
|
|
8
|
+
export { createSyncCommand } from './commands/sync.js';
|
|
@@ -106,6 +106,62 @@ describe('ClaudeClient', () => {
|
|
|
106
106
|
expect(result.reasoning).toBe('Found documentation pages');
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
it('should extract structured_output from Claude CLI wrapper format', async () => {
|
|
110
|
+
const promise = client.determineCrawlUrls(
|
|
111
|
+
'https://example.com',
|
|
112
|
+
'<html>test</html>',
|
|
113
|
+
'Find all docs'
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Claude CLI with --json-schema returns this wrapper format
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
mockProcess.stdout.emit(
|
|
119
|
+
'data',
|
|
120
|
+
Buffer.from(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
type: 'result',
|
|
123
|
+
subtype: 'success',
|
|
124
|
+
result: '',
|
|
125
|
+
structured_output: {
|
|
126
|
+
urls: ['https://example.com/page1', 'https://example.com/page2'],
|
|
127
|
+
reasoning: 'Found documentation pages',
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
mockProcess.emit('close', 0);
|
|
133
|
+
}, 10);
|
|
134
|
+
|
|
135
|
+
const result = await promise;
|
|
136
|
+
expect(result.urls).toEqual(['https://example.com/page1', 'https://example.com/page2']);
|
|
137
|
+
expect(result.reasoning).toBe('Found documentation pages');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should fall back to raw response when structured_output is not an object', async () => {
|
|
141
|
+
const promise = client.determineCrawlUrls(
|
|
142
|
+
'https://example.com',
|
|
143
|
+
'<html>test</html>',
|
|
144
|
+
'Find all docs'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// When structured_output is not an object, use the raw response
|
|
148
|
+
// (which will fail validation if it doesn't have urls/reasoning)
|
|
149
|
+
setTimeout(() => {
|
|
150
|
+
mockProcess.stdout.emit(
|
|
151
|
+
'data',
|
|
152
|
+
Buffer.from(
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
type: 'result',
|
|
155
|
+
structured_output: 'not an object',
|
|
156
|
+
})
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
mockProcess.emit('close', 0);
|
|
160
|
+
}, 10);
|
|
161
|
+
|
|
162
|
+
await expect(promise).rejects.toThrow('invalid crawl strategy');
|
|
163
|
+
});
|
|
164
|
+
|
|
109
165
|
it('should call spawn with correct arguments for determineCrawlUrls', async () => {
|
|
110
166
|
const promise = client.determineCrawlUrls(
|
|
111
167
|
'https://example.com',
|
|
@@ -96,7 +96,11 @@ Return only URLs that are relevant to the instruction. If the instruction mentio
|
|
|
96
96
|
|
|
97
97
|
try {
|
|
98
98
|
const result = await this.callClaude(prompt, CRAWL_STRATEGY_SCHEMA);
|
|
99
|
-
const
|
|
99
|
+
const rawParsed: unknown = JSON.parse(result);
|
|
100
|
+
|
|
101
|
+
// Claude CLI with --json-schema returns wrapper: {type, result, structured_output: {...}}
|
|
102
|
+
// Extract structured_output if present, otherwise use raw response
|
|
103
|
+
const parsed = this.extractStructuredOutput(rawParsed);
|
|
100
104
|
|
|
101
105
|
// Validate and narrow type
|
|
102
106
|
if (
|
|
@@ -232,4 +236,26 @@ ${this.truncateMarkdown(markdown, 100000)}`;
|
|
|
232
236
|
|
|
233
237
|
return `${markdown.substring(0, maxLength)}\n\n[... content truncated ...]`;
|
|
234
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Type guard to check if value is a record (plain object)
|
|
242
|
+
*/
|
|
243
|
+
private isRecord(value: unknown): value is Record<string, unknown> {
|
|
244
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Extract structured_output from Claude CLI wrapper format if present.
|
|
249
|
+
* Claude CLI with --json-schema returns: {type, result, structured_output: {...}}
|
|
250
|
+
* This method extracts the inner structured_output, or returns the raw value if not wrapped.
|
|
251
|
+
*/
|
|
252
|
+
private extractStructuredOutput(rawParsed: unknown): unknown {
|
|
253
|
+
if (this.isRecord(rawParsed) && 'structured_output' in rawParsed) {
|
|
254
|
+
const structuredOutput = rawParsed['structured_output'];
|
|
255
|
+
if (typeof structuredOutput === 'object') {
|
|
256
|
+
return structuredOutput;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return rawParsed;
|
|
260
|
+
}
|
|
235
261
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { Command } from 'commander';
|
|
6
|
+
import { AdapterRegistry } from './analysis/adapter-registry.js';
|
|
7
|
+
import { ZilAdapter } from './analysis/zil/index.js';
|
|
6
8
|
import { createCrawlCommand } from './cli/commands/crawl.js';
|
|
7
9
|
import { createIndexCommand } from './cli/commands/index-cmd.js';
|
|
8
10
|
import { createMCPCommand } from './cli/commands/mcp.js';
|
|
@@ -16,8 +18,13 @@ import { createSearchCommand } from './cli/commands/search.js';
|
|
|
16
18
|
import { createServeCommand } from './cli/commands/serve.js';
|
|
17
19
|
import { createSetupCommand } from './cli/commands/setup.js';
|
|
18
20
|
import { createStoreCommand } from './cli/commands/store.js';
|
|
21
|
+
import { createSyncCommand } from './cli/commands/sync.js';
|
|
19
22
|
import { createProgram, getGlobalOptions } from './cli/program.js';
|
|
20
23
|
|
|
24
|
+
// Register built-in language adapters
|
|
25
|
+
const registry = AdapterRegistry.getInstance();
|
|
26
|
+
registry.register(new ZilAdapter());
|
|
27
|
+
|
|
21
28
|
// Default paths
|
|
22
29
|
const DEFAULT_DATA_DIR = join(homedir(), '.bluera', 'bluera-knowledge', 'data');
|
|
23
30
|
const DEFAULT_CONFIG = join(homedir(), '.bluera', 'bluera-knowledge', 'config.json');
|
|
@@ -105,6 +112,7 @@ program.addCommand(createIndexCommand(() => getGlobalOptions(program)));
|
|
|
105
112
|
program.addCommand(createServeCommand(() => getGlobalOptions(program)));
|
|
106
113
|
program.addCommand(createCrawlCommand(() => getGlobalOptions(program)));
|
|
107
114
|
program.addCommand(createSetupCommand(() => getGlobalOptions(program)));
|
|
115
|
+
program.addCommand(createSyncCommand(() => getGlobalOptions(program)));
|
|
108
116
|
program.addCommand(createMCPCommand(() => getGlobalOptions(program)));
|
|
109
117
|
|
|
110
118
|
// Show comprehensive help when no arguments provided
|
|
@@ -9,11 +9,13 @@ import { jobCommands } from './job.commands.js';
|
|
|
9
9
|
import { metaCommands } from './meta.commands.js';
|
|
10
10
|
import { commandRegistry } from './registry.js';
|
|
11
11
|
import { storeCommands } from './store.commands.js';
|
|
12
|
+
import { syncCommands } from './sync.commands.js';
|
|
12
13
|
|
|
13
14
|
// Register all commands
|
|
14
15
|
commandRegistry.registerAll(storeCommands);
|
|
15
16
|
commandRegistry.registerAll(jobCommands);
|
|
16
17
|
commandRegistry.registerAll(metaCommands);
|
|
18
|
+
commandRegistry.registerAll(syncCommands);
|
|
17
19
|
|
|
18
20
|
// Re-export for convenience
|
|
19
21
|
export { commandRegistry, executeCommand, generateHelp } from './registry.js';
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { rm, mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { syncCommands, handleStoresSync } from './sync.commands.js';
|
|
7
|
+
import { StoreService } from '../../services/store.service.js';
|
|
8
|
+
import { StoreDefinitionService } from '../../services/store-definition.service.js';
|
|
9
|
+
import type { HandlerContext } from '../types.js';
|
|
10
|
+
import type { ServiceContainer } from '../../services/index.js';
|
|
11
|
+
import type { StoreDefinitionsConfig } from '../../types/store-definition.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a minimal mock service container for testing
|
|
15
|
+
*/
|
|
16
|
+
function createMockServices(storeService: StoreService): ServiceContainer {
|
|
17
|
+
return {
|
|
18
|
+
store: storeService,
|
|
19
|
+
// Other services not needed for sync tests
|
|
20
|
+
config: {} as ServiceContainer['config'],
|
|
21
|
+
search: {} as ServiceContainer['search'],
|
|
22
|
+
index: {} as ServiceContainer['index'],
|
|
23
|
+
lance: {} as ServiceContainer['lance'],
|
|
24
|
+
embeddings: {} as ServiceContainer['embeddings'],
|
|
25
|
+
codeGraph: {} as ServiceContainer['codeGraph'],
|
|
26
|
+
pythonBridge: {} as ServiceContainer['pythonBridge'],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('sync.commands', () => {
|
|
31
|
+
describe('command definition', () => {
|
|
32
|
+
it('exports stores:sync command', () => {
|
|
33
|
+
const syncCmd = syncCommands.find((c) => c.name === 'stores:sync');
|
|
34
|
+
expect(syncCmd).toBeDefined();
|
|
35
|
+
expect(syncCmd?.description).toContain('Sync');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('has correct args schema', () => {
|
|
39
|
+
const syncCmd = syncCommands.find((c) => c.name === 'stores:sync');
|
|
40
|
+
expect(syncCmd?.argsSchema).toBeDefined();
|
|
41
|
+
|
|
42
|
+
// Valid empty args
|
|
43
|
+
const result1 = syncCmd?.argsSchema?.safeParse({});
|
|
44
|
+
expect(result1?.success).toBe(true);
|
|
45
|
+
|
|
46
|
+
// Valid with options
|
|
47
|
+
const result2 = syncCmd?.argsSchema?.safeParse({
|
|
48
|
+
reindex: true,
|
|
49
|
+
prune: true,
|
|
50
|
+
dryRun: true,
|
|
51
|
+
});
|
|
52
|
+
expect(result2?.success).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('handleStoresSync', () => {
|
|
57
|
+
let projectRoot: string;
|
|
58
|
+
let dataDir: string;
|
|
59
|
+
let storeService: StoreService;
|
|
60
|
+
let defService: StoreDefinitionService;
|
|
61
|
+
let context: HandlerContext;
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
projectRoot = await mkdtemp(join(tmpdir(), 'sync-test-'));
|
|
65
|
+
dataDir = join(projectRoot, '.bluera/bluera-knowledge/data');
|
|
66
|
+
defService = new StoreDefinitionService(projectRoot);
|
|
67
|
+
storeService = new StoreService(dataDir, { definitionService: defService });
|
|
68
|
+
await storeService.initialize();
|
|
69
|
+
|
|
70
|
+
context = {
|
|
71
|
+
services: createMockServices(storeService),
|
|
72
|
+
options: { projectRoot },
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(async () => {
|
|
77
|
+
await rm(projectRoot, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('creates missing stores', () => {
|
|
81
|
+
it('creates file store from definition', async () => {
|
|
82
|
+
// Create a directory to reference
|
|
83
|
+
const docsDir = join(projectRoot, 'docs');
|
|
84
|
+
await mkdir(docsDir, { recursive: true });
|
|
85
|
+
|
|
86
|
+
// Add definition manually (simulating config from git)
|
|
87
|
+
await defService.addDefinition({
|
|
88
|
+
type: 'file',
|
|
89
|
+
name: 'my-docs',
|
|
90
|
+
path: './docs',
|
|
91
|
+
description: 'Documentation',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await handleStoresSync({}, context);
|
|
95
|
+
const response = JSON.parse(result.content[0].text);
|
|
96
|
+
|
|
97
|
+
expect(response.created).toContain('my-docs');
|
|
98
|
+
expect(response.skipped).toHaveLength(0);
|
|
99
|
+
expect(response.failed).toHaveLength(0);
|
|
100
|
+
|
|
101
|
+
// Verify store was created
|
|
102
|
+
const store = await storeService.getByName('my-docs');
|
|
103
|
+
expect(store).toBeDefined();
|
|
104
|
+
expect(store?.type).toBe('file');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('creates web store from definition', async () => {
|
|
108
|
+
// Add web store definition
|
|
109
|
+
await defService.addDefinition({
|
|
110
|
+
type: 'web',
|
|
111
|
+
name: 'api-docs',
|
|
112
|
+
url: 'https://example.com/docs',
|
|
113
|
+
depth: 2,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const result = await handleStoresSync({}, context);
|
|
117
|
+
const response = JSON.parse(result.content[0].text);
|
|
118
|
+
|
|
119
|
+
expect(response.created).toContain('api-docs');
|
|
120
|
+
|
|
121
|
+
const store = await storeService.getByName('api-docs');
|
|
122
|
+
expect(store).toBeDefined();
|
|
123
|
+
expect(store?.type).toBe('web');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('skips existing stores', () => {
|
|
128
|
+
it('skips store that already exists', async () => {
|
|
129
|
+
const docsDir = join(projectRoot, 'docs');
|
|
130
|
+
await mkdir(docsDir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
// Create store first (this auto-adds the definition via the integration)
|
|
133
|
+
await storeService.create({
|
|
134
|
+
name: 'existing-docs',
|
|
135
|
+
type: 'file',
|
|
136
|
+
path: docsDir,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Definition was auto-added, so sync should skip this store
|
|
140
|
+
const result = await handleStoresSync({}, context);
|
|
141
|
+
const response = JSON.parse(result.content[0].text);
|
|
142
|
+
|
|
143
|
+
expect(response.skipped).toContain('existing-docs');
|
|
144
|
+
expect(response.created).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('reports orphans', () => {
|
|
149
|
+
it('reports stores not in definitions', async () => {
|
|
150
|
+
const docsDir = join(projectRoot, 'docs');
|
|
151
|
+
await mkdir(docsDir, { recursive: true });
|
|
152
|
+
|
|
153
|
+
// Create store without definition (using skipDefinitionSync)
|
|
154
|
+
await storeService.create(
|
|
155
|
+
{
|
|
156
|
+
name: 'orphan-store',
|
|
157
|
+
type: 'file',
|
|
158
|
+
path: docsDir,
|
|
159
|
+
},
|
|
160
|
+
{ skipDefinitionSync: true }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const result = await handleStoresSync({}, context);
|
|
164
|
+
const response = JSON.parse(result.content[0].text);
|
|
165
|
+
|
|
166
|
+
expect(response.orphans).toContain('orphan-store');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('dry run mode', () => {
|
|
171
|
+
it('does not create stores in dry run mode', async () => {
|
|
172
|
+
const docsDir = join(projectRoot, 'docs');
|
|
173
|
+
await mkdir(docsDir, { recursive: true });
|
|
174
|
+
|
|
175
|
+
await defService.addDefinition({
|
|
176
|
+
type: 'file',
|
|
177
|
+
name: 'dry-run-store',
|
|
178
|
+
path: './docs',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = await handleStoresSync({ dryRun: true }, context);
|
|
182
|
+
const response = JSON.parse(result.content[0].text);
|
|
183
|
+
|
|
184
|
+
expect(response.dryRun).toBe(true);
|
|
185
|
+
expect(response.wouldCreate).toContain('dry-run-store');
|
|
186
|
+
|
|
187
|
+
// Store should NOT exist
|
|
188
|
+
const store = await storeService.getByName('dry-run-store');
|
|
189
|
+
expect(store).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('prune mode', () => {
|
|
194
|
+
it('removes orphan stores when prune is true', async () => {
|
|
195
|
+
const docsDir = join(projectRoot, 'docs');
|
|
196
|
+
await mkdir(docsDir, { recursive: true });
|
|
197
|
+
|
|
198
|
+
// Create orphan store
|
|
199
|
+
await storeService.create(
|
|
200
|
+
{
|
|
201
|
+
name: 'to-prune',
|
|
202
|
+
type: 'file',
|
|
203
|
+
path: docsDir,
|
|
204
|
+
},
|
|
205
|
+
{ skipDefinitionSync: true }
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const result = await handleStoresSync({ prune: true }, context);
|
|
209
|
+
const response = JSON.parse(result.content[0].text);
|
|
210
|
+
|
|
211
|
+
expect(response.pruned).toContain('to-prune');
|
|
212
|
+
|
|
213
|
+
// Store should be deleted
|
|
214
|
+
const store = await storeService.getByName('to-prune');
|
|
215
|
+
expect(store).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('does not prune in dry run mode', async () => {
|
|
219
|
+
const docsDir = join(projectRoot, 'docs');
|
|
220
|
+
await mkdir(docsDir, { recursive: true });
|
|
221
|
+
|
|
222
|
+
await storeService.create(
|
|
223
|
+
{
|
|
224
|
+
name: 'keep-me',
|
|
225
|
+
type: 'file',
|
|
226
|
+
path: docsDir,
|
|
227
|
+
},
|
|
228
|
+
{ skipDefinitionSync: true }
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const result = await handleStoresSync({ prune: true, dryRun: true }, context);
|
|
232
|
+
const response = JSON.parse(result.content[0].text);
|
|
233
|
+
|
|
234
|
+
expect(response.wouldPrune).toContain('keep-me');
|
|
235
|
+
|
|
236
|
+
// Store should still exist
|
|
237
|
+
const store = await storeService.getByName('keep-me');
|
|
238
|
+
expect(store).toBeDefined();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('error handling', () => {
|
|
243
|
+
it('continues on error and reports failures', async () => {
|
|
244
|
+
// Add definition for non-existent directory
|
|
245
|
+
await defService.addDefinition({
|
|
246
|
+
type: 'file',
|
|
247
|
+
name: 'bad-store',
|
|
248
|
+
path: './nonexistent',
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Also add a valid definition
|
|
252
|
+
const docsDir = join(projectRoot, 'docs');
|
|
253
|
+
await mkdir(docsDir, { recursive: true });
|
|
254
|
+
await defService.addDefinition({
|
|
255
|
+
type: 'file',
|
|
256
|
+
name: 'good-store',
|
|
257
|
+
path: './docs',
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const result = await handleStoresSync({}, context);
|
|
261
|
+
const response = JSON.parse(result.content[0].text);
|
|
262
|
+
|
|
263
|
+
// Should have one failure and one success
|
|
264
|
+
expect(response.failed).toHaveLength(1);
|
|
265
|
+
expect(response.failed[0].name).toBe('bad-store');
|
|
266
|
+
expect(response.failed[0].error).toBeDefined();
|
|
267
|
+
expect(response.created).toContain('good-store');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('empty config', () => {
|
|
272
|
+
it('handles empty definitions gracefully', async () => {
|
|
273
|
+
const result = await handleStoresSync({}, context);
|
|
274
|
+
const response = JSON.parse(result.content[0].text);
|
|
275
|
+
|
|
276
|
+
expect(response.created).toHaveLength(0);
|
|
277
|
+
expect(response.skipped).toHaveLength(0);
|
|
278
|
+
expect(response.failed).toHaveLength(0);
|
|
279
|
+
expect(response.orphans).toHaveLength(0);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|