cursor-guard 4.2.2 → 4.3.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/package.json +1 -1
- package/references/dashboard/public/app.js +31 -0
- package/references/dashboard/public/style.css +8 -3
- package/references/lib/auto-backup.js +7 -2
- package/references/lib/core/backups.js +44 -13
- package/references/lib/core/restore.js +3 -1
- package/references/lib/core/snapshot.js +23 -1
- package/references/mcp/server.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-guard",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Protects code from accidental AI overwrite or deletion in Cursor IDE — mandatory pre-write snapshots, review-before-apply, local Git safety net, and deterministic recovery. | 保护代码免受 Cursor AI 代理意外覆写或删除——强制写前快照、预览再执行、本地 Git 安全网、确定性恢复。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cursor",
|
|
@@ -88,6 +88,13 @@ const I18N = {
|
|
|
88
88
|
'drawer.field.hash': 'Commit Hash',
|
|
89
89
|
'drawer.field.path': 'Path',
|
|
90
90
|
'drawer.field.message': 'Message',
|
|
91
|
+
'drawer.field.filesChanged': 'Files Changed',
|
|
92
|
+
'drawer.field.summary': 'Change Summary',
|
|
93
|
+
'drawer.field.trigger': 'Trigger',
|
|
94
|
+
'trigger.auto': 'Auto (scheduled)',
|
|
95
|
+
'trigger.manual': 'Manual (agent)',
|
|
96
|
+
'trigger.pre-restore': 'Pre-Restore',
|
|
97
|
+
'backups.col.summary': 'Changes',
|
|
91
98
|
|
|
92
99
|
'error.fetchFailed': 'Failed to fetch data',
|
|
93
100
|
'error.sectionFailed': 'This section failed to load',
|
|
@@ -254,6 +261,13 @@ const I18N = {
|
|
|
254
261
|
'drawer.field.hash': '提交 Hash',
|
|
255
262
|
'drawer.field.path': '路径',
|
|
256
263
|
'drawer.field.message': '消息',
|
|
264
|
+
'drawer.field.filesChanged': '变更文件数',
|
|
265
|
+
'drawer.field.summary': '变更摘要',
|
|
266
|
+
'drawer.field.trigger': '触发方式',
|
|
267
|
+
'trigger.auto': '自动(定时)',
|
|
268
|
+
'trigger.manual': '手动(Agent)',
|
|
269
|
+
'trigger.pre-restore': '恢复前快照',
|
|
270
|
+
'backups.col.summary': '变更',
|
|
257
271
|
|
|
258
272
|
'error.fetchFailed': '数据拉取失败',
|
|
259
273
|
'error.sectionFailed': '此区块加载失败',
|
|
@@ -779,6 +793,17 @@ function renderFilterBar() {
|
|
|
779
793
|
).join('');
|
|
780
794
|
}
|
|
781
795
|
|
|
796
|
+
function formatSummaryCell(b) {
|
|
797
|
+
const parts = [];
|
|
798
|
+
if (b.filesChanged != null) parts.push(`<span class="text-sm">${b.filesChanged} files</span>`);
|
|
799
|
+
if (b.trigger) parts.push(`<span class="badge badge-trigger">${t('trigger.' + b.trigger)}</span>`);
|
|
800
|
+
if (b.summary) {
|
|
801
|
+
const short = b.summary.length > 60 ? b.summary.substring(0, 57) + '...' : b.summary;
|
|
802
|
+
parts.push(`<span class="text-muted text-sm">${esc(short)}</span>`);
|
|
803
|
+
}
|
|
804
|
+
return parts.length > 0 ? parts.join(' ') : '<span class="text-muted text-sm">-</span>';
|
|
805
|
+
}
|
|
806
|
+
|
|
782
807
|
function renderBackupTable(backups) {
|
|
783
808
|
if (!Array.isArray(backups)) {
|
|
784
809
|
$('#backup-table-wrap').innerHTML = `<div class="error-panel">${t('error.sectionFailed')}</div>`;
|
|
@@ -795,10 +820,12 @@ function renderBackupTable(backups) {
|
|
|
795
820
|
|
|
796
821
|
const rows = state.filteredBackups.map((b, i) => {
|
|
797
822
|
const badgeClass = b.type.startsWith('git') ? (b.type.includes('pre') ? 'badge-pre' : 'badge-git') : (b.type.includes('pre') ? 'badge-pre' : 'badge-shadow');
|
|
823
|
+
const summaryCell = formatSummaryCell(b);
|
|
798
824
|
return `<tr data-bi="${i}">
|
|
799
825
|
<td><div>${esc(formatTime(b.timestamp))}</div><div class="text-muted text-sm">${esc(relativeTime(b.timestamp))}</div></td>
|
|
800
826
|
<td><span class="badge ${badgeClass}">${t('type.' + b.type)}</span></td>
|
|
801
827
|
<td class="text-mono">${esc(b.shortHash || b.timestamp || '-')}</td>
|
|
828
|
+
<td class="backup-summary-cell">${summaryCell}</td>
|
|
802
829
|
</tr>`;
|
|
803
830
|
}).join('');
|
|
804
831
|
|
|
@@ -808,6 +835,7 @@ function renderBackupTable(backups) {
|
|
|
808
835
|
<th>${t('backups.col.time')}</th>
|
|
809
836
|
<th>${t('backups.col.type')}</th>
|
|
810
837
|
<th>${t('backups.col.ref')}</th>
|
|
838
|
+
<th>${t('backups.col.summary')}</th>
|
|
811
839
|
</tr></thead>
|
|
812
840
|
<tbody>${rows}</tbody>
|
|
813
841
|
</table>
|
|
@@ -895,10 +923,13 @@ function openRestoreDrawer(backup) {
|
|
|
895
923
|
{ key: 'drawer.field.time', val: formatTime(backup.timestamp) },
|
|
896
924
|
{ key: 'drawer.field.type', val: t('type.' + backup.type) },
|
|
897
925
|
];
|
|
926
|
+
if (backup.trigger) fields.push({ key: 'drawer.field.trigger', val: t('trigger.' + backup.trigger) });
|
|
927
|
+
if (backup.filesChanged != null) fields.push({ key: 'drawer.field.filesChanged', val: String(backup.filesChanged) });
|
|
898
928
|
if (backup.ref) fields.push({ key: 'drawer.field.ref', val: backup.ref });
|
|
899
929
|
if (backup.commitHash) fields.push({ key: 'drawer.field.hash', val: backup.commitHash });
|
|
900
930
|
if (backup.path) fields.push({ key: 'drawer.field.path', val: backup.path });
|
|
901
931
|
if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
|
|
932
|
+
if (backup.summary) fields.push({ key: 'drawer.field.summary', val: backup.summary });
|
|
902
933
|
|
|
903
934
|
const refText = backup.ref || backup.shortHash || backup.timestamp || '';
|
|
904
935
|
const jsonText = JSON.stringify(backup, null, 2);
|
|
@@ -244,9 +244,14 @@ main {
|
|
|
244
244
|
.badge-warn { background: var(--yellow-bg); color: var(--yellow); }
|
|
245
245
|
.badge-fail { background: var(--red-bg); color: var(--red); }
|
|
246
246
|
|
|
247
|
-
.badge-git
|
|
248
|
-
.badge-shadow
|
|
249
|
-
.badge-pre
|
|
247
|
+
.badge-git { background: var(--blue-bg); color: var(--blue); }
|
|
248
|
+
.badge-shadow { background: var(--amber-bg); color: var(--amber); }
|
|
249
|
+
.badge-pre { background: var(--purple-bg); color: var(--purple); }
|
|
250
|
+
.badge-trigger { background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.7rem; }
|
|
251
|
+
|
|
252
|
+
.backup-summary-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
253
|
+
.backup-summary-cell .badge-trigger { margin-right: 4px; }
|
|
254
|
+
.backup-summary-cell .text-sm { font-size: 0.75rem; }
|
|
250
255
|
|
|
251
256
|
/* ── Stats Row ────────────────────────────────────────────── */
|
|
252
257
|
|
|
@@ -210,10 +210,10 @@ async function runBackup(projectDir, intervalOverride) {
|
|
|
210
210
|
|
|
211
211
|
// V4: Record change event and check for anomalies
|
|
212
212
|
let changedFileCount = 0;
|
|
213
|
+
let porcelain = '';
|
|
213
214
|
if (repo) {
|
|
214
215
|
// Use execFileSync directly — git() helper's trim() strips leading spaces
|
|
215
216
|
// from porcelain output, corrupting the first line when it starts with ' '.
|
|
216
|
-
let porcelain = '';
|
|
217
217
|
try {
|
|
218
218
|
porcelain = execFileSync('git', ['status', '--porcelain'], {
|
|
219
219
|
cwd: projectDir, stdio: 'pipe', encoding: 'utf-8',
|
|
@@ -260,7 +260,12 @@ async function runBackup(projectDir, intervalOverride) {
|
|
|
260
260
|
|
|
261
261
|
// Git snapshot via Core
|
|
262
262
|
if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
|
|
263
|
-
const
|
|
263
|
+
const context = { trigger: 'auto', changedFileCount };
|
|
264
|
+
if (porcelain) {
|
|
265
|
+
const pLines = porcelain.split('\n').filter(Boolean).slice(0, 20);
|
|
266
|
+
context.summary = pLines.map(l => l.substring(0, 2).trim() + ' ' + l.substring(3)).join(', ');
|
|
267
|
+
}
|
|
268
|
+
const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
|
|
264
269
|
if (snapResult.status === 'created') {
|
|
265
270
|
let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
|
|
266
271
|
if (snapResult.secretsExcluded) {
|
|
@@ -41,6 +41,20 @@ function entryToMs(entry) {
|
|
|
41
41
|
return d ? d.getTime() : 0;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function parseCommitTrailers(body) {
|
|
45
|
+
if (!body) return {};
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const line of body.split('\n')) {
|
|
48
|
+
const m = line.match(/^(Files-Changed|Summary|Trigger):\s*(.+)$/);
|
|
49
|
+
if (m) {
|
|
50
|
+
const key = m[1] === 'Files-Changed' ? 'filesChanged'
|
|
51
|
+
: m[1] === 'Summary' ? 'summary' : 'trigger';
|
|
52
|
+
result[key] = key === 'filesChanged' ? parseInt(m[2], 10) : m[2];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
// ── List backups ────────────────────────────────────────────────
|
|
45
59
|
|
|
46
60
|
/**
|
|
@@ -52,7 +66,7 @@ function entryToMs(entry) {
|
|
|
52
66
|
* @param {string} [opts.file] - Filter to commits touching this relative path
|
|
53
67
|
* @param {string} [opts.before] - Time boundary (e.g. '10 minutes ago', ISO string)
|
|
54
68
|
* @param {number} [opts.limit=20] - Max total results
|
|
55
|
-
* @returns {{ sources: Array<{type: string, ref?: string, commitHash?: string, shortHash?: string, timestamp?: string, message?: string, path?: string}> }}
|
|
69
|
+
* @returns {{ sources: Array<{type: string, ref?: string, commitHash?: string, shortHash?: string, timestamp?: string, message?: string, path?: string, filesChanged?: number, summary?: string, trigger?: string}> }}
|
|
56
70
|
*/
|
|
57
71
|
function listBackups(projectDir, opts = {}) {
|
|
58
72
|
const limit = opts.limit || 20;
|
|
@@ -74,24 +88,27 @@ function listBackups(projectDir, opts = {}) {
|
|
|
74
88
|
const autoRef = 'refs/guard/auto-backup';
|
|
75
89
|
const autoExists = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
|
|
76
90
|
if (autoExists) {
|
|
77
|
-
const logArgs = ['log', autoRef,
|
|
91
|
+
const logArgs = ['log', autoRef, '--format=%H\x1f%aI\x1f%B\x1e', `-${limit}`, '--grep=^guard:'];
|
|
78
92
|
if (opts.before) logArgs.push(`--before=${opts.before}`);
|
|
79
93
|
if (opts.file) logArgs.push('--', opts.file);
|
|
80
94
|
const out = git(logArgs, { cwd: projectDir, allowFail: true });
|
|
81
95
|
if (out) {
|
|
82
|
-
for (const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
const hash =
|
|
86
|
-
const timestamp =
|
|
87
|
-
const
|
|
96
|
+
for (const record of out.split('\x1e').filter(r => r.trim())) {
|
|
97
|
+
const parts = record.split('\x1f');
|
|
98
|
+
if (parts.length < 3) continue;
|
|
99
|
+
const hash = parts[0].trim();
|
|
100
|
+
const timestamp = parts[1];
|
|
101
|
+
const body = parts[2];
|
|
102
|
+
const subject = body.split('\n')[0];
|
|
103
|
+
const trailers = parseCommitTrailers(body);
|
|
88
104
|
sources.push({
|
|
89
105
|
type: 'git-auto-backup',
|
|
90
106
|
ref: autoRef,
|
|
91
107
|
commitHash: hash,
|
|
92
108
|
shortHash: hash.substring(0, 7),
|
|
93
109
|
timestamp,
|
|
94
|
-
message,
|
|
110
|
+
message: subject,
|
|
111
|
+
...trailers,
|
|
95
112
|
});
|
|
96
113
|
}
|
|
97
114
|
}
|
|
@@ -112,20 +129,32 @@ function listBackups(projectDir, opts = {}) {
|
|
|
112
129
|
const ms = Date.parse(timestamp);
|
|
113
130
|
if (!isNaN(ms) && ms > beforeDate.getTime()) continue;
|
|
114
131
|
}
|
|
115
|
-
|
|
132
|
+
const entry = {
|
|
116
133
|
type: 'git-pre-restore',
|
|
117
134
|
ref,
|
|
118
135
|
commitHash: hash,
|
|
119
136
|
shortHash: hash.substring(0, 7),
|
|
120
137
|
timestamp,
|
|
121
|
-
}
|
|
138
|
+
};
|
|
139
|
+
const prBody = git(['log', '-1', '--format=%B', hash], { cwd: projectDir, allowFail: true });
|
|
140
|
+
if (prBody) {
|
|
141
|
+
const prSubject = prBody.split('\n')[0];
|
|
142
|
+
if (prSubject) entry.message = prSubject;
|
|
143
|
+
Object.assign(entry, parseCommitTrailers(prBody));
|
|
144
|
+
}
|
|
145
|
+
sources.push(entry);
|
|
122
146
|
}
|
|
123
147
|
}
|
|
124
148
|
|
|
125
149
|
// Agent snapshot ref
|
|
126
150
|
const snapshotHash = git(['rev-parse', '--verify', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
|
|
127
151
|
if (snapshotHash) {
|
|
128
|
-
const
|
|
152
|
+
const snapLog = git(['log', '-1', '--format=%aI\x1f%B', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
|
|
153
|
+
const snapParts = snapLog ? snapLog.split('\x1f') : [];
|
|
154
|
+
const ts = snapParts[0] || null;
|
|
155
|
+
const snapBody = snapParts[1] || '';
|
|
156
|
+
const snapTrailers = parseCommitTrailers(snapBody);
|
|
157
|
+
const snapSubject = snapBody.split('\n')[0] || '';
|
|
129
158
|
const include = !beforeDate || (ts && Date.parse(ts) <= beforeDate.getTime());
|
|
130
159
|
if (include) {
|
|
131
160
|
sources.push({
|
|
@@ -133,7 +162,9 @@ function listBackups(projectDir, opts = {}) {
|
|
|
133
162
|
ref: 'refs/guard/snapshot',
|
|
134
163
|
commitHash: snapshotHash,
|
|
135
164
|
shortHash: snapshotHash.substring(0, 7),
|
|
136
|
-
timestamp: ts
|
|
165
|
+
timestamp: ts,
|
|
166
|
+
message: snapSubject || undefined,
|
|
167
|
+
...snapTrailers,
|
|
137
168
|
});
|
|
138
169
|
}
|
|
139
170
|
}
|
|
@@ -370,8 +370,10 @@ function createPreRestoreSnapshot(projectDir, scope) {
|
|
|
370
370
|
return { status: 'skipped', reason: 'no changes to preserve' };
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
let msg = `guard: pre-restore snapshot ${ts}`;
|
|
374
|
+
msg += '\n\nTrigger: pre-restore';
|
|
373
375
|
const commitHash = execFileSync('git', [
|
|
374
|
-
'commit-tree', tree, '-p', head, '-m',
|
|
376
|
+
'commit-tree', tree, '-p', head, '-m', msg,
|
|
375
377
|
], { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
376
378
|
|
|
377
379
|
if (!commitHash) return { status: 'error', error: 'commit-tree returned empty' };
|
|
@@ -52,6 +52,24 @@ function removeSecretsFromIndex(secretsPatterns, cwd, env) {
|
|
|
52
52
|
return excluded;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// ── Commit message builder ──────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function buildCommitMessage(ts, opts) {
|
|
58
|
+
if (opts.message && !opts.context) return opts.message;
|
|
59
|
+
|
|
60
|
+
const ctx = opts.context || {};
|
|
61
|
+
const countTag = ctx.changedFileCount ? ` (${ctx.changedFileCount} files)` : '';
|
|
62
|
+
const subject = opts.message || `guard: auto-backup ${ts}${countTag}`;
|
|
63
|
+
|
|
64
|
+
const trailers = [];
|
|
65
|
+
if (ctx.changedFileCount != null) trailers.push(`Files-Changed: ${ctx.changedFileCount}`);
|
|
66
|
+
if (ctx.summary) trailers.push(`Summary: ${ctx.summary}`);
|
|
67
|
+
if (ctx.trigger) trailers.push(`Trigger: ${ctx.trigger}`);
|
|
68
|
+
|
|
69
|
+
if (trailers.length === 0) return subject;
|
|
70
|
+
return subject + '\n\n' + trailers.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
// ── Git snapshot ────────────────────────────────────────────────
|
|
56
74
|
|
|
57
75
|
/**
|
|
@@ -63,6 +81,10 @@ function removeSecretsFromIndex(secretsPatterns, cwd, env) {
|
|
|
63
81
|
* @param {object} [opts]
|
|
64
82
|
* @param {string} [opts.branchRef='refs/guard/auto-backup']
|
|
65
83
|
* @param {string} [opts.message] - Commit message (auto-generated if omitted)
|
|
84
|
+
* @param {object} [opts.context] - Backup context metadata
|
|
85
|
+
* @param {string} [opts.context.trigger] - 'auto' | 'manual' | 'pre-restore'
|
|
86
|
+
* @param {number} [opts.context.changedFileCount] - Number of changed files
|
|
87
|
+
* @param {string} [opts.context.summary] - Short change summary (e.g. "M src/app.js, A new.ts")
|
|
66
88
|
* @returns {{ status: 'created'|'skipped'|'error', commitHash?: string, shortHash?: string, fileCount?: number, reason?: string, error?: string, secretsExcluded?: string[] }}
|
|
67
89
|
*/
|
|
68
90
|
function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
@@ -109,7 +131,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
109
131
|
}
|
|
110
132
|
|
|
111
133
|
const ts = formatTimestamp(new Date());
|
|
112
|
-
const msg = opts
|
|
134
|
+
const msg = buildCommitMessage(ts, opts);
|
|
113
135
|
const commitArgs = parentHash
|
|
114
136
|
? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
|
|
115
137
|
: ['commit-tree', newTree, '-m', msg];
|
package/references/mcp/server.js
CHANGED