cursor-guard 2.0.2 → 2.1.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 CHANGED
@@ -179,7 +179,7 @@ npx cursor-guard-backup --path /my/project --interval 30
179
179
  ./references/auto-backup.sh /my/project
180
180
  ```
181
181
 
182
- The script uses Git plumbing commands to snapshot to `cursor-guard/auto-backup` branch — it never switches branches or touches your working index. Supports `shadow` mode for non-Git directories.
182
+ The script uses Git plumbing commands to snapshot to `refs/guard/auto-backup` — it never switches branches or touches your working index. The ref lives outside `refs/heads/` so `git push --all` won't push it. Supports `shadow` mode for non-Git directories.
183
183
 
184
184
  ### Health Check
185
185
 
@@ -229,7 +229,7 @@ If the pre-restore backup fails, the agent will **not** proceed — it will wait
229
229
  ### Recovery priority
230
230
 
231
231
  1. **Git** — `git restore`, `git reset`, `git reflog`
232
- 2. **Auto-backup branch** — `cursor-guard/auto-backup`
232
+ 2. **Auto-backup ref** — `refs/guard/auto-backup`
233
233
  3. **Shadow copies** — `.cursor-guard-backup/<timestamp>/`
234
234
  4. **Conversation context** — Original file content captured by agent Read calls
235
235
  5. **Editor history** — VS Code/Cursor Timeline (auxiliary)
package/README.zh-CN.md CHANGED
@@ -179,7 +179,7 @@ npx cursor-guard-backup --path /my/project --interval 30
179
179
  ./references/auto-backup.sh /my/project
180
180
  ```
181
181
 
182
- 脚本使用 Git 底层命令快照到 `cursor-guard/auto-backup` 分支——不会切换分支,也不会影响你的工作索引。支持 `shadow` 模式用于非 Git 目录。
182
+ 脚本使用 Git 底层命令快照到 `refs/guard/auto-backup`——不会切换分支,也不会影响你的工作索引。该引用位于 `refs/heads/` 之外,`git push --all` 不会推送它。支持 `shadow` 模式用于非 Git 目录。
183
183
 
184
184
  ### 健康检查
185
185
 
@@ -229,7 +229,7 @@ npx cursor-guard-doctor --path /my/project
229
229
  ### 恢复优先级
230
230
 
231
231
  1. **Git** — `git restore`, `git reset`, `git reflog`
232
- 2. **自动备份分支** — `cursor-guard/auto-backup`
232
+ 2. **自动备份引用** — `refs/guard/auto-backup`
233
233
  3. **影子拷贝** — `.cursor-guard-backup/<时间戳>/`
234
234
  4. **对话上下文** — 代理 Read 调用捕获的原始文件内容
235
235
  5. **编辑器历史** — VS Code/Cursor Timeline(辅助)
package/SKILL.md CHANGED
@@ -204,16 +204,16 @@ There are two distinct backup mechanisms. Do not confuse them:
204
204
 
205
205
  | | **Git branch snapshot** | **Shadow copy** |
206
206
  |---|---|---|
207
- | **What** | Commits to `cursor-guard/auto-backup` branch via plumbing | File copies to `.cursor-guard-backup/<timestamp>/` |
207
+ | **What** | Commits to `refs/guard/auto-backup` via plumbing | File copies to `.cursor-guard-backup/<timestamp>/` |
208
208
  | **Who creates** | Auto-backup script (when `backup_strategy` = `git` or `both`) | Auto-backup script (when `backup_strategy` = `shadow` or `both`); or the agent manually (§2b) |
209
209
  | **Who cleans up** | `git_retention` config (auto, opt-in); or manual `git branch -D` | `retention` config (auto); or manual |
210
- | **Restore** | `git restore --source=cursor-guard/auto-backup -- <file>` | Copy file from `.cursor-guard-backup/<ts>/<file>` to original path |
210
+ | **Restore** | `git restore --source=guard/auto-backup -- <file>` | Copy file from `.cursor-guard-backup/<ts>/<file>` to original path |
211
211
  | **Requires Git** | Yes | No (fallback for non-git repos) |
212
212
 
213
213
  **Priority order for the agent:**
214
214
 
215
215
  1. **Guard ref snapshot** (`refs/guard/snapshot`) — agent creates before each high-risk edit using temp index (§2a). Does not pollute user's branch or staging area.
216
- 2. **Git branch auto-backup** (`cursor-guard/auto-backup`) — periodic snapshots by auto-backup script.
216
+ 2. **Git auto-backup ref** (`refs/guard/auto-backup`) — periodic snapshots by auto-backup script. Lives outside `refs/heads/` so `git push --all` won't push it.
217
217
  3. **Shadow copy** (`.cursor-guard-backup/`) — fallback for non-git repos, or as extra insurance when `backup_strategy = "both"`.
218
218
  4. **Editor habits** — Ctrl+S frequently; optional extensions are user-configured, mention only if asked.
219
219
 
@@ -267,7 +267,7 @@ If unclear, ask: "你想恢复哪个文件?还是整个项目?" / "Which fil
267
267
  git log --oneline --before="5 minutes ago" -5 -- <file>
268
268
 
269
269
  # Auto-backup branch (if exists)
