@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.
@@ -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 };