ql-publish
Version:
533 lines (477 loc) • 17.9 kB
JavaScript
const config = require('../config/config.js')[process.env.NODE_ENV];
const scpClient = require('scp2');
const path = require('path');
const logger = require('../utils/logger');
const { Client } = require('ssh2');
const { PassThrough } = require('stream');
const dayjs = require("dayjs");
const refreshCdn = require("../cdn/refresh");
const readline = require("readline");
const deleteFolder = () => {
return new Promise(resolve => {
logger.warn('开始连接服务器');
// 创建 SSH 客户端
const conn = new Client();
conn.on('ready', () => {
logger.log('✅ 已成功连接到服务器');
// 执行清空文件夹的命令(保留文件夹本身)
// 命令解释:
// - 先检查文件夹是否存在
// - 再删除文件夹内所有文件和子目录
const clearCommand = `
if [ -d "${config.path}" ]; then
find "${config.path}" -mindepth 1 -delete
echo "文件夹内容已清空"
else
echo "错误:文件夹 ${config.path} 不存在"
exit 1
fi
`;
conn.exec(clearCommand, (err, stream) => {
if (err) throw err;
stream.on('close', (code) => {
if (code === 0 || code === 1) {
logger.info(`✅ 成功清空文件夹: ${config.path}`);
resolve(true);
} else {
logger.error(`❌ 清空文件夹失败,退出码: ${code}`);
resolve(null);
}
conn.end(); // 关闭连接
}).on('data', () => {
// console.log(`输出: ${data}`);
}).stderr.on('data', (data) => {
logger.error(`❌ 错误: ${data}`);
});
});
}).connect(config);
conn.on('error', (err) => {
logger.error('❌ 连接错误:', err);
resolve(null);
});
conn.on('end', () => {});
})
}
/**
* 获取文件列表
*/
const getFileList = (sftp) => {
return new Promise(async resolve => {
const authDirectoryByName = (name) => {
return new Promise(r => {
// 检查目录是否存在
sftp.stat(name, (err, stats) => {
if (!err && stats.isDirectory()) {
r(true)
return;
}
// 目录不存在,创建目录
sftp.mkdir(name, (err) => {
if (err) {
console.error(`❌ 创建目录 ${name} 失败:`, err);
process.exit(1);
r(false)
} else {
logger.log(`✅ 目录 ${name} 创建成功`);
r(true)
}
});
});
})
}
if (!await authDirectoryByName(`${config.path}_old`)) {
process.exit(1);
return
}
// 读取目录内容
sftp.readdir(`${config.path}_old`, (err, list) => {
if (err) {
console.error('读取目录失败:', err);
process.exit(1);
return;
}
// 遍历并显示目录内容
resolve(list.map(item => {
return item.filename
}));
});
})
}
/**
* 确保目录存在,如果不存在则创建
* @param {Object} sftp - SFTP客户端实例
* @param {string} dirPath - 目录路径
*/
function ensureDirectoryExists(sftp, dirPath) {
return new Promise((resolve, reject) => {
sftp.stat(dirPath, (err) => {
if (!err) {
// 目录已存在
resolve();
return;
}
// 递归创建父目录
const parentDir = path.posix.dirname(dirPath);
if (parentDir === dirPath) {
// 已经是根目录
reject(new Error(`无法创建目录: ${dirPath}`));
return;
}
// 先确保父目录存在,再创建当前目录
ensureDirectoryExists(sftp, parentDir)
.then(() => {
sftp.mkdir(dirPath, (err) => {
if (err) {
reject(new Error(`创建目录失败 ${dirPath}: ${err.message}`));
} else {
// console.log(`已创建目录: ${dirPath}`);
resolve();
}
});
})
.catch(reject);
});
});
}
const reduceLog = (n = 1.98) => {
return Math.random() * 2 > n
}
/**
* 复制单个文件
* @param {Object} sftp - SFTP客户端实例
* @param {string} sourcePath - 源文件路径
* @param {string} targetPath - 目标文件路径
*/
function copyFile(sftp, sourcePath, targetPath) {
return new Promise((resolve) => {
const readStream = sftp.createReadStream(sourcePath);
const writeStream = sftp.createWriteStream(targetPath);
const transferStream = new PassThrough();
// 错误处理
readStream.on('error', (err) => {
console.error(`读取文件失败 ${sourcePath}: ${err.message}`);
resolve(false);
});
writeStream.on('error', (err) => {
console.error(`写入文件失败 ${targetPath}: ${err.message}`);
resolve(false);
});
writeStream.on('close', () => {
// console.log(`写入流已关闭: ${targetPath}`);
reduceLog(1.9) && logger.log(`✅ 已复制: ${targetPath}`);
resolve(true);
});
// 完成处理
writeStream.on('finish', () => {
// console.log(`已复制: ${sourcePath}`);
resolve(true);
});
// 管道传输
readStream.pipe(transferStream).pipe(writeStream);
});
}
/**
* 递归复制目录内容
* @param {Object} sftp - SFTP客户端实例
* @param {string} currentSourceDir - 当前源目录
* @param {string} currentTargetDir - 当前目标目录
*/
async function recursiveCopy(sftp, currentSourceDir, currentTargetDir) {
// 确保目标目录存在
await ensureDirectoryExists(sftp, currentTargetDir);
// 获取源目录中的所有项目
const items = await new Promise((resolve, reject) => {
sftp.readdir(currentSourceDir, (err, list) => {
if (err) {
reject(new Error(`读取目录失败 ${currentSourceDir}: ${err.message}`));
} else {
resolve(list);
}
});
});
// 处理每个项目
for (const item of items) {
const sourcePath = path.posix.join(currentSourceDir, item.filename);
const targetPath = path.posix.join(currentTargetDir, item.filename);
if (item.attrs.isDirectory()) {
// 如果是目录,递归处理
await recursiveCopy(sftp, sourcePath, targetPath);
} else {
// 如果是文件,直接复制
await copyFile(sftp, sourcePath, targetPath);
}
}
}
// await readStreamByFile(sftp, `${config.path}/fileMap.json`)
const readStreamByFile = async (sftp, remoteFilePath) => {
return new Promise((resolve) => {
console.log(`准备读取文件: ${remoteFilePath}`);
// 创建读取流
const readStream = sftp.createReadStream(remoteFilePath, {
encoding: 'utf8' // 以UTF-8编码读取
});
let fileContent = '';
// 收集文件内容
readStream.on('data', (chunk) => {
fileContent += chunk;
});
// 读取完成处理
readStream.on('end', () => {
try {
// 解析JSON数据
const jsonData = JSON.parse(fileContent);
console.log('文件内容解析成功:');
console.log(jsonData.deleteList);
} catch (parseErr) {
logger.error('JSON解析失败:', parseErr);
console.log('原始文件内容:', fileContent);
process.exit(1);
}
});
// 处理读取错误
readStream.on('error', (err) => {
logger.error('读取文件时发生错误:', err);
process.exit(1);
});
})
}
const writeStreamToFile = async (sftp, data, filePath) => {
return new Promise((resolve) => {
// 使用SFTP创建文件并写入内容
// 注意:如果文件已存在,会被覆盖
const writeStream = sftp.createWriteStream(filePath, {
flags: 'w', // 写入模式,会覆盖现有文件
encoding: 'utf8', // 编码格式
mode: 0o644 // 文件权限(可读可写)
});
// 写入JS模块内容
writeStream.end(data);
// 处理写入完成事件
writeStream.on('finish', () => {
console.log(`文件 ${filePath} 已成功创建并写入数据`);
resolve();
});
// 处理写入完成事件
writeStream.on('close', () => {
logger.warn(`✅ 旧项目文件索引已成功创建并写入数据,${filePath}`);
resolve();
});
// 处理写入错误
writeStream.on('error', (err) => {
logger.error('❌ 写入文件时发生错误:', err);
process.exit(1);
});
})
}
let fileMapData = {
mtime: 0
}
const creatFileMap = async (sftp, currentSourceDir) => {
// 获取源目录中的所有项目
const items = await new Promise((resolve, reject) => {
sftp.readdir(currentSourceDir, (err, list) => {
if (err) {
reject(new Error(`读取目录失败 ${currentSourceDir}: ${err.message}`));
} else {
resolve(list);
}
});
});
// 处理每个项目
for (const item of items) {
const sourcePath = path.posix.join(currentSourceDir, item.filename);
if (item.attrs.isDirectory()) {
// 如果是目录,递归处理
await creatFileMap(sftp, sourcePath);
} else {
fileMapData.mtime = item.attrs.mtime > fileMapData.mtime ? item.attrs.mtime : fileMapData.mtime;
// if (sourcePath.indexOf(`assets`) > -1) {
// if (sourcePath.indexOf(`assets/_plugin`) === -1) {
// fileMapData.deleteList.push(sourcePath)
// } else {
// fileMapData.unDeleteList.push(sourcePath);
// }
// } else {
// fileMapData.unDeleteList.push(sourcePath);
// }
}
}
}
const sftpReaddir = async (sftp, pathStr) => {
return new Promise((resolve) => {
// 读取目录内容
sftp.readdir(pathStr, (err, entries) => {
if (err) {
logger.error(`❌ 读取目录失败 ${pathStr}: ${err.message}`);
resolve([]);
} else {
resolve(entries);
}
});
})
}
// 递归删除文件夹及其内容
async function deleteRemoteDirectory(sftp, pathStr) {
try {
// 获取目录下的所有条目
const entries = await sftpReaddir(sftp, pathStr);
// 先删除目录中的所有内容
for (const entry of entries) {
const fullPath = `${pathStr}/${entry.filename}`;
// 判断是文件还是目录
if (entry.attrs.isDirectory()) {
// 递归删除子目录
await deleteRemoteDirectory(sftp, fullPath);
} else {
// 删除文件
await sftp.unlink(fullPath);
}
}
// 最后删除空目录
await sftp.rmdir(pathStr);
logger.info(`✅ 已删除目录: ${pathStr}`);
} catch (err) {
logger.error(`❌ 删除过程中出错: ${err.message}`);
throw err;
}
}
const removeOldDir = async (sftp) => {
logger.warn('正在删除过期备份');
let list = await getFileList(sftp);
list = list.filter(fName => {
let fileDate = fName.split('_');
fileDate.length = 3
if (!fileDate.length) return false;
fileDate = fileDate.join('-');
return dayjs().diff(dayjs(`${fileDate} 00:00:00`), 'seconds') > config.backUpSeconds;
})
for (const item of list) {
// 开始删除操作
await deleteRemoteDirectory(sftp, `${config.path}_old/${item}`);
}
logger.info('✅ 过期备份删除成功');
}
const isHasDir = (sftp, dirPath) => {
return new Promise(resolve => {
sftp.stat(dirPath, (err) => {
resolve(!err);
})
})
}
const sshAction = async () => {
let conn, sftp;
try {
logger.warn('连接到服务器...');
// 连接到服务器
conn = new Client();
await new Promise((resolve, reject) => {
conn.on('ready', resolve);
conn.on('error', (err) => reject(new Error(`❌ 服务器连接失败: ${err.message}`)));
conn.connect(config);
});
logger.info('✅ 已成功连接到服务器');
// 初始化SFTP
sftp = await new Promise((resolve, reject) => {
conn.sftp((err, sftpClient) => {
if (err) {
reject(new Error(`SFTP初始化失败: ${err.message}`));
} else {
resolve(sftpClient);
}
});
});
// 没有项目目录,直接返回
if (!await isHasDir(sftp, config.path)) {
return
}
if (config.backUp) {
// 删除三个月之前的备份
await removeOldDir(sftp);
const oldDir = `${config.path}_old/${dayjs().format("YYYY_MM_DD_HH_mm_ss")}/`;
logger.warn(`正在备份项目文件到: ${oldDir}`);
// 备份上一次的代码
await recursiveCopy(sftp, `${config.path}/`, oldDir);
logger.info('✅ 项目备份完成!');
}
// 生成文件地址路径映像
await creatFileMap(sftp, `${config.path}/`);
if (fileMapData.mtime === 0) {
logger.error(`❌ 旧项目文件索引创建失败`);
}
await writeStreamToFile(sftp, JSON.stringify(fileMapData), `${config.path}/fileMap.json`);
} catch (err) {
logger.error('❌ 过程出错:', err.message);
} finally {
// 关闭连接
if (sftp) sftp.end();
if (conn) conn.end();
// console.log('连接已关闭');
}
}
const scpAction = async () => {
// 清空目标目录文件
// if (!await deleteFolder()) {
// process.exit(1);
// }
logger.warn('开始上传文件到服务器...');
let timer = null;
const runTimer = () => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
if (!timer) return;
logger.log('✅ 拼命上传中...');
runTimer();
}, Math.random() * 5000 + 1000);
}
runTimer();
// 上传文件
scpClient.scp(
config.localPath,
config,
async (err) => {
clearTimeout(timer);
timer = null;
if (err) {
logger.error(`❌ ${process.env.NODE_ENV}环境:上传失败:`);
logger.error(err);
process.exit(1);
} else {
logger.success(`🎉 ${process.env.NODE_ENV}环境:所有文件上传成功!`);
// 刷新cdn
config.cdn && config.cdn.list && await refreshCdn(config.cdn.list);
logger.warn(`接下来1分钟后,记得清理旧项目版本,请手动执行 yarn clear:${process.env.NODE_ENV}`)
process.exit(0);
}
}
);
}
const authProjectName = () => {
return new Promise((resolve) => {
// 创建readline接口
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// 向用户提问
rl.question(`${process.env.NODE_ENV}环境:是否确认发布 ${config.path} 项目? (输入 y 继续) `, (answer) => {
// 检查用户输入是否为'y'(不区分大小写)
if (answer.trim().toLowerCase() === 'y') {
resolve(true);
} else {
logger.error('❌ 发布程序终止');
resolve(false);
}
// 关闭接口
rl.close();
});
})
}
// 上传文件到服务器
async function run() {
if (!await authProjectName()) {
process.exit(1);
}
await sshAction();
await scpAction();
}
module.exports = run;