270
- git log cursor-guard/auto-backup --oneline --before="5 minutes ago" -5 -- <file>
270
+ git log guard/auto-backup --oneline --before="5 minutes ago" -5 -- <file>
271
271
 
272
272
  # Reflog as fallback (shows all HEAD movements)
273
273
  git reflog --before="5 minutes ago" -5
@@ -291,7 +291,7 @@ git log --oneline -<N+5> -- <file>
291
291
  git show HEAD~<N>:<file>
292
292
 
293
293
  # Auto-backup branch
294
- git log cursor-guard/auto-backup --oneline -<N+5> -- <file>
294
+ git log guard/auto-backup --oneline -<N+5> -- <file>
295
295
  ```
296
296
 
297
297
  ### Step 3: Present Candidates to User
@@ -314,7 +314,7 @@ Recommended: #1 (closest to target time). Restore this one? / 推荐 #1(最接
314
314
  - If only ONE candidate is found, confirm with the user before restoring.
315
315
  - If MULTIPLE candidates, pre-select #1 (closest before target) but let the user pick another.
316
316
  - If NO candidates before the target time:
317
- - Check auto-backup branch: `git rev-parse --verify cursor-guard/auto-backup`
317
+ - Check auto-backup ref: `git rev-parse --verify refs/guard/auto-backup`
318
318
  - Check shadow copies: `Get-ChildItem .cursor-guard-backup/ -Directory | Sort-Object Name -Descending`
319
319
  - If still nothing, report clearly: "No snapshot found before that time. The earliest available is [hash] at [time]. Do you want to use it?"
320
320
  - **Never silently pick a version.** Always show and confirm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "2.0.2",
3
+ "version": "2.1.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",
@@ -23,6 +23,9 @@
23
23
  "engines": {
24
24
  "node": ">=18"
25
25
  },
26
+ "scripts": {
27
+ "test": "node references/lib/utils.test.js"
28
+ },
26
29
  "bin": {
27
30
  "cursor-guard-backup": "references/bin/cursor-guard-backup.js",
28
31
  "cursor-guard-doctor": "references/bin/cursor-guard-doctor.js"
@@ -32,7 +35,8 @@
32
35
  "README.md",
33
36
  "README.zh-CN.md",
34
37
  "LICENSE",
35
- "references/"
38
+ "references/",
39
+ "!references/lib/utils.test.js"
36
40
  ],
37
41
  "main": "SKILL.md"
38
42
  }
@@ -5,9 +5,26 @@ const path = require('path');
5
5
  const { parseArgs } = require('../lib/utils');
6
6
 
7
7
  const args = parseArgs(process.argv);
8
+
9
+ if (args.help || args.h) {
10
+ console.log(`Usage: cursor-guard-backup [options]
11
+
12
+ Options:
13
+ --path <dir> Project directory to watch (default: current dir)
14
+ --interval <sec> Override backup interval in seconds
15
+ --help, -h Show this help message
16
+ --version, -v Show version number`);
17
+ process.exit(0);
18
+ }
19
+
20
+ if (args.version || args.v) {
21
+ const pkg = require('../../package.json');
22
+ console.log(pkg.version);
23
+ process.exit(0);
24
+ }
25
+
8
26
  const targetPath = args.path || '.';
9
27
  const interval = parseInt(args.interval, 10) || 0;
10
-
11
28
  const resolved = path.resolve(targetPath);
12
29
 
13
30
  const { runBackup } = require('../lib/auto-backup');
@@ -5,6 +5,23 @@ const path = require('path');
5
5
  const { parseArgs } = require('../lib/utils');
6
6
 
7
7
  const args = parseArgs(process.argv);
8
+
9
+ if (args.help || args.h) {
10
+ console.log(`Usage: cursor-guard-doctor [options]
11
+
12
+ Options:
13
+ --path <dir> Project directory to check (default: current dir)
14
+ --help, -h Show this help message
15
+ --version, -v Show version number`);
16
+ process.exit(0);
17
+ }
18
+
19
+ if (args.version || args.v) {
20
+ const pkg = require('../../package.json');
21
+ console.log(pkg.version);
22
+ process.exit(0);
23
+ }
24
+
8
25
  const targetPath = args.path || '.';
9
26
  const resolved = path.resolve(targetPath);
10
27
 
@@ -51,7 +51,7 @@ Blacklist glob patterns. Matching files are excluded from protection even if the
51
51
 
52
52
  | Value | Description |
53
53
  |-------|-------------|
54
- | `"git"` | Local commits to dedicated branch `cursor-guard/auto-backup` |
54
+ | `"git"` | Local commits to dedicated ref `refs/guard/auto-backup` |
55
55
  | `"shadow"` | File copies to `.cursor-guard-backup/<timestamp>/` |
56
56
  | `"both"` | Git branch snapshot + shadow copies |
57
57
 
@@ -155,7 +155,7 @@ Retention policy for **shadow copies** only. Git branch snapshots are not auto-c
155
155
  - **Type**: `object`
156
156
  - **Default**: `{ "enabled": false, "mode": "count", "max_count": 200 }`
157
157
 
158
- Retention policy for the **`cursor-guard/auto-backup` Git branch**. By default, auto-backup commits accumulate indefinitely. Enable this to automatically prune old commits.
158
+ Retention policy for the **`refs/guard/auto-backup` Git ref**. By default, auto-backup commits accumulate indefinitely. Enable this to automatically prune old commits.
159
159
 
160
160
  ### Sub-fields
161
161
 
@@ -51,7 +51,7 @@
51
51
 
52
52
  | 值 | 说明 |
53
53
  |----|------|
54
- | `"git"` | 提交到专用分支 `cursor-guard/auto-backup` |
54
+ | `"git"` | 提交到专用引用 `refs/guard/auto-backup` |
55
55
  | `"shadow"` | 文件拷贝到 `.cursor-guard-backup/<timestamp>/` |
56
56
  | `"both"` | Git 分支快照 + 影子拷贝同时进行 |
57
57
 
@@ -155,7 +155,7 @@
155
155
  - **类型**:`object`
156
156
  - **默认值**:`{ "enabled": false, "mode": "count", "max_count": 200 }`
157
157
 
158
- **`cursor-guard/auto-backup` Git 分支**的保留策略。默认情况下自动备份提交会无限累积。启用此项可自动裁剪旧提交。
158
+ **`refs/guard/auto-backup` Git 引用**的保留策略。默认情况下自动备份提交会无限累积。启用此项可自动裁剪旧提交。
159
159
 
160
160
  ### 子字段
161
161
 
@@ -75,7 +75,7 @@
75
75
  },
76
76
  "git_retention": {
77
77
  "type": "object",
78
- "description": "Controls automatic cleanup of old commits on the cursor-guard/auto-backup branch.",
78
+ "description": "Controls automatic cleanup of old commits on refs/guard/auto-backup.",
79
79
  "properties": {
80
80
  "enabled": {
81
81
  "type": "boolean",
@@ -312,8 +312,6 @@ function isProcessAlive(pid) {
312
312
  // ── Main ────────────────────────────────────────────────────────
313
313
 
314
314
  async function runBackup(projectDir, intervalOverride) {
315
- process.chdir(projectDir);
316
-
317
315
  const hasGit = gitAvailable();
318
316
  const repo = hasGit && isGitRepo(projectDir);
319
317
  const gDir = repo ? getGitDir(projectDir) : null;
@@ -326,7 +324,7 @@ async function runBackup(projectDir, intervalOverride) {
326
324
  const guardIndex = gDir ? path.join(gDir, 'cursor-guard-index') : null;
327
325
 
328
326
  // Load config
329
- let { cfg, loaded, error } = loadConfig(projectDir);
327
+ let { cfg, loaded, error, warnings } = loadConfig(projectDir);
330
328
  let interval = intervalOverride || cfg.auto_backup_interval_seconds || 60;
331
329
  if (interval < 5) interval = 5;
332
330
  let cfgMtime = 0;
@@ -337,6 +335,9 @@ async function runBackup(projectDir, intervalOverride) {
337
335
  console.log(color.yellow(`[guard] WARNING: .cursor-guard.json parse error — using defaults. ${error}`));
338
336
  } else if (loaded) {
339
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
+ }
340
341
  }
341
342
 
342
343
  // Strategy check
@@ -399,13 +400,24 @@ async function runBackup(projectDir, intervalOverride) {
399
400
  process.on('exit', cleanup);
400
401
 
401
402
  // Git-specific setup
402
- const branch = 'cursor-guard/auto-backup';
403
- const branchRef = `refs/heads/${branch}`;
403
+ const branchRef = 'refs/guard/auto-backup';
404
+ const legacyRef = 'refs/heads/cursor-guard/auto-backup';
404
405
  if (repo) {
405
406
  const exists = git(['rev-parse', '--verify', branchRef], { cwd: projectDir, allowFail: true });
406
407
  if (!exists) {
407
- git(['branch', branch, 'HEAD'], { cwd: projectDir, allowFail: true });
408
- console.log(color.green(`[guard] Created branch: ${branch}`));
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
+ }
409
421
  }
410
422
 
411
423
  const excludeFile = path.join(gDir, 'info', 'exclude');
@@ -420,10 +432,20 @@ async function runBackup(projectDir, intervalOverride) {
420
432
 
421
433
  const logger = createLogger(logFilePath);
422
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
+
423
445
  // Banner
424
446
  console.log('');
425
447
  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}`));
