swd-deploy
Version:
swd-deploy 一个简单方便的前端自动部署工具,可通过使用 npm 将包安装到你的项目中。
578 lines (461 loc) • 17.9 kB
JavaScript
const chalk = require('chalk') //命令行颜色
const fs = require('fs')
const CliProgress = require('cli-progress');
const ora = require('ora') // 加载流程动画
const spinner_style = require('./src/spinner_style') //加载动画样式
const shell = require('shelljs') // 执行shell命令
const Client = require('ssh2-sftp-client') // ssh连接服务器
const inquirer = require('inquirer') //命令行交互
const zipFile = require('compressing') // 压缩zip
// const fs = require('fs') // nodejs内置文件模块
const path = require('path') // nodejs内置路径模块
const colors = require('ansi-colors');
// const { NodeSSH } = require('node-ssh') // ssh连接服务器
// const nodeSSH = new NodeSSH()
//logs
const defaultLog = log => console.log(chalk.blue(`☀ ${log}`))
const errorLog = log => console.log(chalk.red(`✘ ${log}`))
// const warningLog = log => console.log(chalk.yellow(`◎ ${log}`))
const successLog = log => console.log(chalk.green(`✔ ${log}`))
const SSH = new Client()
console.log(chalk.green(`☺ 欢迎使用自动部署工具!`))
let config = {} // 用于保存 inquirer 命令行交互后选择正式|测试版的配置
const getOption = () => {
const arr = process.argv.slice(2); // 获取命令行参数数组
const r = arr.reduce((pre, item) => { // 使用reduce方法对参数数组进行处理
if (item.indexOf("=") !== -1) { // 判断参数是否有等号
return [...pre, item.split("=")]; // 将带有等号的参数进行分割并添加到结果数组中
}
return pre; // 否则返回原结果数组
}, []);
if (r.length == 0) {
return false
}
const params = Object.fromEntries(r); // 将结果数组转化为参数对象
return params; // 返回参数对象
}
let params = getOption()
const pathHierarchy = params['--config'] || process.cwd()
const getFilePath = () => {
let wwwPath = params['--wwwPath'] || config.wwwPath || "";
let distDir = config.distFolder || config.localPath || "";
if ((!wwwPath) || (wwwPath == '/') || (wwwPath == '\\')) {
throw new Error('请输入正确的 wwwPath 路径!')
}
const dirName = path.basename(distDir);
// './' //脚本到项目的层级 项目/node_modules/
distDir = path.join(pathHierarchy, distDir)
const distZipPath = path.join(distDir, "../", dirName + ".zip")
const wwwZipPath = path.join(wwwPath, path.basename(distZipPath))
// const distZipPath = path.resolve(__dirname, `${pathHierarchy + distDir}.zip`)
// distDir = path.resolve(__dirname, `${pathHierarchy + distDir}`)
return { distDir, distZipPath, wwwPath, wwwZipPath, dirName }
}
// deploy - node / index.js
let CONFIG = {};
// let isExitConfigFile = true;
try {
CONFIG = require(path.join(pathHierarchy, 'deploy.config.js')) // 项目配置
// CONFIG = require(path.resolve(__dirname, `${pathHierarchy}deploy.config.js`)) // 项目配置
} catch (error) {
console.log(error);
if (params['--host'] && params['--password'] && params['--wwwPath'] && params['--localPath']) {
// 命令行参数优先级最高
// isExitConfigFile = false
} else {
errorLog('请在项目根目录添加 deploy.config.js 配置文件。')
console.log(colors.grey('参考说明文档中的配置:https://github.com/zlluGitHub/swd-deploy'));
process.exit() //退出流程
}
}
//项目打包代码 npm run build
const buildDist = async () => {
const loading = ora(defaultLog('开始打包项目')).start()
loading.spinner = spinner_style[config.loadingStyle || 'arrow4']
shell.cd(pathHierarchy)
// shell.cd(path.resolve(__dirname, pathHierarchy))
const exec = params['--build'] || config.build || config.buildShell || 'npm run build'
const res = await shell.exec(exec) //执行shell 打包命令
loading.stop()
if (res.code === 0) {
successLog('项目打包成功!')
return true
} else {
errorLog('项目打包失败, 请重试!')
process.exit() //退出流程
// return false
}
}
//线上执行命令
/**
* @param {String} command 命令操作 如 ls
*/
const runCommand = async (command) => {
// const { wwwPath } = getFilePath()
return new Promise((resolve, reject) => {
SSH.client.exec(command, (error, stream) => {
if (error) {
// console.log(error);
reject(error)
resolve({ type: 'error', error })
} else {
let output = '';
stream.on('data', (data) => {
output += data;
}).on('end', () => {
// defaultLog(output)
resolve(output)
// console.log(output);
}).on('close', (code, signal) => {
// console.log('Stream close!')
})
}
})
})
// const result = await nodeSSH.exec(command, [], {
// cwd: wwwPath
// }).catch(err => {
// errorLog(err)
// process.exit() //退出流程
// })
// defaultLog(result)
}
const getConnectSshOption = () => {
//privateKey 秘钥登录(推荐) 方式一
//password 密码登录 方式二
const type = (params['--password'] || config.password) ? 'password' : 'privateKey'
const data = (params['--password'] || config.password) || config.privateKey
return {
host: params['--host'] || config.sshIp || config.host,
username: params['--username'] || config.username || config.sshUserName || 'root',
port: params['--port'] || config.port || 22,
[type]: data,
tryKeyboard: true,
readyTimeout: params['--readyTimeout'] || config.readyTimeout || 60000
}
// if (config.readyTimeout) {
// opt.
// }
}
//连接服务器
const connectSSH = async () => {
const loading = ora(defaultLog('正在连接服务器')).start()
try {
loading.spinner = spinner_style[config.loadingStyle || 'arrow4']
await SSH.connect(getConnectSshOption())
successLog('服务器连接成功!')
} catch (error) {
errorLog('SSH连接失败! (可能原因: 1:密码不对, 2:privateKey 本机私钥地址不对, 3:服务器未配置本机公钥, 4:服务器未安装 SSH 服务或端口), 5:使用命令参数时, 请检查是否配置了 --key 参数')
process.exit() //退出流程
}
loading.stop()
}
const formatNodePath = (filePath) => {
// 返回格式化后的路径
return filePath.replace(/\\/g, '/')
}
const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 B'; //Bytes
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + '' + sizes[i];
}
const getIgnoreFileArr = (localPath) => {
let ignoreFileArr = []
if (params['--localPath']) {
ignoreFileArr = splitPath(params['--ignoreFiles'])
} else if (config.ignoreFiles) {
if (typeof config.ignoreFiles == 'object') {
ignoreFileArr = config.ignoreFiles
} else {
ignoreFileArr = splitPath(config.ignoreFiles)
}
}
const findItem = ignoreFileArr.find(item => localPath.indexOf(item) > -1)
return !!findItem
}
const uploadFile = async (localPath, remotePath, stats) => {
const isIgnore = getIgnoreFileArr(localPath)
if (isIgnore) return
defaultLog(`正在上传 ${localPath} 文件`)
const b1 = new CliProgress.Bar({
format: '上传进度:[' + colors.cyan('{bar}') + '] {percentage}% ' + colors.magenta('{size} ') + colors.green('{speed}'),
// barCompleteChar: '\u2588',
// barIncompleteChar: '\u2591',
barCompleteChar: '#',
barIncompleteChar: '-',
hideCursor: true
});
let totalTransferredW1 = 0;
let totalTransferredW2 = 0;
let totalW = 1;
b1.start(100, 0, {
speed: "N/A",
size: '0KB/0KB'
});
const startTime = new Date().getTime();
const timer = setInterval(() => {
b1.update((((totalTransferredW1 / totalW) * 100).toFixed(2)) * 1, {
speed: formatBytes(totalTransferredW1 - totalTransferredW2) + '/s',
size: formatBytes(totalTransferredW1) + '/' + formatBytes(totalW)
});
totalTransferredW2 = totalTransferredW1;
}, 1000)
// console.log(localPath, remotePath);
await SSH.fastPut(localPath, remotePath, {
step: (totalTransferred, chunk, total) => {
totalTransferredW1 = totalTransferred;
totalW = total;
}
})
const endTime = new Date().getTime();
const time = (endTime - startTime) / 1000;
b1.update(100, {
speed: formatBytes(totalW / time) + '/s',
size: formatBytes(totalW) + '/' + formatBytes(totalW)
});
clearInterval(timer)
b1.stop();
// loading.stop()
successLog(`文件 ${localPath} 上传成功!`)
}
const uploadDirectory = async (localDir, remoteDir) => {
const isIgnore = getIgnoreFileArr(localDir)
if (isIgnore) return
const files = fs.readdirSync(localDir)
for (let i = 0; i < files.length; i++) {
const fileName = files[i]
const localFilePath = formatNodePath(path.join(localDir, fileName))
const remoteFilePath = formatNodePath(path.join(remoteDir, fileName))
const stats = fs.statSync(localFilePath)
if (stats.isFile()) {
await uploadFile(localFilePath, remoteFilePath, stats)
} else if (stats.isDirectory()) {
// console.log(localFilePath,'==> dir');
await SSH.mkdir(remoteFilePath, true)
await uploadDirectory(localFilePath, remoteFilePath, stats)
}
}
}
const fuh = [',', '、', ';', ';']
const splitPath = (str) => {
const fgf = fuh.find(item => str.indexOf(item) > -1)
return str.split(fgf)
}
const updateDirFile = async () => {
let pathArr = []
if (params['--localPath']) {
pathArr = splitPath(params['--localPath'])
} else {
const localPaths = config.distFolder || config.localPath || false
if (!localPaths) throw new Error();
if (typeof localPaths == 'object') {
pathArr = localPaths
} else {
pathArr = splitPath(localPaths)
}
}
for (let i = 0; i < pathArr.length; i++) {
const localPath = pathArr[i] //path.resolve(__dirname, pathArr[i])
const stats = fs.statSync(localPath)
let wwwPath = params['--wwwPath'] || config.wwwPath
if (stats.isFile()) {
if (wwwPath.indexOf('.' == -1)) {
wwwPath = wwwPath + '/' + path.basename(localPath)
}
// console.log(localPath, 9999999, wwwPath);
await uploadFile(localPath, wwwPath)
} else if (stats.isDirectory()) {
await SSH.mkdir(wwwPath, true)
await uploadDirectory(localPath, wwwPath)
successLog(`文件夹 ${localPath} 中的所有文件已上传成功!`)
}
}
}
//压缩代码 文件夹目录
const zipDistDirFile = async () => {
// defaultLog('')
const loading = ora(defaultLog('正在压缩项目...')).start()
try {
const { distDir, distZipPath } = getFilePath()
// if (!distDir) throw new Error();
loading.spinner = spinner_style[config.loadingStyle || 'arrow4']
await zipFile.zip.compressDir(distDir, distZipPath)
// successLog('压缩成功!')
loading.stop()
} catch (error) {
loading.stop()
errorLog('压缩失败:' + error)
// errorLog(', 退出程序!')
process.exit() //退出流程
}
}
//传送zip文件到服务器
const uploadZipBySSH = async () => {
try {
// let wwwPath = params['--wwwPath'] || config.wwwPath
// const { distDir, distZipPath } = getFilePath()
// const localPath = config.distFolder || config.localPath || false
// wwwPath = wwwPath + '/' + path.basename(localPath)
const { distZipPath, wwwZipPath, dirName } = getFilePath()
await uploadFile(formatNodePath(distZipPath), config.isCompress ? `/root/.swd_deploy/${dirName}.zip` : wwwZipPath)
} catch (error) {
errorLog(error)
process.exit() //退出流程
}
}
//传送文件到服务器
const updateConnectZipFile = async () => {
try {
// 压缩文件
await zipDistDirFile()
//连接ssh
await connectSSH()
if (config.isCompress) {
// 创建临时目录
await runCommand(`rm -rf /root/.swd_deploy`)
await runCommand(`mkdir /root/.swd_deploy`)
// 上传压缩文件
await uploadZipBySSH()
if (config.isCompress) {
const { wwwZipPath, dirName, wwwPath, distDir } = getFilePath()
const loading = ora(defaultLog(`正在解压缩 ${dirName}.zip 文件...`)).start()
loading.spinner = spinner_style[config.loadingStyle || 'arrow4']
// await nodeSSH.connect(getConnectSshOption())
// const remPathZip = formatNodePath(wwwZipPath)
const wwwPathDist = formatNodePath(wwwPath)
// console.log(remPathZip);
await runCommand(`rm -rf ${wwwPathDist}`)
await runCommand(`mkdir ${wwwPathDist}`)
await runCommand(`unzip -o /root/.swd_deploy/${dirName}.zip -d /root/.swd_deploy`) //解压
await runCommand(`mv -f /root/.swd_deploy/${dirName}/* ${wwwPathDist}`) //移动
await runCommand(`rm -rf /root/.swd_deploy`)
loading.stop()
successLog(`文件 ${dirName}.zip 解压成功!`)
// await runCommand(`rm -rf ${wwwPathDist}/${dirName}`) //解压完删除线上压缩包
// await runCommand(`rm -rf /root/.swd_deploy`) //解压完删除线上压缩包
}
//将目标目录的dist里面文件移出到目标文件
//举个例子 假如我们部署在 /test/html 这个目录下 只有一个网站, 那么上传解压后的文件在 /test/html/dist 里
//需要将 dist 目录下的文件 移出到 /test/html 多网站情况, 如 /test/html/h5 或者 /test/html/admin 都和上面同样道理
// await runCommand(`mv -f ${config.wwwPath}/${config.distFolder}/* ${config.wwwPath}`)
// await runCommand(`rm -rf ${config.wwwPath}/${config.distFolder}`) //移出后删除 dist 文件夹
// await runCommand(`rm -f ${config.wwwPath}/${config.distFolder}`) //移出后删除 dist 文件夹
} else {
// 上传文件
await updateDirFile()
}
SSH.end()
// nodeSSH.dispose() //断开连接
} catch (error) {
SSH.end()
// nodeSSH.dispose() //断开连接
errorLog(error)
errorLog('上传失败,请检查文件或文件夹路径是否正确!')
process.exit() //退出流程
}
// loading.stop()
}
//------------发布程序---------------
const runUploadTask = async () => {
//打包
if (params['--build'] || config.build || config.buildShell) {
await buildDist()
// if (!res) return
}
await updateConnectZipFile()
successLog('大吉大利, 部署成功!ヾ(@^▽^@)ノ')
process.exit()
}
// 开始前的配置检查
/**
*
* @param {Object} conf 配置对象
*/
// const checkConfig = (conf) => {
// const checkArr = Object.entries(conf)
// checkArr.map(it => {
// const key = it[0]
// if (conf[key] === '/') { //上传zip前会清空目标目录内所有文件
// errorLog('buildShell 不能是服务器根目录!')
// process.exit() //退出流程
// }
// if (!conf[key]) {
// errorLog(`配置项 ${key} 不能为空`)
// process.exit() //退出流程
// }
// })
// }
// console.log(params);
let choices = [];
for (const key in CONFIG) {
choices.push({
name: CONFIG[key].title || `发布到 ${params['--host'] || config.sshIp || config.host} 服务器环境`,
value: key
})
};
if (choices.length === 0) {
choices = [{
name: '测试环境',
value: 'development'
}, {
name: '正式环境',
value: 'production'
}]
}
const initAnswers = async (key) => {
config = key ? CONFIG[key] : {};
// config.distFolder = config.distFolder || config.distFolder.replace("/", "");
//文件夹目录
// console.log(answers.env);
const localPaths = params['--localPath'] || config.distFolder || config.localPath || false
if (!localPaths) {
errorLog('请配置本地打包目录,或检查 “deploy.config.js” 文件是否在根目录下!')
process.exit() //退出流程
// return
};
// distDir = path.resolve(__dirname, `${pathHierarchy + config.distFolder}`) //待打包
// distZipPath = path.resolve(__dirname, `${pathHierarchy + config.distFolder}.zip`) //打包后地址(dist.zip是文件名,不需要更改, 主要在config中配置 PATH 即可)
// checkConfig(config) // 检查
await runUploadTask() // 发布
}
// path.resolve(__dirname, 'D:/zx/zxczx')
// 获取执行命令的路径
// const execPath = process.argv[1];
// console.log('当前执行命令的路径:', execPath);
// console.log(path.resolve(__dirname, './'));
// return
if (params) {
// if (isExitConfigFile) {
// if (!params['--key']) {
// errorLog('请指定 --key 参数!')
// process.exit() //退出流程
// // return
// }
// }
let key = params['--key']
if (key && (!CONFIG[key])) {
errorLog('请检查 “deploy.config.js” 文件是否配置 --key 参数!')
process.exit() //退出流程
}
initAnswers(key)
} else {
params = {}
// 执行交互后 启动发布程序
// for (const key in CONFIG) {
// // const element = CONFIG[key];
// }
inquirer
.prompt([{
type: 'list',
message: '请选择发布环境',
name: 'env',
choices
}])
.then(answers => {
initAnswers(answers.env)
})
}