UNPKG

pure-js-sftp

Version:

A pure JavaScript SFTP client with revolutionary RSA-SHA2 compatibility fixes. Zero native dependencies, built on ssh2-streams with 100% SSH key support.

512 lines 20.9 kB
"use strict"; /** * Pure JS SFTP Client * A pure JavaScript SFTP client with no native dependencies * 100% API compatible with ssh2-sftp-client */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SSH2StreamsTransport = exports.SSH2StreamsSFTPClient = exports.SftpClient = void 0; const ssh2_streams_client_1 = require("./sftp/ssh2-streams-client"); const types_1 = require("./ssh/types"); const fsPromises = __importStar(require("fs/promises")); const path = __importStar(require("path")); const events_1 = require("events"); class SftpClient extends events_1.EventEmitter { constructor(_name) { super(); this.client = null; // name parameter for ssh2-sftp-client compatibility } // ssh2-sftp-client compatible connect method async connect(config) { this.client = new ssh2_streams_client_1.SSH2StreamsSFTPClient(config); // Forward events this.client.on('ready', () => this.emit('ready')); this.client.on('error', (err) => this.emit('error', err)); this.client.on('close', () => this.emit('close')); this.client.on('debug', (msg) => this.emit('debug', msg)); return this.client.connect(); } // ssh2-sftp-client compatible API methods async list(remotePath, filter) { if (!this.client) throw new Error('Not connected'); const entries = await this.client.listDirectory(remotePath); // Convert DirectoryEntry[] to FileInfo[] for ssh2-sftp-client compatibility const fileInfos = entries.map(entry => { // Determine file type let type = '-'; // default to file if (entry.attrs.isDirectory?.()) { type = 'd'; } else if (entry.attrs.isSymbolicLink?.()) { type = 'l'; } // Parse permissions from mode const mode = entry.attrs.permissions || 0; const formatPermissions = (perm) => { const r = (perm & 4) ? 'r' : '-'; const w = (perm & 2) ? 'w' : '-'; const x = (perm & 1) ? 'x' : '-'; return r + w + x; }; const fileInfo = { type, name: entry.filename, size: entry.attrs.size || 0, modifyTime: entry.attrs.mtime || 0, accessTime: entry.attrs.atime || 0, rights: { user: formatPermissions((mode >> 6) & 7), group: formatPermissions((mode >> 3) & 7), other: formatPermissions(mode & 7) }, owner: entry.attrs.uid || 0, group: entry.attrs.gid || 0 }; return fileInfo; }); // Apply filter if provided return filter ? fileInfos.filter(filter) : fileInfos; } async get(remotePath, dst) { if (!this.client) throw new Error('Not connected'); const handle = await this.client.openFile(remotePath, types_1.SFTP_OPEN_FLAGS.READ); try { // Read entire file into memory first const chunks = []; let offset = 0; const chunkSize = 32768; // 32KB chunks while (true) { const data = await this.client.readFile(handle, offset, chunkSize); if (!data || data.length === 0) break; chunks.push(data); offset += data.length; if (data.length < chunkSize) break; // End of file } const fileBuffer = Buffer.concat(chunks); if (dst === undefined) { // No destination specified - return Buffer return fileBuffer; } else if (typeof dst === 'string') { // String destination - write to local file await fsPromises.writeFile(dst, fileBuffer); return dst; } else { // Writable stream destination return new Promise((resolve, reject) => { dst.write(fileBuffer, (error) => { if (error) { reject(error); } else { dst.end(); resolve(dst); } }); }); } } finally { await this.client.closeFile(handle); } } async put(input, remotePath, options) { if (!this.client) throw new Error('Not connected'); const handle = await this.client.openFile(remotePath, types_1.SFTP_OPEN_FLAGS.WRITE | types_1.SFTP_OPEN_FLAGS.CREAT | types_1.SFTP_OPEN_FLAGS.TRUNC); try { let dataBuffer; if (Buffer.isBuffer(input)) { // Input is already a Buffer dataBuffer = input; } else if (typeof input === 'string') { // Input is a file path - read the file dataBuffer = await fsPromises.readFile(input); } else { // Input is a Readable stream - collect all data const chunks = []; await new Promise((resolve, reject) => { input.on('data', (chunk) => { chunks.push(chunk); }); input.on('end', () => { resolve(); }); input.on('error', (error) => { reject(error); }); }); dataBuffer = Buffer.concat(chunks); } // Write data in chunks let offset = 0; const chunkSize = 32768; // 32KB chunks while (offset < dataBuffer.length) { const end = Math.min(offset + chunkSize, dataBuffer.length); const chunk = dataBuffer.subarray(offset, end); await this.client.writeFile(handle, offset, chunk); offset += chunk.length; } return remotePath; } finally { await this.client.closeFile(handle); } } async delete(remotePath) { if (!this.client) throw new Error('Not connected'); return this.client.removeFile(remotePath); } async rename(oldPath, newPath) { if (!this.client) throw new Error('Not connected'); return this.client.renameFile(oldPath, newPath); } async mkdir(remotePath, recursive = false) { if (!this.client) throw new Error('Not connected'); if (recursive) { // Normalize path and split into parts const normalizedPath = remotePath.replace(/\/+/g, '/'); // Remove duplicate slashes const parts = normalizedPath.split('/').filter(p => p); let currentPath = normalizedPath.startsWith('/') ? '' : ''; for (const part of parts) { currentPath = currentPath ? `${currentPath}/${part}` : (normalizedPath.startsWith('/') ? `/${part}` : part); try { // Check if directory already exists first const stats = await this.client.stat(currentPath); if (stats.isDirectory?.()) { continue; // Directory exists, skip } else { throw new Error(`Path exists but is not a directory: ${currentPath}`); } } catch (error) { // Use SFTP status codes instead of error message parsing const isNotFound = error instanceof Error && error.name === 'SFTPError' && error.code === 2; // SFTP_STATUS.NO_SUCH_FILE if (isNotFound || error.message?.includes('No such file') || error.message?.includes('not a directory')) { try { await this.client.makeDirectory(currentPath); } catch (createError) { // Only ignore if directory was created by another process const alreadyExists = createError instanceof Error && createError.name === 'SFTPError' && createError.code === 4; // SFTP_STATUS.FAILURE for existing dir if (!alreadyExists && !createError.message?.includes('File exists')) { throw new Error(`Failed to create directory ${currentPath}: ${createError.message}`); } } } else { throw error; } } } } else { return this.client.makeDirectory(remotePath); } } async rmdir(remotePath, recursive = false) { if (!this.client) throw new Error('Not connected'); if (recursive) { // First check if directory exists and is actually a directory try { const stats = await this.client.stat(remotePath); if (!stats.isDirectory?.()) { throw new Error(`Path is not a directory: ${remotePath}`); } } catch (error) { const isNotFound = error instanceof Error && error.name === 'SFTPError' && error.code === 2; // SFTP_STATUS.NO_SUCH_FILE if (isNotFound || error.message?.includes('No such file')) { return; // Directory doesn't exist, nothing to remove } throw error; } // List directory contents and remove recursively try { const entries = await this.client.listDirectory(remotePath); for (const entry of entries) { if (entry.filename === '.' || entry.filename === '..') continue; // Normalize path construction const fullPath = remotePath.endsWith('/') ? `${remotePath}${entry.filename}` : `${remotePath}/${entry.filename}`; if (entry.attrs.isDirectory?.()) { await this.rmdir(fullPath, true); } else { await this.client.removeFile(fullPath); } } } catch (error) { // If we can't list the directory, it might be empty or permission denied const isNotFound = error instanceof Error && error.name === 'SFTPError' && error.code === 2; // SFTP_STATUS.NO_SUCH_FILE if (!isNotFound && !error.message?.includes('No such file')) { throw new Error(`Failed to list directory contents: ${error.message}`); } } } return this.client.removeDirectory(remotePath); } async exists(remotePath) { if (!this.client) throw new Error('Not connected'); try { const stats = await this.client.stat(remotePath); // Determine file type based on attributes if (stats.isDirectory?.()) { return 'd'; } else if (stats.isSymbolicLink?.()) { return 'l'; } else { return '-'; // regular file } } catch (error) { return false; } } async stat(remotePath) { if (!this.client) throw new Error('Not connected'); return this.client.stat(remotePath); } // Fast transfer methods (optimized versions) async fastGet(remotePath, localPath, options) { if (!this.client) throw new Error('Not connected'); // For now, use regular get - can be optimized later with parallel streams await this.get(remotePath, localPath); return localPath; } async fastPut(localPath, remotePath, options) { if (!this.client) throw new Error('Not connected'); // For now, use regular put - can be optimized later with parallel streams await this.put(localPath, remotePath); return remotePath; } async append(input, remotePath, options) { if (!this.client) throw new Error('Not connected'); const data = Buffer.isBuffer(input) ? input : Buffer.from(input, 'utf8'); // For append, we need to get current file size to know where to write let fileSize = 0; try { const stats = await this.client.stat(remotePath); fileSize = stats.size || 0; } catch (error) { // File doesn't exist, start at 0 if (!(error.message?.includes('No such file') || error.message?.includes('not found'))) { throw error; } } const handle = await this.client.openFile(remotePath, types_1.SFTP_OPEN_FLAGS.WRITE | types_1.SFTP_OPEN_FLAGS.CREAT); try { // Write at end of file using actual file size await this.client.writeFile(handle, fileSize, data); } finally { await this.client.closeFile(handle); } return remotePath; } async chmod(remotePath, mode) { if (!this.client) throw new Error('Not connected'); const numericMode = typeof mode === 'string' ? parseInt(mode, 8) : mode; return this.client.setAttributes(remotePath, { permissions: numericMode }); } async realPath(remotePath) { if (!this.client) throw new Error('Not connected'); return this.client.realPath(remotePath); } async uploadDir(srcDir, dstDir, options) { if (!this.client) throw new Error('Not connected'); // Verify source directory exists try { const srcStats = await fsPromises.stat(srcDir); if (!srcStats.isDirectory()) { throw new Error(`Source path is not a directory: ${srcDir}`); } } catch (error) { throw new Error(`Source directory not accessible: ${srcDir} - ${error.message}`); } // Create destination directory if it doesn't exist try { await this.mkdir(dstDir, true); } catch (error) { // Only ignore if directory already exists if (!error.message?.includes('File exists')) { throw new Error(`Failed to create destination directory: ${dstDir} - ${error.message}`); } } const entries = await fsPromises.readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(srcDir, entry.name); const dstPath = `${dstDir}/${entry.name}`; // Apply filter if provided if (options?.filter && !options.filter(srcPath, entry.isDirectory())) { continue; } try { if (entry.isDirectory()) { await this.uploadDir(srcPath, dstPath, options); } else { await this.put(srcPath, dstPath); } } catch (error) { throw new Error(`Failed to upload ${srcPath}: ${error.message}`); } } } async downloadDir(srcDir, dstDir, options) { if (!this.client) throw new Error('Not connected'); // Verify source directory exists on remote try { const srcStats = await this.client.stat(srcDir); if (!srcStats.isDirectory?.()) { throw new Error(`Remote path is not a directory: ${srcDir}`); } } catch (error) { throw new Error(`Remote directory not accessible: ${srcDir} - ${error.message}`); } // Create local destination directory if it doesn't exist try { await fsPromises.mkdir(dstDir, { recursive: true }); } catch (error) { throw new Error(`Failed to create local directory: ${dstDir} - ${error.message}`); } const entries = await this.client.listDirectory(srcDir); for (const entry of entries) { if (entry.filename === '.' || entry.filename === '..') continue; const srcPath = `${srcDir}/${entry.filename}`; const dstPath = path.join(dstDir, entry.filename); // Apply filter if provided if (options?.filter && !options.filter(srcPath, entry.attrs.isDirectory?.() || false)) { continue; } try { if (entry.attrs.isDirectory?.()) { await this.downloadDir(srcPath, dstPath, options); } else { await this.get(srcPath, dstPath); } } catch (error) { throw new Error(`Failed to download ${srcPath}: ${error.message}`); } } } // Alias for ssh2-sftp-client compatibility async end() { this.disconnect(); } // Low-level methods (for advanced users) async listDirectory(path) { if (!this.client) throw new Error('Not connected'); return this.client.listDirectory(path); } async openFile(path, flags) { if (!this.client) throw new Error('Not connected'); return this.client.openFile(path, flags); } async closeFile(handle) { if (!this.client) throw new Error('Not connected'); return this.client.closeFile(handle); } async readFile(handle, offset, length) { if (!this.client) throw new Error('Not connected'); return this.client.readFile(handle, offset, length); } disconnect() { if (this.client) { this.client.disconnect(); this.client = null; } } isReady() { return this.client ? this.client.isReady() : false; } } exports.SftpClient = SftpClient; exports.default = SftpClient; // Export types and classes for TypeScript users __exportStar(require("./ssh/types"), exports); var ssh2_streams_client_2 = require("./sftp/ssh2-streams-client"); Object.defineProperty(exports, "SSH2StreamsSFTPClient", { enumerable: true, get: function () { return ssh2_streams_client_2.SSH2StreamsSFTPClient; } }); var ssh2_streams_transport_1 = require("./ssh/ssh2-streams-transport"); Object.defineProperty(exports, "SSH2StreamsTransport", { enumerable: true, get: function () { return ssh2_streams_transport_1.SSH2StreamsTransport; } }); //# sourceMappingURL=index.js.map