cursor-guard 2.1.1 → 3.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.
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+ const {
7
+ loadConfig, gitAvailable, git, isGitRepo, gitDir,
8
+ } = require('../utils');
9
+
10
+ /**
11
+ * Auto-fix common configuration and environment issues detected by doctor.
12
+ *
13
+ * Each fix is idempotent — running it when nothing is wrong is a no-op.
14
+ * Returns a list of actions taken (or skipped).
15
+ *
16
+ * @param {string} projectDir
17
+ * @param {object} [opts]
18
+ * @param {boolean} [opts.dryRun=false] - If true, report what would be fixed without modifying anything.
19
+ * @returns {{ actions: Array<{name: string, status: 'fixed'|'skipped'|'error', detail: string}>, totalFixed: number }}
20
+ */
21
+ function runFixes(projectDir, opts = {}) {
22
+ const dryRun = !!opts.dryRun;
23
+ const actions = [];
24
+ let totalFixed = 0;
25
+
26
+ function action(name, status, detail) {
27
+ actions.push({ name, status, detail });
28
+ if (status === 'fixed') totalFixed++;
29
+ }
30
+
31
+ const hasGit = gitAvailable();
32
+ const repo = hasGit && isGitRepo(projectDir);
33
+
34
+ // Fix 1: Create .cursor-guard.json with defaults if missing
35
+ const configPath = path.join(projectDir, '.cursor-guard.json');
36
+ if (!fs.existsSync(configPath)) {
37
+ const examplePath = path.resolve(__dirname, '../../cursor-guard.example.json');
38
+ if (fs.existsSync(examplePath)) {
39
+ if (dryRun) {
40
+ action('Create config', 'skipped', 'would copy cursor-guard.example.json → .cursor-guard.json (dry-run)');
41
+ } else {
42
+ try {
43
+ fs.copyFileSync(examplePath, configPath);
44
+ action('Create config', 'fixed', 'copied cursor-guard.example.json → .cursor-guard.json');
45
+ } catch (e) {
46
+ action('Create config', 'error', e.message);
47
+ }
48
+ }
49
+ } else {
50
+ const defaultConfig = {
51
+ protect: [],
52
+ ignore: ["node_modules/**", "dist/**", "*.log"],
53
+ backup_strategy: "git",
54
+ auto_backup_interval_seconds: 60,
55
+ pre_restore_backup: "always",
56
+ retention: { mode: "days", days: 30 },
57
+ };
58
+ if (dryRun) {
59
+ action('Create config', 'skipped', 'would create .cursor-guard.json with defaults (dry-run)');
60
+ } else {
61
+ try {
62
+ fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n');
63
+ action('Create config', 'fixed', 'created .cursor-guard.json with defaults');
64
+ } catch (e) {
65
+ action('Create config', 'error', e.message);
66
+ }
67
+ }
68
+ }
69
+ } else {
70
+ action('Create config', 'skipped', '.cursor-guard.json already exists');
71
+ }
72
+
73
+ // Fix 2: Init git repo if missing
74
+ if (hasGit && !repo) {
75
+ if (dryRun) {
76
+ action('Init Git repo', 'skipped', 'would run git init (dry-run)');
77
+ } else {
78
+ try {
79
+ execFileSync('git', ['init'], { cwd: projectDir, stdio: 'pipe' });
80
+ // Ensure git commit works even without global user config
81
+ try { execFileSync('git', ['config', 'user.email'], { cwd: projectDir, stdio: 'pipe' }); }
82
+ catch { execFileSync('git', ['config', 'user.email', 'cursor-guard@local'], { cwd: projectDir, stdio: 'pipe' }); }
83
+ try { execFileSync('git', ['config', 'user.name'], { cwd: projectDir, stdio: 'pipe' }); }
84
+ catch { execFileSync('git', ['config', 'user.name', 'cursor-guard'], { cwd: projectDir, stdio: 'pipe' }); }
85
+ // Ensure .gitignore contains backup dir + secrets patterns BEFORE git add
86
+ const { cfg: initCfg } = loadConfig(projectDir);
87
+ const gitignorePath = path.join(projectDir, '.gitignore');
88
+ const existingIgnore = fs.existsSync(gitignorePath)
89
+ ? fs.readFileSync(gitignorePath, 'utf-8')
90
+ : '';
91
+ const missingPatterns = [];
92
+ if (!existingIgnore.includes('.cursor-guard-backup')) {
93
+ missingPatterns.push('# cursor-guard shadow copies', '.cursor-guard-backup/', '');
94
+ }
95
+ const missingSecrets = initCfg.secrets_patterns.filter(p => !existingIgnore.includes(p));
96
+ if (missingSecrets.length > 0) {
97
+ missingPatterns.push('# Secrets (cursor-guard defaults)', ...missingSecrets, '');
98
+ }
99
+ if (missingPatterns.length > 0) {
100
+ const separator = existingIgnore && !existingIgnore.endsWith('\n') ? '\n' : '';
101
+ const block = separator + missingPatterns.join('\n');
102
+ if (existingIgnore) {
103
+ fs.appendFileSync(gitignorePath, block);
104
+ } else {
105
+ fs.writeFileSync(gitignorePath, block);
106
+ }
107
+ }
108
+ execFileSync('git', ['add', '-A'], { cwd: projectDir, stdio: 'pipe' });
109
+ execFileSync('git', ['commit', '-m', 'guard: initial snapshot', '--no-verify', '--allow-empty'], {
110
+ cwd: projectDir, stdio: 'pipe',
111
+ });
112
+ action('Init Git repo', 'fixed', 'initialized git repo with .gitignore and initial commit');
113
+ } catch (e) {
114
+ action('Init Git repo', 'error', e.message);
115
+ }
116
+ }
117
+ } else if (repo) {
118
+ action('Init Git repo', 'skipped', 'already a git repo');
119
+ } else {
120
+ action('Init Git repo', 'skipped', 'git not available');
121
+ }
122
+
123
+ // Fix 3: Add .cursor-guard-backup/ to .gitignore
124
+ const effectiveRepo = hasGit && isGitRepo(projectDir);
125
+ if (effectiveRepo) {
126
+ const ignored = git(['check-ignore', '.cursor-guard-backup/test'], { cwd: projectDir, allowFail: true });
127
+ if (!ignored) {
128
+ const gitignorePath = path.join(projectDir, '.gitignore');
129
+ const entry = '\n# cursor-guard shadow copies\n.cursor-guard-backup/\n';
130
+ if (dryRun) {
131
+ action('Gitignore backup dir', 'skipped', 'would add .cursor-guard-backup/ to .gitignore (dry-run)');
132
+ } else {
133
+ try {
134
+ if (fs.existsSync(gitignorePath)) {
135
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
136
+ if (!content.includes('.cursor-guard-backup')) {
137
+ fs.appendFileSync(gitignorePath, entry);
138
+ action('Gitignore backup dir', 'fixed', 'appended .cursor-guard-backup/ to .gitignore');
139
+ } else {
140
+ action('Gitignore backup dir', 'skipped', 'entry exists but git check-ignore failed — may need manual fix');
141
+ }
142
+ } else {
143
+ fs.writeFileSync(gitignorePath, entry.trimStart());
144
+ action('Gitignore backup dir', 'fixed', 'created .gitignore with .cursor-guard-backup/ entry');
145
+ }
146
+ } catch (e) {
147
+ action('Gitignore backup dir', 'error', e.message);
148
+ }
149
+ }
150
+ } else {
151
+ action('Gitignore backup dir', 'skipped', '.cursor-guard-backup/ already git-ignored');
152
+ }
153
+ } else {
154
+ action('Gitignore backup dir', 'skipped', 'not a git repo');
155
+ }
156
+
157
+ // Fix 4: Create .cursor-guard-backup/ directory if shadow strategy
158
+ const { cfg } = loadConfig(projectDir);
159
+ if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
160
+ const backupDir = path.join(projectDir, '.cursor-guard-backup');
161
+ if (!fs.existsSync(backupDir)) {
162
+ if (dryRun) {
163
+ action('Create backup dir', 'skipped', 'would create .cursor-guard-backup/ (dry-run)');
164
+ } else {
165
+ try {
166
+ fs.mkdirSync(backupDir, { recursive: true });
167
+ action('Create backup dir', 'fixed', 'created .cursor-guard-backup/');
168
+ } catch (e) {
169
+ action('Create backup dir', 'error', e.message);
170
+ }
171
+ }
172
+ } else {
173
+ action('Create backup dir', 'skipped', '.cursor-guard-backup/ already exists');
174
+ }
175
+ }
176
+
177
+ // Fix 5: Remove stale lock file
178
+ const gDir = effectiveRepo ? gitDir(projectDir) : null;
179
+ const lockFile = gDir
180
+ ? path.join(gDir, 'cursor-guard.lock')
181
+ : path.join(projectDir, '.cursor-guard-backup', 'cursor-guard.lock');
182
+ if (fs.existsSync(lockFile)) {
183
+ let stale = false;
184
+ try {
185
+ const content = fs.readFileSync(lockFile, 'utf-8').trim();
186
+ const pidMatch = content.match(/pid[:\s]+(\d+)/i);
187
+ if (pidMatch) {
188
+ const pid = parseInt(pidMatch[1], 10);
189
+ try { process.kill(pid, 0); } catch { stale = true; }
190
+ } else {
191
+ const stat = fs.statSync(lockFile);
192
+ if (Date.now() - stat.mtimeMs > 300000) stale = true;
193
+ }
194
+ } catch {
195
+ stale = true;
196
+ }
197
+
198
+ if (stale) {
199
+ if (dryRun) {
200
+ action('Remove stale lock', 'skipped', 'would remove stale cursor-guard.lock (dry-run)');
201
+ } else {
202
+ try {
203
+ fs.unlinkSync(lockFile);
204
+ action('Remove stale lock', 'fixed', 'removed stale cursor-guard.lock');
205
+ } catch (e) {
206
+ action('Remove stale lock', 'error', e.message);
207
+ }
208
+ }
209
+ } else {
210
+ action('Remove stale lock', 'skipped', 'lock file is active — another instance is running');
211
+ }
212
+ }
213
+
214
+ // Fix 6: Fix config strategy mismatch — if strategy is git/both but no repo
215
+ // (Already handled by Fix 2 above — this reports if the combo is still wrong)
216
+ const { cfg: freshCfg } = loadConfig(projectDir);
217
+ const freshRepo = hasGit && isGitRepo(projectDir);
218
+ if ((freshCfg.backup_strategy === 'git' || freshCfg.backup_strategy === 'both') && !freshRepo) {
219
+ if (dryRun) {
220
+ action('Fix strategy mismatch', 'skipped', "would switch backup_strategy to 'shadow' since no git repo (dry-run)");
221
+ } else {
222
+ try {
223
+ const raw = fs.readFileSync(configPath, 'utf-8');
224
+ const parsed = JSON.parse(raw);
225
+ parsed.backup_strategy = 'shadow';
226
+ fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2) + '\n');
227
+ action('Fix strategy mismatch', 'fixed', "changed backup_strategy to 'shadow' (no git repo available)");
228
+ } catch (e) {
229
+ action('Fix strategy mismatch', 'error', e.message);
230
+ }
231
+ }
232
+ }
233
+
234
+ return { actions, totalFixed };
235
+ }
236
+
237
+ module.exports = { runFixes };
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ loadConfig, gitAvailable, git, isGitRepo, gitDir, gitVersion,
7
+ walkDir, matchesAny, diskFreeGB,
8
+ } = require('../utils');
9
+
10
+ /**
11
+ * Run all diagnostic checks for a project directory.
12
+ *
13
+ * @param {string} projectDir - Absolute path to the project root.
14
+ * @returns {{ checks: Array<{name: string, status: 'PASS'|'WARN'|'FAIL', detail?: string}>, summary: {pass: number, warn: number, fail: number} }}
15
+ */
16
+ function runDiagnostics(projectDir) {
17
+ const checks = [];
18
+
19
+ function check(name, status, detail) {
20
+ checks.push({ name, status, detail: detail || null });
21
+ }
22
+
23
+ // 1. Git availability
24
+ const hasGit = gitAvailable();
25
+ if (hasGit) {
26
+ check('Git installed', 'PASS', `version ${gitVersion()}`);
27
+ } else {
28
+ check('Git installed', 'WARN', 'git not found in PATH; only shadow strategy available');
29
+ }
30
+
31
+ // 2. Git repo status
32
+ let repo = false;
33
+ let gDir = null;
34
+ let isWorktree = false;
35
+ if (hasGit) {
36
+ repo = isGitRepo(projectDir);
37
+ if (repo) {
38
+ gDir = gitDir(projectDir);
39
+ try {
40
+ const commonDir = git(['rev-parse', '--git-common-dir'], { cwd: projectDir, allowFail: true });
41
+ const currentDir = git(['rev-parse', '--git-dir'], { cwd: projectDir, allowFail: true });
42
+ isWorktree = commonDir && currentDir && commonDir !== currentDir;
43
+ } catch { /* ignore */ }
44
+ if (isWorktree) {
45
+ check('Git repository', 'PASS', `worktree detected (git-dir: ${gDir})`);
46
+ } else {
47
+ check('Git repository', 'PASS', 'standard repo');
48
+ }
49
+ } else {
50
+ check('Git repository', 'WARN', 'not a Git repo; git/both strategies won\'t work');
51
+ }
52
+ }
53
+
54
+ // 3. Config file
55
+ const { cfg, loaded, error } = loadConfig(projectDir);
56
+ if (loaded) {
57
+ check('Config file', 'PASS', '.cursor-guard.json found and valid JSON');
58
+ } else if (error) {
59
+ check('Config file', 'FAIL', `JSON parse error: ${error}`);
60
+ } else {
61
+ check('Config file', 'WARN', 'no .cursor-guard.json found; using defaults (protect everything)');
62
+ }
63
+
64
+ // 4. Strategy vs environment
65
+ const strategy = cfg.backup_strategy;
66
+ if (strategy === 'git' || strategy === 'both') {
67
+ if (!repo) {
68
+ check('Strategy compatibility', 'FAIL', `backup_strategy='${strategy}' but directory is not a Git repo`);
69
+ } else {
70
+ check('Strategy compatibility', 'PASS', `backup_strategy='${strategy}' and Git repo exists`);
71
+ }
72
+ } else if (strategy === 'shadow') {
73
+ check('Strategy compatibility', 'PASS', "backup_strategy='shadow' — no Git required");
74
+ } else {
75
+ check('Strategy compatibility', 'FAIL', `unknown backup_strategy='${strategy}' (must be git/shadow/both)`);
76
+ }
77
+
78
+ // 5. Backup ref
79
+ if (repo) {
80
+ const guardRef = 'refs/guard/auto-backup';
81
+ const legacyRef = 'refs/heads/cursor-guard/auto-backup';
82
+ const exists = git(['rev-parse', '--verify', guardRef], { cwd: projectDir, allowFail: true });
83
+ const legacyExists = git(['rev-parse', '--verify', legacyRef], { cwd: projectDir, allowFail: true });
84
+ if (exists) {
85
+ const count = git(['rev-list', '--count', guardRef], { cwd: projectDir, allowFail: true }) || '?';
86
+ check('Backup ref', 'PASS', `refs/guard/auto-backup exists (${count} commits)`);
87
+ } else if (legacyExists) {
88
+ const count = git(['rev-list', '--count', legacyRef], { cwd: projectDir, allowFail: true }) || '?';
89
+ check('Backup ref', 'WARN', `legacy refs/heads/cursor-guard/auto-backup found (${count} commits) — run auto-backup once to migrate`);
90
+ } else {
91
+ check('Backup ref', 'WARN', 'refs/guard/auto-backup not created yet (will be created on first backup)');
92
+ }
93
+ }
94
+
95
+ // 6. Guard refs
96
+ if (repo) {
97
+ const refs = git(['for-each-ref', 'refs/guard/', '--format=%(refname)'], { cwd: projectDir, allowFail: true });
98
+ if (refs) {
99
+ const refList = refs.split('\n').filter(Boolean);
100
+ const preRestoreCount = refList.filter(r => r.includes('pre-restore/')).length;
101
+ check('Guard refs', 'PASS', `${refList.length} ref(s) found (${preRestoreCount} pre-restore snapshots)`);
102
+ } else {
103
+ check('Guard refs', 'WARN', 'no guard refs yet (created on first snapshot or restore)');
104
+ }
105
+ }
106
+
107
+ // 7. Shadow copy directory
108
+ const backupDir = path.join(projectDir, '.cursor-guard-backup');
109
+ if (fs.existsSync(backupDir)) {
110
+ let snapCount = 0;
111
+ let totalBytes = 0;
112
+ try {
113
+ const dirs = fs.readdirSync(backupDir, { withFileTypes: true })
114
+ .filter(d => d.isDirectory() && (/^\d{8}_\d{6}$/.test(d.name) || d.name.startsWith('pre-restore-')));
115
+ snapCount = dirs.length;
116
+ } catch { /* ignore */ }
117
+ try {
118
+ const allFiles = walkDir(backupDir, backupDir);
119
+ for (const f of allFiles) {
120
+ try { totalBytes += fs.statSync(f.full).size; } catch { /* skip */ }
121
+ }
122
+ } catch { /* ignore */ }
123
+ const totalMB = (totalBytes / (1024 * 1024)).toFixed(1);
124
+ check('Shadow copies', 'PASS', `${snapCount} snapshot(s), ${totalMB} MB total`);
125
+ } else {
126
+ check('Shadow copies', 'WARN', '.cursor-guard-backup/ not found (will be created on first shadow backup)');
127
+ }
128
+
129
+ // 8. .gitignore / exclude coverage
130
+ if (repo) {
131
+ const ignored = git(['check-ignore', '.cursor-guard-backup/test'], { cwd: projectDir, allowFail: true });
132
+ if (ignored) {
133
+ check('Backup dir ignored', 'PASS', '.cursor-guard-backup/ is git-ignored');
134
+ } else {
135
+ check('Backup dir ignored', 'WARN', '.cursor-guard-backup/ may NOT be git-ignored — backup changes could trigger commits');
136
+ }
137
+ }
138
+
139
+ // 9. Config field validation
140
+ if (loaded) {
141
+ const validStrategies = ['git', 'shadow', 'both'];
142
+ if (cfg.backup_strategy && !validStrategies.includes(cfg.backup_strategy)) {
143
+ check('Config: backup_strategy', 'FAIL', `invalid value '${cfg.backup_strategy}'`);
144
+ }
145
+ const validPreRestore = ['always', 'ask', 'never'];
146
+ if (cfg.pre_restore_backup && !validPreRestore.includes(cfg.pre_restore_backup)) {
147
+ check('Config: pre_restore_backup', 'FAIL', `invalid value '${cfg.pre_restore_backup}'`);
148
+ } else if (cfg.pre_restore_backup === 'never') {
149
+ check('Config: pre_restore_backup', 'WARN', "set to 'never' — restores won't auto-preserve current version");
150
+ }
151
+ if (cfg.auto_backup_interval_seconds && cfg.auto_backup_interval_seconds < 5) {
152
+ check('Config: interval', 'WARN', `${cfg.auto_backup_interval_seconds}s is below minimum (5s), will be clamped`);
153
+ }
154
+ if (cfg.retention && cfg.retention.mode) {
155
+ const validModes = ['days', 'count', 'size'];
156
+ if (!validModes.includes(cfg.retention.mode)) {
157
+ check('Config: retention.mode', 'FAIL', `invalid value '${cfg.retention.mode}'`);
158
+ }
159
+ }
160
+ if (cfg.git_retention && cfg.git_retention.mode) {
161
+ const validGitModes = ['days', 'count'];
162
+ if (!validGitModes.includes(cfg.git_retention.mode)) {
163
+ check('Config: git_retention.mode', 'FAIL', `invalid value '${cfg.git_retention.mode}'`);
164
+ }
165
+ }
166
+ }
167
+
168
+ // 10. Protect / Ignore effectiveness
169
+ if (loaded && cfg.protect.length > 0) {
170
+ const allFiles = walkDir(projectDir, projectDir);
171
+ let protectedCount = 0;
172
+ for (const f of allFiles) {
173
+ if (matchesAny(cfg.protect, f.rel)) protectedCount++;
174
+ }
175
+ check('Protect patterns', 'PASS', `${protectedCount} / ${allFiles.length} files matched by protect patterns`);
176
+ }
177
+
178
+ // 11. Disk space
179
+ const freeGB = diskFreeGB(projectDir);
180
+ if (freeGB !== null) {
181
+ const rounded = freeGB.toFixed(1);
182
+ if (freeGB < 1) {
183
+ check('Disk space', 'FAIL', `${rounded} GB free — critically low`);
184
+ } else if (freeGB < 5) {
185
+ check('Disk space', 'WARN', `${rounded} GB free`);
186
+ } else {
187
+ check('Disk space', 'PASS', `${rounded} GB free`);
188
+ }
189
+ } else {
190
+ check('Disk space', 'WARN', 'could not determine free space');
191
+ }
192
+
193
+ // 12. Lock file
194
+ const lockFile = gDir
195
+ ? path.join(gDir, 'cursor-guard.lock')
196
+ : path.join(backupDir, 'cursor-guard.lock');
197
+ if (fs.existsSync(lockFile)) {
198
+ let content = '';
199
+ try { content = fs.readFileSync(lockFile, 'utf-8').trim(); } catch { /* ignore */ }
200
+ check('Lock file', 'WARN', `lock file exists — another instance may be running. ${content}`);
201
+ } else {
202
+ check('Lock file', 'PASS', 'no lock file (no running instance)');
203
+ }
204
+
205
+ // 13. Node.js version
206
+ const nodeVer = process.version;
207
+ const major = parseInt(nodeVer.slice(1), 10);
208
+ if (major >= 18) {
209
+ check('Node.js', 'PASS', `${nodeVer}`);
210
+ } else {
211
+ check('Node.js', 'WARN', `${nodeVer} — recommended >=18`);
212
+ }
213
+
214
+ // 14. MCP server status
215
+ const mcpServerPath = path.resolve(__dirname, '../../mcp/server.js');
216
+ const mcpServerExists = fs.existsSync(mcpServerPath);
217
+
218
+ let mcpSdkAvailable = false;
219
+ let mcpSdkVersion = null;
220
+ try {
221
+ const mcpPkgPath = require.resolve('@modelcontextprotocol/sdk/package.json');
222
+ const mcpPkg = JSON.parse(fs.readFileSync(mcpPkgPath, 'utf-8'));
223
+ mcpSdkAvailable = true;
224
+ mcpSdkVersion = mcpPkg.version;
225
+ } catch { /* not installed */ }
226
+
227
+ if (mcpServerExists && mcpSdkAvailable) {
228
+ check('MCP server', 'PASS', `server.js found, SDK ${mcpSdkVersion}`);
229
+ } else if (mcpServerExists && !mcpSdkAvailable) {
230
+ check('MCP server', 'WARN', 'server.js found but @modelcontextprotocol/sdk not installed — run npm install');
231
+ } else if (!mcpServerExists && mcpSdkAvailable) {
232
+ check('MCP server', 'WARN', `SDK installed (${mcpSdkVersion}) but server.js not found at expected path`);
233
+ } else {
234
+ check('MCP server', 'WARN', 'MCP not configured (optional — cursor-guard works without it)');
235
+ }
236
+
237
+ // Build summary
238
+ let pass = 0, warn = 0, fail = 0;
239
+ for (const c of checks) {
240
+ if (c.status === 'PASS') pass++;
241
+ else if (c.status === 'WARN') warn++;
242
+ else if (c.status === 'FAIL') fail++;
243
+ }
244
+
245
+ return { checks, summary: { pass, warn, fail } };
246
+ }
247
+
248
+ module.exports = { runDiagnostics };