cursor-guard 1.3.2 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
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);
@@ -13,8 +13,7 @@ Replace `<path>` / `<file>` with real paths. Run from repository root. **Review
13
13
  ### Single file / 单文件
14
14
 
15
15
  ```powershell
16
- # Preserve current file state via temp index (does not touch staging area)
17
- # 通过临时索引保留当前文件(不影响暂存区)
16
+ $ts = Get-Date -Format 'yyyyMMdd_HHmmss'
18
17
  $guardIdx = Join-Path (git rev-parse --git-dir) "guard-pre-restore-index"
19
18
  $env:GIT_INDEX_FILE = $guardIdx
20
19
  git read-tree HEAD
@@ -23,14 +22,15 @@ $tree = git write-tree
23
22
  $env:GIT_INDEX_FILE = $null
24
23
  Remove-Item $guardIdx -Force -ErrorAction SilentlyContinue
25
24
  $commit = git commit-tree $tree -p HEAD -m "guard: preserve current before restore"
26
- git update-ref refs/guard/pre-restore $commit
27
- Write-Host "Pre-restore backup: $($commit.Substring(0,7))"
25
+ git update-ref "refs/guard/pre-restore/$ts" $commit
26
+ git update-ref refs/guard/pre-restore $commit # alias to latest
27
+ Write-Host "Pre-restore backup: refs/guard/pre-restore/$ts ($($commit.Substring(0,7)))"
28
28
  ```
29
29
 
30
30
  ### Entire project / 整个项目
31
31
 
32
32
  ```powershell
33
- # Same as above but with git add -A
33
+ $ts = Get-Date -Format 'yyyyMMdd_HHmmss'
34
34
  $guardIdx = Join-Path (git rev-parse --git-dir) "guard-pre-restore-index"
35
35
  $env:GIT_INDEX_FILE = $guardIdx
36
36
  git read-tree HEAD
@@ -39,8 +39,9 @@ $tree = git write-tree
39
39
  $env:GIT_INDEX_FILE = $null
40
40
  Remove-Item $guardIdx -Force -ErrorAction SilentlyContinue
41
41
  $commit = git commit-tree $tree -p HEAD -m "guard: preserve current before restore"
42
- git update-ref refs/guard/pre-restore $commit
43
- Write-Host "Pre-restore backup: $($commit.Substring(0,7))"
42
+ git update-ref "refs/guard/pre-restore/$ts" $commit
43
+ git update-ref refs/guard/pre-restore $commit # alias to latest
44
+ Write-Host "Pre-restore backup: refs/guard/pre-restore/$ts ($($commit.Substring(0,7)))"
44
45
  ```
45
46
 
46
47
  ### Non-Git fallback (shadow copy) / 非 Git 备选方案
@@ -53,21 +54,40 @@ Copy-Item "<file>" "$dir/<filename>"
53
54
  Write-Host "Pre-restore shadow copy: $dir"
54
55
  ```
55
56
 
57
+ ### List all pre-restore snapshots / 列出所有恢复前快照
58
+
59
+ ```bash
60
+ git for-each-ref refs/guard/pre-restore/ --sort=-creatordate \
61
+ --format="%(refname:short) %(creatordate:short) %(objectname:short)"
62
+ ```
63
+
56
64
  ### Undo a restore (recover pre-restore state) / 撤销恢复(回到恢复前的状态)
57
65
 
58
66
  ```bash
59
- # Restore single file to pre-restore state
60
- # 将单个文件恢复到恢复前的状态
67
+ # Undo using a specific timestamped snapshot
68
+ # 使用特定时间戳快照撤销恢复
69
+ git restore --source=refs/guard/pre-restore/<yyyyMMdd_HHmmss> -- <file>
70
+
71
+ # Undo using the latest pre-restore snapshot (alias)
72
+ # 使用最近一次的恢复前快照撤销
61
73
  git restore --source=refs/guard/pre-restore -- <file>
62
74
 
63
- # Restore entire project to pre-restore state
64
- # 将整个项目恢复到恢复前的状态
65
- git restore --source=refs/guard/pre-restore -- .
75
+ # Restore entire project / 恢复整个项目
76
+ git restore --source=refs/guard/pre-restore/<yyyyMMdd_HHmmss> -- .
66
77
 
67
78
  # From shadow copy / 从影子拷贝恢复
68
79
  Copy-Item ".cursor-guard-backup/pre-restore-<ts>/<file>" "<original-path>"
69
80
  ```
70
81
 
82
+ ### Clean up old pre-restore refs / 清理旧的恢复前快照
83
+
84
+ ```bash
85
+ # Delete all pre-restore refs (keep only the latest alias)
86
+ # 删除所有时间戳快照(仅保留 latest alias)
87
+ git for-each-ref refs/guard/pre-restore/ --format="%(refname)" |
88
+ xargs -n1 git update-ref -d
89
+ ```
90
+
71
91
  ---
72
92
 
73
93
  ## Inspect current state