gitnexus 1.2.7 → 1.2.9

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.
@@ -228,8 +228,10 @@ export class LocalBackend {
228
228
  switch (method) {
229
229
  case 'query':
230
230
  return this.query(repo, params);
231
- case 'cypher':
232
- return this.cypher(repo, params);
231
+ case 'cypher': {
232
+ const raw = await this.cypher(repo, params);
233
+ return this.formatCypherAsMarkdown(raw);
234
+ }
233
235
  case 'context':
234
236
  return this.context(repo, params);
235
237
  case 'impact':
@@ -326,16 +328,18 @@ export class LocalBackend {
326
328
  `);
327
329
  }
328
330
  catch { /* symbol might not be in any process */ }
329
- // Get cluster cohesion as internal ranking signal (never exposed)
331
+ // Get cluster membership + cohesion (cohesion used as internal ranking signal)
330
332
  let cohesion = 0;
333
+ let module;
331
334
  try {
332
335
  const cohesionRows = await executeQuery(repo.id, `
333
336
  MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
334
- RETURN c.cohesion AS cohesion
337
+ RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
335
338
  LIMIT 1
336
339
  `);
337
340
  if (cohesionRows.length > 0) {
338
341
  cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
342
+ module = cohesionRows[0].module ?? cohesionRows[0][1];
339
343
  }
340
344
  }
341
345
  catch { /* no cluster info */ }
@@ -360,6 +364,7 @@ export class LocalBackend {
360
364
  filePath: sym.filePath,
361
365
  startLine: sym.startLine,
362
366
  endLine: sym.endLine,
367
+ ...(module ? { module } : {}),
363
368
  ...(includeContent && content ? { content } : {}),
364
369
  };
365
370
  if (processRows.length === 0) {
@@ -497,6 +502,10 @@ export class LocalBackend {
497
502
  */
498
503
  async semanticSearch(repo, query, limit) {
499
504
  try {
505
+ // Check if embedding table exists before loading the model (avoids heavy model init when embeddings are off)
506
+ const tableCheck = await executeQuery(repo.id, `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`);
507
+ if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0)
508
+ return [];
500
509
  const queryVec = await embedQuery(query);
501
510
  const dims = getEmbeddingDims();
502
511
  const queryVecStr = `[${queryVec.join(',')}]`;
@@ -544,11 +553,15 @@ export class LocalBackend {
544
553
  }
545
554
  return results;
546
555
  }
547
- catch (err) {
548
- console.error('GitNexus: Semantic search unavailable -', err.message);
556
+ catch {
557
+ // Expected when embeddings are disabled — silently fall back to BM25-only
549
558
  return [];
550
559
  }
551
560
  }
561
+ async executeCypher(repoName, query) {
562
+ const repo = await this.resolveRepo(repoName);
563
+ return this.cypher(repo, { query });
564
+ }
552
565
  async cypher(repo, params) {
553
566
  await this.ensureInitialized(repo.id);
554
567
  if (!isKuzuReady(repo.id)) {
@@ -562,6 +575,34 @@ export class LocalBackend {
562
575
  return { error: err.message || 'Query failed' };
563
576
  }
564
577
  }
578
+ /**
579
+ * Format raw Cypher result rows as a markdown table for LLM readability.
580
+ * Falls back to raw result if rows aren't tabular objects.
581
+ */
582
+ formatCypherAsMarkdown(result) {
583
+ if (!Array.isArray(result) || result.length === 0)
584
+ return result;
585
+ const firstRow = result[0];
586
+ if (typeof firstRow !== 'object' || firstRow === null)
587
+ return result;
588
+ const keys = Object.keys(firstRow);
589
+ if (keys.length === 0)
590
+ return result;
591
+ const header = '| ' + keys.join(' | ') + ' |';
592
+ const separator = '| ' + keys.map(() => '---').join(' | ') + ' |';
593
+ const dataRows = result.map((row) => '| ' + keys.map(k => {
594
+ const v = row[k];
595
+ if (v === null || v === undefined)
596
+ return '';
597
+ if (typeof v === 'object')
598
+ return JSON.stringify(v);
599
+ return String(v);
600
+ }).join(' | ') + ' |');
601
+ return {
602
+ markdown: [header, separator, ...dataRows].join('\n'),
603
+ row_count: result.length,
604
+ };
605
+ }
565
606
  /**
566
607
  * Aggregate same-named clusters: group by heuristicLabel, sum symbols,
567
608
  * weighted-average cohesion, filter out tiny clusters (<5 symbols).
@@ -1151,6 +1192,64 @@ export class LocalBackend {
1151
1192
  grouped[item.depth] = [];
1152
1193
  grouped[item.depth].push(item);
1153
1194
  }
1195
+ // ── Enrichment: affected processes, modules, risk ──────────────
1196
+ const directCount = (grouped[1] || []).length;
1197
+ let affectedProcesses = [];
1198
+ let affectedModules = [];
1199
+ if (impacted.length > 0) {
1200
+ const allIds = impacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', ');
1201
+ const d1Ids = (grouped[1] || []).map((i) => `'${i.id.replace(/'/g, "''")}'`).join(', ');
1202
+ // Affected processes: which execution flows are broken and at which step
1203
+ const [processRows, moduleRows, directModuleRows] = await Promise.all([
1204
+ executeQuery(repo.id, `
1205
+ MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
1206
+ WHERE s.id IN [${allIds}]
1207
+ RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount
1208
+ ORDER BY hits DESC
1209
+ LIMIT 20
1210
+ `).catch(() => []),
1211
+ executeQuery(repo.id, `
1212
+ MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1213
+ WHERE s.id IN [${allIds}]
1214
+ RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
1215
+ ORDER BY hits DESC
1216
+ LIMIT 20
1217
+ `).catch(() => []),
1218
+ d1Ids ? executeQuery(repo.id, `
1219
+ MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1220
+ WHERE s.id IN [${d1Ids}]
1221
+ RETURN DISTINCT c.heuristicLabel AS name
1222
+ `).catch(() => []) : Promise.resolve([]),
1223
+ ]);
1224
+ affectedProcesses = processRows.map((r) => ({
1225
+ name: r.name || r[0],
1226
+ hits: r.hits || r[1],
1227
+ broken_at_step: r.minStep ?? r[2],
1228
+ step_count: r.stepCount ?? r[3],
1229
+ }));
1230
+ const directModuleSet = new Set(directModuleRows.map((r) => r.name || r[0]));
1231
+ affectedModules = moduleRows.map((r) => {
1232
+ const name = r.name || r[0];
1233
+ return {
1234
+ name,
1235
+ hits: r.hits || r[1],
1236
+ impact: directModuleSet.has(name) ? 'direct' : 'indirect',
1237
+ };
1238
+ });
1239
+ }
1240
+ // Risk scoring
1241
+ const processCount = affectedProcesses.length;
1242
+ const moduleCount = affectedModules.length;
1243
+ let risk = 'LOW';
1244
+ if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impacted.length >= 200) {
1245
+ risk = 'CRITICAL';
1246
+ }
1247
+ else if (directCount >= 15 || processCount >= 3 || moduleCount >= 3 || impacted.length >= 100) {
1248
+ risk = 'HIGH';
1249
+ }
1250
+ else if (directCount >= 5 || impacted.length >= 30) {
1251
+ risk = 'MEDIUM';
1252
+ }
1154
1253
  return {
1155
1254
  target: {
1156
1255
  id: symId,
@@ -1160,6 +1259,14 @@ export class LocalBackend {
1160
1259
  },
1161
1260
  direction,
1162
1261
  impactedCount: impacted.length,
1262
+ risk,
1263
+ summary: {
1264
+ direct: directCount,
1265
+ processes_affected: processCount,
1266
+ modules_affected: moduleCount,
1267
+ },
1268
+ affected_processes: affectedProcesses,
1269
+ affected_modules: affectedModules,
1163
1270
  byDepth: grouped,
1164
1271
  };
1165
1272
  }
