| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- #!/usr/bin/env node
- import sftpClient from 'ssh2-sftp-client'
- import {
- readFileSync,
- existsSync,
- statSync,
- readdirSync,
- unlinkSync,
- createReadStream,
- writeFileSync
- } from 'fs'
- import { join, dirname } from 'path'
- import { fileURLToPath } from 'url'
- import { SingleBar } from 'cli-progress'
- import { exec, spawn } from 'child_process'
- import { promisify } from 'util'
- import { Command } from 'commander'
- const program = new Command();
- program.option('-n --env <env>', '发布环境', 'dev')
- program.parse(process.argv);
- const options = program.opts();
- const __filename = fileURLToPath(import.meta.url)
- const __dirname = dirname(__filename)
- const projectRoot = join(__dirname, '..')
- // 配置 测试环境 :
- const config = options.env === 'prod' ? {
- host: '60.205.166.0',
- username: 'root',
- password: 'U/N$$XBv', // 运行时输入
- remotePath: '/opt/data/airport-web/dist',
- localPath: join(projectRoot, 'dist'),
- accessLocation: 'http://airport.samsundot.com:9011'
- } : {
- host: '192.168.3.221',
- username: 'root',
- password: 'root', // 运行时输入
- remotePath: '/opt/data/airport-web/dist',
- localPath: join(projectRoot, 'dist')
- }
- // 颜色输出
- const colors = {
- reset: '\x1b[0m',
- bright: '\x1b[1m',
- red: '\x1b[31m',
- green: '\x1b[32m',
- yellow: '\x1b[33m',
- blue: '\x1b[34m',
- magenta: '\x1b[35m',
- cyan: '\x1b[36m'
- }
- function log (message, color = 'reset') {
- console.log(`${colors[ color ]}${message}${colors.reset}`)
- }
- // 获取密码
- async function getPassword () {
- const readline = await import('readline')
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
- })
- return new Promise(resolve => {
- rl.question('🔐 请输入服务器密码: ', password => {
- rl.close()
- resolve(password)
- })
- })
- }
- // 检查并构建 dist 目录
- async function checkAndBuildDist () {
- if (!existsSync(config.localPath)) {
- log('⚠️ dist 目录不存在,开始自动构建...', 'yellow')
- // 检查 package.json 是否存在
- const packageJsonPath = join(projectRoot, 'package.json')
- if (!existsSync(packageJsonPath)) {
- log('❌ package.json 不存在', 'red')
- process.exit(1)
- }
- // 检查是否有 build 脚本
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
- if (!packageJson.scripts || !packageJson.scripts.build) {
- log('❌ package.json 中没有找到 build 脚本', 'red')
- process.exit(1)
- }
- // 执行构建
- log('🔨 正在执行构建命令...', 'cyan')
- const execAsync = promisify(exec)
- try {
- // 优先使用 yarn,如果没有则使用 npm
- let buildCommand = 'npm run build'
- let packageManager = 'npm'
- // 检查是否有 yarn
- try {
- await execAsync('yarn --version', { cwd: projectRoot })
- buildCommand = 'yarn build'
- packageManager = 'yarn'
- log(`使用 ${packageManager} 构建...`, 'cyan')
- } catch (yarnError) {
- log('yarn 不可用,使用 npm 构建...', 'yellow')
- }
- await execAsync(buildCommand, { cwd: projectRoot })
- // 再次检查构建结果
- if (!existsSync(config.localPath)) {
- log('❌ 构建失败,dist 目录仍未生成', 'red')
- process.exit(1)
- }
- log(`✅ 构建完成!(使用 ${packageManager})`, 'green')
- } catch (error) {
- log(`❌ 构建失败: ${error.message}`, 'red')
- process.exit(1)
- }
- } else {
- const stats = statSync(config.localPath)
- if (!stats.isDirectory()) {
- log('❌ dist 不是目录', 'red')
- process.exit(1)
- }
- log('✅ dist 目录检查通过', 'green')
- }
- }
- // 计算目录大小
- function getDirectorySize (dirPath) {
- let totalSize = 0
- const files = readdirSync(dirPath, { withFileTypes: true })
- for (const file of files) {
- const fullPath = join(dirPath, file.name)
- if (file.isDirectory()) {
- totalSize += getDirectorySize(fullPath)
- } else {
- totalSize += statSync(fullPath).size
- }
- }
- return totalSize
- }
- // 格式化文件大小
- function formatSize (bytes) {
- const units = [ 'B', 'KB', 'MB', 'GB' ]
- let size = bytes
- let unitIndex = 0
- while (size >= 1024 && unitIndex < units.length - 1) {
- size /= 1024
- unitIndex++
- }
- return `${size.toFixed(1)}${units[ unitIndex ]}`
- }
- // 创建压缩包
- async function createArchive () {
- const execAsync = promisify(exec)
- const archivePath = join(projectRoot, 'dist.tar.gz')
- log('📦 正在创建压缩包...', 'yellow')
- try {
- // 使用 tar 创建压缩包,排除 macOS 扩展属性
- await execAsync(`tar --no-xattrs -czf "${archivePath}" -C "${config.localPath}" .`)
- const stats = statSync(archivePath)
- log(`✅ 压缩包创建完成,大小: ${formatSize(stats.size)}`, 'green')
- return archivePath
- } catch (error) {
- log(`❌ 创建压缩包失败: ${error.message}`, 'red')
- throw error
- }
- }
- // 获取用户选择
- async function getUserChoice (question) {
- const readline = await import('readline')
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
- })
- return new Promise(resolve => {
- rl.question(question, answer => {
- rl.close()
- resolve(answer.toLowerCase().trim())
- })
- })
- }
- // 主部署函数
- async function deploy () {
- let archivePath = null
- try {
- log('🚀 开始 Node.js 部署...', 'cyan')
- // 检查并构建 dist 目录
- await checkAndBuildDist()
- if (!config.password) {
- // 获取密码
- config.password = await getPassword()
- }
- // 计算总大小
- const totalSize = getDirectorySize(config.localPath)
- log(`📊 总大小: ${formatSize(totalSize)}`, 'blue')
- // 检查是否已有压缩包,支持断点续传
- const existingArchivePath = join(projectRoot, 'dist.tar.gz')
- if (existsSync(existingArchivePath)) {
- log('📦 发现已存在的压缩包,跳过打包步骤', 'yellow')
- archivePath = existingArchivePath
- } else {
- // 创建压缩包
- archivePath = await createArchive()
- }
- // 连接 SFTP
- log('🔌 正在连接服务器...', 'yellow')
- const sftp = new sftpClient()
- await sftp.connect({
- host: config.host,
- username: config.username,
- password: config.password
- })
- log('✅ SFTP 连接成功', 'green')
- // 创建远程目录
- log('📁 创建远程目录...', 'yellow')
- await sftp.mkdir(config.remotePath, true) // true 表示递归创建
- // 检查服务器是否已存在压缩包
- const remoteArchivePath = '/opt/data/airport-web/dist.tar.gz'
- let needUpload = true
- try {
- await sftp.stat(remoteArchivePath)
- log('📦 服务器已存在压缩包', 'yellow')
- // 询问用户是否续传
- const choice = await getUserChoice('是否续传?(y/n): ')
- if (choice === 'y' || choice === 'yes') {
- log('📦 跳过上传步骤,使用现有压缩包', 'green')
- needUpload = false
- } else {
- log('🗑️ 删除服务器压缩包,重新上传...', 'yellow')
- await sftp.unlink(remoteArchivePath)
- log('📤 开始上传新压缩包...', 'yellow')
- }
- } catch (error) {
- // 文件不存在,需要上传
- log('📤 服务器不存在压缩包,开始上传...', 'yellow')
- }
- if (needUpload) {
- // 上传压缩包
- log('📤 正在上传压缩包...', 'yellow')
- // 显示上传进度
- const fileSize = statSync(archivePath).size
- log(`📊 文件大小: ${formatSize(fileSize)}`, 'blue')
- // 创建进度条
- const progressBar = new SingleBar({
- format: '📤 上传进度 |{bar}| {percentage}% | {value}/{total} Bytes | {speed}',
- barCompleteChar: '█',
- barIncompleteChar: '░',
- hideCursor: true
- })
- progressBar.start(fileSize, 0)
- try {
- // 使用流的方式上传,获得实时进度
- const readStream = createReadStream(archivePath)
- const writeStream = await sftp.createWriteStream(remoteArchivePath)
- let uploadedBytes = 0
- const startTime = Date.now()
- // 监听数据流,实时更新进度
- readStream.on('data', chunk => {
- uploadedBytes += chunk.length
- const elapsedTime = (Date.now() - startTime) / 1000
- const speed = elapsedTime > 0 ? (uploadedBytes / elapsedTime).toFixed(2) : '0'
- progressBar.update(uploadedBytes, { speed: `${speed} B/s` })
- })
- // 处理流事件
- readStream.pipe(writeStream)
- // 等待上传完成
- await new Promise((resolve, reject) => {
- writeStream.on('close', () => {
- progressBar.stop()
- log('✅ 压缩包上传完成', 'green')
- resolve()
- })
- writeStream.on('error', err => {
- progressBar.stop()
- reject(err)
- })
- readStream.on('error', err => {
- progressBar.stop()
- reject(err)
- })
- })
- } catch (error) {
- progressBar.stop()
- throw error
- }
- }
- // 在服务器端解压
- log('📦 正在服务器端解压...', 'yellow')
- // 检测操作系统
- const isWindows = process.platform === 'win32'
- const execAsync = promisify(exec)
- // 转义密码(关键步骤!)
- function escapeForExpect (password) {
- return password
- .replace(/\\/g, "\\\\") // 转义反斜杠 \
- .replace(/\$/g, "\\$"); // 转义 $
- }
- if (isWindows) {
- // Windows 系统:使用 PowerShell 脚本(单次密码输入)
- log('🪟 Windows 系统检测到,使用 PowerShell 脚本', 'yellow')
- const runPowerShellScript = (scriptPath) => {
- return new Promise((resolve, reject) => {
- const child = spawn('powershell', [
- '-ExecutionPolicy',
- 'Bypass',
- '-File',
- scriptPath
- ], {
- stdio: 'inherit'
- });
- child.on('close', (code) => {
- if (code === 0) {
- resolve();
- } else {
- reject(new Error(`PowerShell 脚本执行失败,退出码: ${code}`));
- }
- });
- child.on('error', (error) => {
- reject(new Error(`无法启动 PowerShell: ${error.message}`));
- });
- });
- };
- const powershellScript = `
- $username = "${config.username}"
- $hostname = "${config.host}"
- $remotePath = "${config.remotePath}"
- $archivePath = "${remoteArchivePath}"
- $safePath = [System.Management.Automation.WildcardPattern]::Escape($remotePath)
- $safeArchive = [System.Management.Automation.WildcardPattern]::Escape($archivePath)
- # 创建 SSH 命令
- $combinedCommand = "cd '$safePath' && tar -xzf '$safeArchive' && rm '$safeArchive' && chmod -R 755 '$safePath'"
- Write-Host "=== 部署脚本 ===" -ForegroundColor Green
- Write-Host "目标服务器: $username@$hostname" -ForegroundColor Yellow
- Write-Host "远程路径: $remotePath" -ForegroundColor Yellow
- Write-Host ""
- Write-Host "🔑 请输入 SSH 密码 密码:${config.password}" -ForegroundColor Cyan
- Write-Host ""
- try {
- # 执行合并命令
- Write-Host "🚀 正在执行部署操作..." -ForegroundColor Cyan
- ssh -o StrictHostKeyChecking=no $username@$hostname $combinedCommand
-
- Write-Host "✅ 所有操作完成!" -ForegroundColor Green
-
- } catch {
- Write-Host "❌ 执行失败: $_" -ForegroundColor Red
- Write-Host ""
- Write-Host "请手动执行以下命令:" -ForegroundColor Yellow
- Write-Host "ssh $username@$hostname \"cd '$remotePath' && tar -xzf '$archivePath' && rm '$archivePath' && chmod -R 755 '$remotePath'\"" -ForegroundColor Cyan
- exit 1
- }`
- const psScriptPath = join(projectRoot, 'temp_deploy.ps1');
- writeFileSync(psScriptPath, powershellScript);
- try {
- // 执行 PowerShell 脚本
- log('ℹ️ 即将执行部署命令,请根据提示输入密码...', 'blue');
- await runPowerShellScript(psScriptPath)
- log('✅ 部署完成', 'green');
- } catch (error) {
- log('⚠️ 执行失败,请手动执行以下命令:', 'yellow');
- log(`ssh ${config.username}@${config.host} "cd '${config.remotePath}' && tar -xzf '${remoteArchivePath}' && rm '${remoteArchivePath}' && chmod -R 755 '${config.remotePath}'"`, 'cyan');
- // 等待用户确认
- const readline = await import('readline');
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
- });
- await new Promise(resolve => {
- rl.question('执行完成后按回车键继续...', () => {
- rl.close();
- resolve();
- });
- });
- } finally {
- if (existsSync(psScriptPath)) {
- unlinkSync(psScriptPath);
- }
- }
- } else {
- // Unix/Linux/macOS 系统:使用 expect 脚本
- const expectScript = `#!/usr/bin/expect -f
- set timeout 30
- set password {${config.password}}
- spawn ssh -o StrictHostKeyChecking=no ${config.username}@${config.host} "cd ${config.remotePath} && tar -xzf ${remoteArchivePath} && rm ${remoteArchivePath}"
- expect "password:"
- send "$password\r"
- expect eof
- `
- const expectScriptPath = join(projectRoot, 'temp_expect.sh')
- writeFileSync(expectScriptPath, expectScript)
- try {
- await execAsync(`chmod +x ${expectScriptPath}`)
- await execAsync(expectScriptPath)
- log('✅ 解压完成', 'green')
- } finally {
- if (existsSync(expectScriptPath)) {
- unlinkSync(expectScriptPath)
- }
- }
- // 设置权限
- log('🔧 设置文件权限...', 'yellow')
- const chmodExpectScript = `#!/usr/bin/expect -f
- set timeout 30
- set password {${config.password}}
- spawn ssh -o StrictHostKeyChecking=no ${config.username}@${config.host} "chmod -R 755 ${config.remotePath}"
- expect "password:"
- send "$password\r"
- expect eof
- `
- const chmodScriptPath = join(projectRoot, 'temp_chmod.sh')
- writeFileSync(chmodScriptPath, chmodExpectScript)
- try {
- await execAsync(`chmod +x ${chmodScriptPath}`)
- await execAsync(chmodScriptPath)
- log('✅ 权限设置完成', 'green')
- } finally {
- if (existsSync(chmodScriptPath)) {
- unlinkSync(chmodScriptPath)
- }
- }
- }
- // 清理服务器上的压缩包
- log('🗑️ 清理服务器压缩包...', 'yellow')
- try {
- await sftp.unlink(remoteArchivePath)
- log('✅ 服务器压缩包已清理', 'green')
- } catch (error) {
- log('⚠️ 服务器压缩包清理失败(可能已被删除)', 'yellow')
- }
- // 关闭连接
- await sftp.end()
- log('🗑️ 清理本地压缩包...', 'yellow')
- if (archivePath && existsSync(archivePath)) {
- unlinkSync(archivePath)
- }
- log('🎉 部署成功!', 'green')
- config.accessLocation ? log(`🌐 访问地址: ${config.accessLocation}`, 'cyan') : log(`🌐 访问地址: http://${config.host}`, 'cyan')
- } catch (error) {
- log(`❌ 部署失败: ${error.message}`, 'red')
- console.error(error)
- process.exit(1)
- }
- }
- // 运行部署
- deploy()
|