@kadi.build/file-manager 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 +268 -0
- package/package.json +48 -0
- package/src/ConfigManager.js +301 -0
- package/src/FileManager.js +526 -0
- package/src/index.js +48 -0
- package/src/providers/CompressionProvider.js +968 -0
- package/src/providers/LocalProvider.js +824 -0
- package/src/providers/RemoteProvider.js +514 -0
- package/src/providers/WatchProvider.js +611 -0
- package/src/utils/FileStreamingUtils.js +757 -0
- package/src/utils/PathUtils.js +144 -0
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import fsSync from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
|
|
7
|
+
class LocalProvider extends EventEmitter {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
super();
|
|
10
|
+
this.config = config || {};
|
|
11
|
+
this.localRoot = this.config.localRoot || process.cwd();
|
|
12
|
+
this.maxFileSize = this.config.maxFileSize || 1073741824; // 1GB
|
|
13
|
+
this.chunkSize = this.config.chunkSize || 8388608; // 8MB
|
|
14
|
+
this.allowSymlinks = this.config.allowSymlinks || false;
|
|
15
|
+
this.restrictToBasePath = this.config.restrictToBasePath !== false; // Default true
|
|
16
|
+
this.maxPathLength = this.config.maxPathLength || 255;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// PATH VALIDATION AND UTILITIES
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
normalizePath(inputPath) {
|
|
24
|
+
if (!inputPath || inputPath === '/') {
|
|
25
|
+
return this.localRoot;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Handle absolute paths
|
|
29
|
+
if (path.isAbsolute(inputPath)) {
|
|
30
|
+
const normalizedPath = path.normalize(inputPath);
|
|
31
|
+
|
|
32
|
+
// Security check: ensure absolute path is within base path if restriction is enabled
|
|
33
|
+
if (this.restrictToBasePath) {
|
|
34
|
+
const resolvedLocalRoot = path.resolve(this.localRoot);
|
|
35
|
+
if (!normalizedPath.startsWith(resolvedLocalRoot)) {
|
|
36
|
+
throw new Error(`Path '${inputPath}' is outside the allowed base path '${this.localRoot}'`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return normalizedPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Handle relative paths - resolve them relative to localRoot
|
|
44
|
+
const resolvedLocalRoot = path.resolve(this.localRoot);
|
|
45
|
+
const normalizedPath = path.resolve(resolvedLocalRoot, inputPath);
|
|
46
|
+
|
|
47
|
+
// Security check: ensure resolved path is within base path if restriction is enabled
|
|
48
|
+
if (this.restrictToBasePath) {
|
|
49
|
+
const relativePath = path.relative(resolvedLocalRoot, normalizedPath);
|
|
50
|
+
|
|
51
|
+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
52
|
+
throw new Error(`Path '${inputPath}' is outside the allowed base path '${this.localRoot}'`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return normalizedPath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
validatePath(inputPath) {
|
|
60
|
+
if (!inputPath) {
|
|
61
|
+
throw new Error('Path cannot be empty');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (inputPath.length > this.maxPathLength) {
|
|
65
|
+
throw new Error(`Path length exceeds maximum of ${this.maxPathLength} characters`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check for invalid characters (Windows + Unix)
|
|
69
|
+
if (/[<>:"|?*\x00-\x1f]/.test(inputPath)) {
|
|
70
|
+
throw new Error(`Path contains invalid characters: ${inputPath}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async ensureDirectory(dirPath) {
|
|
77
|
+
try {
|
|
78
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error.code !== 'EEXIST') {
|
|
81
|
+
throw new Error(`Failed to create directory '${dirPath}': ${error.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// CONNECTION AND VALIDATION
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
async testConnection() {
|
|
91
|
+
try {
|
|
92
|
+
const stats = await fs.stat(this.localRoot);
|
|
93
|
+
if (!stats.isDirectory()) {
|
|
94
|
+
throw new Error(`Local root '${this.localRoot}' is not a directory`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const testFile = path.join(this.localRoot, '.local-provider-test');
|
|
98
|
+
await fs.writeFile(testFile, 'test');
|
|
99
|
+
await fs.unlink(testFile);
|
|
100
|
+
|
|
101
|
+
const totalSize = await this.getDirectorySize(this.localRoot);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
provider: 'local',
|
|
105
|
+
localRoot: this.localRoot,
|
|
106
|
+
accessible: true,
|
|
107
|
+
writable: true,
|
|
108
|
+
totalSize: totalSize,
|
|
109
|
+
freeSpace: await this.getFreeSpace()
|
|
110
|
+
};
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error(`Local provider connection test failed: ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
validateConfig() {
|
|
117
|
+
const errors = [];
|
|
118
|
+
const warnings = [];
|
|
119
|
+
|
|
120
|
+
if (!this.localRoot) {
|
|
121
|
+
errors.push('Local root directory is required');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.maxFileSize <= 0) {
|
|
125
|
+
errors.push('Max file size must be positive');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (this.chunkSize <= 0) {
|
|
129
|
+
errors.push('Chunk size must be positive');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.chunkSize > this.maxFileSize) {
|
|
133
|
+
warnings.push('Chunk size is larger than max file size');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this.allowSymlinks) {
|
|
137
|
+
warnings.push('Allowing symlinks may pose security risks');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
isValid: errors.length === 0,
|
|
142
|
+
errors,
|
|
143
|
+
warnings
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// FILE OPERATIONS (CRUD)
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
async uploadFile(sourcePath, targetPath) {
|
|
152
|
+
this.validatePath(sourcePath);
|
|
153
|
+
this.validatePath(targetPath);
|
|
154
|
+
|
|
155
|
+
const resolvedSourcePath = this.normalizePath(sourcePath);
|
|
156
|
+
const resolvedTargetPath = this.normalizePath(targetPath);
|
|
157
|
+
|
|
158
|
+
this.emit('progress', { operation: 'upload', source: sourcePath, target: targetPath });
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const sourceStats = await fs.stat(resolvedSourcePath);
|
|
162
|
+
if (!sourceStats.isFile()) {
|
|
163
|
+
throw new Error(`Source '${sourcePath}' is not a file`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (sourceStats.size > this.maxFileSize) {
|
|
167
|
+
throw new Error(`File size ${this.formatBytes(sourceStats.size)} exceeds maximum of ${this.formatBytes(this.maxFileSize)}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const targetDir = path.dirname(resolvedTargetPath);
|
|
171
|
+
await this.ensureDirectory(targetDir);
|
|
172
|
+
|
|
173
|
+
await fs.copyFile(resolvedSourcePath, resolvedTargetPath);
|
|
174
|
+
|
|
175
|
+
const targetStats = await fs.stat(resolvedTargetPath);
|
|
176
|
+
|
|
177
|
+
this.emit('progress', { operation: 'upload', status: 'complete', target: targetPath });
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
name: path.basename(targetPath),
|
|
181
|
+
path: targetPath,
|
|
182
|
+
size: targetStats.size,
|
|
183
|
+
modifiedTime: targetStats.mtime.toISOString(),
|
|
184
|
+
hash: await this.calculateChecksum(resolvedTargetPath)
|
|
185
|
+
};
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (this.isFileNotFoundError(error)) {
|
|
188
|
+
throw new Error(`Source file not found: ${sourcePath}`);
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`Upload failed: ${error.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async downloadFile(sourcePath, targetPath) {
|
|
195
|
+
this.validatePath(sourcePath);
|
|
196
|
+
this.validatePath(targetPath);
|
|
197
|
+
|
|
198
|
+
const resolvedSourcePath = this.normalizePath(sourcePath);
|
|
199
|
+
const resolvedTargetPath = this.normalizePath(targetPath);
|
|
200
|
+
|
|
201
|
+
this.emit('progress', { operation: 'download', source: sourcePath, target: targetPath });
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const sourceStats = await fs.stat(resolvedSourcePath);
|
|
205
|
+
if (!sourceStats.isFile()) {
|
|
206
|
+
throw new Error(`Source '${sourcePath}' is not a file`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const targetDir = path.dirname(resolvedTargetPath);
|
|
210
|
+
await this.ensureDirectory(targetDir);
|
|
211
|
+
|
|
212
|
+
await fs.copyFile(resolvedSourcePath, resolvedTargetPath);
|
|
213
|
+
|
|
214
|
+
this.emit('progress', { operation: 'download', status: 'complete', target: targetPath });
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
path: targetPath,
|
|
218
|
+
size: sourceStats.size,
|
|
219
|
+
sourcePath: sourcePath
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (this.isFileNotFoundError(error)) {
|
|
223
|
+
throw new Error(`Source file not found: ${sourcePath}`);
|
|
224
|
+
}
|
|
225
|
+
throw new Error(`Download failed: ${error.message}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async getFile(filePath) {
|
|
230
|
+
this.validatePath(filePath);
|
|
231
|
+
const resolvedPath = this.normalizePath(filePath);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const stats = await fs.stat(resolvedPath);
|
|
235
|
+
if (!stats.isFile()) {
|
|
236
|
+
throw new Error(`Path '${filePath}' is not a file`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
name: path.basename(filePath),
|
|
241
|
+
path: filePath,
|
|
242
|
+
size: stats.size,
|
|
243
|
+
modifiedTime: stats.mtime.toISOString(),
|
|
244
|
+
createdTime: stats.birthtime.toISOString(),
|
|
245
|
+
isDirectory: false,
|
|
246
|
+
isFile: true,
|
|
247
|
+
hash: await this.calculateChecksum(resolvedPath),
|
|
248
|
+
permissions: stats.mode
|
|
249
|
+
};
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (this.isFileNotFoundError(error)) {
|
|
252
|
+
throw new Error(`File not found: ${filePath}`);
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async listFiles(directoryPath = '/', options = {}) {
|
|
259
|
+
this.validatePath(directoryPath);
|
|
260
|
+
const resolvedPath = this.normalizePath(directoryPath);
|
|
261
|
+
|
|
262
|
+
const {
|
|
263
|
+
recursive = false,
|
|
264
|
+
includeHidden = false,
|
|
265
|
+
fileTypesOnly = true
|
|
266
|
+
} = options;
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const stats = await fs.stat(resolvedPath);
|
|
270
|
+
if (!stats.isDirectory()) {
|
|
271
|
+
throw new Error(`Path '${directoryPath}' is not a directory`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const files = [];
|
|
275
|
+
await this.collectFiles(resolvedPath, directoryPath, files, recursive, includeHidden, fileTypesOnly);
|
|
276
|
+
|
|
277
|
+
return files;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (this.isFileNotFoundError(error)) {
|
|
280
|
+
throw new Error(`Directory not found: ${directoryPath}`);
|
|
281
|
+
}
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async collectFiles(resolvedPath, relativePath, files, recursive, includeHidden, fileTypesOnly) {
|
|
287
|
+
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
288
|
+
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
if (!includeHidden && entry.name.startsWith('.')) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const fullPath = path.join(resolvedPath, entry.name);
|
|
295
|
+
const relativeFilePath = path.join(relativePath, entry.name);
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const stats = await fs.stat(fullPath);
|
|
299
|
+
|
|
300
|
+
if (entry.isFile() || (!fileTypesOnly && !entry.isDirectory())) {
|
|
301
|
+
files.push({
|
|
302
|
+
name: entry.name,
|
|
303
|
+
path: relativeFilePath.replace(/\\/g, '/'),
|
|
304
|
+
size: stats.size,
|
|
305
|
+
modifiedTime: stats.mtime.toISOString(),
|
|
306
|
+
createdTime: stats.birthtime.toISOString(),
|
|
307
|
+
isDirectory: false,
|
|
308
|
+
isFile: true,
|
|
309
|
+
permissions: stats.mode
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (recursive && entry.isDirectory()) {
|
|
314
|
+
await this.collectFiles(fullPath, relativeFilePath, files, recursive, includeHidden, fileTypesOnly);
|
|
315
|
+
}
|
|
316
|
+
} catch (error) {
|
|
317
|
+
// Skip files we can't read
|
|
318
|
+
this.emit('warning', { message: `Could not read ${relativeFilePath}: ${error.message}` });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async deleteFile(filePath) {
|
|
324
|
+
this.validatePath(filePath);
|
|
325
|
+
const resolvedPath = this.normalizePath(filePath);
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const stats = await fs.stat(resolvedPath);
|
|
329
|
+
if (!stats.isFile()) {
|
|
330
|
+
throw new Error(`Path '${filePath}' is not a file`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await fs.unlink(resolvedPath);
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
deleted: true,
|
|
337
|
+
path: filePath
|
|
338
|
+
};
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (this.isFileNotFoundError(error)) {
|
|
341
|
+
return {
|
|
342
|
+
deleted: true,
|
|
343
|
+
path: filePath
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
throw new Error(`Delete failed: ${error.message}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async renameFile(oldPath, newName) {
|
|
351
|
+
this.validatePath(oldPath);
|
|
352
|
+
this.validatePath(newName);
|
|
353
|
+
|
|
354
|
+
const resolvedOldPath = this.normalizePath(oldPath);
|
|
355
|
+
const directory = path.dirname(resolvedOldPath);
|
|
356
|
+
const resolvedNewPath = path.join(directory, newName);
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const stats = await fs.stat(resolvedOldPath);
|
|
360
|
+
if (!stats.isFile()) {
|
|
361
|
+
throw new Error(`Path '${oldPath}' is not a file`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await fs.rename(resolvedOldPath, resolvedNewPath);
|
|
365
|
+
|
|
366
|
+
const newRelativePath = path.join(path.dirname(oldPath), newName);
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
name: newName,
|
|
370
|
+
oldPath: oldPath,
|
|
371
|
+
newPath: newRelativePath
|
|
372
|
+
};
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (this.isFileNotFoundError(error)) {
|
|
375
|
+
throw new Error(`File not found: ${oldPath}`);
|
|
376
|
+
}
|
|
377
|
+
throw new Error(`Rename failed: ${error.message}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async copyFile(sourcePath, targetPath) {
|
|
382
|
+
this.validatePath(sourcePath);
|
|
383
|
+
this.validatePath(targetPath);
|
|
384
|
+
|
|
385
|
+
const resolvedSourcePath = this.normalizePath(sourcePath);
|
|
386
|
+
const resolvedTargetPath = this.normalizePath(targetPath);
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const stats = await fs.stat(resolvedSourcePath);
|
|
390
|
+
if (!stats.isFile()) {
|
|
391
|
+
throw new Error(`Source '${sourcePath}' is not a file`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const targetDir = path.dirname(resolvedTargetPath);
|
|
395
|
+
await this.ensureDirectory(targetDir);
|
|
396
|
+
|
|
397
|
+
await fs.copyFile(resolvedSourcePath, resolvedTargetPath);
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
name: path.basename(targetPath),
|
|
401
|
+
sourcePath: sourcePath,
|
|
402
|
+
targetPath: targetPath,
|
|
403
|
+
size: stats.size
|
|
404
|
+
};
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (this.isFileNotFoundError(error)) {
|
|
407
|
+
throw new Error(`Source file not found: ${sourcePath}`);
|
|
408
|
+
}
|
|
409
|
+
throw new Error(`Copy failed: ${error.message}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async moveFile(sourcePath, targetPath) {
|
|
414
|
+
this.validatePath(sourcePath);
|
|
415
|
+
this.validatePath(targetPath);
|
|
416
|
+
|
|
417
|
+
const resolvedSourcePath = this.normalizePath(sourcePath);
|
|
418
|
+
const resolvedTargetPath = this.normalizePath(targetPath);
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const stats = await fs.stat(resolvedSourcePath);
|
|
422
|
+
if (!stats.isFile()) {
|
|
423
|
+
throw new Error(`Source '${sourcePath}' is not a file`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const targetDir = path.dirname(resolvedTargetPath);
|
|
427
|
+
await this.ensureDirectory(targetDir);
|
|
428
|
+
|
|
429
|
+
await fs.rename(resolvedSourcePath, resolvedTargetPath);
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
name: path.basename(targetPath),
|
|
433
|
+
oldPath: sourcePath,
|
|
434
|
+
newPath: targetPath,
|
|
435
|
+
size: stats.size
|
|
436
|
+
};
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (this.isFileNotFoundError(error)) {
|
|
439
|
+
throw new Error(`Source file not found: ${sourcePath}`);
|
|
440
|
+
}
|
|
441
|
+
throw new Error(`Move failed: ${error.message}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ============================================================================
|
|
446
|
+
// FOLDER OPERATIONS (CRUD)
|
|
447
|
+
// ============================================================================
|
|
448
|
+
|
|
449
|
+
async createFolder(folderPath) {
|
|
450
|
+
this.validatePath(folderPath);
|
|
451
|
+
const resolvedPath = this.normalizePath(folderPath);
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
await fs.mkdir(resolvedPath, { recursive: true });
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
name: path.basename(folderPath),
|
|
458
|
+
path: folderPath,
|
|
459
|
+
created: true
|
|
460
|
+
};
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (error.code === 'EEXIST') {
|
|
463
|
+
return {
|
|
464
|
+
name: path.basename(folderPath),
|
|
465
|
+
path: folderPath,
|
|
466
|
+
created: false,
|
|
467
|
+
message: 'Folder already exists'
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
throw new Error(`Create folder failed: ${error.message}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async getFolder(folderPath) {
|
|
475
|
+
this.validatePath(folderPath);
|
|
476
|
+
const resolvedPath = this.normalizePath(folderPath);
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const stats = await fs.stat(resolvedPath);
|
|
480
|
+
if (!stats.isDirectory()) {
|
|
481
|
+
throw new Error(`Path '${folderPath}' is not a directory`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const entries = await fs.readdir(resolvedPath);
|
|
485
|
+
const itemCount = entries.length;
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
name: path.basename(folderPath) || 'Root',
|
|
489
|
+
path: folderPath,
|
|
490
|
+
itemCount: itemCount,
|
|
491
|
+
modifiedTime: stats.mtime.toISOString(),
|
|
492
|
+
createdTime: stats.birthtime.toISOString(),
|
|
493
|
+
isDirectory: true,
|
|
494
|
+
isFile: false,
|
|
495
|
+
permissions: stats.mode
|
|
496
|
+
};
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (this.isFileNotFoundError(error)) {
|
|
499
|
+
throw new Error(`Folder not found: ${folderPath}`);
|
|
500
|
+
}
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async listFolders(directoryPath = '/') {
|
|
506
|
+
this.validatePath(directoryPath);
|
|
507
|
+
const resolvedPath = this.normalizePath(directoryPath);
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
const stats = await fs.stat(resolvedPath);
|
|
511
|
+
if (!stats.isDirectory()) {
|
|
512
|
+
throw new Error(`Path '${directoryPath}' is not a directory`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
516
|
+
const folders = [];
|
|
517
|
+
|
|
518
|
+
for (const entry of entries) {
|
|
519
|
+
if (entry.isDirectory()) {
|
|
520
|
+
try {
|
|
521
|
+
const fullPath = path.join(resolvedPath, entry.name);
|
|
522
|
+
const folderStats = await fs.stat(fullPath);
|
|
523
|
+
const subEntries = await fs.readdir(fullPath);
|
|
524
|
+
|
|
525
|
+
folders.push({
|
|
526
|
+
name: entry.name,
|
|
527
|
+
path: path.join(directoryPath, entry.name).replace(/\\/g, '/'),
|
|
528
|
+
itemCount: subEntries.length,
|
|
529
|
+
modifiedTime: folderStats.mtime.toISOString(),
|
|
530
|
+
createdTime: folderStats.birthtime.toISOString(),
|
|
531
|
+
isDirectory: true,
|
|
532
|
+
isFile: false,
|
|
533
|
+
permissions: folderStats.mode
|
|
534
|
+
});
|
|
535
|
+
} catch (error) {
|
|
536
|
+
this.emit('warning', { message: `Could not read folder ${entry.name}: ${error.message}` });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return folders;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (this.isFileNotFoundError(error)) {
|
|
544
|
+
throw new Error(`Directory not found: ${directoryPath}`);
|
|
545
|
+
}
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async deleteFolder(folderPath, recursive = false) {
|
|
551
|
+
this.validatePath(folderPath);
|
|
552
|
+
const resolvedPath = this.normalizePath(folderPath);
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
const stats = await fs.stat(resolvedPath);
|
|
556
|
+
if (!stats.isDirectory()) {
|
|
557
|
+
throw new Error(`Path '${folderPath}' is not a directory`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (recursive) {
|
|
561
|
+
await fs.rm(resolvedPath, { recursive: true, force: true });
|
|
562
|
+
} else {
|
|
563
|
+
await fs.rmdir(resolvedPath);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
deleted: true,
|
|
568
|
+
path: folderPath,
|
|
569
|
+
recursive: recursive
|
|
570
|
+
};
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (this.isFileNotFoundError(error)) {
|
|
573
|
+
return {
|
|
574
|
+
deleted: true,
|
|
575
|
+
path: folderPath,
|
|
576
|
+
recursive: recursive
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
throw new Error(`Delete folder failed: ${error.message}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async renameFolder(oldPath, newName) {
|
|
584
|
+
this.validatePath(oldPath);
|
|
585
|
+
this.validatePath(newName);
|
|
586
|
+
|
|
587
|
+
const resolvedOldPath = this.normalizePath(oldPath);
|
|
588
|
+
const directory = path.dirname(resolvedOldPath);
|
|
589
|
+
const resolvedNewPath = path.join(directory, newName);
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
const stats = await fs.stat(resolvedOldPath);
|
|
593
|
+
if (!stats.isDirectory()) {
|
|
594
|
+
throw new Error(`Path '${oldPath}' is not a directory`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
await fs.rename(resolvedOldPath, resolvedNewPath);
|
|
598
|
+
|
|
599
|
+
const newRelativePath = path.join(path.dirname(oldPath), newName);
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
name: newName,
|
|
603
|
+
oldPath: oldPath,
|
|
604
|
+
newPath: newRelativePath
|
|
605
|
+
};
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (this.isFileNotFoundError(error)) {
|
|
608
|
+
throw new Error(`Folder not found: ${oldPath}`);
|
|
609
|
+
}
|
|
610
|
+
throw new Error(`Rename folder failed: ${error.message}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async copyFolder(sourcePath, targetPath) {
|
|
615
|
+
this.validatePath(sourcePath);
|
|
616
|
+
this.validatePath(targetPath);
|
|
617
|
+
|
|
618
|
+
const resolvedSourcePath = this.normalizePath(sourcePath);
|
|
619
|
+
const resolvedTargetPath = this.normalizePath(targetPath);
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
const stats = await fs.stat(resolvedSourcePath);
|
|
623
|
+
if (!stats.isDirectory()) {
|
|
624
|
+
throw new Error(`Source '${sourcePath}' is not a directory`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
await fs.mkdir(resolvedTargetPath, { recursive: true });
|
|
628
|
+
|
|
629
|
+
await this.copyFolderRecursive(resolvedSourcePath, resolvedTargetPath);
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
name: path.basename(targetPath),
|
|
633
|
+
sourcePath: sourcePath,
|
|
634
|
+
targetPath: targetPath
|
|
635
|
+
};
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (this.isFileNotFoundError(error)) {
|
|
638
|
+
throw new Error(`Source folder not found: ${sourcePath}`);
|
|
639
|
+
}
|
|
640
|
+
throw new Error(`Copy folder failed: ${error.message}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async copyFolderRecursive(sourceDir, targetDir) {
|
|
645
|
+
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
646
|
+
|
|
647
|
+
for (const entry of entries) {
|
|
648
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
649
|
+
const targetPath = path.join(targetDir, entry.name);
|
|
650
|
+
|
|
651
|
+
if (entry.isDirectory()) {
|
|
652
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
653
|
+
await this.copyFolderRecursive(sourcePath, targetPath);
|
|
654
|
+
} else {
|
|
655
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async moveFolder(sourcePath, targetPath) {
|
|
661
|
+
this.validatePath(sourcePath);
|
|
662
|
+
this.validatePath(targetPath);
|
|
663
|
+
|
|
664
|
+
const resolvedSourcePath = this.normalizePath(sourcePath);
|
|
665
|
+
const resolvedTargetPath = this.normalizePath(targetPath);
|
|
666
|
+
|
|
667
|
+
try {
|
|
668
|
+
const stats = await fs.stat(resolvedSourcePath);
|
|
669
|
+
if (!stats.isDirectory()) {
|
|
670
|
+
throw new Error(`Source '${sourcePath}' is not a directory`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const targetParent = path.dirname(resolvedTargetPath);
|
|
674
|
+
await this.ensureDirectory(targetParent);
|
|
675
|
+
|
|
676
|
+
await fs.rename(resolvedSourcePath, resolvedTargetPath);
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
name: path.basename(targetPath),
|
|
680
|
+
oldPath: sourcePath,
|
|
681
|
+
newPath: targetPath
|
|
682
|
+
};
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (this.isFileNotFoundError(error)) {
|
|
685
|
+
throw new Error(`Source folder not found: ${sourcePath}`);
|
|
686
|
+
}
|
|
687
|
+
throw new Error(`Move folder failed: ${error.message}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ============================================================================
|
|
692
|
+
// SEARCH OPERATIONS
|
|
693
|
+
// ============================================================================
|
|
694
|
+
|
|
695
|
+
async searchFiles(query, options = {}) {
|
|
696
|
+
const {
|
|
697
|
+
directory = '/',
|
|
698
|
+
recursive = true,
|
|
699
|
+
caseSensitive = false,
|
|
700
|
+
fileTypesOnly = true,
|
|
701
|
+
limit = 100
|
|
702
|
+
} = options;
|
|
703
|
+
|
|
704
|
+
this.validatePath(directory);
|
|
705
|
+
const resolvedDir = this.normalizePath(directory);
|
|
706
|
+
|
|
707
|
+
try {
|
|
708
|
+
const results = [];
|
|
709
|
+
await this.searchInDirectory(resolvedDir, directory, query, results, recursive, caseSensitive, fileTypesOnly);
|
|
710
|
+
|
|
711
|
+
return results.slice(0, limit);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (this.isFileNotFoundError(error)) {
|
|
714
|
+
throw new Error(`Search directory not found: ${directory}`);
|
|
715
|
+
}
|
|
716
|
+
throw new Error(`Search failed: ${error.message}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async searchInDirectory(resolvedDir, relativeDir, query, results, recursive, caseSensitive, fileTypesOnly) {
|
|
721
|
+
const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
|
|
722
|
+
const searchQuery = caseSensitive ? query : query.toLowerCase();
|
|
723
|
+
|
|
724
|
+
for (const entry of entries) {
|
|
725
|
+
const fullPath = path.join(resolvedDir, entry.name);
|
|
726
|
+
const relativePath = path.join(relativeDir, entry.name);
|
|
727
|
+
const fileName = caseSensitive ? entry.name : entry.name.toLowerCase();
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
if (fileName.includes(searchQuery)) {
|
|
731
|
+
const stats = await fs.stat(fullPath);
|
|
732
|
+
|
|
733
|
+
if (entry.isFile() || (!fileTypesOnly && !entry.isDirectory())) {
|
|
734
|
+
results.push({
|
|
735
|
+
name: entry.name,
|
|
736
|
+
path: relativePath.replace(/\\/g, '/'),
|
|
737
|
+
size: stats.size,
|
|
738
|
+
modifiedTime: stats.mtime.toISOString(),
|
|
739
|
+
isDirectory: entry.isDirectory(),
|
|
740
|
+
isFile: entry.isFile()
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (recursive && entry.isDirectory()) {
|
|
746
|
+
await this.searchInDirectory(fullPath, relativePath, query, results, recursive, caseSensitive, fileTypesOnly);
|
|
747
|
+
}
|
|
748
|
+
} catch (error) {
|
|
749
|
+
this.emit('warning', { message: `Could not search in ${relativePath}: ${error.message}` });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ============================================================================
|
|
755
|
+
// UTILITY METHODS
|
|
756
|
+
// ============================================================================
|
|
757
|
+
|
|
758
|
+
async calculateChecksum(filePath, algorithm = 'sha256') {
|
|
759
|
+
return new Promise((resolve, reject) => {
|
|
760
|
+
const hash = crypto.createHash(algorithm);
|
|
761
|
+
const stream = fsSync.createReadStream(filePath);
|
|
762
|
+
|
|
763
|
+
stream.on('error', reject);
|
|
764
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
765
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async getDirectorySize(dirPath) {
|
|
770
|
+
let totalSize = 0;
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
const files = await this.listFiles(path.relative(this.localRoot, dirPath) || '/', { recursive: true });
|
|
774
|
+
totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0);
|
|
775
|
+
} catch (error) {
|
|
776
|
+
totalSize = 0;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return totalSize;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async getFreeSpace() {
|
|
783
|
+
try {
|
|
784
|
+
if (fs.statfs) {
|
|
785
|
+
const stats = await fs.statfs(this.localRoot);
|
|
786
|
+
return stats.bavail * stats.bsize;
|
|
787
|
+
}
|
|
788
|
+
} catch (error) {
|
|
789
|
+
// Fallback
|
|
790
|
+
}
|
|
791
|
+
return Number.MAX_SAFE_INTEGER;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
formatBytes(bytes) {
|
|
795
|
+
if (bytes === 0) return '0 Bytes';
|
|
796
|
+
const k = 1024;
|
|
797
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
798
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
799
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ============================================================================
|
|
803
|
+
// ERROR HANDLING HELPERS
|
|
804
|
+
// ============================================================================
|
|
805
|
+
|
|
806
|
+
isFileNotFoundError(error) {
|
|
807
|
+
return error.code === 'ENOENT' ||
|
|
808
|
+
error.message.includes('no such file') ||
|
|
809
|
+
error.message.includes('not found');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
isPermissionError(error) {
|
|
813
|
+
return error.code === 'EACCES' ||
|
|
814
|
+
error.code === 'EPERM' ||
|
|
815
|
+
error.message.includes('permission denied');
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
isDirectoryNotEmptyError(error) {
|
|
819
|
+
return error.code === 'ENOTEMPTY' ||
|
|
820
|
+
error.message.includes('directory not empty');
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export { LocalProvider };
|