filemayor 2.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/core/categories.js +235 -0
- package/core/cleaner.js +527 -0
- package/core/config.js +562 -0
- package/core/index.js +79 -0
- package/core/organizer.js +528 -0
- package/core/reporter.js +572 -0
- package/core/scanner.js +436 -0
- package/core/security.js +317 -0
- package/core/sop-parser.js +565 -0
- package/core/watcher.js +478 -0
- package/index.js +536 -0
- package/package.json +55 -0
package/core/scanner.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
5
|
+
* FILEMAYOR CORE — SCANNER
|
|
6
|
+
* Recursive directory scanner with configurable depth, filters,
|
|
7
|
+
* glob patterns, and category detection. Enterprise-grade.
|
|
8
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { categorize } = require('./categories');
|
|
16
|
+
const { validatePath, isDirSafe, canRead } = require('./security');
|
|
17
|
+
|
|
18
|
+
// ─── Default Configuration ────────────────────────────────────────
|
|
19
|
+
const DEFAULT_SCAN_OPTIONS = {
|
|
20
|
+
maxDepth: 20, // Max recursion depth
|
|
21
|
+
followSymlinks: false, // Follow symbolic links
|
|
22
|
+
includeHidden: false, // Include dot files/dirs
|
|
23
|
+
includeDirectories: false, // Include directories in results
|
|
24
|
+
extensions: null, // Filter by extensions (null = all)
|
|
25
|
+
excludeExtensions: null, // Exclude specific extensions
|
|
26
|
+
ignore: [ // Directories to skip
|
|
27
|
+
'node_modules', '.git', '__pycache__', '.venv',
|
|
28
|
+
'venv', '.idea', '.vscode', 'dist', 'build',
|
|
29
|
+
'.svn', '.hg', 'bower_components', '.terraform',
|
|
30
|
+
'.next', '.nuxt', '.cache', 'coverage'
|
|
31
|
+
],
|
|
32
|
+
ignorePatterns: [], // Glob-like name patterns to skip
|
|
33
|
+
minSize: 0, // Minimum file size in bytes
|
|
34
|
+
maxSize: Infinity, // Maximum file size in bytes
|
|
35
|
+
modifiedAfter: null, // Only files modified after this date
|
|
36
|
+
modifiedBefore: null, // Only files modified before this date
|
|
37
|
+
onProgress: null, // Progress callback
|
|
38
|
+
onError: null, // Error callback
|
|
39
|
+
abortSignal: null, // AbortController signal for cancellation
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ─── Glob Pattern Matching ────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Simple glob matcher (supports * and ? wildcards)
|
|
46
|
+
* @param {string} pattern - Glob pattern
|
|
47
|
+
* @param {string} str - String to test
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function matchGlob(pattern, str) {
|
|
51
|
+
const regex = pattern
|
|
52
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
|
|
53
|
+
.replace(/\*/g, '.*') // * → .*
|
|
54
|
+
.replace(/\?/g, '.'); // ? → .
|
|
55
|
+
return new RegExp(`^${regex}$`, 'i').test(str);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── File Info Construction ───────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a structured FileInfo object from a filesystem entry
|
|
62
|
+
* @param {string} fullPath - Absolute file path
|
|
63
|
+
* @param {fs.Stats} stats - File stats
|
|
64
|
+
* @param {string} rootPath - Original scan root for relative path
|
|
65
|
+
* @returns {Object} Structured file info
|
|
66
|
+
*/
|
|
67
|
+
function buildFileInfo(fullPath, stats, rootPath) {
|
|
68
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
69
|
+
const name = path.basename(fullPath);
|
|
70
|
+
const relativePath = path.relative(rootPath, fullPath);
|
|
71
|
+
const category = categorize(ext);
|
|
72
|
+
const dir = path.dirname(fullPath);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
name,
|
|
76
|
+
path: fullPath,
|
|
77
|
+
relativePath,
|
|
78
|
+
directory: dir,
|
|
79
|
+
ext,
|
|
80
|
+
category,
|
|
81
|
+
size: stats.size,
|
|
82
|
+
sizeHuman: formatBytes(stats.size),
|
|
83
|
+
modified: stats.mtime,
|
|
84
|
+
created: stats.birthtime,
|
|
85
|
+
accessed: stats.atime,
|
|
86
|
+
isFile: stats.isFile(),
|
|
87
|
+
isDirectory: stats.isDirectory(),
|
|
88
|
+
isSymlink: stats.isSymbolicLink ? stats.isSymbolicLink() : false,
|
|
89
|
+
permissions: {
|
|
90
|
+
readable: true, // We got stats, so it's readable
|
|
91
|
+
mode: stats.mode
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Format bytes to human-readable string
|
|
98
|
+
* @param {number} bytes
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function formatBytes(bytes) {
|
|
102
|
+
if (bytes === 0) return '0 B';
|
|
103
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
104
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
105
|
+
const value = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
|
|
106
|
+
return `${value} ${units[i]}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Scanner Class ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
class Scanner {
|
|
112
|
+
constructor(options = {}) {
|
|
113
|
+
this.options = { ...DEFAULT_SCAN_OPTIONS, ...options };
|
|
114
|
+
this.stats = {
|
|
115
|
+
filesFound: 0,
|
|
116
|
+
dirsScanned: 0,
|
|
117
|
+
totalSize: 0,
|
|
118
|
+
errors: [],
|
|
119
|
+
skipped: 0,
|
|
120
|
+
startTime: null,
|
|
121
|
+
endTime: null,
|
|
122
|
+
categories: {}
|
|
123
|
+
};
|
|
124
|
+
this._aborted = false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scan a directory and return all matching files
|
|
129
|
+
* @param {string} dirPath - Directory to scan
|
|
130
|
+
* @returns {{ files: Object[], stats: Object }}
|
|
131
|
+
*/
|
|
132
|
+
scan(dirPath) {
|
|
133
|
+
// Validate the path
|
|
134
|
+
const validation = validatePath(dirPath);
|
|
135
|
+
if (!validation.valid) {
|
|
136
|
+
throw new Error(`Invalid path: ${validation.error}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const safeCheck = isDirSafe(validation.resolved);
|
|
140
|
+
if (!safeCheck.safe) {
|
|
141
|
+
throw new Error(`Unsafe directory: ${safeCheck.reason}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!canRead(validation.resolved)) {
|
|
145
|
+
throw new Error(`Permission denied: cannot read "${validation.resolved}"`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Verify it's actually a directory
|
|
149
|
+
try {
|
|
150
|
+
const stat = fs.statSync(validation.resolved);
|
|
151
|
+
if (!stat.isDirectory()) {
|
|
152
|
+
throw new Error(`Not a directory: "${validation.resolved}"`);
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err.code === 'ENOENT') {
|
|
156
|
+
throw new Error(`Directory not found: "${validation.resolved}"`);
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.stats.startTime = Date.now();
|
|
162
|
+
const files = [];
|
|
163
|
+
|
|
164
|
+
this._scanRecursive(validation.resolved, validation.resolved, 0, files);
|
|
165
|
+
|
|
166
|
+
this.stats.endTime = Date.now();
|
|
167
|
+
this.stats.duration = this.stats.endTime - this.stats.startTime;
|
|
168
|
+
this.stats.durationHuman = this._formatDuration(this.stats.duration);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
root: validation.resolved,
|
|
172
|
+
files,
|
|
173
|
+
stats: { ...this.stats }
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Internal recursive scan implementation
|
|
179
|
+
*/
|
|
180
|
+
_scanRecursive(dir, rootPath, depth, results) {
|
|
181
|
+
// Check abort signal
|
|
182
|
+
if (this._aborted || this.options.abortSignal?.aborted) {
|
|
183
|
+
this._aborted = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Depth limit
|
|
188
|
+
if (depth > this.options.maxDepth) return;
|
|
189
|
+
|
|
190
|
+
this.stats.dirsScanned++;
|
|
191
|
+
|
|
192
|
+
// Progress callback
|
|
193
|
+
if (this.options.onProgress) {
|
|
194
|
+
this.options.onProgress({
|
|
195
|
+
phase: 'scanning',
|
|
196
|
+
currentDir: dir,
|
|
197
|
+
filesFound: this.stats.filesFound,
|
|
198
|
+
dirsScanned: this.stats.dirsScanned,
|
|
199
|
+
depth
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let items;
|
|
204
|
+
try {
|
|
205
|
+
items = fs.readdirSync(dir, { withFileTypes: true });
|
|
206
|
+
} catch (err) {
|
|
207
|
+
const errorInfo = {
|
|
208
|
+
path: dir,
|
|
209
|
+
code: err.code,
|
|
210
|
+
message: err.message,
|
|
211
|
+
depth
|
|
212
|
+
};
|
|
213
|
+
this.stats.errors.push(errorInfo);
|
|
214
|
+
if (this.options.onError) {
|
|
215
|
+
this.options.onError(errorInfo);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
if (this._aborted) return;
|
|
222
|
+
|
|
223
|
+
const fullPath = path.join(dir, item.name);
|
|
224
|
+
|
|
225
|
+
// ─── Filtering ────────────────────────────────
|
|
226
|
+
|
|
227
|
+
// Skip hidden files/dirs
|
|
228
|
+
if (!this.options.includeHidden && item.name.startsWith('.')) {
|
|
229
|
+
this.stats.skipped++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Skip ignored directories
|
|
234
|
+
if (item.isDirectory() && this.options.ignore.includes(item.name)) {
|
|
235
|
+
this.stats.skipped++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Skip by glob patterns
|
|
240
|
+
if (this.options.ignorePatterns.length > 0) {
|
|
241
|
+
const shouldSkip = this.options.ignorePatterns.some(p => matchGlob(p, item.name));
|
|
242
|
+
if (shouldSkip) {
|
|
243
|
+
this.stats.skipped++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Handle symlinks
|
|
249
|
+
if (item.isSymbolicLink && item.isSymbolicLink() && !this.options.followSymlinks) {
|
|
250
|
+
this.stats.skipped++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Process Entry ────────────────────────────
|
|
255
|
+
|
|
256
|
+
let stats;
|
|
257
|
+
try {
|
|
258
|
+
stats = this.options.followSymlinks
|
|
259
|
+
? fs.statSync(fullPath)
|
|
260
|
+
: fs.lstatSync(fullPath);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
this.stats.errors.push({
|
|
263
|
+
path: fullPath,
|
|
264
|
+
code: err.code,
|
|
265
|
+
message: err.message,
|
|
266
|
+
depth
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (stats.isFile()) {
|
|
272
|
+
// Apply file filters
|
|
273
|
+
if (this._shouldIncludeFile(fullPath, stats)) {
|
|
274
|
+
const fileInfo = buildFileInfo(fullPath, stats, rootPath);
|
|
275
|
+
results.push(fileInfo);
|
|
276
|
+
this.stats.filesFound++;
|
|
277
|
+
this.stats.totalSize += stats.size;
|
|
278
|
+
|
|
279
|
+
// Track category counts
|
|
280
|
+
const cat = fileInfo.category;
|
|
281
|
+
this.stats.categories[cat] = (this.stats.categories[cat] || 0) + 1;
|
|
282
|
+
} else {
|
|
283
|
+
this.stats.skipped++;
|
|
284
|
+
}
|
|
285
|
+
} else if (stats.isDirectory()) {
|
|
286
|
+
if (this.options.includeDirectories) {
|
|
287
|
+
const dirInfo = buildFileInfo(fullPath, stats, rootPath);
|
|
288
|
+
results.push(dirInfo);
|
|
289
|
+
}
|
|
290
|
+
this._scanRecursive(fullPath, rootPath, depth + 1, results);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Check if a file passes all configured filters
|
|
297
|
+
*/
|
|
298
|
+
_shouldIncludeFile(filePath, stats) {
|
|
299
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
300
|
+
|
|
301
|
+
// Extension whitelist
|
|
302
|
+
if (this.options.extensions) {
|
|
303
|
+
const normalizedExts = this.options.extensions.map(e =>
|
|
304
|
+
e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
|
|
305
|
+
);
|
|
306
|
+
if (!normalizedExts.includes(ext)) return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Extension blacklist
|
|
310
|
+
if (this.options.excludeExtensions) {
|
|
311
|
+
const normalizedExcludes = this.options.excludeExtensions.map(e =>
|
|
312
|
+
e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
|
|
313
|
+
);
|
|
314
|
+
if (normalizedExcludes.includes(ext)) return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Size filters
|
|
318
|
+
if (stats.size < this.options.minSize) return false;
|
|
319
|
+
if (stats.size > this.options.maxSize) return false;
|
|
320
|
+
|
|
321
|
+
// Date filters
|
|
322
|
+
if (this.options.modifiedAfter) {
|
|
323
|
+
const threshold = new Date(this.options.modifiedAfter).getTime();
|
|
324
|
+
if (stats.mtime.getTime() < threshold) return false;
|
|
325
|
+
}
|
|
326
|
+
if (this.options.modifiedBefore) {
|
|
327
|
+
const threshold = new Date(this.options.modifiedBefore).getTime();
|
|
328
|
+
if (stats.mtime.getTime() > threshold) return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Abort the current scan
|
|
336
|
+
*/
|
|
337
|
+
abort() {
|
|
338
|
+
this._aborted = true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Format milliseconds to human-readable duration
|
|
343
|
+
*/
|
|
344
|
+
_formatDuration(ms) {
|
|
345
|
+
if (ms < 1000) return `${ms}ms`;
|
|
346
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
347
|
+
const mins = Math.floor(ms / 60000);
|
|
348
|
+
const secs = ((ms % 60000) / 1000).toFixed(0);
|
|
349
|
+
return `${mins}m ${secs}s`;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Convenience Functions ────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Quick scan a directory with default options
|
|
357
|
+
* @param {string} dirPath - Directory to scan
|
|
358
|
+
* @param {Object} options - Scan options
|
|
359
|
+
* @returns {{ files: Object[], stats: Object }}
|
|
360
|
+
*/
|
|
361
|
+
function scan(dirPath, options = {}) {
|
|
362
|
+
const scanner = new Scanner(options);
|
|
363
|
+
return scanner.scan(dirPath);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Quick scan and group results by category
|
|
368
|
+
* @param {string} dirPath - Directory to scan
|
|
369
|
+
* @param {Object} options - Scan options
|
|
370
|
+
* @returns {{ categories: Object, stats: Object }}
|
|
371
|
+
*/
|
|
372
|
+
function scanByCategory(dirPath, options = {}) {
|
|
373
|
+
const result = scan(dirPath, options);
|
|
374
|
+
const grouped = {};
|
|
375
|
+
|
|
376
|
+
for (const file of result.files) {
|
|
377
|
+
if (!grouped[file.category]) {
|
|
378
|
+
grouped[file.category] = {
|
|
379
|
+
files: [],
|
|
380
|
+
count: 0,
|
|
381
|
+
totalSize: 0
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
grouped[file.category].files.push(file);
|
|
385
|
+
grouped[file.category].count++;
|
|
386
|
+
grouped[file.category].totalSize += file.size;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Sort each category's files by name
|
|
390
|
+
for (const cat of Object.values(grouped)) {
|
|
391
|
+
cat.files.sort((a, b) => a.name.localeCompare(b.name));
|
|
392
|
+
cat.totalSizeHuman = formatBytes(cat.totalSize);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
root: result.root,
|
|
397
|
+
categories: grouped,
|
|
398
|
+
stats: result.stats
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Quick scan and get a size summary
|
|
404
|
+
* @param {string} dirPath - Directory to scan
|
|
405
|
+
* @param {Object} options - Scan options
|
|
406
|
+
* @returns {{ totalSize: number, totalSizeHuman: string, fileCount: number, largestFiles: Object[] }}
|
|
407
|
+
*/
|
|
408
|
+
function scanSummary(dirPath, options = {}) {
|
|
409
|
+
const result = scan(dirPath, options);
|
|
410
|
+
|
|
411
|
+
// Sort by size descending
|
|
412
|
+
const sorted = [...result.files].sort((a, b) => b.size - a.size);
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
root: result.root,
|
|
416
|
+
totalSize: result.stats.totalSize,
|
|
417
|
+
totalSizeHuman: formatBytes(result.stats.totalSize),
|
|
418
|
+
fileCount: result.stats.filesFound,
|
|
419
|
+
dirCount: result.stats.dirsScanned,
|
|
420
|
+
largestFiles: sorted.slice(0, 20),
|
|
421
|
+
categories: result.stats.categories,
|
|
422
|
+
duration: result.stats.durationHuman,
|
|
423
|
+
errors: result.stats.errors
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = {
|
|
428
|
+
Scanner,
|
|
429
|
+
scan,
|
|
430
|
+
scanByCategory,
|
|
431
|
+
scanSummary,
|
|
432
|
+
buildFileInfo,
|
|
433
|
+
formatBytes,
|
|
434
|
+
matchGlob,
|
|
435
|
+
DEFAULT_SCAN_OPTIONS
|
|
436
|
+
};
|