codesearch-mcp 0.0.17-alpha → 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.
Files changed (83) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +30 -26
  3. package/dist/app.js +34 -10
  4. package/dist/src/commands/ConfigCommand.d.ts +1 -0
  5. package/dist/src/commands/ConfigCommand.js +84 -4
  6. package/dist/src/commands/IndexCommand.js +22 -7
  7. package/dist/src/commands/McpCommand.js +4 -3
  8. package/dist/src/commands/WebCommand.d.ts +8 -0
  9. package/dist/src/commands/WebCommand.js +56 -0
  10. package/dist/src/config-store.d.ts +43 -0
  11. package/dist/src/config-store.js +92 -27
  12. package/dist/src/config.js +16 -17
  13. package/dist/src/constants.d.ts +2 -1
  14. package/dist/src/constants.js +6 -4
  15. package/dist/src/container.d.ts +10 -6
  16. package/dist/src/container.js +6 -3
  17. package/dist/src/graph.d.ts +60 -0
  18. package/dist/src/graph.js +239 -120
  19. package/dist/src/help.js +15 -1
  20. package/dist/src/interfaces/IAstParser.d.ts +6 -3
  21. package/dist/src/interfaces/IDatabase.d.ts +13 -0
  22. package/dist/src/interfaces/IDatabase.js +2 -0
  23. package/dist/src/interfaces/IEmbedder.d.ts +1 -0
  24. package/dist/src/interfaces/ILogger.d.ts +10 -0
  25. package/dist/src/interfaces/ILogger.js +2 -0
  26. package/dist/src/interfaces/IMcpToolHandler.d.ts +22 -0
  27. package/dist/src/interfaces/IMcpToolHandler.js +2 -0
  28. package/dist/src/interfaces/IMonorepoResolver.d.ts +33 -0
  29. package/dist/src/interfaces/IMonorepoResolver.js +2 -0
  30. package/dist/src/interfaces/IProjectDiscovery.d.ts +28 -0
  31. package/dist/src/interfaces/IProjectDiscovery.js +2 -0
  32. package/dist/src/interfaces/ISearcher.d.ts +36 -0
  33. package/dist/src/interfaces/ISearcher.js +2 -0
  34. package/dist/src/interfaces/index.d.ts +7 -0
  35. package/dist/src/interfaces/index.js +2 -0
  36. package/dist/src/project-selector.d.ts +1 -1
  37. package/dist/src/services/AstParserService.d.ts +4 -6
  38. package/dist/src/services/AstParserService.js +29 -13
  39. package/dist/src/services/DatabaseService.d.ts +2 -1
  40. package/dist/src/services/IndexerService.d.ts +6 -8
  41. package/dist/src/services/IndexerService.js +5 -8
  42. package/dist/src/services/LoggerService.d.ts +2 -1
  43. package/dist/src/services/McpServer.d.ts +16 -7
  44. package/dist/src/services/McpServer.js +54 -367
  45. package/dist/src/services/MonorepoAwareDiscoveryService.d.ts +62 -0
  46. package/dist/src/services/MonorepoAwareDiscoveryService.js +125 -0
  47. package/dist/src/services/NxMonorepoResolver.d.ts +45 -0
  48. package/dist/src/services/NxMonorepoResolver.js +308 -0
  49. package/dist/src/services/OllamaEmbedder.d.ts +5 -2
  50. package/dist/src/services/OllamaEmbedder.js +9 -2
  51. package/dist/src/services/ProjectDiscoveryService.d.ts +8 -0
  52. package/dist/src/services/ProjectDiscoveryService.js +52 -0
  53. package/dist/src/services/SearcherService.d.ts +39 -6
  54. package/dist/src/services/SearcherService.js +151 -40
  55. package/dist/src/services/WebServerService.d.ts +72 -0
  56. package/dist/src/services/WebServerService.js +247 -0
  57. package/dist/src/setup.js +7 -7
  58. package/dist/src/tools/FindSymbolTool.d.ts +8 -0
  59. package/dist/src/tools/FindSymbolTool.js +45 -0
  60. package/dist/src/tools/GetCallersTool.d.ts +8 -0
  61. package/dist/src/tools/GetCallersTool.js +42 -0
  62. package/dist/src/tools/GetDependenciesTool.d.ts +8 -0
  63. package/dist/src/tools/GetDependenciesTool.js +38 -0
  64. package/dist/src/tools/GetDependentsTool.d.ts +8 -0
  65. package/dist/src/tools/GetDependentsTool.js +57 -0
  66. package/dist/src/tools/ListFilesTool.d.ts +8 -0
  67. package/dist/src/tools/ListFilesTool.js +39 -0
  68. package/dist/src/tools/ListProjectsTool.d.ts +8 -0
  69. package/dist/src/tools/ListProjectsTool.js +44 -0
  70. package/dist/src/tools/ReadFileRangeTool.d.ts +8 -0
  71. package/dist/src/tools/ReadFileRangeTool.js +44 -0
  72. package/dist/src/tools/ReadFileTool.d.ts +8 -0
  73. package/dist/src/tools/ReadFileTool.js +42 -0
  74. package/dist/src/tools/SearchCodebaseTool.d.ts +13 -0
  75. package/dist/src/tools/SearchCodebaseTool.js +81 -0
  76. package/dist/src/tools/format-helpers.d.ts +8 -0
  77. package/dist/src/tools/format-helpers.js +57 -0
  78. package/dist/src/tools/index.d.ts +9 -0
  79. package/dist/src/tools/index.js +21 -0
  80. package/dist/src/web/dashboard.d.ts +9 -0
  81. package/dist/src/web/dashboard.js +337 -0
  82. package/docs/web-dashboard.md +187 -0
  83. 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 LoggerService_1 = require("./LoggerService");
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 = LoggerService_1.LoggerService.silent()) {
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 meta = (0, file_utils_1.readRagMeta)(path.join(this.config.workspaceRoot, r.serviceName));
77
- return { ...r, ...(typeof meta === 'object' ? meta : EMPTY_META) };
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 2000`, params);
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 = path.join(this.config.workspaceRoot, project);
315
- const result = (0, child_process_1.spawnSync)('grep', [
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
- ], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
323
- if (result.error || !result.stdout) {
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.stdout.split('\n').flatMap(line => {
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 Promise.resolve(this.grepFindSymbol(name, project, kind));
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
- ? path.join(this.config.workspaceRoot, project)
463
+ ? this.resolveProjectRoot(project)
393
464
  : this.config.workspaceRoot;
394
465
  const pattern = this.buildFindSymbolPattern(name, kind);
395
- const result = (0, child_process_1.spawnSync)('grep', [
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
- ], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
403
- if (result.error) {
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.stdout.split('\n')) {
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 = path.join(this.config.workspaceRoot, project);
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
- for await (const line of rl) {
516
- lineNo++;
517
- if (lineNo >= startLine && lineNo <= endLine)
518
- lines.push(line);
519
- if (lineNo <= endLine)
520
- lastRead = lineNo;
521
- if (lineNo > endLine) {
522
- hasMore = true;
523
- rl.close();
524
- stream.destroy();
525
- break;
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
- return /[^\x00-\x7F]/.test(text);
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;