codesearch-mcp 0.0.17-alpha.1 → 0.0.18-alpha
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/CHANGELOG.md +25 -0
- package/README.md +20 -0
- package/dist/app.js +34 -10
- package/dist/src/commands/ConfigCommand.d.ts +1 -0
- package/dist/src/commands/ConfigCommand.js +84 -4
- package/dist/src/commands/IndexCommand.js +22 -7
- package/dist/src/commands/McpCommand.js +4 -3
- package/dist/src/commands/WebCommand.d.ts +8 -0
- package/dist/src/commands/WebCommand.js +56 -0
- package/dist/src/config-store.d.ts +43 -0
- package/dist/src/config-store.js +92 -27
- package/dist/src/config.js +16 -17
- package/dist/src/constants.d.ts +2 -1
- package/dist/src/constants.js +6 -4
- package/dist/src/container.d.ts +10 -6
- package/dist/src/container.js +6 -3
- package/dist/src/graph.d.ts +60 -0
- package/dist/src/graph.js +239 -120
- package/dist/src/help.js +15 -1
- package/dist/src/interfaces/IAstParser.d.ts +6 -3
- package/dist/src/interfaces/IDatabase.d.ts +13 -0
- package/dist/src/interfaces/IDatabase.js +2 -0
- package/dist/src/interfaces/IEmbedder.d.ts +1 -0
- package/dist/src/interfaces/ILogger.d.ts +10 -0
- package/dist/src/interfaces/ILogger.js +2 -0
- package/dist/src/interfaces/IMcpToolHandler.d.ts +22 -0
- package/dist/src/interfaces/IMcpToolHandler.js +2 -0
- package/dist/src/interfaces/IMonorepoResolver.d.ts +33 -0
- package/dist/src/interfaces/IMonorepoResolver.js +2 -0
- package/dist/src/interfaces/IProjectDiscovery.d.ts +28 -0
- package/dist/src/interfaces/IProjectDiscovery.js +2 -0
- package/dist/src/interfaces/ISearcher.d.ts +36 -0
- package/dist/src/interfaces/ISearcher.js +2 -0
- package/dist/src/interfaces/index.d.ts +7 -0
- package/dist/src/interfaces/index.js +2 -0
- package/dist/src/project-selector.d.ts +1 -1
- package/dist/src/services/AstParserService.d.ts +4 -6
- package/dist/src/services/AstParserService.js +29 -13
- package/dist/src/services/DatabaseService.d.ts +2 -1
- package/dist/src/services/IndexerService.d.ts +6 -8
- package/dist/src/services/IndexerService.js +5 -8
- package/dist/src/services/LoggerService.d.ts +2 -1
- package/dist/src/services/McpServer.d.ts +16 -7
- package/dist/src/services/McpServer.js +54 -367
- package/dist/src/services/MonorepoAwareDiscoveryService.d.ts +62 -0
- package/dist/src/services/MonorepoAwareDiscoveryService.js +125 -0
- package/dist/src/services/NxMonorepoResolver.d.ts +45 -0
- package/dist/src/services/NxMonorepoResolver.js +308 -0
- package/dist/src/services/OllamaEmbedder.d.ts +5 -2
- package/dist/src/services/OllamaEmbedder.js +9 -2
- package/dist/src/services/ProjectDiscoveryService.d.ts +8 -0
- package/dist/src/services/ProjectDiscoveryService.js +52 -0
- package/dist/src/services/SearcherService.d.ts +39 -6
- package/dist/src/services/SearcherService.js +151 -40
- package/dist/src/services/WebServerService.d.ts +72 -0
- package/dist/src/services/WebServerService.js +247 -0
- package/dist/src/setup.js +7 -7
- package/dist/src/tools/FindSymbolTool.d.ts +8 -0
- package/dist/src/tools/FindSymbolTool.js +45 -0
- package/dist/src/tools/GetCallersTool.d.ts +8 -0
- package/dist/src/tools/GetCallersTool.js +42 -0
- package/dist/src/tools/GetDependenciesTool.d.ts +8 -0
- package/dist/src/tools/GetDependenciesTool.js +38 -0
- package/dist/src/tools/GetDependentsTool.d.ts +8 -0
- package/dist/src/tools/GetDependentsTool.js +57 -0
- package/dist/src/tools/ListFilesTool.d.ts +8 -0
- package/dist/src/tools/ListFilesTool.js +39 -0
- package/dist/src/tools/ListProjectsTool.d.ts +8 -0
- package/dist/src/tools/ListProjectsTool.js +44 -0
- package/dist/src/tools/ReadFileRangeTool.d.ts +8 -0
- package/dist/src/tools/ReadFileRangeTool.js +44 -0
- package/dist/src/tools/ReadFileTool.d.ts +8 -0
- package/dist/src/tools/ReadFileTool.js +42 -0
- package/dist/src/tools/SearchCodebaseTool.d.ts +13 -0
- package/dist/src/tools/SearchCodebaseTool.js +81 -0
- package/dist/src/tools/format-helpers.d.ts +8 -0
- package/dist/src/tools/format-helpers.js +57 -0
- package/dist/src/tools/index.d.ts +9 -0
- package/dist/src/tools/index.js +21 -0
- package/dist/src/web/dashboard.d.ts +9 -0
- package/dist/src/web/dashboard.js +337 -0
- package/docs/web-dashboard.md +187 -0
- package/package.json +5 -2
|
@@ -39,7 +39,13 @@ const path = __importStar(require("path"));
|
|
|
39
39
|
const readline = __importStar(require("readline"));
|
|
40
40
|
const child_process_1 = require("child_process");
|
|
41
41
|
const file_utils_1 = require("../file-utils");
|
|
42
|
-
const
|
|
42
|
+
const MonorepoAwareDiscoveryService_1 = require("./MonorepoAwareDiscoveryService");
|
|
43
|
+
const SILENT_LOGGER = {
|
|
44
|
+
debug() { },
|
|
45
|
+
info() { },
|
|
46
|
+
warn() { },
|
|
47
|
+
error() { },
|
|
48
|
+
};
|
|
43
49
|
const EMPTY_META = { description: null, stack: null, notes: null, relations: null };
|
|
44
50
|
const GREP_MAX_RESULTS = 200;
|
|
45
51
|
const GREP_RAW_LIMIT = GREP_MAX_RESULTS * 5;
|
|
@@ -49,14 +55,78 @@ const FIND_SYMBOL_RAW_LIMIT = FIND_SYMBOL_LIMIT * 10;
|
|
|
49
55
|
class SearcherService {
|
|
50
56
|
db;
|
|
51
57
|
embedder;
|
|
58
|
+
discovery;
|
|
52
59
|
config;
|
|
53
60
|
logger;
|
|
54
|
-
constructor(db, embedder, config, logger =
|
|
61
|
+
constructor(db, embedder, discovery, config, logger = SILENT_LOGGER) {
|
|
55
62
|
this.db = db;
|
|
56
63
|
this.embedder = embedder;
|
|
64
|
+
this.discovery = discovery;
|
|
57
65
|
this.config = config;
|
|
58
66
|
this.logger = logger;
|
|
59
67
|
}
|
|
68
|
+
/** Lazily-built, cached map of project name → real filesystem root. */
|
|
69
|
+
rootMap;
|
|
70
|
+
/** Cached, lazily-built map of leaf project name → real filesystem root (via discovery). */
|
|
71
|
+
projectRootMap() {
|
|
72
|
+
if (!this.rootMap) {
|
|
73
|
+
this.rootMap = new Map((0, MonorepoAwareDiscoveryService_1.toLeafProjects)(this.discovery.discover(this.config.workspaceRoot)).map(p => [p.name, p.path]));
|
|
74
|
+
}
|
|
75
|
+
return this.rootMap;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a project name to its real filesystem root. Uses discovery (leaf
|
|
79
|
+
* projects) so nested-monorepo names like `repo/app` map to their true path;
|
|
80
|
+
* falls back to `join(workspaceRoot, name)` when the name is unknown (e.g. a
|
|
81
|
+
* project since removed from disk, or a flat project not surfaced by discovery).
|
|
82
|
+
*/
|
|
83
|
+
resolveProjectRoot(name) {
|
|
84
|
+
let root = this.projectRootMap().get(name);
|
|
85
|
+
if (root === undefined) {
|
|
86
|
+
// Cache miss — the project may have been added/renamed on disk since the
|
|
87
|
+
// map was built (mcp/web servers are long-lived). Rebuild once and retry
|
|
88
|
+
// before the flat fallback, which is wrong for nested-monorepo names
|
|
89
|
+
// (`repo/app` lives at `repo/apps/app`, not `repo/app`).
|
|
90
|
+
this.rootMap = undefined;
|
|
91
|
+
root = this.projectRootMap().get(name);
|
|
92
|
+
}
|
|
93
|
+
return root ?? path.join(this.config.workspaceRoot, name);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Project-level dependency edges aggregated from `code_dependency`: an edge
|
|
97
|
+
* `A → B` means some file in project A imports a file owned by project B.
|
|
98
|
+
* Used by the graph to draw links between a monorepo's sub-projects. Only
|
|
99
|
+
* edges whose target file belongs to an indexed project are returned (targets
|
|
100
|
+
* outside any indexed project — e.g. unindexed libs — are skipped).
|
|
101
|
+
*/
|
|
102
|
+
async getProjectEdges() {
|
|
103
|
+
const rows = await this.db.query(`SELECT DISTINCT project, "toFile" FROM code_dependency`);
|
|
104
|
+
// Reverse map: workspace-relative project root → name, longest first so the
|
|
105
|
+
// most specific owner wins (e.g. repo/apps/account before repo).
|
|
106
|
+
const byRelPath = [...this.projectRootMap().entries()]
|
|
107
|
+
.map(([name, abs]) => ({ name, rel: path.relative(this.config.workspaceRoot, abs) }))
|
|
108
|
+
.sort((a, b) => b.rel.length - a.rel.length);
|
|
109
|
+
const ownerOf = (toFile) => {
|
|
110
|
+
for (const { name, rel } of byRelPath) {
|
|
111
|
+
if (toFile === rel || toFile.startsWith(rel + path.sep) || toFile.startsWith(rel + '/'))
|
|
112
|
+
return name;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const edges = [];
|
|
118
|
+
for (const { project, toFile } of rows) {
|
|
119
|
+
const to = ownerOf(toFile);
|
|
120
|
+
if (!to || to === project)
|
|
121
|
+
continue;
|
|
122
|
+
const key = project + ' ' + to;
|
|
123
|
+
if (seen.has(key))
|
|
124
|
+
continue;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
edges.push({ from: project, to });
|
|
127
|
+
}
|
|
128
|
+
return edges;
|
|
129
|
+
}
|
|
60
130
|
async prepareQuery(raw) {
|
|
61
131
|
const t0 = Date.now();
|
|
62
132
|
if (this.config.search.autoTranslate && this.isNonEnglish(raw) && this.embedder.translate) {
|
|
@@ -73,8 +143,9 @@ class SearcherService {
|
|
|
73
143
|
GROUP BY "serviceName"
|
|
74
144
|
ORDER BY "serviceName"`);
|
|
75
145
|
return rows.map(r => {
|
|
76
|
-
const
|
|
77
|
-
|
|
146
|
+
const projectRoot = this.resolveProjectRoot(r.serviceName);
|
|
147
|
+
const meta = (0, file_utils_1.readRagMeta)(projectRoot);
|
|
148
|
+
return { ...r, path: projectRoot, ...(typeof meta === 'object' ? meta : EMPTY_META) };
|
|
78
149
|
});
|
|
79
150
|
}
|
|
80
151
|
async searchChunks(query, limit, project, threshold) {
|
|
@@ -115,7 +186,7 @@ class SearcherService {
|
|
|
115
186
|
}
|
|
116
187
|
catch (err) {
|
|
117
188
|
this.logger.warn('SearcherService: rerank failed, falling back to vector order', {
|
|
118
|
-
reason: err.message,
|
|
189
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
119
190
|
ms: Date.now() - t0,
|
|
120
191
|
});
|
|
121
192
|
return candidates.slice(0, limit);
|
|
@@ -168,6 +239,8 @@ class SearcherService {
|
|
|
168
239
|
if (pairs.length === 0)
|
|
169
240
|
return new Map();
|
|
170
241
|
const n = pairs.length;
|
|
242
|
+
// Use proportional limit: 50 imports + 50 usedBy per file
|
|
243
|
+
const proportionalLimit = Math.max(2000, n * 100);
|
|
171
244
|
const firstHalf = pairs.map((_, i) => `($${i * 2 + 1}::text, $${i * 2 + 2}::text)`).join(', ');
|
|
172
245
|
const secondHalf = pairs.map((_, i) => `($${n * 2 + i * 2 + 1}::text, $${n * 2 + i * 2 + 2}::text)`).join(', ');
|
|
173
246
|
const params = [
|
|
@@ -184,7 +257,7 @@ class SearcherService {
|
|
|
184
257
|
FROM (VALUES ${secondHalf}) AS r(fp, proj)
|
|
185
258
|
JOIN code_dependency d ON d."toFile" = r.fp AND d.project = r.proj
|
|
186
259
|
|
|
187
|
-
LIMIT
|
|
260
|
+
LIMIT ${proportionalLimit}`, params);
|
|
188
261
|
const ctx = new Map();
|
|
189
262
|
for (const { filePath } of pairs)
|
|
190
263
|
ctx.set(filePath, { imports: new Set(), usedBy: new Set() });
|
|
@@ -227,7 +300,7 @@ class SearcherService {
|
|
|
227
300
|
FROM code_dependency
|
|
228
301
|
WHERE "fromFile" = $1 AND project = $2
|
|
229
302
|
|
|
230
|
-
UNION
|
|
303
|
+
UNION ALL
|
|
231
304
|
|
|
232
305
|
SELECT d."toFile", deps.depth + 1, deps."toFile" AS via
|
|
233
306
|
FROM code_dependency d
|
|
@@ -270,7 +343,7 @@ class SearcherService {
|
|
|
270
343
|
FROM code_dependency
|
|
271
344
|
WHERE "toFile" = $1 AND project = $2
|
|
272
345
|
|
|
273
|
-
UNION
|
|
346
|
+
UNION ALL
|
|
274
347
|
|
|
275
348
|
SELECT d."fromFile", rdeps.depth + 1, rdeps."fromFile" AS via
|
|
276
349
|
FROM code_dependency d
|
|
@@ -308,25 +381,23 @@ class SearcherService {
|
|
|
308
381
|
this.logger.debug('SearcherService: getCallers result', { count: rows.length, ms: Date.now() - t0 });
|
|
309
382
|
return rows;
|
|
310
383
|
}
|
|
311
|
-
grepSymbol(symbol, project, excludeFile) {
|
|
384
|
+
async grepSymbol(symbol, project, excludeFile) {
|
|
312
385
|
const t0 = Date.now();
|
|
313
386
|
this.logger.debug('SearcherService: grepSymbol', { symbol, project, excludeFile });
|
|
314
|
-
const projectDir =
|
|
315
|
-
const result =
|
|
387
|
+
const projectDir = this.resolveProjectRoot(project);
|
|
388
|
+
const result = await this.spawnGrep([
|
|
316
389
|
'-rn', '-F', '-w',
|
|
317
390
|
'-m', String(GREP_RAW_LIMIT),
|
|
318
391
|
'--include=*.ts', '--include=*.tsx', '--include=*.js', '--include=*.jsx',
|
|
319
392
|
...GREP_EXCLUDE_DIRS,
|
|
320
393
|
symbol,
|
|
321
394
|
projectDir,
|
|
322
|
-
]
|
|
323
|
-
if (
|
|
324
|
-
if (result.error)
|
|
325
|
-
this.logger.error('SearcherService: grep failed', { method: 'grepSymbol', reason: result.error.message });
|
|
395
|
+
]);
|
|
396
|
+
if (!result) {
|
|
326
397
|
return { matches: [], truncated: false };
|
|
327
398
|
}
|
|
328
399
|
const excludeAbsPath = path.join(this.config.workspaceRoot, excludeFile);
|
|
329
|
-
const all = result.
|
|
400
|
+
const all = result.split('\n').flatMap(line => {
|
|
330
401
|
const match = line.match(/^(.+?):(\d+):/);
|
|
331
402
|
if (!match)
|
|
332
403
|
return [];
|
|
@@ -345,7 +416,7 @@ class SearcherService {
|
|
|
345
416
|
if (!/^[\w$]+$/.test(name))
|
|
346
417
|
throw new Error('Invalid symbol name');
|
|
347
418
|
if (kind === 'method')
|
|
348
|
-
return
|
|
419
|
+
return this.grepFindSymbol(name, project, kind);
|
|
349
420
|
return this.findSymbolFromIndex(name, project, kind);
|
|
350
421
|
}
|
|
351
422
|
async findSymbolFromIndex(name, project, kind) {
|
|
@@ -385,29 +456,25 @@ class SearcherService {
|
|
|
385
456
|
return this.grepFindSymbol(name, project, kind);
|
|
386
457
|
}
|
|
387
458
|
}
|
|
388
|
-
grepFindSymbol(name, project, kind) {
|
|
459
|
+
async grepFindSymbol(name, project, kind) {
|
|
389
460
|
const t0 = Date.now();
|
|
390
461
|
this.logger.debug('SearcherService: findSymbol', { name, project, kind, source: 'grep' });
|
|
391
462
|
const searchDir = project
|
|
392
|
-
?
|
|
463
|
+
? this.resolveProjectRoot(project)
|
|
393
464
|
: this.config.workspaceRoot;
|
|
394
465
|
const pattern = this.buildFindSymbolPattern(name, kind);
|
|
395
|
-
const result =
|
|
466
|
+
const result = await this.spawnGrep([
|
|
396
467
|
'-rn', '-E',
|
|
397
468
|
'-m', String(FIND_SYMBOL_RAW_LIMIT),
|
|
398
469
|
'--include=*.ts', '--include=*.tsx', '--include=*.js', '--include=*.jsx',
|
|
399
470
|
...GREP_EXCLUDE_DIRS,
|
|
400
471
|
pattern,
|
|
401
472
|
searchDir,
|
|
402
|
-
]
|
|
403
|
-
if (result
|
|
404
|
-
this.logger.error('SearcherService: grep failed', { method: 'findSymbol', reason: result.error.message });
|
|
405
|
-
return { hits: [], truncated: false };
|
|
406
|
-
}
|
|
407
|
-
if (!result.stdout)
|
|
473
|
+
]);
|
|
474
|
+
if (!result)
|
|
408
475
|
return { hits: [], truncated: false };
|
|
409
476
|
const hits = [];
|
|
410
|
-
for (const raw of result.
|
|
477
|
+
for (const raw of result.split('\n')) {
|
|
411
478
|
if (hits.length >= FIND_SYMBOL_LIMIT)
|
|
412
479
|
break;
|
|
413
480
|
const m = raw.match(/^(.+?):(\d+):(.*)$/);
|
|
@@ -434,7 +501,7 @@ class SearcherService {
|
|
|
434
501
|
listFiles(relativePath, project, depth, pattern) {
|
|
435
502
|
const t0 = Date.now();
|
|
436
503
|
this.logger.debug('SearcherService: listFiles', { relativePath, project, depth, pattern });
|
|
437
|
-
const projectRoot =
|
|
504
|
+
const projectRoot = this.resolveProjectRoot(project);
|
|
438
505
|
const resolvedTarget = path.resolve(projectRoot, relativePath);
|
|
439
506
|
const resolvedProject = path.resolve(projectRoot);
|
|
440
507
|
if (resolvedTarget !== resolvedProject && !resolvedTarget.startsWith(resolvedProject + path.sep)) {
|
|
@@ -512,24 +579,33 @@ class SearcherService {
|
|
|
512
579
|
let lineNo = 0;
|
|
513
580
|
let lastRead = 0;
|
|
514
581
|
let hasMore = false;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
582
|
+
try {
|
|
583
|
+
for await (const line of rl) {
|
|
584
|
+
lineNo++;
|
|
585
|
+
if (lineNo >= startLine && lineNo <= endLine)
|
|
586
|
+
lines.push(line);
|
|
587
|
+
if (lineNo <= endLine)
|
|
588
|
+
lastRead = lineNo;
|
|
589
|
+
if (lineNo > endLine) {
|
|
590
|
+
hasMore = true;
|
|
591
|
+
rl.close();
|
|
592
|
+
stream.destroy();
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
526
595
|
}
|
|
527
596
|
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
rl.close();
|
|
599
|
+
stream.destroy();
|
|
600
|
+
throw err;
|
|
601
|
+
}
|
|
528
602
|
this.logger.debug('SearcherService: readFileRange result', { linesRead: lines.length, hasMore, ms: Date.now() - t0 });
|
|
529
603
|
return { ok: true, filePath, startLine, endLine: Math.min(endLine, lastRead), lines, hasMore };
|
|
530
604
|
}
|
|
531
605
|
isNonEnglish(text) {
|
|
532
|
-
|
|
606
|
+
// Use Unicode script detection: check for CJK, Cyrillic, Arabic, Hebrew, Thai, etc.
|
|
607
|
+
// Excludes common non-ASCII punctuation (smart quotes, em-dashes, accented Latin chars)
|
|
608
|
+
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Cyrillic}\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Thai}\p{Script=Devanagari}]/u.test(text);
|
|
533
609
|
}
|
|
534
610
|
walkDir(absDir, currentDepth, maxDepth, pattern, lines) {
|
|
535
611
|
let entries;
|
|
@@ -590,5 +666,40 @@ class SearcherService {
|
|
|
590
666
|
return 'method';
|
|
591
667
|
return 'variable';
|
|
592
668
|
}
|
|
669
|
+
/**
|
|
670
|
+
* Async wrapper around grep to avoid blocking the event loop.
|
|
671
|
+
* Returns stdout as a string, or null if grep returned no results or errored.
|
|
672
|
+
*/
|
|
673
|
+
spawnGrep(args) {
|
|
674
|
+
return new Promise((resolve) => {
|
|
675
|
+
const proc = (0, child_process_1.spawn)('grep', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
676
|
+
const chunks = [];
|
|
677
|
+
let totalBytes = 0;
|
|
678
|
+
const MAX_BUFFER = 10 * 1024 * 1024;
|
|
679
|
+
proc.stdout.on('data', (chunk) => {
|
|
680
|
+
totalBytes += chunk.length;
|
|
681
|
+
if (totalBytes <= MAX_BUFFER) {
|
|
682
|
+
chunks.push(chunk);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
proc.stderr.on('data', () => { });
|
|
686
|
+
proc.on('error', (err) => {
|
|
687
|
+
this.logger.error('SearcherService: grep spawn failed', { reason: err.message });
|
|
688
|
+
resolve(null);
|
|
689
|
+
});
|
|
690
|
+
proc.on('close', (code) => {
|
|
691
|
+
// grep returns 1 for "no matches" — not an error
|
|
692
|
+
if (code !== 0 && code !== 1) {
|
|
693
|
+
this.logger.warn('SearcherService: grep exited with code', { code });
|
|
694
|
+
}
|
|
695
|
+
if (chunks.length === 0) {
|
|
696
|
+
resolve(null);
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
}
|
|
593
704
|
}
|
|
594
705
|
exports.SearcherService = SearcherService;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Config } from '../config';
|
|
2
|
+
import type { ISearcher } from '../interfaces';
|
|
3
|
+
import type { IEmbedder } from '../interfaces';
|
|
4
|
+
import type { ILogger } from '../interfaces';
|
|
5
|
+
import { type JsonConfig } from '../config-store';
|
|
6
|
+
/**
|
|
7
|
+
* Per-field choice lists for the config form. `strict` → render a dropdown that
|
|
8
|
+
* only accepts a listed value (e.g. the embedding model, tied to the vector
|
|
9
|
+
* dimension). Non-strict → a combobox: the values are suggestions but free text
|
|
10
|
+
* is still allowed (e.g. any Ollama model the user has pulled).
|
|
11
|
+
*/
|
|
12
|
+
export interface FieldOption {
|
|
13
|
+
values: string[];
|
|
14
|
+
strict: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Placeholder shown to the browser instead of the real DB password. */
|
|
17
|
+
export declare const PASSWORD_MASK = "********";
|
|
18
|
+
/**
|
|
19
|
+
* Merge a config submitted from the browser with the stored one. If the
|
|
20
|
+
* password field still holds the mask (i.e. the user did not change it),
|
|
21
|
+
* preserve the previously stored password rather than overwriting it with the
|
|
22
|
+
* placeholder. Pure — no I/O. Exported for unit testing.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mergeConfigForSave(incoming: JsonConfig, existing: JsonConfig | null): JsonConfig;
|
|
25
|
+
/**
|
|
26
|
+
* Return a copy of the config with the DB password replaced by the mask, so the
|
|
27
|
+
* real secret never reaches the browser. The masked value round-trips through
|
|
28
|
+
* `mergeConfigForSave` on save. Pure — no I/O. Exported for unit testing.
|
|
29
|
+
*/
|
|
30
|
+
export declare function maskConfig(cfg: JsonConfig): JsonConfig;
|
|
31
|
+
/**
|
|
32
|
+
* Local HTTP dashboard. Serves a schema-driven config editor, a project
|
|
33
|
+
* overview, and the dependency graph. Bind to 127.0.0.1 only — no auth.
|
|
34
|
+
*/
|
|
35
|
+
export declare class WebServer {
|
|
36
|
+
private readonly searcher;
|
|
37
|
+
private readonly config;
|
|
38
|
+
private readonly logger;
|
|
39
|
+
private readonly embedder;
|
|
40
|
+
private server?;
|
|
41
|
+
constructor(searcher: ISearcher, config: Config, logger: ILogger, embedder: IEmbedder);
|
|
42
|
+
start(port: number, host: string): Promise<void>;
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
/** The bound address once started (e.g. `.port` after listen(0)); null otherwise. */
|
|
45
|
+
address(): {
|
|
46
|
+
port: number;
|
|
47
|
+
} | null;
|
|
48
|
+
private handle;
|
|
49
|
+
private maskedConfig;
|
|
50
|
+
private saveConfig;
|
|
51
|
+
/**
|
|
52
|
+
* Choice lists for model fields. The embedding model is strict (only
|
|
53
|
+
* dimension-compatible models); the translate/reranker models are suggested
|
|
54
|
+
* from the user's installed Ollama models but stay free-text.
|
|
55
|
+
*/
|
|
56
|
+
private fieldOptions;
|
|
57
|
+
private installedModels;
|
|
58
|
+
/** Tabbed graph views (Workspace + one per nested Nx repo). */
|
|
59
|
+
private graphViews;
|
|
60
|
+
/** `/api/graph` — the Workspace view's raw `{ nodes, edges }` (back-compat). */
|
|
61
|
+
private graphData;
|
|
62
|
+
/**
|
|
63
|
+
* The full interactive graph page — the same HTML as the standalone
|
|
64
|
+
* `project-graph.html` (inner tabs, filters, legend, project toggles). The
|
|
65
|
+
* dashboard's Graph tab embeds this via an iframe rather than re-implementing
|
|
66
|
+
* vis-network.
|
|
67
|
+
*/
|
|
68
|
+
private graphPage;
|
|
69
|
+
private readBody;
|
|
70
|
+
private json;
|
|
71
|
+
private html;
|
|
72
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.WebServer = exports.PASSWORD_MASK = void 0;
|
|
37
|
+
exports.mergeConfigForSave = mergeConfigForSave;
|
|
38
|
+
exports.maskConfig = maskConfig;
|
|
39
|
+
const http = __importStar(require("http"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const config_store_1 = require("../config-store");
|
|
42
|
+
const constants_1 = require("../constants");
|
|
43
|
+
const graph_1 = require("../graph");
|
|
44
|
+
const dashboard_1 = require("../web/dashboard");
|
|
45
|
+
/** Placeholder shown to the browser instead of the real DB password. */
|
|
46
|
+
exports.PASSWORD_MASK = '********';
|
|
47
|
+
const MAX_BODY = 1_000_000; // 1 MB — config payloads are tiny
|
|
48
|
+
/**
|
|
49
|
+
* Merge a config submitted from the browser with the stored one. If the
|
|
50
|
+
* password field still holds the mask (i.e. the user did not change it),
|
|
51
|
+
* preserve the previously stored password rather than overwriting it with the
|
|
52
|
+
* placeholder. Pure — no I/O. Exported for unit testing.
|
|
53
|
+
*/
|
|
54
|
+
function mergeConfigForSave(incoming, existing) {
|
|
55
|
+
const merged = JSON.parse(JSON.stringify(incoming ?? {}));
|
|
56
|
+
if (merged.db && merged.db.password === exports.PASSWORD_MASK) {
|
|
57
|
+
const prev = existing?.db?.password;
|
|
58
|
+
if (prev !== undefined)
|
|
59
|
+
merged.db.password = prev;
|
|
60
|
+
else
|
|
61
|
+
delete merged.db.password;
|
|
62
|
+
}
|
|
63
|
+
return merged;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Return a copy of the config with the DB password replaced by the mask, so the
|
|
67
|
+
* real secret never reaches the browser. The masked value round-trips through
|
|
68
|
+
* `mergeConfigForSave` on save. Pure — no I/O. Exported for unit testing.
|
|
69
|
+
*/
|
|
70
|
+
function maskConfig(cfg) {
|
|
71
|
+
if (!cfg.db?.password)
|
|
72
|
+
return cfg;
|
|
73
|
+
return { ...cfg, db: { ...cfg.db, password: exports.PASSWORD_MASK } };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Local HTTP dashboard. Serves a schema-driven config editor, a project
|
|
77
|
+
* overview, and the dependency graph. Bind to 127.0.0.1 only — no auth.
|
|
78
|
+
*/
|
|
79
|
+
class WebServer {
|
|
80
|
+
searcher;
|
|
81
|
+
config;
|
|
82
|
+
logger;
|
|
83
|
+
embedder;
|
|
84
|
+
server;
|
|
85
|
+
constructor(searcher, config, logger, embedder) {
|
|
86
|
+
this.searcher = searcher;
|
|
87
|
+
this.config = config;
|
|
88
|
+
this.logger = logger;
|
|
89
|
+
this.embedder = embedder;
|
|
90
|
+
}
|
|
91
|
+
async start(port, host) {
|
|
92
|
+
this.server = http.createServer((req, res) => {
|
|
93
|
+
this.handle(req, res).catch(err => {
|
|
94
|
+
this.logger.error('WebServer: request failed', { reason: err instanceof Error ? err.message : String(err) });
|
|
95
|
+
if (!res.headersSent) {
|
|
96
|
+
res.writeHead(500);
|
|
97
|
+
res.end('Internal error');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
this.server.once('error', reject);
|
|
103
|
+
this.server.listen(port, host, () => resolve());
|
|
104
|
+
});
|
|
105
|
+
this.logger.info('WebServer: listening', { host, port });
|
|
106
|
+
}
|
|
107
|
+
async stop() {
|
|
108
|
+
if (!this.server)
|
|
109
|
+
return;
|
|
110
|
+
await new Promise(resolve => this.server.close(() => resolve()));
|
|
111
|
+
this.server = undefined;
|
|
112
|
+
}
|
|
113
|
+
/** The bound address once started (e.g. `.port` after listen(0)); null otherwise. */
|
|
114
|
+
address() {
|
|
115
|
+
const a = this.server?.address();
|
|
116
|
+
return a && typeof a === 'object' ? { port: a.port } : null;
|
|
117
|
+
}
|
|
118
|
+
async handle(req, res) {
|
|
119
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
120
|
+
const p = url.pathname;
|
|
121
|
+
const m = req.method ?? 'GET';
|
|
122
|
+
if (m === 'GET' && p === '/')
|
|
123
|
+
return this.html(res, (0, dashboard_1.renderDashboardHtml)());
|
|
124
|
+
if (m === 'GET' && p === '/api/schema')
|
|
125
|
+
return this.json(res, 200, (0, config_store_1.buildJsonSchema)());
|
|
126
|
+
if (m === 'GET' && p === '/api/defaults')
|
|
127
|
+
return this.json(res, 200, config_store_1.CONFIG_DEFAULTS);
|
|
128
|
+
if (m === 'GET' && p === '/api/field-options')
|
|
129
|
+
return this.json(res, 200, await this.fieldOptions());
|
|
130
|
+
if (m === 'GET' && p === '/api/config')
|
|
131
|
+
return this.json(res, 200, this.maskedConfig());
|
|
132
|
+
if (m === 'PUT' && p === '/api/config')
|
|
133
|
+
return this.saveConfig(req, res);
|
|
134
|
+
if (m === 'GET' && p === '/api/projects')
|
|
135
|
+
return this.json(res, 200, await this.searcher.listProjects());
|
|
136
|
+
if (m === 'GET' && p === '/api/graph')
|
|
137
|
+
return this.json(res, 200, await this.graphData());
|
|
138
|
+
if (m === 'GET' && p === '/graph')
|
|
139
|
+
return this.html(res, await this.graphPage());
|
|
140
|
+
this.json(res, 404, { error: 'Not found' });
|
|
141
|
+
}
|
|
142
|
+
maskedConfig() {
|
|
143
|
+
return maskConfig((0, config_store_1.readConfigJson)() ?? {});
|
|
144
|
+
}
|
|
145
|
+
async saveConfig(req, res) {
|
|
146
|
+
let body;
|
|
147
|
+
try {
|
|
148
|
+
body = await this.readBody(req);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
// Distinguish the size limit from other stream errors.
|
|
152
|
+
return this.json(res, 413, { error: e instanceof Error ? e.message : 'Request body too large' });
|
|
153
|
+
}
|
|
154
|
+
let incoming;
|
|
155
|
+
try {
|
|
156
|
+
incoming = JSON.parse(body);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return this.json(res, 400, { error: 'Invalid JSON body' });
|
|
160
|
+
}
|
|
161
|
+
const merged = mergeConfigForSave(incoming, (0, config_store_1.readConfigJson)());
|
|
162
|
+
let validated;
|
|
163
|
+
try {
|
|
164
|
+
validated = (0, config_store_1.parseConfig)(merged);
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
return this.json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
168
|
+
}
|
|
169
|
+
(0, config_store_1.writeConfigJson)(validated);
|
|
170
|
+
this.logger.info('WebServer: config updated');
|
|
171
|
+
this.json(res, 200, { ok: true });
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Choice lists for model fields. The embedding model is strict (only
|
|
175
|
+
* dimension-compatible models); the translate/reranker models are suggested
|
|
176
|
+
* from the user's installed Ollama models but stay free-text.
|
|
177
|
+
*/
|
|
178
|
+
async fieldOptions() {
|
|
179
|
+
const installed = await this.installedModels();
|
|
180
|
+
return {
|
|
181
|
+
'ollama.model': { values: [...constants_1.SUPPORTED_EMBED_MODELS], strict: true },
|
|
182
|
+
'search.translateModel': { values: installed, strict: false },
|
|
183
|
+
'reranker.model': { values: installed, strict: false },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async installedModels() {
|
|
187
|
+
try {
|
|
188
|
+
return (await this.embedder.listModels?.()) ?? [];
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
this.logger.warn('WebServer: could not list Ollama models', { reason: err instanceof Error ? err.message : String(err) });
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Tabbed graph views (Workspace + one per nested Nx repo). */
|
|
196
|
+
async graphViews() {
|
|
197
|
+
const projects = (await this.searcher.listProjects()).map(p => ({
|
|
198
|
+
name: p.serviceName,
|
|
199
|
+
// Real project root (resolved via discovery — handles nested-monorepo
|
|
200
|
+
// names). Fall back to the flat assumption if listProjects omits it.
|
|
201
|
+
path: p.path ?? path.join(this.config.workspaceRoot, p.serviceName),
|
|
202
|
+
}));
|
|
203
|
+
const projectEdges = await this.searcher.getProjectEdges();
|
|
204
|
+
return (0, graph_1.buildGraphViews)(projects, projectEdges, this.config.workspaceRoot);
|
|
205
|
+
}
|
|
206
|
+
/** `/api/graph` — the Workspace view's raw `{ nodes, edges }` (back-compat). */
|
|
207
|
+
async graphData() {
|
|
208
|
+
const views = await this.graphViews();
|
|
209
|
+
return { nodes: views[0].nodes, edges: views[0].edges };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The full interactive graph page — the same HTML as the standalone
|
|
213
|
+
* `project-graph.html` (inner tabs, filters, legend, project toggles). The
|
|
214
|
+
* dashboard's Graph tab embeds this via an iframe rather than re-implementing
|
|
215
|
+
* vis-network.
|
|
216
|
+
*/
|
|
217
|
+
async graphPage() {
|
|
218
|
+
return (0, graph_1.buildHtml)(await this.graphViews());
|
|
219
|
+
}
|
|
220
|
+
readBody(req) {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
let data = '';
|
|
223
|
+
let tooLarge = false;
|
|
224
|
+
req.on('data', chunk => {
|
|
225
|
+
if (tooLarge)
|
|
226
|
+
return; // stop growing `data`; keep the socket alive so the 413 flushes
|
|
227
|
+
data += chunk;
|
|
228
|
+
if (data.length > MAX_BODY) {
|
|
229
|
+
tooLarge = true;
|
|
230
|
+
reject(new Error('Request body too large (max 1 MB)'));
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
req.on('end', () => resolve(data));
|
|
234
|
+
req.on('error', reject);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
json(res, status, body) {
|
|
238
|
+
const s = JSON.stringify(body);
|
|
239
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(s) });
|
|
240
|
+
res.end(s);
|
|
241
|
+
}
|
|
242
|
+
html(res, body) {
|
|
243
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
244
|
+
res.end(body);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
exports.WebServer = WebServer;
|