cursor-guard 1.3.2 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -20
- package/README.zh-CN.md +54 -20
- package/SKILL.md +65 -21
- package/package.json +10 -2
- package/references/auto-backup.ps1 +10 -342
- package/references/auto-backup.sh +19 -0
- package/references/bin/cursor-guard-backup.js +14 -0
- package/references/bin/cursor-guard-doctor.js +13 -0
- package/references/config-reference.md +43 -2
- package/references/config-reference.zh-CN.md +43 -2
- package/references/cursor-guard.example.json +7 -0
- package/references/cursor-guard.schema.json +38 -3
- package/references/guard-doctor.ps1 +22 -0
- package/references/guard-doctor.sh +18 -0
- package/references/lib/auto-backup.js +508 -0
- package/references/lib/guard-doctor.js +233 -0
- package/references/lib/utils.js +325 -0
- package/references/lib/utils.test.js +329 -0
- package/references/recovery.md +32 -12
|
@@ -0,0 +1,508 @@
|
|
|
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
|
+
process.chdir(projectDir);
|
|
316
|
+
|
|
317
|
+
const hasGit = gitAvailable();
|
|
318
|
+
const repo = hasGit && isGitRepo(projectDir);
|
|
319
|
+
const gDir = repo ? getGitDir(projectDir) : null;
|
|
320
|
+
|
|
321
|
+
const backupDir = path.join(projectDir, '.cursor-guard-backup');
|
|
322
|
+
const logFilePath = path.join(backupDir, 'backup.log');
|
|
323
|
+
const lockFile = gDir
|
|
324
|
+
? path.join(gDir, 'cursor-guard.lock')
|
|
325
|
+
: path.join(backupDir, 'cursor-guard.lock');
|
|
326
|
+
const guardIndex = gDir ? path.join(gDir, 'cursor-guard-index') : null;
|
|
327
|
+
|
|
328
|
+
// Load config
|
|
329
|
+
let { cfg, loaded, error } = loadConfig(projectDir);
|
|
330
|
+
let interval = intervalOverride || cfg.auto_backup_interval_seconds || 60;
|
|
331
|
+
if (interval < 5) interval = 5;
|
|
332
|
+
let cfgMtime = 0;
|
|
333
|
+
const cfgPath = path.join(projectDir, '.cursor-guard.json');
|
|
334
|
+
try { cfgMtime = fs.statSync(cfgPath).mtimeMs; } catch { /* no config */ }
|
|
335
|
+
|
|
336
|
+
if (error) {
|
|
337
|
+
console.log(color.yellow(`[guard] WARNING: .cursor-guard.json parse error — using defaults. ${error}`));
|
|
338
|
+
} else if (loaded) {
|
|
339
|
+
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'}`));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Strategy check
|
|
343
|
+
const needsGit = cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both';
|
|
344
|
+
if (needsGit && !repo) {
|
|
345
|
+
if (!hasGit) {
|
|
346
|
+
console.log(color.red(`[guard] ERROR: backup_strategy='${cfg.backup_strategy}' requires Git, but git is not installed.`));
|
|
347
|
+
console.log(color.yellow(" Either install Git or set backup_strategy to 'shadow' in .cursor-guard.json."));
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
console.log(color.red(`[guard] ERROR: backup_strategy='${cfg.backup_strategy}' but directory is not a Git repo.`));
|
|
351
|
+
console.log(color.yellow(" Run 'git init' first, or set backup_strategy to 'shadow'."));
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
if (!repo && cfg.backup_strategy === 'shadow') {
|
|
355
|
+
console.log(color.cyan('[guard] Non-Git directory detected. Running in shadow-only mode.'));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Ensure backup dir
|
|
359
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
360
|
+
|
|
361
|
+
// Lock file with stale detection
|
|
362
|
+
if (fs.existsSync(lockFile)) {
|
|
363
|
+
let stale = false;
|
|
364
|
+
try {
|
|
365
|
+
const content = fs.readFileSync(lockFile, 'utf-8');
|
|
366
|
+
const pidMatch = content.match(/pid=(\d+)/);
|
|
367
|
+
if (pidMatch) {
|
|
368
|
+
const oldPid = parseInt(pidMatch[1], 10);
|
|
369
|
+
if (!isProcessAlive(oldPid)) {
|
|
370
|
+
stale = true;
|
|
371
|
+
console.log(color.yellow(`[guard] Stale lock detected (pid ${oldPid} not running). Cleaning up.`));
|
|
372
|
+
fs.unlinkSync(lockFile);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} catch { /* ignore */ }
|
|
376
|
+
if (!stale) {
|
|
377
|
+
console.log(color.red(`[guard] ERROR: Lock file exists (${lockFile}).`));
|
|
378
|
+
console.log(color.red(' If no other instance is running, delete it and retry.'));
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
fs.writeFileSync(lockFile, `pid=${process.pid}\nstarted=${new Date().toISOString()}`, { flag: 'wx' });
|
|
384
|
+
} catch (e) {
|
|
385
|
+
if (e.code === 'EEXIST') {
|
|
386
|
+
console.log(color.red('[guard] ERROR: Another instance just acquired the lock.'));
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
throw e;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Cleanup on exit
|
|
393
|
+
function cleanup() {
|
|
394
|
+
try { if (guardIndex) fs.unlinkSync(guardIndex); } catch { /* ignore */ }
|
|
395
|
+
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
|
|
396
|
+
}
|
|
397
|
+
process.on('SIGINT', () => { cleanup(); console.log(color.cyan('\n[guard] Stopped.')); process.exit(0); });
|
|
398
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
399
|
+
process.on('exit', cleanup);
|
|
400
|
+
|
|
401
|
+
// Git-specific setup
|
|
402
|
+
const branch = 'cursor-guard/auto-backup';
|
|
403
|
+
const branchRef = `refs/heads/${branch}`;
|
|
404
|
+
if (repo) {
|
|
405
|
+
const exists = git(['rev-parse', '--verify', branchRef], { cwd: projectDir, allowFail: true });
|
|
406
|
+
if (!exists) {
|
|
407
|
+
git(['branch', branch, 'HEAD'], { cwd: projectDir, allowFail: true });
|
|
408
|
+
console.log(color.green(`[guard] Created branch: ${branch}`));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const excludeFile = path.join(gDir, 'info', 'exclude');
|
|
412
|
+
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
|
413
|
+
const entry = '.cursor-guard-backup/';
|
|
414
|
+
let content = '';
|
|
415
|
+
try { content = fs.readFileSync(excludeFile, 'utf-8'); } catch { /* doesn't exist yet */ }
|
|
416
|
+
if (!content.includes(entry)) {
|
|
417
|
+
fs.appendFileSync(excludeFile, `\n${entry}\n`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const logger = createLogger(logFilePath);
|
|
422
|
+
|
|
423
|
+
// Banner
|
|
424
|
+
console.log('');
|
|
425
|
+
console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
|
|
426
|
+
console.log(color.cyan(`[guard] Strategy: ${cfg.backup_strategy} | Branch: ${branch} | Retention: ${cfg.retention.mode}`));
|
|
427
|
+
console.log(color.cyan(`[guard] Log: ${logFilePath}`));
|
|
428
|
+
console.log('');
|
|
429
|
+
|
|
430
|
+
// Main loop
|
|
431
|
+
let cycle = 0;
|
|
432
|
+
while (true) {
|
|
433
|
+
await sleep(interval * 1000);
|
|
434
|
+
cycle++;
|
|
435
|
+
|
|
436
|
+
// Hot-reload config every 10 cycles
|
|
437
|
+
if (cycle % 10 === 0) {
|
|
438
|
+
try {
|
|
439
|
+
const newMtime = fs.statSync(cfgPath).mtimeMs;
|
|
440
|
+
if (newMtime !== cfgMtime) {
|
|
441
|
+
const reload = loadConfig(projectDir);
|
|
442
|
+
if (reload.loaded && !reload.error) {
|
|
443
|
+
cfg = reload.cfg;
|
|
444
|
+
cfgMtime = newMtime;
|
|
445
|
+
logger.info('Config reloaded (file changed)');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} catch { /* no config file or read error, keep current */ }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Detect changes (manifest write is deferred until shadow copy succeeds)
|
|
452
|
+
let hasChanges = false;
|
|
453
|
+
let pendingManifest = null;
|
|
454
|
+
try {
|
|
455
|
+
if (repo) {
|
|
456
|
+
const dirty = git(['status', '--porcelain'], { cwd: projectDir, allowFail: true });
|
|
457
|
+
hasChanges = !!dirty;
|
|
458
|
+
} else {
|
|
459
|
+
const allFiles = walkDir(projectDir, projectDir);
|
|
460
|
+
const filtered = filterFiles(allFiles, cfg);
|
|
461
|
+
const newManifest = buildManifest(filtered);
|
|
462
|
+
const oldManifest = loadManifest(backupDir);
|
|
463
|
+
hasChanges = manifestChanged(oldManifest, newManifest);
|
|
464
|
+
if (hasChanges) pendingManifest = newManifest;
|
|
465
|
+
}
|
|
466
|
+
} catch (e) {
|
|
467
|
+
logger.error(`Change detection failed: ${e.message}`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (!hasChanges) continue;
|
|
471
|
+
|
|
472
|
+
// Git snapshot (with error protection)
|
|
473
|
+
if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
|
|
474
|
+
try {
|
|
475
|
+
gitSnapshot(projectDir, branchRef, guardIndex, cfg, logger);
|
|
476
|
+
} catch (e) {
|
|
477
|
+
logger.error(`Git snapshot failed: ${e.message}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Shadow copy (with error protection)
|
|
482
|
+
if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
|
|
483
|
+
try {
|
|
484
|
+
shadowCopy(projectDir, backupDir, cfg, logger);
|
|
485
|
+
if (pendingManifest) {
|
|
486
|
+
saveManifest(backupDir, pendingManifest);
|
|
487
|
+
pendingManifest = null;
|
|
488
|
+
}
|
|
489
|
+
} catch (e) {
|
|
490
|
+
logger.error(`Shadow copy failed: ${e.message}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Periodic retention every 10 cycles
|
|
495
|
+
if (cycle % 10 === 0) {
|
|
496
|
+
try { shadowRetention(backupDir, cfg, logger); } catch (e) {
|
|
497
|
+
logger.error(`Shadow retention failed: ${e.message}`);
|
|
498
|
+
}
|
|
499
|
+
if (repo) {
|
|
500
|
+
try { gitRetention(branchRef, gDir, cfg, projectDir, logger); } catch (e) {
|
|
501
|
+
logger.error(`Git retention failed: ${e.message}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
module.exports = { runBackup };
|