1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
#!/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