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.
@@ -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, diskFreeGB, createLogger, matchesAny,
10
- } = require('./utils');
11
-
12
- // ── Secrets filter (remove from temp git index) ─────────────────
13
-
14
- function removeSecretsFromIndex(secretsPatterns, cwd, env, logger) {
15
- let files;
16
- try {
17
- const out = execFileSync('git', ['ls-files', '--cached'], {
18
- cwd, env, stdio: 'pipe', encoding: 'utf-8',
19
- }).trim();
20
- files = out ? out.split('\n').filter(Boolean) : [];
21
- } catch { return; }
22
-
23
- const excluded = [];
24
- for (const f of files) {
25
- const leaf = path.basename(f);
26
- if (matchesAny(secretsPatterns, f) || matchesAny(secretsPatterns, leaf)) {
27
- try {
28
- execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-q', '--', f], {
29
- cwd, env, stdio: 'pipe',
30
- });
31
- } catch { /* ignore */ }
32
- excluded.push(f);
33
- }
34
- }
35
- if (excluded.length > 0) {
36
- logger.warn(`Secrets auto-excluded: ${excluded.join(', ')}`);
37
- }
38
- }
39
-
40
- // ── Shadow retention cleanup ────────────────────────────────────
41
-
42
- function shadowRetention(backupDir, cfg, logger) {
43
- const { mode, days, max_count, max_size_mb } = cfg.retention;
44
- let dirs;
45
- try {
46
- dirs = fs.readdirSync(backupDir, { withFileTypes: true })
47
- .filter(d => d.isDirectory() && /^\d{8}_\d{6}$/.test(d.name))
48
- .map(d => d.name)
49
- .sort()
50
- .reverse();
51
- } catch { return; }
52
- if (!dirs || dirs.length === 0) return;
53
-
54
- let removed = 0;
55
-
56
- if (mode === 'days') {
57
- const cutoff = Date.now() - days * 86400000;
58
- for (const name of dirs) {
59
- const m = name.match(/^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/);
60
- if (!m) continue;
61
- const dt = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`);
62
- if (dt.getTime() < cutoff) {
63
- fs.rmSync(path.join(backupDir, name), { recursive: true, force: true });
64
- removed++;
65
- }
66
- }
67
- } else if (mode === 'count') {
68
- if (dirs.length > max_count) {
69
- for (const name of dirs.slice(max_count)) {
70
- fs.rmSync(path.join(backupDir, name), { recursive: true, force: true });
71
- removed++;
72
- }
73
- }
74
- } else if (mode === 'size') {
75
- let totalBytes = 0;
76
- try {
77
- const allFiles = walkDir(backupDir, backupDir);
78
- for (const f of allFiles) {
79
- try { totalBytes += fs.statSync(f.full).size; } catch { /* skip */ }
80
- }
81
- } catch { /* ignore */ }
82
- const oldestFirst = [...dirs].reverse();
83
- for (const name of oldestFirst) {
84
- if (totalBytes / (1024 * 1024) <= max_size_mb) break;
85
- const dirPath = path.join(backupDir, name);
86
- let dirSize = 0;
87
- try {
88
- const files = walkDir(dirPath, dirPath);
89
- for (const f of files) {
90
- try { dirSize += fs.statSync(f.full).size; } catch { /* skip */ }
91
- }
92
- } catch { /* ignore */ }
93
- fs.rmSync(dirPath, { recursive: true, force: true });
94
- totalBytes -= dirSize;
95
- removed++;
96
- }
97
- }
98
-
99
- if (removed > 0) {
100
- logger.log(`Retention (${mode}): cleaned ${removed} old snapshot(s)`, 'gray');
101
- }
102
-
103
- const freeGB = diskFreeGB(backupDir);
104
- if (freeGB !== null) {
105
- if (freeGB < 1) logger.error(`WARNING: disk critically low — ${freeGB.toFixed(1)} GB free`);
106
- else if (freeGB < 5) logger.warn(`Disk note: ${freeGB.toFixed(1)} GB free`);
107
- }
108
- }
109
-
110
- // ── Git branch retention (best-effort, safe rebuild) ────────────
111
- //
112
- // The backup branch inherits the user's real commit history as its
113
- // ancestor chain. We must NEVER graft/replace any of those commits
114
- // because git-replace refs have global visibility and would corrupt
115
- // the user's branches.
116
- //
117
- // Strategy: enumerate only guard-created commits (message prefix
118
- // "guard: auto-backup"), then rebuild the kept slice as an orphan
119
- // chain via commit-tree so the branch no longer references any user
120
- // history. Old objects become unreachable and are collected by gc.
121
-
122
- function gitRetention(branchRef, gitDirPath, cfg, cwd, logger) {
123
- if (!cfg.git_retention.enabled) return;
124
-
125
- // %aI = author date ISO, %cI = committer date ISO
126
- const out = git(['log', branchRef, '--format=%H %aI %cI %s'], { cwd, allowFail: true });
127
- if (!out) return;
128
-
129
- const lines = out.split('\n').filter(Boolean);
130
- const guardCommits = [];
131
- for (const line of lines) {
132
- const firstSpace = line.indexOf(' ');
133
- const secondSpace = line.indexOf(' ', firstSpace + 1);
134
- const thirdSpace = line.indexOf(' ', secondSpace + 1);
135
- const hash = line.substring(0, firstSpace);
136
- const authorDate = line.substring(firstSpace + 1, secondSpace);
137
- const committerDate = line.substring(secondSpace + 1, thirdSpace);
138
- const subject = line.substring(thirdSpace + 1);
139
- if (subject.startsWith('guard: auto-backup')) {
140
- guardCommits.push({ hash, authorDate, committerDate, subject });
141
- } else {
142
- break;
143
- }
144
- }
145
-
146
- const total = guardCommits.length;
147
- if (total === 0) return;
148
-
149
- let keepCount = total;
150
- const { mode, days, max_count } = cfg.git_retention;
151
-
152
- if (mode === 'count') {
153
- keepCount = Math.min(total, max_count);
154
- } else if (mode === 'days') {
155
- const cutoff = Date.now() - days * 86400000;
156
- keepCount = 0;
157
- for (const c of guardCommits) {
158
- if (new Date(c.authorDate).getTime() >= cutoff) keepCount++;
159
- else break;
160
- }
161
- keepCount = Math.max(keepCount, 10);
162
- }
163
-
164
- if (keepCount >= total) return;
165
-
166
- // Rebuild kept commits as a new orphan chain (oldest-to-keep first),
167
- // preserving original author/committer dates so "days" mode stays accurate.
168
- const toKeep = guardCommits.slice(0, keepCount).reverse();
169
-
170
- function commitTreeWithDate(args, commit) {
171
- const env = {
172
- ...process.env,
173
- GIT_AUTHOR_DATE: commit.authorDate,
174
- GIT_COMMITTER_DATE: commit.committerDate,
175
- };
176
- try {
177
- return execFileSync('git', args, { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim() || null;
178
- } catch { return null; }
179
- }
180
-
181
- const rootTree = git(['rev-parse', `${toKeep[0].hash}^{tree}`], { cwd, allowFail: true });
182
- if (!rootTree) return;
183
- let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', toKeep[0].subject], toKeep[0]);
184
- if (!prevHash) return;
185
-
186
- for (let i = 1; i < toKeep.length; i++) {
187
- const tree = git(['rev-parse', `${toKeep[i].hash}^{tree}`], { cwd, allowFail: true });
188
- if (!tree) return;
189
- prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', toKeep[i].subject], toKeep[i]);
190
- if (!prevHash) return;
191
- }
192
-
193
- git(['update-ref', branchRef, prevHash], { cwd, allowFail: true });
194
-
195
- const pruned = total - keepCount;
196
- logger.log(`Git retention (${mode}): rebuilt branch with ${keepCount} newest snapshots, pruned ${pruned}. Run 'git gc' to reclaim space.`, 'gray');
197
- }
198
-
199
- // ── Shadow copy ─────────────────────────────────────────────────
200
-
201
- function shadowCopy(projectDir, backupDir, cfg, logger) {
202
- const ts = formatTimestamp(new Date());
203
- const snapDir = path.join(backupDir, ts);
204
- fs.mkdirSync(snapDir, { recursive: true });
205
-
206
- const allFiles = walkDir(projectDir, projectDir);
207
- const files = filterFiles(allFiles, cfg);
208
-
209
- let copied = 0;
210
- for (const f of files) {
211
- const dest = path.join(snapDir, f.rel);
212
- fs.mkdirSync(path.dirname(dest), { recursive: true });
213
- try {
214
- fs.copyFileSync(f.full, dest);
215
- copied++;
216
- } catch { /* skip unreadable */ }
217
- }
218
-
219
- if (copied > 0) {
220
- logger.log(`Shadow copy ${ts} (${copied} files)`);
221
- } else {
222
- fs.rmSync(snapDir, { recursive: true, force: true });
223
- }
224
- return copied;
225
- }
226
-
227
- // ── Git snapshot (plumbing) ─────────────────────────────────────
228
-
229
- function gitSnapshot(projectDir, branchRef, guardIndex, cfg, logger) {
230
- const cwd = projectDir;
231
- const env = { ...process.env, GIT_INDEX_FILE: guardIndex };
232
-
233
- // Clean up stale temp index from prior crash
234
- try { fs.unlinkSync(guardIndex); } catch { /* doesn't exist */ }
235
-
236
- try {
237
- const parentHash = git(['rev-parse', '--verify', branchRef], { cwd, allowFail: true });
238
- if (parentHash) {
239
- execFileSync('git', ['read-tree', branchRef], { cwd, env, stdio: 'pipe' });
240
- }
241
-
242
- if (cfg.protect.length > 0) {
243
- for (const p of cfg.protect) {
244
- execFileSync('git', ['add', '--', p], { cwd, env, stdio: 'pipe' });
245
- }
246
- } else {
247
- execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
248
- }
249
-
250
- for (const ig of cfg.ignore) {
251
- execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-rq', '--', ig], { cwd, env, stdio: 'pipe' });
252
- }
253
-
254
- removeSecretsFromIndex(cfg.secrets_patterns, cwd, env, logger);
255
-
256
- const newTree = execFileSync('git', ['write-tree'], { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim();
257
- const parentTree = parentHash
258
- ? git(['rev-parse', `${branchRef}^{tree}`], { cwd, allowFail: true })
259
- : null;
260
-
261
- if (newTree === parentTree) {
262
- console.log(color.gray(`[guard] ${new Date().toTimeString().slice(0,8)} tree unchanged, skipped.`));
263
- return;
264
- }
265
-
266
- const ts = formatTimestamp(new Date());
267
- const msg = `guard: auto-backup ${ts}`;
268
- const commitArgs = parentHash
269
- ? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
270
- : ['commit-tree', newTree, '-m', msg];
271
- const commitHash = execFileSync('git', commitArgs, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
272
-
273
- if (!commitHash) {
274
- logger.error('commit-tree failed, snapshot skipped');
275
- return;
276
- }
277
-
278
- git(['update-ref', branchRef, commitHash], { cwd });
279
- const short = commitHash.substring(0, 7);
280
-
281
- let count = 0;
282
- if (parentTree) {
283
- const diff = git(['diff-tree', '--no-commit-id', '--name-only', '-r', parentTree, newTree], { cwd, allowFail: true });
284
- count = diff ? diff.split('\n').filter(Boolean).length : 0;
285
- } else {
286
- const all = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
287
- count = all ? all.split('\n').filter(Boolean).length : 0;
288
- }
289
-
290
- logger.log(`Git snapshot ${short} (${count} files)`);
291
- } finally {
292
- try { fs.unlinkSync(guardIndex); } catch { /* ignore */ }
293
- }
294
- }
295
-
296
- // ── Helpers ─────────────────────────────────────────────────────
297
-
298
- function formatTimestamp(d) {
299
- const pad = n => String(n).padStart(2, '0');
300
- return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
301
- }
302
-
303
- function sleep(ms) {
304
- return new Promise(resolve => setTimeout(resolve, ms));
305
- }
306
-
307
- function isProcessAlive(pid) {
308
- try { process.kill(pid, 0); return true; }
309
- catch { return false; }
310
- }
311
-
312
- // ── Main ────────────────────────────────────────────────────────
313
-
314
- async function runBackup(projectDir, intervalOverride) {
315
- const hasGit = gitAvailable();
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 };