cursor-guard 2.1.1 → 3.1.0
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 +63 -11
- package/README.zh-CN.md +345 -293
- package/ROADMAP.md +834 -0
- package/SKILL.md +617 -557
- package/package.json +14 -5
- package/references/config-reference.md +175 -175
- package/references/config-reference.zh-CN.md +175 -175
- package/references/cursor-guard.example.json +0 -6
- package/references/lib/auto-backup.js +257 -530
- package/references/lib/core/backups.js +357 -0
- package/references/lib/core/core.test.js +859 -0
- package/references/lib/core/doctor-fix.js +237 -0
- package/references/lib/core/doctor.js +248 -0
- package/references/lib/core/restore.js +305 -0
- package/references/lib/core/snapshot.js +173 -0
- package/references/lib/core/status.js +163 -0
- package/references/lib/guard-doctor.js +46 -238
- package/references/lib/utils.js +371 -371
- package/references/mcp/mcp.test.js +279 -0
- package/references/mcp/server.js +198 -0
- package/references/quickstart.zh-CN.md +342 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const { runDiagnostics } = require('./doctor');
|
|
10
|
+
const { createGitSnapshot, createShadowCopy } = require('./snapshot');
|
|
11
|
+
const { listBackups, cleanShadowRetention } = require('./backups');
|
|
12
|
+
const { restoreFile, previewProjectRestore, executeProjectRestore, createPreRestoreSnapshot, validateShadowSource } = require('./restore');
|
|
13
|
+
const { runFixes } = require('./doctor-fix');
|
|
14
|
+
const { getBackupStatus } = require('./status');
|
|
15
|
+
|
|
16
|
+
let passed = 0;
|
|
17
|
+
let failed = 0;
|
|
18
|
+
|
|
19
|
+
function test(name, fn) {
|
|
20
|
+
try {
|
|
21
|
+
fn();
|
|
22
|
+
passed++;
|
|
23
|
+
console.log(` \x1b[32m✓\x1b[0m ${name}`);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
failed++;
|
|
26
|
+
console.log(` \x1b[31m✗\x1b[0m ${name}`);
|
|
27
|
+
console.log(` ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createTempGitRepo() {
|
|
32
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-core-test-'));
|
|
33
|
+
execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
|
34
|
+
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
|
|
35
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
|
|
36
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello world');
|
|
37
|
+
fs.mkdirSync(path.join(tmpDir, 'src'));
|
|
38
|
+
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("app");');
|
|
39
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
40
|
+
execFileSync('git', ['commit', '-m', 'initial', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
41
|
+
return tmpDir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createTempDir() {
|
|
45
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'guard-core-test-'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cleanupDir(dir) {
|
|
49
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── core/doctor.js ──────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
console.log('\ncore/doctor:');
|
|
55
|
+
|
|
56
|
+
test('returns structured result with checks and summary', () => {
|
|
57
|
+
const tmpDir = createTempGitRepo();
|
|
58
|
+
try {
|
|
59
|
+
const result = runDiagnostics(tmpDir);
|
|
60
|
+
assert.ok(Array.isArray(result.checks), 'checks should be an array');
|
|
61
|
+
assert.ok(result.checks.length > 0, 'should have at least one check');
|
|
62
|
+
assert.ok(typeof result.summary === 'object', 'summary should be an object');
|
|
63
|
+
assert.ok(typeof result.summary.pass === 'number');
|
|
64
|
+
assert.ok(typeof result.summary.warn === 'number');
|
|
65
|
+
assert.ok(typeof result.summary.fail === 'number');
|
|
66
|
+
} finally {
|
|
67
|
+
cleanupDir(tmpDir);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('each check has name, status, and optional detail', () => {
|
|
72
|
+
const tmpDir = createTempGitRepo();
|
|
73
|
+
try {
|
|
74
|
+
const { checks } = runDiagnostics(tmpDir);
|
|
75
|
+
for (const c of checks) {
|
|
76
|
+
assert.ok(typeof c.name === 'string', `check name should be string, got ${typeof c.name}`);
|
|
77
|
+
assert.ok(['PASS', 'WARN', 'FAIL'].includes(c.status), `invalid status: ${c.status}`);
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
cleanupDir(tmpDir);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('detects git repo correctly', () => {
|
|
85
|
+
const tmpDir = createTempGitRepo();
|
|
86
|
+
try {
|
|
87
|
+
const { checks } = runDiagnostics(tmpDir);
|
|
88
|
+
const repoCheck = checks.find(c => c.name === 'Git repository');
|
|
89
|
+
assert.ok(repoCheck, 'should have Git repository check');
|
|
90
|
+
assert.strictEqual(repoCheck.status, 'PASS');
|
|
91
|
+
} finally {
|
|
92
|
+
cleanupDir(tmpDir);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('warns for non-git directory', () => {
|
|
97
|
+
const tmpDir = createTempDir();
|
|
98
|
+
try {
|
|
99
|
+
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'test');
|
|
100
|
+
const { checks } = runDiagnostics(tmpDir);
|
|
101
|
+
const repoCheck = checks.find(c => c.name === 'Git repository');
|
|
102
|
+
if (repoCheck) {
|
|
103
|
+
assert.strictEqual(repoCheck.status, 'WARN');
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
cleanupDir(tmpDir);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('summary counts match check statuses', () => {
|
|
111
|
+
const tmpDir = createTempGitRepo();
|
|
112
|
+
try {
|
|
113
|
+
const { checks, summary } = runDiagnostics(tmpDir);
|
|
114
|
+
let pass = 0, warn = 0, fail = 0;
|
|
115
|
+
for (const c of checks) {
|
|
116
|
+
if (c.status === 'PASS') pass++;
|
|
117
|
+
else if (c.status === 'WARN') warn++;
|
|
118
|
+
else if (c.status === 'FAIL') fail++;
|
|
119
|
+
}
|
|
120
|
+
assert.strictEqual(summary.pass, pass);
|
|
121
|
+
assert.strictEqual(summary.warn, warn);
|
|
122
|
+
assert.strictEqual(summary.fail, fail);
|
|
123
|
+
} finally {
|
|
124
|
+
cleanupDir(tmpDir);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('includes MCP server status check', () => {
|
|
129
|
+
const tmpDir = createTempGitRepo();
|
|
130
|
+
try {
|
|
131
|
+
const { checks } = runDiagnostics(tmpDir);
|
|
132
|
+
const mcpCheck = checks.find(c => c.name === 'MCP server');
|
|
133
|
+
assert.ok(mcpCheck, 'should have MCP server check');
|
|
134
|
+
assert.ok(['PASS', 'WARN'].includes(mcpCheck.status), `status should be PASS or WARN, got ${mcpCheck.status}`);
|
|
135
|
+
assert.ok(mcpCheck.detail, 'should have detail');
|
|
136
|
+
} finally {
|
|
137
|
+
cleanupDir(tmpDir);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── core/snapshot.js ────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
console.log('\ncore/snapshot (git):');
|
|
144
|
+
|
|
145
|
+
test('creates git snapshot and returns structured result', () => {
|
|
146
|
+
const tmpDir = createTempGitRepo();
|
|
147
|
+
try {
|
|
148
|
+
const { loadConfig } = require('../utils');
|
|
149
|
+
const { cfg } = loadConfig(tmpDir);
|
|
150
|
+
const result = createGitSnapshot(tmpDir, cfg);
|
|
151
|
+
assert.strictEqual(result.status, 'created');
|
|
152
|
+
assert.ok(result.commitHash, 'should have commitHash');
|
|
153
|
+
assert.ok(result.shortHash, 'should have shortHash');
|
|
154
|
+
assert.strictEqual(result.shortHash.length, 7);
|
|
155
|
+
assert.ok(typeof result.fileCount === 'number');
|
|
156
|
+
} finally {
|
|
157
|
+
cleanupDir(tmpDir);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('skips when tree is unchanged', () => {
|
|
162
|
+
const tmpDir = createTempGitRepo();
|
|
163
|
+
try {
|
|
164
|
+
const { loadConfig } = require('../utils');
|
|
165
|
+
const { cfg } = loadConfig(tmpDir);
|
|
166
|
+
createGitSnapshot(tmpDir, cfg);
|
|
167
|
+
const result2 = createGitSnapshot(tmpDir, cfg);
|
|
168
|
+
assert.strictEqual(result2.status, 'skipped');
|
|
169
|
+
assert.strictEqual(result2.reason, 'tree unchanged');
|
|
170
|
+
} finally {
|
|
171
|
+
cleanupDir(tmpDir);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('returns error for non-git directory', () => {
|
|
176
|
+
const tmpDir = createTempDir();
|
|
177
|
+
try {
|
|
178
|
+
const { loadConfig } = require('../utils');
|
|
179
|
+
const { cfg } = loadConfig(tmpDir);
|
|
180
|
+
const result = createGitSnapshot(tmpDir, cfg);
|
|
181
|
+
assert.strictEqual(result.status, 'error');
|
|
182
|
+
assert.ok(result.error);
|
|
183
|
+
} finally {
|
|
184
|
+
cleanupDir(tmpDir);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
console.log('\ncore/snapshot (shadow):');
|
|
189
|
+
|
|
190
|
+
test('creates shadow copy and returns structured result', () => {
|
|
191
|
+
const tmpDir = createTempDir();
|
|
192
|
+
try {
|
|
193
|
+
fs.writeFileSync(path.join(tmpDir, 'file.js'), 'content');
|
|
194
|
+
const { loadConfig } = require('../utils');
|
|
195
|
+
const { cfg } = loadConfig(tmpDir);
|
|
196
|
+
const result = createShadowCopy(tmpDir, cfg);
|
|
197
|
+
assert.strictEqual(result.status, 'created');
|
|
198
|
+
assert.ok(result.timestamp);
|
|
199
|
+
assert.ok(result.fileCount > 0);
|
|
200
|
+
assert.ok(result.snapshotDir);
|
|
201
|
+
assert.ok(fs.existsSync(result.snapshotDir));
|
|
202
|
+
} finally {
|
|
203
|
+
cleanupDir(tmpDir);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── core/backups.js ─────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
console.log('\ncore/backups:');
|
|
210
|
+
|
|
211
|
+
test('listBackups returns structured result with sources array', () => {
|
|
212
|
+
const tmpDir = createTempGitRepo();
|
|
213
|
+
try {
|
|
214
|
+
const result = listBackups(tmpDir);
|
|
215
|
+
assert.ok(Array.isArray(result.sources), 'sources should be an array');
|
|
216
|
+
} finally {
|
|
217
|
+
cleanupDir(tmpDir);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('listBackups finds git auto-backup after snapshot', () => {
|
|
222
|
+
const tmpDir = createTempGitRepo();
|
|
223
|
+
try {
|
|
224
|
+
const { loadConfig } = require('../utils');
|
|
225
|
+
const { cfg } = loadConfig(tmpDir);
|
|
226
|
+
createGitSnapshot(tmpDir, cfg);
|
|
227
|
+
const result = listBackups(tmpDir);
|
|
228
|
+
const autoBackups = result.sources.filter(s => s.type === 'git-auto-backup');
|
|
229
|
+
assert.ok(autoBackups.length > 0, 'should find auto-backup after snapshot');
|
|
230
|
+
assert.ok(autoBackups[0].commitHash);
|
|
231
|
+
assert.ok(autoBackups[0].shortHash);
|
|
232
|
+
} finally {
|
|
233
|
+
cleanupDir(tmpDir);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('listBackups finds shadow copies', () => {
|
|
238
|
+
const tmpDir = createTempDir();
|
|
239
|
+
try {
|
|
240
|
+
fs.writeFileSync(path.join(tmpDir, 'file.js'), 'content');
|
|
241
|
+
const { loadConfig } = require('../utils');
|
|
242
|
+
const { cfg } = loadConfig(tmpDir);
|
|
243
|
+
createShadowCopy(tmpDir, cfg);
|
|
244
|
+
const result = listBackups(tmpDir);
|
|
245
|
+
const shadows = result.sources.filter(s => s.type === 'shadow');
|
|
246
|
+
assert.ok(shadows.length > 0, 'should find shadow copy');
|
|
247
|
+
assert.ok(shadows[0].timestamp);
|
|
248
|
+
assert.ok(shadows[0].path);
|
|
249
|
+
} finally {
|
|
250
|
+
cleanupDir(tmpDir);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('listBackups returns globally time-sorted results across sources', () => {
|
|
255
|
+
const tmpDir = createTempGitRepo();
|
|
256
|
+
try {
|
|
257
|
+
const { loadConfig } = require('../utils');
|
|
258
|
+
const { cfg } = loadConfig(tmpDir);
|
|
259
|
+
createGitSnapshot(tmpDir, cfg);
|
|
260
|
+
|
|
261
|
+
// Create a shadow copy (non-git timestamp dir)
|
|
262
|
+
const backupDir = path.join(tmpDir, '.cursor-guard-backup');
|
|
263
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
264
|
+
const futureTs = '29990101_000000';
|
|
265
|
+
const futureDir = path.join(backupDir, futureTs);
|
|
266
|
+
fs.mkdirSync(futureDir);
|
|
267
|
+
fs.writeFileSync(path.join(futureDir, 'hello.txt'), 'x');
|
|
268
|
+
|
|
269
|
+
const result = listBackups(tmpDir);
|
|
270
|
+
assert.ok(result.sources.length >= 2, 'should have both git and shadow sources');
|
|
271
|
+
|
|
272
|
+
// Verify sorted descending by time
|
|
273
|
+
for (let i = 1; i < result.sources.length; i++) {
|
|
274
|
+
const cur = result.sources[i].timestamp;
|
|
275
|
+
const prev = result.sources[i - 1].timestamp;
|
|
276
|
+
if (cur && prev) {
|
|
277
|
+
assert.ok(Date.parse(prev) >= Date.parse(cur) || prev >= cur,
|
|
278
|
+
`sources[${i - 1}] (${prev}) should be >= sources[${i}] (${cur})`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
cleanupDir(tmpDir);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('listBackups before filter applies to snapshot ref', () => {
|
|
287
|
+
const tmpDir = createTempGitRepo();
|
|
288
|
+
try {
|
|
289
|
+
const { loadConfig } = require('../utils');
|
|
290
|
+
const { cfg } = loadConfig(tmpDir);
|
|
291
|
+
createGitSnapshot(tmpDir, cfg, { branchRef: 'refs/guard/snapshot', message: 'guard: manual snapshot' });
|
|
292
|
+
|
|
293
|
+
const result = listBackups(tmpDir, { before: '2020-01-01T00:00:00Z' });
|
|
294
|
+
const snaps = result.sources.filter(s => s.type === 'git-snapshot');
|
|
295
|
+
assert.strictEqual(snaps.length, 0, 'snapshot ref should be filtered out by before=2020');
|
|
296
|
+
} finally {
|
|
297
|
+
cleanupDir(tmpDir);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('cleanShadowRetention respects count mode', () => {
|
|
302
|
+
const tmpDir = createTempDir();
|
|
303
|
+
const backupDir = path.join(tmpDir, '.cursor-guard-backup');
|
|
304
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
305
|
+
try {
|
|
306
|
+
// Create 5 fake snapshot dirs
|
|
307
|
+
for (let i = 0; i < 5; i++) {
|
|
308
|
+
const name = `20260301_10000${i}`;
|
|
309
|
+
const dir = path.join(backupDir, name);
|
|
310
|
+
fs.mkdirSync(dir);
|
|
311
|
+
fs.writeFileSync(path.join(dir, 'f.txt'), 'x');
|
|
312
|
+
}
|
|
313
|
+
const cfg = {
|
|
314
|
+
retention: { mode: 'count', max_count: 2, days: 30, max_size_mb: 500 },
|
|
315
|
+
};
|
|
316
|
+
const result = cleanShadowRetention(backupDir, cfg);
|
|
317
|
+
assert.strictEqual(result.removed, 3);
|
|
318
|
+
assert.strictEqual(result.mode, 'count');
|
|
319
|
+
// Should have 2 dirs left
|
|
320
|
+
const remaining = fs.readdirSync(backupDir).filter(d => /^\d{8}_\d{6}$/.test(d));
|
|
321
|
+
assert.strictEqual(remaining.length, 2);
|
|
322
|
+
} finally {
|
|
323
|
+
cleanupDir(tmpDir);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ── core/restore.js ─────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
console.log('\ncore/restore:');
|
|
330
|
+
|
|
331
|
+
test('restoreFile restores from git source with pre-restore snapshot', () => {
|
|
332
|
+
const tmpDir = createTempGitRepo();
|
|
333
|
+
try {
|
|
334
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
335
|
+
|
|
336
|
+
// Modify file and leave it uncommitted (realistic scenario: user has unsaved work)
|
|
337
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'uncommitted changes');
|
|
338
|
+
|
|
339
|
+
const result = restoreFile(tmpDir, 'hello.txt', headHash, { preserveCurrent: true });
|
|
340
|
+
assert.strictEqual(result.status, 'restored');
|
|
341
|
+
assert.strictEqual(result.sourceType, 'git');
|
|
342
|
+
assert.ok(result.preRestoreRef, 'should have pre-restore ref when uncommitted changes exist');
|
|
343
|
+
|
|
344
|
+
const content = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
|
|
345
|
+
assert.strictEqual(content, 'hello world');
|
|
346
|
+
} finally {
|
|
347
|
+
cleanupDir(tmpDir);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('restoreFile skips pre-restore when working tree is clean', () => {
|
|
352
|
+
const tmpDir = createTempGitRepo();
|
|
353
|
+
try {
|
|
354
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
355
|
+
|
|
356
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'modified');
|
|
357
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
358
|
+
execFileSync('git', ['commit', '-m', 'modify', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
359
|
+
|
|
360
|
+
const result = restoreFile(tmpDir, 'hello.txt', headHash, { preserveCurrent: true });
|
|
361
|
+
assert.strictEqual(result.status, 'restored');
|
|
362
|
+
assert.strictEqual(result.sourceType, 'git');
|
|
363
|
+
assert.ok(!result.preRestoreRef, 'no pre-restore ref when tree is clean (HEAD is the restore point)');
|
|
364
|
+
|
|
365
|
+
const content = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
|
|
366
|
+
assert.strictEqual(content, 'hello world');
|
|
367
|
+
} finally {
|
|
368
|
+
cleanupDir(tmpDir);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('restoreFile restores from shadow copy', () => {
|
|
373
|
+
const tmpDir = createTempDir();
|
|
374
|
+
try {
|
|
375
|
+
fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'original');
|
|
376
|
+
const { loadConfig } = require('../utils');
|
|
377
|
+
const { cfg } = loadConfig(tmpDir);
|
|
378
|
+
const snap = createShadowCopy(tmpDir, cfg);
|
|
379
|
+
|
|
380
|
+
// Modify file
|
|
381
|
+
fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'changed');
|
|
382
|
+
|
|
383
|
+
const result = restoreFile(tmpDir, 'data.txt', snap.timestamp, { preserveCurrent: false });
|
|
384
|
+
assert.strictEqual(result.status, 'restored');
|
|
385
|
+
assert.strictEqual(result.sourceType, 'shadow');
|
|
386
|
+
|
|
387
|
+
const content = fs.readFileSync(path.join(tmpDir, 'data.txt'), 'utf-8');
|
|
388
|
+
assert.strictEqual(content, 'original');
|
|
389
|
+
} finally {
|
|
390
|
+
cleanupDir(tmpDir);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('restoreFile creates shadow pre-restore for non-git project', () => {
|
|
395
|
+
const tmpDir = createTempDir();
|
|
396
|
+
try {
|
|
397
|
+
fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'original');
|
|
398
|
+
const { loadConfig } = require('../utils');
|
|
399
|
+
const { cfg } = loadConfig(tmpDir);
|
|
400
|
+
const snap = createShadowCopy(tmpDir, cfg);
|
|
401
|
+
|
|
402
|
+
fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'modified-later');
|
|
403
|
+
|
|
404
|
+
const result = restoreFile(tmpDir, 'data.txt', snap.timestamp, { preserveCurrent: true });
|
|
405
|
+
assert.strictEqual(result.status, 'restored');
|
|
406
|
+
assert.ok(result.preRestoreShadow, 'should have preRestoreShadow for non-git project');
|
|
407
|
+
assert.ok(result.preRestoreShadow.startsWith('pre-restore-'), 'shadow dir should start with pre-restore-');
|
|
408
|
+
|
|
409
|
+
const preRestoreDir = path.join(tmpDir, '.cursor-guard-backup', result.preRestoreShadow);
|
|
410
|
+
assert.ok(fs.existsSync(path.join(preRestoreDir, 'data.txt')), 'pre-restore should contain the file');
|
|
411
|
+
const preserved = fs.readFileSync(path.join(preRestoreDir, 'data.txt'), 'utf-8');
|
|
412
|
+
assert.strictEqual(preserved, 'modified-later', 'pre-restore should preserve the current version');
|
|
413
|
+
|
|
414
|
+
const restored = fs.readFileSync(path.join(tmpDir, 'data.txt'), 'utf-8');
|
|
415
|
+
assert.strictEqual(restored, 'original', 'file should be restored to original');
|
|
416
|
+
} finally {
|
|
417
|
+
cleanupDir(tmpDir);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('restoreFile returns error for invalid source', () => {
|
|
422
|
+
const tmpDir = createTempGitRepo();
|
|
423
|
+
try {
|
|
424
|
+
const result = restoreFile(tmpDir, 'hello.txt', 'nonexistent-ref-abc123', { preserveCurrent: false });
|
|
425
|
+
assert.strictEqual(result.status, 'error');
|
|
426
|
+
assert.ok(result.error);
|
|
427
|
+
} finally {
|
|
428
|
+
cleanupDir(tmpDir);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('restoreFile rejects path-traversal shadow source', () => {
|
|
433
|
+
const tmpDir = createTempDir();
|
|
434
|
+
try {
|
|
435
|
+
fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'original');
|
|
436
|
+
const { loadConfig } = require('../utils');
|
|
437
|
+
const { cfg } = loadConfig(tmpDir);
|
|
438
|
+
createShadowCopy(tmpDir, cfg);
|
|
439
|
+
|
|
440
|
+
const result = restoreFile(tmpDir, 'data.txt', '../../etc', { preserveCurrent: false });
|
|
441
|
+
assert.strictEqual(result.status, 'error', 'path-traversal source should be rejected');
|
|
442
|
+
} finally {
|
|
443
|
+
cleanupDir(tmpDir);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('validateShadowSource accepts valid timestamps and rejects traversals', () => {
|
|
448
|
+
assert.strictEqual(validateShadowSource('20260321_143205').valid, true);
|
|
449
|
+
assert.strictEqual(validateShadowSource('pre-restore-20260321_143205').valid, true);
|
|
450
|
+
assert.strictEqual(validateShadowSource('../../etc').valid, false);
|
|
451
|
+
assert.strictEqual(validateShadowSource('..\\..\\Windows').valid, false);
|
|
452
|
+
assert.strictEqual(validateShadowSource('some-arbitrary-name').valid, false);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('restoreFile respects pre_restore_backup=never from config', () => {
|
|
456
|
+
const tmpDir = createTempGitRepo();
|
|
457
|
+
try {
|
|
458
|
+
fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'),
|
|
459
|
+
JSON.stringify({ pre_restore_backup: 'never', backup_strategy: 'git' }));
|
|
460
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
461
|
+
|
|
462
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'uncommitted changes');
|
|
463
|
+
|
|
464
|
+
const result = restoreFile(tmpDir, 'hello.txt', headHash);
|
|
465
|
+
assert.strictEqual(result.status, 'restored');
|
|
466
|
+
assert.ok(!result.preRestoreRef, 'should NOT create pre-restore when config says never');
|
|
467
|
+
} finally {
|
|
468
|
+
cleanupDir(tmpDir);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('createPreRestoreSnapshot creates ref under refs/guard/pre-restore/', () => {
|
|
473
|
+
const tmpDir = createTempGitRepo();
|
|
474
|
+
try {
|
|
475
|
+
// Make a change so snapshot is not skipped
|
|
476
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
|
|
477
|
+
const result = createPreRestoreSnapshot(tmpDir);
|
|
478
|
+
assert.strictEqual(result.status, 'created');
|
|
479
|
+
assert.ok(result.ref.startsWith('refs/guard/pre-restore/'));
|
|
480
|
+
assert.ok(result.shortHash);
|
|
481
|
+
} finally {
|
|
482
|
+
cleanupDir(tmpDir);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('createPreRestoreSnapshot excludes secrets from snapshot', () => {
|
|
487
|
+
const tmpDir = createTempGitRepo();
|
|
488
|
+
try {
|
|
489
|
+
fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET_KEY=abc123');
|
|
490
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
|
|
491
|
+
|
|
492
|
+
const result = createPreRestoreSnapshot(tmpDir);
|
|
493
|
+
assert.strictEqual(result.status, 'created');
|
|
494
|
+
|
|
495
|
+
const filesInSnapshot = execFileSync('git', ['ls-tree', '--name-only', '-r', result.ref], {
|
|
496
|
+
cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
|
|
497
|
+
}).trim().split('\n');
|
|
498
|
+
assert.ok(!filesInSnapshot.includes('.env'), '.env should be excluded from pre-restore snapshot');
|
|
499
|
+
assert.ok(filesInSnapshot.includes('hello.txt'), 'non-secret files should be included');
|
|
500
|
+
} finally {
|
|
501
|
+
cleanupDir(tmpDir);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('createPreRestoreSnapshot skips when no changes', () => {
|
|
506
|
+
const tmpDir = createTempGitRepo();
|
|
507
|
+
try {
|
|
508
|
+
const result = createPreRestoreSnapshot(tmpDir);
|
|
509
|
+
assert.strictEqual(result.status, 'skipped');
|
|
510
|
+
} finally {
|
|
511
|
+
cleanupDir(tmpDir);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('previewProjectRestore returns file list', () => {
|
|
516
|
+
const tmpDir = createTempGitRepo();
|
|
517
|
+
try {
|
|
518
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
519
|
+
|
|
520
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
|
|
521
|
+
fs.writeFileSync(path.join(tmpDir, 'new.txt'), 'new file');
|
|
522
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
523
|
+
execFileSync('git', ['commit', '-m', 'changes', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
524
|
+
|
|
525
|
+
const result = previewProjectRestore(tmpDir, headHash);
|
|
526
|
+
assert.strictEqual(result.status, 'ok');
|
|
527
|
+
assert.ok(Array.isArray(result.files));
|
|
528
|
+
assert.ok(result.totalChanged > 0);
|
|
529
|
+
const filePaths = result.files.map(f => f.path);
|
|
530
|
+
assert.ok(filePaths.includes('hello.txt'));
|
|
531
|
+
} finally {
|
|
532
|
+
cleanupDir(tmpDir);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// ── core/restore (executeProjectRestore) ─────────────────────────
|
|
537
|
+
|
|
538
|
+
console.log('\ncore/restore (executeProjectRestore):');
|
|
539
|
+
|
|
540
|
+
test('executeProjectRestore restores all changed files with uncommitted changes', () => {
|
|
541
|
+
const tmpDir = createTempGitRepo();
|
|
542
|
+
try {
|
|
543
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
544
|
+
|
|
545
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed-v2');
|
|
546
|
+
fs.writeFileSync(path.join(tmpDir, 'extra.txt'), 'extra');
|
|
547
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
548
|
+
execFileSync('git', ['commit', '-m', 'v2 changes', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
549
|
+
|
|
550
|
+
// Add uncommitted change so pre-restore snapshot is created
|
|
551
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'uncommitted');
|
|
552
|
+
|
|
553
|
+
const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: true });
|
|
554
|
+
assert.strictEqual(result.status, 'restored');
|
|
555
|
+
assert.ok(result.filesRestored > 0, 'should restore at least 1 file');
|
|
556
|
+
assert.ok(result.preRestoreRef, 'should have pre-restore ref');
|
|
557
|
+
|
|
558
|
+
const restored = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
|
|
559
|
+
assert.strictEqual(restored, 'hello world');
|
|
560
|
+
} finally {
|
|
561
|
+
cleanupDir(tmpDir);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('executeProjectRestore restores with clean tree (pre-restore skipped)', () => {
|
|
566
|
+
const tmpDir = createTempGitRepo();
|
|
567
|
+
try {
|
|
568
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
569
|
+
|
|
570
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed-v2');
|
|
571
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
572
|
+
execFileSync('git', ['commit', '-m', 'v2 changes', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
|
|
573
|
+
|
|
574
|
+
const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: true });
|
|
575
|
+
assert.strictEqual(result.status, 'restored');
|
|
576
|
+
assert.ok(result.filesRestored > 0, 'should restore at least 1 file');
|
|
577
|
+
// pre-restore skipped because working tree is clean — HEAD itself is the restore point
|
|
578
|
+
} finally {
|
|
579
|
+
cleanupDir(tmpDir);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test('executeProjectRestore detects dirty working tree against HEAD', () => {
|
|
584
|
+
const tmpDir = createTempGitRepo();
|
|
585
|
+
try {
|
|
586
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
587
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'dirty content');
|
|
588
|
+
|
|
589
|
+
const preview = previewProjectRestore(tmpDir, headHash);
|
|
590
|
+
assert.ok(preview.totalChanged > 0, 'preview should detect dirty file vs HEAD');
|
|
591
|
+
|
|
592
|
+
const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: false });
|
|
593
|
+
assert.strictEqual(result.status, 'restored');
|
|
594
|
+
assert.ok(result.filesRestored > 0, 'should restore dirty files');
|
|
595
|
+
assert.strictEqual(fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8'), 'hello world');
|
|
596
|
+
} finally {
|
|
597
|
+
cleanupDir(tmpDir);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('executeProjectRestore returns 0 files when already at target', () => {
|
|
602
|
+
const tmpDir = createTempGitRepo();
|
|
603
|
+
try {
|
|
604
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
605
|
+
const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: false });
|
|
606
|
+
assert.strictEqual(result.status, 'restored');
|
|
607
|
+
assert.strictEqual(result.filesRestored, 0);
|
|
608
|
+
} finally {
|
|
609
|
+
cleanupDir(tmpDir);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('executeProjectRestore errors on invalid source', () => {
|
|
614
|
+
const tmpDir = createTempGitRepo();
|
|
615
|
+
try {
|
|
616
|
+
const result = executeProjectRestore(tmpDir, 'nonexistent-ref');
|
|
617
|
+
assert.strictEqual(result.status, 'error');
|
|
618
|
+
} finally {
|
|
619
|
+
cleanupDir(tmpDir);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ── core/doctor-fix ─────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
console.log('\ncore/doctor-fix:');
|
|
626
|
+
|
|
627
|
+
test('runFixes dry-run reports actions without modifying', () => {
|
|
628
|
+
const tmpDir = createTempDir();
|
|
629
|
+
try {
|
|
630
|
+
const result = runFixes(tmpDir, { dryRun: true });
|
|
631
|
+
assert.ok(Array.isArray(result.actions));
|
|
632
|
+
assert.strictEqual(result.totalFixed, 0, 'dry-run should not fix anything');
|
|
633
|
+
const configAction = result.actions.find(a => a.name === 'Create config');
|
|
634
|
+
assert.ok(configAction, 'should report config action');
|
|
635
|
+
assert.strictEqual(configAction.status, 'skipped');
|
|
636
|
+
} finally {
|
|
637
|
+
cleanupDir(tmpDir);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('runFixes creates config and inits git on empty dir', () => {
|
|
642
|
+
const tmpDir = createTempDir();
|
|
643
|
+
try {
|
|
644
|
+
const result = runFixes(tmpDir, { dryRun: false });
|
|
645
|
+
assert.ok(result.totalFixed > 0, 'should fix at least 1 issue');
|
|
646
|
+
|
|
647
|
+
const configExists = fs.existsSync(path.join(tmpDir, '.cursor-guard.json'));
|
|
648
|
+
assert.ok(configExists, 'should create .cursor-guard.json');
|
|
649
|
+
|
|
650
|
+
const initAction = result.actions.find(a => a.name === 'Init Git repo');
|
|
651
|
+
assert.ok(initAction, 'should have init git action');
|
|
652
|
+
assert.strictEqual(initAction.status, 'fixed');
|
|
653
|
+
} finally {
|
|
654
|
+
cleanupDir(tmpDir);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('runFixes init git excludes secrets via .gitignore', () => {
|
|
659
|
+
const tmpDir = createTempDir();
|
|
660
|
+
try {
|
|
661
|
+
fs.writeFileSync(path.join(tmpDir, 'app.js'), 'console.log("ok")');
|
|
662
|
+
fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET=value');
|
|
663
|
+
fs.writeFileSync(path.join(tmpDir, 'credentials.json'), '{}');
|
|
664
|
+
|
|
665
|
+
const result = runFixes(tmpDir, { dryRun: false });
|
|
666
|
+
const initAction = result.actions.find(a => a.name === 'Init Git repo');
|
|
667
|
+
assert.strictEqual(initAction.status, 'fixed');
|
|
668
|
+
|
|
669
|
+
const tracked = execFileSync('git', ['ls-files'], {
|
|
670
|
+
cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
|
|
671
|
+
}).trim().split('\n');
|
|
672
|
+
assert.ok(!tracked.includes('.env'), '.env should not be tracked');
|
|
673
|
+
assert.ok(!tracked.includes('credentials.json'), 'credentials.json should not be tracked');
|
|
674
|
+
assert.ok(tracked.includes('app.js'), 'normal files should be tracked');
|
|
675
|
+
|
|
676
|
+
const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
|
|
677
|
+
assert.ok(gitignore.includes('.env'), '.gitignore should contain .env pattern');
|
|
678
|
+
assert.ok(gitignore.includes('.cursor-guard-backup/'), '.gitignore should contain backup dir');
|
|
679
|
+
} finally {
|
|
680
|
+
cleanupDir(tmpDir);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test('runFixes init git excludes secrets even with pre-existing .gitignore', () => {
|
|
685
|
+
const tmpDir = createTempDir();
|
|
686
|
+
try {
|
|
687
|
+
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n');
|
|
688
|
+
fs.writeFileSync(path.join(tmpDir, 'app.js'), 'console.log("ok")');
|
|
689
|
+
fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET=leak');
|
|
690
|
+
|
|
691
|
+
const result = runFixes(tmpDir, { dryRun: false });
|
|
692
|
+
const initAction = result.actions.find(a => a.name === 'Init Git repo');
|
|
693
|
+
assert.strictEqual(initAction.status, 'fixed');
|
|
694
|
+
|
|
695
|
+
const tracked = execFileSync('git', ['ls-files'], {
|
|
696
|
+
cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
|
|
697
|
+
}).trim().split('\n');
|
|
698
|
+
assert.ok(!tracked.includes('.env'), '.env should not be tracked even when .gitignore pre-exists');
|
|
699
|
+
assert.ok(tracked.includes('app.js'), 'normal files should be tracked');
|
|
700
|
+
|
|
701
|
+
const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
|
|
702
|
+
assert.ok(gitignore.includes('dist/'), 'original entries should be preserved');
|
|
703
|
+
assert.ok(gitignore.includes('.env'), 'secrets should be appended');
|
|
704
|
+
} finally {
|
|
705
|
+
cleanupDir(tmpDir);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test('runFixes is idempotent on already-configured repo', () => {
|
|
710
|
+
const tmpDir = createTempGitRepo();
|
|
711
|
+
try {
|
|
712
|
+
fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), '{"backup_strategy":"git"}');
|
|
713
|
+
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.cursor-guard-backup/\n');
|
|
714
|
+
|
|
715
|
+
const result = runFixes(tmpDir, { dryRun: false });
|
|
716
|
+
const fixed = result.actions.filter(a => a.status === 'fixed');
|
|
717
|
+
assert.strictEqual(fixed.length, 0, `should fix nothing, but fixed: ${JSON.stringify(fixed)}`);
|
|
718
|
+
} finally {
|
|
719
|
+
cleanupDir(tmpDir);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('runFixes adds gitignore entry when missing', () => {
|
|
724
|
+
const tmpDir = createTempGitRepo();
|
|
725
|
+
try {
|
|
726
|
+
fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), '{"backup_strategy":"git"}');
|
|
727
|
+
|
|
728
|
+
const result = runFixes(tmpDir, { dryRun: false });
|
|
729
|
+
const gitignoreAction = result.actions.find(a => a.name === 'Gitignore backup dir');
|
|
730
|
+
assert.ok(gitignoreAction);
|
|
731
|
+
assert.strictEqual(gitignoreAction.status, 'fixed');
|
|
732
|
+
|
|
733
|
+
const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
|
|
734
|
+
assert.ok(gitignore.includes('.cursor-guard-backup/'));
|
|
735
|
+
} finally {
|
|
736
|
+
cleanupDir(tmpDir);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test('runFixes removes stale lock file', () => {
|
|
741
|
+
const tmpDir = createTempGitRepo();
|
|
742
|
+
try {
|
|
743
|
+
const gDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
744
|
+
const lockPath = path.join(tmpDir, gDir, 'cursor-guard.lock');
|
|
745
|
+
fs.writeFileSync(lockPath, 'pid: 99999999');
|
|
746
|
+
fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), '{"backup_strategy":"git"}');
|
|
747
|
+
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.cursor-guard-backup/\n');
|
|
748
|
+
|
|
749
|
+
const result = runFixes(tmpDir, { dryRun: false });
|
|
750
|
+
const lockAction = result.actions.find(a => a.name === 'Remove stale lock');
|
|
751
|
+
assert.ok(lockAction, 'should have lock removal action');
|
|
752
|
+
assert.strictEqual(lockAction.status, 'fixed');
|
|
753
|
+
assert.ok(!fs.existsSync(lockPath), 'lock file should be removed');
|
|
754
|
+
} finally {
|
|
755
|
+
cleanupDir(tmpDir);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// ── core/status ─────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
console.log('\ncore/status:');
|
|
762
|
+
|
|
763
|
+
test('getBackupStatus returns structured result for git repo', () => {
|
|
764
|
+
const tmpDir = createTempGitRepo();
|
|
765
|
+
try {
|
|
766
|
+
const result = getBackupStatus(tmpDir);
|
|
767
|
+
assert.ok(typeof result.watcher === 'object', 'should have watcher');
|
|
768
|
+
assert.strictEqual(result.watcher.running, false);
|
|
769
|
+
assert.ok(typeof result.config === 'object', 'should have config');
|
|
770
|
+
assert.strictEqual(result.config.strategy, 'git');
|
|
771
|
+
assert.ok(typeof result.lastBackup === 'object', 'should have lastBackup');
|
|
772
|
+
assert.ok(typeof result.refs === 'object', 'should have refs');
|
|
773
|
+
assert.ok(typeof result.disk === 'object', 'should have disk');
|
|
774
|
+
assert.ok(result.disk.freeGB === null || typeof result.disk.freeGB === 'number');
|
|
775
|
+
} finally {
|
|
776
|
+
cleanupDir(tmpDir);
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test('getBackupStatus detects running watcher via lock file', () => {
|
|
781
|
+
const tmpDir = createTempGitRepo();
|
|
782
|
+
try {
|
|
783
|
+
const gDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
784
|
+
const lockPath = path.join(tmpDir, gDir, 'cursor-guard.lock');
|
|
785
|
+
// Use current PID to simulate running process
|
|
786
|
+
fs.writeFileSync(lockPath, `pid=${process.pid}\nstarted=2026-03-21T12:00:00Z`);
|
|
787
|
+
|
|
788
|
+
const result = getBackupStatus(tmpDir);
|
|
789
|
+
assert.strictEqual(result.watcher.running, true);
|
|
790
|
+
assert.strictEqual(result.watcher.pid, process.pid);
|
|
791
|
+
assert.strictEqual(result.watcher.startedAt, '2026-03-21T12:00:00Z');
|
|
792
|
+
} finally {
|
|
793
|
+
cleanupDir(tmpDir);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('getBackupStatus detects stale lock file', () => {
|
|
798
|
+
const tmpDir = createTempGitRepo();
|
|
799
|
+
try {
|
|
800
|
+
const gDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
801
|
+
const lockPath = path.join(tmpDir, gDir, 'cursor-guard.lock');
|
|
802
|
+
fs.writeFileSync(lockPath, 'pid=99999999\nstarted=2026-03-21T12:00:00Z');
|
|
803
|
+
|
|
804
|
+
const result = getBackupStatus(tmpDir);
|
|
805
|
+
assert.strictEqual(result.watcher.running, false);
|
|
806
|
+
assert.strictEqual(result.watcher.stale, true);
|
|
807
|
+
} finally {
|
|
808
|
+
cleanupDir(tmpDir);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('getBackupStatus finds last git backup after snapshot', () => {
|
|
813
|
+
const tmpDir = createTempGitRepo();
|
|
814
|
+
try {
|
|
815
|
+
const cfg = { protect: [], ignore: [], secrets_patterns: [], backup_strategy: 'git' };
|
|
816
|
+
createGitSnapshot(tmpDir, cfg, { branchRef: 'refs/guard/auto-backup' });
|
|
817
|
+
|
|
818
|
+
const result = getBackupStatus(tmpDir);
|
|
819
|
+
assert.ok(result.lastBackup.git, 'should have git lastBackup');
|
|
820
|
+
assert.ok(result.lastBackup.git.shortHash, 'should have shortHash');
|
|
821
|
+
assert.ok(result.refs.autoBackup, 'should have autoBackup ref info');
|
|
822
|
+
assert.ok(result.refs.autoBackup.commitCount > 0);
|
|
823
|
+
} finally {
|
|
824
|
+
cleanupDir(tmpDir);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test('getBackupStatus finds last shadow backup', () => {
|
|
829
|
+
const tmpDir = createTempGitRepo();
|
|
830
|
+
try {
|
|
831
|
+
const cfg = { protect: [], ignore: [], secrets_patterns: [], backup_strategy: 'shadow' };
|
|
832
|
+
createShadowCopy(tmpDir, cfg);
|
|
833
|
+
|
|
834
|
+
const result = getBackupStatus(tmpDir);
|
|
835
|
+
assert.ok(result.lastBackup.shadow, 'should have shadow lastBackup');
|
|
836
|
+
assert.ok(result.lastBackup.shadow.timestamp, 'should have timestamp');
|
|
837
|
+
assert.ok(result.lastBackup.shadow.fileCount > 0, 'should have files');
|
|
838
|
+
} finally {
|
|
839
|
+
cleanupDir(tmpDir);
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test('getBackupStatus works for non-git directory', () => {
|
|
844
|
+
const tmpDir = createTempDir();
|
|
845
|
+
try {
|
|
846
|
+
const result = getBackupStatus(tmpDir);
|
|
847
|
+
assert.strictEqual(result.watcher.running, false);
|
|
848
|
+
assert.ok(typeof result.config === 'object');
|
|
849
|
+
assert.ok(typeof result.refs === 'object');
|
|
850
|
+
assert.strictEqual(result.refs.preRestoreCount, 0);
|
|
851
|
+
} finally {
|
|
852
|
+
cleanupDir(tmpDir);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// ── Summary ─────────────────────────────────────────────────────
|
|
857
|
+
|
|
858
|
+
console.log(`\n${passed + failed} tests: \x1b[32m${passed} passed\x1b[0m` + (failed ? `, \x1b[31m${failed} failed\x1b[0m` : ''));
|
|
859
|
+
process.exit(failed > 0 ? 1 : 0);
|