@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,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RemoteProvider - SSH/SFTP-based remote file operations
|
|
3
|
+
*
|
|
4
|
+
* Mirrors LocalProvider API for seamless local/remote operations
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EventEmitter } from 'events';
|
|
8
|
+
import { Client } from 'ssh2';
|
|
9
|
+
import SftpClient from 'ssh2-sftp-client';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { promises as fs } from 'fs';
|
|
12
|
+
|
|
13
|
+
class RemoteProvider extends EventEmitter {
|
|
14
|
+
constructor(config) {
|
|
15
|
+
super();
|
|
16
|
+
this.config = config || {};
|
|
17
|
+
|
|
18
|
+
// Connection settings
|
|
19
|
+
this.host = this.config.host;
|
|
20
|
+
this.port = this.config.port || 22;
|
|
21
|
+
// Backfill config.port so external access sees the default
|
|
22
|
+
this.config.port = this.port;
|
|
23
|
+
this.username = this.config.username;
|
|
24
|
+
this.password = this.config.password;
|
|
25
|
+
this.privateKey = this.config.privateKey;
|
|
26
|
+
this.privateKeyPath = this.config.privateKeyPath;
|
|
27
|
+
this.passphrase = this.config.passphrase;
|
|
28
|
+
this.remoteRoot = this.config.remoteRoot || '/home';
|
|
29
|
+
|
|
30
|
+
// Jump host / bastion support
|
|
31
|
+
this.bastionHost = this.config.bastionHost;
|
|
32
|
+
this.bastionPort = this.config.bastionPort || 22;
|
|
33
|
+
this.bastionUsername = this.config.bastionUsername;
|
|
34
|
+
this.bastionPrivateKey = this.config.bastionPrivateKey;
|
|
35
|
+
|
|
36
|
+
// State
|
|
37
|
+
this.sftp = null;
|
|
38
|
+
this.sshClient = null;
|
|
39
|
+
this.isConnected = false;
|
|
40
|
+
|
|
41
|
+
// Settings
|
|
42
|
+
this.maxFileSize = this.config.maxFileSize || 1073741824; // 1GB
|
|
43
|
+
this.timeout = this.config.timeout || 30000;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// CONNECTION MANAGEMENT
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
async connect() {
|
|
51
|
+
if (this.isConnected) {
|
|
52
|
+
return { status: 'already_connected' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Load private key if path provided
|
|
57
|
+
let privateKey = this.privateKey;
|
|
58
|
+
if (!privateKey && this.privateKeyPath) {
|
|
59
|
+
const keyPath = this.privateKeyPath.replace('~', process.env.HOME);
|
|
60
|
+
privateKey = await fs.readFile(keyPath, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build connection config
|
|
64
|
+
const connectionConfig = {
|
|
65
|
+
host: this.host,
|
|
66
|
+
port: this.port,
|
|
67
|
+
username: this.username,
|
|
68
|
+
readyTimeout: this.timeout,
|
|
69
|
+
...(privateKey && { privateKey }),
|
|
70
|
+
...(this.password && { password: this.password }),
|
|
71
|
+
...(this.passphrase && { passphrase: this.passphrase })
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Connect via SFTP client
|
|
75
|
+
this.sftp = new SftpClient();
|
|
76
|
+
|
|
77
|
+
if (this.bastionHost) {
|
|
78
|
+
await this._connectViaBastionHost(connectionConfig, privateKey);
|
|
79
|
+
} else {
|
|
80
|
+
await this.sftp.connect(connectionConfig);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.isConnected = true;
|
|
84
|
+
this.emit('connected', { host: this.host });
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
status: 'connected',
|
|
88
|
+
host: this.host,
|
|
89
|
+
username: this.username
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.emit('error', { type: 'connection', error: error.message });
|
|
93
|
+
throw new Error(`Failed to connect to ${this.host}: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async _connectViaBastionHost(targetConfig, privateKey) {
|
|
98
|
+
const bastionClient = new Client();
|
|
99
|
+
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
bastionClient.on('ready', () => {
|
|
102
|
+
bastionClient.forwardOut(
|
|
103
|
+
'127.0.0.1', 0,
|
|
104
|
+
this.host, this.port,
|
|
105
|
+
async (err, stream) => {
|
|
106
|
+
if (err) return reject(err);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await this.sftp.connect({
|
|
110
|
+
...targetConfig,
|
|
111
|
+
sock: stream
|
|
112
|
+
});
|
|
113
|
+
resolve();
|
|
114
|
+
} catch (e) {
|
|
115
|
+
reject(e);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
bastionClient.on('error', reject);
|
|
122
|
+
|
|
123
|
+
bastionClient.connect({
|
|
124
|
+
host: this.bastionHost,
|
|
125
|
+
port: this.bastionPort,
|
|
126
|
+
username: this.bastionUsername,
|
|
127
|
+
privateKey: this.bastionPrivateKey || privateKey
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async disconnect() {
|
|
133
|
+
if (this.sftp) {
|
|
134
|
+
await this.sftp.end();
|
|
135
|
+
this.sftp = null;
|
|
136
|
+
}
|
|
137
|
+
this.isConnected = false;
|
|
138
|
+
this.emit('disconnected');
|
|
139
|
+
return { status: 'disconnected' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async testConnection() {
|
|
143
|
+
try {
|
|
144
|
+
await this.connect();
|
|
145
|
+
const cwd = await this.sftp.cwd();
|
|
146
|
+
await this.disconnect();
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
provider: 'remote',
|
|
150
|
+
host: this.host,
|
|
151
|
+
username: this.username,
|
|
152
|
+
accessible: true,
|
|
153
|
+
currentDirectory: cwd
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return {
|
|
157
|
+
provider: 'remote',
|
|
158
|
+
host: this.host,
|
|
159
|
+
accessible: false,
|
|
160
|
+
error: error.message
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// FILE OPERATIONS (mirrors LocalProvider)
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
async uploadFile(localPath, remotePath) {
|
|
170
|
+
await this._ensureConnected();
|
|
171
|
+
|
|
172
|
+
const resolvedRemotePath = this._normalizePath(remotePath);
|
|
173
|
+
|
|
174
|
+
// Ensure remote directory exists
|
|
175
|
+
const remoteDir = path.dirname(resolvedRemotePath);
|
|
176
|
+
await this._ensureRemoteDirectory(remoteDir);
|
|
177
|
+
|
|
178
|
+
// Upload file
|
|
179
|
+
await this.sftp.put(localPath, resolvedRemotePath);
|
|
180
|
+
|
|
181
|
+
// Get uploaded file info
|
|
182
|
+
const stat = await this.sftp.stat(resolvedRemotePath);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
name: path.basename(remotePath),
|
|
186
|
+
path: resolvedRemotePath,
|
|
187
|
+
size: stat.size,
|
|
188
|
+
modifiedTime: new Date(stat.modifyTime * 1000).toISOString()
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async downloadFile(remotePath, localPath) {
|
|
193
|
+
await this._ensureConnected();
|
|
194
|
+
|
|
195
|
+
const resolvedRemotePath = this._normalizePath(remotePath);
|
|
196
|
+
|
|
197
|
+
// Ensure local directory exists
|
|
198
|
+
const localDir = path.dirname(localPath);
|
|
199
|
+
await fs.mkdir(localDir, { recursive: true });
|
|
200
|
+
|
|
201
|
+
// Download file
|
|
202
|
+
await this.sftp.get(resolvedRemotePath, localPath);
|
|
203
|
+
|
|
204
|
+
// Get local file info
|
|
205
|
+
const stat = await fs.stat(localPath);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
name: path.basename(localPath),
|
|
209
|
+
path: localPath,
|
|
210
|
+
size: stat.size,
|
|
211
|
+
modifiedTime: stat.mtime.toISOString()
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async getFile(remotePath) {
|
|
216
|
+
await this._ensureConnected();
|
|
217
|
+
|
|
218
|
+
const resolvedPath = this._normalizePath(remotePath);
|
|
219
|
+
const stat = await this.sftp.stat(resolvedPath);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
name: path.basename(remotePath),
|
|
223
|
+
path: resolvedPath,
|
|
224
|
+
size: stat.size,
|
|
225
|
+
isFile: stat.isFile,
|
|
226
|
+
isDirectory: stat.isDirectory,
|
|
227
|
+
modifiedTime: new Date(stat.modifyTime * 1000).toISOString(),
|
|
228
|
+
accessTime: new Date(stat.accessTime * 1000).toISOString(),
|
|
229
|
+
permissions: stat.mode
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async listFiles(directoryPath, options = {}) {
|
|
234
|
+
await this._ensureConnected();
|
|
235
|
+
|
|
236
|
+
const resolvedPath = this._normalizePath(directoryPath);
|
|
237
|
+
const listing = await this.sftp.list(resolvedPath);
|
|
238
|
+
|
|
239
|
+
const files = listing
|
|
240
|
+
.filter(item => item.type === '-')
|
|
241
|
+
.map(item => ({
|
|
242
|
+
name: item.name,
|
|
243
|
+
path: path.join(resolvedPath, item.name),
|
|
244
|
+
relativePath: item.name,
|
|
245
|
+
size: item.size,
|
|
246
|
+
modifiedTime: new Date(item.modifyTime).toISOString(),
|
|
247
|
+
permissions: item.rights
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
if (options.recursive) {
|
|
251
|
+
const dirs = listing.filter(item => item.type === 'd' && item.name !== '.' && item.name !== '..');
|
|
252
|
+
for (const dir of dirs) {
|
|
253
|
+
const subPath = path.join(directoryPath, dir.name);
|
|
254
|
+
const subFiles = await this.listFiles(subPath, options);
|
|
255
|
+
files.push(...subFiles.map(f => ({
|
|
256
|
+
...f,
|
|
257
|
+
relativePath: path.join(dir.name, f.relativePath)
|
|
258
|
+
})));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return files;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async deleteFile(remotePath) {
|
|
266
|
+
await this._ensureConnected();
|
|
267
|
+
|
|
268
|
+
const resolvedPath = this._normalizePath(remotePath);
|
|
269
|
+
await this.sftp.delete(resolvedPath);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
deleted: true,
|
|
273
|
+
path: resolvedPath
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async renameFile(oldPath, newName) {
|
|
278
|
+
await this._ensureConnected();
|
|
279
|
+
|
|
280
|
+
const resolvedOldPath = this._normalizePath(oldPath);
|
|
281
|
+
const newPath = path.join(path.dirname(resolvedOldPath), newName);
|
|
282
|
+
|
|
283
|
+
await this.sftp.rename(resolvedOldPath, newPath);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
oldPath: resolvedOldPath,
|
|
287
|
+
newPath: newPath,
|
|
288
|
+
name: newName
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async copyFile(sourcePath, targetPath) {
|
|
293
|
+
await this._ensureConnected();
|
|
294
|
+
|
|
295
|
+
const resolvedSource = this._normalizePath(sourcePath);
|
|
296
|
+
const resolvedTarget = this._normalizePath(targetPath);
|
|
297
|
+
|
|
298
|
+
// SFTP doesn't have native copy - download then re-upload
|
|
299
|
+
const tempPath = `/tmp/kadi-copy-${Date.now()}`;
|
|
300
|
+
await this.sftp.get(resolvedSource, tempPath);
|
|
301
|
+
await this._ensureRemoteDirectory(path.dirname(resolvedTarget));
|
|
302
|
+
await this.sftp.put(tempPath, resolvedTarget);
|
|
303
|
+
await fs.unlink(tempPath);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
source: resolvedSource,
|
|
307
|
+
target: resolvedTarget,
|
|
308
|
+
success: true
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async moveFile(sourcePath, targetPath) {
|
|
313
|
+
await this._ensureConnected();
|
|
314
|
+
|
|
315
|
+
const resolvedSource = this._normalizePath(sourcePath);
|
|
316
|
+
const resolvedTarget = this._normalizePath(targetPath);
|
|
317
|
+
|
|
318
|
+
await this._ensureRemoteDirectory(path.dirname(resolvedTarget));
|
|
319
|
+
await this.sftp.rename(resolvedSource, resolvedTarget);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
source: resolvedSource,
|
|
323
|
+
target: resolvedTarget,
|
|
324
|
+
success: true
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// FOLDER OPERATIONS
|
|
330
|
+
// ============================================================================
|
|
331
|
+
|
|
332
|
+
async createFolder(remotePath) {
|
|
333
|
+
await this._ensureConnected();
|
|
334
|
+
|
|
335
|
+
const resolvedPath = this._normalizePath(remotePath);
|
|
336
|
+
await this.sftp.mkdir(resolvedPath, true); // recursive
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
path: resolvedPath,
|
|
340
|
+
created: true
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async deleteFolder(remotePath, recursive = false) {
|
|
345
|
+
await this._ensureConnected();
|
|
346
|
+
|
|
347
|
+
const resolvedPath = this._normalizePath(remotePath);
|
|
348
|
+
|
|
349
|
+
if (recursive) {
|
|
350
|
+
await this.sftp.rmdir(resolvedPath, true);
|
|
351
|
+
} else {
|
|
352
|
+
await this.sftp.rmdir(resolvedPath, false);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
path: resolvedPath,
|
|
357
|
+
deleted: true
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async listFolders(directoryPath) {
|
|
362
|
+
await this._ensureConnected();
|
|
363
|
+
|
|
364
|
+
const resolvedPath = this._normalizePath(directoryPath);
|
|
365
|
+
const listing = await this.sftp.list(resolvedPath);
|
|
366
|
+
|
|
367
|
+
return listing
|
|
368
|
+
.filter(item => item.type === 'd' && item.name !== '.' && item.name !== '..')
|
|
369
|
+
.map(item => ({
|
|
370
|
+
name: item.name,
|
|
371
|
+
path: path.join(resolvedPath, item.name),
|
|
372
|
+
modifiedTime: new Date(item.modifyTime).toISOString()
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ============================================================================
|
|
377
|
+
// SEARCH OPERATIONS
|
|
378
|
+
// ============================================================================
|
|
379
|
+
|
|
380
|
+
async searchFiles(query, options = {}) {
|
|
381
|
+
await this._ensureConnected();
|
|
382
|
+
|
|
383
|
+
const searchDir = this._normalizePath(options.directory || this.remoteRoot);
|
|
384
|
+
|
|
385
|
+
// Use find command via SSH for efficient searching
|
|
386
|
+
const command = `find ${searchDir} -name "${query}" -type f 2>/dev/null`;
|
|
387
|
+
const result = await this._execCommand(command);
|
|
388
|
+
|
|
389
|
+
if (!result.stdout.trim()) {
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const filePaths = result.stdout.trim().split('\n');
|
|
394
|
+
const files = [];
|
|
395
|
+
|
|
396
|
+
for (const filePath of filePaths.slice(0, options.maxResults || 100)) {
|
|
397
|
+
try {
|
|
398
|
+
const stat = await this.sftp.stat(filePath);
|
|
399
|
+
files.push({
|
|
400
|
+
name: path.basename(filePath),
|
|
401
|
+
path: filePath,
|
|
402
|
+
size: stat.size,
|
|
403
|
+
modifiedTime: new Date(stat.modifyTime * 1000).toISOString()
|
|
404
|
+
});
|
|
405
|
+
} catch (e) {
|
|
406
|
+
// File may have been deleted, skip
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return files;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ============================================================================
|
|
414
|
+
// SSH COMMAND EXECUTION
|
|
415
|
+
// ============================================================================
|
|
416
|
+
|
|
417
|
+
async exec(command) {
|
|
418
|
+
return this._execCommand(command);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ============================================================================
|
|
422
|
+
// HELPER METHODS
|
|
423
|
+
// ============================================================================
|
|
424
|
+
|
|
425
|
+
async _ensureConnected() {
|
|
426
|
+
if (!this.isConnected) {
|
|
427
|
+
await this.connect();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
_normalizePath(inputPath) {
|
|
432
|
+
if (!inputPath || inputPath === '/') {
|
|
433
|
+
return this.remoteRoot;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (path.isAbsolute(inputPath)) {
|
|
437
|
+
return inputPath;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return path.join(this.remoteRoot, inputPath);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async _ensureRemoteDirectory(dirPath) {
|
|
444
|
+
try {
|
|
445
|
+
await this.sftp.mkdir(dirPath, true);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (!error.message.includes('already exists')) {
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async _execCommand(command) {
|
|
454
|
+
return new Promise((resolve, reject) => {
|
|
455
|
+
const conn = new Client();
|
|
456
|
+
|
|
457
|
+
conn.on('ready', () => {
|
|
458
|
+
conn.exec(command, (err, stream) => {
|
|
459
|
+
if (err) {
|
|
460
|
+
conn.end();
|
|
461
|
+
return reject(err);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
let stdout = '';
|
|
465
|
+
let stderr = '';
|
|
466
|
+
|
|
467
|
+
stream.on('data', (data) => { stdout += data; });
|
|
468
|
+
stream.stderr.on('data', (data) => { stderr += data; });
|
|
469
|
+
|
|
470
|
+
stream.on('close', (code) => {
|
|
471
|
+
conn.end();
|
|
472
|
+
resolve({ stdout, stderr, code });
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
conn.on('error', reject);
|
|
478
|
+
|
|
479
|
+
const connConfig = {
|
|
480
|
+
host: this.host,
|
|
481
|
+
port: this.port,
|
|
482
|
+
username: this.username
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
if (this.password) connConfig.password = this.password;
|
|
486
|
+
if (this.privateKey) connConfig.privateKey = this.privateKey;
|
|
487
|
+
|
|
488
|
+
conn.connect(connConfig);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
validateConfig() {
|
|
493
|
+
const errors = [];
|
|
494
|
+
const warnings = [];
|
|
495
|
+
|
|
496
|
+
if (!this.host) errors.push('Remote host is required');
|
|
497
|
+
if (!this.username) errors.push('Remote username is required');
|
|
498
|
+
if (!this.password && !this.privateKey && !this.privateKeyPath) {
|
|
499
|
+
errors.push('Either password or private key is required');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (this.timeout < 5000) {
|
|
503
|
+
warnings.push('Timeout less than 5 seconds may cause connection issues');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
isValid: errors.length === 0,
|
|
508
|
+
errors,
|
|
509
|
+
warnings
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export { RemoteProvider };
|