@itriton/sftp
Version:
基于ssh2-sftp-client的项目部署工具,支持密钥证书登录和配置文件管理
343 lines (342 loc) • 13.8 kB
JavaScript
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;
}
}