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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.2.2",
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 { 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); }
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 snapResult = createGitSnapshot(projectDir, cfg, { branchRef });
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, `--format=%H %aI %s`, `-${limit}`, '--grep=^guard:'];
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 line of out.split('\n').filter(Boolean)) {
83
- const firstSpace = line.indexOf(' ');
84
- const secondSpace = line.indexOf(' ', firstSpace + 1);
85
- const hash = line.substring(0, firstSpace);
86
- const timestamp = line.substring(firstSpace + 1, secondSpace);
87
- const message = line.substring(secondSpace + 1);
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
- sources.push({
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 ts = git(['log', '-1', '--format=%aI', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
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 || null,
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', `guard: pre-restore snapshot ${ts}`,
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.message || `guard: auto-backup ${ts}`;
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];
@@ -112,6 +112,7 @@ server.tool(
112
112
  results.git = createGitSnapshot(resolved, cfg, {
113
113
  branchRef: 'refs/guard/snapshot',
114
114
  message: message || `guard: manual snapshot ${new Date().toISOString()}`,
115
+ context: { trigger: 'manual' },
115
116
  });
116
117
  }
117
118