UNPKG

node-ssh-plus

Version:

Enhance functionality in the node-ssh library by providing common function implementations

1,147 lines (1,074 loc) 37.4 kB
const { format } = require('util') const { isAsyncFunction } = require('util/types') const { basename, join, dirname, posix, extname } = require('path') const zlib = require('zlib') const crypto = require('crypto') const { spawn } = require('child_process') const os = require('os') const EventEmitter = require('events') const fs = require('fs-extra') const { fromUnixTime, format: formatTime } = require('date-fns') const _ = require('lodash') const { NodeSSH } = require('node-ssh') const StatusEnum = require('./StatusEnum') const IsChangedEnum = require('./IsChangedEnum') const { ConnectError, ReconnectError, MaxRetriesExceededError, AsyncFunctionError } = require('./Exception') class SSHClient extends NodeSSH { #sftpCache = new Map() trashcanPath = '/trashcan' options clientCacheRootPath = '' maxPath = 255 logPath = '' isUserClosed = true emitter = new EventEmitter() maxTryCount = 3 unixSecondFormat(unixSecond, format = 'yyyy-MM-dd HH:mm:ss') { const date = fromUnixTime(unixSecond) return formatTime(date, format) } attachSuffixToFilePath(filePath, suffix) { const path = dirname(filePath) const ext = extname(filePath) const name = basename(filePath, ext) return posix.join(path, name + suffix + ext) } toChunk(total, chunkSize) { const sliceList = [] const chunkCount = Math.ceil(total / chunkSize) for (let index = 0; index < chunkCount; index++) { let start = index * chunkSize let end = Math.min(total, start + chunkSize) sliceList.push({ start, end, index }) } return sliceList } changeFileExtension(filePath, extension) { const path = dirname(filePath) const name = basename(filePath, extname(filePath)) return posix.join(path, name + extension) } clientCachePath(serverPath) { return join(this.clientCacheRootPath, serverPath) } clientSourcePath(serverPath) { return this.clientCachePath(serverPath) + '.source' } pathLength(path) { return Buffer.byteLength(path, 'utf8') } validatePathOrThrow(path) { const name = basename(path) this.validateNameOrThrow(name) if (this.pathLength(path) > this.maxPath) { throw new Error(this.MESSAGES.PATH_LENGTH(this.maxPath)) } } validateNameOrThrow(name) { if (/[\\/:*?"<>|]/.test(name)) { throw new Error(this.MESSAGES.INVALID_CHARACTERS) } const reservedNames = [ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' ] if (reservedNames.includes(name.toUpperCase())) { throw new Error(this.MESSAGES.RESERVED_NAME(name)) } if (name.length === 0) { throw new Error(this.MESSAGES.EMPTY_NAME) } if (name.length > this.maxPath) { throw new Error(this.MESSAGES.MAX_LENGTH(this.maxPath)) } if (name.endsWith(' ')) { throw new Error(this.MESSAGES.TRAILING_SPACE) } } isValidName(name) { try { this.validateNameOrThrow(name) return true } catch (error) { return false } } isValidPath(path) { try { this.validatePathOrThrow(path) return true } catch (error) { return false } } async sftp(tag = 'SHORT_SFTP') { if (this.#sftpCache.has(tag)) { return this.#sftpCache.get(tag) } const sftp = await this.requestSFTP() this.#sftpCache.set(tag, sftp) return sftp } async stat(serverPath) { return await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.stat(serverPath, (error, stat) => { if (error) { return reject(error) } resolve(stat) }) }) }) } async hasFileCache(serverFile) { const sourcePath = this.clientSourcePath(serverFile) if (await fs.pathExists(sourcePath)) { const stats = await this.stat(serverFile) const sourceJson = await fs.readJson(sourcePath, { encoding: 'utf8' }) if (sourceJson.mtime == stats.mtime) { return true } } return false } async checkOrUpdateFileCache(serverFile, onProgress) { if (await this.hasFileCache(serverFile)) { return } const cachePath = this.clientCachePath(serverFile) await this.downloadFile(cachePath, serverFile, onProgress) const sourcePath = this.clientSourcePath(serverFile) const stats = await this.stat(serverFile) await fs.writeJson(sourcePath, { serverPath: serverFile, host: this.options.host, mtime: stats.mtime, size: stats.size }) } async close() { this.isUserClosed = true this.dispose() this.#sftpCache.clear() } async shell() { return this.requestShell() } async pwd() { return await this.execute('pwd') } async cd(path, currentPath, proviousPath) { let target = path.trim() if (target === '-') { if (proviousPath) { target = proviousPath } else { throw new Error('没有上一次路径') } } if (target.startsWith('~')) { const home = await this.execute('echo ~') target = posix.join(home, target.slice(1)) } return posix.resolve(currentPath, target) } async execute(command, options) { const result = await super.execCommand(command, options) if (result.code == 0 || !result.stderr) { return result.stdout } throw new Error(result.stderr) } async executeText(text, options) { const { onOutput } = options || {} const commandList = text.split('\n') let cwd = await this.pwd() let previous = '' onOutput?.(0, '当前工作目录: ' + cwd) for (const item of commandList) { try { let msg = '' item.startsWith('echo') || onOutput?.(1, item) let match = item.match(/^\s*cd\s+(.+)$/) if (match) { cwd = await this.cd(match[1], cwd, previous) msg = '进入目录: ' + cwd } else { msg = await this.execute(item, { cwd: cwd }) } onOutput?.(2, msg) } catch (error) { onOutput?.(3, error.message || '执行出错, 但无错误详情!') break } } } async readPath(serverPath) { return await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.readdir(serverPath, (error, items) => { if (error) { return reject(error) } resolve(items) }) }) }) } async copy(clientPath, serverPath) { const command = format('cp -r -n "%s" "%s"', clientPath, serverPath) await this.execCommand(command) } async createBackupOfVersion(serverPath, isFile) { let name = basename(serverPath) let ext = '' if (isFile) { ext = extname(name) name = basename(name, ext) } const parentPath = dirname(serverPath) const items = await this.readPath(parentPath) let max = 0 let suffix = 'v' const regex = new RegExp(format('%s-%s(\\d+)%s', name, suffix, ext && '\\' + ext)) items?.forEach(it => { const match = it.filename.match(regex) if (match) { max = Math.max(max, parseInt(match[1])) } }) const newRemote = posix.join(parentPath, format('%s-%s%s%s', name, suffix, max + 1, ext)) await this.copy(serverPath, newRemote) } async createTimePointBackup(serverPath, isFile) { let name = basename(serverPath) let ext = '' if (isFile) { ext = extname(name) name = basename(name, ext) } const parentPath = dirname(serverPath) const now = new Date() const timestamp = formatTime(now, 'yyyyMMddHHmmss') const newRemote = posix.join(parentPath, format('%s-%s%s', name, timestamp, ext)) await this.copy(serverPath, newRemote) } async move(source, target) { const command = format('mv "%s" "%s"', source, target) await this.execCommand(command) } async merge(source, target, onMerge) { const sourceStat = await this.stat(source) const queue = [ { source, sourceStat, target } ] let code = 0 while (queue.length > 0) { let item = queue.shift() try { item.targetStat = await this.stat(item.target) if (item.sourceStat.isDirectory()) { const children = await this.readPath(item.source) children.forEach(it => { queue.push({ source: posix.join(item.source, it.filename), sourceStat: it.attrs, target: posix.join(item.target, it.filename) }) }) } else { code = await onMerge?.(item) if (code == -1) { break } } } catch (_) { await this.move(item.source, item.target) } } if (code != -1) { await this.remove(source) } return code } async remove(path) { const command = format('rm -rf "%s"', path) await this.execCommand(command) } async removeContent(path) { const command = format('rm -rf "%s"', path) await this.execCommand(command) await this.ensurePath(path) } async brotliCompress(text) { return await new Promise((resolve, reject) => { zlib.brotliCompress(text, (error, resurt) => { if (error) { return reject(error) } resolve(resurt.toString('base64url')) }) }) } async brotliDecompress(text) { return await new Promise((resolve, reject) => { const buffer = Buffer.from(text, 'base64url') zlib.brotliDecompress(buffer, (error, resurt) => { if (error) { return reject(error) } resolve(resurt.toString('utf-8')) }) }) } async findAvailableName(serverPath, nameFn) { const items = await this.readPath(serverPath) const set = new Set(items.map(item => item.filename)) let index = 1 while (set.has(nameFn(index))) { index++ } return nameFn(index) } async symlink(serverPath, linkPath) { const name = basename(serverPath) const newName = await this.findAvailableName(linkPath, i => format('%s - 快捷方式(%s)', name, i) ) const shortcutPath = posix.join(linkPath, newName) return await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.symlink(serverPath, shortcutPath, error => { if (error) { return reject(error) } resolve(shortcutPath) }) }) }) } async realpath(serverPath) { return await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.realpath(serverPath, (error, abs) => { if (error) { return reject(error) } resolve(abs) }) }) }) } async symlinkTarget(serverPath) { const target = await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.readlink(serverPath, (error, target) => { if (error) { return reject(error) } resolve(target) }) }) }) return posix.resolve(dirname(serverPath), target) } async ensureTrashcan(path) { this.trashcanPath = path || this.trashcanPath return await this.ensurePath(this.trashcanPath) } async removeToTrashcan(remote) { const name = basename(remote) const time = Date.now() const metedata = { s: remote, d: time } const json = JSON.stringify(metedata) const zipText = await this.brotliCompress(json) if (zipText.length < 255) { const target = posix.join(this.trashcanPath, zipText) await this.move(remote, target) } else { const target = posix.join(this.trashcanPath, time + '-' + name) await this.move(remote, target) await this.writeJson(target + '.metedata', metedata) } } async parseMetedata(remote) { const name = basename(remote) let text = '' if (/^[0-9]+-/.test(name)) { text = this.readJson(remote + '.metedata') } else { text = await this.brotliDecompress(name) } return JSON.parse(text) } async write(fd, buffer, position) { return await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.write(fd, buffer, 0, buffer.length, position, error => { if (error) { return reject(error) } resolve(serverFile) }) }) }) } async writeFile(serverFile, data, options) { const remotePath = dirname(serverFile) await this.ensurePath(remotePath) return await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.writeFile(serverFile, data, options, error => { if (error) { return reject(error) } resolve(serverFile) }) }) }) } async writeText(serverFile, data) { return await this.writeFile(serverFile, data, { encoding: 'utf8' }) } async writeJson(serverFile, data) { const json = JSON.stringify(data) return await this.writeText(serverFile, json) } async readFile(serverFile, options) { return await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.readFile(serverFile, options, (error, result) => { if (error) { return reject(error) } resolve(result) }) }) }) } async readText(serverFile) { return await this.readFile(serverFile, { encoding: 'utf8' }) } async readJson(serverFile) { const text = await this.readText(serverFile) return JSON.parse(text) } async truncate(serverFile, length) { const command = format('truncate -s %s "%s"', length, serverFile) return await this.execute(command) } async rename(sourcePath, name) { return await new Promise((resolve, reject) => { this.validateNameOrThrow(name) const parentPath = dirname(sourcePath) const targetPath = posix.join(parentPath, name) this.sftp().then(sftp => { sftp.rename(sourcePath, targetPath, (error, stat) => { if (error) { return reject(error) } resolve(stat) }) }) }) } async open(serverFile, mode) { return await new Promise((resolve, reject) => { this.sftp().then(sftp => { sftp.open(serverFile, mode, (error, buffer) => { if (error) { return reject(error) } resolve(buffer) }) }) }) } async exists(remotePath) { return await new Promise(resolve => { this.sftp().then(sftp => { sftp.exists(remotePath, hasError => { resolve(hasError) }) }) }) } async ensurePath(path) { const command = format('mkdir -p "%s"', path) await this.execCommand(command) } async upload(client, server, onProgress) { const stat = await fs.stat(client) if (stat.isDirectory()) { return this.uploadPath(client, server, onProgress) } const name = basename(client) const serverFile = posix.join(server, name) return await this.uploadFile(client, serverFile, onProgress) } async uploadFile(clientFile, serverFile, onProgress) { const sftp = await this.sftp() onProgress?.call(this, { status: StatusEnum.STARTED, clientFile, serverFile }) try { await this.putFile(clientFile, serverFile, sftp, { step: (total, _, fsize) => { const progress = { status: StatusEnum.IN_PROGRESS, clientFile, serverFile, total: fsize, current: total, percentage: Math.ceil((total * 100) / fsize) } onProgress?.call(this, progress) } }) onProgress?.call(this, { status: StatusEnum.SUCCESS, clientFile, serverFile }) } catch (error) { onProgress?.call(this, { status: StatusEnum.FAILURE, clientFile, serverFile, error }) throw error } } async uploadPath(clientPath, serverPath, onProgress) { await fs.ensureDir(clientPath) const sftp = await this.sftp() const successs = [] const failures = [] onProgress?.call(this, { status: StatusEnum.STARTED, clientPath, serverPath }) await this.putDirectory(clientPath, serverPath, { sftp, recursive: true, tick: (clientFile, serverFile, error) => { if (error) { failures.push({ clientFile, serverFile, error }) } else { successs.push({ clientFile, serverFile }) } onProgress?.call(this, { status: StatusEnum.IN_PROGRESS, clientFile, serverFile, error }) } }) if (failures.length == 0) { onProgress?.call(this, { status: StatusEnum.SUCCESS, clientPath, serverPath, successs }) } else if (successs.length > 0) { onProgress?.call(this, { status: StatusEnum.PARTIAL_FAILURE, clientPath, serverPath, successs, failures }) } else { onProgress?.call(this, { status: StatusEnum.FAILURE, clientPath, serverPath, failures }) } } async downloadFile(clientFile, serverFile, onProgress) { const parentPath = dirname(clientFile) await fs.ensureDir(parentPath) const sftp = await this.sftp('LONG_SFTP') onProgress?.call(this, { status: StatusEnum.STARTED, clientFile, serverFile }) try { await this.getFile(clientFile, serverFile, sftp, { step: (current, _, total) => { const progress = { status: StatusEnum.IN_PROGRESS, clientFile, serverFile, total, current, percentage: Math.ceil((current * 100) / total) } onProgress?.call(this, progress) } }) onProgress?.call(this, { status: StatusEnum.SUCCESS, clientFile, serverFile }) } catch (error) { onProgress?.call(this, { status: StatusEnum.FAILURE, clientFile, serverFile, error }) throw error } } async downloadPath(clientPath, serverPath, onProgress) { await fs.ensureDir(clientPath) const sftp = await this.sftp() const successs = [] const failures = [] onProgress?.call(this, { status: StatusEnum.STARTED, clientPath, serverPath }) await this.getDirectory(clientPath, serverPath, { sftp, recursive: true, tick: (clientFile, serverFile, error) => { if (error) { failures.push({ clientFile, serverFile, error }) } else { successs.push({ clientFile, serverFile }) } onProgress?.call(this, { status: StatusEnum.IN_PROGRESS, clientFile, serverFile, error }) } }) if (failures.length == 0) { onProgress?.call(this, { status: StatusEnum.SUCCESS, clientPath, serverPath, successs }) } else if (successs.length > 0) { onProgress?.call(this, { status: StatusEnum.PARTIAL_FAILURE, clientPath, serverPath, successs, failures }) } else { onProgress?.call(this, { status: StatusEnum.FAILURE, clientPath, serverPath, failures }) } } async utimes(serverFile, lstAccessTime, lastUpdateTime) { const sftp = await this.sftp() return await new Promise((resolve, reject) => { sftp.utimes(serverFile, lstAccessTime, lastUpdateTime, error => { if (error) { return reject(error) } resolve() }) }) } async clientSyncToServer(clientFile, serverFile, options) { let { chunkSize, isBackup = true } = options || {} if (isBackup) { const stat = await this.stat(serverFile) const timestamp = this.unixSecondFormat(stat.mtime, 'yyyyMMddHHmmss') const backup = this.attachSuffixToFilePath(serverFile, '-同步备份-' + timestamp) await this.copy(serverFile, backup) } chunkSize = chunkSize || 5 * 1024 * 1024 const client = await this.computeClientFileChunkHashCode(clientFile, chunkSize) const clientChunks = client.hashCodes const server = await this.computeServerFileChunkHashCode(serverFile, chunkSize) const serverChunks = server.hashCodes const chunks = [] for (let i = 0; i < clientChunks.length; i++) { const it = clientChunks[i] if (i < serverChunks.length) { if (it.hashCode == serverChunks[i].hashCode) { continue } } chunks.push(it) } if (chunks.length > 0) { const serverFd = await this.open(serverFile, 'w') const clientFd = await fs.open(clientFile, 'r') for (const chunk of chunks) { const size = chunk.end - chunk.start const buffer = Buffer.alloc(size) const data = await fs.read(clientFd, buffer, 0, size, chunk.start) await this.write(serverFd, data.buffer, chunk.start) } } if (server.size > client.size) { await this.truncate(serverFile, server.size) } const sftp = await this.sftp() await sftp.utimes(serverFile, client.atime, client.mtime) } async serverSyncToClient(clientFile, serverFile, options) { let { chunkSize, isBackup = true } = options || {} if (isBackup) { const stat = await fs.stat(clientFile) const timestamp = this.unixSecondFormat(stat.mtime, 'yyyyMMddHHmmss') const backup = this.attachSuffixToFilePath(clientFile, '-同步备份-' + timestamp) await fs.copy(clientFile, backup) } chunkSize = chunkSize || 5 * 1024 * 1024 const client = await this.computeClientFileChunkHashCode(clientFile, chunkSize) const clientChunks = client.hashCodes const server = await this.computeServerFileChunkHashCode(serverFile, chunkSize) const serverChunks = server.hashCodes const chunks = [] for (let i = 0; i < serverChunks.length; i++) { const it = serverChunks[i] if (i < clientChunks.length) { if (it.hashCode == clientChunks[i].hashCode) { continue } } chunks.push({ path: it.path, position: i * chunkSize }) } if (chunks.length > 0) { const fd = await fs.open(clientFile, 'w') for (const chunk of chunks) { const data = await this.readFile(chunk.path) await fs.write(fd, data, 0, data.length, chunk.position) } } if (client.size > server.size) { await fs.truncate(clientFile, server.size) } await fs.utimes(clientFile, server.atime, server.mtime) } async zip(serverPath) { const path = dirname(serverPath) const name = basename(serverPath) const command = format('tar -czvf "%s.tar.gz" -C "%s" "%s"', serverPath, path, name) return await this.execute(command) } async unzip(serverPath, isRemove) { const removeCommand = isRemove ? format('&& rm %s', serverPath) : '' const path = dirname(serverPath) const command = format('tar -xzvf "%s" -C "%s" %s', serverPath, path, removeCommand) return await this.execute(command) } async isFileChanged(clientFile, serverFile, compare) { compare = compare || IsChangedEnum.MTIME | IsChangedEnum.SIZE const serverStat = await this.stat(serverFile) const clientStat = await fs.stat(clientFile) let changed = false if (IsChangedEnum.has(compare, IsChangedEnum.MTIME)) { const mtime = Math.floor(clientStat.mtimeMs / 1000) if (serverStat.mtime != mtime) { changed = true } } if (IsChangedEnum.has(compare, IsChangedEnum.SIZE)) { if (serverStat.size != clientStat.size) { changed = true } } return changed } async computeServerFileChunkHashCode(serverFile, chunkSize) { const stats = await this.stat(serverFile) const prefix = 'chunk_' const remoteName = basename(serverFile) const remotePath = dirname(serverFile) const chunkPath = posix.join(remotePath, remoteName + '_' + chunkSize) await this.ensurePath(chunkPath) const chunkFile = posix.join(chunkPath, prefix) const command = format( 'split -b %s %s %s && for f in %s*; do sha256sum $f; done', chunkSize, serverFile, chunkFile, chunkFile ) const text = await this.execute(command) const hashCodes = text.split('\n').map(it => { const parts = it.split(' ') return { hashCode: parts[0], path: parts[1] } }) return { size: stats.size, mtime: stats.mtime, atime: stats.atime, hashCodes } } async computeClientFileChunkHashCode(filePath, chunkSize) { const stats = await fs.stat(filePath) const chunks = this.toChunk(stats.size, chunkSize) const fd = await fs.open(filePath, 'r') const hashCodes = [] for (const chunk of chunks) { const size = chunk.end - chunk.start const buffer = Buffer.alloc(size) const result = await fs.read(fd, buffer, 0, buffer.length, chunk.start) const hash = crypto.createHash('sha256') hash.update(result.buffer) chunk.hashCode = hash.digest('hex') hashCodes.push(chunk) } return { size: stats.size, mtime: Math.floor(stats.mtimeMs / 1000), atime: Math.floor(stats.atimeMs / 1000), hashCodes } } async uploadPublicKeyPair(publicKey, { logger }) { try { const key = publicKey.trim() logger?.inf('待上传公钥... ') logger?.inf(key) const sftp = await this.sftp() const temp = join(os.tmpdir(), String(Date.now()) + '.txt') const home = await this.execCommand('eval echo ~') const authorized_keys = posix.join(home.stdout, '.ssh', 'authorized_keys') logger?.inf('公钥验证中 ...') await this.getFile(temp, authorized_keys, sftp) const text = await fs.readFile(temp, { encoding: 'utf-8' }) if (text.indexOf(key) == -1) { logger?.inf('公钥开始写入 ...') const command = format('echo "%s" >> ~/.ssh/authorized_keys', key) await this.execCommand(command) logger?.inf('写入完成, 登录公钥设置完成。') } else { logger?.inf('服务器已存在此公钥, 登录公钥设置完成。') } } catch (error) { logger?.inf(error.message) } } async checkUpdateLocalCache(serverPath) { const serverStat = await this.stat(serverPath) const clientPath = this.clientCachePath(serverPath) const state = { isPath: serverStat.isDirectory() } if (await fs.pathExists(clientPath)) { const clientStat = await fs.stat(clientPath) if (serverStat.isDirectory() && clientStat.isDirectory()) { if (clientStat.mtime == serverStat.mtime) { return state } } else if (clientStat.isFile() && clientStat.isFile()) { if (clientStat.mtime == serverStat.mtime) { return state } } else { await fs.remove(clientPath) } } if (serverStat.isDirectory()) { await this.downloadPath(clientPath, serverPath) } else { await this.downloadFile(clientPath, serverPath) } await fs.utimes(clientPath, serverStat.atime * 1000, serverStat.mtime * 1000) return state } async forwardToServerPort(clientPort, serverPort, serverSock) { const { host, username } = this.options const command = 'ssh' const port = format('%s:%s:%s', clientPort, serverSock || host, serverPort) const server = format('%s@%s', username, host) const args = ['-L', port, server] return spawn(command, args) } async forwardToClientPort(clientPort, serverPort) { const { host, username } = this.options const command = 'ssh' const port = format('%s:%s:%s', serverPort, '0.0.0.0', clientPort) const server = format('%s@%s', username, host) const args = ['-R', port, server] return spawn(command, args) } async forward(clientPort, serverPort, isIn, serverSock) { if (isIn) { return await this.forwardToClientPort(clientPort, serverPort) } return await this.forwardToServerPort(clientPort, serverPort, serverSock) } onClose() { this.emitter.emit('close', this.options, this.isUserClosed) if (this.isUserClosed || !this.options.isAutoConnect) { return } this.reconnect() } async startListening() { this.connection.on('close', () => this.onClose()) this.connection.on('error', error => { this.isConnected() || this.onClose() this.emitter.emit('error', error, this.options) }) } async connect(options) { try { this.trashcanPath = options.trashcanPath || this.trashcanPath const temp = options.root || join(os.homedir(), '.ssh-server') this.clientCacheRootPath = join(temp, options.host) this.logPath = join(temp, 'logs', options.host) await super.connect(options) await this.ensureTrashcan(this.trashcanPath) await this.ensurePath(this.clientCacheRootPath) this.options = _.clone(options) this.isUserClosed = false this.startListening() this.emitter.emit('open', this.options) return this } catch (error) { let custom = new ConnectError(error) this.emitter.emit('error', custom, this.options) throw error } } async sleep(ms) { await new Promise(resolve => setTimeout(resolve, ms)) } async reconnect() { this.#sftpCache.clear() for (let i = 1; i <= this.maxTryCount; i++) { this.emitter.emit('reconnect', this.options, i) try { return await this.connect(this.options) } catch (error) { let custom = new ReconnectError(error) this.emitter.emit('error', custom, this.options, i) if (i == this.maxTryCount) { custom = new MaxRetriesExceededError(error) this.emitter.emit('error', custom, this.options, i) throw custom } const ms = Math.pow(2, i) * 5000 await this.sleep(ms) } } } async toProxy() { const handle = { get: (target, prop, receiver) => { let value = target[prop] if (!isAsyncFunction(value)) { return Reflect.get(target, prop, receiver) } const fn = async (...args) => { try { return await value.call(target, ...args) } catch (error) { const custom = new AsyncFunctionError(error) this.emitter.emit('error', custom, this.options) throw error } } Object.defineProperty(fn, 'length', { value: value.length }) return fn } } return new Proxy(this, handle) } static async connect(options) { const ssh = new SSHClient() return await ssh.connect(options) } } module.exports = SSHClient