filemayor 2.1.0 → 3.6.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/security.js CHANGED
@@ -27,7 +27,8 @@ const PROTECTED_DIRECTORIES = new Set([
27
27
  const PROTECTED_PREFIXES = [
28
28
  '/System', '/Library/System',
29
29
  'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)',
30
- 'C:\\ProgramData', '/usr/lib', '/usr/bin', '/usr/sbin',
30
+ 'C:\\ProgramData', 'C:\\Users\\Public',
31
+ '/usr/lib', '/usr/bin', '/usr/sbin',
31
32
  '/var/lib', '/var/run', '/boot', '/dev', '/proc', '/sys'
32
33
  ];
33
34
 
@@ -36,6 +37,14 @@ const DANGEROUS_EXTENSIONS = new Set([
36
37
  '.plist', '.reg', '.msc', '.cpl'
37
38
  ]);
38
39
 
40
+ // ─── Project Substrates (v3.5) ────────────────────────────────────
41
+ const SUBSTRATE_MARKERS = [
42
+ '.git', '.svn', 'node_modules', 'package.json',
43
+ 'venv', '.venv', '__pycache__', 'env',
44
+ 'pom.xml', 'build.gradle', 'CMakeLists.txt',
45
+ 'Makefile', '.hg', 'composer.json', 'go.mod'
46
+ ];
47
+
39
48
  // ─── Path Validation ──────────────────────────────────────────────
40
49
 
41
50
  /**
@@ -49,27 +58,32 @@ function validatePath(inputPath, basePath = null) {
49
58
  return { valid: false, resolved: '', error: 'Path is empty or invalid' };
50
59
  }
51
60
 
52
- // Reject null bytes (path traversal vector)
61
+ // [Hacker-Proof] Reject null bytes (classic path traversal vector)
53
62
  if (inputPath.includes('\0')) {
54
63
  return { valid: false, resolved: '', error: 'Path contains null bytes (rejected)' };
55
64
  }
56
65
 
57
- // Resolve to absolute
66
+ // [Hacker-Proof] Resolve to absolute and normalize to handle '..' etc.
58
67
  const resolved = path.resolve(inputPath);
68
+ const rLower = resolved.toLowerCase();
59
69
 
60
- // If a basePath is given, ensure resolved path stays within it
70
+ // [Hacker-Proof] Jail Logic: If a basePath is given, ensure resolved path stays within it
61
71
  if (basePath) {
62
- const resolvedBase = path.resolve(basePath);
63
- if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
72
+ const resolvedBase = path.resolve(basePath).toLowerCase();
73
+
74
+ // [Elite Fix] Robust jailing: Target must start with base + separator or be the base itself
75
+ const isSafe = rLower === resolvedBase || rLower.startsWith(resolvedBase + path.sep);
76
+
77
+ if (!isSafe) {
64
78
  return {
65
79
  valid: false,
66
80
  resolved,
67
- error: `Path escapes base directory: "${resolved}" is outside "${resolvedBase}"`
81
+ error: `Security Alert: Path Traversal Attempted. "${resolved}" is outside scope.`
68
82
  };
69
83
  }
70
84
  }
71
85
 
72
- // Check against protected directories
86
+ // [Hacker-Proof] Check against protected directories
73
87
  if (PROTECTED_DIRECTORIES.has(resolved)) {
74
88
  return {
75
89
  valid: false,
@@ -78,14 +92,17 @@ function validatePath(inputPath, basePath = null) {
78
92
  };
79
93
  }
80
94
 
81
- // Check against protected prefixes
95
+ // [Hacker-Proof] Check against protected prefixes (System/Library/Program Files)
82
96
  for (const prefix of PROTECTED_PREFIXES) {
83
- const normalizedPrefix = path.resolve(prefix);
84
- if (resolved.startsWith(normalizedPrefix + path.sep) || resolved === normalizedPrefix) {
97
+ const normalizedPrefix = path.resolve(prefix).toLowerCase();
98
+ const rLower = resolved.toLowerCase();
99
+
100
+ // Block if exact match OR if it starts with prefix + separator
101
+ if (rLower === normalizedPrefix || rLower.startsWith(normalizedPrefix + path.sep)) {
85
102
  return {
86
103
  valid: false,
87
104
  resolved,
88
- error: `Refusing to operate on system directory: "${resolved}"`
105
+ error: `Refusal: Path is within a system-protected root ("${resolved}")`
89
106
  };
90
107
  }
91
108
  }
@@ -99,6 +116,9 @@ function validatePath(inputPath, basePath = null) {
99
116
  * @returns {{ safe: boolean, reason?: string }}
100
117
  */
101
118
  function isFileSafe(filePath) {
119
+ if (!filePath || typeof filePath !== 'string') {
120
+ return { safe: false, reason: 'File path is empty or invalid' };
121
+ }
102
122
  const ext = path.extname(filePath).toLowerCase();
103
123
  const basename = path.basename(filePath);
104
124
 
@@ -122,6 +142,37 @@ function isFileSafe(filePath) {
122
142
  return { safe: true };
123
143
  }
124
144
 
145
+ /**
146
+ * Check if a directory is a "Project Substrate" (should not be scattered)
147
+ * @param {string} dirPath - Directory to check
148
+ * @returns {boolean}
149
+ */
150
+ function isSubstrateCritical(dirPath) {
151
+ try {
152
+ const items = fs.readdirSync(dirPath);
153
+ return SUBSTRATE_MARKERS.some(marker => items.includes(marker));
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Find the nearest substrate parent for a file
161
+ * @param {string} filePath
162
+ * @returns {string|null}
163
+ */
164
+ function findNearestSubstrate(filePath) {
165
+ let current = path.dirname(path.resolve(filePath));
166
+ const root = path.parse(current).root;
167
+
168
+ while (current !== root) {
169
+ if (isSubstrateCritical(current)) return current;
170
+ current = path.dirname(current);
171
+ }
172
+ return null;
173
+ }
174
+
175
+
125
176
  /**
126
177
  * Check if a directory is safe to scan/organize
127
178
  * @param {string} dirPath - Absolute directory path
@@ -303,6 +354,8 @@ module.exports = {
303
354
  validatePath,
304
355
  isFileSafe,
305
356
  isDirSafe,
357
+ isSubstrateCritical,
358
+ findNearestSubstrate,
306
359
  sanitizeFilename,
307
360
  sanitizeDirname,
308
361
  canRead,
package/core/vault.js ADDED
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — VAULT (ZERO-DEPENDENCY SECRET MANAGER)
6
+ *
7
+ * Uses the OS-native credential store to keep API keys out of
8
+ * the filesystem entirely. Falls back to .env / environment
9
+ * variables if the system keychain is unavailable.
10
+ *
11
+ * Supported backends:
12
+ * macOS → security (Keychain Services)
13
+ * Windows → cmdkey / PowerShell CredentialManager
14
+ * Linux → secret-tool (freedesktop Secret Service)
15
+ * ═══════════════════════════════════════════════════════════════════
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { execSync } = require('child_process');
21
+ const path = require('path');
22
+ const fs = require('fs');
23
+
24
+ const SERVICE_NAME = 'FileMayor';
25
+
26
+ // ─── Platform-Specific Implementations ───────────────────────────
27
+
28
+ function _saveSecret_darwin(key, value) {
29
+ // -U flag updates if already exists
30
+ execSync(
31
+ `security add-generic-password -a "${SERVICE_NAME}" -s "${key}" -w "${value}" -U`,
32
+ { stdio: 'ignore' }
33
+ );
34
+ }
35
+
36
+ function _getSecret_darwin(key) {
37
+ return execSync(
38
+ `security find-generic-password -a "${SERVICE_NAME}" -s "${key}" -w`,
39
+ { encoding: 'utf8' }
40
+ ).trim();
41
+ }
42
+
43
+ function _deleteSecret_darwin(key) {
44
+ execSync(
45
+ `security delete-generic-password -a "${SERVICE_NAME}" -s "${key}"`,
46
+ { stdio: 'ignore' }
47
+ );
48
+ }
49
+
50
+ function _saveSecret_win32(key, value) {
51
+ // Use PowerShell to store in Windows Credential Manager
52
+ const cmd = `powershell -Command "cmdkey /generic:${SERVICE_NAME}_${key} /user:${key} /pass:${value}"`;
53
+ execSync(cmd, { stdio: 'ignore' });
54
+ }
55
+
56
+ function _getSecret_win32(key) {
57
+ // Use PowerShell to retrieve from Windows Credential Manager
58
+ const cmd = `powershell -Command "(cmdkey /list:${SERVICE_NAME}_${key}) -match 'User' | Out-Null; $cred = Get-StoredCredential -Target ${SERVICE_NAME}_${key}; if($cred){$cred.GetNetworkCredential().Password}else{throw 'Not found'}"`;
59
+ try {
60
+ return execSync(cmd, { encoding: 'utf8' }).trim();
61
+ } catch {
62
+ // Fallback: try simpler cmdkey parsing
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function _saveSecret_linux(key, value) {
68
+ execSync(
69
+ `echo "${value}" | secret-tool store --label="${SERVICE_NAME} ${key}" service "${SERVICE_NAME}" key "${key}"`,
70
+ { stdio: 'ignore' }
71
+ );
72
+ }
73
+
74
+ function _getSecret_linux(key) {
75
+ return execSync(
76
+ `secret-tool lookup service "${SERVICE_NAME}" key "${key}"`,
77
+ { encoding: 'utf8' }
78
+ ).trim();
79
+ }
80
+
81
+ // ─── The Vault ────────────────────────────────────────────────────
82
+
83
+ const Vault = {
84
+ /**
85
+ * Save a secret to the system keychain
86
+ * @param {string} key - Secret name (e.g., 'GEMINI_API_KEY')
87
+ * @param {string} value - Secret value
88
+ * @returns {boolean} Success
89
+ */
90
+ saveSecret(key, value) {
91
+ try {
92
+ if (process.platform === 'darwin') _saveSecret_darwin(key, value);
93
+ else if (process.platform === 'win32') _saveSecret_win32(key, value);
94
+ else _saveSecret_linux(key, value);
95
+ return true;
96
+ } catch (err) {
97
+ console.warn(`[Vault] Could not save to system keychain: ${err.message}`);
98
+ return false;
99
+ }
100
+ },
101
+
102
+ /**
103
+ * Retrieve a secret — checks System Keychain → ENV → .env file
104
+ * @param {string} key - Secret name
105
+ * @returns {string|null} The secret value, or null
106
+ */
107
+ getSecret(key) {
108
+ // Priority 1: System Keychain (most secure)
109
+ try {
110
+ if (process.platform === 'darwin') return _getSecret_darwin(key);
111
+ else if (process.platform === 'win32') {
112
+ const val = _getSecret_win32(key);
113
+ if (val) return val;
114
+ }
115
+ else return _getSecret_linux(key);
116
+ } catch { /* Keychain not available or key not found */ }
117
+
118
+ // Priority 2: Environment Variable
119
+ if (process.env[key]) return process.env[key];
120
+
121
+ // Priority 3: .env file (least secure, but common)
122
+ try {
123
+ const envPath = path.join(process.cwd(), '.env');
124
+ if (fs.existsSync(envPath)) {
125
+ const envFile = fs.readFileSync(envPath, 'utf-8');
126
+ for (const line of envFile.split('\n')) {
127
+ const trimmed = line.trim();
128
+ if (trimmed.startsWith('#') || !trimmed) continue;
129
+ const [k, ...vals] = trimmed.split('=');
130
+ if (k.trim() === key && vals.length) {
131
+ return vals.join('=').trim();
132
+ }
133
+ }
134
+ }
135
+ } catch { /* .env not available */ }
136
+
137
+ return null;
138
+ },
139
+
140
+ /**
141
+ * Delete a secret from the system keychain
142
+ * @param {string} key - Secret name
143
+ * @returns {boolean} Success
144
+ */
145
+ deleteSecret(key) {
146
+ try {
147
+ if (process.platform === 'darwin') _deleteSecret_darwin(key);
148
+ // Windows and Linux deletion handled differently
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ },
154
+
155
+ /**
156
+ * Check if a secret exists anywhere in the priority chain
157
+ * @param {string} key
158
+ * @returns {boolean}
159
+ */
160
+ hasSecret(key) {
161
+ return Vault.getSecret(key) !== null;
162
+ }
163
+ };
164
+
165
+ module.exports = Vault;
package/index.js CHANGED
@@ -19,6 +19,22 @@
19
19
 
20
20
  'use strict';
21
21
 
22
+ // ─── Load .env (zero-dependency) ─────────────────────────────────
23
+ try {
24
+ const _fs = require('fs');
25
+ const _path = require('path');
26
+ const envPath = _path.join(__dirname, '..', '.env');
27
+ _fs.readFileSync(envPath, 'utf-8').split('\n').forEach(line => {
28
+ const trimmed = line.trim();
29
+ if (trimmed && !trimmed.startsWith('#')) {
30
+ const [key, ...vals] = trimmed.split('=');
31
+ if (key && vals.length && !process.env[key.trim()]) {
32
+ process.env[key.trim()] = vals.join('=').trim();
33
+ }
34
+ }
35
+ });
36
+ } catch { /* .env not found — that's fine, user may set env vars directly */ }
37
+
22
38
  const path = require('path');
23
39
  const os = require('os');
24
40
  const fs = require('fs');
@@ -35,11 +51,12 @@ const { formatBytes } = require('./core/scanner');
35
51
  const { getCategories } = require('./core/categories');
36
52
  const { checkPermissions } = require('./core/security');
37
53
  const { activateLicense, deactivateLicense, getLicenseInfo, checkProFeature, checkBulkLimit } = require('./core/license');
54
+ const { ExplainEngine, CureEngine, ApplyEngine, DedupeEngine } = require('./core/index');
38
55
 
39
56
  const { c, banner, Spinner, success, error, warn, info } = reporter;
40
57
 
41
58
  // ─── Version ──────────────────────────────────────────────────────
42
- const VERSION = '2.1.0';
59
+ const VERSION = '3.6.0';
43
60
 
44
61
  // ─── Path Helpers ─────────────────────────────────────────────────
45
62
 
@@ -154,6 +171,12 @@ ${banner()}
154
171
  ${c('bold', 'USAGE')}
155
172
  ${c('cyan', 'filemayor')} ${c('yellow', '<command>')} ${c('dim', '[path]')} ${c('dim', '[options]')}
156
173
 
174
+ ${c('yellow', 'explain')} ${c('dim', '<path>')} Diagnose folder health (Viral!)
175
+ ${c('yellow', 'cure')} ${c('dim', '<path>')} Plan treatment using AI
176
+ ${c('yellow', 'apply')} Execute the cure plan
177
+ ${c('yellow', 'duplicates')} ${c('dim', '<path>')} Find duplicate files
178
+ ${c('yellow', 'dedupe')} ${c('dim', '<path>')} Remove duplicate files
179
+
157
180
  ${c('bold', 'COMMANDS')}
158
181
  ${c('yellow', 'scan')} ${c('dim', '<path>')} Scan directory and report contents
159
182
  ${c('yellow', 'analyze')} ${c('dim', '<path>')} Deep analysis (duplicates, bloat, savings)
@@ -459,6 +482,151 @@ async function cmdUndo(target, flags) {
459
482
  } catch { /* ignore */ }
460
483
  }
461
484
 
485
+ // ==================== DIAGNOSTIC TRIAD (v3.5) ====================
486
+
487
+ let activePlanState = null;
488
+
489
+ async function cmdExplain(target, flags) {
490
+ const targetPath = path.resolve(target || '.');
491
+ const spinner = new Spinner(`Diagnosing ${c('cyan', targetPath)}...`);
492
+ spinner.start();
493
+
494
+ try {
495
+ const engine = new ExplainEngine();
496
+ const result = engine.run(targetPath);
497
+ spinner.stop();
498
+ console.log(reporter.formatExplainReport(result));
499
+ } catch (err) {
500
+ spinner.fail(err.message);
501
+ process.exit(1);
502
+ }
503
+ }
504
+
505
+ async function cmdCure(target, flags) {
506
+ const targetPath = path.resolve(target || '.');
507
+ const prompt = flags.prompt || 'Clean up this folder and group similar files.';
508
+
509
+ if (!process.env.GEMINI_API_KEY) {
510
+ console.log(error('GEMINI_API_KEY environment variable is required for AI curative features.'));
511
+ process.exit(1);
512
+ }
513
+
514
+ const spinner = new Spinner(`Consulting AI for ${c('cyan', targetPath)}...`);
515
+ spinner.start();
516
+
517
+ try {
518
+ const engine = new CureEngine(targetPath, process.env.GEMINI_API_KEY);
519
+ activePlanState = await engine.generatePlan(prompt);
520
+ spinner.stop();
521
+
522
+ // Cache plan locally for 'apply' command
523
+ const cachePath = path.join(os.tmpdir(), 'filemayor-plan.json');
524
+ fs.writeFileSync(cachePath, JSON.stringify(activePlanState), 'utf8');
525
+
526
+ console.log(reporter.formatCureReport(activePlanState));
527
+ } catch (err) {
528
+ spinner.fail(err.message);
529
+ process.exit(1);
530
+ }
531
+ }
532
+
533
+ async function cmdApply(flags) {
534
+ const cachePath = path.join(os.tmpdir(), 'filemayor-plan.json');
535
+ if (!fs.existsSync(cachePath)) {
536
+ console.log(error('No plan found. Run "filemayor cure" first.'));
537
+ process.exit(1);
538
+ }
539
+
540
+ const plan = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
541
+
542
+ // [Sprint B] Logic Guardrail: Check batch volume & destructive patterns
543
+ const LogicGuardrail = require('./core/guardrail');
544
+ const { FileMayorJailer } = require('./core/jailer');
545
+ const guardrail = new LogicGuardrail(50);
546
+ const jailer = new FileMayorJailer(process.cwd());
547
+
548
+ if (plan.plan && Array.isArray(plan.plan)) {
549
+ const approved = await guardrail.verifyBatch(plan.plan);
550
+ if (!approved) {
551
+ console.log(warn('Operation cancelled by user.'));
552
+ return;
553
+ }
554
+
555
+ // [Sprint B] Jailer: Validate every move before execution
556
+ const blocked = [];
557
+ for (const step of plan.plan) {
558
+ const check = jailer.validateMove(step.source, step.destination);
559
+ if (!check.safe) {
560
+ blocked.push({ file: path.basename(step.source), reason: check.error });
561
+ }
562
+ }
563
+ if (blocked.length > 0) {
564
+ console.log(error(`Jailer blocked ${blocked.length} unsafe operations:`));
565
+ for (const b of blocked) {
566
+ console.log(c('dim', ` ✗ ${b.file}: ${b.reason}`));
567
+ }
568
+ plan.plan = plan.plan.filter(step => jailer.validateMove(step.source, step.destination).safe);
569
+ if (plan.plan.length === 0) {
570
+ console.log(error('No safe operations remain. Aborting.'));
571
+ return;
572
+ }
573
+ console.log(info(`Proceeding with ${plan.plan.length} safe operations.`));
574
+ }
575
+ }
576
+
577
+ const spinner = new Spinner(`Executing cure...`);
578
+ spinner.start();
579
+
580
+ try {
581
+ const engine = new ApplyEngine();
582
+ const result = await engine.apply(plan, (p) => {
583
+ spinner.update(`Relocating: ${p.index}/${p.total} — ${path.basename(p.current || '')}`);
584
+ });
585
+ spinner.succeed(`Cure applied! ${result.stats.success} files moved.`);
586
+ fs.unlinkSync(cachePath); // Clear plan
587
+ } catch (err) {
588
+ spinner.fail(err.message);
589
+ process.exit(1);
590
+ }
591
+ }
592
+
593
+ async function cmdDuplicates(target, flags) {
594
+ const targetPath = path.resolve(target || '.');
595
+ const spinner = new Spinner(`Hunting duplicates in ${c('cyan', targetPath)}...`);
596
+ spinner.start();
597
+
598
+ try {
599
+ const engine = new DedupeEngine();
600
+ const result = engine.find(targetPath);
601
+ spinner.stop();
602
+ console.log(reporter.formatDedupeReport(result));
603
+ } catch (err) {
604
+ spinner.fail(err.message);
605
+ process.exit(1);
606
+ }
607
+ }
608
+
609
+ async function cmdDedupe(target, flags) {
610
+ const targetPath = path.resolve(target || '.');
611
+ const spinner = new Spinner(`Deduplicating ${c('cyan', targetPath)}...`);
612
+ spinner.start();
613
+
614
+ try {
615
+ const engine = new DedupeEngine();
616
+ const report = engine.find(targetPath);
617
+ if (report.sets === 0) {
618
+ spinner.succeed('Total order restored: No duplicates found.');
619
+ return;
620
+ }
621
+
622
+ const result = await engine.clean(report);
623
+ spinner.succeed(`Purged ${result.deleted} duplicates. Reclaimed ${result.freedHuman}.`);
624
+ } catch (err) {
625
+ spinner.fail(err.message);
626
+ process.exit(1);
627
+ }
628
+ }
629
+
462
630
  async function cmdInfo(config) {
463
631
  console.log(banner());
464
632
  console.log(` ${c('bold', 'Version')} ${VERSION}`);
@@ -621,6 +789,30 @@ async function main() {
621
789
  await cmdScan(args.target, args.flags, config);
622
790
  break;
623
791
 
792
+ case 'explain':
793
+ case 'dx':
794
+ await cmdExplain(args.target, args.flags);
795
+ break;
796
+
797
+ case 'cure':
798
+ case 'plan':
799
+ await cmdCure(args.target, args.flags);
800
+ break;
801
+
802
+ case 'apply':
803
+ case 'exec':
804
+ await cmdApply(args.flags);
805
+ break;
806
+
807
+ case 'duplicates':
808
+ case 'dupes':
809
+ await cmdDuplicates(args.target, args.flags);
810
+ break;
811
+
812
+ case 'dedupe':
813
+ await cmdDedupe(args.target, args.flags);
814
+ break;
815
+
624
816
  case 'analyze':
625
817
  case 'a':
626
818
  await cmdAnalyze(args.target, args.flags, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filemayor",
3
- "version": "2.1.0",
3
+ "version": "3.6.0",
4
4
  "description": "FileMayor — Your Digital Life Organizer. CLI + Desktop + PWA.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -52,4 +52,4 @@
52
52
  "LICENSE",
53
53
  "README.md"
54
54
  ]
55
- }
55
+ }