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.
- package/README.md +54 -20
- package/README.zh-CN.md +54 -20
- package/SKILL.md +65 -21
- package/package.json +10 -2
- package/references/auto-backup.ps1 +10 -342
- package/references/auto-backup.sh +19 -0
- package/references/bin/cursor-guard-backup.js +14 -0
- package/references/bin/cursor-guard-doctor.js +13 -0
- package/references/config-reference.md +43 -2
- package/references/config-reference.zh-CN.md +43 -2
- package/references/cursor-guard.example.json +7 -0
- package/references/cursor-guard.schema.json +38 -3
- package/references/guard-doctor.ps1 +22 -0
- package/references/guard-doctor.sh +18 -0
- package/references/lib/auto-backup.js +508 -0
- package/references/lib/guard-doctor.js +233 -0
- package/references/lib/utils.js +325 -0
- package/references/lib/utils.test.js +329 -0
- package/references/recovery.md +32 -12
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
color, loadConfig, gitAvailable, git, isGitRepo, gitDir, gitVersion,
|
|
7
|
+
walkDir, matchesAny, diskFreeGB,
|
|
8
|
+
} = require('./utils');
|
|
9
|
+
|
|
10
|
+
function runDoctor(projectDir) {
|
|
11
|
+
let pass = 0, warn = 0, fail = 0;
|
|
12
|
+
|
|
13
|
+
function check(name, status, detail) {
|
|
14
|
+
const tag = `[${status}]`;
|
|
15
|
+
const line = detail ? ` ${tag} ${name} — ${detail}` : ` ${tag} ${name}`;
|
|
16
|
+
switch (status) {
|
|
17
|
+
case 'PASS': pass++; console.log(color.green(line)); break;
|
|
18
|
+
case 'WARN': warn++; console.log(color.yellow(line)); break;
|
|
19
|
+
case 'FAIL': fail++; console.log(color.red(line)); break;
|
|
20
|
+
default: console.log(line);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log('');
|
|
25
|
+
console.log(color.cyan('=== Cursor Guard Doctor ==='));
|
|
26
|
+
console.log(color.cyan(` Target: ${projectDir}`));
|
|
27
|
+
console.log('');
|
|
28
|
+
|
|
29
|
+
// 1. Git availability
|
|
30
|
+
const hasGit = gitAvailable();
|
|
31
|
+
if (hasGit) {
|
|
32
|
+
check('Git installed', 'PASS', `version ${gitVersion()}`);
|
|
33
|
+
} else {
|
|
34
|
+
check('Git installed', 'WARN', 'git not found in PATH; only shadow strategy available');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Git repo status
|
|
38
|
+
let repo = false;
|
|
39
|
+
let gDir = null;
|
|
40
|
+
let isWorktree = false;
|
|
41
|
+
if (hasGit) {
|
|
42
|
+
repo = isGitRepo(projectDir);
|
|
43
|
+
if (repo) {
|
|
44
|
+
gDir = gitDir(projectDir);
|
|
45
|
+
try {
|
|
46
|
+
const commonDir = git(['rev-parse', '--git-common-dir'], { cwd: projectDir, allowFail: true });
|
|
47
|
+
const currentDir = git(['rev-parse', '--git-dir'], { cwd: projectDir, allowFail: true });
|
|
48
|
+
isWorktree = commonDir && currentDir && commonDir !== currentDir;
|
|
49
|
+
} catch { /* ignore */ }
|
|
50
|
+
if (isWorktree) {
|
|
51
|
+
check('Git repository', 'PASS', `worktree detected (git-dir: ${gDir})`);
|
|
52
|
+
} else {
|
|
53
|
+
check('Git repository', 'PASS', 'standard repo');
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
check('Git repository', 'WARN', 'not a Git repo; git/both strategies won\'t work');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 3. Config file
|
|
61
|
+
const { cfg, loaded, error } = loadConfig(projectDir);
|
|
62
|
+
if (loaded) {
|
|
63
|
+
check('Config file', 'PASS', '.cursor-guard.json found and valid JSON');
|
|
64
|
+
} else if (error) {
|
|
65
|
+
check('Config file', 'FAIL', `JSON parse error: ${error}`);
|
|
66
|
+
} else {
|
|
67
|
+
check('Config file', 'WARN', 'no .cursor-guard.json found; using defaults (protect everything)');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 4. Strategy vs environment
|
|
71
|
+
const strategy = cfg.backup_strategy;
|
|
72
|
+
if (strategy === 'git' || strategy === 'both') {
|
|
73
|
+
if (!repo) {
|
|
74
|
+
check('Strategy compatibility', 'FAIL', `backup_strategy='${strategy}' but directory is not a Git repo`);
|
|
75
|
+
} else {
|
|
76
|
+
check('Strategy compatibility', 'PASS', `backup_strategy='${strategy}' and Git repo exists`);
|
|
77
|
+
}
|
|
78
|
+
} else if (strategy === 'shadow') {
|
|
79
|
+
check('Strategy compatibility', 'PASS', "backup_strategy='shadow' — no Git required");
|
|
80
|
+
} else {
|
|
81
|
+
check('Strategy compatibility', 'FAIL', `unknown backup_strategy='${strategy}' (must be git/shadow/both)`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 5. Backup branch
|
|
85
|
+
if (repo) {
|
|
86
|
+
const branchRef = 'refs/heads/cursor-guard/auto-backup';
|
|
87
|
+
const exists = git(['rev-parse', '--verify', branchRef], { cwd: projectDir, allowFail: true });
|
|
88
|
+
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
|
+
} else {
|
|
92
|
+
check('Backup branch', 'WARN', 'cursor-guard/auto-backup not created yet (will be created on first backup)');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 6. Guard refs
|
|
97
|
+
if (repo) {
|
|
98
|
+
const refs = git(['for-each-ref', 'refs/guard/', '--format=%(refname)'], { cwd: projectDir, allowFail: true });
|
|
99
|
+
if (refs) {
|
|
100
|
+
const refList = refs.split('\n').filter(Boolean);
|
|
101
|
+
const preRestoreCount = refList.filter(r => r.includes('pre-restore/')).length;
|
|
102
|
+
check('Guard refs', 'PASS', `${refList.length} ref(s) found (${preRestoreCount} pre-restore snapshots)`);
|
|
103
|
+
} else {
|
|
104
|
+
check('Guard refs', 'WARN', 'no guard refs yet (created on first snapshot or restore)');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 7. Shadow copy directory
|
|
109
|
+
const backupDir = path.join(projectDir, '.cursor-guard-backup');
|
|
110
|
+
if (fs.existsSync(backupDir)) {
|
|
111
|
+
let snapCount = 0;
|
|
112
|
+
let totalBytes = 0;
|
|
113
|
+
try {
|
|
114
|
+
const dirs = fs.readdirSync(backupDir, { withFileTypes: true })
|
|
115
|
+
.filter(d => d.isDirectory() && (/^\d{8}_\d{6}$/.test(d.name) || d.name.startsWith('pre-restore-')));
|
|
116
|
+
snapCount = dirs.length;
|
|
117
|
+
} catch { /* ignore */ }
|
|
118
|
+
try {
|
|
119
|
+
const allFiles = walkDir(backupDir, backupDir);
|
|
120
|
+
for (const f of allFiles) {
|
|
121
|
+
try { totalBytes += fs.statSync(f.full).size; } catch { /* skip */ }
|
|
122
|
+
}
|
|
123
|
+
} catch { /* ignore */ }
|
|
124
|
+
const totalMB = (totalBytes / (1024 * 1024)).toFixed(1);
|
|
125
|
+
check('Shadow copies', 'PASS', `${snapCount} snapshot(s), ${totalMB} MB total`);
|
|
126
|
+
} else {
|
|
127
|
+
check('Shadow copies', 'WARN', '.cursor-guard-backup/ not found (will be created on first shadow backup)');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 8. .gitignore / exclude coverage
|
|
131
|
+
if (repo) {
|
|
132
|
+
const ignored = git(['check-ignore', '.cursor-guard-backup/test'], { cwd: projectDir, allowFail: true });
|
|
133
|
+
if (ignored) {
|
|
134
|
+
check('Backup dir ignored', 'PASS', '.cursor-guard-backup/ is git-ignored');
|
|
135
|
+
} else {
|
|
136
|
+
check('Backup dir ignored', 'WARN', '.cursor-guard-backup/ may NOT be git-ignored — backup changes could trigger commits');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 9. Config field validation
|
|
141
|
+
if (loaded) {
|
|
142
|
+
const validStrategies = ['git', 'shadow', 'both'];
|
|
143
|
+
if (cfg.backup_strategy && !validStrategies.includes(cfg.backup_strategy)) {
|
|
144
|
+
check('Config: backup_strategy', 'FAIL', `invalid value '${cfg.backup_strategy}'`);
|
|
145
|
+
}
|
|
146
|
+
const validPreRestore = ['always', 'ask', 'never'];
|
|
147
|
+
if (cfg.pre_restore_backup && !validPreRestore.includes(cfg.pre_restore_backup)) {
|
|
148
|
+
check('Config: pre_restore_backup', 'FAIL', `invalid value '${cfg.pre_restore_backup}'`);
|
|
149
|
+
} else if (cfg.pre_restore_backup === 'never') {
|
|
150
|
+
check('Config: pre_restore_backup', 'WARN', "set to 'never' — restores won't auto-preserve current version");
|
|
151
|
+
}
|
|
152
|
+
if (cfg.auto_backup_interval_seconds && cfg.auto_backup_interval_seconds < 5) {
|
|
153
|
+
check('Config: interval', 'WARN', `${cfg.auto_backup_interval_seconds}s is below minimum (5s), will be clamped`);
|
|
154
|
+
}
|
|
155
|
+
if (cfg.retention && cfg.retention.mode) {
|
|
156
|
+
const validModes = ['days', 'count', 'size'];
|
|
157
|
+
if (!validModes.includes(cfg.retention.mode)) {
|
|
158
|
+
check('Config: retention.mode', 'FAIL', `invalid value '${cfg.retention.mode}'`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (cfg.git_retention && cfg.git_retention.mode) {
|
|
162
|
+
const validGitModes = ['days', 'count'];
|
|
163
|
+
if (!validGitModes.includes(cfg.git_retention.mode)) {
|
|
164
|
+
check('Config: git_retention.mode', 'FAIL', `invalid value '${cfg.git_retention.mode}'`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 10. Protect / Ignore effectiveness
|
|
170
|
+
if (loaded && cfg.protect.length > 0) {
|
|
171
|
+
const allFiles = walkDir(projectDir, projectDir);
|
|
172
|
+
let protectedCount = 0;
|
|
173
|
+
for (const f of allFiles) {
|
|
174
|
+
if (matchesAny(cfg.protect, f.rel)) protectedCount++;
|
|
175
|
+
}
|
|
176
|
+
check('Protect patterns', 'PASS', `${protectedCount} / ${allFiles.length} files matched by protect patterns`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 11. Disk space
|
|
180
|
+
const freeGB = diskFreeGB(projectDir);
|
|
181
|
+
if (freeGB !== null) {
|
|
182
|
+
const rounded = freeGB.toFixed(1);
|
|
183
|
+
if (freeGB < 1) {
|
|
184
|
+
check('Disk space', 'FAIL', `${rounded} GB free — critically low`);
|
|
185
|
+
} else if (freeGB < 5) {
|
|
186
|
+
check('Disk space', 'WARN', `${rounded} GB free`);
|
|
187
|
+
} else {
|
|
188
|
+
check('Disk space', 'PASS', `${rounded} GB free`);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
check('Disk space', 'WARN', 'could not determine free space');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 12. Lock file
|
|
195
|
+
const lockFile = gDir
|
|
196
|
+
? path.join(gDir, 'cursor-guard.lock')
|
|
197
|
+
: path.join(backupDir, 'cursor-guard.lock');
|
|
198
|
+
if (fs.existsSync(lockFile)) {
|
|
199
|
+
let content = '';
|
|
200
|
+
try { content = fs.readFileSync(lockFile, 'utf-8').trim(); } catch { /* ignore */ }
|
|
201
|
+
check('Lock file', 'WARN', `lock file exists — another instance may be running. ${content}`);
|
|
202
|
+
} else {
|
|
203
|
+
check('Lock file', 'PASS', 'no lock file (no running instance)');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 13. Node.js version
|
|
207
|
+
const nodeVer = process.version;
|
|
208
|
+
const major = parseInt(nodeVer.slice(1), 10);
|
|
209
|
+
if (major >= 18) {
|
|
210
|
+
check('Node.js', 'PASS', `${nodeVer}`);
|
|
211
|
+
} else {
|
|
212
|
+
check('Node.js', 'WARN', `${nodeVer} — recommended >=18`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Summary
|
|
216
|
+
console.log('');
|
|
217
|
+
console.log(color.cyan('=== Summary ==='));
|
|
218
|
+
const summaryColor = fail > 0 ? 'red' : warn > 0 ? 'yellow' : 'green';
|
|
219
|
+
console.log(color[summaryColor](` PASS: ${pass} | WARN: ${warn} | FAIL: ${fail}`));
|
|
220
|
+
console.log('');
|
|
221
|
+
if (fail > 0) {
|
|
222
|
+
console.log(color.red(' Fix FAIL items before relying on Cursor Guard.'));
|
|
223
|
+
} else if (warn > 0) {
|
|
224
|
+
console.log(color.yellow(' Review WARN items to ensure everything works as expected.'));
|
|
225
|
+
} else {
|
|
226
|
+
console.log(color.green(' All checks passed. Cursor Guard is ready.'));
|
|
227
|
+
}
|
|
228
|
+
console.log('');
|
|
229
|
+
|
|
230
|
+
return fail > 0 ? 1 : 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { runDoctor };
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
// ── ANSI colors ──────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const color = {
|
|
10
|
+
red: s => `\x1b[31m${s}\x1b[0m`,
|
|
11
|
+
green: s => `\x1b[32m${s}\x1b[0m`,
|
|
12
|
+
yellow: s => `\x1b[33m${s}\x1b[0m`,
|
|
13
|
+
cyan: s => `\x1b[36m${s}\x1b[0m`,
|
|
14
|
+
gray: s => `\x1b[90m${s}\x1b[0m`,
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ── Glob matching (minimatch subset, zero deps) ─────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Match a relative path against a glob pattern.
|
|
22
|
+
* Supports: *, **, ? — enough for .cursor-guard.json patterns.
|
|
23
|
+
*/
|
|
24
|
+
function globMatch(pattern, relPath) {
|
|
25
|
+
const p = pattern.replace(/\\/g, '/');
|
|
26
|
+
const r = relPath.replace(/\\/g, '/');
|
|
27
|
+
const re = '^' + p
|
|
28
|
+
.replace(/[.+^${}()|[\]]/g, '\\$&') // escape regex specials (except * and ?)
|
|
29
|
+
.replace(/\*\*/g, '\0') // placeholder for **
|
|
30
|
+
.replace(/\*/g, '[^/]*') // * = anything except /
|
|
31
|
+
.replace(/\?/g, '[^/]') // ? = single char except /
|
|
32
|
+
.replace(/\0/g, '.*') // ** = anything including /
|
|
33
|
+
+ '$';
|
|
34
|
+
return new RegExp(re).test(r);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a relative file path matches any pattern in a list.
|
|
39
|
+
* Also checks leaf filename for patterns like "*.log".
|
|
40
|
+
*/
|
|
41
|
+
function matchesAny(patterns, relPath) {
|
|
42
|
+
const leaf = path.basename(relPath);
|
|
43
|
+
for (const pat of patterns) {
|
|
44
|
+
if (globMatch(pat, relPath) || globMatch(pat, leaf)) return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── File traversal (recursive, no external deps) ────────────────
|
|
50
|
+
|
|
51
|
+
const ALWAYS_SKIP = /[/\\](\.git|\.cursor-guard-backup|node_modules)[/\\]/;
|
|
52
|
+
|
|
53
|
+
function walkDir(dir, rootDir) {
|
|
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 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Config loading ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const DEFAULT_SECRETS = ['.env', '.env.*', '*.key', '*.pem', '*.p12', '*.pfx', 'credentials*'];
|
|
75
|
+
|
|
76
|
+
const DEFAULT_CONFIG = {
|
|
77
|
+
protect: [],
|
|
78
|
+
ignore: [],
|
|
79
|
+
secrets_patterns: DEFAULT_SECRETS,
|
|
80
|
+
backup_strategy: 'git',
|
|
81
|
+
auto_backup_interval_seconds: 60,
|
|
82
|
+
pre_restore_backup: 'always',
|
|
83
|
+
retention: { mode: 'days', days: 30, max_count: 100, max_size_mb: 500 },
|
|
84
|
+
git_retention: { enabled: false, mode: 'count', days: 30, max_count: 200 },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function loadConfig(projectDir) {
|
|
88
|
+
const cfgPath = path.join(projectDir, '.cursor-guard.json');
|
|
89
|
+
const cfg = { ...DEFAULT_CONFIG };
|
|
90
|
+
cfg.retention = { ...DEFAULT_CONFIG.retention };
|
|
91
|
+
cfg.git_retention = { ...DEFAULT_CONFIG.git_retention };
|
|
92
|
+
|
|
93
|
+
if (!fs.existsSync(cfgPath)) return { cfg, loaded: false, error: null };
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
|
97
|
+
if (Array.isArray(raw.protect)) cfg.protect = raw.protect;
|
|
98
|
+
if (Array.isArray(raw.ignore)) cfg.ignore = raw.ignore;
|
|
99
|
+
if (Array.isArray(raw.secrets_patterns)) cfg.secrets_patterns = raw.secrets_patterns;
|
|
100
|
+
if (Array.isArray(raw.secrets_patterns_extra)) {
|
|
101
|
+
const merged = [...new Set([...cfg.secrets_patterns, ...raw.secrets_patterns_extra])];
|
|
102
|
+
cfg.secrets_patterns = merged;
|
|
103
|
+
}
|
|
104
|
+
if (typeof raw.backup_strategy === 'string') cfg.backup_strategy = raw.backup_strategy;
|
|
105
|
+
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;
|
|
107
|
+
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;
|
|
112
|
+
}
|
|
113
|
+
if (raw.git_retention) {
|
|
114
|
+
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;
|
|
118
|
+
}
|
|
119
|
+
return { cfg, loaded: true, error: null };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { cfg, loaded: false, error: e.message };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Git helpers ─────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function gitAvailable() {
|
|
128
|
+
try {
|
|
129
|
+
execFileSync('git', ['--version'], { stdio: 'pipe' });
|
|
130
|
+
return true;
|
|
131
|
+
} catch { return false; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function git(args, opts = {}) {
|
|
135
|
+
const options = {
|
|
136
|
+
stdio: 'pipe',
|
|
137
|
+
encoding: 'utf-8',
|
|
138
|
+
...opts,
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
return execFileSync('git', args, options).trim();
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (opts.allowFail) return null;
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isGitRepo(cwd) {
|
|
149
|
+
try {
|
|
150
|
+
const result = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
151
|
+
cwd, stdio: 'pipe', encoding: 'utf-8',
|
|
152
|
+
}).trim();
|
|
153
|
+
return result === 'true';
|
|
154
|
+
} catch { return false; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function gitDir(cwd) {
|
|
158
|
+
try {
|
|
159
|
+
const dir = execFileSync('git', ['rev-parse', '--git-dir'], {
|
|
160
|
+
cwd, stdio: 'pipe', encoding: 'utf-8',
|
|
161
|
+
}).trim();
|
|
162
|
+
return path.resolve(cwd, dir);
|
|
163
|
+
} catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function gitVersion() {
|
|
167
|
+
try {
|
|
168
|
+
return execFileSync('git', ['--version'], { stdio: 'pipe', encoding: 'utf-8' })
|
|
169
|
+
.trim().replace('git version ', '');
|
|
170
|
+
} catch { return null; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Manifest (for shadow-mode change detection) ─────────────────
|
|
174
|
+
|
|
175
|
+
function buildManifest(files) {
|
|
176
|
+
const manifest = {};
|
|
177
|
+
for (const f of files) {
|
|
178
|
+
try {
|
|
179
|
+
const st = fs.statSync(f.full);
|
|
180
|
+
manifest[f.rel] = { mtimeMs: st.mtimeMs, size: st.size };
|
|
181
|
+
} catch { /* skip unreadable files */ }
|
|
182
|
+
}
|
|
183
|
+
return manifest;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function manifestPath(backupDir) {
|
|
187
|
+
return path.join(backupDir, '.manifest.json');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function loadManifest(backupDir) {
|
|
191
|
+
const p = manifestPath(backupDir);
|
|
192
|
+
if (!fs.existsSync(p)) return null;
|
|
193
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); }
|
|
194
|
+
catch { return null; }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function saveManifest(backupDir, manifest) {
|
|
198
|
+
fs.writeFileSync(manifestPath(backupDir), JSON.stringify(manifest, null, 2));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function manifestChanged(oldM, newM) {
|
|
202
|
+
if (!oldM) return true;
|
|
203
|
+
const oldKeys = Object.keys(oldM);
|
|
204
|
+
const newKeys = Object.keys(newM);
|
|
205
|
+
if (oldKeys.length !== newKeys.length) return true;
|
|
206
|
+
for (const k of newKeys) {
|
|
207
|
+
if (!oldM[k]) return true;
|
|
208
|
+
if (oldM[k].mtimeMs !== newM[k].mtimeMs || oldM[k].size !== newM[k].size) return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Disk space (cross-platform) ─────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function diskFreeGB(dir) {
|
|
216
|
+
try {
|
|
217
|
+
if (process.platform === 'win32') {
|
|
218
|
+
const drive = path.parse(dir).root.replace(/\\$/, '');
|
|
219
|
+
// Try PowerShell first (works on all modern Windows)
|
|
220
|
+
try {
|
|
221
|
+
const out = execFileSync('powershell', [
|
|
222
|
+
'-NoProfile', '-Command',
|
|
223
|
+
`(Get-PSDrive ${drive[0]}).Free`,
|
|
224
|
+
], { stdio: 'pipe', encoding: 'utf-8' });
|
|
225
|
+
const bytes = parseInt(out.trim(), 10);
|
|
226
|
+
if (!isNaN(bytes)) return bytes / (1024 ** 3);
|
|
227
|
+
} catch { /* fall through */ }
|
|
228
|
+
// Fallback to wmic
|
|
229
|
+
try {
|
|
230
|
+
const out = execFileSync('wmic', [
|
|
231
|
+
'logicaldisk', 'where', `DeviceID="${drive}"`, 'get', 'FreeSpace', '/value',
|
|
232
|
+
], { stdio: 'pipe', encoding: 'utf-8' });
|
|
233
|
+
const m = out.match(/FreeSpace=(\d+)/);
|
|
234
|
+
return m ? parseFloat(m[1]) / (1024 ** 3) : null;
|
|
235
|
+
} catch { return null; }
|
|
236
|
+
}
|
|
237
|
+
const out = execFileSync('df', ['-k', dir], { stdio: 'pipe', encoding: 'utf-8' });
|
|
238
|
+
const lines = out.trim().split('\n');
|
|
239
|
+
if (lines.length < 2) return null;
|
|
240
|
+
const parts = lines[1].split(/\s+/);
|
|
241
|
+
const availKB = parseInt(parts[3], 10);
|
|
242
|
+
return isNaN(availKB) ? null : availKB / (1024 * 1024);
|
|
243
|
+
} catch { return null; }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Logging ─────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function timestamp() {
|
|
249
|
+
return new Date().toISOString().replace('T', ' ').substring(0, 19);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function createLogger(logFilePath) {
|
|
253
|
+
return {
|
|
254
|
+
log(msg, c = 'green') {
|
|
255
|
+
const line = `${timestamp()} ${msg}`;
|
|
256
|
+
try { fs.appendFileSync(logFilePath, line + '\n'); } catch { /* ignore */ }
|
|
257
|
+
console.log(color[c] ? color[c](`[guard] ${line}`) : `[guard] ${line}`);
|
|
258
|
+
},
|
|
259
|
+
info(msg) { this.log(msg, 'cyan'); },
|
|
260
|
+
warn(msg) { this.log(msg, 'yellow'); },
|
|
261
|
+
error(msg) { this.log(msg, 'red'); },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── CLI arg parsing (zero deps) ─────────────────────────────────
|
|
266
|
+
|
|
267
|
+
function parseArgs(argv) {
|
|
268
|
+
const args = {};
|
|
269
|
+
for (let i = 2; i < argv.length; i++) {
|
|
270
|
+
const a = argv[i];
|
|
271
|
+
if (a.startsWith('--')) {
|
|
272
|
+
const key = a.slice(2);
|
|
273
|
+
const next = argv[i + 1];
|
|
274
|
+
if (next && !next.startsWith('--')) {
|
|
275
|
+
args[key] = next;
|
|
276
|
+
i++;
|
|
277
|
+
} else {
|
|
278
|
+
args[key] = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return args;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Filter files by config ──────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
function filterFiles(files, cfg) {
|
|
288
|
+
let result = files;
|
|
289
|
+
if (cfg.protect.length > 0) {
|
|
290
|
+
result = result.filter(f => matchesAny(cfg.protect, f.rel));
|
|
291
|
+
}
|
|
292
|
+
result = result.filter(f => {
|
|
293
|
+
if (cfg.ignore.length > 0 && matchesAny(cfg.ignore, f.rel)) return false;
|
|
294
|
+
if (matchesAny(cfg.secrets_patterns, f.rel)) return false;
|
|
295
|
+
return true;
|
|
296
|
+
});
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Exports ─────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
module.exports = {
|
|
303
|
+
color,
|
|
304
|
+
globMatch,
|
|
305
|
+
matchesAny,
|
|
306
|
+
walkDir,
|
|
307
|
+
loadConfig,
|
|
308
|
+
DEFAULT_CONFIG,
|
|
309
|
+
DEFAULT_SECRETS,
|
|
310
|
+
gitAvailable,
|
|
311
|
+
git,
|
|
312
|
+
isGitRepo,
|
|
313
|
+
gitDir,
|
|
314
|
+
gitVersion,
|
|
315
|
+
buildManifest,
|
|
316
|
+
manifestPath,
|
|
317
|
+
loadManifest,
|
|
318
|
+
saveManifest,
|
|
319
|
+
manifestChanged,
|
|
320
|
+
diskFreeGB,
|
|
321
|
+
timestamp,
|
|
322
|
+
createLogger,
|
|
323
|
+
parseArgs,
|
|
324
|
+
filterFiles,
|
|
325
|
+
};
|