package/dist/mcp/tools.js CHANGED
@@ -32,7 +32,7 @@ AFTER THIS: Use context() on a specific symbol for 360-degree view (callers, cal
32
32
 
33
33
  Returns results grouped by process (execution flow):
34
34
  - processes: ranked execution flows with relevance priority
35
- - process_symbols: all symbols in those flows with file locations
35
+ - process_symbols: all symbols in those flows with file locations and module (functional area)
36
36
  - definitions: standalone types/interfaces not in any process
37
37
 
38
38
  Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank Fusion.`,
@@ -74,6 +74,8 @@ EXAMPLES:
74
74
  • Trace a process:
75
75
  MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) WHERE p.heuristicLabel = "UserLogin" RETURN s.name, r.step ORDER BY r.step
76
76
 
77
+ OUTPUT: Returns { markdown, row_count } — results formatted as a Markdown table for easy reading.
78
+
77
79
  TIPS:
78
80
  - All relationships use single CodeRelation table — filter with {type: 'CALLS'} etc.
79
81
  - Community = auto-detected functional area (Leiden algorithm)
@@ -155,10 +157,17 @@ Each edit is tagged with confidence:
155
157
  {
156
158
  name: 'impact',
157
159
  description: `Analyze the blast radius of changing a code symbol.
158
- Returns all symbols affected by modifying the target, grouped by depth with edge types and confidence.
160
+ Returns affected symbols grouped by depth, plus risk assessment, affected execution flows, and affected modules.
159
161
 
160
162
  WHEN TO USE: Before making code changes — especially refactoring, renaming, or modifying shared code. Shows what would break.
161
- AFTER THIS: Review d=1 items (WILL BREAK). READ gitnexus://repo/{name}/processes to check affected execution flows.
163
+ AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols.
164
+
165
+ Output includes:
166
+ - risk: LOW / MEDIUM / HIGH / CRITICAL
167
+ - summary: direct callers, processes affected, modules affected
168
+ - affected_processes: which execution flows break and at which step
169
+ - affected_modules: which functional areas are hit (direct vs indirect)
170
+ - byDepth: all affected symbols grouped by traversal depth
162
171
 
163
172
  Depth groups:
164
173
  - d=1: WILL BREAK (direct callers/importers)
@@ -1,6 +1,8 @@
1
1
  /**
2
- * HTTP API Server
2
+ * HTTP API Server (Multi-Repo)
3
3
  *
4
- * REST API for browser-based clients to query the local .gitnexus/ index.
4
+ * REST API for browser-based clients to query indexed repositories.
5
+ * Uses LocalBackend for multi-repo support via the global registry —
6
+ * the same backend the MCP server uses.
5
7
  */
6
8
  export declare const createServer: (port: number) => Promise<void>;
@@ -1,20 +1,21 @@
1
1
  /**
2
- * HTTP API Server
2
+ * HTTP API Server (Multi-Repo)
3
3
  *
4
- * REST API for browser-based clients to query the local .gitnexus/ index.
4
+ * REST API for browser-based clients to query indexed repositories.
5
+ * Uses LocalBackend for multi-repo support via the global registry —
6
+ * the same backend the MCP server uses.
5
7
  */
6
8
  import express from 'express';
7
9
  import cors from 'cors';
8
10
  import path from 'path';
9
11
  import fs from 'fs/promises';
10
- import { findRepo } from '../storage/repo-manager.js';
11
- import { initKuzu, executeQuery } from '../core/kuzu/kuzu-adapter.js';
12
+ import { LocalBackend } from '../mcp/local/local-backend.js';
12
13
  import { NODE_TABLES } from '../core/kuzu/schema.js';
13
- import { searchFTSFromKuzu } from '../core/search/bm25-index.js';
14
- import { hybridSearch } from '../core/search/hybrid-search.js';
15
- import { semanticSearch } from '../core/embeddings/embedding-pipeline.js';
16
- import { isEmbedderReady } from '../core/embeddings/embedder.js';
17
- const buildGraph = async () => {
14
+ /**
15
+ * Build the full knowledge graph for a repo by querying each node table
16
+ * and all relationships via the backend's cypher tool.
17
+ */
18
+ const buildGraph = async (backend, repoName) => {
18
19
  const nodes = [];
19
20
  for (const table of NODE_TABLES) {
20
21
  try {
@@ -34,7 +35,9 @@ const buildGraph = async () => {
34
35
  else {
35
36
  query = `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`;
36
37
  }
37
- const rows = await executeQuery(query);
38
+ const result = await backend.executeCypher(repoName, query);
39
+ // cypher returns the rows directly (array), or { error } on failure
40
+ const rows = Array.isArray(result) ? result : [];
38
41
  for (const row of rows) {
39
42
  nodes.push({
40
43
  id: row.id ?? row[0],
@@ -62,95 +65,262 @@ const buildGraph = async () => {
62
65
  }
63
66
  }
64
67
  const relationships = [];
65
- const relRows = await executeQuery(`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`);
66
- for (const row of relRows) {
67
- relationships.push({
68
- id: `${row.sourceId}_${row.type}_${row.targetId}`,
69
- type: row.type,
70
- sourceId: row.sourceId,
71
- targetId: row.targetId,
72
- confidence: row.confidence,
73
- reason: row.reason,
74
- step: row.step,
75
- });
68
+ try {
69
+ const relResult = await backend.executeCypher(repoName, `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`);
70
+ const relRows = Array.isArray(relResult) ? relResult : [];
71
+ for (const row of relRows) {
72
+ relationships.push({
73
+ id: `${row.sourceId}_${row.type}_${row.targetId}`,
74
+ type: row.type,
75
+ sourceId: row.sourceId,
76
+ targetId: row.targetId,
77
+ confidence: row.confidence,
78
+ reason: row.reason,
79
+ step: row.step,
80
+ });
81
+ }
82
+ }
83
+ catch (err) {
84
+ console.warn('GitNexus: relationship query failed:', err?.message);
76
85
  }
77
86
  return { nodes, relationships };
78
87
  };
88
+ const httpStatus = (err) => {
89
+ const msg = err?.message ?? '';
90
+ if (msg.includes('not found') || msg.includes('No indexed'))
91
+ return 404;
92
+ if (msg.includes('Multiple repositories'))
93
+ return 400;
94
+ return 500;
95
+ };
79
96
  export const createServer = async (port) => {
97
+ const backend = new LocalBackend();
98
+ const hasRepos = await backend.init();
99
+ if (!hasRepos) {
100
+ console.warn('GitNexus: No indexed repositories found. The server will start but most endpoints will return errors.');
101
+ console.warn('Run "gitnexus analyze" in a repository to index it first.');
102
+ }
80
103
  const app = express();
81
- app.use(cors());
104
+ app.use(cors({
105
+ origin: (origin, callback) => {
106
+ // Allow requests with no origin (curl, server-to-server), localhost, and the deployed site.
107
+ // The server binds to 127.0.0.1 so only the local machine can reach it — CORS just gates
108
+ // which browser-tab origins may issue the request.
109
+ if (!origin
110
+ || origin.startsWith('http://localhost:')
111
+ || origin.startsWith('http://127.0.0.1:')
112
+ || origin === 'https://gitnexus.vercel.app') {
113
+ callback(null, true);
114
+ }
115
+ else {
116
+ callback(new Error('Not allowed by CORS'));
117
+ }
118
+ }
119
+ }));
82
120
  app.use(express.json({ limit: '10mb' }));
83
- // Get repo info
84
- app.get('/api/repo', async (_req, res) => {
85
- const repo = await findRepo(process.cwd());
86
- if (!repo) {
87
- res.status(404).json({ error: 'Repository not indexed. Run: gitnexus analyze' });
88
- return;
89
- }
90
- res.json({
91
- repoPath: repo.repoPath,
92
- indexedAt: repo.meta.indexedAt,
93
- stats: repo.meta.stats || {},
94
- });
121
+ // ─── GET /api/repos ─────────────────────────────────────────────
122
+ // List all indexed repositories
123
+ app.get('/api/repos', async (_req, res) => {
124
+ try {
125
+ const repos = await backend.listRepos();
126
+ res.json(repos);
127
+ }
128
+ catch (err) {
129
+ res.status(500).json({ error: err.message || 'Failed to list repos' });
130
+ }
131
+ });
132
+ // ─── GET /api/repo?repo=X ──────────────────────────────────────
133
+ // Get metadata for a specific repo
134
+ app.get('/api/repo', async (req, res) => {
135
+ try {
136
+ const repoName = req.query.repo;
137
+ const repo = await backend.resolveRepo(repoName);
138
+ res.json({
139
+ name: repo.name,
140
+ path: repo.repoPath,
141
+ indexedAt: repo.indexedAt,
142
+ lastCommit: repo.lastCommit,
143
+ stats: repo.stats || {},
144
+ });
145
+ }
146
+ catch (err) {
147
+ res.status(httpStatus(err))
148
+ .json({ error: err.message || 'Repository not found' });
149
+ }
95
150
  });
96
- // Get full graph
97
- app.get('/api/graph', async (_req, res) => {
98
- const repo = await findRepo(process.cwd());
99
- if (!repo) {
100
- res.status(404).json({ error: 'Repository not indexed' });
101
- return;
102
- }
103
- await initKuzu(repo.kuzuPath);
104
- const graph = await buildGraph();
105
- res.json(graph);
151
+ // ─── GET /api/graph?repo=X ─────────────────────────────────────
152
+ // Full knowledge graph (all nodes + relationships)
153
+ app.get('/api/graph', async (req, res) => {
154
+ try {
155
+ const repoName = req.query.repo;
156
+ // Resolve repo to validate it exists and get the name
157
+ const repo = await backend.resolveRepo(repoName);
158
+ const graph = await buildGraph(backend, repo.name);
159
+ res.json(graph);
160
+ }
161
+ catch (err) {
162
+ res.status(httpStatus(err))
163
+ .json({ error: err.message || 'Failed to build graph' });
164
+ }
106
165
  });
107
- // Execute Cypher query
166
+ // ─── POST /api/query ───────────────────────────────────────────
167
+ // Execute a raw Cypher query.
168
+ // This endpoint is intentionally unrestricted (no query validation) because
169
+ // the server binds to 127.0.0.1 only — it exposes full graph query
170
+ // capabilities to local clients by design.
108
171
  app.post('/api/query', async (req, res) => {
109
- const repo = await findRepo(process.cwd());
110
- if (!repo) {
111
- res.status(404).json({ error: 'Repository not indexed' });
112
- return;
113
- }
114
- await initKuzu(repo.kuzuPath);
115
- const result = await executeQuery(req.body.cypher);
116
- res.json({ result });
172
+ try {
173
+ const repoName = (req.body.repo ?? req.query.repo);
174
+ const cypher = req.body.cypher;
175
+ if (!cypher) {
176
+ res.status(400).json({ error: 'Missing "cypher" in request body' });
177
+ return;
178
+ }
179
+ const result = await backend.executeCypher(repoName, cypher);
180
+ if (result && !Array.isArray(result) && result.error) {
181
+ res.status(500).json({ error: result.error });
182
+ return;
183
+ }
184
+ res.json({ result });
185
+ }
186
+ catch (err) {
187
+ res.status(httpStatus(err))
188
+ .json({ error: err.message || 'Query failed' });
189
+ }
117
190
  });
118
- // Search
191
+ // ─── POST /api/search ──────────────────────────────────────────
192
+ // Process-grouped semantic search
119
193
  app.post('/api/search', async (req, res) => {
120
- const repo = await findRepo(process.cwd());
121
- if (!repo) {
122
- res.status(404).json({ error: 'Repository not indexed' });
123
- return;
124
- }
125
- await initKuzu(repo.kuzuPath);
126
- const query = req.body.query ?? '';
127
- const limit = req.body.limit ?? 10;
128
- if (isEmbedderReady()) {
129
- const results = await hybridSearch(query, limit, executeQuery, semanticSearch);
194
+ try {
195
+ const repoName = (req.body.repo ?? req.query.repo);
196
+ const query = (req.body.query ?? '').trim();
197
+ const limit = req.body.limit;
198
+ if (!query) {
199
+ res.status(400).json({ error: 'Missing "query" in request body' });
200
+ return;
201
+ }
202
+ const results = await backend.callTool('query', {
203
+ repo: repoName,
204
+ query,
205
+ limit,
206
+ });
130
207
  res.json({ results });
131
- return;
132
208
  }
133
- // FTS-only fallback when embeddings aren't loaded
134
- const results = await searchFTSFromKuzu(query, limit);
135
- res.json({ results });
209
+ catch (err) {
210
+ res.status(httpStatus(err))
211
+ .json({ error: err.message || 'Search failed' });
212
+ }
136
213
  });
137
- // Read file
214
+ // ─── GET /api/file?repo=X&path=Y ──────────────────────────────
215
+ // Read a file from a resolved repo path on disk
138
216
  app.get('/api/file', async (req, res) => {
139
- const repo = await findRepo(process.cwd());
140
- if (!repo) {
141
- res.status(404).json({ error: 'Repository not indexed' });
142
- return;
143
- }
144
- const filePath = req.query.path;
145
- if (!filePath) {
146
- res.status(400).json({ error: 'Missing path' });
147
- return;
148
- }
149
- const fullPath = path.join(repo.repoPath, filePath);
150
- const content = await fs.readFile(fullPath, 'utf-8');
151
- res.json({ content });
217
+ try {
218
+ const repoName = req.query.repo;
219
+ const filePath = req.query.path;
220
+ if (!filePath) {
221
+ res.status(400).json({ error: 'Missing "path" query parameter' });
222
+ return;
223
+ }
224
+ const repo = await backend.resolveRepo(repoName);
225
+ // Resolve the full path and validate it stays within the repo root
226
+ const repoRoot = path.resolve(repo.repoPath);
227
+ const fullPath = path.resolve(repoRoot, filePath);
228
+ if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
229
+ res.status(403).json({ error: 'Path traversal denied: path escapes repo root' });
230
+ return;
231
+ }
232
+ const content = await fs.readFile(fullPath, 'utf-8');
233
+ res.json({ content });
234
+ }
235
+ catch (err) {
236
+ if (err.code === 'ENOENT') {
237
+ res.status(404).json({ error: 'File not found' });
238
+ }
239
+ else {
240
+ res.status(httpStatus(err))
241
+ .json({ error: err.message || 'Failed to read file' });
242
+ }
243
+ }
244
+ });
245
+ // ─── GET /api/processes?repo=X ─────────────────────────────────
246
+ // List all processes for a repo
247
+ app.get('/api/processes', async (req, res) => {
248
+ try {
249
+ const repoName = req.query.repo;
250
+ const result = await backend.queryProcesses(repoName);
251
+ res.json(result);
252
+ }
253
+ catch (err) {
254
+ res.status(httpStatus(err))
255
+ .json({ error: err.message || 'Failed to query processes' });
256
+ }
257
+ });
258
+ // ─── GET /api/process?repo=X&name=Y ───────────────────────────
259
+ // Get detailed process info including steps
260
+ app.get('/api/process', async (req, res) => {
261
+ try {
262
+ const repoName = req.query.repo;
263
+ const name = req.query.name;
264
+ if (!name) {
265
+ res.status(400).json({ error: 'Missing "name" query parameter' });
266
+ return;
267
+ }
268
+ const result = await backend.queryProcessDetail(name, repoName);
269
+ if (result.error) {
270
+ res.status(404).json({ error: result.error });
271
+ return;
272
+ }
273
+ res.json(result);
274
+ }
275
+ catch (err) {
276
+ res.status(httpStatus(err))
277
+ .json({ error: err.message || 'Failed to query process detail' });
278
+ }
279
+ });
280
+ // ─── GET /api/clusters?repo=X ─────────────────────────────────
281
+ // List all clusters for a repo
282
+ app.get('/api/clusters', async (req, res) => {
283
+ try {
284
+ const repoName = req.query.repo;
285
+ const result = await backend.queryClusters(repoName);
286
+ res.json(result);
287
+ }
288
+ catch (err) {
289
+ res.status(httpStatus(err))
290
+ .json({ error: err.message || 'Failed to query clusters' });
291
+ }
292
+ });
293
+ // ─── GET /api/cluster?repo=X&name=Y ───────────────────────────
294
+ // Get detailed cluster info including members
295
+ app.get('/api/cluster', async (req, res) => {
296
+ try {
297
+ const repoName = req.query.repo;
298
+ const name = req.query.name;
299
+ if (!name) {
300
+ res.status(400).json({ error: 'Missing "name" query parameter' });
301
+ return;
302
+ }
303
+ const result = await backend.queryClusterDetail(name, repoName);
304
+ if (result.error) {
305
+ res.status(404).json({ error: result.error });
306
+ return;
307
+ }
308
+ res.json(result);
309
+ }
310
+ catch (err) {
311
+ res.status(httpStatus(err))
312
+ .json({ error: err.message || 'Failed to query cluster detail' });
313
+ }
152
314
  });
153
- app.listen(port, () => {
315
+ const server = app.listen(port, '127.0.0.1', () => {
154
316
  console.log(`GitNexus server running on http://localhost:${port}`);
317
+ console.log(`Serving ${hasRepos ? 'all indexed repositories' : 'no repositories (run gitnexus analyze first)'}`);
155
318
  });
319
+ const shutdown = async () => {
320
+ server.close();
321
+ await backend.disconnect();
322
+ process.exit(0);
323
+ };
324
+ process.once('SIGINT', shutdown);
325
+ process.once('SIGTERM', shutdown);
156
326
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",