cursor-guard 2.1.0 → 3.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 +63 -11
- package/README.zh-CN.md +345 -293
- package/ROADMAP.md +834 -0
- package/SKILL.md +617 -557
- package/package.json +26 -6
- 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,305 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const {
|
|
7
|
+
git, isGitRepo, gitDir: getGitDir, loadConfig,
|
|
8
|
+
} = require('../utils');
|
|
9
|
+
const { createGitSnapshot, formatTimestamp, removeSecretsFromIndex } = require('./snapshot');
|
|
10
|
+
|
|
11
|
+
// ── Path safety ─────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function validateRelativePath(file) {
|
|
14
|
+
const normalized = path.normalize(file).replace(/\\/g, '/');
|
|
15
|
+
if (path.isAbsolute(normalized) || normalized.startsWith('..')) {
|
|
16
|
+
return { valid: false, error: 'file path must be relative and within project directory' };
|
|
17
|
+
}
|
|
18
|
+
return { valid: true, normalized };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const VALID_SHADOW_SOURCE = /^\d{8}_\d{6}$|^pre-restore-\d{8}_\d{6}$/;
|
|
22
|
+
|
|
23
|
+
function validateShadowSource(source) {
|
|
24
|
+
if (!VALID_SHADOW_SOURCE.test(source)) {
|
|
25
|
+
return { valid: false };
|
|
26
|
+
}
|
|
27
|
+
return { valid: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Determine whether to create a pre-restore snapshot.
|
|
32
|
+
* Priority: explicit opts.preserveCurrent > config pre_restore_backup > default true.
|
|
33
|
+
*/
|
|
34
|
+
function resolvePreserve(projectDir, opts) {
|
|
35
|
+
if (typeof opts.preserveCurrent === 'boolean') return opts.preserveCurrent;
|
|
36
|
+
const { cfg } = loadConfig(projectDir);
|
|
37
|
+
if (cfg.pre_restore_backup === 'never') return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Restore file ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Restore a single file from a backup source.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} projectDir
|
|
47
|
+
* @param {string} file - Relative path to the file
|
|
48
|
+
* @param {string} source - Commit hash, ref name, or shadow timestamp
|
|
49
|
+
* @param {object} [opts]
|
|
50
|
+
* @param {boolean} [opts.preserveCurrent=true] - Snapshot current state before restoring
|
|
51
|
+
* @returns {{ status: 'restored'|'error', preRestoreRef?: string, preRestoreShortHash?: string, restoredFrom: string, sourceType?: 'git'|'shadow', error?: string }}
|
|
52
|
+
*/
|
|
53
|
+
function restoreFile(projectDir, file, source, opts = {}) {
|
|
54
|
+
const pathCheck = validateRelativePath(file);
|
|
55
|
+
if (!pathCheck.valid) {
|
|
56
|
+
return { status: 'error', restoredFrom: source, error: pathCheck.error };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const preserveCurrent = resolvePreserve(projectDir, opts);
|
|
60
|
+
const repo = isGitRepo(projectDir);
|
|
61
|
+
const result = { restoredFrom: source };
|
|
62
|
+
|
|
63
|
+
// Determine source type — shadow source must be a valid timestamp directory name
|
|
64
|
+
const shadowCheck = validateShadowSource(source);
|
|
65
|
+
const shadowDir = shadowCheck.valid
|
|
66
|
+
? path.join(projectDir, '.cursor-guard-backup', source, pathCheck.normalized)
|
|
67
|
+
: null;
|
|
68
|
+
const isShadowSource = shadowDir && fs.existsSync(shadowDir);
|
|
69
|
+
|
|
70
|
+
if (!isShadowSource && !repo) {
|
|
71
|
+
return { status: 'error', restoredFrom: source, error: 'not a git repo and source is not a shadow copy timestamp' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Pre-restore snapshot (git path)
|
|
75
|
+
if (preserveCurrent && repo) {
|
|
76
|
+
const preRestoreResult = createPreRestoreSnapshot(projectDir, file);
|
|
77
|
+
if (preRestoreResult.status === 'created') {
|
|
78
|
+
result.preRestoreRef = preRestoreResult.ref;
|
|
79
|
+
result.preRestoreShortHash = preRestoreResult.shortHash;
|
|
80
|
+
} else if (preRestoreResult.status === 'error') {
|
|
81
|
+
return { status: 'error', restoredFrom: source, error: `pre-restore snapshot failed: ${preRestoreResult.error}` };
|
|
82
|
+
}
|
|
83
|
+
// 'skipped' (no changes) is fine, proceed
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Pre-restore shadow copy (non-git path)
|
|
87
|
+
if (preserveCurrent && !repo) {
|
|
88
|
+
const targetFile = path.join(projectDir, file);
|
|
89
|
+
if (fs.existsSync(targetFile)) {
|
|
90
|
+
try {
|
|
91
|
+
const ts = formatTimestamp(new Date());
|
|
92
|
+
const preRestoreDir = path.join(projectDir, '.cursor-guard-backup', `pre-restore-${ts}`);
|
|
93
|
+
fs.mkdirSync(path.join(preRestoreDir, path.dirname(file)), { recursive: true });
|
|
94
|
+
fs.copyFileSync(targetFile, path.join(preRestoreDir, file));
|
|
95
|
+
result.preRestoreShadow = `pre-restore-${ts}`;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { status: 'error', restoredFrom: source, error: `pre-restore shadow copy failed: ${e.message}` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Restore from shadow copy
|
|
103
|
+
if (isShadowSource) {
|
|
104
|
+
try {
|
|
105
|
+
const dest = path.join(projectDir, file);
|
|
106
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
107
|
+
fs.copyFileSync(shadowDir, dest);
|
|
108
|
+
result.status = 'restored';
|
|
109
|
+
result.sourceType = 'shadow';
|
|
110
|
+
return result;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return { status: 'error', restoredFrom: source, error: e.message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Restore from git
|
|
117
|
+
try {
|
|
118
|
+
// Verify the source ref/hash is valid
|
|
119
|
+
const resolved = git(['rev-parse', '--verify', source], { cwd: projectDir, allowFail: true });
|
|
120
|
+
if (!resolved) {
|
|
121
|
+
return { status: 'error', restoredFrom: source, error: `cannot resolve git source: ${source}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check that the file exists in the source
|
|
125
|
+
const fileExists = git(['cat-file', '-e', `${resolved}:${file}`], { cwd: projectDir, allowFail: true });
|
|
126
|
+
if (fileExists === null) {
|
|
127
|
+
// cat-file -e returns empty on success with allowFail, null on error
|
|
128
|
+
// Try ls-tree instead
|
|
129
|
+
const lsOut = git(['ls-tree', resolved, '--', file], { cwd: projectDir, allowFail: true });
|
|
130
|
+
if (!lsOut) {
|
|
131
|
+
return { status: 'error', restoredFrom: source, error: `file '${file}' not found in source ${source}` };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
execFileSync('git', ['restore', `--source=${resolved}`, '--', file], {
|
|
136
|
+
cwd: projectDir, stdio: 'pipe',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
result.status = 'restored';
|
|
140
|
+
result.sourceType = 'git';
|
|
141
|
+
return result;
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return { status: 'error', restoredFrom: source, error: e.message };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Restore project (preview only for V3.0) ─────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Preview which files would be affected by a full project restore.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} projectDir
|
|
153
|
+
* @param {string} source - Commit hash or ref
|
|
154
|
+
* @returns {{ status: 'ok'|'error', files?: Array<{path: string, change: 'modified'|'added'|'deleted'}>, totalChanged?: number, error?: string }}
|
|
155
|
+
*/
|
|
156
|
+
function previewProjectRestore(projectDir, source) {
|
|
157
|
+
if (!isGitRepo(projectDir)) {
|
|
158
|
+
return { status: 'error', error: 'not a git repository' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const resolved = git(['rev-parse', '--verify', source], { cwd: projectDir, allowFail: true });
|
|
163
|
+
if (!resolved) {
|
|
164
|
+
return { status: 'error', error: `cannot resolve git source: ${source}` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const diffOutput = git(
|
|
168
|
+
['diff', '--name-status', resolved],
|
|
169
|
+
{ cwd: projectDir, allowFail: true }
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (!diffOutput) {
|
|
173
|
+
return { status: 'ok', files: [], totalChanged: 0 };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const files = [];
|
|
177
|
+
for (const line of diffOutput.split('\n').filter(Boolean)) {
|
|
178
|
+
const tab = line.indexOf('\t');
|
|
179
|
+
const code = line.substring(0, tab).trim();
|
|
180
|
+
const filePath = line.substring(tab + 1).trim();
|
|
181
|
+
let change = 'modified';
|
|
182
|
+
if (code === 'A') change = 'added';
|
|
183
|
+
else if (code === 'D') change = 'deleted';
|
|
184
|
+
files.push({ path: filePath, change });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { status: 'ok', files, totalChanged: files.length };
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return { status: 'error', error: e.message };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Execute project restore ─────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Execute a full project restore to a given source commit.
|
|
197
|
+
* Creates a pre-restore snapshot first (unless opted out), then
|
|
198
|
+
* restores all changed files.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} projectDir
|
|
201
|
+
* @param {string} source - Commit hash or ref
|
|
202
|
+
* @param {object} [opts]
|
|
203
|
+
* @param {boolean} [opts.preserveCurrent=true]
|
|
204
|
+
* @returns {{ status: 'restored'|'error', preRestoreRef?: string, preRestoreShortHash?: string, filesRestored: number, files?: Array<{path: string, change: string}>, error?: string }}
|
|
205
|
+
*/
|
|
206
|
+
function executeProjectRestore(projectDir, source, opts = {}) {
|
|
207
|
+
const preserveCurrent = resolvePreserve(projectDir, opts);
|
|
208
|
+
|
|
209
|
+
if (!isGitRepo(projectDir)) {
|
|
210
|
+
return { status: 'error', filesRestored: 0, error: 'not a git repository' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const resolved = git(['rev-parse', '--verify', source], { cwd: projectDir, allowFail: true });
|
|
214
|
+
if (!resolved) {
|
|
215
|
+
return { status: 'error', filesRestored: 0, error: `cannot resolve git source: ${source}` };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const preview = previewProjectRestore(projectDir, source);
|
|
219
|
+
if (preview.status === 'error') {
|
|
220
|
+
return { status: 'error', filesRestored: 0, error: preview.error };
|
|
221
|
+
}
|
|
222
|
+
if (preview.totalChanged === 0) {
|
|
223
|
+
return { status: 'restored', filesRestored: 0, files: [], preRestoreRef: null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const result = { filesRestored: 0, files: preview.files };
|
|
227
|
+
|
|
228
|
+
if (preserveCurrent) {
|
|
229
|
+
const snap = createPreRestoreSnapshot(projectDir, null);
|
|
230
|
+
if (snap.status === 'created') {
|
|
231
|
+
result.preRestoreRef = snap.ref;
|
|
232
|
+
result.preRestoreShortHash = snap.shortHash;
|
|
233
|
+
} else if (snap.status === 'error') {
|
|
234
|
+
return { status: 'error', filesRestored: 0, error: `pre-restore snapshot failed: ${snap.error}` };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
execFileSync('git', ['restore', `--source=${resolved}`, '--', '.'], {
|
|
240
|
+
cwd: projectDir, stdio: 'pipe',
|
|
241
|
+
});
|
|
242
|
+
result.status = 'restored';
|
|
243
|
+
result.filesRestored = preview.totalChanged;
|
|
244
|
+
return result;
|
|
245
|
+
} catch (e) {
|
|
246
|
+
return { status: 'error', filesRestored: 0, error: e.message };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Pre-restore snapshot helper ─────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create a pre-restore snapshot on refs/guard/pre-restore/<timestamp>.
|
|
254
|
+
* Uses temp index so the user's staging area is never touched.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} projectDir
|
|
257
|
+
* @param {string} [scope] - Specific file to check for changes, or null for all
|
|
258
|
+
* @returns {{ status: 'created'|'skipped'|'error', ref?: string, shortHash?: string, error?: string }}
|
|
259
|
+
*/
|
|
260
|
+
function createPreRestoreSnapshot(projectDir, scope) {
|
|
261
|
+
const gDir = getGitDir(projectDir);
|
|
262
|
+
if (!gDir) return { status: 'error', error: 'not a git repository' };
|
|
263
|
+
|
|
264
|
+
const ts = formatTimestamp(new Date());
|
|
265
|
+
const ref = `refs/guard/pre-restore/${ts}`;
|
|
266
|
+
const guardIdx = path.join(gDir, 'guard-pre-restore-index');
|
|
267
|
+
const env = { ...process.env, GIT_INDEX_FILE: guardIdx };
|
|
268
|
+
const cwd = projectDir;
|
|
269
|
+
|
|
270
|
+
try { fs.unlinkSync(guardIdx); } catch { /* doesn't exist */ }
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const head = git(['rev-parse', 'HEAD'], { cwd, allowFail: true });
|
|
274
|
+
if (!head) return { status: 'skipped', reason: 'no HEAD commit' };
|
|
275
|
+
|
|
276
|
+
execFileSync('git', ['read-tree', 'HEAD'], { cwd, env, stdio: 'pipe' });
|
|
277
|
+
execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
|
|
278
|
+
|
|
279
|
+
const { cfg } = loadConfig(projectDir);
|
|
280
|
+
removeSecretsFromIndex(cfg.secrets_patterns, cwd, env);
|
|
281
|
+
|
|
282
|
+
const tree = execFileSync('git', ['write-tree'], { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
283
|
+
const headTree = git(['rev-parse', 'HEAD^{tree}'], { cwd, allowFail: true });
|
|
284
|
+
|
|
285
|
+
if (tree === headTree) {
|
|
286
|
+
return { status: 'skipped', reason: 'no changes to preserve' };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const commitHash = execFileSync('git', [
|
|
290
|
+
'commit-tree', tree, '-p', head, '-m', `guard: pre-restore snapshot ${ts}`,
|
|
291
|
+
], { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
292
|
+
|
|
293
|
+
if (!commitHash) return { status: 'error', error: 'commit-tree returned empty' };
|
|
294
|
+
|
|
295
|
+
git(['update-ref', ref, commitHash], { cwd });
|
|
296
|
+
|
|
297
|
+
return { status: 'created', ref, shortHash: commitHash.substring(0, 7) };
|
|
298
|
+
} catch (e) {
|
|
299
|
+
return { status: 'error', error: e.message };
|
|
300
|
+
} finally {
|
|
301
|
+
try { fs.unlinkSync(guardIdx); } catch { /* ignore */ }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = { restoreFile, previewProjectRestore, executeProjectRestore, createPreRestoreSnapshot, validateRelativePath, validateShadowSource };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const {
|
|
7
|
+
git, isGitRepo, gitDir: getGitDir, walkDir, filterFiles, matchesAny,
|
|
8
|
+
} = require('../utils');
|
|
9
|
+
|
|
10
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function formatTimestamp(d) {
|
|
13
|
+
const pad = n => String(n).padStart(2, '0');
|
|
14
|
+
return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function removeSecretsFromIndex(secretsPatterns, cwd, env) {
|
|
18
|
+
let files;
|
|
19
|
+
try {
|
|
20
|
+
const out = execFileSync('git', ['ls-files', '--cached'], {
|
|
21
|
+
cwd, env, stdio: 'pipe', encoding: 'utf-8',
|
|
22
|
+
}).trim();
|
|
23
|
+
files = out ? out.split('\n').filter(Boolean) : [];
|
|
24
|
+
} catch { return []; }
|
|
25
|
+
|
|
26
|
+
const excluded = [];
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
const leaf = path.basename(f);
|
|
29
|
+
if (matchesAny(secretsPatterns, f) || matchesAny(secretsPatterns, leaf)) {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-q', '--', f], {
|
|
32
|
+
cwd, env, stdio: 'pipe',
|
|
33
|
+
});
|
|
34
|
+
} catch { /* ignore */ }
|
|
35
|
+
excluded.push(f);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return excluded;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Git snapshot ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a git snapshot commit on a dedicated ref using plumbing commands.
|
|
45
|
+
* Does not touch the user's index or branch.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} projectDir
|
|
48
|
+
* @param {object} cfg - Loaded config
|
|
49
|
+
* @param {object} [opts]
|
|
50
|
+
* @param {string} [opts.branchRef='refs/guard/auto-backup']
|
|
51
|
+
* @param {string} [opts.message] - Commit message (auto-generated if omitted)
|
|
52
|
+
* @returns {{ status: 'created'|'skipped'|'error', commitHash?: string, shortHash?: string, fileCount?: number, reason?: string, error?: string, secretsExcluded?: string[] }}
|
|
53
|
+
*/
|
|
54
|
+
function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
55
|
+
const branchRef = opts.branchRef || 'refs/guard/auto-backup';
|
|
56
|
+
const cwd = projectDir;
|
|
57
|
+
const gDir = getGitDir(projectDir);
|
|
58
|
+
if (!gDir) return { status: 'error', error: 'not a git repository' };
|
|
59
|
+
|
|
60
|
+
const guardIndex = path.join(gDir, 'cursor-guard-index');
|
|
61
|
+
const env = { ...process.env, GIT_INDEX_FILE: guardIndex };
|
|
62
|
+
|
|
63
|
+
try { fs.unlinkSync(guardIndex); } catch { /* doesn't exist */ }
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const parentHash = git(['rev-parse', '--verify', branchRef], { cwd, allowFail: true });
|
|
67
|
+
if (parentHash) {
|
|
68
|
+
execFileSync('git', ['read-tree', branchRef], { cwd, env, stdio: 'pipe' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (cfg.protect.length > 0) {
|
|
72
|
+
for (const p of cfg.protect) {
|
|
73
|
+
execFileSync('git', ['add', '--', p], { cwd, env, stdio: 'pipe' });
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
execFileSync('git', ['add', '-A'], { cwd, env, stdio: 'pipe' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const ig of cfg.ignore) {
|
|
80
|
+
execFileSync('git', ['rm', '--cached', '--ignore-unmatch', '-rq', '--', ig], { cwd, env, stdio: 'pipe' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const secretsExcluded = removeSecretsFromIndex(cfg.secrets_patterns, cwd, env);
|
|
84
|
+
|
|
85
|
+
const newTree = execFileSync('git', ['write-tree'], { cwd, env, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
86
|
+
const parentTree = parentHash
|
|
87
|
+
? git(['rev-parse', `${branchRef}^{tree}`], { cwd, allowFail: true })
|
|
88
|
+
: null;
|
|
89
|
+
|
|
90
|
+
if (newTree === parentTree) {
|
|
91
|
+
return { status: 'skipped', reason: 'tree unchanged' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ts = formatTimestamp(new Date());
|
|
95
|
+
const msg = opts.message || `guard: auto-backup ${ts}`;
|
|
96
|
+
const commitArgs = parentHash
|
|
97
|
+
? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
|
|
98
|
+
: ['commit-tree', newTree, '-m', msg];
|
|
99
|
+
const commitHash = execFileSync('git', commitArgs, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
100
|
+
|
|
101
|
+
if (!commitHash) {
|
|
102
|
+
return { status: 'error', error: 'commit-tree returned empty hash' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
git(['update-ref', branchRef, commitHash], { cwd });
|
|
106
|
+
|
|
107
|
+
let fileCount = 0;
|
|
108
|
+
if (parentTree) {
|
|
109
|
+
const diff = git(['diff-tree', '--no-commit-id', '--name-only', '-r', parentTree, newTree], { cwd, allowFail: true });
|
|
110
|
+
fileCount = diff ? diff.split('\n').filter(Boolean).length : 0;
|
|
111
|
+
} else {
|
|
112
|
+
const all = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
|
|
113
|
+
fileCount = all ? all.split('\n').filter(Boolean).length : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
status: 'created',
|
|
118
|
+
commitHash,
|
|
119
|
+
shortHash: commitHash.substring(0, 7),
|
|
120
|
+
fileCount,
|
|
121
|
+
secretsExcluded: secretsExcluded.length > 0 ? secretsExcluded : undefined,
|
|
122
|
+
};
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { status: 'error', error: e.message };
|
|
125
|
+
} finally {
|
|
126
|
+
try { fs.unlinkSync(guardIndex); } catch { /* ignore */ }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Shadow copy ─────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a shadow (file) copy of the project.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} projectDir
|
|
136
|
+
* @param {object} cfg - Loaded config
|
|
137
|
+
* @param {object} [opts]
|
|
138
|
+
* @param {string} [opts.backupDir] - Override backup directory (default: projectDir/.cursor-guard-backup)
|
|
139
|
+
* @returns {{ status: 'created'|'empty'|'error', timestamp?: string, fileCount?: number, snapshotDir?: string, error?: string }}
|
|
140
|
+
*/
|
|
141
|
+
function createShadowCopy(projectDir, cfg, opts = {}) {
|
|
142
|
+
const backupDir = opts.backupDir || path.join(projectDir, '.cursor-guard-backup');
|
|
143
|
+
const ts = formatTimestamp(new Date());
|
|
144
|
+
const snapDir = path.join(backupDir, ts);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
fs.mkdirSync(snapDir, { recursive: true });
|
|
148
|
+
|
|
149
|
+
const allFiles = walkDir(projectDir, projectDir);
|
|
150
|
+
const files = filterFiles(allFiles, cfg);
|
|
151
|
+
|
|
152
|
+
let copied = 0;
|
|
153
|
+
for (const f of files) {
|
|
154
|
+
const dest = path.join(snapDir, f.rel);
|
|
155
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
156
|
+
try {
|
|
157
|
+
fs.copyFileSync(f.full, dest);
|
|
158
|
+
copied++;
|
|
159
|
+
} catch { /* skip unreadable */ }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (copied === 0) {
|
|
163
|
+
fs.rmSync(snapDir, { recursive: true, force: true });
|
|
164
|
+
return { status: 'empty', timestamp: ts };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { status: 'created', timestamp: ts, fileCount: copied, snapshotDir: snapDir };
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return { status: 'error', error: e.message };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { createGitSnapshot, createShadowCopy, formatTimestamp, removeSecretsFromIndex };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir, diskFreeGB,
|
|
7
|
+
} = require('../utils');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gather comprehensive backup system status.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} projectDir
|
|
13
|
+
* @returns {{
|
|
14
|
+
* watcher: { running: boolean, pid?: number, startedAt?: string, lockFile?: string, stale?: boolean },
|
|
15
|
+
* config: { loaded: boolean, strategy: string, interval: number, retention: object, gitRetention: object, error?: string },
|
|
16
|
+
* lastBackup: { git?: { ref: string, hash: string, shortHash: string, timestamp: string, message: string }, shadow?: { timestamp: string, path: string, fileCount: number } },
|
|
17
|
+
* refs: { snapshot?: string, autoBackup?: { hash: string, commitCount: number }, preRestoreCount: number },
|
|
18
|
+
* disk: { freeGB: number|null, warning?: string },
|
|
19
|
+
* }}
|
|
20
|
+
*/
|
|
21
|
+
function getBackupStatus(projectDir) {
|
|
22
|
+
const hasGit = gitAvailable();
|
|
23
|
+
const repo = hasGit && isGitRepo(projectDir);
|
|
24
|
+
const gDir = repo ? getGitDir(projectDir) : null;
|
|
25
|
+
const backupDir = path.join(projectDir, '.cursor-guard-backup');
|
|
26
|
+
|
|
27
|
+
// ── Watcher status ────────────────────────────────────────────
|
|
28
|
+
const lockFile = gDir
|
|
29
|
+
? path.join(gDir, 'cursor-guard.lock')
|
|
30
|
+
: path.join(backupDir, 'cursor-guard.lock');
|
|
31
|
+
|
|
32
|
+
const watcher = { running: false };
|
|
33
|
+
|
|
34
|
+
if (fs.existsSync(lockFile)) {
|
|
35
|
+
watcher.lockFile = lockFile;
|
|
36
|
+
try {
|
|
37
|
+
const content = fs.readFileSync(lockFile, 'utf-8').trim();
|
|
38
|
+
const pidMatch = content.match(/pid=(\d+)/);
|
|
39
|
+
const startedMatch = content.match(/started=(.+)/);
|
|
40
|
+
|
|
41
|
+
if (pidMatch) {
|
|
42
|
+
const pid = parseInt(pidMatch[1], 10);
|
|
43
|
+
watcher.pid = pid;
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, 0);
|
|
46
|
+
watcher.running = true;
|
|
47
|
+
} catch {
|
|
48
|
+
watcher.running = false;
|
|
49
|
+
watcher.stale = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (startedMatch) {
|
|
53
|
+
watcher.startedAt = startedMatch[1].trim();
|
|
54
|
+
}
|
|
55
|
+
} catch { /* unreadable lock */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Config ────────────────────────────────────────────────────
|
|
59
|
+
const { cfg, loaded, error } = loadConfig(projectDir);
|
|
60
|
+
const config = {
|
|
61
|
+
loaded,
|
|
62
|
+
strategy: cfg.backup_strategy,
|
|
63
|
+
interval: cfg.auto_backup_interval_seconds || 60,
|
|
64
|
+
retention: cfg.retention,
|
|
65
|
+
gitRetention: cfg.git_retention,
|
|
66
|
+
};
|
|
67
|
+
if (error) config.error = error;
|
|
68
|
+
|
|
69
|
+
// ── Last backup ───────────────────────────────────────────────
|
|
70
|
+
const lastBackup = {};
|
|
71
|
+
|
|
72
|
+
if (repo) {
|
|
73
|
+
const autoRef = 'refs/guard/auto-backup';
|
|
74
|
+
const autoExists = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
|
|
75
|
+
if (autoExists) {
|
|
76
|
+
const logLine = git(
|
|
77
|
+
['log', autoRef, '--format=%H %aI %s', '-1'],
|
|
78
|
+
{ cwd: projectDir, allowFail: true }
|
|
79
|
+
);
|
|
80
|
+
if (logLine) {
|
|
81
|
+
const firstSpace = logLine.indexOf(' ');
|
|
82
|
+
const secondSpace = logLine.indexOf(' ', firstSpace + 1);
|
|
83
|
+
const hash = logLine.substring(0, firstSpace);
|
|
84
|
+
const timestamp = logLine.substring(firstSpace + 1, secondSpace);
|
|
85
|
+
const message = logLine.substring(secondSpace + 1);
|
|
86
|
+
lastBackup.git = {
|
|
87
|
+
ref: autoRef,
|
|
88
|
+
hash,
|
|
89
|
+
shortHash: hash.substring(0, 7),
|
|
90
|
+
timestamp,
|
|
91
|
+
message,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (fs.existsSync(backupDir)) {
|
|
98
|
+
try {
|
|
99
|
+
const dirs = fs.readdirSync(backupDir, { withFileTypes: true })
|
|
100
|
+
.filter(d => d.isDirectory() && /^\d{8}_\d{6}$/.test(d.name))
|
|
101
|
+
.sort((a, b) => b.name.localeCompare(a.name));
|
|
102
|
+
|
|
103
|
+
if (dirs.length > 0) {
|
|
104
|
+
const latest = dirs[0].name;
|
|
105
|
+
const latestPath = path.join(backupDir, latest);
|
|
106
|
+
let fileCount = 0;
|
|
107
|
+
try {
|
|
108
|
+
const countFiles = (dir) => {
|
|
109
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
110
|
+
if (entry.isDirectory()) countFiles(path.join(dir, entry.name));
|
|
111
|
+
else fileCount++;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
countFiles(latestPath);
|
|
115
|
+
} catch { /* ignore */ }
|
|
116
|
+
|
|
117
|
+
lastBackup.shadow = {
|
|
118
|
+
timestamp: latest,
|
|
119
|
+
path: latestPath,
|
|
120
|
+
fileCount,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
} catch { /* ignore */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Guard refs ────────────────────────────────────────────────
|
|
127
|
+
const refs = { preRestoreCount: 0 };
|
|
128
|
+
|
|
129
|
+
if (repo) {
|
|
130
|
+
const snapshotHash = git(['rev-parse', '--verify', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
|
|
131
|
+
if (snapshotHash) refs.snapshot = snapshotHash.substring(0, 7);
|
|
132
|
+
|
|
133
|
+
const autoRef = 'refs/guard/auto-backup';
|
|
134
|
+
const autoHash = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
|
|
135
|
+
if (autoHash) {
|
|
136
|
+
const count = git(['rev-list', '--count', autoRef], { cwd: projectDir, allowFail: true });
|
|
137
|
+
refs.autoBackup = {
|
|
138
|
+
hash: autoHash.substring(0, 7),
|
|
139
|
+
commitCount: count ? parseInt(count, 10) : 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const preRestoreRefs = git(
|
|
144
|
+
['for-each-ref', 'refs/guard/pre-restore/', '--format=%(refname)'],
|
|
145
|
+
{ cwd: projectDir, allowFail: true }
|
|
146
|
+
);
|
|
147
|
+
if (preRestoreRefs) {
|
|
148
|
+
refs.preRestoreCount = preRestoreRefs.split('\n').filter(Boolean).length;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Disk ──────────────────────────────────────────────────────
|
|
153
|
+
const freeGB = diskFreeGB(projectDir);
|
|
154
|
+
const disk = { freeGB };
|
|
155
|
+
if (freeGB !== null) {
|
|
156
|
+
if (freeGB < 1) disk.warning = 'critically low';
|
|
157
|
+
else if (freeGB < 5) disk.warning = 'low';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { watcher, config, lastBackup, refs, disk };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { getBackupStatus };
|