448
+ console.log(color.cyan(`[guard] Strategy: ${cfg.backup_strategy} | Ref: ${branchRef} | Retention: ${cfg.retention.mode}`));
427
449
  console.log(color.cyan(`[guard] Log: ${logFilePath}`));
428
450
  console.log('');
429
451
 
@@ -81,15 +81,20 @@ function runDoctor(projectDir) {
81
81
  check('Strategy compatibility', 'FAIL', `unknown backup_strategy='${strategy}' (must be git/shadow/both)`);
82
82
  }
83
83
 
84
- // 5. Backup branch
84
+ // 5. Backup ref
85
85
  if (repo) {
86
- const branchRef = 'refs/heads/cursor-guard/auto-backup';
87
- const exists = git(['rev-parse', '--verify', branchRef], { cwd: projectDir, allowFail: true });
86
+ const guardRef = 'refs/guard/auto-backup';
87
+ const legacyRef = 'refs/heads/cursor-guard/auto-backup';
88
+ const exists = git(['rev-parse', '--verify', guardRef], { cwd: projectDir, allowFail: true });
89
+ const legacyExists = git(['rev-parse', '--verify', legacyRef], { cwd: projectDir, allowFail: true });
88
90
  if (exists) {
89
- const count = git(['rev-list', '--count', branchRef], { cwd: projectDir, allowFail: true }) || '?';
90
- check('Backup branch', 'PASS', `cursor-guard/auto-backup exists (${count} commits)`);
91
+ const count = git(['rev-list', '--count', guardRef], { cwd: projectDir, allowFail: true }) || '?';
92
+ check('Backup ref', 'PASS', `refs/guard/auto-backup exists (${count} commits)`);
93
+ } else if (legacyExists) {
94
+ const count = git(['rev-list', '--count', legacyRef], { cwd: projectDir, allowFail: true }) || '?';
95
+ check('Backup ref', 'WARN', `legacy refs/heads/cursor-guard/auto-backup found (${count} commits) — run auto-backup once to migrate`);
91
96
  } else {
92
- check('Backup branch', 'WARN', 'cursor-guard/auto-backup not created yet (will be created on first backup)');
97
+ check('Backup ref', 'WARN', 'refs/guard/auto-backup not created yet (will be created on first backup)');
93
98
  }
94
99
  }
