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