filemayor 2.0.5 → 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/README.md +48 -43
- package/core/ai/planner.js +42 -0
- package/core/ai/sentry.js +92 -0
- package/core/ai/strategist.js +186 -0
- package/core/ai/validator.js +116 -0
- package/core/analyzer.js +163 -0
- package/core/emergency-halt.js +104 -0
- package/core/engine/apply-engine.js +69 -0
- package/core/engine/cure-engine.js +70 -0
- package/core/engine/dedupe-engine.js +77 -0
- package/core/engine/explain-engine.js +114 -0
- package/core/engine/preview-engine.js +49 -0
- package/core/fs-abstraction.js +199 -0
- package/core/guardrail.js +115 -0
- package/core/index.js +56 -0
- package/core/intent-interpreter.js +158 -0
- package/core/jailer.js +151 -0
- package/core/license.js +12 -13
- package/core/metadata-store.js +104 -0
- package/core/organizer.js +28 -142
- package/core/reporter.js +212 -2
- package/core/scanner.js +92 -62
- package/core/security.js +65 -12
- package/core/sop-parser.js +1 -2
- package/core/vault.js +165 -0
- package/index.js +230 -2
- package/package.json +55 -55
package/core/jailer.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
5
|
+
* FILEMAYOR CORE — THE JAILER (IMMUTABLE SECURITY LAYER)
|
|
6
|
+
*
|
|
7
|
+
* This is the final firewall between FileMayor's AI "intuition" and
|
|
8
|
+
* the actual operating system. Even if Gemini suggests moving files
|
|
9
|
+
* into System32, this module kills the operation cold.
|
|
10
|
+
*
|
|
11
|
+
* CRITICAL: This file must NEVER be modified by AI assistants.
|
|
12
|
+
* It is the immutable source of truth for filesystem safety.
|
|
13
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
|
|
22
|
+
// ─── Forbidden Zones (OS-Agnostic) ───────────────────────────────
|
|
23
|
+
const FORBIDDEN_ZONES = [
|
|
24
|
+
// Windows
|
|
25
|
+
'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)',
|
|
26
|
+
'C:\\ProgramData', 'C:\\Users\\Public', 'C:\\Users\\Default',
|
|
27
|
+
// Linux / macOS
|
|
28
|
+
'/etc', '/usr', '/bin', '/sbin', '/var', '/root',
|
|
29
|
+
'/boot', '/dev', '/proc', '/sys', '/run',
|
|
30
|
+
// macOS specific
|
|
31
|
+
'/System', '/Library', '/private'
|
|
32
|
+
].map(p => path.resolve(p).toLowerCase());
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* THE JAILER: Final security check for ALL FileMayor operations.
|
|
36
|
+
* Use this before ANY fs.rename, fs.unlink, or fs.copy operation.
|
|
37
|
+
*/
|
|
38
|
+
class FileMayorJailer {
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} rootDirectory - The user's working directory (the "jail")
|
|
41
|
+
*/
|
|
42
|
+
constructor(rootDirectory = process.cwd()) {
|
|
43
|
+
// Resolve the root to its absolute, REAL path (no symlinks)
|
|
44
|
+
try {
|
|
45
|
+
this.safeRoot = fs.realpathSync(path.resolve(rootDirectory)).toLowerCase();
|
|
46
|
+
} catch {
|
|
47
|
+
this.safeRoot = path.resolve(rootDirectory).toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate that a target path is safe for FileMayor to operate on.
|
|
53
|
+
* Resolves symlinks to their TRUE destination before checking.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} targetPath - The path to validate
|
|
56
|
+
* @returns {{ safe: boolean, realPath: string, error?: string }}
|
|
57
|
+
*/
|
|
58
|
+
validate(targetPath) {
|
|
59
|
+
try {
|
|
60
|
+
if (!targetPath || typeof targetPath !== 'string') {
|
|
61
|
+
return { safe: false, realPath: '', error: 'Empty or invalid path' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Reject null bytes (classic injection vector)
|
|
65
|
+
if (targetPath.includes('\0')) {
|
|
66
|
+
return { safe: false, realPath: '', error: 'Null byte injection detected' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const absoluteTarget = path.resolve(targetPath);
|
|
70
|
+
|
|
71
|
+
// [ANTI-SYMLINK] Resolve the REAL path (follows symlinks to true source)
|
|
72
|
+
// This defeats CVE-2025-55130 style symlink race conditions
|
|
73
|
+
let realTarget;
|
|
74
|
+
try {
|
|
75
|
+
realTarget = fs.existsSync(absoluteTarget)
|
|
76
|
+
? fs.realpathSync(absoluteTarget)
|
|
77
|
+
: absoluteTarget;
|
|
78
|
+
} catch {
|
|
79
|
+
realTarget = absoluteTarget;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const normalizedTarget = realTarget.toLowerCase();
|
|
83
|
+
|
|
84
|
+
// [JAIL CHECK] Is the target inside our allowed project folder?
|
|
85
|
+
if (!normalizedTarget.startsWith(this.safeRoot + path.sep) &&
|
|
86
|
+
normalizedTarget !== this.safeRoot) {
|
|
87
|
+
return {
|
|
88
|
+
safe: false,
|
|
89
|
+
realPath: realTarget,
|
|
90
|
+
error: `JAIL BREACH: "${realTarget}" is outside the safe root "${this.safeRoot}"`
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// [SYSTEM CHECK] Is the target a protected OS zone?
|
|
95
|
+
for (const zone of FORBIDDEN_ZONES) {
|
|
96
|
+
if (normalizedTarget.startsWith(zone + path.sep) || normalizedTarget === zone) {
|
|
97
|
+
return {
|
|
98
|
+
safe: false,
|
|
99
|
+
realPath: realTarget,
|
|
100
|
+
error: `SYSTEM PROTECTION: "${realTarget}" is within forbidden zone "${zone}"`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// [EXTENSION CHECK] Reject system-critical file types
|
|
106
|
+
const ext = path.extname(realTarget).toLowerCase();
|
|
107
|
+
const dangerousExts = new Set(['.sys', '.drv', '.dll', '.so', '.dylib', '.kext', '.plist', '.reg']);
|
|
108
|
+
if (dangerousExts.has(ext)) {
|
|
109
|
+
return {
|
|
110
|
+
safe: false,
|
|
111
|
+
realPath: realTarget,
|
|
112
|
+
error: `DANGEROUS FILE: "${ext}" files are system-critical and cannot be moved`
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { safe: true, realPath: realTarget };
|
|
117
|
+
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { safe: false, realPath: '', error: `Jailer error: ${err.message}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate a move operation (both source AND destination must be safe)
|
|
125
|
+
* @param {string} source - Source file path
|
|
126
|
+
* @param {string} destination - Destination file path
|
|
127
|
+
* @returns {{ safe: boolean, error?: string }}
|
|
128
|
+
*/
|
|
129
|
+
validateMove(source, destination) {
|
|
130
|
+
const srcCheck = this.validate(source);
|
|
131
|
+
if (!srcCheck.safe) return { safe: false, error: `Source: ${srcCheck.error}` };
|
|
132
|
+
|
|
133
|
+
const dstCheck = this.validate(destination);
|
|
134
|
+
if (!dstCheck.safe) return { safe: false, error: `Destination: ${dstCheck.error}` };
|
|
135
|
+
|
|
136
|
+
return { safe: true };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Root/Sudo Kill-Switch ────────────────────────────────────────
|
|
141
|
+
// If running as root on Unix, exit immediately. FileMayor should
|
|
142
|
+
// NEVER have elevated privileges to limit blast radius.
|
|
143
|
+
function enforceUserSpace() {
|
|
144
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
145
|
+
console.error('❌ FILEMAYOR SECURITY ERROR: Running as root/sudo is strictly forbidden.');
|
|
146
|
+
console.error(' FileMayor is designed to run in User Space only.');
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { FileMayorJailer, enforceUserSpace, FORBIDDEN_ZONES };
|
package/core/license.js
CHANGED
|
@@ -19,7 +19,7 @@ const crypto = require('crypto');
|
|
|
19
19
|
|
|
20
20
|
const LICENSE_FILE = path.join(os.homedir(), '.filemayor-license.json');
|
|
21
21
|
const KEY_PREFIX = 'FM';
|
|
22
|
-
const CHECKSUM_SECRET = 'filemayor-chevza-2026';
|
|
22
|
+
const CHECKSUM_SECRET = process.env.FM_CHECKSUM_SECRET || 'filemayor-chevza-2026';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* License tiers with capabilities
|
|
@@ -66,10 +66,12 @@ const BUILTIN_KEYS = {
|
|
|
66
66
|
* @returns {string} 5-char checksum
|
|
67
67
|
*/
|
|
68
68
|
function generateChecksum(body) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
// Ported from Admin Dashboard simpleHash for cross-platform compatibility
|
|
70
|
+
let hash = 0;
|
|
71
|
+
for (let i = 0; i < body.length; i++) {
|
|
72
|
+
hash = ((hash << 5) - hash + body.charCodeAt(i)) | 0;
|
|
73
|
+
}
|
|
74
|
+
return Math.abs(hash).toString(36).toUpperCase().padEnd(5, '0').substring(0, 5);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
/**
|
|
@@ -284,16 +286,13 @@ function checkProFeature(feature, featureLabel) {
|
|
|
284
286
|
return {
|
|
285
287
|
allowed: false,
|
|
286
288
|
message: [
|
|
287
|
-
`⚡ ${featureLabel} is a Pro
|
|
289
|
+
c('yellow', `⚡ ${featureLabel} is a Pro Feature`),
|
|
288
290
|
'',
|
|
289
|
-
|
|
290
|
-
'
|
|
291
|
-
' • AI-powered SOP parsing (Gemini)',
|
|
292
|
-
' • Bulk organize (unlimited files)',
|
|
293
|
-
' • CSV export for all reports',
|
|
291
|
+
` FileMayor Core is free. ${featureLabel} requires a Pro License.`,
|
|
292
|
+
' Unlocks: Real-time Watch, AI SOP Engine, and Unlimited Bulk processing.',
|
|
294
293
|
'',
|
|
295
|
-
' Get
|
|
296
|
-
'
|
|
294
|
+
' Get Key: https://filemayor.com/pro',
|
|
295
|
+
' Activate: filemayor license activate YOUR-KEY',
|
|
297
296
|
'',
|
|
298
297
|
].join('\n'),
|
|
299
298
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
5
|
+
* FILEMAYOR CORE — METADATA STORE
|
|
6
|
+
* Lightweight, persistent indexing for S2 compliance (<300ms search).
|
|
7
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs').promises;
|
|
13
|
+
const fssync = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
class MetadataStore {
|
|
19
|
+
constructor(dbPath) {
|
|
20
|
+
this.rootPath = dbPath || path.join(os.homedir(), '.filemayor', 'metadata_index.json');
|
|
21
|
+
this.index = {
|
|
22
|
+
version: '1.0',
|
|
23
|
+
lastUpdated: null,
|
|
24
|
+
files: {} // path -> metadata
|
|
25
|
+
};
|
|
26
|
+
this._ensureDir();
|
|
27
|
+
this.load();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_ensureDir() {
|
|
31
|
+
const dir = path.dirname(this.rootPath);
|
|
32
|
+
if (!fssync.existsSync(dir)) {
|
|
33
|
+
fssync.mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
load() {
|
|
38
|
+
try {
|
|
39
|
+
if (fssync.existsSync(this.rootPath)) {
|
|
40
|
+
const data = fssync.readFileSync(this.rootPath, 'utf8');
|
|
41
|
+
this.index = JSON.parse(data);
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('[IDX] Failed to load index:', err.message);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async save() {
|
|
49
|
+
try {
|
|
50
|
+
this.index.lastUpdated = new Date().toISOString();
|
|
51
|
+
await fs.writeFile(this.rootPath, JSON.stringify(this.index, null, 2));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('[IDX] Failed to save index:', err.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Update the index with fresh file metadata
|
|
59
|
+
* @param {Object[]} files
|
|
60
|
+
*/
|
|
61
|
+
update(files) {
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
this.index.files[file.path] = {
|
|
64
|
+
name: file.name,
|
|
65
|
+
size: file.size,
|
|
66
|
+
mtime: file.modified,
|
|
67
|
+
category: file.category,
|
|
68
|
+
ext: file.ext
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Search the index with < 300ms latency
|
|
75
|
+
* @param {string} query
|
|
76
|
+
* @returns {Object[]}
|
|
77
|
+
*/
|
|
78
|
+
search(query) {
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
const results = [];
|
|
81
|
+
const lowerQuery = query.toLowerCase();
|
|
82
|
+
|
|
83
|
+
for (const [filePath, meta] of Object.entries(this.index.files)) {
|
|
84
|
+
if (meta.name.toLowerCase().includes(lowerQuery) || filePath.toLowerCase().includes(lowerQuery)) {
|
|
85
|
+
results.push({ path: filePath, ...meta });
|
|
86
|
+
}
|
|
87
|
+
if (results.length > 100) break; // Cap results for performance
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const duration = Date.now() - startTime;
|
|
91
|
+
return {
|
|
92
|
+
results,
|
|
93
|
+
duration,
|
|
94
|
+
totalFound: results.length
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clear() {
|
|
99
|
+
this.index.files = {};
|
|
100
|
+
this.save();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = new MetadataStore();
|
package/core/organizer.js
CHANGED
|
@@ -14,7 +14,10 @@ const fs = require('fs');
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const { categorize, getCategories } = require('./categories');
|
|
16
16
|
const { scan } = require('./scanner');
|
|
17
|
-
const {
|
|
17
|
+
const {
|
|
18
|
+
validatePath, isFileSafe, sanitizeFilename, canWrite, createSnapshot,
|
|
19
|
+
findNearestSubstrate
|
|
20
|
+
} = require('./security');
|
|
18
21
|
const { formatBytes } = require('./scanner');
|
|
19
22
|
|
|
20
23
|
// ─── Naming Conventions ───────────────────────────────────────────
|
|
@@ -175,6 +178,13 @@ function generatePlan(srcDir, options = {}) {
|
|
|
175
178
|
|
|
176
179
|
// Build the plan
|
|
177
180
|
for (const file of scanResult.files) {
|
|
181
|
+
// Substrate Protection (v3.5)
|
|
182
|
+
// If file is inside a project substrate, DO NOT scatter it by default
|
|
183
|
+
const projectRoot = findNearestSubstrate(file.path);
|
|
184
|
+
if (projectRoot) {
|
|
185
|
+
continue; // Skip project-locked files
|
|
186
|
+
}
|
|
187
|
+
|
|
178
188
|
const category = file.category;
|
|
179
189
|
categoryCounts[category] = (categoryCounts[category] || 0);
|
|
180
190
|
|
|
@@ -241,22 +251,23 @@ function generatePlan(srcDir, options = {}) {
|
|
|
241
251
|
|
|
242
252
|
// ─── Execution ────────────────────────────────────────────────────
|
|
243
253
|
|
|
254
|
+
const FileMayorFS = require('./fs-abstraction');
|
|
255
|
+
const fmfs = new FileMayorFS();
|
|
256
|
+
|
|
244
257
|
/**
|
|
245
258
|
* Execute a move plan with journal logging for rollback
|
|
246
259
|
* @param {MovePlanItem[]} plan - The move plan to execute
|
|
247
260
|
* @param {Object} options - Execution options
|
|
248
261
|
* @returns {{ results: Object[], journal: Object[], summary: Object }}
|
|
249
262
|
*/
|
|
250
|
-
function executePlan(plan, options = {}) {
|
|
263
|
+
async function executePlan(plan, options = {}) {
|
|
251
264
|
const {
|
|
252
265
|
onProgress = null,
|
|
253
266
|
onError = null,
|
|
254
|
-
|
|
255
|
-
abortOnError = false, // Stop on first error
|
|
267
|
+
abortOnError = false,
|
|
256
268
|
} = options;
|
|
257
269
|
|
|
258
270
|
const results = [];
|
|
259
|
-
const journal = [];
|
|
260
271
|
let succeeded = 0;
|
|
261
272
|
let failed = 0;
|
|
262
273
|
let skipped = 0;
|
|
@@ -264,7 +275,6 @@ function executePlan(plan, options = {}) {
|
|
|
264
275
|
for (let i = 0; i < plan.length; i++) {
|
|
265
276
|
const item = plan[i];
|
|
266
277
|
|
|
267
|
-
// Progress callback
|
|
268
278
|
if (onProgress) {
|
|
269
279
|
onProgress({
|
|
270
280
|
current: i + 1,
|
|
@@ -276,104 +286,21 @@ function executePlan(plan, options = {}) {
|
|
|
276
286
|
});
|
|
277
287
|
}
|
|
278
288
|
|
|
279
|
-
// Skip items marked as skip
|
|
280
289
|
if (item.action === 'skip') {
|
|
281
290
|
results.push({ ...item, status: 'skipped', error: null });
|
|
282
291
|
skipped++;
|
|
283
292
|
continue;
|
|
284
293
|
}
|
|
285
294
|
|
|
286
|
-
// Safety check
|
|
287
|
-
const safeCheck = isFileSafe(item.source);
|
|
288
|
-
if (!safeCheck.safe) {
|
|
289
|
-
const error = `Safety check failed: ${safeCheck.reason}`;
|
|
290
|
-
results.push({ ...item, status: 'error', error });
|
|
291
|
-
failed++;
|
|
292
|
-
if (onError) onError({ item, error });
|
|
293
|
-
if (abortOnError) break;
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Create destination directory
|
|
298
|
-
const destDir = path.dirname(item.destination);
|
|
299
295
|
try {
|
|
300
|
-
|
|
301
|
-
} catch (err) {
|
|
302
|
-
const error = `Failed to create directory: ${err.message}`;
|
|
303
|
-
results.push({ ...item, status: 'error', error });
|
|
304
|
-
failed++;
|
|
305
|
-
if (onError) onError({ item, error });
|
|
306
|
-
if (abortOnError) break;
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Create pre-move snapshot for integrity
|
|
311
|
-
const snapshot = createSnapshot(item.source);
|
|
312
|
-
|
|
313
|
-
// Execute the move
|
|
314
|
-
try {
|
|
315
|
-
// Handle overwrite
|
|
316
|
-
if (item.action === 'overwrite' && fs.existsSync(item.destination)) {
|
|
317
|
-
fs.unlinkSync(item.destination);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Perform the move
|
|
321
|
-
fs.renameSync(item.source, item.destination);
|
|
322
|
-
|
|
323
|
-
// Journal entry for rollback
|
|
324
|
-
const journalEntry = {
|
|
325
|
-
source: item.source,
|
|
326
|
-
destination: item.destination,
|
|
327
|
-
originalName: item.originalName,
|
|
328
|
-
newName: item.newName,
|
|
329
|
-
size: item.size,
|
|
330
|
-
timestamp: Date.now(),
|
|
331
|
-
snapshot
|
|
332
|
-
};
|
|
333
|
-
journal.push(journalEntry);
|
|
334
|
-
|
|
296
|
+
await fmfs.move(item.source, item.destination);
|
|
335
297
|
results.push({ ...item, status: 'success', error: null });
|
|
336
298
|
succeeded++;
|
|
337
299
|
} catch (err) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const journalEntry = {
|
|
344
|
-
source: item.source,
|
|
345
|
-
destination: item.destination,
|
|
346
|
-
originalName: item.originalName,
|
|
347
|
-
newName: item.newName,
|
|
348
|
-
size: item.size,
|
|
349
|
-
timestamp: Date.now(),
|
|
350
|
-
snapshot,
|
|
351
|
-
crossDevice: true
|
|
352
|
-
};
|
|
353
|
-
journal.push(journalEntry);
|
|
354
|
-
|
|
355
|
-
results.push({ ...item, status: 'success', error: null });
|
|
356
|
-
succeeded++;
|
|
357
|
-
} catch (copyErr) {
|
|
358
|
-
const error = `Move failed: ${copyErr.message}`;
|
|
359
|
-
results.push({ ...item, status: 'error', error });
|
|
360
|
-
failed++;
|
|
361
|
-
if (onError) onError({ item, error });
|
|
362
|
-
if (abortOnError) break;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Save journal to disk if path provided
|
|
368
|
-
if (journalPath && journal.length > 0) {
|
|
369
|
-
try {
|
|
370
|
-
const existing = fs.existsSync(journalPath)
|
|
371
|
-
? JSON.parse(fs.readFileSync(journalPath, 'utf8'))
|
|
372
|
-
: [];
|
|
373
|
-
const merged = [...existing, ...journal];
|
|
374
|
-
fs.writeFileSync(journalPath, JSON.stringify(merged, null, 2), 'utf8');
|
|
375
|
-
} catch (err) {
|
|
376
|
-
// Journal save failed — non-fatal
|
|
300
|
+
results.push({ ...item, status: 'error', error: err.message });
|
|
301
|
+
failed++;
|
|
302
|
+
if (onError) onError({ item, error: err.message });
|
|
303
|
+
if (abortOnError) break;
|
|
377
304
|
}
|
|
378
305
|
}
|
|
379
306
|
|
|
@@ -386,7 +313,7 @@ function executePlan(plan, options = {}) {
|
|
|
386
313
|
totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0))
|
|
387
314
|
};
|
|
388
315
|
|
|
389
|
-
return { results, journal, summary };
|
|
316
|
+
return { results, journal: fmfs.getJournal(), summary };
|
|
390
317
|
}
|
|
391
318
|
|
|
392
319
|
// ─── Rollback (Undo) ──────────────────────────────────────────────
|
|
@@ -395,50 +322,11 @@ function executePlan(plan, options = {}) {
|
|
|
395
322
|
* Rollback operations using a journal
|
|
396
323
|
* @param {Object[]} journal - Journal entries from executePlan
|
|
397
324
|
* @param {Object} options - Rollback options
|
|
398
|
-
* @returns {{ undone: number, failed: number, errors: string[] }}
|
|
399
325
|
*/
|
|
400
|
-
function rollback(journal, options = {}) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const errors = [];
|
|
405
|
-
|
|
406
|
-
// Process in reverse order (most recent first)
|
|
407
|
-
const reversed = [...journal].reverse();
|
|
408
|
-
|
|
409
|
-
for (let i = 0; i < reversed.length; i++) {
|
|
410
|
-
const entry = reversed[i];
|
|
411
|
-
|
|
412
|
-
if (onProgress) {
|
|
413
|
-
onProgress({
|
|
414
|
-
current: i + 1,
|
|
415
|
-
total: reversed.length,
|
|
416
|
-
percent: Math.round(((i + 1) / reversed.length) * 100),
|
|
417
|
-
file: entry.originalName
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
try {
|
|
422
|
-
// Ensure source directory exists
|
|
423
|
-
const sourceDir = path.dirname(entry.source);
|
|
424
|
-
fs.mkdirSync(sourceDir, { recursive: true });
|
|
425
|
-
|
|
426
|
-
// Move back
|
|
427
|
-
if (entry.crossDevice) {
|
|
428
|
-
fs.copyFileSync(entry.destination, entry.source);
|
|
429
|
-
fs.unlinkSync(entry.destination);
|
|
430
|
-
} else {
|
|
431
|
-
fs.renameSync(entry.destination, entry.source);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
undone++;
|
|
435
|
-
} catch (err) {
|
|
436
|
-
failed++;
|
|
437
|
-
errors.push(`Failed to undo ${entry.originalName}: ${err.message}`);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return { undone, failed, errors };
|
|
326
|
+
async function rollback(journal, options = {}) {
|
|
327
|
+
// Fill the fmfs internal journal with the passed journal to use its logic
|
|
328
|
+
fmfs.sessionJournal = journal;
|
|
329
|
+
return await fmfs.rollback();
|
|
442
330
|
}
|
|
443
331
|
|
|
444
332
|
/**
|
|
@@ -463,13 +351,12 @@ function loadJournal(journalPath) {
|
|
|
463
351
|
* @param {Object} options - Full options
|
|
464
352
|
* @returns {{ results: Object[], journal: Object[], planSummary: Object, execSummary: Object }}
|
|
465
353
|
*/
|
|
466
|
-
function organize(dirPath, options = {}) {
|
|
354
|
+
async function organize(dirPath, options = {}) {
|
|
467
355
|
const {
|
|
468
356
|
dryRun = false,
|
|
469
357
|
naming = 'original',
|
|
470
358
|
outputDir = null,
|
|
471
359
|
duplicateStrategy = 'rename',
|
|
472
|
-
journalPath = null,
|
|
473
360
|
onProgress = null,
|
|
474
361
|
onError = null,
|
|
475
362
|
abortOnError = false,
|
|
@@ -498,10 +385,9 @@ function organize(dirPath, options = {}) {
|
|
|
498
385
|
}
|
|
499
386
|
|
|
500
387
|
// Execute
|
|
501
|
-
const { results, journal, summary: execSummary } = executePlan(plan, {
|
|
388
|
+
const { results, journal, summary: execSummary } = await executePlan(plan, {
|
|
502
389
|
onProgress,
|
|
503
390
|
onError,
|
|
504
|
-
journalPath: journalPath || path.join(dirPath, '.filemayor-journal.json'),
|
|
505
391
|
abortOnError
|
|
506
392
|
});
|
|
507
393
|
|