95
100
 
@@ -52,18 +52,22 @@ const ALWAYS_SKIP = /[/\\](\.git|\.cursor-guard-backup|node_modules)[/\\]/;
52
52
 
53
53
  function walkDir(dir, rootDir) {
54
54
  const results = [];
55
- let entries;
56
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
57
- catch { return results; }
58
- for (const entry of entries) {
59
- const full = path.join(dir, entry.name);
60
- const rel = path.relative(rootDir, full).replace(/\\/g, '/');
61
- if (ALWAYS_SKIP.test('/' + rel + '/')) continue;
62
- if (entry.isSymbolicLink()) continue;
63
- if (entry.isDirectory()) {
64
- results.push(...walkDir(full, rootDir));
65
- } else if (entry.isFile()) {
66
- results.push({ full, rel, name: entry.name });
55
+ const stack = [dir];
56
+ while (stack.length > 0) {
57
+ const current = stack.pop();
58
+ let entries;
59
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); }
60
+ catch { continue; }
61
+ for (const entry of entries) {
62
+ const full = path.join(current, entry.name);
63
+ const rel = path.relative(rootDir, full).replace(/\\/g, '/');
64
+ if (ALWAYS_SKIP.test('/' + rel + '/')) continue;
65
+ if (entry.isSymbolicLink()) continue;
66
+ if (entry.isDirectory()) {
67
+ stack.push(full);
68
+ } else if (entry.isFile()) {
69
+ results.push({ full, rel, name: entry.name });
70
+ }
67
71
  }
68
72
  }
69
73
  return results;
@@ -73,6 +77,11 @@ function walkDir(dir, rootDir) {
73
77
 
74
78
  const DEFAULT_SECRETS = ['.env', '.env.*', '*.key', '*.pem', '*.p12', '*.pfx', 'credentials*'];
75
79
 
80
+ const VALID_STRATEGIES = ['git', 'shadow', 'both'];
81
+ const VALID_PRE_RESTORE = ['always', 'ask', 'never'];
82
+ const VALID_RETENTION_MODES = ['days', 'count', 'size'];
83
+ const VALID_GIT_RETENTION_MODES = ['days', 'count'];
84
+
76
85
  const DEFAULT_CONFIG = {
77
86
  protect: [],
78
87
  ignore: [],
@@ -101,22 +110,47 @@ function loadConfig(projectDir) {
101
110
  const merged = [...new Set([...cfg.secrets_patterns, ...raw.secrets_patterns_extra])];
102
111
  cfg.secrets_patterns = merged;
103
112
  }
104
- if (typeof raw.backup_strategy === 'string') cfg.backup_strategy = raw.backup_strategy;
113
+ const warnings = [];
114
+ if (typeof raw.backup_strategy === 'string') {
115
+ if (VALID_STRATEGIES.includes(raw.backup_strategy)) {
116
+ cfg.backup_strategy = raw.backup_strategy;
117
+ } else {
118
+ warnings.push(`Unknown backup_strategy "${raw.backup_strategy}", using default "${cfg.backup_strategy}"`);
119
+ }
120
+ }
105
121
  if (typeof raw.auto_backup_interval_seconds === 'number') cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
106
- if (typeof raw.pre_restore_backup === 'string') cfg.pre_restore_backup = raw.pre_restore_backup;
122
+ if (typeof raw.pre_restore_backup === 'string') {
123
+ if (VALID_PRE_RESTORE.includes(raw.pre_restore_backup)) {
124
+ cfg.pre_restore_backup = raw.pre_restore_backup;
125
+ } else {
126
+ warnings.push(`Unknown pre_restore_backup "${raw.pre_restore_backup}", using default "${cfg.pre_restore_backup}"`);
127
+ }
128
+ }
107
129
  if (raw.retention) {
108
- if (raw.retention.mode) cfg.retention.mode = raw.retention.mode;
109
- if (raw.retention.days) cfg.retention.days = raw.retention.days;
110
- if (raw.retention.max_count) cfg.retention.max_count = raw.retention.max_count;
111
- if (raw.retention.max_size_mb) cfg.retention.max_size_mb = raw.retention.max_size_mb;
130
+ if (raw.retention.mode) {
131
+ if (VALID_RETENTION_MODES.includes(raw.retention.mode)) {
132
+ cfg.retention.mode = raw.retention.mode;
133
+ } else {
134
+ warnings.push(`Unknown retention.mode "${raw.retention.mode}", using default "${cfg.retention.mode}"`);
135
+ }
136
+ }
137
+ if (typeof raw.retention.days === 'number') cfg.retention.days = raw.retention.days;
138
+ if (typeof raw.retention.max_count === 'number') cfg.retention.max_count = raw.retention.max_count;
139
+ if (typeof raw.retention.max_size_mb === 'number') cfg.retention.max_size_mb = raw.retention.max_size_mb;
112
140
  }
113
141
  if (raw.git_retention) {
114
142
  if (raw.git_retention.enabled === true) cfg.git_retention.enabled = true;
115
- if (raw.git_retention.mode) cfg.git_retention.mode = raw.git_retention.mode;
116
- if (raw.git_retention.days) cfg.git_retention.days = raw.git_retention.days;
117
- if (raw.git_retention.max_count) cfg.git_retention.max_count = raw.git_retention.max_count;
143
+ if (raw.git_retention.mode) {
144
+ if (VALID_GIT_RETENTION_MODES.includes(raw.git_retention.mode)) {
145
+ cfg.git_retention.mode = raw.git_retention.mode;
146
+ } else {
147
+ warnings.push(`Unknown git_retention.mode "${raw.git_retention.mode}", using default "${cfg.git_retention.mode}"`);
148
+ }
149
+ }
150
+ if (typeof raw.git_retention.days === 'number') cfg.git_retention.days = raw.git_retention.days;
151
+ if (typeof raw.git_retention.max_count === 'number') cfg.git_retention.max_count = raw.git_retention.max_count;
118
152
  }
119
- return { cfg, loaded: true, error: null };
153
+ return { cfg, loaded: true, error: null, warnings };
120
154
  } catch (e) {
121
155
  return { cfg, loaded: false, error: e.message };
122
156
  }
@@ -249,11 +283,23 @@ function timestamp() {
249
283
  return new Date().toISOString().replace('T', ' ').substring(0, 19);
250
284
  }
251
285
 
252
- function createLogger(logFilePath) {
286
+ function createLogger(logFilePath, maxSizeMB = 10) {
287
+ let writeCount = 0;
288
+ function rotateIfNeeded() {
289
+ if (++writeCount % 100 !== 0) return;
290
+ try {
291
+ const stat = fs.statSync(logFilePath);
292
+ if (stat.size > maxSizeMB * 1024 * 1024) {
293
+ const old = logFilePath + '.old';
294
+ try { fs.unlinkSync(old); } catch { /* ignore */ }
295
+ fs.renameSync(logFilePath, old);
296
+ }
297
+ } catch { /* ignore */ }
298
+ }
253
299
  return {
254
300
  log(msg, c = 'green') {
255
301
  const line = `${timestamp()} ${msg}`;
256
- try { fs.appendFileSync(logFilePath, line + '\n'); } catch { /* ignore */ }
302
+ try { fs.appendFileSync(logFilePath, line + '\n'); rotateIfNeeded(); } catch { /* ignore */ }
257
303
  console.log(color[c] ? color[c](`[guard] ${line}`) : `[guard] ${line}`);
258
304
  },
259
305
  info(msg) { this.log(msg, 'cyan'); },
@@ -268,24 +268,24 @@ The `auto-backup.ps1` script stores periodic snapshots on a dedicated branch via
268
268
 
269
269
  ```bash
270
270
  # List recent auto-backup snapshots
271
- git log cursor-guard/auto-backup --oneline -20
271
+ git log guard/auto-backup --oneline -20
272
272
 
273
273
  # Restore a file from the latest auto-backup
274
- git restore --source=cursor-guard/auto-backup -- <path/to/file>
274
+ git restore --source=guard/auto-backup -- <path/to/file>
275
275
 
276
276
  # Restore from a specific auto-backup snapshot
277
277
  git restore --source=<commit-hash> -- <path/to/file>
278
278
 
279
279
  # Diff your working copy against the auto-backup version
280
- git diff cursor-guard/auto-backup -- <path/to/file>
280
+ git diff guard/auto-backup -- <path/to/file>
281
281
 
282
282
  # Time-based: find auto-backup snapshot from before N minutes ago
283
283
  # 按时间查找:N 分钟前之前最近的自动备份快照
284
- git log cursor-guard/auto-backup --oneline --before="5 minutes ago" -5 -- <path/to/file>
284
+ git log guard/auto-backup --oneline --before="5 minutes ago" -5 -- <path/to/file>
285
285
 
286
286
  # Version-based: list recent N auto-backup snapshots
287
287
  # 按版本查找:最近 N 个自动备份快照
288
- git log cursor-guard/auto-backup --oneline -10 -- <path/to/file>
288
+ git log guard/auto-backup --oneline -10 -- <path/to/file>
289
289
  ```
290
290
 
291
291
  ## If not a Git repo yet
@@ -374,10 +374,10 @@ Get-ChildItem .cursor-guard-backup/ -Directory | Where-Object {
374
374
 
375
375
  ```bash
376
376
  # View auto-backup history
377
- git log cursor-guard/auto-backup --oneline -30
377
+ git log guard/auto-backup --oneline -30
378
378
 
379
379
  # Delete the branch entirely (script will recreate it on next run)
380
- git branch -D cursor-guard/auto-backup
380
+ git update-ref -d refs/guard/auto-backup
381
381
 
382
382
  # Reclaim disk space after removing old branches
383
383
  git gc --prune=now
@@ -1,329 +0,0 @@
1
- 'use strict';
2
-
3
- const assert = require('assert');
4
- const path = require('path');
5
- const fs = require('fs');
6
- const os = require('os');
7
- const {
8
- globMatch, matchesAny, loadConfig, DEFAULT_CONFIG, DEFAULT_SECRETS,
9
- filterFiles, buildManifest, manifestChanged, parseArgs, walkDir,
10
- } = require('./utils');
11
-
12
- let passed = 0;
13
- let failed = 0;
14
-
15
- function test(name, fn) {
16
- try {
17
- fn();
18
- passed++;
19
- console.log(` \x1b[32m✓\x1b[0m ${name}`);
20
- } catch (e) {
21
- failed++;
22
- console.log(` \x1b[31m✗\x1b[0m ${name}`);
23
- console.log(` ${e.message}`);
24
- }
25
- }
26
-
27
- // ── globMatch ────────────────────────────────────────────────────
28
-
29
- console.log('\nglobMatch:');
30
-
31
- test('exact filename match', () => {
32
- assert.strictEqual(globMatch('.env', '.env'), true);
33
- assert.strictEqual(globMatch('.env', '.envx'), false);
34
- });
35
-
36
- test('* matches within a single segment', () => {
37
- assert.strictEqual(globMatch('*.js', 'foo.js'), true);
38
- assert.strictEqual(globMatch('*.js', 'bar.ts'), false);
39
- assert.strictEqual(globMatch('*.js', 'dir/foo.js'), false);
40
- });
41
-
42
- test('** matches across directories', () => {
43
- assert.strictEqual(globMatch('**/*.js', 'src/foo.js'), true);
44
- assert.strictEqual(globMatch('**/*.js', 'a/b/c/foo.js'), true);
45
- // **/*.js requires a slash — root-level 'foo.js' doesn't match (matchesAny checks leaf separately)
46
- assert.strictEqual(globMatch('**/*.js', 'foo.js'), false);
47
- assert.strictEqual(globMatch('**/*.js', 'foo.ts'), false);
48
- });
49
-
50
- test('? matches single character', () => {
51
- assert.strictEqual(globMatch('?.txt', 'a.txt'), true);
52
- assert.strictEqual(globMatch('?.txt', 'ab.txt'), false);
53
- });
54
-
55
- test('.env.* pattern', () => {
56
- assert.strictEqual(globMatch('.env.*', '.env.local'), true);
57
- assert.strictEqual(globMatch('.env.*', '.env.production'), true);
58
- assert.strictEqual(globMatch('.env.*', '.env'), false);
59
- });
60
-
61
- test('credentials* pattern', () => {
62
- assert.strictEqual(globMatch('credentials*', 'credentials'), true);
63
- assert.strictEqual(globMatch('credentials*', 'credentials.json'), true);
64
- assert.strictEqual(globMatch('credentials*', 'my-credentials'), false);
65
- });
66
-
67
- test('directory pattern src/**', () => {
68
- assert.strictEqual(globMatch('src/**', 'src/foo.js'), true);
69
- assert.strictEqual(globMatch('src/**', 'src/a/b.js'), true);
70
- assert.strictEqual(globMatch('src/**', 'lib/foo.js'), false);
71
- });
72
-
73
- test('backslash normalization', () => {
74
- assert.strictEqual(globMatch('src/**/*.ts', 'src\\components\\App.ts'), true);
75
- });
76
-
77
- test('regex special chars in pattern are escaped', () => {
78
- assert.strictEqual(globMatch('file(1).txt', 'file(1).txt'), true);
79
- assert.strictEqual(globMatch('file[0].txt', 'file[0].txt'), true); // [] escaped as literals
80
- });
81
-
82
- // ── matchesAny ───────────────────────────────────────────────────
83
-
84
- console.log('\nmatchesAny:');
85
-
86
- test('matches when any pattern hits', () => {
87
- assert.strictEqual(matchesAny(['*.js', '*.ts'], 'foo.js'), true);
88
- assert.strictEqual(matchesAny(['*.js', '*.ts'], 'foo.ts'), true);
89
- assert.strictEqual(matchesAny(['*.js', '*.ts'], 'foo.py'), false);
90
- });
91
-
92
- test('checks leaf filename for deep paths', () => {
93
- assert.strictEqual(matchesAny(['*.key'], 'secrets/server.key'), true);
94
- assert.strictEqual(matchesAny(['.env'], 'config/.env'), true);
95
- });
96
-
97
- test('empty patterns matches nothing', () => {
98
- assert.strictEqual(matchesAny([], 'anything'), false);
99
- });
100
-
101
- // ── loadConfig ───────────────────────────────────────────────────
102
-
103
- console.log('\nloadConfig:');
104
-
105
- test('returns defaults when no config file', () => {
106
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
107
- try {
108
- const { cfg, loaded, error } = loadConfig(tmpDir);
109
- assert.strictEqual(loaded, false);
110
- assert.strictEqual(error, null);
111
- assert.deepStrictEqual(cfg.protect, []);
112
- assert.deepStrictEqual(cfg.ignore, []);
113
- assert.deepStrictEqual(cfg.secrets_patterns, DEFAULT_SECRETS);
114
- assert.strictEqual(cfg.backup_strategy, 'git');
115
- assert.strictEqual(cfg.git_retention.enabled, false);
116
- } finally {
117
- fs.rmSync(tmpDir, { recursive: true, force: true });
118
- }
119
- });
120
-
121
- test('loads and merges custom config', () => {
122
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
123
- try {
124
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
125
- protect: ['src/**'],
126
- backup_strategy: 'both',
127
- retention: { mode: 'count', max_count: 50 },
128
- }));
129
- const { cfg, loaded, error } = loadConfig(tmpDir);
130
- assert.strictEqual(loaded, true);
131
- assert.strictEqual(error, null);
132
- assert.deepStrictEqual(cfg.protect, ['src/**']);
133
- assert.strictEqual(cfg.backup_strategy, 'both');
134
- assert.strictEqual(cfg.retention.mode, 'count');
135
- assert.strictEqual(cfg.retention.max_count, 50);
136
- assert.strictEqual(cfg.retention.days, 30); // default preserved
137
- } finally {
138
- fs.rmSync(tmpDir, { recursive: true, force: true });
139
- }
140
- });
141
-
142
- test('handles malformed JSON gracefully', () => {
143
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
144
- try {
145
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), '{ broken }');
146
- const { cfg, loaded, error } = loadConfig(tmpDir);
147
- assert.strictEqual(loaded, false);
148
- assert.ok(error, 'should have an error message');
149
- assert.deepStrictEqual(cfg.protect, []);
150
- } finally {
151
- fs.rmSync(tmpDir, { recursive: true, force: true });
152
- }
153
- });
154
-
155
- test('secrets_patterns override replaces defaults entirely', () => {
156
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
157
- try {
158
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
159
- secrets_patterns: ['my-secret'],
160
- }));
161
- const { cfg } = loadConfig(tmpDir);
162
- assert.deepStrictEqual(cfg.secrets_patterns, ['my-secret']);
163
- } finally {
164
- fs.rmSync(tmpDir, { recursive: true, force: true });
165
- }
166
- });
167
-
168
- test('secrets_patterns_extra appends to defaults', () => {
169
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
170
- try {
171
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
172
- secrets_patterns_extra: ['*.secret', 'tokens.*'],
173
- }));
174
- const { cfg } = loadConfig(tmpDir);
175
- assert.ok(cfg.secrets_patterns.includes('.env'), 'should keep default .env');
176
- assert.ok(cfg.secrets_patterns.includes('*.p12'), 'should keep default *.p12');
177
- assert.ok(cfg.secrets_patterns.includes('*.secret'), 'should include extra *.secret');
178
- assert.ok(cfg.secrets_patterns.includes('tokens.*'), 'should include extra tokens.*');
179
- } finally {
180
- fs.rmSync(tmpDir, { recursive: true, force: true });
181
- }
182
- });
183
-
184
- test('secrets_patterns_extra merges with custom secrets_patterns', () => {
185
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
186
- try {
187
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
188
- secrets_patterns: ['.env'],
189
- secrets_patterns_extra: ['.env', '*.secret'],
190
- }));
191
- const { cfg } = loadConfig(tmpDir);
192
- assert.deepStrictEqual(cfg.secrets_patterns, ['.env', '*.secret']);
193
- } finally {
194
- fs.rmSync(tmpDir, { recursive: true, force: true });
195
- }
196
- });
197
-
198
- test('non-string backup_strategy is ignored', () => {
199
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
200
- try {
201
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
202
- backup_strategy: 123,
203
- auto_backup_interval_seconds: 'bad',
204
- }));
205
- const { cfg } = loadConfig(tmpDir);
206
- assert.strictEqual(cfg.backup_strategy, 'git');
207
- assert.strictEqual(cfg.auto_backup_interval_seconds, 60);
208
- } finally {
209
- fs.rmSync(tmpDir, { recursive: true, force: true });
210
- }
211
- });
212
-
213
- // ── filterFiles ──────────────────────────────────────────────────
214
-
215
- console.log('\nfilterFiles:');
216
-
217
- const makeFiles = names => names.map(n => ({ full: `/fake/${n}`, rel: n, name: path.basename(n) }));
218
-
219
- test('no protect/ignore returns all non-secret files', () => {
220
- const files = makeFiles(['a.js', 'b.ts', '.env', 'credentials.json']);
221
- const cfg = { ...DEFAULT_CONFIG };
222
- const result = filterFiles(files, cfg);
223
- const rels = result.map(f => f.rel);
224
- assert.ok(!rels.includes('.env'));
225
- assert.ok(!rels.includes('credentials.json'));
226
- assert.ok(rels.includes('a.js'));
227
- assert.ok(rels.includes('b.ts'));
228
- });
229
-
230
- test('protect narrows scope', () => {
231
- const files = makeFiles(['src/a.js', 'lib/b.js', 'README.md']);
232
- const cfg = { ...DEFAULT_CONFIG, protect: ['src/**'] };
233
- const result = filterFiles(files, cfg);
234
- assert.strictEqual(result.length, 1);
235
- assert.strictEqual(result[0].rel, 'src/a.js');
236
- });
237
-
238
- test('ignore excludes files', () => {
239
- const files = makeFiles(['src/a.js', 'src/a.test.js', 'src/b.js']);
240
- const cfg = { ...DEFAULT_CONFIG, ignore: ['**/*.test.js'] };
241
- const result = filterFiles(files, cfg);
242
- const rels = result.map(f => f.rel);
243
- assert.ok(!rels.includes('src/a.test.js'));
244
- assert.ok(rels.includes('src/a.js'));
245
- });
246
-
247
- // ── manifestChanged ──────────────────────────────────────────────
248
-
249
- console.log('\nmanifestChanged:');
250
-
251
- test('null old manifest means changed', () => {
252
- assert.strictEqual(manifestChanged(null, { 'a.js': { mtimeMs: 1, size: 100 } }), true);
253
- });
254
-
255
- test('identical manifests are not changed', () => {
256
- const m = { 'a.js': { mtimeMs: 1, size: 100 } };
257
- assert.strictEqual(manifestChanged(m, { ...m }), false);
258
- });
259
-
260
- test('different mtime means changed', () => {
261
- const old = { 'a.js': { mtimeMs: 1, size: 100 } };
262
- const nw = { 'a.js': { mtimeMs: 2, size: 100 } };
263
- assert.strictEqual(manifestChanged(old, nw), true);
264
- });
265
-
266
- test('new file means changed', () => {
267
- const old = { 'a.js': { mtimeMs: 1, size: 100 } };
268
- const nw = { 'a.js': { mtimeMs: 1, size: 100 }, 'b.js': { mtimeMs: 2, size: 50 } };
269
- assert.strictEqual(manifestChanged(old, nw), true);
270
- });
271
-
272
- // ── parseArgs ────────────────────────────────────────────────────
273
-
274
- console.log('\nparseArgs:');
275
-
276
- test('parses --key value pairs', () => {
277
- const args = parseArgs(['node', 'script', '--path', '/tmp', '--interval', '30']);
278
- assert.strictEqual(args.path, '/tmp');
279
- assert.strictEqual(args.interval, '30');
280
- });
281
-
282
- test('parses boolean flags', () => {
283
- const args = parseArgs(['node', 'script', '--verbose']);
284
- assert.strictEqual(args.verbose, true);
285
- });
286
-
287
- test('empty args returns empty object', () => {
288
- const args = parseArgs(['node', 'script']);
289
- assert.deepStrictEqual(args, {});
290
- });
291
-
292
- // ── walkDir ──────────────────────────────────────────────────────
293
-
294
- console.log('\nwalkDir:');
295
-
296
- test('discovers files recursively', () => {
297
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-walk-'));
298
- try {
299
- fs.mkdirSync(path.join(tmpDir, 'sub'), { recursive: true });
300
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'a');
301
- fs.writeFileSync(path.join(tmpDir, 'sub', 'b.txt'), 'b');
302
- const files = walkDir(tmpDir, tmpDir);
303
- const rels = files.map(f => f.rel).sort();
304
- assert.deepStrictEqual(rels, ['a.txt', 'sub/b.txt']);
305
- } finally {
306
- fs.rmSync(tmpDir, { recursive: true, force: true });
307
- }
308
- });
309
-
310
- test('skips .git and node_modules', () => {
311
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-walk-'));
312
- try {
313
- fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true });
314
- fs.mkdirSync(path.join(tmpDir, 'node_modules'), { recursive: true });
315
- fs.writeFileSync(path.join(tmpDir, '.git', 'HEAD'), 'ref');
316
- fs.writeFileSync(path.join(tmpDir, 'node_modules', 'x.js'), 'x');
317
- fs.writeFileSync(path.join(tmpDir, 'real.js'), 'y');
318
- const files = walkDir(tmpDir, tmpDir);
319
- assert.strictEqual(files.length, 1);
320
- assert.strictEqual(files[0].rel, 'real.js');
321
- } finally {
322
- fs.rmSync(tmpDir, { recursive: true, force: true });
323
- }
324
- });
325
-
326
- // ── Summary ──────────────────────────────────────────────────────
327
-
328
- console.log(`\n${passed + failed} tests: \x1b[32m${passed} passed\x1b[0m` + (failed ? `, \x1b[31m${failed} failed\x1b[0m` : ''));
329
- process.exit(failed > 0 ? 1 : 0);