cursor-guard 2.1.0 → 3.0.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.
@@ -1,371 +1,371 @@
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
- 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
- }
71
- }
72
- }
73
- return results;
74
- }
75
-
76
- // ── Config loading ──────────────────────────────────────────────
77
-
78
- const DEFAULT_SECRETS = ['.env', '.env.*', '*.key', '*.pem', '*.p12', '*.pfx', 'credentials*'];
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
-
85
- const DEFAULT_CONFIG = {
86
- protect: [],
87
- ignore: [],
88
- secrets_patterns: DEFAULT_SECRETS,
89
- backup_strategy: 'git',
90
- auto_backup_interval_seconds: 60,
91
- pre_restore_backup: 'always',
92
- retention: { mode: 'days', days: 30, max_count: 100, max_size_mb: 500 },
93
- git_retention: { enabled: false, mode: 'count', days: 30, max_count: 200 },
94
- };
95
-
96
- function loadConfig(projectDir) {
97
- const cfgPath = path.join(projectDir, '.cursor-guard.json');
98
- const cfg = { ...DEFAULT_CONFIG };
99
- cfg.retention = { ...DEFAULT_CONFIG.retention };
100
- cfg.git_retention = { ...DEFAULT_CONFIG.git_retention };
101
-
102
- if (!fs.existsSync(cfgPath)) return { cfg, loaded: false, error: null };
103
-
104
- try {
105
- const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
106
- if (Array.isArray(raw.protect)) cfg.protect = raw.protect;
107
- if (Array.isArray(raw.ignore)) cfg.ignore = raw.ignore;
108
- if (Array.isArray(raw.secrets_patterns)) cfg.secrets_patterns = raw.secrets_patterns;
109
- if (Array.isArray(raw.secrets_patterns_extra)) {
110
- const merged = [...new Set([...cfg.secrets_patterns, ...raw.secrets_patterns_extra])];
111
- cfg.secrets_patterns = merged;
112
- }
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
- }
121
- if (typeof raw.auto_backup_interval_seconds === 'number') cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
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
- }
129
- if (raw.retention) {
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;
140
- }
141
- if (raw.git_retention) {
142
- if (raw.git_retention.enabled === true) cfg.git_retention.enabled = true;
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;
152
- }
153
- return { cfg, loaded: true, error: null, warnings };
154
- } catch (e) {
155
- return { cfg, loaded: false, error: e.message };
156
- }
157
- }
158
-
159
- // ── Git helpers ─────────────────────────────────────────────────
160
-
161
- function gitAvailable() {
162
- try {
163
- execFileSync('git', ['--version'], { stdio: 'pipe' });
164
- return true;
165
- } catch { return false; }
166
- }
167
-
168
- function git(args, opts = {}) {
169
- const options = {
170
- stdio: 'pipe',
171
- encoding: 'utf-8',
172
- ...opts,
173
- };
174
- try {
175
- return execFileSync('git', args, options).trim();
176
- } catch (e) {
177
- if (opts.allowFail) return null;
178
- throw e;
179
- }
180
- }
181
-
182
- function isGitRepo(cwd) {
183
- try {
184
- const result = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
185
- cwd, stdio: 'pipe', encoding: 'utf-8',
186
- }).trim();
187
- return result === 'true';
188
- } catch { return false; }
189
- }
190
-
191
- function gitDir(cwd) {
192
- try {
193
- const dir = execFileSync('git', ['rev-parse', '--git-dir'], {
194
- cwd, stdio: 'pipe', encoding: 'utf-8',
195
- }).trim();
196
- return path.resolve(cwd, dir);
197
- } catch { return null; }
198
- }
199
-
200
- function gitVersion() {
201
- try {
202
- return execFileSync('git', ['--version'], { stdio: 'pipe', encoding: 'utf-8' })
203
- .trim().replace('git version ', '');
204
- } catch { return null; }
205
- }
206
-
207
- // ── Manifest (for shadow-mode change detection) ─────────────────
208
-
209
- function buildManifest(files) {
210
- const manifest = {};
211
- for (const f of files) {
212
- try {
213
- const st = fs.statSync(f.full);
214
- manifest[f.rel] = { mtimeMs: st.mtimeMs, size: st.size };
215
- } catch { /* skip unreadable files */ }
216
- }
217
- return manifest;
218
- }
219
-
220
- function manifestPath(backupDir) {
221
- return path.join(backupDir, '.manifest.json');
222
- }
223
-
224
- function loadManifest(backupDir) {
225
- const p = manifestPath(backupDir);
226
- if (!fs.existsSync(p)) return null;
227
- try { return JSON.parse(fs.readFileSync(p, 'utf-8')); }
228
- catch { return null; }
229
- }
230
-
231
- function saveManifest(backupDir, manifest) {
232
- fs.writeFileSync(manifestPath(backupDir), JSON.stringify(manifest, null, 2));
233
- }
234
-
235
- function manifestChanged(oldM, newM) {
236
- if (!oldM) return true;
237
- const oldKeys = Object.keys(oldM);
238
- const newKeys = Object.keys(newM);
239
- if (oldKeys.length !== newKeys.length) return true;
240
- for (const k of newKeys) {
241
- if (!oldM[k]) return true;
242
- if (oldM[k].mtimeMs !== newM[k].mtimeMs || oldM[k].size !== newM[k].size) return true;
243
- }
244
- return false;
245
- }
246
-
247
- // ── Disk space (cross-platform) ─────────────────────────────────
248
-
249
- function diskFreeGB(dir) {
250
- try {
251
- if (process.platform === 'win32') {
252
- const drive = path.parse(dir).root.replace(/\\$/, '');
253
- // Try PowerShell first (works on all modern Windows)
254
- try {
255
- const out = execFileSync('powershell', [
256
- '-NoProfile', '-Command',
257
- `(Get-PSDrive ${drive[0]}).Free`,
258
- ], { stdio: 'pipe', encoding: 'utf-8' });
259
- const bytes = parseInt(out.trim(), 10);
260
- if (!isNaN(bytes)) return bytes / (1024 ** 3);
261
- } catch { /* fall through */ }
262
- // Fallback to wmic
263
- try {
264
- const out = execFileSync('wmic', [
265
- 'logicaldisk', 'where', `DeviceID="${drive}"`, 'get', 'FreeSpace', '/value',
266
- ], { stdio: 'pipe', encoding: 'utf-8' });
267
- const m = out.match(/FreeSpace=(\d+)/);
268
- return m ? parseFloat(m[1]) / (1024 ** 3) : null;
269
- } catch { return null; }
270
- }
271
- const out = execFileSync('df', ['-k', dir], { stdio: 'pipe', encoding: 'utf-8' });
272
- const lines = out.trim().split('\n');
273
- if (lines.length < 2) return null;
274
- const parts = lines[1].split(/\s+/);
275
- const availKB = parseInt(parts[3], 10);
276
- return isNaN(availKB) ? null : availKB / (1024 * 1024);
277
- } catch { return null; }
278
- }
279
-
280
- // ── Logging ─────────────────────────────────────────────────────
281
-
282
- function timestamp() {
283
- return new Date().toISOString().replace('T', ' ').substring(0, 19);
284
- }
285
-
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
- }
299
- return {
300
- log(msg, c = 'green') {
301
- const line = `${timestamp()} ${msg}`;
302
- try { fs.appendFileSync(logFilePath, line + '\n'); rotateIfNeeded(); } catch { /* ignore */ }
303
- console.log(color[c] ? color[c](`[guard] ${line}`) : `[guard] ${line}`);
304
- },
305
- info(msg) { this.log(msg, 'cyan'); },
306
- warn(msg) { this.log(msg, 'yellow'); },
307
- error(msg) { this.log(msg, 'red'); },
308
- };
309
- }
310
-
311
- // ── CLI arg parsing (zero deps) ─────────────────────────────────
312
-
313
- function parseArgs(argv) {
314
- const args = {};
315
- for (let i = 2; i < argv.length; i++) {
316
- const a = argv[i];
317
- if (a.startsWith('--')) {
318
- const key = a.slice(2);
319
- const next = argv[i + 1];
320
- if (next && !next.startsWith('--')) {
321
- args[key] = next;
322
- i++;
323
- } else {
324
- args[key] = true;
325
- }
326
- }
327
- }
328
- return args;
329
- }
330
-
331
- // ── Filter files by config ──────────────────────────────────────
332
-
333
- function filterFiles(files, cfg) {
334
- let result = files;
335
- if (cfg.protect.length > 0) {
336
- result = result.filter(f => matchesAny(cfg.protect, f.rel));
337
- }
338
- result = result.filter(f => {
339
- if (cfg.ignore.length > 0 && matchesAny(cfg.ignore, f.rel)) return false;
340
- if (matchesAny(cfg.secrets_patterns, f.rel)) return false;
341
- return true;
342
- });
343
- return result;
344
- }
345
-
346
- // ── Exports ─────────────────────────────────────────────────────
347
-
348
- module.exports = {
349
- color,
350
- globMatch,
351
- matchesAny,
352
- walkDir,
353
- loadConfig,
354
- DEFAULT_CONFIG,
355
- DEFAULT_SECRETS,
356
- gitAvailable,
357
- git,
358
- isGitRepo,
359
- gitDir,
360
- gitVersion,
361
- buildManifest,
362
- manifestPath,
363
- loadManifest,
364
- saveManifest,
365
- manifestChanged,
366
- diskFreeGB,
367
- timestamp,
368
- createLogger,
369
- parseArgs,
370
- filterFiles,
371
- };
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
+ 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
+ }
71
+ }
72
+ }
73
+ return results;
74
+ }
75
+
76
+ // ── Config loading ──────────────────────────────────────────────
77
+
78
+ const DEFAULT_SECRETS = ['.env', '.env.*', '*.key', '*.pem', '*.p12', '*.pfx', 'credentials*'];
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
+
85
+ const DEFAULT_CONFIG = {
86
+ protect: [],
87
+ ignore: [],
88
+ secrets_patterns: DEFAULT_SECRETS,
89
+ backup_strategy: 'git',
90
+ auto_backup_interval_seconds: 60,
91
+ pre_restore_backup: 'always',
92
+ retention: { mode: 'days', days: 30, max_count: 100, max_size_mb: 500 },
93
+ git_retention: { enabled: false, mode: 'count', days: 30, max_count: 200 },
94
+ };
95
+
96
+ function loadConfig(projectDir) {
97
+ const cfgPath = path.join(projectDir, '.cursor-guard.json');
98
+ const cfg = { ...DEFAULT_CONFIG };
99
+ cfg.retention = { ...DEFAULT_CONFIG.retention };
100
+ cfg.git_retention = { ...DEFAULT_CONFIG.git_retention };
101
+
102
+ if (!fs.existsSync(cfgPath)) return { cfg, loaded: false, error: null };
103
+
104
+ try {
105
+ const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
106
+ if (Array.isArray(raw.protect)) cfg.protect = raw.protect;
107
+ if (Array.isArray(raw.ignore)) cfg.ignore = raw.ignore;
108
+ if (Array.isArray(raw.secrets_patterns)) cfg.secrets_patterns = raw.secrets_patterns;
109
+ if (Array.isArray(raw.secrets_patterns_extra)) {
110
+ const merged = [...new Set([...cfg.secrets_patterns, ...raw.secrets_patterns_extra])];
111
+ cfg.secrets_patterns = merged;
112
+ }
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
+ }
121
+ if (typeof raw.auto_backup_interval_seconds === 'number') cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
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
+ }
129
+ if (raw.retention) {
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;
140
+ }
141
+ if (raw.git_retention) {
142
+ if (raw.git_retention.enabled === true) cfg.git_retention.enabled = true;
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;
152
+ }
153
+ return { cfg, loaded: true, error: null, warnings };
154
+ } catch (e) {
155
+ return { cfg, loaded: false, error: e.message };
156
+ }
157
+ }
158
+
159
+ // ── Git helpers ─────────────────────────────────────────────────
160
+
161
+ function gitAvailable() {
162
+ try {
163
+ execFileSync('git', ['--version'], { stdio: 'pipe' });
164
+ return true;
165
+ } catch { return false; }
166
+ }
167
+
168
+ function git(args, opts = {}) {
169
+ const options = {
170
+ stdio: 'pipe',
171
+ encoding: 'utf-8',
172
+ ...opts,
173
+ };
174
+ try {
175
+ return execFileSync('git', args, options).trim();
176
+ } catch (e) {
177
+ if (opts.allowFail) return null;
178
+ throw e;
179
+ }
180
+ }
181
+
182
+ function isGitRepo(cwd) {
183
+ try {
184
+ const result = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
185
+ cwd, stdio: 'pipe', encoding: 'utf-8',
186
+ }).trim();
187
+ return result === 'true';
188
+ } catch { return false; }
189
+ }
190
+
191
+ function gitDir(cwd) {
192
+ try {
193
+ const dir = execFileSync('git', ['rev-parse', '--git-dir'], {
194
+ cwd, stdio: 'pipe', encoding: 'utf-8',
195
+ }).trim();
196
+ return path.resolve(cwd, dir);
197
+ } catch { return null; }
198
+ }
199
+
200
+ function gitVersion() {
201
+ try {
202
+ return execFileSync('git', ['--version'], { stdio: 'pipe', encoding: 'utf-8' })
203
+ .trim().replace('git version ', '');
204
+ } catch { return null; }
205
+ }
206
+
207
+ // ── Manifest (for shadow-mode change detection) ─────────────────
208
+
209
+ function buildManifest(files) {
210
+ const manifest = {};
211
+ for (const f of files) {
212
+ try {
213
+ const st = fs.statSync(f.full);
214
+ manifest[f.rel] = { mtimeMs: st.mtimeMs, size: st.size };
215
+ } catch { /* skip unreadable files */ }
216
+ }
217
+ return manifest;
218
+ }
219
+
220
+ function manifestPath(backupDir) {
221
+ return path.join(backupDir, '.manifest.json');
222
+ }
223
+
224
+ function loadManifest(backupDir) {
225
+ const p = manifestPath(backupDir);
226
+ if (!fs.existsSync(p)) return null;
227
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')); }
228
+ catch { return null; }
229
+ }
230
+
231
+ function saveManifest(backupDir, manifest) {
232
+ fs.writeFileSync(manifestPath(backupDir), JSON.stringify(manifest, null, 2));
233
+ }
234
+
235
+ function manifestChanged(oldM, newM) {
236
+ if (!oldM) return true;
237
+ const oldKeys = Object.keys(oldM);
238
+ const newKeys = Object.keys(newM);
239
+ if (oldKeys.length !== newKeys.length) return true;
240
+ for (const k of newKeys) {
241
+ if (!oldM[k]) return true;
242
+ if (oldM[k].mtimeMs !== newM[k].mtimeMs || oldM[k].size !== newM[k].size) return true;
243
+ }
244
+ return false;
245
+ }
246
+
247
+ // ── Disk space (cross-platform) ─────────────────────────────────
248
+
249
+ function diskFreeGB(dir) {
250
+ try {
251
+ if (process.platform === 'win32') {
252
+ const drive = path.parse(dir).root.replace(/\\$/, '');
253
+ // Try PowerShell first (works on all modern Windows)
254
+ try {
255
+ const out = execFileSync('powershell', [
256
+ '-NoProfile', '-Command',
257
+ `(Get-PSDrive ${drive[0]}).Free`,
258
+ ], { stdio: 'pipe', encoding: 'utf-8' });
259
+ const bytes = parseInt(out.trim(), 10);
260
+ if (!isNaN(bytes)) return bytes / (1024 ** 3);
261
+ } catch { /* fall through */ }
262
+ // Fallback to wmic
263
+ try {
264
+ const out = execFileSync('wmic', [
265
+ 'logicaldisk', 'where', `DeviceID="${drive}"`, 'get', 'FreeSpace', '/value',
266
+ ], { stdio: 'pipe', encoding: 'utf-8' });
267
+ const m = out.match(/FreeSpace=(\d+)/);
268
+ return m ? parseFloat(m[1]) / (1024 ** 3) : null;
269
+ } catch { return null; }
270
+ }
271
+ const out = execFileSync('df', ['-k', dir], { stdio: 'pipe', encoding: 'utf-8' });
272
+ const lines = out.trim().split('\n');
273
+ if (lines.length < 2) return null;
274
+ const parts = lines[1].split(/\s+/);
275
+ const availKB = parseInt(parts[3], 10);
276
+ return isNaN(availKB) ? null : availKB / (1024 * 1024);
277
+ } catch { return null; }
278
+ }
279
+
280
+ // ── Logging ─────────────────────────────────────────────────────
281
+
282
+ function timestamp() {
283
+ return new Date().toISOString().replace('T', ' ').substring(0, 19);
284
+ }
285
+
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
+ }
299
+ return {
300
+ log(msg, c = 'green') {
301
+ const line = `${timestamp()} ${msg}`;
302
+ try { fs.appendFileSync(logFilePath, line + '\n'); rotateIfNeeded(); } catch { /* ignore */ }
303
+ console.log(color[c] ? color[c](`[guard] ${line}`) : `[guard] ${line}`);
304
+ },
305
+ info(msg) { this.log(msg, 'cyan'); },
306
+ warn(msg) { this.log(msg, 'yellow'); },
307
+ error(msg) { this.log(msg, 'red'); },
308
+ };
309
+ }
310
+
311
+ // ── CLI arg parsing (zero deps) ─────────────────────────────────
312
+
313
+ function parseArgs(argv) {
314
+ const args = {};
315
+ for (let i = 2; i < argv.length; i++) {
316
+ const a = argv[i];
317
+ if (a.startsWith('--')) {
318
+ const key = a.slice(2);
319
+ const next = argv[i + 1];
320
+ if (next && !next.startsWith('--')) {
321
+ args[key] = next;
322
+ i++;
323
+ } else {
324
+ args[key] = true;
325
+ }
326
+ }
327
+ }
328
+ return args;
329
+ }
330
+
331
+ // ── Filter files by config ──────────────────────────────────────
332
+
333
+ function filterFiles(files, cfg) {
334
+ let result = files;
335
+ if (cfg.protect.length > 0) {
336
+ result = result.filter(f => matchesAny(cfg.protect, f.rel));
337
+ }
338
+ result = result.filter(f => {
339
+ if (cfg.ignore.length > 0 && matchesAny(cfg.ignore, f.rel)) return false;
340
+ if (matchesAny(cfg.secrets_patterns, f.rel)) return false;
341
+ return true;
342
+ });
343
+ return result;
344
+ }
345
+
346
+ // ── Exports ─────────────────────────────────────────────────────
347
+
348
+ module.exports = {
349
+ color,
350
+ globMatch,
351
+ matchesAny,
352
+ walkDir,
353
+ loadConfig,
354
+ DEFAULT_CONFIG,
355
+ DEFAULT_SECRETS,
356
+ gitAvailable,
357
+ git,
358
+ isGitRepo,
359
+ gitDir,
360
+ gitVersion,
361
+ buildManifest,
362
+ manifestPath,
363
+ loadManifest,
364
+ saveManifest,
365
+ manifestChanged,
366
+ diskFreeGB,
367
+ timestamp,
368
+ createLogger,
369
+ parseArgs,
370
+ filterFiles,
371
+ };