fchek 1.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.
- package/README.md +64 -0
- package/bin/fchek.js +107 -0
- package/lib/api.js +110 -0
- package/lib/audit.js +211 -0
- package/lib/bench.js +248 -0
- package/lib/config.js +191 -0
- package/lib/context.js +356 -0
- package/lib/convention.js +526 -0
- package/lib/coverage.js +604 -0
- package/lib/db.js +135 -0
- package/lib/deps-check.js +264 -0
- package/lib/deps.js +374 -0
- package/lib/docker.js +84 -0
- package/lib/doctor.js +149 -0
- package/lib/dom.js +226 -0
- package/lib/fuzz.js +470 -0
- package/lib/git.js +290 -0
- package/lib/goto.js +544 -0
- package/lib/launch.js +182 -0
- package/lib/lint.js +624 -0
- package/lib/new_features.test.js +181 -0
- package/lib/output.js +46 -0
- package/lib/port.js +173 -0
- package/lib/process.js +228 -0
- package/lib/profile.js +453 -0
- package/lib/python.js +41 -0
- package/lib/race.js +186 -0
- package/lib/registry.js +179 -0
- package/lib/repl.js +135 -0
- package/lib/run.js +403 -0
- package/lib/screenshot.js +152 -0
- package/lib/secrets.js +257 -0
- package/lib/state.js +219 -0
- package/lib/test.js +471 -0
- package/lib/vuln.js +253 -0
- package/lib/watch.js +240 -0
- package/lib/winlog.js +123 -0
- package/package.json +27 -0
- package/skills/ACTIVATE.md +274 -0
- package/skills/README.md +163 -0
- package/skills/agent.md +444 -0
- package/skills/api.md +47 -0
- package/skills/bench.md +117 -0
- package/skills/context.md +116 -0
- package/skills/convention.md +143 -0
- package/skills/coverage.md +99 -0
- package/skills/csharp.md +97 -0
- package/skills/db.md +66 -0
- package/skills/deps-check.md +135 -0
- package/skills/deps.md +143 -0
- package/skills/docker.md +61 -0
- package/skills/dom.md +56 -0
- package/skills/fuzz.md +167 -0
- package/skills/goto.md +111 -0
- package/skills/lint.md +123 -0
- package/skills/port.md +57 -0
- package/skills/profile.md +91 -0
- package/skills/race.md +117 -0
- package/skills/repl.md +81 -0
- package/skills/rules.md +318 -0
- package/skills/run.md +135 -0
- package/skills/secrets.md +170 -0
- package/skills/security.md +360 -0
- package/skills/state.md +261 -0
- package/skills/vuln.md +57 -0
- package/skills/windows.md +320 -0
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* secrets.js — detect secrets and credentials in code
|
|
5
|
+
*
|
|
6
|
+
* Wrappers gitleaks and trufflehog.
|
|
7
|
+
* Falls back to built-in regex patterns if neither is installed.
|
|
8
|
+
*
|
|
9
|
+
* Run BEFORE committing code with SQL, DB connections, API calls.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { spawnSync, execSync } = require('child_process');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const { output, ok, fail } = require('./output');
|
|
17
|
+
|
|
18
|
+
const HELP = `
|
|
19
|
+
fchek secrets <path> [--staged] [--full-history]
|
|
20
|
+
|
|
21
|
+
Scan for secrets, credentials, and API keys in code.
|
|
22
|
+
Run BEFORE committing any code that touches DB, APIs, or auth.
|
|
23
|
+
|
|
24
|
+
Tools used (in order of preference):
|
|
25
|
+
1. gitleaks (apt install gitleaks / brew install gitleaks)
|
|
26
|
+
2. trufflehog (pip install trufflehog)
|
|
27
|
+
3. Built-in regex scanner (no install required, less accurate)
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--staged Scan only git staged files (pre-commit check)
|
|
31
|
+
--full-history Scan entire git history (slow, thorough)
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
fchek secrets .
|
|
35
|
+
fchek secrets src/auth.py
|
|
36
|
+
fchek secrets . --staged
|
|
37
|
+
`.trim();
|
|
38
|
+
|
|
39
|
+
function commandExists(cmd) {
|
|
40
|
+
try {
|
|
41
|
+
execSync(process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`, { stdio: 'ignore' });
|
|
42
|
+
return true;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Built-in regex patterns (fallback) ──────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const PATTERNS = [
|
|
49
|
+
{ name: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g, severity: 'critical' },
|
|
50
|
+
{ name: 'AWS Secret Key', regex: /aws_secret_access_key\s*=\s*["']?([A-Za-z0-9/+=]{40})["']?/gi, severity: 'critical' },
|
|
51
|
+
{ name: 'Generic API Key', regex: /(?:api[_-]?key|apikey)\s*[=:]\s*["']([A-Za-z0-9_\-]{20,})["']/gi, severity: 'high' },
|
|
52
|
+
{ name: 'Generic Secret', regex: /(?:secret|password|passwd|pwd)\s*[=:]\s*["']([^"']{8,})["']/gi, severity: 'high' },
|
|
53
|
+
{ name: 'Private Key Header', regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, severity: 'critical' },
|
|
54
|
+
{ name: 'GitHub Token', regex: /ghp_[A-Za-z0-9]{36}/g, severity: 'critical' },
|
|
55
|
+
{ name: 'GitHub OAuth', regex: /gho_[A-Za-z0-9]{36}/g, severity: 'critical' },
|
|
56
|
+
{ name: 'Slack Token', regex: /xox[baprs]-[A-Za-z0-9\-]{10,}/g, severity: 'high' },
|
|
57
|
+
{ name: 'Stripe Key', regex: /(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{24,}/g, severity: 'critical' },
|
|
58
|
+
{ name: 'Generic Bearer Token', regex: /bearer\s+([A-Za-z0-9+/=._\-]{20,})/gi, severity: 'medium' },
|
|
59
|
+
{ name: 'Basic Auth in URL', regex: /https?:\/\/[^:]+:[^@]{4,}@/g, severity: 'high' },
|
|
60
|
+
{ name: 'Database URL', regex: /(?:postgres(?:ql)?|mysql|mongodb|redis|mssql|sqlite):\/\/[^:@\s]+:[^@\s]{3,}@/gi, severity: 'high' },
|
|
61
|
+
{ name: 'Hardcoded IP+Port', regex: /\b(?:\d{1,3}\.){3}\d{1,3}:\d{4,5}\b/g, severity: 'low' },
|
|
62
|
+
{ name: 'Hex Secret (32+ chars)',regex: /(?:secret|key|token)\s*[=:]\s*["']?[0-9a-f]{32,}["']?/gi, severity: 'medium' },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const IGNORED_EXTENSIONS = new Set([
|
|
66
|
+
// Images / media / fonts
|
|
67
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.bmp',
|
|
68
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
69
|
+
'.mp4', '.mp3', '.wav', '.ogg', '.avi', '.mov',
|
|
70
|
+
// Archives / binaries
|
|
71
|
+
'.pdf', '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar', '.xz',
|
|
72
|
+
'.exe', '.dll', '.so', '.dylib', '.lib', '.a', '.o', '.obj', '.pdb',
|
|
73
|
+
// .NET / JVM compiled artifacts
|
|
74
|
+
'.class', '.jar', '.war', '.ear', '.nupkg', '.snupkg',
|
|
75
|
+
'.pdb', '.mdb', '.ilk', '.exp',
|
|
76
|
+
// Lock files and large generated files
|
|
77
|
+
'.lock', '.sum',
|
|
78
|
+
// Database / binary data
|
|
79
|
+
'.db', '.sqlite', '.sqlite3', '.mdb', '.accdb',
|
|
80
|
+
// Misc binary
|
|
81
|
+
'.bin', '.dat', '.cache', '.pyc', '.pyo',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// Directories to skip — binaries, compiled output, vendor code
|
|
85
|
+
const IGNORED_DIRS = new Set([
|
|
86
|
+
'node_modules', '.git', 'dist', 'build', 'target', '__pycache__',
|
|
87
|
+
'.venv', 'vendor', 'packages', 'bin', 'obj', // bin/obj = .NET build output
|
|
88
|
+
'.vs', '.idea', '.gradle', 'gradle',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
function scanFileBuiltin(filePath) {
|
|
92
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
93
|
+
if (IGNORED_EXTENSIONS.has(ext)) return [];
|
|
94
|
+
|
|
95
|
+
// Skip large files — binaries often disguise as text but are huge
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(filePath);
|
|
98
|
+
if (stat.size > 1_000_000) return []; // skip files > 1MB
|
|
99
|
+
} catch { return []; }
|
|
100
|
+
|
|
101
|
+
let content;
|
|
102
|
+
try { content = fs.readFileSync(filePath, 'utf8'); }
|
|
103
|
+
catch { return []; }
|
|
104
|
+
|
|
105
|
+
// Skip files that look binary (high ratio of non-printable chars in first 512 bytes)
|
|
106
|
+
const sample = content.slice(0, 512);
|
|
107
|
+
const nonPrintable = (sample.match(/[\x00-\x08\x0E-\x1F\x7F-\xFF]/g) || []).length;
|
|
108
|
+
if (nonPrintable / sample.length > 0.1) return [];
|
|
109
|
+
|
|
110
|
+
const findings = [];
|
|
111
|
+
const lines = content.split('\n');
|
|
112
|
+
|
|
113
|
+
for (const pattern of PATTERNS) {
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const line = lines[i];
|
|
116
|
+
const matches = [...line.matchAll(pattern.regex)];
|
|
117
|
+
for (const match of matches) {
|
|
118
|
+
// Redact the actual value — show first 4 chars + hash
|
|
119
|
+
const raw = match[1] || match[0];
|
|
120
|
+
const redacted = raw.slice(0, 4) + '***' + crypto.createHash('md5').update(raw).digest('hex').slice(0, 4);
|
|
121
|
+
findings.push({
|
|
122
|
+
file: filePath,
|
|
123
|
+
line: i + 1,
|
|
124
|
+
pattern: pattern.name,
|
|
125
|
+
severity: pattern.severity,
|
|
126
|
+
match_redacted: redacted,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return findings;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scanDirBuiltin(dir) {
|
|
136
|
+
const findings = [];
|
|
137
|
+
function walk(d) {
|
|
138
|
+
let entries;
|
|
139
|
+
try { entries = fs.readdirSync(d); } catch { return; }
|
|
140
|
+
for (const name of entries) {
|
|
141
|
+
if (IGNORED_DIRS.has(name) || name.startsWith('.')) continue;
|
|
142
|
+
const full = path.join(d, name);
|
|
143
|
+
const stat = fs.statSync(full);
|
|
144
|
+
if (stat.isDirectory()) walk(full);
|
|
145
|
+
else if (stat.isFile()) findings.push(...scanFileBuiltin(full));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
walk(dir);
|
|
149
|
+
return findings;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── gitleaks ─────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function runGitleaks(target, staged, fullHistory) {
|
|
155
|
+
const args = ['detect', '--source', target, '--report-format', 'json', '--no-banner'];
|
|
156
|
+
if (staged) args.push('--staged');
|
|
157
|
+
if (fullHistory) args.unshift('git-log', '--source', target);
|
|
158
|
+
|
|
159
|
+
const res = spawnSync('gitleaks', args, { encoding: 'utf8', timeout: 60_000 });
|
|
160
|
+
|
|
161
|
+
// gitleaks exits 1 when secrets found, 0 when clean
|
|
162
|
+
let findings = [];
|
|
163
|
+
try {
|
|
164
|
+
// Output may contain JSON array
|
|
165
|
+
const raw = res.stdout || res.stderr || '';
|
|
166
|
+
const jsonStart = raw.indexOf('[');
|
|
167
|
+
if (jsonStart !== -1) findings = JSON.parse(raw.slice(jsonStart));
|
|
168
|
+
} catch {}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
tool: 'gitleaks',
|
|
172
|
+
findings: findings.map(f => ({
|
|
173
|
+
file: f.File || f.file,
|
|
174
|
+
line: f.StartLine || f.line,
|
|
175
|
+
rule: f.RuleID || f.rule,
|
|
176
|
+
description: f.Description || f.description,
|
|
177
|
+
severity: 'high',
|
|
178
|
+
match_redacted: (f.Secret || '').slice(0, 4) + '***',
|
|
179
|
+
})).slice(0, 50),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── trufflehog ───────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function runTrufflehog(target) {
|
|
186
|
+
const res = spawnSync('trufflehog', ['filesystem', target, '--json', '--no-verification'],
|
|
187
|
+
{ encoding: 'utf8', timeout: 120_000 });
|
|
188
|
+
|
|
189
|
+
const findings = [];
|
|
190
|
+
for (const line of (res.stdout || '').split('\n')) {
|
|
191
|
+
try {
|
|
192
|
+
const obj = JSON.parse(line);
|
|
193
|
+
if (obj.DetectorName) {
|
|
194
|
+
findings.push({
|
|
195
|
+
file: obj.SourceMetadata?.Data?.Filesystem?.file || 'unknown',
|
|
196
|
+
line: obj.SourceMetadata?.Data?.Filesystem?.line || null,
|
|
197
|
+
rule: obj.DetectorName,
|
|
198
|
+
description: obj.DetectorDescription || obj.DetectorName,
|
|
199
|
+
severity: 'high',
|
|
200
|
+
match_redacted: (obj.Raw || '').slice(0, 4) + '***',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
return { tool: 'trufflehog', findings: findings.slice(0, 50) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
async function run(args) {
|
|
211
|
+
if (args.length === 0 || args[0] === '--help') { console.log(HELP); return; }
|
|
212
|
+
|
|
213
|
+
const target = args[0];
|
|
214
|
+
const staged = args.includes('--staged');
|
|
215
|
+
const fullHistory = args.includes('--full-history');
|
|
216
|
+
|
|
217
|
+
if (!fs.existsSync(target)) {
|
|
218
|
+
return output(fail(`Not found: ${target}`));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let scanResult;
|
|
222
|
+
|
|
223
|
+
if (commandExists('gitleaks')) {
|
|
224
|
+
scanResult = runGitleaks(target, staged, fullHistory);
|
|
225
|
+
} else if (commandExists('trufflehog')) {
|
|
226
|
+
scanResult = runTrufflehog(target);
|
|
227
|
+
} else {
|
|
228
|
+
// Built-in fallback
|
|
229
|
+
const stat = fs.statSync(target);
|
|
230
|
+
const findings = stat.isDirectory()
|
|
231
|
+
? scanDirBuiltin(target)
|
|
232
|
+
: scanFileBuiltin(target);
|
|
233
|
+
scanResult = { tool: 'built-in-regex', findings };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const critical = scanResult.findings.filter(f => f.severity === 'critical');
|
|
237
|
+
const high = scanResult.findings.filter(f => f.severity === 'high');
|
|
238
|
+
const total = scanResult.findings.length;
|
|
239
|
+
|
|
240
|
+
output(ok({
|
|
241
|
+
target: path.resolve(target),
|
|
242
|
+
tool: scanResult.tool,
|
|
243
|
+
total_findings: total,
|
|
244
|
+
verdict: total === 0 ? 'clean' : critical.length > 0 ? 'critical' : 'issues_found',
|
|
245
|
+
summary: {
|
|
246
|
+
critical: critical.length,
|
|
247
|
+
high: high.length,
|
|
248
|
+
other: total - critical.length - high.length,
|
|
249
|
+
},
|
|
250
|
+
findings: scanResult.findings,
|
|
251
|
+
note: scanResult.tool === 'built-in-regex'
|
|
252
|
+
? 'Using built-in patterns. Install gitleaks for better coverage: https://github.com/gitleaks/gitleaks'
|
|
253
|
+
: null,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { run };
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* state.js — persistent project memory across sessions.
|
|
5
|
+
*
|
|
6
|
+
* Stores data in .fcheck_state.json in the nearest project root
|
|
7
|
+
* (where package.json / Cargo.toml / .git lives), NOT in cwd blindly.
|
|
8
|
+
*
|
|
9
|
+
* Difference from repl.js:
|
|
10
|
+
* state = disk-based memory that survives process restarts (long-term context)
|
|
11
|
+
* repl = in-memory live process for interactive use within one session
|
|
12
|
+
*
|
|
13
|
+
* SECURITY NOTE:
|
|
14
|
+
* .fcheck_state.json is added to .gitignore automatically on first write.
|
|
15
|
+
* Never store secrets (tokens, passwords) in state — use env vars for those.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const { output, ok, fail } = require('./output');
|
|
22
|
+
|
|
23
|
+
const HELP = `
|
|
24
|
+
fchek state <action> [key] [value]
|
|
25
|
+
|
|
26
|
+
Persistent project memory — survives between sessions (stored on disk).
|
|
27
|
+
Different from REPL: state persists across process restarts.
|
|
28
|
+
|
|
29
|
+
Actions:
|
|
30
|
+
set <key> <value> Save a value
|
|
31
|
+
get <key> Read a value
|
|
32
|
+
list Show entire state
|
|
33
|
+
delete <key> Remove one key
|
|
34
|
+
clear Wipe all state (asks for confirmation unless --yes)
|
|
35
|
+
snapshot Save a snapshot of project file tree + hashes
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
fchek state set task "fix auth bug"
|
|
39
|
+
fchek state set branch "feature/auth"
|
|
40
|
+
fchek state get task
|
|
41
|
+
fchek state list
|
|
42
|
+
fchek state snapshot
|
|
43
|
+
fchek state clear --yes
|
|
44
|
+
|
|
45
|
+
State file location: <project_root>/.fcheck_state.json
|
|
46
|
+
`.trim();
|
|
47
|
+
|
|
48
|
+
const STATE_FILE = '.fcheck_state.json';
|
|
49
|
+
|
|
50
|
+
function findProjectRoot(startDir) {
|
|
51
|
+
const markers = ['package.json', 'Cargo.toml', 'CMakeLists.txt', '.git', 'pyproject.toml', 'go.mod'];
|
|
52
|
+
let dir = path.resolve(startDir);
|
|
53
|
+
for (let i = 0; i < 8; i++) {
|
|
54
|
+
if (markers.some(m => fs.existsSync(path.join(dir, m)))) return dir;
|
|
55
|
+
const parent = path.dirname(dir);
|
|
56
|
+
if (parent === dir) break;
|
|
57
|
+
dir = parent;
|
|
58
|
+
}
|
|
59
|
+
return path.resolve(startDir); // fallback: use cwd
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function statePath() {
|
|
63
|
+
return path.join(findProjectRoot(process.cwd()), STATE_FILE);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function loadState() {
|
|
67
|
+
const p = statePath();
|
|
68
|
+
if (!fs.existsSync(p)) return {};
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function saveState(state) {
|
|
77
|
+
const p = statePath();
|
|
78
|
+
fs.writeFileSync(p, JSON.stringify(state, null, 2), 'utf8');
|
|
79
|
+
ensureGitignore(path.dirname(p));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ensureGitignore(dir) {
|
|
83
|
+
const gi = path.join(dir, '.gitignore');
|
|
84
|
+
const entry = '.fcheck_state.json';
|
|
85
|
+
if (!fs.existsSync(gi)) {
|
|
86
|
+
fs.writeFileSync(gi, `${entry}\n`, 'utf8');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const content = fs.readFileSync(gi, 'utf8');
|
|
90
|
+
if (!content.includes(entry)) {
|
|
91
|
+
fs.appendFileSync(gi, `\n${entry}\n`, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fileHash(filePath) {
|
|
96
|
+
try {
|
|
97
|
+
const content = fs.readFileSync(filePath);
|
|
98
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildSnapshot(rootDir) {
|
|
105
|
+
const result = {};
|
|
106
|
+
const ignored = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__']);
|
|
107
|
+
|
|
108
|
+
function walk(dir, prefix = '') {
|
|
109
|
+
let entries;
|
|
110
|
+
try { entries = fs.readdirSync(dir); } catch { return; }
|
|
111
|
+
for (const name of entries) {
|
|
112
|
+
if (ignored.has(name) || name.startsWith('.')) continue;
|
|
113
|
+
const full = path.join(dir, name);
|
|
114
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
115
|
+
const stat = fs.statSync(full);
|
|
116
|
+
if (stat.isDirectory()) {
|
|
117
|
+
walk(full, rel);
|
|
118
|
+
} else if (stat.isFile()) {
|
|
119
|
+
result[rel] = { size: stat.size, hash: fileHash(full), mtime: stat.mtimeMs };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
walk(rootDir);
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function run(args) {
|
|
129
|
+
if (args.length === 0 || args[0] === '--help') {
|
|
130
|
+
console.log(HELP);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const action = args[0];
|
|
135
|
+
|
|
136
|
+
switch (action) {
|
|
137
|
+
case 'set': {
|
|
138
|
+
const key = args[1];
|
|
139
|
+
const value = args.slice(2).join(' ');
|
|
140
|
+
if (!key) return output(fail('Usage: fchek state set <key> <value>'));
|
|
141
|
+
const state = loadState();
|
|
142
|
+
state[key] = value;
|
|
143
|
+
state.__updated_at = new Date().toISOString();
|
|
144
|
+
saveState(state);
|
|
145
|
+
output(ok({ action: 'set', key, value, state_file: statePath() }));
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case 'get': {
|
|
150
|
+
const key = args[1];
|
|
151
|
+
if (!key) return output(fail('Usage: fchek state get <key>'));
|
|
152
|
+
const state = loadState();
|
|
153
|
+
if (!(key in state)) {
|
|
154
|
+
return output(fail(`Key not found: "${key}"`));
|
|
155
|
+
}
|
|
156
|
+
output(ok({ action: 'get', key, value: state[key] }));
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'list': {
|
|
161
|
+
const state = loadState();
|
|
162
|
+
output(ok({ action: 'list', state_file: statePath(), state }));
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case 'delete': {
|
|
167
|
+
const key = args[1];
|
|
168
|
+
if (!key) return output(fail('Usage: fchek state delete <key>'));
|
|
169
|
+
const state = loadState();
|
|
170
|
+
if (!(key in state)) return output(fail(`Key not found: "${key}"`));
|
|
171
|
+
delete state[key];
|
|
172
|
+
state.__updated_at = new Date().toISOString();
|
|
173
|
+
saveState(state);
|
|
174
|
+
output(ok({ action: 'delete', key, state_file: statePath() }));
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'clear': {
|
|
179
|
+
const confirmed = args.includes('--yes');
|
|
180
|
+
if (!confirmed) {
|
|
181
|
+
return output(fail(
|
|
182
|
+
'This will wipe all state. Add --yes to confirm: fchek state clear --yes'
|
|
183
|
+
));
|
|
184
|
+
}
|
|
185
|
+
const p = statePath();
|
|
186
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
187
|
+
output(ok({ action: 'clear', state_file: p }));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case 'snapshot': {
|
|
192
|
+
const rootDir = findProjectRoot(process.cwd());
|
|
193
|
+
const files = buildSnapshot(rootDir);
|
|
194
|
+
const count = Object.keys(files).length;
|
|
195
|
+
const state = loadState();
|
|
196
|
+
state.__snapshot = {
|
|
197
|
+
created_at: new Date().toISOString(),
|
|
198
|
+
root: rootDir,
|
|
199
|
+
file_count: count,
|
|
200
|
+
files,
|
|
201
|
+
};
|
|
202
|
+
saveState(state);
|
|
203
|
+
output(ok({
|
|
204
|
+
action: 'snapshot',
|
|
205
|
+
root: rootDir,
|
|
206
|
+
file_count: count,
|
|
207
|
+
state_file: statePath(),
|
|
208
|
+
}));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
default:
|
|
213
|
+
output(fail(
|
|
214
|
+
`Unknown action: "${action}". Valid: set, get, list, delete, clear, snapshot`
|
|
215
|
+
));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { run };
|