UNPKG

@itriton/sftp

Version:

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

343 lines (342 loc) 13.8 kB
import fs from 'fs'; import path from 'path'; import archiver from 'archiver'; import { NodeSSH } from 'node-ssh'; import SftpClient from 'ssh2-sftp-client'; import chalk from 'chalk'; import { LogLevel } from './types.js'; /** * 压缩传输器类 */ export class ArchiveTransfer { sftp; ssh; config; verbose; logs = []; constructor(config, verbose = false) { this.sftp = new SftpClient(); this.ssh = new NodeSSH(); 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); } } } /** * 连接到服务器 */ async connect() { try { this.log('正在连接到服务器...', 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('必须提供密码或私钥进行认证'); } // 同时连接 SFTP 和 SSH await Promise.all([ this.sftp.connect(connectConfig), this.ssh.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); } } /** * 断开连接 */ async disconnect() { try { const promises = []; if (this.sftp) { promises.push(this.sftp.end().catch(() => { })); } if (this.ssh) { promises.push(Promise.resolve(this.ssh.dispose()).catch(() => { })); } await Promise.all(promises); this.log('已断开服务器连接', LogLevel.DEBUG); } catch (error) { this.log(`断开连接时出错: ${error instanceof Error ? error.message : 'Unknown error'}`, LogLevel.WARN); } } /** * 创建压缩包 * @param files 文件列表 * @param archivePath 压缩包路径 * @returns Promise<void> */ async createArchive(files, archivePath) { return new Promise((resolve, reject) => { const output = fs.createWriteStream(archivePath); // 根据格式创建不同的压缩器 const format = this.config.run.archiveFormat || 'tar.gz'; let archive; if (format === 'zip') { archive = archiver('zip', { zlib: { level: 9 } // 最高压缩级别 }); } else { // tar.gz 格式 archive = archiver('tar', { gzip: true, gzipOptions: { level: 9, memLevel: 9 } }); } this.log(`📦 创建压缩包格式: ${format}`, LogLevel.DEBUG); output.on('close', () => { this.log(`✅ 压缩包创建完成: ${archivePath} (${archive.pointer()} bytes)`, LogLevel.DEBUG); resolve(); }); archive.on('error', (err) => { this.log(`❌ 压缩包创建失败: ${err.message}`, LogLevel.ERROR); reject(err); }); archive.pipe(output); // 获取基础路径用于计算相对路径 const basePath = this.config.run.localPath; // 添加文件到压缩包 for (const file of files) { const relativePath = path.relative(basePath, file.localPath); archive.file(file.localPath, { name: relativePath }); this.log(`📁 添加文件到压缩包: ${relativePath}`, LogLevel.DEBUG); } archive.finalize(); }); } /** * 上传压缩包 * @param localArchivePath 本地压缩包路径 * @param remoteArchivePath 远程压缩包路径 */ async uploadArchive(localArchivePath, remoteArchivePath) { try { this.log('📤 正在上传压缩包...', LogLevel.INFO); // 确保远程目录存在 const remoteDir = path.posix.dirname(remoteArchivePath); const exists = await this.sftp.exists(remoteDir); if (!exists) { await this.sftp.mkdir(remoteDir, true); } // 上传压缩包 await this.sftp.put(localArchivePath, remoteArchivePath); const stats = fs.statSync(localArchivePath); this.log(`✅ 压缩包上传完成: ${remoteArchivePath} (${stats.size} bytes)`, LogLevel.INFO); } catch (error) { throw new Error(`上传压缩包失败: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * 验证远程压缩包完整性 * @param remoteArchivePath 远程压缩包路径 * @param archiveName 压缩包文件名 */ async verifyArchive(remoteArchivePath, archiveName) { try { this.log('🔍 正在验证压缩包完整性...', LogLevel.INFO); // 检查文件是否存在 const exists = await this.sftp.exists(remoteArchivePath); if (!exists) { throw new Error(`远程压缩包不存在: ${remoteArchivePath}`); } // 获取文件大小 const stat = await this.sftp.stat(remoteArchivePath); if (stat.size === 0) { throw new Error('远程压缩包大小为0,可能上传失败'); } this.log(`📊 远程压缩包大小: ${stat.size} bytes`, LogLevel.DEBUG); // 根据格式验证压缩包 const format = this.config.run.archiveFormat || 'tar.gz'; let testCommand; if (format === 'zip') { testCommand = `cd ${this.config.run.remotePath} && unzip -t ${archiveName}`; } else { // tar.gz 格式 testCommand = `cd ${this.config.run.remotePath} && tar -tzf ${archiveName} > /dev/null`; } this.log(`执行验证命令: ${testCommand}`, LogLevel.DEBUG); const result = await this.ssh.execCommand(testCommand); if (result.code !== 0) { this.log(`验证命令错误输出: ${result.stderr}`, LogLevel.ERROR); throw new Error(`压缩包验证失败: ${result.stderr || '压缩包可能损坏'}`); } this.log('✅ 压缩包验证通过', LogLevel.INFO); } catch (error) { throw new Error(`压缩包验证失败: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * 在服务器上解压文件 * @param remoteArchivePath 远程压缩包路径 * @param archiveName 压缩包文件名 */ async extractArchive(remoteArchivePath, archiveName) { try { this.log('📦 正在解压文件...', LogLevel.INFO); // 根据压缩格式选择解压命令 const format = this.config.run.archiveFormat || 'tar.gz'; let extractCommand; if (this.config.run.extractCommand) { // 使用用户自定义的解压命令 extractCommand = this.config.run.extractCommand; } else { // 使用默认解压命令 if (format === 'zip') { extractCommand = 'cd {remotePath} && unzip -o {archiveName} && rm {archiveName}'; } else { // tar.gz 格式 extractCommand = 'cd {remotePath} && tar -xzf {archiveName} && rm {archiveName}'; } } // 替换变量 const finalCommand = extractCommand .replace(/{remotePath}/g, this.config.run.remotePath) .replace(/{archiveName}/g, archiveName); this.log(`执行解压命令: ${finalCommand}`, LogLevel.DEBUG); // 执行解压命令 const result = await this.ssh.execCommand(finalCommand); if (result.code !== 0) { this.log(`解压命令标准输出: ${result.stdout}`, LogLevel.DEBUG); this.log(`解压命令错误输出: ${result.stderr}`, LogLevel.ERROR); throw new Error(`解压命令执行失败: ${result.stderr || result.stdout}`); } this.log('✅ 文件解压完成', LogLevel.INFO); if (result.stdout) { this.log(`解压输出: ${result.stdout}`, LogLevel.DEBUG); } } catch (error) { throw new Error(`解压文件失败: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * 执行压缩传输 * @param files 文件列表 * @returns 传输结果 */ async transfer(files) { const startTime = Date.now(); let localArchivePath = null; try { if (files.length === 0) { return { success: true, duration: Date.now() - startTime, logs: this.logs }; } this.log(`🗜️ 开始压缩传输 ${files.length} 个文件...`, LogLevel.INFO); // 连接到服务器 await this.connect(); // 创建临时压缩包 const timestamp = Date.now(); const archiveFormat = this.config.run.archiveFormat || 'tar.gz'; const archiveName = `deploy-${timestamp}.${archiveFormat}`; localArchivePath = path.join(process.cwd(), archiveName); const remoteArchivePath = path.posix.join(this.config.run.remotePath, archiveName); // 创建压缩包 this.log('🗜️ 正在创建压缩包...', LogLevel.INFO); await this.createArchive(files, localArchivePath); // 上传压缩包 await this.uploadArchive(localArchivePath, remoteArchivePath); // 验证压缩包完整性 await this.verifyArchive(remoteArchivePath, archiveName); // 解压文件 await this.extractArchive(remoteArchivePath, archiveName); await this.disconnect(); const duration = Date.now() - startTime; this.log(`✅ 压缩传输完成! 用时 ${duration}ms`, LogLevel.INFO); return { success: true, 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, duration: Date.now() - startTime, error: errorMessage, logs: this.logs }; } finally { // 清理本地临时文件 if (localArchivePath && fs.existsSync(localArchivePath)) { try { fs.unlinkSync(localArchivePath); this.log(`🗑️ 已清理临时压缩包: ${localArchivePath}`, LogLevel.DEBUG); } catch (error) { this.log(`警告: 清理临时文件失败: ${error instanceof Error ? error.message : 'Unknown error'}`, LogLevel.WARN); } } } } /** * 获取日志 */ getLogs() { return this.logs; } }