cursor-guard 4.9.9 → 4.9.15

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 (35) hide show
  1. package/README.md +697 -697
  2. package/README.zh-CN.md +696 -696
  3. package/ROADMAP.md +1775 -1720
  4. package/SKILL.md +631 -629
  5. package/docs/RELEASE.md +197 -196
  6. package/docs/SNAPSHOT-BOOKMARK.md +47 -0
  7. package/package.json +70 -69
  8. package/references/dashboard/public/app.js +2079 -1832
  9. package/references/dashboard/public/style.css +1660 -1573
  10. package/references/dashboard/server.js +197 -4
  11. package/references/lib/core/backups.js +509 -492
  12. package/references/lib/core/core.test.js +1761 -1616
  13. package/references/lib/core/snapshot.js +441 -369
  14. package/references/mcp/mcp.test.js +381 -362
  15. package/references/mcp/server.js +404 -347
  16. package/references/vscode-extension/dist/{cursor-guard-ide-4.9.9.vsix → cursor-guard-ide-4.9.15.vsix} +0 -0
  17. package/references/vscode-extension/dist/dashboard/public/app.js +2079 -1832
  18. package/references/vscode-extension/dist/dashboard/public/style.css +1660 -1573
  19. package/references/vscode-extension/dist/dashboard/server.js +197 -4
  20. package/references/vscode-extension/dist/extension.js +780 -704
  21. package/references/vscode-extension/dist/guard-version.json +1 -1
  22. package/references/vscode-extension/dist/lib/auto-setup.js +201 -192
  23. package/references/vscode-extension/dist/lib/core/backups.js +509 -492
  24. package/references/vscode-extension/dist/lib/core/snapshot.js +441 -369
  25. package/references/vscode-extension/dist/lib/poller.js +161 -21
  26. package/references/vscode-extension/dist/lib/sidebar-webview.js +22 -0
  27. package/references/vscode-extension/dist/mcp/server.js +152 -35
  28. package/references/vscode-extension/dist/package.json +7 -1
  29. package/references/vscode-extension/dist/skill/ROADMAP.md +1775 -1720
  30. package/references/vscode-extension/dist/skill/SKILL.md +631 -629
  31. package/references/vscode-extension/extension.js +780 -704
  32. package/references/vscode-extension/lib/auto-setup.js +201 -192
  33. package/references/vscode-extension/lib/poller.js +161 -21
  34. package/references/vscode-extension/lib/sidebar-webview.js +22 -0
  35. package/references/vscode-extension/package.json +146 -140
