UNPKG

@itriton/sftp

Version:

基于ssh2-sftp-client的项目部署工具,支持密钥证书登录和配置文件管理

407 lines (406 loc) 15.6 kB
import fs from 'fs'; import path from 'path'; import SftpClient from 'ssh2-sftp-client'; import chalk from 'chalk'; import { TransferStatus, LogLevel, TransferMode } from './types.js'; import { ArchiveTransfer } from './archive.js'; /** * SFTP运行器类 */ export class SftpRunner { sftp; config; verbose; logs = []; constructor(config, verbose = false) { this.sftp = new SftpClient(); this.config = config; this.verbose = verbose; } /** * 添加日志 * @param message 日志消息 * @param level 日志级别 */ log(message, level = LogLevel.INFO) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; this.logs.push(logMessage); if (this.verbose || level === LogLevel.ERROR) { switch (level) { case LogLevel.ERROR: console.log(chalk.red(message)); break; case LogLevel.WARN: console.log(chalk.yellow(message)); break; case LogLevel.DEBUG: console.log(chalk.gray(message)); break; default: console.log(message); } } } /** * 连接到SFTP服务器 */ async connect() { try { this.log('正在连接到SFTP服务器...', LogLevel.INFO); const connectConfig = { host: this.config.connection.host, port: this.config.connection.port || 22, username: this.config.connection.username, readyTimeout: this.config.connection.readyTimeout || 20000 }; // 处理认证方式 if (this.config.connection.privateKey) { // 使用私钥认证 const privateKeyPath = this.config.connection.privateKey.replace(/^~/, process.env.HOME || ''); if (!fs.existsSync(privateKeyPath)) { throw new Error(`私钥文件不存在: ${privateKeyPath}`); } connectConfig.privateKey = fs.readFileSync(privateKeyPath); if (this.config.connection.passphrase) { connectConfig.passphrase = this.config.connection.passphrase; } this.log(`使用私钥认证: ${privateKeyPath}`, LogLevel.DEBUG); } else if (this.config.connection.password) { // 使用密码认证 connectConfig.password = this.config.connection.password; this.log('使用密码认证', LogLevel.DEBUG); } else { throw new Error('必须提供密码或私钥进行认证'); } await this.sftp.connect(connectConfig); this.log(`✅ 已连接到 ${this.config.connection.host}:${connectConfig.port}`, LogLevel.INFO); } catch (error) { const errorMessage = `连接失败: ${error instanceof Error ? error.message : 'Unknown error'}`; this.log(errorMessage, LogLevel.ERROR); throw new Error(errorMessage); } } /** * 断开SFTP连接 */ async disconnect() { try { await this.sftp.end(); this.log('已断开SFTP连接', LogLevel.DEBUG); } catch (error) { this.log(`断开连接时出错: ${error instanceof Error ? error.message : 'Unknown error'}`, LogLevel.WARN); } } /** * 确保远程目录存在 * @param remotePath 远程路径 */ async ensureRemoteDir(remotePath) { try { const exists = await this.sftp.exists(remotePath); if (!exists) { await this.sftp.mkdir(remotePath, true); this.log(`创建远程目录: ${remotePath}`, LogLevel.DEBUG); } } catch (error) { throw new Error(`创建远程目录失败: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * 清空远程目录 * @param remotePath 远程路径 */ async cleanRemoteDir(remotePath) { let deletedCount = 0; try { const exists = await this.sftp.exists(remotePath); if (!exists) { this.log(`远程目录不存在,跳过清理: ${remotePath}`, LogLevel.DEBUG); return deletedCount; } this.log(`正在清空远程目录: ${remotePath}`, LogLevel.INFO); const files = await this.sftp.list(remotePath); for (const file of files) { const fullPath = path.posix.join(remotePath, file.name); if (file.type === 'd') { // 递归删除子目录 deletedCount += await this.cleanRemoteDir(fullPath); await this.sftp.rmdir(fullPath); this.log(`删除目录: ${fullPath}`, LogLevel.DEBUG); } else { // 删除文件 await this.sftp.delete(fullPath); deletedCount++; this.log(`删除文件: ${fullPath}`, LogLevel.DEBUG); } } } catch (error) { this.log(`清空远程目录时出错: ${error instanceof Error ? error.message : 'Unknown error'}`, LogLevel.WARN); } return deletedCount; } /** * 检查文件是否应该被排除 * @param filePath 文件路径 * @returns 是否应该排除 */ shouldExclude(filePath) { if (!this.config.run.exclude) { return false; } return this.config.run.exclude.some(pattern => { // 简单的通配符匹配 if (pattern.includes('*')) { const regex = new RegExp(pattern.replace(/\*/g, '.*')); return regex.test(filePath); } // 直接匹配 return filePath.includes(pattern); }); } /** * 获取本地文件列表 * @param localPath 本地路径 * @param basePath 基础路径(用于计算相对路径) * @returns 文件信息列表 */ async getLocalFiles(localPath, basePath) { const files = []; const base = basePath || localPath; try { const stats = fs.statSync(localPath); if (stats.isFile()) { const relativePath = path.relative(base, localPath); if (!this.shouldExclude(relativePath)) { const remotePath = path.posix.join(this.config.run.remotePath, relativePath.replace(/\\/g, '/')); files.push({ localPath, remotePath, size: stats.size, status: TransferStatus.PREPARING }); } } else if (stats.isDirectory()) { const items = fs.readdirSync(localPath); for (const item of items) { const itemPath = path.join(localPath, item); const relativePath = path.relative(base, itemPath); if (!this.shouldExclude(relativePath)) { const subFiles = await this.getLocalFiles(itemPath, base); files.push(...subFiles); } } } } catch (error) { this.log(`读取本地文件时出错: ${error instanceof Error ? error.message : 'Unknown error'}`, LogLevel.ERROR); } return files; } /** * 上传单个文件 * @param transferInfo 传输信息 */ async uploadFile(transferInfo) { try { transferInfo.status = TransferStatus.UPLOADING; // 确保远程目录存在 const remoteDir = path.posix.dirname(transferInfo.remotePath); await this.ensureRemoteDir(remoteDir); // 上传文件 await this.sftp.put(transferInfo.localPath, transferInfo.remotePath); transferInfo.status = TransferStatus.COMPLETED; this.log(`✅ ${transferInfo.localPath} -> ${transferInfo.remotePath}`, LogLevel.DEBUG); } catch (error) { transferInfo.status = TransferStatus.FAILED; transferInfo.error = error instanceof Error ? error.message : 'Unknown error'; this.log(`❌ 上传失败: ${transferInfo.localPath} (${transferInfo.error})`, LogLevel.ERROR); throw error; } } /** * 决定使用哪种传输模式 * @param fileCount 文件数量 * @returns 传输模式 */ decideTransferMode(fileCount) { const mode = this.config.run.transferMode || TransferMode.AUTO; const threshold = this.config.run.archiveThreshold || 10; switch (mode) { case TransferMode.SINGLE: return TransferMode.SINGLE; case TransferMode.ARCHIVE: return TransferMode.ARCHIVE; case TransferMode.AUTO: default: return fileCount >= threshold ? TransferMode.ARCHIVE : TransferMode.SINGLE; } } /** * 使用压缩传输 * @param files 文件列表 * @returns 传输结果 */ async runArchiveTransfer(files) { const archiveTransfer = new ArchiveTransfer(this.config, this.verbose); const result = await archiveTransfer.transfer(files); if (!result.success) { throw new Error(result.error || '压缩传输失败'); } // 合并日志 this.logs.push(...result.logs); return { uploadedFiles: files.length, duration: result.duration, logs: result.logs }; } /** * 使用单文件传输 * @param files 文件列表 * @returns 传输结果 */ async runSingleTransfer(files) { let uploadedFiles = 0; for (const file of files) { try { await this.uploadFile(file); uploadedFiles++; // 显示进度 if (!this.verbose) { const progress = Math.round((uploadedFiles / files.length) * 100); process.stdout.write(`\r上传进度: ${progress}% (${uploadedFiles}/${files.length})`); } } catch { // 继续上传其他文件 this.log(`跳过失败的文件: ${file.localPath}`, LogLevel.WARN); } } if (!this.verbose) { process.stdout.write('\n'); } return { uploadedFiles }; } /** * 执行运行 * @param options 运行选项 * @returns 运行结果 */ async run(options = {}) { const startTime = Date.now(); let uploadedFiles = 0; let deletedFiles = 0; try { this.log(`🚀 开始运行: ${this.config.name}`, LogLevel.INFO); this.log(`📁 本地目录: ${this.config.run.localPath}`, LogLevel.INFO); this.log(`🌐 远程目录: ${this.config.run.remotePath}`, LogLevel.INFO); // 检查本地目录是否存在 if (!fs.existsSync(this.config.run.localPath)) { throw new Error(`本地目录不存在: ${this.config.run.localPath}`); } // 获取本地文件列表 this.log('📋 正在扫描本地文件...', LogLevel.INFO); const files = await this.getLocalFiles(this.config.run.localPath); this.log(`📋 找到 ${files.length} 个文件需要上传`, LogLevel.INFO); // 如果没有文件需要上传 if (files.length === 0) { this.log('ℹ️ 没有文件需要上传', LogLevel.INFO); return { success: true, uploadedFiles: 0, deletedFiles: 0, duration: Date.now() - startTime, logs: this.logs }; } // 决定传输模式 const transferMode = this.decideTransferMode(files.length); this.log(`📦 使用传输模式: ${transferMode}`, LogLevel.INFO); // 如果是试运行,只显示将要上传的文件 if (options.dryRun) { this.log('🔍 试运行模式,以下文件将被上传:', LogLevel.INFO); files.forEach(file => { this.log(` ${file.localPath} -> ${file.remotePath}`, LogLevel.INFO); }); this.log(`📦 将使用 ${transferMode} 模式传输`, LogLevel.INFO); return { success: true, uploadedFiles: 0, deletedFiles: 0, duration: Date.now() - startTime, logs: this.logs }; } // 根据传输模式执行不同的传输逻辑 if (transferMode === TransferMode.ARCHIVE) { // 使用压缩传输 const result = await this.runArchiveTransfer(files); uploadedFiles = result.uploadedFiles; } else { // 使用单文件传输 // 连接到服务器 await this.connect(); // 如果需要清空远程目录 if (this.config.run.cleanRemote) { deletedFiles = await this.cleanRemoteDir(this.config.run.remotePath); this.log(`🗑️ 已清理 ${deletedFiles} 个文件`, LogLevel.INFO); } // 确保远程根目录存在 await this.ensureRemoteDir(this.config.run.remotePath); // 上传文件 this.log('📤 开始上传文件...', LogLevel.INFO); const result = await this.runSingleTransfer(files); uploadedFiles = result.uploadedFiles; await this.disconnect(); } const duration = Date.now() - startTime; this.log(`✅ 运行完成! 用时 ${duration}ms`, LogLevel.INFO); this.log(`📊 上传: ${uploadedFiles} 个文件`, LogLevel.INFO); if (deletedFiles > 0) { this.log(`📊 删除: ${deletedFiles} 个文件`, LogLevel.INFO); } return { success: true, uploadedFiles, deletedFiles, duration, logs: this.logs }; } catch (error) { await this.disconnect(); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.log(`❌ 运行失败: ${errorMessage}`, LogLevel.ERROR); return { success: false, uploadedFiles, deletedFiles, duration: Date.now() - startTime, error: errorMessage, logs: this.logs }; } } } /** * 执行SFTP运行 * @param config SFTP配置 * @param options 运行选项 * @returns 运行结果 */ export async function run(config, options = {}) { const runner = new SftpRunner(config, options.verbose); return await runner.run(options); }