magector 1.5.0 → 1.5.2
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/README.md +54 -59
- package/package.json +5 -5
- package/src/cli.js +4 -0
- package/src/init.js +50 -12
- package/src/mcp-server.js +125 -16
- package/src/update.js +158 -0
package/README.md
CHANGED
|
@@ -66,23 +66,27 @@ The result: your AI assistant calls one MCP tool and gets ranked, pattern-enrich
|
|
|
66
66
|
## Architecture
|
|
67
67
|
|
|
68
68
|
```mermaid
|
|
69
|
-
flowchart
|
|
70
|
-
subgraph rust ["Rust Core"]
|
|
71
|
-
A["AST Parser · PHP + JS"]
|
|
72
|
-
B["Pattern Detection · 20+"]
|
|
73
|
-
B2["Description Enrichment"]
|
|
74
|
-
C["ONNX Embedder · 384d"]
|
|
75
|
-
D["HNSW + Reranking"]
|
|
76
|
-
A --> B --> B2 --> C --> D
|
|
77
|
-
end
|
|
69
|
+
flowchart LR
|
|
78
70
|
subgraph node ["Node.js Layer"]
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
71
|
+
direction TB
|
|
72
|
+
G["CLI<br/>init · index · search · describe"]
|
|
73
|
+
E["MCP Server<br/>21 tools · LRU cache"]
|
|
74
|
+
F["Persistent Serve Process"]
|
|
83
75
|
G --> F
|
|
76
|
+
E --> F
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
F -->|"stdin/stdout JSON"| rust
|
|
80
|
+
|
|
81
|
+
subgraph rust ["Rust Core"]
|
|
82
|
+
direction TB
|
|
83
|
+
A["AST Parser<br/>PHP · JS · XML"]
|
|
84
|
+
B["Pattern Detection<br/>20+ Magento patterns"]
|
|
85
|
+
B2["Description Enrichment<br/>LLM-powered di.xml summaries"]
|
|
86
|
+
C["ONNX Embedder<br/>all-MiniLM-L6-v2 · 384d"]
|
|
87
|
+
D["HNSW Vector Search<br/>hybrid reranking · SONA"]
|
|
88
|
+
A --> B --> B2 --> C --> D
|
|
84
89
|
end
|
|
85
|
-
node -->|stdin/stdout JSON| rust
|
|
86
90
|
|
|
87
91
|
style rust fill:#f4a460,color:#000
|
|
88
92
|
style node fill:#68b684,color:#000
|
|
@@ -91,29 +95,28 @@ flowchart TD
|
|
|
91
95
|
### Indexing Pipeline
|
|
92
96
|
|
|
93
97
|
```mermaid
|
|
94
|
-
flowchart
|
|
95
|
-
A[Source File] --> B[AST Parser]
|
|
96
|
-
B --> C[Pattern Detection]
|
|
97
|
-
C --> D[Text Enrichment]
|
|
98
|
-
D --> D2{
|
|
99
|
-
D2 -->|Yes| D3["Prepend Description"]
|
|
100
|
-
D2 -->|No| E[ONNX Embedding]
|
|
98
|
+
flowchart LR
|
|
99
|
+
A["Source File"] --> B["AST Parser"]
|
|
100
|
+
B --> C["Pattern Detection"]
|
|
101
|
+
C --> D["Text Enrichment"]
|
|
102
|
+
D --> D2{"Descriptions DB?"}
|
|
103
|
+
D2 -->|Yes| D3["Prepend LLM Description"]
|
|
104
|
+
D2 -->|No| E["ONNX Embedding"]
|
|
101
105
|
D3 --> E
|
|
102
|
-
E --> F[(HNSW Index)]
|
|
103
|
-
A --> G[Metadata]
|
|
104
|
-
G --> F
|
|
106
|
+
E --> F[("HNSW Index")]
|
|
107
|
+
A --> G["Metadata"] --> F
|
|
105
108
|
```
|
|
106
109
|
|
|
107
110
|
### Search Pipeline
|
|
108
111
|
|
|
109
112
|
```mermaid
|
|
110
|
-
flowchart
|
|
111
|
-
Q[Query] --> E1[Synonym Enrichment]
|
|
112
|
-
E1 --> E2[ONNX Embedding]
|
|
113
|
-
E2 --> H[HNSW Search]
|
|
114
|
-
H --> R[Hybrid Reranking]
|
|
115
|
-
R --> SA[SONA Adjustment
|
|
116
|
-
SA --> J[Structured JSON]
|
|
113
|
+
flowchart LR
|
|
114
|
+
Q["Query"] --> E1["Synonym Enrichment"]
|
|
115
|
+
E1 --> E2["ONNX Embedding"]
|
|
116
|
+
E2 --> H["HNSW Search"]
|
|
117
|
+
H --> R["Hybrid Reranking"]
|
|
118
|
+
R --> SA["SONA Adjustment"]
|
|
119
|
+
SA --> J["Structured JSON"]
|
|
117
120
|
```
|
|
118
121
|
|
|
119
122
|
### Components
|
|
@@ -148,13 +151,14 @@ npx magector init
|
|
|
148
151
|
This single command handles the entire setup:
|
|
149
152
|
|
|
150
153
|
```mermaid
|
|
151
|
-
flowchart
|
|
152
|
-
A["npx magector init"] --> B[Verify
|
|
153
|
-
B --> C[Download Model]
|
|
154
|
-
C --> D[Index
|
|
155
|
-
D --> E[Detect IDE]
|
|
156
|
-
E -->
|
|
157
|
-
|
|
154
|
+
flowchart LR
|
|
155
|
+
A["npx magector init"] --> B["Verify<br/>Project"]
|
|
156
|
+
B --> C["Download<br/>ONNX Model"]
|
|
157
|
+
C --> D["Index<br/>Codebase"]
|
|
158
|
+
D --> E["Detect IDE<br/>Cursor · Claude Code"]
|
|
159
|
+
E --> E2["API Key<br/>(optional)"]
|
|
160
|
+
E2 --> F["Write MCP<br/>Config"]
|
|
161
|
+
F --> G["Update<br/>.gitignore"]
|
|
158
162
|
```
|
|
159
163
|
|
|
160
164
|
### 2. Search
|
|
@@ -304,7 +308,7 @@ npx magector mcp # Start MCP server
|
|
|
304
308
|
npx magector help # Show help
|
|
305
309
|
```
|
|
306
310
|
|
|
307
|
-
The `describe` command
|
|
311
|
+
The `describe` command and `magento_describe` MCP tool require an Anthropic API key. During `npx magector init`, you are prompted to paste your key (optional). If provided, it is stored in the MCP config file as the `ANTHROPIC_API_KEY` environment variable so the MCP server can use it automatically. You can also set it manually later by adding `"ANTHROPIC_API_KEY": "sk-..."` to the `env` section in `.mcp.json` or `~/.cursor/mcp.json`.
|
|
308
312
|
|
|
309
313
|
### Environment Variables
|
|
310
314
|
|
|
@@ -405,7 +409,7 @@ Auto-detects entry type from pattern (`/V1/...` → API, `snake_case` → event,
|
|
|
405
409
|
Each tool description includes "See also" hints to help AI clients chain tools effectively:
|
|
406
410
|
|
|
407
411
|
```mermaid
|
|
408
|
-
graph
|
|
412
|
+
graph LR
|
|
409
413
|
cls["find_class"] --> plg["find_plugin"]
|
|
410
414
|
cls --> prf["find_preference"]
|
|
411
415
|
cls --> mtd["find_method"]
|
|
@@ -635,20 +639,16 @@ Magector scans every `.php`, `.js`, `.xml`, `.phtml`, and `.graphqls` file in a
|
|
|
635
639
|
The MCP server spawns a persistent Rust process (`magector-core serve`) that keeps the ONNX model and HNSW index loaded in memory. Queries are sent as JSON over stdin and responses returned via stdout -- eliminating the ~2.6s cold-start overhead of loading the model per query. Falls back to single-shot `execFileSync` if the serve process is unavailable.
|
|
636
640
|
|
|
637
641
|
```mermaid
|
|
638
|
-
flowchart
|
|
642
|
+
flowchart LR
|
|
639
643
|
subgraph startup ["Startup (once)"]
|
|
640
|
-
S1[Load Model] --> S2[Load Index]
|
|
641
|
-
S2 --> S3[Ready Signal]
|
|
644
|
+
S1["Load Model"] --> S2["Load Index"] --> S3["Ready Signal"]
|
|
642
645
|
end
|
|
646
|
+
startup --> query
|
|
643
647
|
subgraph query ["Per Query (10-45ms)"]
|
|
644
|
-
Q1[stdin JSON] --> Q2[Embed]
|
|
645
|
-
Q2 --> Q3[HNSW Search]
|
|
646
|
-
Q3 --> Q4[Rerank]
|
|
647
|
-
Q4 --> Q5[stdout JSON]
|
|
648
|
+
Q1["stdin JSON"] --> Q2["Embed"] --> Q3["HNSW Search"] --> Q4["Rerank"] --> Q5["stdout JSON"]
|
|
648
649
|
end
|
|
649
|
-
startup --> query
|
|
650
650
|
subgraph fallback ["Fallback"]
|
|
651
|
-
F1[execFileSync ~2.6s]
|
|
651
|
+
F1["execFileSync ~2.6s"]
|
|
652
652
|
end
|
|
653
653
|
|
|
654
654
|
style startup fill:#e8f4e8,color:#000
|
|
@@ -663,17 +663,12 @@ When the serve process is started with `--magento-root`, a background thread pol
|
|
|
663
663
|
Since `hnsw_rs` does not support point deletion, Magector uses a **tombstone** strategy: old vectors for modified/deleted files are marked as tombstoned and filtered out of search results. New vectors are appended. When tombstoned entries exceed 20% of total vectors, the HNSW graph is automatically rebuilt (compacted) to reclaim memory and restore search performance.
|
|
664
664
|
|
|
665
665
|
```mermaid
|
|
666
|
-
flowchart
|
|
667
|
-
W1[Sleep 60s] --> W2[Scan Filesystem]
|
|
668
|
-
W2 --> W3{Changes?}
|
|
666
|
+
flowchart LR
|
|
667
|
+
W1["Sleep 60s"] --> W2["Scan Filesystem"] --> W3{"Changes?"}
|
|
669
668
|
W3 -->|No| W1
|
|
670
|
-
W3 -->|Yes| W4[Tombstone Old Vectors]
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
W6 --> W7{Tombstone > 20%?}
|
|
674
|
-
W7 -->|Yes| W8[Compact / Rebuild HNSW]
|
|
675
|
-
W7 -->|No| W9[Save to Disk]
|
|
676
|
-
W8 --> W9
|
|
669
|
+
W3 -->|Yes| W4["Tombstone Old Vectors"] --> W5["Parse + Embed New Files"] --> W6["Append to HNSW"] --> W7{"Tombstone > 20%?"}
|
|
670
|
+
W7 -->|Yes| W8["Compact / Rebuild HNSW"] --> W9["Save to Disk"]
|
|
671
|
+
W7 -->|No| W9
|
|
677
672
|
W9 --> W1
|
|
678
673
|
|
|
679
674
|
style W4 fill:#f4e8e8,color:#000
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magector",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "Semantic code search for Magento 2 — index, search, MCP server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp-server.js",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"ruvector": "^0.1.96"
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|
|
40
|
-
"@magector/cli-darwin-arm64": "1.5.
|
|
41
|
-
"@magector/cli-linux-x64": "1.5.
|
|
42
|
-
"@magector/cli-linux-arm64": "1.5.
|
|
43
|
-
"@magector/cli-win32-x64": "1.5.
|
|
40
|
+
"@magector/cli-darwin-arm64": "1.5.2",
|
|
41
|
+
"@magector/cli-linux-x64": "1.5.2",
|
|
42
|
+
"@magector/cli-linux-arm64": "1.5.2",
|
|
43
|
+
"@magector/cli-win32-x64": "1.5.2"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"magento",
|
package/src/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'path';
|
|
|
11
11
|
import { resolveBinary } from './binary.js';
|
|
12
12
|
import { ensureModels, resolveModels } from './model.js';
|
|
13
13
|
import { init, setup } from './init.js';
|
|
14
|
+
import { checkForUpdate } from './update.js';
|
|
14
15
|
|
|
15
16
|
const args = process.argv.slice(2);
|
|
16
17
|
const command = args[0];
|
|
@@ -192,6 +193,9 @@ async function runDescribe(targetPath) {
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
async function main() {
|
|
196
|
+
// Auto-update: check npm for newer version, re-exec if found
|
|
197
|
+
await checkForUpdate(command, args);
|
|
198
|
+
|
|
195
199
|
switch (command) {
|
|
196
200
|
case 'init':
|
|
197
201
|
await init(args[1]);
|
package/src/init.js
CHANGED
|
@@ -3,13 +3,29 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'fs';
|
|
5
5
|
import { execFileSync } from 'child_process';
|
|
6
|
+
import { createInterface } from 'readline';
|
|
6
7
|
import { homedir } from 'os';
|
|
7
8
|
import path from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
8
10
|
import { resolveBinary } from './binary.js';
|
|
9
11
|
import { ensureModels } from './model.js';
|
|
10
12
|
import { CURSOR_RULES_MDC } from './templates/cursor-rules-mdc.js';
|
|
11
13
|
import { CLAUDE_MD } from './templates/claude-md.js';
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Prompt the user for input. Returns empty string if stdin is not a TTY.
|
|
17
|
+
*/
|
|
18
|
+
function askQuestion(question) {
|
|
19
|
+
if (!process.stdin.isTTY) return Promise.resolve('');
|
|
20
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
rl.question(question, answer => {
|
|
23
|
+
rl.close();
|
|
24
|
+
resolve(answer.trim());
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
/**
|
|
14
30
|
* Detect if the given path is a Magento 2 project root.
|
|
15
31
|
*/
|
|
@@ -51,14 +67,18 @@ function detectIDEs(projectPath) {
|
|
|
51
67
|
/**
|
|
52
68
|
* Write MCP server configuration for the given IDE(s).
|
|
53
69
|
*/
|
|
54
|
-
function writeMcpConfig(projectPath, ides, dbPath) {
|
|
70
|
+
function writeMcpConfig(projectPath, ides, dbPath, { anthropicApiKey } = {}) {
|
|
71
|
+
const env = {
|
|
72
|
+
MAGENTO_ROOT: projectPath,
|
|
73
|
+
MAGECTOR_DB: dbPath
|
|
74
|
+
};
|
|
75
|
+
if (anthropicApiKey) {
|
|
76
|
+
env.ANTHROPIC_API_KEY = anthropicApiKey;
|
|
77
|
+
}
|
|
55
78
|
const mcpEntry = {
|
|
56
79
|
command: 'npx',
|
|
57
80
|
args: ['-y', 'magector@latest', 'mcp'],
|
|
58
|
-
env
|
|
59
|
-
MAGENTO_ROOT: projectPath,
|
|
60
|
-
MAGECTOR_DB: dbPath
|
|
61
|
-
}
|
|
81
|
+
env
|
|
62
82
|
};
|
|
63
83
|
|
|
64
84
|
const written = [];
|
|
@@ -184,7 +204,9 @@ export async function init(projectPath) {
|
|
|
184
204
|
mkdirSync(path.join(projectPath, '.magector'), { recursive: true });
|
|
185
205
|
const dbPath = path.join(projectPath, '.magector', 'index.db');
|
|
186
206
|
|
|
187
|
-
|
|
207
|
+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
208
|
+
const version = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, 'utf-8')).version : 'dev';
|
|
209
|
+
console.log(`\nMagector Init v${version}\n`);
|
|
188
210
|
|
|
189
211
|
// 1. Verify Magento project
|
|
190
212
|
console.log('Checking Magento project...');
|
|
@@ -248,23 +270,32 @@ export async function init(projectPath) {
|
|
|
248
270
|
if (ideNames.length === 0) ideNames.push('Cursor', 'Claude Code');
|
|
249
271
|
console.log(` Detected: ${ideNames.join(' + ') || 'none (configuring both)'}`);
|
|
250
272
|
|
|
251
|
-
// 6.
|
|
273
|
+
// 6. Optional: Anthropic API key for LLM description enrichment
|
|
274
|
+
let anthropicApiKey = '';
|
|
275
|
+
anthropicApiKey = await askQuestion('\nAnthropic API key (optional, enables LLM enrichment — press Enter to skip): ');
|
|
276
|
+
if (anthropicApiKey) {
|
|
277
|
+
console.log(' API key will be stored in MCP config.');
|
|
278
|
+
} else {
|
|
279
|
+
console.log(' Skipped. You can add ANTHROPIC_API_KEY to MCP config later.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 7. Write MCP config
|
|
252
283
|
console.log('\nWriting MCP config...');
|
|
253
|
-
const mcpFiles = writeMcpConfig(projectPath, ides, dbPath);
|
|
284
|
+
const mcpFiles = writeMcpConfig(projectPath, ides, dbPath, { anthropicApiKey });
|
|
254
285
|
mcpFiles.forEach(f => console.log(` ${f}`));
|
|
255
286
|
|
|
256
|
-
//
|
|
287
|
+
// 8. Write rules
|
|
257
288
|
console.log('\nWriting IDE rules...');
|
|
258
289
|
const rulesFiles = writeRules(projectPath, ides);
|
|
259
290
|
rulesFiles.forEach(f => console.log(` ${f}`));
|
|
260
291
|
|
|
261
|
-
//
|
|
292
|
+
// 9. Update .gitignore
|
|
262
293
|
const giUpdated = updateGitignore(projectPath);
|
|
263
294
|
if (giUpdated) {
|
|
264
295
|
console.log('\nUpdated .gitignore with .magector/');
|
|
265
296
|
}
|
|
266
297
|
|
|
267
|
-
//
|
|
298
|
+
// 10. Get stats and print summary
|
|
268
299
|
let vectorCount = '?';
|
|
269
300
|
try {
|
|
270
301
|
const statsOutput = execFileSync(binary, ['stats', '-d', dbPath], {
|
|
@@ -303,7 +334,14 @@ export async function setup(projectPath) {
|
|
|
303
334
|
|
|
304
335
|
console.log(`Detected: ${ideNames.join(' + ')}`);
|
|
305
336
|
|
|
306
|
-
|
|
337
|
+
let anthropicApiKey = await askQuestion('\nAnthropic API key (optional, enables LLM enrichment — press Enter to skip): ');
|
|
338
|
+
if (anthropicApiKey) {
|
|
339
|
+
console.log(' API key will be stored in MCP config.');
|
|
340
|
+
} else {
|
|
341
|
+
console.log(' Skipped. You can add ANTHROPIC_API_KEY to MCP config later.');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const mcpFiles = writeMcpConfig(projectPath, ides, dbPath, { anthropicApiKey });
|
|
307
345
|
console.log('\nMCP config:');
|
|
308
346
|
mcpFiles.forEach(f => console.log(` ${f}`));
|
|
309
347
|
|
package/src/mcp-server.js
CHANGED
|
@@ -55,9 +55,11 @@ async function loadDescriptions() {
|
|
|
55
55
|
logToFile('INFO', `Loaded ${Object.keys(descriptionMap).length} LLM descriptions via serve`);
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
|
-
} catch {
|
|
59
|
-
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logToFile('WARN', `Failed to load descriptions via serve: ${err.message}`);
|
|
60
60
|
}
|
|
61
|
+
} else {
|
|
62
|
+
logToFile('INFO', 'Skipping description load (serve process not ready)');
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
}
|
|
@@ -82,13 +84,22 @@ function logToFile(level, message) {
|
|
|
82
84
|
// Initialize log file on startup
|
|
83
85
|
try { writeFileSync(LOG_PATH, `[${new Date().toISOString()}] [INFO] Magector MCP server starting\n`); } catch {}
|
|
84
86
|
|
|
87
|
+
// Log resolved configuration so the log file is self-contained for debugging
|
|
88
|
+
logToFile('INFO', `Config: MAGENTO_ROOT=${config.magentoRoot}`);
|
|
89
|
+
logToFile('INFO', `Config: MAGECTOR_DB=${config.dbPath}`);
|
|
90
|
+
logToFile('INFO', `Config: watchInterval=${config.watchInterval}s`);
|
|
91
|
+
try { logToFile('INFO', `Config: rustBinary=${config.rustBinary}`); } catch (e) { logToFile('ERR', `Config: rustBinary resolution failed: ${e.message}`); }
|
|
92
|
+
try { logToFile('INFO', `Config: modelCache=${config.modelCache}`); } catch (e) { logToFile('ERR', `Config: modelCache resolution failed: ${e.message}`); }
|
|
93
|
+
logToFile('INFO', `Config: PID=${process.pid}`);
|
|
94
|
+
|
|
85
95
|
// ─── Rust Core Integration ──────────────────────────────────────
|
|
86
96
|
|
|
87
|
-
// Env vars
|
|
97
|
+
// Env vars for Rust subprocess logging — ORT_LOG_LEVEL suppresses ONNX native noise,
|
|
98
|
+
// RUST_LOG=info surfaces watcher events, indexing progress, model loading, HNSW ops.
|
|
88
99
|
const rustEnv = {
|
|
89
100
|
...process.env,
|
|
90
101
|
ORT_LOG_LEVEL: 'error',
|
|
91
|
-
RUST_LOG: '
|
|
102
|
+
RUST_LOG: 'info',
|
|
92
103
|
};
|
|
93
104
|
|
|
94
105
|
/**
|
|
@@ -120,6 +131,62 @@ function extractJson(stdout) {
|
|
|
120
131
|
throw new SyntaxError('No valid JSON found in command output');
|
|
121
132
|
}
|
|
122
133
|
|
|
134
|
+
// ─── PID File & Orphan Cleanup ──────────────────────────────────
|
|
135
|
+
// Track the serve process PID to clean up orphans on restart.
|
|
136
|
+
|
|
137
|
+
const PID_PATH = path.join(config.magentoRoot, '.magector', 'serve.pid');
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Write the serve process PID to disk so future instances can clean up orphans.
|
|
141
|
+
*/
|
|
142
|
+
function writePidFile(pid) {
|
|
143
|
+
try { writeFileSync(PID_PATH, String(pid)); } catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function removePidFile() {
|
|
147
|
+
try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Kill any stale serve process from a previous MCP server instance.
|
|
152
|
+
* This handles the common case where the MCP server was killed without
|
|
153
|
+
* triggering its exit handler (SIGKILL, crash, IDE disconnect).
|
|
154
|
+
*/
|
|
155
|
+
function killStaleServeProcess() {
|
|
156
|
+
try {
|
|
157
|
+
if (!existsSync(PID_PATH)) return;
|
|
158
|
+
const stalePid = parseInt(readFileSync(PID_PATH, 'utf-8').trim(), 10);
|
|
159
|
+
if (!stalePid || isNaN(stalePid)) return;
|
|
160
|
+
|
|
161
|
+
// Check if the process is still alive
|
|
162
|
+
try {
|
|
163
|
+
process.kill(stalePid, 0); // signal 0 = existence check
|
|
164
|
+
} catch {
|
|
165
|
+
// Process doesn't exist, clean up stale PID file
|
|
166
|
+
removePidFile();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
logToFile('WARN', `Killing stale serve process (PID ${stalePid}) from previous session`);
|
|
171
|
+
console.error(`Killing stale serve process (PID ${stalePid})`);
|
|
172
|
+
try { process.kill(stalePid, 'SIGTERM'); } catch {}
|
|
173
|
+
|
|
174
|
+
// Give it a moment, then force kill if still alive
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(stalePid, 0);
|
|
178
|
+
process.kill(stalePid, 'SIGKILL');
|
|
179
|
+
} catch {
|
|
180
|
+
// Already dead, good
|
|
181
|
+
}
|
|
182
|
+
}, 2000);
|
|
183
|
+
|
|
184
|
+
removePidFile();
|
|
185
|
+
} catch {
|
|
186
|
+
// Non-fatal
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
123
190
|
// ─── Database Format Check & Background Re-index ────────────────
|
|
124
191
|
|
|
125
192
|
let reindexInProgress = false;
|
|
@@ -180,6 +247,7 @@ function startBackgroundReindex() {
|
|
|
180
247
|
if (existsSync(bgDescDbPath)) {
|
|
181
248
|
reindexArgs.push('--descriptions-db', bgDescDbPath);
|
|
182
249
|
}
|
|
250
|
+
logToFile('INFO', `Starting background reindex: ${config.rustBinary} ${reindexArgs.join(' ')}`);
|
|
183
251
|
reindexProcess = spawn(config.rustBinary, reindexArgs, {
|
|
184
252
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
185
253
|
env: rustEnv,
|
|
@@ -326,11 +394,20 @@ function startServeProcess() {
|
|
|
326
394
|
if (existsSync(descDbPath)) {
|
|
327
395
|
args.push('--descriptions-db', descDbPath);
|
|
328
396
|
}
|
|
397
|
+
logToFile('INFO', `Starting serve process: ${config.rustBinary} ${args.join(' ')}`);
|
|
329
398
|
const proc = spawn(config.rustBinary, args,
|
|
330
399
|
{ stdio: ['pipe', 'pipe', 'pipe'], env: rustEnv });
|
|
331
400
|
|
|
332
|
-
proc.on('error', () => {
|
|
333
|
-
|
|
401
|
+
proc.on('error', (err) => {
|
|
402
|
+
logToFile('ERR', `Serve process error: ${err.message}`);
|
|
403
|
+
serveProcess = null; serveReady = false; removePidFile();
|
|
404
|
+
if (serveReadyResolve) { serveReadyResolve(false); serveReadyResolve = null; }
|
|
405
|
+
});
|
|
406
|
+
proc.on('exit', (code, signal) => {
|
|
407
|
+
logToFile('WARN', `Serve process exited (code=${code}, signal=${signal})`);
|
|
408
|
+
serveProcess = null; serveReady = false; removePidFile();
|
|
409
|
+
if (serveReadyResolve) { serveReadyResolve(false); serveReadyResolve = null; }
|
|
410
|
+
});
|
|
334
411
|
proc.stderr.on('data', (d) => {
|
|
335
412
|
// Log serve process stderr (watcher events, tracing, errors) to .magector/magector.log
|
|
336
413
|
// Strip ANSI escape codes for clean log output
|
|
@@ -341,11 +418,15 @@ function startServeProcess() {
|
|
|
341
418
|
serveReadline = createInterface({ input: proc.stdout });
|
|
342
419
|
serveReadline.on('line', (line) => {
|
|
343
420
|
let parsed;
|
|
344
|
-
try { parsed = JSON.parse(line); } catch {
|
|
421
|
+
try { parsed = JSON.parse(line); } catch {
|
|
422
|
+
logToFile('WARN', `Unparseable serve stdout: ${line.slice(0, 200)}`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
345
425
|
|
|
346
426
|
// First line is ready signal
|
|
347
427
|
if (parsed.ready) {
|
|
348
428
|
serveReady = true;
|
|
429
|
+
logToFile('INFO', `Serve process ready (PID ${proc.pid})`);
|
|
349
430
|
if (serveReadyResolve) { serveReadyResolve(true); serveReadyResolve = null; }
|
|
350
431
|
return;
|
|
351
432
|
}
|
|
@@ -359,7 +440,10 @@ function startServeProcess() {
|
|
|
359
440
|
});
|
|
360
441
|
|
|
361
442
|
serveProcess = proc;
|
|
362
|
-
|
|
443
|
+
writePidFile(proc.pid);
|
|
444
|
+
logToFile('INFO', `Serve process spawned (PID ${proc.pid})`);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
logToFile('ERR', `Failed to start serve process: ${err.message}`);
|
|
363
447
|
serveProcess = null;
|
|
364
448
|
serveReady = false;
|
|
365
449
|
if (serveReadyResolve) { serveReadyResolve(false); serveReadyResolve = null; }
|
|
@@ -369,8 +453,10 @@ function startServeProcess() {
|
|
|
369
453
|
function serveQuery(command, params = {}, timeoutMs = 30000) {
|
|
370
454
|
return new Promise((resolve, reject) => {
|
|
371
455
|
const id = serveNextId++;
|
|
456
|
+
logToFile('QUERY', `[${id}] → ${command}(${JSON.stringify(params).slice(0, 200)})`);
|
|
372
457
|
const timer = setTimeout(() => {
|
|
373
458
|
servePending.delete(id);
|
|
459
|
+
logToFile('ERR', `[${id}] ← ${command} TIMEOUT after ${timeoutMs}ms`);
|
|
374
460
|
reject(new Error('Serve query timeout'));
|
|
375
461
|
}, timeoutMs);
|
|
376
462
|
servePending.set(id, {
|
|
@@ -384,11 +470,13 @@ function serveQuery(command, params = {}, timeoutMs = 30000) {
|
|
|
384
470
|
async function rustSearchAsync(query, limit = 10) {
|
|
385
471
|
const cacheKey = `${query}|${limit}`;
|
|
386
472
|
if (searchCache.has(cacheKey)) {
|
|
473
|
+
logToFile('CACHE', `HIT: "${query}" (limit=${limit})`);
|
|
387
474
|
return searchCache.get(cacheKey);
|
|
388
475
|
}
|
|
389
476
|
|
|
390
477
|
// Wait for serve process if it's starting up but not yet ready
|
|
391
478
|
if (serveProcess && !serveReady && serveReadyPromise) {
|
|
479
|
+
logToFile('INFO', `Waiting for serve process to become ready...`);
|
|
392
480
|
await Promise.race([serveReadyPromise, new Promise(r => setTimeout(() => r(false), 10000))]);
|
|
393
481
|
}
|
|
394
482
|
|
|
@@ -400,12 +488,13 @@ async function rustSearchAsync(query, limit = 10) {
|
|
|
400
488
|
cacheSet(cacheKey, resp.data);
|
|
401
489
|
return resp.data;
|
|
402
490
|
}
|
|
403
|
-
} catch {
|
|
404
|
-
|
|
491
|
+
} catch (err) {
|
|
492
|
+
logToFile('WARN', `Serve query failed, falling back to execFileSync: ${err.message}`);
|
|
405
493
|
}
|
|
406
494
|
}
|
|
407
495
|
|
|
408
496
|
// Fallback: cold-start execFileSync
|
|
497
|
+
logToFile('INFO', `Using execFileSync fallback for search: "${query}"`);
|
|
409
498
|
return rustSearchSync(query, limit);
|
|
410
499
|
}
|
|
411
500
|
|
|
@@ -1837,7 +1926,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1837
1926
|
// SONA: flush accumulated feedback signals to Rust core
|
|
1838
1927
|
const signals = sessionTracker.flush();
|
|
1839
1928
|
if (signals.length > 0 && serveProcess && serveReady) {
|
|
1840
|
-
serveQuery('feedback', { signals }).catch(() => {});
|
|
1929
|
+
serveQuery('feedback', { signals }).catch((err) => logToFile('WARN', `Feedback signal send failed: ${err.message}`));
|
|
1841
1930
|
}
|
|
1842
1931
|
}
|
|
1843
1932
|
});
|
|
@@ -1871,6 +1960,9 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
1871
1960
|
});
|
|
1872
1961
|
|
|
1873
1962
|
async function main() {
|
|
1963
|
+
// Kill any orphaned serve process from a previous session
|
|
1964
|
+
killStaleServeProcess();
|
|
1965
|
+
|
|
1874
1966
|
// Check database format compatibility before starting serve process
|
|
1875
1967
|
if (existsSync(config.dbPath) && !checkDbFormat()) {
|
|
1876
1968
|
startBackgroundReindex();
|
|
@@ -1908,11 +2000,28 @@ async function main() {
|
|
|
1908
2000
|
console.error('Magector MCP server started (Rust core backend)');
|
|
1909
2001
|
}
|
|
1910
2002
|
|
|
1911
|
-
// Cleanup on exit
|
|
1912
|
-
|
|
2003
|
+
// Cleanup on exit — kill all child processes and remove PID file
|
|
2004
|
+
function cleanup(reason) {
|
|
2005
|
+
logToFile('INFO', `Cleanup: ${reason || 'exit'}`);
|
|
1913
2006
|
if (serveProcess) {
|
|
1914
|
-
serveProcess.
|
|
2007
|
+
logToFile('INFO', `Cleanup: killing serve process (PID ${serveProcess.pid})`);
|
|
2008
|
+
try { serveProcess.kill(); } catch {}
|
|
2009
|
+
serveProcess = null;
|
|
1915
2010
|
}
|
|
1916
|
-
|
|
2011
|
+
if (reindexProcess) {
|
|
2012
|
+
logToFile('INFO', `Cleanup: killing reindex process (PID ${reindexProcess.pid})`);
|
|
2013
|
+
try { reindexProcess.kill(); } catch {}
|
|
2014
|
+
reindexProcess = null;
|
|
2015
|
+
}
|
|
2016
|
+
removePidFile();
|
|
2017
|
+
}
|
|
1917
2018
|
|
|
1918
|
-
|
|
2019
|
+
process.on('exit', () => cleanup('process exit'));
|
|
2020
|
+
process.on('SIGTERM', () => { cleanup('SIGTERM'); process.exit(0); });
|
|
2021
|
+
process.on('SIGINT', () => { cleanup('SIGINT'); process.exit(0); });
|
|
2022
|
+
process.on('SIGHUP', () => { cleanup('SIGHUP'); process.exit(0); });
|
|
2023
|
+
|
|
2024
|
+
main().catch((err) => {
|
|
2025
|
+
logToFile('FATAL', `Startup failed: ${err.message}\n${err.stack}`);
|
|
2026
|
+
console.error(err);
|
|
2027
|
+
});
|
package/src/update.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-update check for Magector CLI.
|
|
3
|
+
*
|
|
4
|
+
* On each CLI run (except help/mcp), checks the npm registry for a newer version.
|
|
5
|
+
* If found, re-execs the current command via `npx magector@<latest>` so npx
|
|
6
|
+
* downloads the new version and runs it. Results are cached for 1 hour.
|
|
7
|
+
*
|
|
8
|
+
* Never blocks the CLI on failure — network errors are silently ignored.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
11
|
+
import { execSync } from 'child_process';
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const CACHE_TTL = 3600000; // 1 hour
|
|
18
|
+
const REGISTRY_TIMEOUT = 3000; // 3 seconds
|
|
19
|
+
const SKIP_COMMANDS = new Set(['help', '--help', '-h', 'mcp', undefined]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read current package version.
|
|
23
|
+
*/
|
|
24
|
+
function getCurrentVersion() {
|
|
25
|
+
const pkgPath = path.resolve(__dirname, '..', 'package.json');
|
|
26
|
+
if (!existsSync(pkgPath)) return null;
|
|
27
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve cache file path — prefer project .magector/ if it exists, else ~/.magector/
|
|
32
|
+
*/
|
|
33
|
+
function getCachePath() {
|
|
34
|
+
const projectDir = path.join(process.cwd(), '.magector');
|
|
35
|
+
if (existsSync(projectDir)) {
|
|
36
|
+
return path.join(projectDir, 'version-check.json');
|
|
37
|
+
}
|
|
38
|
+
const globalDir = path.join(homedir(), '.magector');
|
|
39
|
+
mkdirSync(globalDir, { recursive: true });
|
|
40
|
+
return path.join(globalDir, 'version-check.json');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read cached version check result.
|
|
45
|
+
*/
|
|
46
|
+
function readCache(cachePath) {
|
|
47
|
+
try {
|
|
48
|
+
if (!existsSync(cachePath)) return null;
|
|
49
|
+
return JSON.parse(readFileSync(cachePath, 'utf-8'));
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write cache.
|
|
57
|
+
*/
|
|
58
|
+
function writeCache(cachePath, latest) {
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(cachePath, JSON.stringify({ latest, checkedAt: Date.now() }));
|
|
61
|
+
} catch {
|
|
62
|
+
// Non-critical
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compare two semver strings. Returns true if a > b.
|
|
68
|
+
*/
|
|
69
|
+
function isNewer(a, b) {
|
|
70
|
+
const pa = a.split('.').map(Number);
|
|
71
|
+
const pb = b.split('.').map(Number);
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
74
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fetch latest version from npm registry using native fetch (Node 18+).
|
|
81
|
+
*/
|
|
82
|
+
async function fetchLatestVersion() {
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT);
|
|
85
|
+
try {
|
|
86
|
+
const resp = await fetch('https://registry.npmjs.org/magector/latest', {
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
headers: { 'Accept': 'application/json' }
|
|
89
|
+
});
|
|
90
|
+
if (!resp.ok) return null;
|
|
91
|
+
const data = await resp.json();
|
|
92
|
+
return data.version || null;
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check for updates and self-update if a newer version is available.
|
|
100
|
+
*
|
|
101
|
+
* When a newer version is found, re-execs the CLI command via
|
|
102
|
+
* `npx -y magector@<latest> <original args>` so npx downloads
|
|
103
|
+
* the new version automatically. The current process exits after.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} command - The CLI command being run
|
|
106
|
+
* @param {string[]} originalArgs - The original process.argv.slice(2)
|
|
107
|
+
*/
|
|
108
|
+
export async function checkForUpdate(command, originalArgs) {
|
|
109
|
+
// Skip for commands that don't need update checks
|
|
110
|
+
if (SKIP_COMMANDS.has(command)) return;
|
|
111
|
+
|
|
112
|
+
// Skip if MAGECTOR_NO_UPDATE is set (for CI, testing, or re-exec guard)
|
|
113
|
+
if (process.env.MAGECTOR_NO_UPDATE) return;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const current = getCurrentVersion();
|
|
117
|
+
if (!current) return;
|
|
118
|
+
|
|
119
|
+
const cachePath = getCachePath();
|
|
120
|
+
const cached = readCache(cachePath);
|
|
121
|
+
|
|
122
|
+
// Cache hit — check if we already know about an update
|
|
123
|
+
if (cached && (Date.now() - cached.checkedAt) < CACHE_TTL) {
|
|
124
|
+
if (!isNewer(cached.latest, current)) return; // up to date
|
|
125
|
+
// Cached says there's an update — proceed to re-exec
|
|
126
|
+
return reExec(current, cached.latest, originalArgs);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fetch from registry
|
|
130
|
+
const latest = await fetchLatestVersion();
|
|
131
|
+
if (!latest) return;
|
|
132
|
+
|
|
133
|
+
writeCache(cachePath, latest);
|
|
134
|
+
|
|
135
|
+
if (!isNewer(latest, current)) return; // up to date
|
|
136
|
+
|
|
137
|
+
return reExec(current, latest, originalArgs);
|
|
138
|
+
} catch {
|
|
139
|
+
// Never block CLI on update check failure
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Re-exec the current command with the latest version.
|
|
145
|
+
*/
|
|
146
|
+
function reExec(current, latest, originalArgs) {
|
|
147
|
+
console.log(`\n⬆ Updating magector: v${current} → v${latest}...\n`);
|
|
148
|
+
try {
|
|
149
|
+
const cmd = `npx -y magector@${latest} ${originalArgs.join(' ')}`;
|
|
150
|
+
execSync(cmd, {
|
|
151
|
+
stdio: 'inherit',
|
|
152
|
+
env: { ...process.env, MAGECTOR_NO_UPDATE: '1' }
|
|
153
|
+
});
|
|
154
|
+
} catch (err) {
|
|
155
|
+
process.exit(err.status || 1);
|
|
156
|
+
}
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|