#!/usr/bin/env bash # ============================================================================ # Vue 前端部署脚本(macOS / Linux 版)—— 银川九鸿化工(JHHG) # # 由同目录的 JHHG.bat 转写而来:步骤、提示语、服务器、账号、远端目录、 # 超时参数与 .bat 完全一致,Windows 上怎么用,这里就怎么用。 # # 与 .bat 的全部差别: # 1. 不依赖 sshpass.exe。系统里有 sshpass 就用 sshpass,没有就退回 macOS # 自带的 /usr/bin/expect 来喂密码 —— 所以这台 Mac 不用装任何东西也能跑。 # 2. 脚本开头会 cd 到自己所在目录,从任何位置调用都能正确构建和上传。 # 3. 结尾的 pause 只在交互式终端里生效,被其它脚本/管道调用时不会卡住。 # 4. 「比较文件大小」这一步按标题的原意真正做了比对(.bat 只打印不比对), # 大小不一致直接报错,避免把一个上传残缺的包解压到线上目录。 # # 用法: # ./JHHG.sh # 首次可能需要 chmod +x JHHG.sh # bash JHHG.sh # # 可选环境变量(一般用不到): # JHHG_TRANSPORT=sshpass|expect 强制指定喂密码的方式 # ============================================================================ set -uo pipefail # ========================= # 配置(与 JHHG.bat 一字不差) # ========================= readonly COMPANY='JHHG' readonly REMOTE_USER='root' readonly REMOTE_HOST='42.63.70.90' readonly REMOTE_PORT='22' # 密码含 ! 和 #:必须用单引号;不要 source 到交互式 shell 里跑(那里会做 ! 历史展开), # 用 ./JHHG.sh 或 bash JHHG.sh 执行。 # 2026-10-12 实测过:服务器接受的就是这个完整字符串(去掉 !@##@! 的 Nxjhhg 会被拒)。 readonly PASSWORD='Nxjhhg!@##@!' readonly ZIP_FILE='dist.zip' readonly REMOTE_DIR='/home/vue/dist' readonly SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3) readonly REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}" readonly REMOTE_TMP="${REMOTE_DIR}/.dist.zip.uploading" # 脚本自身所在目录 = 项目根目录 cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ========================= # 输出helper(与 .bat 的提示格式一致) # ========================= log() { printf '%s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } info() { printf '[INFO] %s\n' "$*"; } warn() { printf '[WARN] %s\n' "$*"; } err() { printf '[ERROR] %s\n' "$*"; } # 与 .bat 的 :END 一样,交互式终端里停一下再退出 pause_if_interactive() { if [[ -t 0 ]]; then printf '\n按回车键退出...' read -r _ || true printf '\n' fi } fail() { log '' log '============================================================' log ' DEPLOY FAILED' log '============================================================' log '' log 'Please check the error message above.' log '' pause_if_interactive exit 1 } # 没被显式处理就失败的命令,也走到 DEPLOY FAILED,不留半截输出 on_unexpected_error() { err "命令失败(退出码 $1,脚本第 $2 行)。" fail } trap 'on_unexpected_error $? $LINENO' ERR # ========================= # 传输层:把密码喂给 ssh/scp # ========================= # 优先 sshpass(和 .bat 一样),没有就用 macOS 自带的 expect TRANSPORT="${JHHG_TRANSPORT:-}" if [[ -z "$TRANSPORT" ]]; then if command -v sshpass >/dev/null 2>&1; then TRANSPORT='sshpass' elif command -v expect >/dev/null 2>&1; then TRANSPORT='expect' fi fi # 用当前 TRANSPORT 执行一条 ssh/scp 命令,参数原样透传 # 密码通过环境变量传递,不出现在命令行参数里(不会进 ps 输出) run_remote() { if [[ "$TRANSPORT" == 'sshpass' ]]; then SSHPASS="$PASSWORD" sshpass -e "$@" return $? fi # expect 分支:程序必须写成文件再用 -f 调用,因为 `expect -c 脚本 参数...` # 不会把参数放进 $argv(会把它当脚本文件名)。 local exp_file rc exp_file="$(mktemp "${TMPDIR:-/tmp}/jhhg-deploy.XXXXXX")" || { err 'temp file creation failed.' return 1 } cat >"$exp_file" <<'EXPECT_PROGRAM' set timeout 300 log_user 1 if {[catch {spawn {*}$argv} spawn_err]} { puts stderr "spawn failed: $spawn_err" exit 127 } expect { -re "(?i)are you sure you want to continue connecting" { send "yes\r"; exp_continue } -re "(?i)password:" { send "$env(JHHG_PW)\r"; exp_continue } eof } # 把子进程的退出码原样带出去(密码错时 ssh 返回 255,必须让上层感知到) set wait_result {} catch wait wait_result set rc [lindex $wait_result 3] if {![string is integer -strict $rc]} { set rc 1 } exit $rc EXPECT_PROGRAM JHHG_PW="$PASSWORD" expect -f "$exp_file" "$@" rc=$? rm -f "$exp_file" return $rc } ssh_remote() { # ssh_remote "远端命令" run_remote ssh -p "$REMOTE_PORT" "${SSH_OPTS[@]}" "$REMOTE_TARGET" "$1" } scp_up() { # scp_up 本地文件 远端路径 run_remote scp -P "$REMOTE_PORT" "${SSH_OPTS[@]}" "$1" "${REMOTE_TARGET}:$2" } # ========================= # 开始 # ========================= log '' log '============================================================' log ' Vue Dist Auto Deploy' log '============================================================' log '' log "Company : $COMPANY" log "Server : $REMOTE_USER@$REMOTE_HOST" log "Port : $REMOTE_PORT" log "Remote Dir : $REMOTE_DIR" log "Package : $ZIP_FILE" log '' # ========================= # [1/7] 密码工具 # ========================= log '[1/7] Checking password tool...' if [[ -z "$TRANSPORT" ]]; then err 'sshpass 和 expect 都没有,无法自动输入密码。' info 'macOS 一般自带 /usr/bin/expect,正常不会走到这里;' info '如果确实没有,装 sshpass 即可(可能需先加第三方 tap):brew install sshpass' info 'Linux: apt install sshpass / yum install sshpass' fail fi if [[ "$TRANSPORT" == 'sshpass' ]]; then ok "sshpass found: $(command -v sshpass)" else ok "expect found: $(command -v expect)(macOS 自带,无需安装 sshpass)" fi # ========================= # [2/7] pnpm # ========================= log '' log '[2/7] Checking pnpm...' if ! command -v pnpm >/dev/null 2>&1; then err 'pnpm not found.' info 'HINT: npm install -g pnpm' fail fi if ! pnpm --version; then err 'pnpm execution failed.' fail fi # ========================= # [3/7] node # ========================= log '' log '[3/7] Checking Node.js...' if ! command -v node >/dev/null 2>&1; then err 'Node.js not found.' fail fi if ! node --version; then err 'Node.js execution failed.' fail fi # ========================= # node_modules # ========================= if [[ ! -d node_modules ]]; then log '' info 'node_modules not found.' info 'Running pnpm install...' if ! pnpm install; then err 'pnpm install failed.' fail fi ok 'pnpm install completed.' else ok 'node_modules exists.' fi # ========================= # [4/7] 构建 # ========================= log '' log '[4/7] Building project...' SKIP_BUILD='' if [[ -f "$ZIP_FILE" ]]; then log '' warn "$ZIP_FILE already exists." printf 'Rebuild? Y/N (default Y): ' read -r REBUILD_ANSWER || true case "$(printf '%s' "${REBUILD_ANSWER:-}" | tr '[:lower:]' '[:upper:]')" in N) info 'Skip build.'; SKIP_BUILD='1' ;; esac fi if [[ -z "$SKIP_BUILD" ]]; then info "Company: $COMPANY" info 'Command: pnpm run build' log '' export VITE_COMPANY="$COMPANY" if ! pnpm run build; then log '' err 'Build failed.' fail fi log '' ok 'Build completed.' fi # ========================= # [5/7] 检查包 # ========================= log '' log '[5/7] Checking package...' if [[ ! -f "$ZIP_FILE" ]]; then err "$ZIP_FILE not found." info 'HINT: Please check whether your build generates dist.zip.' fail fi LOCAL_ZIP_BYTES="$(wc -c < "$ZIP_FILE" | tr -d ' ')" LOCAL_ZIP_MB="$(awk -v b="$LOCAL_ZIP_BYTES" 'BEGIN { printf "%.2f", b / 1048576 }')" ok 'Package found.' info "File size: $LOCAL_ZIP_BYTES bytes" info "File size: $LOCAL_ZIP_MB MB" # ========================= # [6/7] 测试 SSH # ========================= log '' log '[6/7] Testing SSH connection...' if ! ssh_remote 'echo SSH_OK'; then err 'SSH connection failed.' fail fi ok 'SSH connection successful.' # ========================= # 建远端目录 # ========================= log '' info 'Creating remote directory...' if ! ssh_remote "mkdir -p '$REMOTE_DIR'"; then err 'Failed to create remote directory.' fail fi ok 'Remote directory ready.' # ========================= # 上传临时文件 # ========================= log '' info 'Uploading package...' ssh_remote "rm -f '$REMOTE_TMP'" || true # 远端路径不能加引号:ssh 那条的单引号是给远端 shell 剥的,而 scp 的参数不经 shell, # 单引号会变成文件名的真实字符(远端会去开 "'/home/vue/dist/..." 这个相对路径) if ! scp_up "$ZIP_FILE" "$REMOTE_TMP"; then err 'Upload failed.' fail fi ok 'Upload completed.' # ========================= # 校验远端文件 # ========================= log '' info 'Checking remote package...' if ! ssh_remote "test -f '$REMOTE_TMP'"; then err 'Remote package does not exist.' fail fi ok 'Remote package exists.' # ========================= # 比较文件大小 # ========================= log '' info 'Checking package size...' log '' # 大小比对放在远端做、只取退出码。 # 不要用 $(run_remote ...) 去截取远端输出:expect 会把整段交互回显(spawn 命令行、 # 密码提示)一起写进 stdout,截出来的不是命令结果,比对必然失败。 if ! ssh_remote "test \"\$(stat -c %s '$REMOTE_TMP')\" -eq $LOCAL_ZIP_BYTES"; then # 变量一律加 ${}:bash 3.2 会把紧跟其后的全角字符(如「(」)当成变量名的一部分, # 于是 $ZIP_FILE( 会被解析成名为 "ZIP_FILE(" 的变量,配 set -u 直接报 unbound variable err "包大小不一致:远端 ${REMOTE_TMP} 与本地 ${ZIP_FILE}(${LOCAL_ZIP_BYTES} 字节)不符,上传可能被截断。" info "远端临时文件已保留。可在服务器上执行 stat -c %s '${REMOTE_TMP}' 核对,确认后删除。" fail fi ok "包大小一致:${LOCAL_ZIP_BYTES} 字节。" # ========================= # 改名生效 # ========================= log '' info 'Activating package...' if ! ssh_remote "mv -f '$REMOTE_TMP' '$REMOTE_DIR/$ZIP_FILE'"; then err 'Failed to activate package.' fail fi ok 'Package activated.' # ========================= # 检查 unzip # ========================= log '' info 'Checking unzip...' if ! ssh_remote 'command -v unzip'; then err 'unzip is not installed on server.' log '' log 'CentOS/RHEL:' log 'yum install -y unzip' log '' log 'Ubuntu/Debian:' log 'apt install -y unzip' fail fi ok 'unzip found.' # ========================= # 测试 ZIP # ========================= log '' info 'Testing remote ZIP...' if ! ssh_remote "cd '$REMOTE_DIR' && unzip -t '$ZIP_FILE'"; then log '' err 'ZIP test failed.' info 'Remote ZIP has been kept.' fail fi ok 'ZIP test passed.' # ========================= # 解压 # ========================= log '' info 'Extracting package...' if ! ssh_remote "cd '$REMOTE_DIR' && unzip -o '$ZIP_FILE'"; then log '' err 'Remote unzip failed.' info 'Remote ZIP has been kept.' fail fi log '' ok 'Extract completed.' # ========================= # 删除远端 ZIP # ========================= log '' info 'Removing remote ZIP...' if ssh_remote "rm -f '$REMOTE_DIR/$ZIP_FILE'"; then ok 'Remote ZIP removed.' else warn 'Failed to remove remote ZIP.' fi # ========================= # [7/7] 完成 # ========================= log '' log '============================================================' log ' DEPLOY SUCCESS' log '============================================================' log '' log "Company : $COMPANY" log "Server : $REMOTE_HOST:$REMOTE_PORT" log "Remote Dir : $REMOTE_DIR" log "Package : $ZIP_FILE" log "Size : $LOCAL_ZIP_MB MB" log '' log 'Deployment completed successfully.' log '' pause_if_interactive