@@ -1,355 +1,374 @@
1
- 'use strict';
2
-
3
- const { spawn } = require('child_process');
4
- const path = require('path');
5
- const fs = require('fs');
6
- const os = require('os');
7
- const { execFileSync } = require('child_process');
8
-
9
- let passed = 0;
10
- let failed = 0;
11
- let serverProcess = null;
12
- let msgId = 0;
13
-
14
- function log(color, sym, msg) {
15
- const c = { green: 32, red: 31 }[color] || 0;
16
- console.log(` \x1b[${c}m${sym}\x1b[0m ${msg}`);
17
- }
18
-
19
- function createTempGitRepo() {
20
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-mcp-test-'));
21
- execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
22
- execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
23
- execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
24
- fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello world');
25
- fs.mkdirSync(path.join(tmpDir, 'src'));
26
- fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("app");');
27
- execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
28
- execFileSync('git', ['commit', '-m', 'initial', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
29
- return tmpDir;
30
- }
31
-
32
- function startServer() {
33
- const serverPath = path.join(__dirname, 'server.js');
34
- serverProcess = spawn('node', [serverPath], {
35
- stdio: ['pipe', 'pipe', 'pipe'],
36
- });
37
- serverProcess.stderr.on('data', () => {});
38
- }
39
-
40
- function sendMessage(msg) {
41
- serverProcess.stdin.write(JSON.stringify(msg) + '\n');
42
- }
43
-
44
- function readResponse() {
45
- return new Promise((resolve, reject) => {
46
- let buffer = '';
47
- const timeout = setTimeout(() => {
48
- serverProcess.stdout.removeListener('data', onData);
49
- reject(new Error('timeout waiting for response'));
50
- }, 15000);
51
-
52
- function onData(chunk) {
53
- buffer += chunk.toString();
54
- const lines = buffer.split('\n');
55
- for (let i = 0; i < lines.length - 1; i++) {
56
- const line = lines[i].trim();
57
- if (!line) continue;
58
- try {
59
- const parsed = JSON.parse(line);
60
- if (parsed.id !== undefined || parsed.result !== undefined) {
61
- clearTimeout(timeout);
62
- serverProcess.stdout.removeListener('data', onData);
63
- resolve(parsed);
64
- return;
65
- }
66
- } catch { /* not valid JSON yet, continue */ }
67
- }
68
- buffer = lines[lines.length - 1];
69
- }
70
- serverProcess.stdout.on('data', onData);
71
- });
72
- }
73
-
74
- async function rpc(method, params) {
75
- const id = ++msgId;
76
- sendMessage({ jsonrpc: '2.0', id, method, params });
77
- return await readResponse();
78
- }
79
-
80
- async function test(name, fn) {
81
- try {
82
- await fn();
83
- passed++;
84
- log('green', '✓', name);
85
- } catch (e) {
86
- failed++;
87
- log('red', '✗', name);
88
- console.log(` ${e.message}`);
89
- }
90
- }
91
-
92
- async function run() {
93
- const tmpDir = createTempGitRepo();
94
-
95
- console.log('\nMCP Server:');
96
-
97
- startServer();
98
-
99
- // Initialize
100
- const initResp = await rpc('initialize', {
101
- protocolVersion: '2024-11-05',
102
- capabilities: {},
103
- clientInfo: { name: 'test-client', version: '1.0' },
104
- });
105
- sendMessage({ jsonrpc: '2.0', method: 'notifications/initialized' });
106
-
107
- await test('initialize returns server info', () => {
108
- if (!initResp.result) throw new Error('no result');
109
- if (!initResp.result.serverInfo) throw new Error('no serverInfo');
110
- if (initResp.result.serverInfo.name !== 'cursor-guard') throw new Error(`wrong name: ${initResp.result.serverInfo.name}`);
111
- });
112
-
113
- // List tools
114
- const toolsResp = await rpc('tools/list', {});
115
-
116
- await test('lists 9 tools', () => {
117
- const tools = toolsResp.result.tools;
118
- if (!tools) throw new Error('no tools');
119
- if (tools.length !== 9) throw new Error(`expected 9 tools, got ${tools.length}`);
120
- const names = tools.map(t => t.name).sort();
121
- const expected = ['alert_status', 'backup_status', 'dashboard', 'doctor', 'doctor_fix', 'list_backups', 'restore_file', 'restore_project', 'snapshot_now'].sort();
122
- if (JSON.stringify(names) !== JSON.stringify(expected)) {
123
- throw new Error(`tool names mismatch: ${JSON.stringify(names)}`);
124
- }
125
- });
126
-
127
- // Call doctor
128
- const doctorResp = await rpc('tools/call', {
129
- name: 'doctor',
130
- arguments: { path: tmpDir },
131
- });
132
-
133
- await test('doctor returns structured checks', () => {
134
- const content = doctorResp.result.content[0].text;
135
- const data = JSON.parse(content);
136
- if (!data.checks) throw new Error('no checks');
137
- if (!data.summary) throw new Error('no summary');
138
- if (typeof data.summary.pass !== 'number') throw new Error('summary.pass not a number');
139
- });
140
-
141
- // Call snapshot_now
142
- const snapResp = await rpc('tools/call', {
143
- name: 'snapshot_now',
144
- arguments: { path: tmpDir, strategy: 'git' },
145
- });
146
-
147
- await test('snapshot_now creates git snapshot', () => {
148
- const content = snapResp.result.content[0].text;
149
- const data = JSON.parse(content);
150
- if (!data.git) throw new Error('no git result');
151
- if (data.git.status !== 'created' && data.git.status !== 'skipped') {
152
- throw new Error(`unexpected status: ${data.git.status}`);
153
- }
154
- });
155
-
156
- // Call list_backups
157
- const listResp = await rpc('tools/call', {
158
- name: 'list_backups',
159
- arguments: { path: tmpDir },
160
- });
161
-
162
- await test('list_backups returns sources', () => {
163
- const content = listResp.result.content[0].text;
164
- const data = JSON.parse(content);
165
- if (!Array.isArray(data.sources)) throw new Error('sources not an array');
166
- });
167
-
168
- // Setup for restore tests
169
- const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
170
- fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
171
- execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
172
- execFileSync('git', ['commit', '-m', 'change', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
173
-
174
- // Call restore_project (preview)
175
- const previewResp = await rpc('tools/call', {
176
- name: 'restore_project',
177
- arguments: { path: tmpDir, source: headHash, preview: true },
178
- });
179
-
180
- await test('restore_project returns preview', () => {
181
- const content = previewResp.result.content[0].text;
182
- const data = JSON.parse(content);
183
- if (data.status !== 'ok') throw new Error(`status: ${data.status}`);
184
- if (!Array.isArray(data.files)) throw new Error('files not an array');
185
- });
186
-
187
- // Call restore_project (execute mode)
188
- // Re-stage the change so we have something to restore
189
- fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed-again');
190
- execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
191
- execFileSync('git', ['commit', '-m', 'change again', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
192
-
193
- const execResp = await rpc('tools/call', {
194
- name: 'restore_project',
195
- arguments: { path: tmpDir, source: headHash, preview: false, preserve_current: true },
196
- });
197
-
198
- await test('restore_project executes restore', () => {
199
- const content = execResp.result.content[0].text;
200
- const data = JSON.parse(content);
201
- if (data.status !== 'restored') throw new Error(`status: ${data.status}, error: ${data.error}`);
202
- if (typeof data.filesRestored !== 'number') throw new Error('filesRestored missing');
203
- const actual = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
204
- if (actual !== 'hello world') throw new Error(`content mismatch: "${actual}"`);
205
- });
206
-
207
- // Call restore_file
208
- fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'overwritten');
209
- execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
210
- execFileSync('git', ['commit', '-m', 'overwrite', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
211
-
212
- const restoreResp = await rpc('tools/call', {
213
- name: 'restore_file',
214
- arguments: { path: tmpDir, file: 'hello.txt', source: headHash, preserve_current: false },
215
- });
216
-
217
- await test('restore_file restores successfully', () => {
218
- const content = restoreResp.result.content[0].text;
219
- const data = JSON.parse(content);
220
- if (data.status !== 'restored') throw new Error(`status: ${data.status}, error: ${data.error}`);
221
- const actual = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
222
- if (actual !== 'hello world') throw new Error(`content mismatch: "${actual}"`);
223
- });
224
-
225
- // Call backup_status
226
- const statusResp = await rpc('tools/call', {
227
- name: 'backup_status',
228
- arguments: { path: tmpDir },
229
- });
230
-
231
- await test('backup_status returns structured status', () => {
232
- const content = statusResp.result.content[0].text;
233
- const data = JSON.parse(content);
234
- if (typeof data.watcher !== 'object') throw new Error('no watcher');
235
- if (typeof data.config !== 'object') throw new Error('no config');
236
- if (typeof data.lastBackup !== 'object') throw new Error('no lastBackup');
237
- if (typeof data.refs !== 'object') throw new Error('no refs');
238
- if (typeof data.disk !== 'object') throw new Error('no disk');
239
- });
240
-
241
- // Call doctor_fix (dry-run)
242
- const fixDryResp = await rpc('tools/call', {
243
- name: 'doctor_fix',
244
- arguments: { path: tmpDir, dry_run: true },
245
- });
246
-
247
- await test('doctor_fix dry-run returns actions without fixing', () => {
248
- const content = fixDryResp.result.content[0].text;
249
- const data = JSON.parse(content);
250
- if (!Array.isArray(data.actions)) throw new Error('actions not an array');
251
- if (data.totalFixed !== 0) throw new Error(`dry-run should not fix, but fixed ${data.totalFixed}`);
252
- });
253
-
254
- // Call doctor_fix (apply)
255
- const fixResp = await rpc('tools/call', {
256
- name: 'doctor_fix',
257
- arguments: { path: tmpDir },
258
- });
259
-
260
- await test('doctor_fix returns structured result', () => {
261
- const content = fixResp.result.content[0].text;
262
- const data = JSON.parse(content);
263
- if (!Array.isArray(data.actions)) throw new Error('actions not an array');
264
- if (typeof data.totalFixed !== 'number') throw new Error('totalFixed missing');
265
- });
266
-
267
- // Call dashboard
268
- const dashResp = await rpc('tools/call', {
269
- name: 'dashboard',
270
- arguments: { path: tmpDir },
271
- });
272
-
273
- await test('dashboard returns comprehensive health data', () => {
274
- const content = dashResp.result.content[0].text;
275
- const data = JSON.parse(content);
276
- if (typeof data.strategy !== 'string') throw new Error('no strategy');
277
- if (typeof data.counts !== 'object') throw new Error('no counts');
278
- if (typeof data.diskUsage !== 'object') throw new Error('no diskUsage');
279
- if (typeof data.protectionScope !== 'object') throw new Error('no protectionScope');
280
- if (typeof data.health !== 'object') throw new Error('no health');
281
- if (!['healthy', 'warning', 'critical'].includes(data.health.status)) throw new Error(`invalid health: ${data.health.status}`);
282
- if (typeof data.alerts !== 'object') throw new Error('no alerts');
283
- if (typeof data.watcher !== 'object') throw new Error('no watcher');
284
- });
285
-
286
- // Call alert_status
287
- const alertResp = await rpc('tools/call', {
288
- name: 'alert_status',
289
- arguments: { path: tmpDir },
290
- });
291
-
292
- await test('alert_status returns alert info', () => {
293
- const content = alertResp.result.content[0].text;
294
- const data = JSON.parse(content);
295
- if (typeof data.active !== 'boolean') throw new Error('no active field');
296
- if (data.active && !data.alert) throw new Error('active but no alert');
297
- if (!data.active && !data.message) throw new Error('inactive but no message');
298
- });
299
-
300
- // ── injectAlert verification ──────────────────────────────────
301
- // Write an active alert file so all tools should return _activeAlert
302
- const gitDir = path.join(tmpDir, '.git');
303
- const alertFile = path.join(gitDir, 'cursor-guard-alert.json');
304
- const fakeAlert = {
305
- type: 'high_change_velocity',
306
- detectedAt: Date.now(),
307
- timestamp: new Date().toISOString(),
308
- fileCount: 30,
309
- windowSeconds: 10,
310
- threshold: 20,
311
- expiresAt: new Date(Date.now() + 300000).toISOString(),
312
- recommendation: 'Test alert for injection verification',
313
- };
314
- fs.writeFileSync(alertFile, JSON.stringify(fakeAlert, null, 2));
315
-
316
- const toolsWithAlert = ['doctor', 'list_backups', 'backup_status', 'dashboard', 'doctor_fix', 'snapshot_now'];
317
- for (const toolName of toolsWithAlert) {
318
- const resp = await rpc('tools/call', {
319
- name: toolName,
320
- arguments: { path: tmpDir, ...(toolName === 'doctor_fix' ? { dry_run: true } : {}), ...(toolName === 'snapshot_now' ? { strategy: 'git' } : {}) },
321
- });
322
- await test(`${toolName} response contains _activeAlert when alert exists`, () => {
323
- const content = resp.result.content[0].text;
324
- const data = JSON.parse(content);
325
- if (!data._activeAlert) throw new Error(`_activeAlert missing from ${toolName} response`);
326
- if (data._activeAlert.type !== 'high_change_velocity') throw new Error(`wrong alert type: ${data._activeAlert.type}`);
327
- });
328
- }
329
-
330
- // Verify restore_file also injects
331
- fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'pre-alert-test');
332
- execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
333
- execFileSync('git', ['commit', '-m', 'for alert test', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
334
- const alertHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
335
- fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'post-alert-test');
336
- execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
337
- execFileSync('git', ['commit', '-m', 'for alert test 2', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
338
-
339
- const restoreAlertResp = await rpc('tools/call', {
340
- name: 'restore_file',
341
- arguments: { path: tmpDir, file: 'hello.txt', source: alertHead, preserve_current: false },
342
- });
343
- await test('restore_file response contains _activeAlert', () => {
344
- const content = restoreAlertResp.result.content[0].text;
345
- const data = JSON.parse(content);
346
- if (!data._activeAlert) throw new Error('_activeAlert missing from restore_file');
347
- });
348
-
349
- const rpResp = await rpc('tools/call', {
350
- name: 'restore_project',
351
- arguments: { path: tmpDir, source: alertHead, preview: true },
352
- });
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const { execFileSync } = require('child_process');
8
+
9
+ let passed = 0;
10
+ let failed = 0;
11
+ let serverProcess = null;
12
+ let msgId = 0;
13
+
14
+ function log(color, sym, msg) {
15
+ const c = { green: 32, red: 31 }[color] || 0;
16
+ console.log(` \x1b[${c}m${sym}\x1b[0m ${msg}`);
17
+ }
18
+
19
+ function createTempGitRepo() {
20
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-mcp-test-'));
21
+ execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
22
+ execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
23
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
24
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello world');
25
+ fs.mkdirSync(path.join(tmpDir, 'src'));
26
+ fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("app");');
27
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
28
+ execFileSync('git', ['commit', '-m', 'initial', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
29
+ return tmpDir;
30
+ }
31
+
32
+ function startServer() {
33
+ const serverPath = path.join(__dirname, 'server.js');
34
+ serverProcess = spawn('node', [serverPath], {
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ });
37
+ serverProcess.stderr.on('data', () => {});
38
+ }
39
+
40
+ function sendMessage(msg) {
41
+ serverProcess.stdin.write(JSON.stringify(msg) + '\n');
42
+ }
43
+
44
+ function readResponse() {
45
+ return new Promise((resolve, reject) => {
46
+ let buffer = '';
47
+ const timeout = setTimeout(() => {
48
+ serverProcess.stdout.removeListener('data', onData);
49
+ reject(new Error('timeout waiting for response'));
50
+ }, 15000);
51
+
52
+ function onData(chunk) {
53
+ buffer += chunk.toString();
54
+ const lines = buffer.split('\n');
55
+ for (let i = 0; i < lines.length - 1; i++) {
56
+ const line = lines[i].trim();
57
+ if (!line) continue;
58
+ try {
59
+ const parsed = JSON.parse(line);
60
+ if (parsed.id !== undefined || parsed.result !== undefined) {
61
+ clearTimeout(timeout);
62
+ serverProcess.stdout.removeListener('data', onData);
63
+ resolve(parsed);
64
+ return;
65
+ }
66
+ } catch { /* not valid JSON yet, continue */ }
67
+ }
68
+ buffer = lines[lines.length - 1];
69
+ }
70
+ serverProcess.stdout.on('data', onData);
71
+ });
72
+ }
73
+
74
+ async function rpc(method, params) {
75
+ const id = ++msgId;
76
+ sendMessage({ jsonrpc: '2.0', id, method, params });
77
+ return await readResponse();
78
+ }
79
+
80
+ async function test(name, fn) {
81
+ try {
82
+ await fn();
83
+ passed++;
84
+ log('green', '✓', name);
85
+ } catch (e) {
86
+ failed++;
87
+ log('red', '✗', name);
88
+ console.log(` ${e.message}`);
89
+ }
90
+ }
91
+
92
+ async function run() {
93
+ const tmpDir = createTempGitRepo();
94
+
95
+ console.log('\nMCP Server:');
96
+
97
+ startServer();
98
+
99
+ // Initialize
100
+ const initResp = await rpc('initialize', {
101
+ protocolVersion: '2024-11-05',
102
+ capabilities: {},
103
+ clientInfo: { name: 'test-client', version: '1.0' },
104
+ });
105
+ sendMessage({ jsonrpc: '2.0', method: 'notifications/initialized' });
106
+
107
+ await test('initialize returns server info', () => {
108
+ if (!initResp.result) throw new Error('no result');
109
+ if (!initResp.result.serverInfo) throw new Error('no serverInfo');
110
+ if (initResp.result.serverInfo.name !== 'cursor-guard') throw new Error(`wrong name: ${initResp.result.serverInfo.name}`);
111
+ });
112
+
113
+ // List tools
114
+ const toolsResp = await rpc('tools/list', {});
115
+
116
+ await test('lists 10 tools', () => {
117
+ const tools = toolsResp.result.tools;
118
+ if (!tools) throw new Error('no tools');
119
+ if (tools.length !== 10) throw new Error(`expected 10 tools, got ${tools.length}`);
120
+ const names = tools.map(t => t.name).sort();
121
+ const expected = ['alert_status', 'backup_status', 'dashboard', 'doctor', 'doctor_fix', 'list_backups', 'record_guard_event', 'restore_file', 'restore_project', 'snapshot_now'].sort();
122
+ if (JSON.stringify(names) !== JSON.stringify(expected)) {
123
+ throw new Error(`tool names mismatch: ${JSON.stringify(names)}`);
124
+ }
125
+ });
126
+
127
+ // Call doctor
128
+ const doctorResp = await rpc('tools/call', {
129
+ name: 'doctor',
130
+ arguments: { path: tmpDir },
131
+ });
132
+
133
+ await test('doctor returns structured checks', () => {
134
+ const content = doctorResp.result.content[0].text;
135
+ const data = JSON.parse(content);
136
+ if (!data.checks) throw new Error('no checks');
137
+ if (!data.summary) throw new Error('no summary');
138
+ if (typeof data.summary.pass !== 'number') throw new Error('summary.pass not a number');
139
+ });
140
+
141
+ // Call snapshot_now
142
+ const snapResp = await rpc('tools/call', {
143
+ name: 'snapshot_now',
144
+ arguments: { path: tmpDir, strategy: 'git' },
145
+ });
146
+
147
+ await test('snapshot_now creates git snapshot', () => {
148
+ const content = snapResp.result.content[0].text;
149
+ const data = JSON.parse(content);
150
+ if (!data.git) throw new Error('no git result');
151
+ if (data.git.status !== 'created' && data.git.status !== 'skipped') {
152
+ throw new Error(`unexpected status: ${data.git.status}`);
153
+ }
154
+ });
155
+
156
+ const recordEventResp = await rpc('tools/call', {
157
+ name: 'record_guard_event',
158
+ arguments: { path: tmpDir, event: 'list_backups:after_snapshot', detail: 'Audit bookmark after snapshot_now' },
159
+ });
160
+ await test('record_guard_event creates git bookmark with Guard-Event', () => {
161
+ const content = recordEventResp.result.content[0].text;
162
+ const data = JSON.parse(content);
163
+ if (!data.git) throw new Error('no git result');
164
+ if (data.git.status !== 'created') throw new Error(`expected created, got ${data.git.status}`);
165
+ const body = execFileSync('git', ['log', '-1', '--format=%B', data.git.commitHash], { cwd: tmpDir, encoding: 'utf8' });
166
+ if (!body.includes('Guard-Event: list_backups:after_snapshot')) throw new Error('Guard-Event trailer missing');
167
+ if (!body.includes('Trigger: mcp-event')) throw new Error('Trigger mcp-event missing');
168
+ });
169
+
170
+ // Call list_backups
171
+ const listResp = await rpc('tools/call', {
172
+ name: 'list_backups',
173
+ arguments: { path: tmpDir },
174
+ });
175
+
176
+ await test('list_backups returns sources', () => {
177
+ const content = listResp.result.content[0].text;
178
+ const data = JSON.parse(content);
179
+ if (!Array.isArray(data.sources)) throw new Error('sources not an array');
180
+ });
181
+
182
+ // Setup for restore tests
183
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
184
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
185
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
186
+ execFileSync('git', ['commit', '-m', 'change', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
187
+
188
+ // Call restore_project (preview)
189
+ const previewResp = await rpc('tools/call', {
190
+ name: 'restore_project',
191
+ arguments: { path: tmpDir, source: headHash, preview: true },
192
+ });
193
+
194
+ await test('restore_project returns preview', () => {
195
+ const content = previewResp.result.content[0].text;
196
+ const data = JSON.parse(content);
197
+ if (data.status !== 'ok') throw new Error(`status: ${data.status}`);
198
+ if (!Array.isArray(data.files)) throw new Error('files not an array');
199
+ });
200
+
201
+ // Call restore_project (execute mode)
202
+ // Re-stage the change so we have something to restore
203
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed-again');
204
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
205
+ execFileSync('git', ['commit', '-m', 'change again', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
206
+
207
+ const execResp = await rpc('tools/call', {
208
+ name: 'restore_project',
209
+ arguments: { path: tmpDir, source: headHash, preview: false, preserve_current: true },
210
+ });
211
+
212
+ await test('restore_project executes restore', () => {
213
+ const content = execResp.result.content[0].text;
214
+ const data = JSON.parse(content);
215
+ if (data.status !== 'restored') throw new Error(`status: ${data.status}, error: ${data.error}`);
216
+ if (typeof data.filesRestored !== 'number') throw new Error('filesRestored missing');
217
+ const actual = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
218
+ if (actual !== 'hello world') throw new Error(`content mismatch: "${actual}"`);
219
+ });
220
+
221
+ // Call restore_file
222
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'overwritten');
223
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
224
+ execFileSync('git', ['commit', '-m', 'overwrite', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
225
+
226
+ const restoreResp = await rpc('tools/call', {
227
+ name: 'restore_file',
228
+ arguments: { path: tmpDir, file: 'hello.txt', source: headHash, preserve_current: false },
229
+ });
230
+
231
+ await test('restore_file restores successfully', () => {
232
+ const content = restoreResp.result.content[0].text;
233
+ const data = JSON.parse(content);
234
+ if (data.status !== 'restored') throw new Error(`status: ${data.status}, error: ${data.error}`);
235
+ const actual = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
236
+ if (actual !== 'hello world') throw new Error(`content mismatch: "${actual}"`);
237
+ });
238
+
239
+ // Call backup_status
240
+ const statusResp = await rpc('tools/call', {
241
+ name: 'backup_status',
242
+ arguments: { path: tmpDir },
243
+ });
244
+
245
+ await test('backup_status returns structured status', () => {
246
+ const content = statusResp.result.content[0].text;
247
+ const data = JSON.parse(content);
248
+ if (typeof data.watcher !== 'object') throw new Error('no watcher');
249
+ if (typeof data.config !== 'object') throw new Error('no config');
250
+ if (typeof data.lastBackup !== 'object') throw new Error('no lastBackup');
251
+ if (typeof data.refs !== 'object') throw new Error('no refs');
252
+ if (typeof data.disk !== 'object') throw new Error('no disk');
253
+ });
254
+
255
+ // Call doctor_fix (dry-run)
256
+ const fixDryResp = await rpc('tools/call', {
257
+ name: 'doctor_fix',
258
+ arguments: { path: tmpDir, dry_run: true },
259
+ });
260
+
261
+ await test('doctor_fix dry-run returns actions without fixing', () => {
262
+ const content = fixDryResp.result.content[0].text;
263
+ const data = JSON.parse(content);
264
+ if (!Array.isArray(data.actions)) throw new Error('actions not an array');
265
+ if (data.totalFixed !== 0) throw new Error(`dry-run should not fix, but fixed ${data.totalFixed}`);
266
+ });
267
+
268
+ // Call doctor_fix (apply)
269
+ const fixResp = await rpc('tools/call', {
270
+ name: 'doctor_fix',
271
+ arguments: { path: tmpDir },
272
+ });
273
+
274
+ await test('doctor_fix returns structured result', () => {
275
+ const content = fixResp.result.content[0].text;
276
+ const data = JSON.parse(content);
277
+ if (!Array.isArray(data.actions)) throw new Error('actions not an array');
278
+ if (typeof data.totalFixed !== 'number') throw new Error('totalFixed missing');
279
+ });
280
+
281
+ // Call dashboard
282
+ const dashResp = await rpc('tools/call', {
283
+ name: 'dashboard',
284
+ arguments: { path: tmpDir },
285
+ });
286
+
287
+ await test('dashboard returns comprehensive health data', () => {
288
+ const content = dashResp.result.content[0].text;
289
+ const data = JSON.parse(content);
290
+ if (typeof data.strategy !== 'string') throw new Error('no strategy');
291
+ if (typeof data.counts !== 'object') throw new Error('no counts');
292
+ if (typeof data.diskUsage !== 'object') throw new Error('no diskUsage');
293
+ if (typeof data.protectionScope !== 'object') throw new Error('no protectionScope');
294
+ if (typeof data.health !== 'object') throw new Error('no health');
295
+ if (!['healthy', 'warning', 'critical'].includes(data.health.status)) throw new Error(`invalid health: ${data.health.status}`);
296
+ if (typeof data.alerts !== 'object') throw new Error('no alerts');
297
+ if (typeof data.watcher !== 'object') throw new Error('no watcher');
298
+ });
299
+
300
+ // Call alert_status
301
+ const alertResp = await rpc('tools/call', {
302
+ name: 'alert_status',
303
+ arguments: { path: tmpDir },
304
+ });
305
+
306
+ await test('alert_status returns alert info', () => {
307
+ const content = alertResp.result.content[0].text;
308
+ const data = JSON.parse(content);
309
+ if (typeof data.active !== 'boolean') throw new Error('no active field');
310
+ if (data.active && !data.alert) throw new Error('active but no alert');
311
+ if (!data.active && !data.message) throw new Error('inactive but no message');
312
+ });
313
+
314
+ // ── injectAlert verification ──────────────────────────────────
315
+ // Write an active alert file so all tools should return _activeAlert
316
+ const gitDir = path.join(tmpDir, '.git');
317
+ const alertFile = path.join(gitDir, 'cursor-guard-alert.json');
318
+ const fakeAlert = {
319
+ type: 'high_change_velocity',
320
+ detectedAt: Date.now(),
321
+ timestamp: new Date().toISOString(),
322
+ fileCount: 30,
323
+ windowSeconds: 10,
324
+ threshold: 20,
325
+ expiresAt: new Date(Date.now() + 300000).toISOString(),
326
+ recommendation: 'Test alert for injection verification',
327
+ };
328
+ fs.writeFileSync(alertFile, JSON.stringify(fakeAlert, null, 2));
329
+
330
+ const toolsWithAlert = ['doctor', 'list_backups', 'backup_status', 'dashboard', 'doctor_fix', 'snapshot_now', 'record_guard_event'];
331
+ for (const toolName of toolsWithAlert) {
332
+ const resp = await rpc('tools/call', {
333
+ name: toolName,
334
+ arguments: {
335
+ path: tmpDir,
336
+ ...(toolName === 'doctor_fix' ? { dry_run: true } : {}),
337
+ ...(toolName === 'snapshot_now' ? { strategy: 'git' } : {}),
338
+ ...(toolName === 'record_guard_event' ? { event: 'mcp:test:alert_inject' } : {}),
339
+ },
340
+ });
341
+ await test(`${toolName} response contains _activeAlert when alert exists`, () => {
342
+ const content = resp.result.content[0].text;
343
+ const data = JSON.parse(content);
344
+ if (!data._activeAlert) throw new Error(`_activeAlert missing from ${toolName} response`);
345
+ if (data._activeAlert.type !== 'high_change_velocity') throw new Error(`wrong alert type: ${data._activeAlert.type}`);
346
+ });
347
+ }
348
+
349
+ // Verify restore_file also injects
350
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'pre-alert-test');
351
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
352
+ execFileSync('git', ['commit', '-m', 'for alert test', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
353
+ const alertHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
354
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'post-alert-test');
355
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
356
+ execFileSync('git', ['commit', '-m', 'for alert test 2', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
357
+
358
+ const restoreAlertResp = await rpc('tools/call', {
359
+ name: 'restore_file',
360
+ arguments: { path: tmpDir, file: 'hello.txt', source: alertHead, preserve_current: false },
361
+ });
362
+ await test('restore_file response contains _activeAlert', () => {
363
+ const content = restoreAlertResp.result.content[0].text;
364
+ const data = JSON.parse(content);
365
+ if (!data._activeAlert) throw new Error('_activeAlert missing from restore_file');
366
+ });
367
+
368
+ const rpResp = await rpc('tools/call', {
369
+ name: 'restore_project',
370
+ arguments: { path: tmpDir, source: alertHead, preview: true },
371
+ });
353
372
  await test('restore_project response contains _activeAlert', () => {
354
373
  const content = rpResp.result.content[0].text;
355
374
  const data = JSON.parse(content);
@@ -410,13 +429,13 @@ async function run() {
410
429
  // Cleanup
411
430
  serverProcess.kill();
412
431
  fs.rmSync(tmpDir, { recursive: true, force: true });
413
-
414
- console.log(`\n${passed + failed} tests: \x1b[32m${passed} passed\x1b[0m` + (failed ? `, \x1b[31m${failed} failed\x1b[0m` : ''));
415
- process.exit(failed > 0 ? 1 : 0);
416
- }
417
-
418
- run().catch(err => {
419
- console.error('Test runner error:', err);
420
- if (serverProcess) serverProcess.kill();
421
- process.exit(1);
422
- });
432
+
433
+ console.log(`\n${passed + failed} tests: \x1b[32m${passed} passed\x1b[0m` + (failed ? `, \x1b[31m${failed} failed\x1b[0m` : ''));
434
+ process.exit(failed > 0 ? 1 : 0);
435
+ }
436
+
437
+ run().catch(err => {
438
+ console.error('Test runner error:', err);
439
+ if (serverProcess) serverProcess.kill();
440
+ process.exit(1);
441
+ });