@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# 客迹一键部署——固化标准部署链(spec: docs/superpowers/specs/2026-09-29-git-and-deploy-automation-design.md)
# 用法: bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]
# 红线: 只操作 /opt/cms-cj 与 tracker-web 容器,绝不触碰服务器其他项目
set -euo pipefail
SECONDS = 0
# ---- 配置(环境变量可覆盖)----
JUMP_HOST = " ${ JUMP_HOST :- root @8.152.213.143 } "
TARGET_HOST = " ${ TARGET_HOST :- root @47.254.176.10 } "
SSH_KEY = " ${ SSH_KEY :- $HOME /.ssh/id_ed25519 } "
REMOTE_DIR = " ${ REMOTE_DIR :- /opt/cms-cj } "
SITE_URL = " ${ SITE_URL :- https : //tracker.12418113.xyz } "
REPO_DIR = " $( cd " $( dirname " $0 " ) /.. " && pwd ) "
LOG_DIR = " $REPO_DIR /deploy/logs "
STAMP = " $( date +%Y%m%d-%H%M%S) "
LOG_FILE = " $LOG_DIR /deploy- $STAMP .log "
SSH_OPTS = ( -i " $SSH_KEY " -o ConnectTimeout = 15 -o BatchMode = yes)
SSH = ( ssh " ${ SSH_OPTS [@] } " -J " $JUMP_HOST " " $TARGET_HOST " )
SCP = ( scp " ${ SSH_OPTS [@] } " -J " $JUMP_HOST " )
log( ) { echo " [ $( date +%H:%M:%S) ] $* " | tee -a " $LOG_FILE " ; }
die( ) { log " ✗ 失败: $* (部署中止,此前已完成的目标保持已部署状态) " ; exit 1; }
ok( ) { log " ✓ $* " ; }
mkdir -p " $LOG_DIR " # 必须先于参数解析——早期 die() 也要能写日志
# ---- 参数解析 ----
TARGET_CURRENT = "preflight" ; STEP = "-"
[ $# -ge 1 ] || die "用法: bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]"
DEPLOY_TARGET = " $1 " ; shift || true
SKIP_TESTS = 0; ALLOW_DIRTY = 0
for arg in " $@ " ; do
case " $arg " in
--skip-tests) SKIP_TESTS = 1 ; ;
--allow-dirty) ALLOW_DIRTY = 1 ; ;
*) die " 未知参数: $arg " ; ;
esac
done
case " $DEPLOY_TARGET " in
server| admin| sdk| all) ; ;
*) die " 目标必须是 server|admin|sdk|all,收到: $DEPLOY_TARGET " ; ;
esac
GIT_HASH = " $( git -C " $REPO_DIR " rev-parse --short HEAD) "
log " ===== 部署开始 target= $DEPLOY_TARGET git= $GIT_HASH ===== "
# ---- 前置检查(所有目标必经)----
preflight( ) {
STEP = "前置检查"
if [ " $ALLOW_DIRTY " -eq 0 ] && [ -n " $( git -C " $REPO_DIR " status --porcelain) " ] ; then
die "git 工作区不干净(--allow-dirty 可跳过;强烈建议先提交)"
fi
" ${ SSH [@] } " 'echo ok' >/dev/null 2>& 1 || die " 跳板/目标服务器 SSH 不可达( $JUMP_HOST → $TARGET_HOST ) "
ok "工作区干净,SSH 链路可达"
}
# ---- 各目标实现 ----
# server 测试解释器:优先项目 venv( Windows Scripts/ 或 Linux bin/ 布局),退化到 PATH python
server_py( ) {
if [ -x " $REPO_DIR /server/.venv/Scripts/python.exe " ] ; then echo " $REPO_DIR /server/.venv/Scripts/python.exe "
elif [ -x " $REPO_DIR /server/.venv/bin/python " ] ; then echo " $REPO_DIR /server/.venv/bin/python "
else echo python; fi
}
server_tests( ) {
[ " $SKIP_TESTS " -eq 1 ] && return 0
STEP = "server 测试"
local py; py = " $( server_py) "
( cd " $REPO_DIR /server " && " $py " -m pytest tracker/tests/ -q >>" $LOG_FILE " 2>& 1) \
|| die " pytest 未全过(详见 $LOG_FILE ) "
ok "pytest 全过"
}
sdk_tests( ) {
[ " $SKIP_TESTS " -eq 1 ] && return 0
STEP = "sdk 测试"
( cd " $REPO_DIR /sdk " && npx vitest run >>" $LOG_FILE " 2>& 1) || die "vitest 未全过"
( cd " $REPO_DIR /sdk " && npx tsc --noEmit >>" $LOG_FILE " 2>& 1) || die "tsc 报错"
ok "sdk vitest+tsc 全过"
}
build_admin( ) {
STEP = "admin 构建"
( cd " $REPO_DIR /admin " && NODE_OPTIONS = --max-old-space-size= 6144 pnpm build >>" $LOG_FILE " 2>& 1) \
|| die "admin 构建失败(本机 OOM 则确认 NODE_OPTIONS 已带)"
ok " admin 构建完成: $( grep -oE 'index-[A-Za-z0-9_-]+\.js' " $REPO_DIR /admin/dist/index.html " | head -1) "
}
build_sdk( ) {
STEP = "sdk 构建"
( cd " $REPO_DIR /sdk " && node build.mjs >>" $LOG_FILE " 2>& 1) || die "sdk 构建失败"
local gz; gz = $( gzip -c " $REPO_DIR /sdk/dist/v1.js " | wc -c)
ok " sdk 构建完成: build_id= $( grep -oE '[0-9a-f]{7,9}-2026[0-9]{8}' " $REPO_DIR /sdk/dist/v1.js " | head -1) gzip= ${ gz } B "
}
transfer( ) { # $1=包内路径列表(空格分隔,相对 REPO_DIR) $2=远端包名
STEP = "传输与落位"
local tarball = " /tmp/ $2 "
( cd " $REPO_DIR " && tar -czf " $tarball " $1 ) || die " tar 打包失败: $1 "
" ${ SCP [@] } " " $tarball " " $TARGET_HOST :/tmp/ " >/dev/null 2>& 1 || die " scp 传输失败(经 $JUMP_HOST ) "
rm -f " $tarball "
# tar 按真实路径解压——直接落位挂载点(原地合并,无 cp 无暂存)
" ${ SSH [@] } " " cd $REMOTE_DIR && tar -xzf /tmp/ $2 && rm -f /tmp/ $2 " >/dev/null 2>& 1 \
|| die " 远端解压失败( $REMOTE_DIR ) "
ok " 已落位: $1 "
}
rebuild_web( ) {
STEP = "server 容器重建"
" ${ SSH [@] } " " cd $REMOTE_DIR /deploy && docker compose up -d --build web " >>" $LOG_FILE " 2>& 1 \
|| die "docker compose 重建失败"
sleep 5
local status; status = " $( " ${ SSH [@] } " "docker ps --filter name=tracker-web --format '{{.Status}}'" ) " \
|| die "容器状态查询失败"
echo " $status " | grep -q "^Up" || die " tracker-web 容器状态异常: $status "
ok " tracker-web: $status "
}
verify_site_up( ) {
STEP = "站点探活"
curl -sf -o /dev/null --max-time 20 " $SITE_URL " || die " $SITE_URL 探活失败 "
ok "站点 200"
}
verify_admin_hash( ) {
STEP = "admin 哈希验证"
local local_h online_h
local_h = $( sha256sum " $REPO_DIR /admin/dist/index.html " | cut -d' ' -f1)
online_h = $( curl -sf --max-time 20 " $SITE_URL / " | sha256sum | cut -d' ' -f1) \
|| die "线上 index.html 拉取失败"
[ " $local_h " = " $online_h " ] || die "admin index.html 线上哈希与本机产物不一致(部署未生效或缓存)"
ok "admin 哈希一致"
}
verify_sdk_bid( ) {
STEP = "sdk build_id 验证"
local bid; bid = " $( grep -oE '[0-9a-f]{7,9}-2026[0-9]{8}' " $REPO_DIR /sdk/dist/v1.js " | head -1) "
[ -n " $bid " ] || die "本机产物无 build_id"
curl -sf --max-time 20 " $SITE_URL /sdk/v1.js " | grep -q " $bid " \
|| die " 线上 v1.js 不含新 build_id $bid "
ok " sdk 线上已含 build_id $bid "
}
deploy_server( ) {
TARGET_CURRENT = server
server_tests
STEP = "server 打包"
# 排除清单镜像 server/.dockerignore 本意:Windows venv(97MB)/缓存/本地xdb 绝不上服务器
( cd " $REPO_DIR " && tar -czf /tmp/keji-server.tgz \
--exclude= '__pycache__' --exclude= '.pytest_cache' --exclude= 'db.sqlite3' \
--exclude= '.venv' --exclude= '.cache' --exclude= 'tracker/data/*.xdb' server/) \
|| die "server 打包失败"
" ${ SCP [@] } " /tmp/keji-server.tgz " $TARGET_HOST :/tmp/ " >/dev/null 2>& 1 || die "scp 失败"
rm -f /tmp/keji-server.tgz
" ${ SSH [@] } " " cd $REMOTE_DIR && tar -xzf /tmp/keji-server.tgz && rm -f /tmp/keji-server.tgz " >/dev/null 2>& 1 \
|| die "远端解压失败"
ok "server 源码落位"
rebuild_web
verify_site_up
}
deploy_admin( ) {
TARGET_CURRENT = admin
build_admin
transfer "admin/dist" "keji-admin.tgz"
verify_admin_hash
}
deploy_sdk( ) {
TARGET_CURRENT = sdk
sdk_tests
build_sdk
transfer "sdk/dist" "keji-sdk.tgz"
verify_sdk_bid
}
# ---- 编排(all 按契约依赖序: server → admin → sdk) ----
preflight
case " $DEPLOY_TARGET " in
server) deploy_server ; ;
admin) deploy_admin ; ;
sdk) deploy_sdk ; ;
all) deploy_server; deploy_admin; deploy_sdk ; ;
esac
log " ===== 部署完成 target= $DEPLOY_TARGET git= $GIT_HASH 用时 $SECONDS 秒 ===== "