deploy.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. #!/usr/bin/env node
  2. import sftpClient from 'ssh2-sftp-client'
  3. import {
  4. readFileSync,
  5. existsSync,
  6. statSync,
  7. readdirSync,
  8. unlinkSync,
  9. createReadStream,
  10. writeFileSync
  11. } from 'fs'
  12. import { join, dirname } from 'path'
  13. import { fileURLToPath } from 'url'
  14. import { SingleBar } from 'cli-progress'
  15. import { exec, spawn } from 'child_process'
  16. import { promisify } from 'util'
  17. import { Command } from 'commander'
  18. const program = new Command();
  19. program.option('-n --env <env>', '发布环境', 'dev')
  20. program.parse(process.argv);
  21. const options = program.opts();
  22. const __filename = fileURLToPath(import.meta.url)
  23. const __dirname = dirname(__filename)
  24. const projectRoot = join(__dirname, '..')
  25. // 配置 测试环境 :
  26. const config = options.env === 'prod' ? {
  27. host: '60.205.166.0',
  28. username: 'root',
  29. password: 'U/N$$XBv', // 运行时输入
  30. remotePath: '/opt/data/airport-web/dist',
  31. localPath: join(projectRoot, 'dist'),
  32. accessLocation: 'http://airport.samsundot.com:9011'
  33. } : {
  34. host: '192.168.3.221',
  35. username: 'root',
  36. password: 'root', // 运行时输入
  37. remotePath: '/opt/data/airport-web/dist',
  38. localPath: join(projectRoot, 'dist')
  39. }
  40. // 颜色输出
  41. const colors = {
  42. reset: '\x1b[0m',
  43. bright: '\x1b[1m',
  44. red: '\x1b[31m',
  45. green: '\x1b[32m',
  46. yellow: '\x1b[33m',
  47. blue: '\x1b[34m',
  48. magenta: '\x1b[35m',
  49. cyan: '\x1b[36m'
  50. }
  51. function log (message, color = 'reset') {
  52. console.log(`${colors[ color ]}${message}${colors.reset}`)
  53. }
  54. // 获取密码
  55. async function getPassword () {
  56. const readline = await import('readline')
  57. const rl = readline.createInterface({
  58. input: process.stdin,
  59. output: process.stdout
  60. })
  61. return new Promise(resolve => {
  62. rl.question('🔐 请输入服务器密码: ', password => {
  63. rl.close()
  64. resolve(password)
  65. })
  66. })
  67. }
  68. // 检查并构建 dist 目录
  69. async function checkAndBuildDist () {
  70. if (!existsSync(config.localPath)) {
  71. log('⚠️ dist 目录不存在,开始自动构建...', 'yellow')
  72. // 检查 package.json 是否存在
  73. const packageJsonPath = join(projectRoot, 'package.json')
  74. if (!existsSync(packageJsonPath)) {
  75. log('❌ package.json 不存在', 'red')
  76. process.exit(1)
  77. }
  78. // 检查是否有 build 脚本
  79. const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
  80. if (!packageJson.scripts || !packageJson.scripts.build) {
  81. log('❌ package.json 中没有找到 build 脚本', 'red')
  82. process.exit(1)
  83. }
  84. // 执行构建
  85. log('🔨 正在执行构建命令...', 'cyan')
  86. const execAsync = promisify(exec)
  87. try {
  88. // 优先使用 yarn,如果没有则使用 npm
  89. let buildCommand = 'npm run build'
  90. let packageManager = 'npm'
  91. // 检查是否有 yarn
  92. try {
  93. await execAsync('yarn --version', { cwd: projectRoot })
  94. buildCommand = 'yarn build'
  95. packageManager = 'yarn'
  96. log(`使用 ${packageManager} 构建...`, 'cyan')
  97. } catch (yarnError) {
  98. log('yarn 不可用,使用 npm 构建...', 'yellow')
  99. }
  100. await execAsync(buildCommand, { cwd: projectRoot })
  101. // 再次检查构建结果
  102. if (!existsSync(config.localPath)) {
  103. log('❌ 构建失败,dist 目录仍未生成', 'red')
  104. process.exit(1)
  105. }
  106. log(`✅ 构建完成!(使用 ${packageManager})`, 'green')
  107. } catch (error) {
  108. log(`❌ 构建失败: ${error.message}`, 'red')
  109. process.exit(1)
  110. }
  111. } else {
  112. const stats = statSync(config.localPath)
  113. if (!stats.isDirectory()) {
  114. log('❌ dist 不是目录', 'red')
  115. process.exit(1)
  116. }
  117. log('✅ dist 目录检查通过', 'green')
  118. }
  119. }
  120. // 计算目录大小
  121. function getDirectorySize (dirPath) {
  122. let totalSize = 0
  123. const files = readdirSync(dirPath, { withFileTypes: true })
  124. for (const file of files) {
  125. const fullPath = join(dirPath, file.name)
  126. if (file.isDirectory()) {
  127. totalSize += getDirectorySize(fullPath)
  128. } else {
  129. totalSize += statSync(fullPath).size
  130. }
  131. }
  132. return totalSize
  133. }
  134. // 格式化文件大小
  135. function formatSize (bytes) {
  136. const units = [ 'B', 'KB', 'MB', 'GB' ]
  137. let size = bytes
  138. let unitIndex = 0
  139. while (size >= 1024 && unitIndex < units.length - 1) {
  140. size /= 1024
  141. unitIndex++
  142. }
  143. return `${size.toFixed(1)}${units[ unitIndex ]}`
  144. }
  145. // 创建压缩包
  146. async function createArchive () {
  147. const execAsync = promisify(exec)
  148. const archivePath = join(projectRoot, 'dist.tar.gz')
  149. log('📦 正在创建压缩包...', 'yellow')
  150. try {
  151. // 使用 tar 创建压缩包,排除 macOS 扩展属性
  152. await execAsync(`tar --no-xattrs -czf "${archivePath}" -C "${config.localPath}" .`)
  153. const stats = statSync(archivePath)
  154. log(`✅ 压缩包创建完成,大小: ${formatSize(stats.size)}`, 'green')
  155. return archivePath
  156. } catch (error) {
  157. log(`❌ 创建压缩包失败: ${error.message}`, 'red')
  158. throw error
  159. }
  160. }
  161. // 获取用户选择
  162. async function getUserChoice (question) {
  163. const readline = await import('readline')
  164. const rl = readline.createInterface({
  165. input: process.stdin,
  166. output: process.stdout
  167. })
  168. return new Promise(resolve => {
  169. rl.question(question, answer => {
  170. rl.close()
  171. resolve(answer.toLowerCase().trim())
  172. })
  173. })
  174. }
  175. // 主部署函数
  176. async function deploy () {
  177. let archivePath = null
  178. try {
  179. log('🚀 开始 Node.js 部署...', 'cyan')
  180. // 检查并构建 dist 目录
  181. await checkAndBuildDist()
  182. if (!config.password) {
  183. // 获取密码
  184. config.password = await getPassword()
  185. }
  186. // 计算总大小
  187. const totalSize = getDirectorySize(config.localPath)
  188. log(`📊 总大小: ${formatSize(totalSize)}`, 'blue')
  189. // 检查是否已有压缩包,支持断点续传
  190. const existingArchivePath = join(projectRoot, 'dist.tar.gz')
  191. if (existsSync(existingArchivePath)) {
  192. log('📦 发现已存在的压缩包,跳过打包步骤', 'yellow')
  193. archivePath = existingArchivePath
  194. } else {
  195. // 创建压缩包
  196. archivePath = await createArchive()
  197. }
  198. // 连接 SFTP
  199. log('🔌 正在连接服务器...', 'yellow')
  200. const sftp = new sftpClient()
  201. await sftp.connect({
  202. host: config.host,
  203. username: config.username,
  204. password: config.password
  205. })
  206. log('✅ SFTP 连接成功', 'green')
  207. // 创建远程目录
  208. log('📁 创建远程目录...', 'yellow')
  209. await sftp.mkdir(config.remotePath, true) // true 表示递归创建
  210. // 检查服务器是否已存在压缩包
  211. const remoteArchivePath = '/opt/data/airport-web/dist.tar.gz'
  212. let needUpload = true
  213. try {
  214. await sftp.stat(remoteArchivePath)
  215. log('📦 服务器已存在压缩包', 'yellow')
  216. // 询问用户是否续传
  217. const choice = await getUserChoice('是否续传?(y/n): ')
  218. if (choice === 'y' || choice === 'yes') {
  219. log('📦 跳过上传步骤,使用现有压缩包', 'green')
  220. needUpload = false
  221. } else {
  222. log('🗑️ 删除服务器压缩包,重新上传...', 'yellow')
  223. await sftp.unlink(remoteArchivePath)
  224. log('📤 开始上传新压缩包...', 'yellow')
  225. }
  226. } catch (error) {
  227. // 文件不存在,需要上传
  228. log('📤 服务器不存在压缩包,开始上传...', 'yellow')
  229. }
  230. if (needUpload) {
  231. // 上传压缩包
  232. log('📤 正在上传压缩包...', 'yellow')
  233. // 显示上传进度
  234. const fileSize = statSync(archivePath).size
  235. log(`📊 文件大小: ${formatSize(fileSize)}`, 'blue')
  236. // 创建进度条
  237. const progressBar = new SingleBar({
  238. format: '📤 上传进度 |{bar}| {percentage}% | {value}/{total} Bytes | {speed}',
  239. barCompleteChar: '█',
  240. barIncompleteChar: '░',
  241. hideCursor: true
  242. })
  243. progressBar.start(fileSize, 0)
  244. try {
  245. // 使用流的方式上传,获得实时进度
  246. const readStream = createReadStream(archivePath)
  247. const writeStream = await sftp.createWriteStream(remoteArchivePath)
  248. let uploadedBytes = 0
  249. const startTime = Date.now()
  250. // 监听数据流,实时更新进度
  251. readStream.on('data', chunk => {
  252. uploadedBytes += chunk.length
  253. const elapsedTime = (Date.now() - startTime) / 1000
  254. const speed = elapsedTime > 0 ? (uploadedBytes / elapsedTime).toFixed(2) : '0'
  255. progressBar.update(uploadedBytes, { speed: `${speed} B/s` })
  256. })
  257. // 处理流事件
  258. readStream.pipe(writeStream)
  259. // 等待上传完成
  260. await new Promise((resolve, reject) => {
  261. writeStream.on('close', () => {
  262. progressBar.stop()
  263. log('✅ 压缩包上传完成', 'green')
  264. resolve()
  265. })
  266. writeStream.on('error', err => {
  267. progressBar.stop()
  268. reject(err)
  269. })
  270. readStream.on('error', err => {
  271. progressBar.stop()
  272. reject(err)
  273. })
  274. })
  275. } catch (error) {
  276. progressBar.stop()
  277. throw error
  278. }
  279. }
  280. // 在服务器端解压
  281. log('📦 正在服务器端解压...', 'yellow')
  282. // 检测操作系统
  283. const isWindows = process.platform === 'win32'
  284. const execAsync = promisify(exec)
  285. // 转义密码(关键步骤!)
  286. function escapeForExpect (password) {
  287. return password
  288. .replace(/\\/g, "\\\\") // 转义反斜杠 \
  289. .replace(/\$/g, "\\$"); // 转义 $
  290. }
  291. if (isWindows) {
  292. // Windows 系统:使用 PowerShell 脚本(单次密码输入)
  293. log('🪟 Windows 系统检测到,使用 PowerShell 脚本', 'yellow')
  294. const runPowerShellScript = (scriptPath) => {
  295. return new Promise((resolve, reject) => {
  296. const child = spawn('powershell', [
  297. '-ExecutionPolicy',
  298. 'Bypass',
  299. '-File',
  300. scriptPath
  301. ], {
  302. stdio: 'inherit'
  303. });
  304. child.on('close', (code) => {
  305. if (code === 0) {
  306. resolve();
  307. } else {
  308. reject(new Error(`PowerShell 脚本执行失败,退出码: ${code}`));
  309. }
  310. });
  311. child.on('error', (error) => {
  312. reject(new Error(`无法启动 PowerShell: ${error.message}`));
  313. });
  314. });
  315. };
  316. const powershellScript = `
  317. $username = "${config.username}"
  318. $hostname = "${config.host}"
  319. $remotePath = "${config.remotePath}"
  320. $archivePath = "${remoteArchivePath}"
  321. $safePath = [System.Management.Automation.WildcardPattern]::Escape($remotePath)
  322. $safeArchive = [System.Management.Automation.WildcardPattern]::Escape($archivePath)
  323. # 创建 SSH 命令
  324. $combinedCommand = "cd '$safePath' && tar -xzf '$safeArchive' && rm '$safeArchive' && chmod -R 755 '$safePath'"
  325. Write-Host "=== 部署脚本 ===" -ForegroundColor Green
  326. Write-Host "目标服务器: $username@$hostname" -ForegroundColor Yellow
  327. Write-Host "远程路径: $remotePath" -ForegroundColor Yellow
  328. Write-Host ""
  329. Write-Host "🔑 请输入 SSH 密码 密码:${config.password}" -ForegroundColor Cyan
  330. Write-Host ""
  331. try {
  332. # 执行合并命令
  333. Write-Host "🚀 正在执行部署操作..." -ForegroundColor Cyan
  334. ssh -o StrictHostKeyChecking=no $username@$hostname $combinedCommand
  335. Write-Host "✅ 所有操作完成!" -ForegroundColor Green
  336. } catch {
  337. Write-Host "❌ 执行失败: $_" -ForegroundColor Red
  338. Write-Host ""
  339. Write-Host "请手动执行以下命令:" -ForegroundColor Yellow
  340. Write-Host "ssh $username@$hostname \"cd '$remotePath' && tar -xzf '$archivePath' && rm '$archivePath' && chmod -R 755 '$remotePath'\"" -ForegroundColor Cyan
  341. exit 1
  342. }`
  343. const psScriptPath = join(projectRoot, 'temp_deploy.ps1');
  344. writeFileSync(psScriptPath, powershellScript);
  345. try {
  346. // 执行 PowerShell 脚本
  347. log('ℹ️ 即将执行部署命令,请根据提示输入密码...', 'blue');
  348. await runPowerShellScript(psScriptPath)
  349. log('✅ 部署完成', 'green');
  350. } catch (error) {
  351. log('⚠️ 执行失败,请手动执行以下命令:', 'yellow');
  352. log(`ssh ${config.username}@${config.host} "cd '${config.remotePath}' && tar -xzf '${remoteArchivePath}' && rm '${remoteArchivePath}' && chmod -R 755 '${config.remotePath}'"`, 'cyan');
  353. // 等待用户确认
  354. const readline = await import('readline');
  355. const rl = readline.createInterface({
  356. input: process.stdin,
  357. output: process.stdout
  358. });
  359. await new Promise(resolve => {
  360. rl.question('执行完成后按回车键继续...', () => {
  361. rl.close();
  362. resolve();
  363. });
  364. });
  365. } finally {
  366. if (existsSync(psScriptPath)) {
  367. unlinkSync(psScriptPath);
  368. }
  369. }
  370. } else {
  371. // Unix/Linux/macOS 系统:使用 expect 脚本
  372. const expectScript = `#!/usr/bin/expect -f
  373. set timeout 30
  374. set password {${config.password}}
  375. spawn ssh -o StrictHostKeyChecking=no ${config.username}@${config.host} "cd ${config.remotePath} && tar -xzf ${remoteArchivePath} && rm ${remoteArchivePath}"
  376. expect "password:"
  377. send "$password\r"
  378. expect eof
  379. `
  380. const expectScriptPath = join(projectRoot, 'temp_expect.sh')
  381. writeFileSync(expectScriptPath, expectScript)
  382. try {
  383. await execAsync(`chmod +x ${expectScriptPath}`)
  384. await execAsync(expectScriptPath)
  385. log('✅ 解压完成', 'green')
  386. } finally {
  387. if (existsSync(expectScriptPath)) {
  388. unlinkSync(expectScriptPath)
  389. }
  390. }
  391. // 设置权限
  392. log('🔧 设置文件权限...', 'yellow')
  393. const chmodExpectScript = `#!/usr/bin/expect -f
  394. set timeout 30
  395. set password {${config.password}}
  396. spawn ssh -o StrictHostKeyChecking=no ${config.username}@${config.host} "chmod -R 755 ${config.remotePath}"
  397. expect "password:"
  398. send "$password\r"
  399. expect eof
  400. `
  401. const chmodScriptPath = join(projectRoot, 'temp_chmod.sh')
  402. writeFileSync(chmodScriptPath, chmodExpectScript)
  403. try {
  404. await execAsync(`chmod +x ${chmodScriptPath}`)
  405. await execAsync(chmodScriptPath)
  406. log('✅ 权限设置完成', 'green')
  407. } finally {
  408. if (existsSync(chmodScriptPath)) {
  409. unlinkSync(chmodScriptPath)
  410. }
  411. }
  412. }
  413. // 清理服务器上的压缩包
  414. log('🗑️ 清理服务器压缩包...', 'yellow')
  415. try {
  416. await sftp.unlink(remoteArchivePath)
  417. log('✅ 服务器压缩包已清理', 'green')
  418. } catch (error) {
  419. log('⚠️ 服务器压缩包清理失败(可能已被删除)', 'yellow')
  420. }
  421. // 关闭连接
  422. await sftp.end()
  423. log('🗑️ 清理本地压缩包...', 'yellow')
  424. if (archivePath && existsSync(archivePath)) {
  425. unlinkSync(archivePath)
  426. }
  427. log('🎉 部署成功!', 'green')
  428. config.accessLocation ? log(`🌐 访问地址: ${config.accessLocation}`, 'cyan') : log(`🌐 访问地址: http://${config.host}`, 'cyan')
  429. } catch (error) {
  430. log(`❌ 部署失败: ${error.message}`, 'red')
  431. console.error(error)
  432. process.exit(1)
  433. }
  434. }
  435. // 运行部署
  436. deploy()