Files
proxy-station/.claude/skills/deploy/scripts/deploy.sh
YANG JIANKUAN 43a753963c feat: 新增 deploy Skill 与 LA 生产部署文档
线上为 1 核 1.9G 机器,放弃 Docker:Node 直跑 + systemd 守护,
前端本地构建后由服务端静态托管,HTTPS 与反代由 aaPanel 的 nginx 管理。
部署脚本支持 web/server/all 三种目标,含本地类型检查与单测、
暂存目录原子换入、健康检查失败自动回滚源码、本地与远端源码树指纹比对
及公网校验。首次部署与一次性配置写在 docs/DEPLOY.md。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-03 17:38:38 +08:00

143 lines
8.0 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# proxy-station 部署脚本:把前端产物/后端源码推到 LA 服务器Node 直跑 + systemdnginx 由 aaPanel 管)
#
# deploy.sh [web|server|all] [--skip-checks] [--skip-build] [--host <ssh别名>]
#
# web 只构建并替换前端静态产物(服务端按请求读盘,不需要重启)
# server 只推送 shared/ 与 server/ 源码,安装生产依赖并重启 systemd 服务
# all 两者都做(默认)
#
# 数据库、/etc/proxy-station/env、nginx 站点与证书一律不碰——那些是一次性配置,见 docs/DEPLOY.md。
set -euo pipefail
# 会话注入的 HTTP 代理只供模型 API 使用sshpnpmcurl 一律直连
unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY NO_PROXY no_proxy
HOST="${DEPLOY_HOST:-la}"
REMOTE_DIR="${DEPLOY_REMOTE_DIR:-/opt/proxy-station}"
SERVICE="${DEPLOY_SERVICE:-proxy-station}"
RUN_USER="${DEPLOY_RUN_USER:-proxy-station}"
PUBLIC_URL="${DEPLOY_PUBLIC_URL:-https://ps.la.njcqtechaicoding.com}"
LOCAL_PORT="${DEPLOY_LOCAL_PORT:-3000}"
TARGET=all
SKIP_CHECKS=0
SKIP_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
web|server|all) TARGET="$1" ;;
--skip-checks) SKIP_CHECKS=1 ;;
--skip-build) SKIP_BUILD=1 ;;
--host) shift; HOST="$1" ;;
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
*) echo "未知参数:$1可用web|server|all --skip-checks --skip-build --host <别名>" >&2; exit 2 ;;
esac
shift
done
ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || { cd "$(dirname "$0")/../../../.." && pwd; })"
cd "$ROOT"
SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST")
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
fail() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; }
# 本地与远端各算一遍「源码树指纹」:文件按路径排序后逐个取 sha256再对哈希列表取 sha256。
# 部署完两边一致就能确定推上去的就是本地这份,而不是靠「命令没报错」来猜。
hash_cmd_local() { command -v sha256sum >/dev/null && echo sha256sum || echo 'shasum -a 256'; }
tree_hash_local() {
local h; h="$(hash_cmd_local)"
find "$@" -type f ! -name '*.test.ts' | LC_ALL=C sort | xargs $h | awk '{print $1}' | $h | awk '{print $1}'
}
tree_hash_remote() {
"${SSH[@]}" "cd '$REMOTE_DIR' && find $* -type f ! -name '*.test.ts' | LC_ALL=C sort | xargs sha256sum | awk '{print \$1}' | sha256sum | awk '{print \$1}'"
}
# ---------- 预检 ----------
step "预检ssh 别名 ${HOST}、远端目录、服务状态"
case "$HOST" in *[[:upper:]]*) fail "ssh 别名请用小写(大写会被当成域名走 fake-IP 解析而连不上)";; esac
"${SSH[@]}" "test -d '$REMOTE_DIR/server' && test -f /etc/systemd/system/$SERVICE.service" \
|| fail "远端缺少 $REMOTE_DIR/server 或 systemd 单元,请先按 docs/DEPLOY.md 完成首次部署"
GIT_REV="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
GIT_DIRTY="$(git status --porcelain 2>/dev/null | grep -q . && echo '(工作区有未提交改动)' || true)"
echo "本地版本:$GIT_REV $GIT_DIRTY"
echo "目标:$TARGET$HOST:$REMOTE_DIR"
# ---------- 本地检查与构建 ----------
if [ "$TARGET" != web ] && [ "$SKIP_CHECKS" = 0 ]; then
step "本地类型检查与单测(--skip-checks 可跳过)"
pnpm -r typecheck >/dev/null || fail "typecheck 失败,先修再部署"
pnpm test 2>&1 | tail -4
fi
if [ "$TARGET" != server ]; then
if [ "$SKIP_BUILD" = 0 ]; then
step "构建前端"
pnpm --filter @proxy-station/web build 2>&1 | tail -3
else
step "跳过构建,使用现有 web/dist"
fi
test -f web/dist/index.html || fail "web/dist/index.html 不存在"
fi
# ---------- 推送 ----------
# macOS 的 bsdtar 会把文件 xattr 写成扩展头,远端 GNU tar 每个文件刷一行「unknown extended header」两端各关一次
TAR_EXCLUDES=(--no-xattrs --exclude='node_modules' --exclude='*.test.ts' --exclude='.DS_Store' --exclude='data' --exclude='*.db*' --exclude='.env')
export COPYFILE_DISABLE=1
UNTAR='tar xzf - --warning=no-unknown-keyword'
if [ "$TARGET" != server ]; then
step "推送前端产物 → $REMOTE_DIR/web/dist原子替换旧版保留为 dist.old"
tar czf - "${TAR_EXCLUDES[@]}" web/dist | "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; rm -rf .staging-web; mkdir .staging-web; $UNTAR -C .staging-web;
rm -rf web/dist.old; [ -d web/dist ] && mv web/dist web/dist.old; mv .staging-web/web/dist web/dist; rm -rf .staging-web;
chown -R $RUN_USER:$RUN_USER web/dist"
fi
if [ "$TARGET" != web ]; then
step "推送后端源码与锁文件 → ${REMOTE_DIR}(旧源码保留为 src.old 以便回滚)"
tar czf - "${TAR_EXCLUDES[@]}" package.json pnpm-workspace.yaml pnpm-lock.yaml \
shared/package.json shared/tsconfig.json shared/src \
server/package.json server/tsconfig.json server/src \
web/package.json \
| "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; rm -rf .staging; mkdir .staging; $UNTAR -C .staging;
for d in server/src shared/src; do rm -rf \"\$d.old\"; [ -d \"\$d\" ] && mv \"\$d\" \"\$d.old\"; mv \".staging/\$d\" \"\$d\"; done;
cp .staging/package.json .staging/pnpm-workspace.yaml .staging/pnpm-lock.yaml .;
cp .staging/shared/package.json .staging/shared/tsconfig.json shared/;
cp .staging/server/package.json .staging/server/tsconfig.json server/;
cp .staging/web/package.json web/; rm -rf .staging"
step "远端安装生产依赖frozen lockfile"
"${SSH[@]}" "cd '$REMOTE_DIR' && CI=true pnpm install --frozen-lockfile --prod --filter '@proxy-station/server...' 2>&1 | tail -3 && chown -R $RUN_USER:$RUN_USER '$REMOTE_DIR'" \
|| fail "pnpm install 失败。锁文件与 package.json 不一致时先在本地 pnpm install 并提交锁文件"
step "重启 $SERVICE 并等待健康检查"
"${SSH[@]}" "systemctl restart $SERVICE"
if ! "${SSH[@]}" "for i in \$(seq 1 20); do curl -sf -m 2 http://127.0.0.1:$LOCAL_PORT/api/health >/dev/null && exit 0; sleep 1; done; exit 1"; then
echo "健康检查 20 秒未通过,最近日志:"
"${SSH[@]}" "journalctl -u $SERVICE -n 30 --no-pager -o cat" || true
step "回滚到上一版源码"
"${SSH[@]}" "set -e; cd '$REMOTE_DIR'; for d in server/src shared/src; do [ -d \"\$d.old\" ] && rm -rf \"\$d\" && mv \"\$d.old\" \"\$d\"; done; chown -R $RUN_USER:$RUN_USER .; systemctl restart $SERVICE"
fail "已回滚到旧源码并重启(锁文件与依赖未回滚)。请在本地复现后再部署"
fi
fi
# ---------- 校验 ----------
step "校验"
"${SSH[@]}" "printf 'rev=%s target=%s at=%s\n' '$GIT_REV' '$TARGET' \"\$(date -Is)\" > '$REMOTE_DIR/.deploy-stamp'"
OK=1
if [ "$TARGET" != server ]; then
L="$(tree_hash_local web/dist)"; R="$(tree_hash_remote web/dist)"
[ "$L" = "$R" ] && echo "前端产物指纹一致:${L:0:12}" || { echo "前端产物指纹不一致:本地 ${L:0:12} 远端 ${R:0:12}"; OK=0; }
ASSET="$(grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' web/dist/index.html | head -1)"
if curl -sf -m 10 "$PUBLIC_URL/" | grep -q "$ASSET"; then echo "公网首页已引用新入口脚本 $ASSET"; else echo "公网首页未引用 ${ASSET}(缓存或部署失败)"; OK=0; fi
fi
if [ "$TARGET" != web ]; then
L="$(tree_hash_local server/src shared/src)"; R="$(tree_hash_remote server/src shared/src)"
[ "$L" = "$R" ] && echo "后端源码指纹一致:${L:0:12}" || { echo "后端源码指纹不一致:本地 ${L:0:12} 远端 ${R:0:12}"; OK=0; }
"${SSH[@]}" "systemctl is-active --quiet $SERVICE" && echo "systemd 服务 active" || { echo "systemd 服务未运行"; OK=0; }
fi
if curl -sf -m 10 "$PUBLIC_URL/api/health" | grep -q '"ok":true'; then echo "公网健康检查通过:$PUBLIC_URL/api/health"; else echo "公网健康检查失败"; OK=0; fi
CODE="$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$PUBLIC_URL/api/nodes")"
[ "$CODE" = 401 ] && echo "管理接口未带 token 返回 401鉴权正常" || { echo "管理接口未带 token 返回 ${CODE},预期 401"; OK=0; }
[ "$OK" = 1 ] && step "部署完成:$TARGET @ $GIT_REV$PUBLIC_URL" || fail "部署校验有未通过项,见上"