cursor-guard 2.1.1 → 4.0.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 +69 -11
- package/README.zh-CN.md +351 -293
- package/ROADMAP.md +1040 -0
- package/SKILL.md +631 -557
- package/package.json +14 -5
- package/references/config-reference.md +215 -175
- package/references/config-reference.zh-CN.md +215 -175
- package/references/cursor-guard.example.json +6 -6
- package/references/cursor-guard.schema.json +30 -0
- package/references/lib/auto-backup.js +315 -530
- package/references/lib/core/anomaly.js +217 -0
- package/references/lib/core/backups.js +357 -0
- package/references/lib/core/core.test.js +1459 -0
- package/references/lib/core/dashboard.js +208 -0
- package/references/lib/core/doctor-fix.js +237 -0
- package/references/lib/core/doctor.js +248 -0
- package/references/lib/core/restore.js +360 -0
- package/references/lib/core/snapshot.js +198 -0
- package/references/lib/core/status.js +163 -0
- package/references/lib/guard-doctor.js +46 -238
- package/references/lib/utils.js +438 -371
- package/references/mcp/mcp.test.js +374 -0
- package/references/mcp/server.js +252 -0
- package/references/quickstart.zh-CN.md +364 -0
|
@@ -1,530 +1,315 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { execFileSync } = require('child_process');
|
|
6
|
-
const {
|
|
7
|
-
color, loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir,
|
|
8
|
-
walkDir, filterFiles, buildManifest, loadManifest, saveManifest,
|
|
9
|
-
manifestChanged,
|
|
10
|
-
} = require('./utils');
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const repo = hasGit && isGitRepo(projectDir);
|
|
317
|
-
const gDir = repo ? getGitDir(projectDir) : null;
|
|
318
|
-
|
|
319
|
-
const backupDir = path.join(projectDir, '.cursor-guard-backup');
|
|
320
|
-
const logFilePath = path.join(backupDir, 'backup.log');
|
|
321
|
-
const lockFile = gDir
|
|
322
|
-
? path.join(gDir, 'cursor-guard.lock')
|
|
323
|
-
: path.join(backupDir, 'cursor-guard.lock');
|
|
324
|
-
const guardIndex = gDir ? path.join(gDir, 'cursor-guard-index') : null;
|
|
325
|
-
|
|
326
|
-
// Load config
|
|
327
|
-
let { cfg, loaded, error, warnings } = loadConfig(projectDir);
|
|
328
|
-
let interval = intervalOverride || cfg.auto_backup_interval_seconds || 60;
|
|
329
|
-
if (interval < 5) interval = 5;
|
|
330
|
-
let cfgMtime = 0;
|
|
331
|
-
const cfgPath = path.join(projectDir, '.cursor-guard.json');
|
|
332
|
-
try { cfgMtime = fs.statSync(cfgPath).mtimeMs; } catch { /* no config */ }
|
|
333
|
-
|
|
334
|
-
if (error) {
|
|
335
|
-
console.log(color.yellow(`[guard] WARNING: .cursor-guard.json parse error — using defaults. ${error}`));
|
|
336
|
-
} else if (loaded) {
|
|
337
|
-
console.log(color.cyan(`[guard] Config loaded protect=${cfg.protect.length} ignore=${cfg.ignore.length} strategy=${cfg.backup_strategy} git_retention=${cfg.git_retention.enabled ? 'on' : 'off'}`));
|
|
338
|
-
if (warnings && warnings.length > 0) {
|
|
339
|
-
for (const w of warnings) console.log(color.yellow(`[guard] WARNING: ${w}`));
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Strategy check
|
|
344
|
-
const needsGit = cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both';
|
|
345
|
-
if (needsGit && !repo) {
|
|
346
|
-
if (!hasGit) {
|
|
347
|
-
console.log(color.red(`[guard] ERROR: backup_strategy='${cfg.backup_strategy}' requires Git, but git is not installed.`));
|
|
348
|
-
console.log(color.yellow(" Either install Git or set backup_strategy to 'shadow' in .cursor-guard.json."));
|
|
349
|
-
process.exit(1);
|
|
350
|
-
}
|
|
351
|
-
console.log(color.red(`[guard] ERROR: backup_strategy='${cfg.backup_strategy}' but directory is not a Git repo.`));
|
|
352
|
-
console.log(color.yellow(" Run 'git init' first, or set backup_strategy to 'shadow'."));
|
|
353
|
-
process.exit(1);
|
|
354
|
-
}
|
|
355
|
-
if (!repo && cfg.backup_strategy === 'shadow') {
|
|
356
|
-
console.log(color.cyan('[guard] Non-Git directory detected. Running in shadow-only mode.'));
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Ensure backup dir
|
|
360
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
361
|
-
|
|
362
|
-
// Lock file with stale detection
|
|
363
|
-
if (fs.existsSync(lockFile)) {
|
|
364
|
-
let stale = false;
|
|
365
|
-
try {
|
|
366
|
-
const content = fs.readFileSync(lockFile, 'utf-8');
|
|
367
|
-
const pidMatch = content.match(/pid=(\d+)/);
|
|
368
|
-
if (pidMatch) {
|
|
369
|
-
const oldPid = parseInt(pidMatch[1], 10);
|
|
370
|
-
if (!isProcessAlive(oldPid)) {
|
|
371
|
-
stale = true;
|
|
372
|
-
console.log(color.yellow(`[guard] Stale lock detected (pid ${oldPid} not running). Cleaning up.`));
|
|
373
|
-
fs.unlinkSync(lockFile);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
} catch { /* ignore */ }
|
|
377
|
-
if (!stale) {
|
|
378
|
-
console.log(color.red(`[guard] ERROR: Lock file exists (${lockFile}).`));
|
|
379
|
-
console.log(color.red(' If no other instance is running, delete it and retry.'));
|
|
380
|
-
process.exit(1);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
try {
|
|
384
|
-
fs.writeFileSync(lockFile, `pid=${process.pid}\nstarted=${new Date().toISOString()}`, { flag: 'wx' });
|
|
385
|
-
} catch (e) {
|
|
386
|
-
if (e.code === 'EEXIST') {
|
|
387
|
-
console.log(color.red('[guard] ERROR: Another instance just acquired the lock.'));
|
|
388
|
-
process.exit(1);
|
|
389
|
-
}
|
|
390
|
-
throw e;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Cleanup on exit
|
|
394
|
-
function cleanup() {
|
|
395
|
-
try { if (guardIndex) fs.unlinkSync(guardIndex); } catch { /* ignore */ }
|
|
396
|
-
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
|
|
397
|
-
}
|
|
398
|
-
process.on('SIGINT', () => { cleanup(); console.log(color.cyan('\n[guard] Stopped.')); process.exit(0); });
|
|
399
|
-
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
400
|
-
process.on('exit', cleanup);
|
|
401
|
-
|
|
402
|
-
// Git-specific setup
|
|
403
|
-
const branchRef = 'refs/guard/auto-backup';
|
|
404
|
-
const legacyRef = 'refs/heads/cursor-guard/auto-backup';
|
|
405
|
-
if (repo) {
|
|
406
|
-
const exists = git(['rev-parse', '--verify', branchRef], { cwd: projectDir, allowFail: true });
|
|
407
|
-
if (!exists) {
|
|
408
|
-
// Migrate from legacy refs/heads/ location if it exists
|
|
409
|
-
const legacyHash = git(['rev-parse', '--verify', legacyRef], { cwd: projectDir, allowFail: true });
|
|
410
|
-
if (legacyHash) {
|
|
411
|
-
git(['update-ref', branchRef, legacyHash], { cwd: projectDir, allowFail: true });
|
|
412
|
-
git(['update-ref', '-d', legacyRef], { cwd: projectDir, allowFail: true });
|
|
413
|
-
console.log(color.green(`[guard] Migrated ${legacyRef} → ${branchRef}`));
|
|
414
|
-
} else {
|
|
415
|
-
const head = git(['rev-parse', 'HEAD'], { cwd: projectDir, allowFail: true });
|
|
416
|
-
if (head) {
|
|
417
|
-
git(['update-ref', branchRef, head], { cwd: projectDir, allowFail: true });
|
|
418
|
-
console.log(color.green(`[guard] Created ref: ${branchRef}`));
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const excludeFile = path.join(gDir, 'info', 'exclude');
|
|
424
|
-
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
|
425
|
-
const entry = '.cursor-guard-backup/';
|
|
426
|
-
let content = '';
|
|
427
|
-
try { content = fs.readFileSync(excludeFile, 'utf-8'); } catch { /* doesn't exist yet */ }
|
|
428
|
-
if (!content.includes(entry)) {
|
|
429
|
-
fs.appendFileSync(excludeFile, `\n${entry}\n`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const logger = createLogger(logFilePath);
|
|
434
|
-
|
|
435
|
-
// Global error handlers
|
|
436
|
-
process.on('uncaughtException', (err) => {
|
|
437
|
-
logger.error(`Uncaught exception: ${err.message}`);
|
|
438
|
-
cleanup();
|
|
439
|
-
process.exit(1);
|
|
440
|
-
});
|
|
441
|
-
process.on('unhandledRejection', (reason) => {
|
|
442
|
-
logger.error(`Unhandled rejection: ${reason}`);
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
// Banner
|
|
446
|
-
console.log('');
|
|
447
|
-
console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
|
|
448
|
-
console.log(color.cyan(`[guard] Strategy: ${cfg.backup_strategy} | Ref: ${branchRef} | Retention: ${cfg.retention.mode}`));
|
|
449
|
-
console.log(color.cyan(`[guard] Log: ${logFilePath}`));
|
|
450
|
-
console.log('');
|
|
451
|
-
|
|
452
|
-
// Main loop
|
|
453
|
-
let cycle = 0;
|
|
454
|
-
while (true) {
|
|
455
|
-
await sleep(interval * 1000);
|
|
456
|
-
cycle++;
|
|
457
|
-
|
|
458
|
-
// Hot-reload config every 10 cycles
|
|
459
|
-
if (cycle % 10 === 0) {
|
|
460
|
-
try {
|
|
461
|
-
const newMtime = fs.statSync(cfgPath).mtimeMs;
|
|
462
|
-
if (newMtime !== cfgMtime) {
|
|
463
|
-
const reload = loadConfig(projectDir);
|
|
464
|
-
if (reload.loaded && !reload.error) {
|
|
465
|
-
cfg = reload.cfg;
|
|
466
|
-
cfgMtime = newMtime;
|
|
467
|
-
logger.info('Config reloaded (file changed)');
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
} catch { /* no config file or read error, keep current */ }
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// Detect changes (manifest write is deferred until shadow copy succeeds)
|
|
474
|
-
let hasChanges = false;
|
|
475
|
-
let pendingManifest = null;
|
|
476
|
-
try {
|
|
477
|
-
if (repo) {
|
|
478
|
-
const dirty = git(['status', '--porcelain'], { cwd: projectDir, allowFail: true });
|
|
479
|
-
hasChanges = !!dirty;
|
|
480
|
-
} else {
|
|
481
|
-
const allFiles = walkDir(projectDir, projectDir);
|
|
482
|
-
const filtered = filterFiles(allFiles, cfg);
|
|
483
|
-
const newManifest = buildManifest(filtered);
|
|
484
|
-
const oldManifest = loadManifest(backupDir);
|
|
485
|
-
hasChanges = manifestChanged(oldManifest, newManifest);
|
|
486
|
-
if (hasChanges) pendingManifest = newManifest;
|
|
487
|
-
}
|
|
488
|
-
} catch (e) {
|
|
489
|
-
logger.error(`Change detection failed: ${e.message}`);
|
|
490
|
-
continue;
|
|
491
|
-
}
|
|
492
|
-
if (!hasChanges) continue;
|
|
493
|
-
|
|
494
|
-
// Git snapshot (with error protection)
|
|
495
|
-
if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
|
|
496
|
-
try {
|
|
497
|
-
gitSnapshot(projectDir, branchRef, guardIndex, cfg, logger);
|
|
498
|
-
} catch (e) {
|
|
499
|
-
logger.error(`Git snapshot failed: ${e.message}`);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Shadow copy (with error protection)
|
|
504
|
-
if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
|
|
505
|
-
try {
|
|
506
|
-
shadowCopy(projectDir, backupDir, cfg, logger);
|
|
507
|
-
if (pendingManifest) {
|
|
508
|
-
saveManifest(backupDir, pendingManifest);
|
|
509
|
-
pendingManifest = null;
|
|
510
|
-
}
|
|
511
|
-
} catch (e) {
|
|
512
|
-
logger.error(`Shadow copy failed: ${e.message}`);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Periodic retention every 10 cycles
|
|
517
|
-
if (cycle % 10 === 0) {
|
|
518
|
-
try { shadowRetention(backupDir, cfg, logger); } catch (e) {
|
|
519
|
-
logger.error(`Shadow retention failed: ${e.message}`);
|
|
520
|
-
}
|
|
521
|
-
if (repo) {
|
|
522
|
-
try { gitRetention(branchRef, gDir, cfg, projectDir, logger); } catch (e) {
|
|
523
|
-
logger.error(`Git retention failed: ${e.message}`);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
module.exports = { runBackup };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const {
|
|
7
|
+
color, loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir,
|
|
8
|
+
walkDir, filterFiles, buildManifest, loadManifest, saveManifest,
|
|
9
|
+
manifestChanged, createLogger, unquoteGitPath,
|
|
10
|
+
} = require('./utils');
|
|
11
|
+
const { createGitSnapshot, createShadowCopy } = require('./core/snapshot');
|
|
12
|
+
const { cleanShadowRetention, cleanGitRetention } = require('./core/backups');
|
|
13
|
+
const { createChangeTracker, recordChange, checkAnomaly, saveAlert, clearExpiredAlert } = require('./core/anomaly');
|
|
14
|
+
|
|
15
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function sleep(ms) {
|
|
18
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isProcessAlive(pid) {
|
|
22
|
+
try { process.kill(pid, 0); return true; }
|
|
23
|
+
catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Main ────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
async function runBackup(projectDir, intervalOverride) {
|
|
29
|
+
const hasGit = gitAvailable();
|
|
30
|
+
const repo = hasGit && isGitRepo(projectDir);
|
|
31
|
+
const gDir = repo ? getGitDir(projectDir) : null;
|
|
32
|
+
|
|
33
|
+
const backupDir = path.join(projectDir, '.cursor-guard-backup');
|
|
34
|
+
const logFilePath = path.join(backupDir, 'backup.log');
|
|
35
|
+
const lockFile = gDir
|
|
36
|
+
? path.join(gDir, 'cursor-guard.lock')
|
|
37
|
+
: path.join(backupDir, 'cursor-guard.lock');
|
|
38
|
+
|
|
39
|
+
// Load config
|
|
40
|
+
let { cfg, loaded, error, warnings } = loadConfig(projectDir);
|
|
41
|
+
let interval = intervalOverride || cfg.auto_backup_interval_seconds || 60;
|
|
42
|
+
if (interval < 5) interval = 5;
|
|
43
|
+
let cfgMtime = 0;
|
|
44
|
+
const cfgPath = path.join(projectDir, '.cursor-guard.json');
|
|
45
|
+
try { cfgMtime = fs.statSync(cfgPath).mtimeMs; } catch { /* no config */ }
|
|
46
|
+
|
|
47
|
+
if (error) {
|
|
48
|
+
console.log(color.yellow(`[guard] WARNING: .cursor-guard.json parse error — using defaults. ${error}`));
|
|
49
|
+
} else if (loaded) {
|
|
50
|
+
console.log(color.cyan(`[guard] Config loaded protect=${cfg.protect.length} ignore=${cfg.ignore.length} strategy=${cfg.backup_strategy} git_retention=${cfg.git_retention.enabled ? 'on' : 'off'}`));
|
|
51
|
+
if (warnings && warnings.length > 0) {
|
|
52
|
+
for (const w of warnings) console.log(color.yellow(`[guard] WARNING: ${w}`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Strategy check
|
|
57
|
+
const needsGit = cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both';
|
|
58
|
+
if (needsGit && !repo) {
|
|
59
|
+
if (!hasGit) {
|
|
60
|
+
console.log(color.red(`[guard] ERROR: backup_strategy='${cfg.backup_strategy}' requires Git, but git is not installed.`));
|
|
61
|
+
console.log(color.yellow(" Either install Git or set backup_strategy to 'shadow' in .cursor-guard.json."));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
console.log(color.red(`[guard] ERROR: backup_strategy='${cfg.backup_strategy}' but directory is not a Git repo.`));
|
|
65
|
+
console.log(color.yellow(" Run 'git init' first, or set backup_strategy to 'shadow'."));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
if (!repo && cfg.backup_strategy === 'shadow') {
|
|
69
|
+
console.log(color.cyan('[guard] Non-Git directory detected. Running in shadow-only mode.'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Ensure backup dir
|
|
73
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
// Lock file with stale detection
|
|
76
|
+
if (fs.existsSync(lockFile)) {
|
|
77
|
+
let stale = false;
|
|
78
|
+
try {
|
|
79
|
+
const content = fs.readFileSync(lockFile, 'utf-8');
|
|
80
|
+
const pidMatch = content.match(/pid=(\d+)/);
|
|
81
|
+
if (pidMatch) {
|
|
82
|
+
const oldPid = parseInt(pidMatch[1], 10);
|
|
83
|
+
if (!isProcessAlive(oldPid)) {
|
|
84
|
+
stale = true;
|
|
85
|
+
console.log(color.yellow(`[guard] Stale lock detected (pid ${oldPid} not running). Cleaning up.`));
|
|
86
|
+
fs.unlinkSync(lockFile);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* ignore */ }
|
|
90
|
+
if (!stale) {
|
|
91
|
+
console.log(color.red(`[guard] ERROR: Lock file exists (${lockFile}).`));
|
|
92
|
+
console.log(color.red(' If no other instance is running, delete it and retry.'));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
fs.writeFileSync(lockFile, `pid=${process.pid}\nstarted=${new Date().toISOString()}`, { flag: 'wx' });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e.code === 'EEXIST') {
|
|
100
|
+
console.log(color.red('[guard] ERROR: Another instance just acquired the lock.'));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Cleanup on exit
|
|
107
|
+
const guardIndex = gDir ? path.join(gDir, 'cursor-guard-index') : null;
|
|
108
|
+
function cleanup() {
|
|
109
|
+
try { if (guardIndex) fs.unlinkSync(guardIndex); } catch { /* ignore */ }
|
|
110
|
+
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
|
|
111
|
+
}
|
|
112
|
+
process.on('SIGINT', () => { cleanup(); console.log(color.cyan('\n[guard] Stopped.')); process.exit(0); });
|
|
113
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
114
|
+
process.on('exit', cleanup);
|
|
115
|
+
|
|
116
|
+
// Git-specific setup
|
|
117
|
+
const branchRef = 'refs/guard/auto-backup';
|
|
118
|
+
const legacyRef = 'refs/heads/cursor-guard/auto-backup';
|
|
119
|
+
if (repo) {
|
|
120
|
+
const exists = git(['rev-parse', '--verify', branchRef], { cwd: projectDir, allowFail: true });
|
|
121
|
+
if (!exists) {
|
|
122
|
+
const legacyHash = git(['rev-parse', '--verify', legacyRef], { cwd: projectDir, allowFail: true });
|
|
123
|
+
if (legacyHash) {
|
|
124
|
+
git(['update-ref', branchRef, legacyHash], { cwd: projectDir, allowFail: true });
|
|
125
|
+
git(['update-ref', '-d', legacyRef], { cwd: projectDir, allowFail: true });
|
|
126
|
+
console.log(color.green(`[guard] Migrated ${legacyRef} → ${branchRef}`));
|
|
127
|
+
} else {
|
|
128
|
+
console.log(color.cyan(`[guard] Ref ${branchRef} does not exist yet — will be created on first snapshot.`));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const excludeFile = path.join(gDir, 'info', 'exclude');
|
|
133
|
+
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
|
134
|
+
const entry = '.cursor-guard-backup/';
|
|
135
|
+
let content = '';
|
|
136
|
+
try { content = fs.readFileSync(excludeFile, 'utf-8'); } catch { /* doesn't exist yet */ }
|
|
137
|
+
if (!content.includes(entry)) {
|
|
138
|
+
fs.appendFileSync(excludeFile, `\n${entry}\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const logger = createLogger(logFilePath);
|
|
143
|
+
|
|
144
|
+
// Global error handlers
|
|
145
|
+
process.on('uncaughtException', (err) => {
|
|
146
|
+
logger.error(`Uncaught exception: ${err.message}`);
|
|
147
|
+
cleanup();
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
150
|
+
process.on('unhandledRejection', (reason) => {
|
|
151
|
+
logger.error(`Unhandled rejection: ${reason}`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// V4: Initialize change tracker for anomaly detection
|
|
155
|
+
let tracker = createChangeTracker(cfg);
|
|
156
|
+
if (cfg.proactive_alert) {
|
|
157
|
+
console.log(color.cyan(`[guard] Proactive alert: ON (threshold: ${cfg.alert_thresholds.files_per_window} files / ${cfg.alert_thresholds.window_seconds}s)`));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Banner
|
|
161
|
+
console.log('');
|
|
162
|
+
console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
|
|
163
|
+
console.log(color.cyan(`[guard] Strategy: ${cfg.backup_strategy} | Ref: ${branchRef} | Retention: ${cfg.retention.mode}`));
|
|
164
|
+
console.log(color.cyan(`[guard] Log: ${logFilePath}`));
|
|
165
|
+
console.log('');
|
|
166
|
+
|
|
167
|
+
// Main loop
|
|
168
|
+
let cycle = 0;
|
|
169
|
+
while (true) {
|
|
170
|
+
await sleep(interval * 1000);
|
|
171
|
+
cycle++;
|
|
172
|
+
|
|
173
|
+
// Hot-reload config every 10 cycles
|
|
174
|
+
if (cycle % 10 === 0) {
|
|
175
|
+
try {
|
|
176
|
+
const newMtime = fs.statSync(cfgPath).mtimeMs;
|
|
177
|
+
if (newMtime !== cfgMtime) {
|
|
178
|
+
const reload = loadConfig(projectDir);
|
|
179
|
+
if (reload.loaded && !reload.error) {
|
|
180
|
+
cfg = reload.cfg;
|
|
181
|
+
cfgMtime = newMtime;
|
|
182
|
+
tracker = createChangeTracker(cfg);
|
|
183
|
+
logger.info('Config reloaded (file changed)');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch { /* no config file or read error, keep current */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Detect changes
|
|
190
|
+
let hasChanges = false;
|
|
191
|
+
let pendingManifest = null;
|
|
192
|
+
let lastManifest = null;
|
|
193
|
+
try {
|
|
194
|
+
if (repo) {
|
|
195
|
+
const dirty = git(['status', '--porcelain'], { cwd: projectDir, allowFail: true });
|
|
196
|
+
hasChanges = !!dirty;
|
|
197
|
+
} else {
|
|
198
|
+
const allFiles = walkDir(projectDir, projectDir);
|
|
199
|
+
const filtered = filterFiles(allFiles, cfg);
|
|
200
|
+
const newManifest = buildManifest(filtered);
|
|
201
|
+
lastManifest = loadManifest(backupDir);
|
|
202
|
+
hasChanges = manifestChanged(lastManifest, newManifest);
|
|
203
|
+
if (hasChanges) pendingManifest = newManifest;
|
|
204
|
+
}
|
|
205
|
+
} catch (e) {
|
|
206
|
+
logger.error(`Change detection failed: ${e.message}`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!hasChanges) continue;
|
|
210
|
+
|
|
211
|
+
// V4: Record change event and check for anomalies
|
|
212
|
+
let changedFileCount = 0;
|
|
213
|
+
if (repo) {
|
|
214
|
+
// Use execFileSync directly — git() helper's trim() strips leading spaces
|
|
215
|
+
// from porcelain output, corrupting the first line when it starts with ' '.
|
|
216
|
+
let porcelain = '';
|
|
217
|
+
try {
|
|
218
|
+
porcelain = execFileSync('git', ['status', '--porcelain'], {
|
|
219
|
+
cwd: projectDir, stdio: 'pipe', encoding: 'utf-8',
|
|
220
|
+
});
|
|
221
|
+
} catch { /* ignore */ }
|
|
222
|
+
if (porcelain) {
|
|
223
|
+
const lines = porcelain.split('\n').filter(Boolean);
|
|
224
|
+
if (cfg.protect.length === 0 && cfg.ignore.length === 0) {
|
|
225
|
+
changedFileCount = lines.length;
|
|
226
|
+
} else {
|
|
227
|
+
const changedPaths = lines.map(line => {
|
|
228
|
+
const filePart = line.substring(3);
|
|
229
|
+
const arrowIdx = filePart.indexOf(' -> ');
|
|
230
|
+
const raw = arrowIdx >= 0 ? filePart.substring(arrowIdx + 4) : filePart;
|
|
231
|
+
return unquoteGitPath(raw);
|
|
232
|
+
});
|
|
233
|
+
const fakeFiles = changedPaths.map(rel => ({ rel, full: path.join(projectDir, rel) }));
|
|
234
|
+
changedFileCount = filterFiles(fakeFiles, cfg).length;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} else if (pendingManifest) {
|
|
238
|
+
if (!lastManifest) {
|
|
239
|
+
changedFileCount = Object.keys(pendingManifest).length;
|
|
240
|
+
} else {
|
|
241
|
+
const newKeys = new Set(Object.keys(pendingManifest));
|
|
242
|
+
const oldKeys = new Set(Object.keys(lastManifest));
|
|
243
|
+
let diffCount = 0;
|
|
244
|
+
for (const k of newKeys) {
|
|
245
|
+
if (!oldKeys.has(k) || lastManifest[k].mtimeMs !== pendingManifest[k].mtimeMs || lastManifest[k].size !== pendingManifest[k].size) diffCount++;
|
|
246
|
+
}
|
|
247
|
+
for (const k of oldKeys) {
|
|
248
|
+
if (!newKeys.has(k)) diffCount++;
|
|
249
|
+
}
|
|
250
|
+
changedFileCount = diffCount;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
recordChange(tracker, changedFileCount);
|
|
255
|
+
const anomalyResult = checkAnomaly(tracker);
|
|
256
|
+
if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
|
|
257
|
+
saveAlert(projectDir, anomalyResult.alert);
|
|
258
|
+
logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Git snapshot via Core
|
|
262
|
+
if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
|
|
263
|
+
const snapResult = createGitSnapshot(projectDir, cfg, { branchRef });
|
|
264
|
+
if (snapResult.status === 'created') {
|
|
265
|
+
let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
|
|
266
|
+
if (snapResult.secretsExcluded) {
|
|
267
|
+
msg += ` [secrets excluded: ${snapResult.secretsExcluded.join(', ')}]`;
|
|
268
|
+
}
|
|
269
|
+
logger.log(msg);
|
|
270
|
+
} else if (snapResult.status === 'skipped') {
|
|
271
|
+
console.log(color.gray(`[guard] ${new Date().toTimeString().slice(0,8)} tree unchanged, skipped.`));
|
|
272
|
+
} else if (snapResult.status === 'error') {
|
|
273
|
+
logger.error(`Git snapshot failed: ${snapResult.error}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Shadow copy via Core
|
|
278
|
+
if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
|
|
279
|
+
const shadowResult = createShadowCopy(projectDir, cfg, { backupDir });
|
|
280
|
+
if (shadowResult.status === 'created') {
|
|
281
|
+
logger.log(`Shadow copy ${shadowResult.timestamp} (${shadowResult.fileCount} files)`);
|
|
282
|
+
if (pendingManifest) {
|
|
283
|
+
saveManifest(backupDir, pendingManifest);
|
|
284
|
+
pendingManifest = null;
|
|
285
|
+
}
|
|
286
|
+
} else if (shadowResult.status === 'error') {
|
|
287
|
+
logger.error(`Shadow copy failed: ${shadowResult.error}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Periodic retention every 10 cycles via Core
|
|
292
|
+
if (cycle % 10 === 0) {
|
|
293
|
+
const retResult = cleanShadowRetention(backupDir, cfg);
|
|
294
|
+
if (retResult.removed > 0) {
|
|
295
|
+
logger.log(`Retention (${retResult.mode}): cleaned ${retResult.removed} old snapshot(s)`, 'gray');
|
|
296
|
+
}
|
|
297
|
+
if (retResult.diskWarning === 'critically low') {
|
|
298
|
+
logger.error(`WARNING: disk critically low — ${retResult.diskFreeGB} GB free`);
|
|
299
|
+
} else if (retResult.diskWarning === 'low') {
|
|
300
|
+
logger.warn(`Disk note: ${retResult.diskFreeGB} GB free`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (repo) {
|
|
304
|
+
const gitRetResult = cleanGitRetention(branchRef, gDir, cfg, projectDir);
|
|
305
|
+
if (gitRetResult.rebuilt) {
|
|
306
|
+
logger.log(`Git retention (${gitRetResult.mode}): rebuilt branch with ${gitRetResult.kept} newest snapshots, pruned ${gitRetResult.pruned}. Run 'git gc' to reclaim space.`, 'gray');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
clearExpiredAlert(projectDir);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
module.exports = { runBackup };
|