#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/common.sh source "$SCRIPT_DIR/common.sh" # ── 全局变量 ───────────────────────────────── TARGET_ENV="" REPO_URL="" GIT_BRANCH="" ASSUME_YES=0 SKIP_BACKUP=0 FORCE_PULL=0 INSTALL_ARGS=() # 项目身份验证标识文件(至少需要其中 2 个) REQUIRED_PROJECT_FILES=( "scripts/install.sh" "docker-compose.yaml" "backend/app/main.py" "README.md" ) # ── Banner ─────────────────────────────────── _banner() { local b="${CC_PRIMARY}${C_BOLD}" local a="${CC_INFO}" local r="${C_RESET}" printf '\n' printf '%s╔══════════════════════════════════════════════╗%s\n' "$b" "$r" printf '%s║%s ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ██║ ██║██████╔╝██║ ██║███████║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ██║ ██║██╔═══╝ ██║ ██║██╔══██║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ╚██████╔╝██║ ██████╔╝██║ ██║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r" printf '%s║%s 临床试验管理系统 · 在线更新程序 %s║%s\n' "$b" "${CC_MUTED}" "${CC_PRIMARY}" "$r" printf '%s╚══════════════════════════════════════════════╝%s\n' "$b" "$r" printf '\n' } # ── 帮助 ───────────────────────────────────── usage() { cat <${C_RESET} [选项] ${C_BOLD}环境:${C_RESET} ${C_CYAN}dev${C_RESET} 本地开发环境 ${C_CYAN}main${C_RESET} 内网测试 / 预发布环境 ${C_CYAN}release${C_RESET} 生产环境 ${C_BOLD}选项:${C_RESET} ${C_WHITE}--repo-url ${C_RESET} Git 仓库地址(HTTPS 或 SSH) 不指定时会提示输入或使用当前远程地址 ${C_WHITE}--branch ${C_RESET} 指定拉取的分支(默认为当前分支) ${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行 ${C_WHITE}--skip-backup${C_RESET} 跳过更新前自动备份 tag(不推荐) ${C_WHITE}--force${C_RESET} 强制覆盖本地更改(git reset --hard) ${C_WHITE}-h, --help${C_RESET} 显示此帮助信息 ${C_BOLD}认证方式:${C_RESET} ${C_WHITE}1. SSH 密钥${C_RESET} 使用 SSH URL(如已配置密钥,自动使用) ${C_WHITE}2. Personal Token${C_RESET} 推荐使用 GitHub/Gitea Personal Access Token ${C_WHITE}3. 用户名密码${C_RESET} HTTPS 基础认证(不推荐,安全性较低) 可通过环境变量提供凭证(适合自动化): GIT_USERNAME Git 用户名 GIT_PASSWORD Git 密码或 Personal Access Token ${C_BOLD}示例:${C_RESET} # 交互式更新(会提示输入仓库地址) bash scripts/update.sh dev # 指定仓库地址更新 bash scripts/update.sh main --repo-url https://github.com/user/ctms-dev.git # 全自动模式(从环境变量读取凭证) export GIT_USERNAME="user" export GIT_PASSWORD="ghp_xxxxxxxxxxxxx" bash scripts/update.sh release --yes --repo-url https://github.com/user/ctms-dev.git # 强制覆盖本地更改 bash scripts/update.sh dev --force ${C_BOLD}说明:${C_RESET} 更新流程:检查工作目录 → 验证仓库身份 → 拉取代码 → 调用安装脚本部署 默认会在拉取前创建备份 tag(格式:pre-update-YYYYMMDD-HHMMSS) EOF } # ── 参数解析 ───────────────────────────────── parse_args() { if [[ $# -eq 0 ]]; then usage exit 1 fi TARGET_ENV="$1" shift case "$TARGET_ENV" in dev|main|release) ;; -h|--help) usage; exit 0 ;; *) usage ctms_fail "未知环境 \"$TARGET_ENV\",有效值为 dev、main 或 release" ;; esac while [[ $# -gt 0 ]]; do case "$1" in --repo-url) [[ $# -ge 2 ]] || ctms_fail "--repo-url 需要提供一个 URL 参数" REPO_URL="$2" shift 2 ;; --branch) [[ $# -ge 2 ]] || ctms_fail "--branch 需要提供分支名" GIT_BRANCH="$2" shift 2 ;; --yes) ASSUME_YES=1; shift ;; --skip-backup) SKIP_BACKUP=1; shift ;; --force) FORCE_PULL=1; shift ;; -h|--help) usage; exit 0 ;; --skip-build|--skip-migrate|--verbose|--base-url) # 透传给 install.sh 的参数 INSTALL_ARGS+=("$1") if [[ "$1" == "--base-url" && $# -ge 2 ]]; then INSTALL_ARGS+=("$2") shift fi shift ;; *) usage ctms_fail "未知参数: $1" ;; esac done } # ── Git 工具函数 ───────────────────────────── check_git_installed() { command -v git >/dev/null 2>&1 || ctms_fail "未检测到 git 命令,请先安装 Git" } check_is_git_repo() { git rev-parse --git-dir >/dev/null 2>&1 || ctms_fail "当前目录不是 Git 仓库" } get_current_branch() { git branch --show-current 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD" } get_current_remote_url() { local branch="${1:-$(get_current_branch)}" local remote remote="$(git config "branch.$branch.remote" 2>/dev/null || echo "origin")" git config "remote.$remote.url" 2>/dev/null || echo "" } has_uncommitted_changes() { ! git diff-index --quiet HEAD -- 2>/dev/null } has_untracked_files() { [[ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]] } # ── 项目身份验证 ───────────────────────────── verify_project_identity() { ctms_step "验证项目身份" local found=0 local file for file in "${REQUIRED_PROJECT_FILES[@]}"; do if [[ -f "$CTMS_ROOT_DIR/$file" ]]; then found=$((found + 1)) fi done if [[ $found -lt 2 ]]; then ctms_fail "项目身份验证失败:未检测到足够的 CTMS 项目特征文件\n" \ " 需要至少 2 个特征文件,当前仅找到 $found 个\n" \ " 请确认仓库地址是否正确" fi # 额外检查 README 内容 if [[ -f "$CTMS_ROOT_DIR/README.md" ]]; then if ! grep -q "CTMS" "$CTMS_ROOT_DIR/README.md" 2>/dev/null; then ctms_warn "README.md 不包含 CTMS 关键字,可能拉取了错误的仓库" fi fi ctms_ok "项目身份验证通过(检测到 $found 个特征文件)" } # ── 工作目录状态检查 ───────────────────────── check_working_directory() { ctms_step "检查工作目录状态" if [[ "$FORCE_PULL" -eq 1 ]]; then ctms_warn "已启用 --force,将强制覆盖本地更改" return 0 fi local has_changes=0 if has_uncommitted_changes; then ctms_warn "检测到未提交的更改" has_changes=1 fi if has_untracked_files; then ctms_warn "检测到未跟踪的文件" has_changes=1 fi if [[ $has_changes -eq 0 ]]; then ctms_ok "工作目录干净" return 0 fi if [[ "$ASSUME_YES" -eq 1 ]]; then ctms_fail "检测到未提交的更改且已启用 --yes 模式\n" \ " 请先提交或使用 --force 参数强制覆盖" fi printf '\n %s%s%s 选择处理方式:%s\n' "${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_RESET}" printf ' %s1)%s 暂存更改(git stash)\n' "${C_BOLD}" "${C_RESET}" printf ' %s2)%s 放弃更改(git reset --hard)\n' "${C_BOLD}" "${C_RESET}" printf ' %s3)%s 取消更新\n' "${C_BOLD}" "${C_RESET}" printf ' 请输入选项 [1-3]: ' local choice read -r choice || choice="" case "$choice" in 1) ctms_info "暂存本地更改..." git stash push -m "auto-stash before update $(date +%Y%m%d-%H%M%S)" || ctms_fail "暂存失败" ctms_ok "已暂存本地更改(可使用 'git stash pop' 恢复)" ;; 2) ctms_warn "即将放弃所有本地更改..." printf ' 确认输入 %syes%s 继续: ' "${C_BOLD}" "${C_RESET}" local confirm read -r confirm || confirm="" [[ "$confirm" == "yes" ]] || ctms_fail "用户取消操作" git reset --hard HEAD || ctms_fail "重置失败" git clean -fd || true ctms_ok "已放弃本地更改" ;; 3|*) ctms_fail "用户取消更新" ;; esac } # ── 仓库地址解析 ───────────────────────────── resolve_repo_url() { ctms_step "解析仓库地址" # 如果未指定,尝试从当前 remote 获取 if [[ -z "$REPO_URL" ]]; then REPO_URL="$(get_current_remote_url)" if [[ -n "$REPO_URL" ]]; then ctms_info "检测到当前远程地址: $REPO_URL" fi fi # 如果仍为空且非交互模式,报错 if [[ -z "$REPO_URL" && "$ASSUME_YES" -eq 1 ]]; then ctms_fail "未指定仓库地址且无法从当前 remote 获取\n" \ " 请使用 --repo-url 参数指定" fi # 交互式输入 if [[ -z "$REPO_URL" ]]; then printf ' %s▸%s 请输入 Git 仓库地址: ' "${C_YELLOW}${C_BOLD}" "${C_RESET}" read -r REPO_URL || true fi [[ -n "$REPO_URL" ]] || ctms_fail "仓库地址不能为空" # 验证 URL 格式 if [[ ! "$REPO_URL" =~ ^(https?://|git@|ssh://) ]]; then ctms_fail "仓库地址格式不正确\n" \ " 支持格式:https://..., git@..., ssh://..." fi ctms_ok "仓库地址: $REPO_URL" } # ── 远程引用选择 ───────────────────────────── pick_remote_ref() { # --branch 已指定则跳过 if [[ -n "$GIT_BRANCH" ]]; then ctms_info "指定引用: $GIT_BRANCH" return fi # --yes 模式无法交互,fallback 到当前分支 if [[ "$ASSUME_YES" -eq 1 ]]; then GIT_BRANCH="$(get_current_branch)" ctms_info "自动模式,使用当前分支: $GIT_BRANCH" return fi ctms_step "选择远程引用" local raw raw="$(git ls-remote --refs "$REPO_URL" 2>/dev/null)" || { GIT_BRANCH="$(ctms_ask_line "无法列出远程引用,请手动输入分支/标签/SHA" "$(get_current_branch)")" return } local -a names types local sha ref name while IFS=$'\t' read -r sha ref; do name="${ref#refs/heads/}" names+=("$name"); types+=("branch") done < <(printf '%s\n' "$raw" | grep $'\trefs/heads/') while IFS=$'\t' read -r sha ref; do name="${ref#refs/tags/}" names+=("$name"); types+=("tag") done < <(printf '%s\n' "$raw" | grep $'\trefs/tags/' | sort -rt/ -k3 | head -10) local manual_idx=$(( ${#names[@]} + 1 )) local choice i while true; do printf '\n' for i in "${!names[@]}"; do printf ' %s%2d)%s %s%-8s%s %s\n' \ "${C_BOLD}" $((i+1)) "${C_RESET}" \ "${CC_MUTED}" "${types[$i]}" "${C_RESET}" "${names[$i]}" done printf ' %s%2d)%s %s手动输入 SHA / 分支名 / 标签名%s\n\n' \ "${C_BOLD}" "$manual_idx" "${C_RESET}" "${CC_MUTED}" "${C_RESET}" printf ' %s▸%s 请选择 (1-%d): ' "${C_YELLOW}${C_BOLD}" "${C_RESET}" "$manual_idx" read -r choice || choice="" if [[ "$choice" == "$manual_idx" || -z "$choice" ]]; then GIT_BRANCH="$(ctms_ask_line "请输入分支名、标签或 SHA")" break elif [[ "$choice" =~ ^[0-9]+$ && "$choice" -ge 1 && "$choice" -le "${#names[@]}" ]]; then GIT_BRANCH="${names[$((choice-1))]}" ctms_ok "已选择 ${types[$((choice-1))]}: $GIT_BRANCH" break else ctms_warn "无效选择,请重新输入" fi done } # ── 认证处理 ───────────────────────────────── setup_git_credentials() { # SSH 协议不需要额外配置 if [[ "$REPO_URL" =~ ^(git@|ssh://) ]]; then ctms_info "使用 SSH 协议,跳过凭证配置" return 0 fi ctms_step "配置 Git 认证" # 从环境变量读取凭证 local username="${GIT_USERNAME:-}" local password="${GIT_PASSWORD:-}" # 如果环境变量未设置且非自动模式,交互式输入 if [[ -z "$username" && "$ASSUME_YES" -eq 0 ]]; then printf ' %s▸%s Git 用户名: ' "${C_YELLOW}${C_BOLD}" "${C_RESET}" read -r username || username="" fi if [[ -z "$password" && "$ASSUME_YES" -eq 0 ]]; then printf ' %s▸%s Git 密码/Token (输入不回显): ' "${C_YELLOW}${C_BOLD}" "${C_RESET}" read -rs password || password="" printf '\n' fi # 如果凭证为空,尝试使用系统凭证存储 if [[ -z "$username" || -z "$password" ]]; then ctms_info "未提供凭证,将尝试使用系统凭证存储" return 0 fi # 配置临时凭证助手(仅本次会话有效) export GIT_ASKPASS_USERNAME="$username" export GIT_ASKPASS_PASSWORD="$password" # 创建临时凭证助手脚本 local askpass_script="/tmp/git-askpass-$$.sh" cat > "$askpass_script" <<'ASKPASS' #!/bin/bash case "$1" in *Username*) echo "$GIT_ASKPASS_USERNAME" ;; *Password*) echo "$GIT_ASKPASS_PASSWORD" ;; esac ASKPASS chmod +x "$askpass_script" export GIT_ASKPASS="$askpass_script" ctms_ok "认证配置完成" } cleanup_git_credentials() { if [[ -n "${GIT_ASKPASS:-}" && -f "$GIT_ASKPASS" ]]; then rm -f "$GIT_ASKPASS" 2>/dev/null || true fi unset GIT_ASKPASS GIT_ASKPASS_USERNAME GIT_ASKPASS_PASSWORD } # ── 备份当前版本 ───────────────────────────── create_backup_tag() { if [[ "$SKIP_BACKUP" -eq 1 ]]; then ctms_info "已跳过备份(--skip-backup)" return 0 fi ctms_step "创建更新前备份" local backup_tag="pre-update-$(date +%Y%m%d-%H%M%S)" if git tag "$backup_tag" 2>/dev/null; then ctms_ok "已创建备份 tag: $backup_tag" ctms_info "如需回滚,执行: git reset --hard $backup_tag" else ctms_warn "备份 tag 创建失败(可能当前无提交)" fi } # ── 拉取代码 ───────────────────────────────── fetch_and_pull() { ctms_step "拉取远程代码" local remote="origin" # 更新或添加 remote if git remote | grep -q "^${remote}$"; then git remote set-url "$remote" "$REPO_URL" || ctms_fail "更新远程地址失败" else git remote add "$remote" "$REPO_URL" || ctms_fail "添加远程仓库失败" fi # Fetch(含 tags) ctms_info "正在从远程仓库获取更新..." if ! git fetch "$remote" --tags 2>&1 | while IFS= read -r line; do printf ' %s\n' "$line" done; then cleanup_git_credentials ctms_fail "拉取失败,请检查网络连接和仓库权限" fi # 支持分支、标签、SHA — 统一 reset --hard if git rev-parse --verify "$remote/$GIT_BRANCH" >/dev/null 2>&1; then ctms_info "切换到远程分支: $GIT_BRANCH" git reset --hard "$remote/$GIT_BRANCH" || ctms_fail "重置失败" elif git rev-parse --verify "$GIT_BRANCH" >/dev/null 2>&1; then ctms_info "切换到引用: $GIT_BRANCH" git reset --hard "$GIT_BRANCH" || ctms_fail "重置失败" else cleanup_git_credentials ctms_fail "无法解析引用 '$GIT_BRANCH',请确认分支/标签/SHA 正确" fi cleanup_git_credentials ctms_ok "代码拉取完成" } # ── 更新确认 ───────────────────────────────── confirm_update() { if [[ "$ASSUME_YES" -eq 1 ]]; then ctms_info "已启用 --yes,跳过确认" return 0 fi ctms_step "确认更新参数" local w=66 local row row() { local label="$1" value="$2" vcolor="${3:-$CC_TEXT}" local padded padded="$(ctms_pad_right "$label" 14)" ctms_box_line "${CC_PRIMARY}${C_BOLD}" "$w" \ "${padded}${value}" \ "${CC_MUTED}${padded}${C_RESET}${vcolor}${value}${C_RESET}" } printf '\n' ctms_box_top "${CC_PRIMARY}${C_BOLD}" "更新参数预览" "$w" row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}" row "仓库地址" "$REPO_URL" "${CC_INFO}" row "目标引用" "$GIT_BRANCH" "${CC_TEXT}" row "本地更改" "$([[ "$FORCE_PULL" -eq 1 ]] && echo "强制跳过检查" || echo "检测并确认")" "${CC_TEXT}" row "备份策略" "$([[ "$SKIP_BACKUP" -eq 1 ]] && echo "跳过" || echo "自动创建 tag")" "${CC_TEXT}" ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$w" printf '\n %s%s%s 确认以上参数并继续更新?输入 %syes%s 继续: ' \ "${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_BOLD}" "${C_RESET}" local answer read -r answer || answer="" [[ "$answer" == "yes" ]] || ctms_fail "用户取消更新" } # ── 调用安装脚本 ───────────────────────────── run_install_script() { ctms_step "执行部署更新" local install_script="$CTMS_ROOT_DIR/scripts/install.sh" [[ -x "$install_script" ]] || ctms_fail "安装脚本不可执行: $install_script" ctms_info "调用安装脚本完成容器更新..." "$install_script" "$TARGET_ENV" "${INSTALL_ARGS[@]}" } # ── 完成提示 ───────────────────────────────── show_success() { local w=66 local current_commit current_commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")" printf '\n' ctms_box_top "${CC_SUCCESS}${C_BOLD}" "$ICON_OK CTMS 更新成功" "$w" ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \ "$(ctms_pad_right "当前版本" 14)${current_commit}" \ "${CC_MUTED}$(ctms_pad_right "当前版本" 14)${C_RESET}${CC_INFO}${current_commit}${C_RESET}" ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \ "$(ctms_pad_right "环境" 14)${TARGET_ENV}" \ "${CC_MUTED}$(ctms_pad_right "环境" 14)${C_RESET}${CC_TEXT}${TARGET_ENV}${C_RESET}" ctms_box_bottom "${CC_SUCCESS}${C_BOLD}" "$w" printf '\n' } # ── 入口 ───────────────────────────────────── main() { _banner parse_args "$@" cd "$CTMS_ROOT_DIR" check_git_installed check_is_git_repo check_working_directory resolve_repo_url setup_git_credentials pick_remote_ref confirm_update # 先验证当前项目身份 verify_project_identity # 创建备份 create_backup_tag # 拉取代码 fetch_and_pull # 拉取后再次验证(防止拉错仓库) verify_project_identity # 调用安装脚本完成部署 run_install_script show_success } # 捕获退出信号,清理凭证 trap cleanup_git_credentials EXIT main "$@"