滚动更新新增 Git 访问方式选择(HTTPS/SSH)及工作区变更预检

- install.sh:
  - 滚动更新流程新增「选择 Git 访问方式」步骤(HTTPS 登录 / SSH 访问)
  - SSH 方式支持指定私钥文件路径或粘贴私钥内容(临时文件用后即删)
  - 仓库地址输入回填当前 remote(同协议时)作为默认值
  - 选环境后立即检测工作区变更,提供暂存/放弃/取消处理
  - 更新结束后按 HEAD 是否变化决定自动恢复暂存或提示手动恢复
- scripts/update.sh:
  - 新增 --ssh-key 参数,SSH 协议下通过 GIT_SSH_COMMAND -i 指定私钥
  - 新增 --skip-dirty-check 参数,供 install.sh 预检后透传
- scripts/common.sh:
  - ctms_ask_line 提示语改输出到 stderr,避免被命令替换吞掉

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-06-24 09:26:54 +08:00
parent 2df8d60172
commit 24d5b7445f
5 changed files with 388 additions and 1237 deletions
File diff suppressed because it is too large Load Diff
+294 -19
View File
@@ -8,7 +8,14 @@ source "$ROOT_DIR/scripts/common.sh"
SELECTED_INDEX=0 SELECTED_INDEX=0
SELECTED_ENV="" SELECTED_ENV=""
SELECTED_BASE_URL="" SELECTED_BASE_URL=""
SELECTED_REPO_URL=""
SELECTED_SSH_KEY=""
SELECTED_SSH_KEY_TEMP=0
STASHED_BEFORE_UPDATE=0
ENV_STATUS_LINES="" ENV_STATUS_LINES=""
DEPLOYED_ENVS=""
BREADCRUMB="请选择操作"
OP_LABEL=""
UI_BOX_WIDTH=76 UI_BOX_WIDTH=76
usage() { usage() {
@@ -46,7 +53,7 @@ require_interactive_terminal() {
banner() { banner() {
printf '\n' printf '\n'
ctms_box_top "${C_BLUE}${C_BOLD}" "CTMS DEPLOYMENT CONSOLE" "$UI_BOX_WIDTH" ctms_box_top "${C_BLUE}${C_BOLD}" "CTMS DEPLOYMENT CONSOLE" "$UI_BOX_WIDTH"
ctms_box_line "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH" "${ICON_BULLET} 安装 · 更新 · 卸载 · 资源状态" "${CC_INFO}${C_BOLD}${ICON_BULLET}${C_RESET} 安装 · 更新 · 卸载 · 资源状态" ctms_box_line "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH" "${ICON_BULLET} ${BREADCRUMB}" "${CC_INFO}${C_BOLD}${ICON_BULLET}${C_RESET} ${CC_PRIMARY}${C_BOLD}${BREADCRUMB}${C_RESET}"
ctms_box_line "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH" "↑/↓ 选择 Enter 确认 Q 退出" "${CC_MUTED}↑/↓ 选择 Enter 确认 Q 退出${C_RESET}" ctms_box_line "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH" "↑/↓ 选择 Enter 确认 Q 退出" "${CC_MUTED}↑/↓ 选择 Enter 确认 Q 退出${C_RESET}"
ctms_box_bottom "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH" ctms_box_bottom "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH"
printf '\n' printf '\n'
@@ -66,6 +73,7 @@ build_env_status() {
local output="" local output=""
local detected=0 local detected=0
local docker_error="" local docker_error=""
DEPLOYED_ENVS=""
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
ENV_STATUS_LINES=" ${C_YELLOW}Docker 未安装或 docker 命令不可用${C_RESET}" ENV_STATUS_LINES=" ${C_YELLOW}Docker 未安装或 docker 命令不可用${C_RESET}"
@@ -88,6 +96,7 @@ build_env_status() {
[[ -n "$rows" ]] || continue [[ -n "$rows" ]] || continue
detected=1 detected=1
DEPLOYED_ENVS="${DEPLOYED_ENVS}${env} "
total="$(printf '%s\n' "$rows" | awk 'NF { count++ } END { print count + 0 }')" total="$(printf '%s\n' "$rows" | awk 'NF { count++ } END { print count + 0 }')"
running="$(printf '%s\n' "$rows" | awk -F'|' '$2 == "running" { count++ } END { print count + 0 }')" running="$(printf '%s\n' "$rows" | awk -F'|' '$2 == "running" { count++ } END { print count + 0 }')"
stopped=$(( total - running )) stopped=$(( total - running ))
@@ -176,32 +185,93 @@ pick_env() {
main) default_index=1 ;; main) default_index=1 ;;
release) default_index=2 ;; release) default_index=2 ;;
esac esac
local -a opts=( # 按显示宽度对齐三列(中文双宽,半角空格凑不齐)
"dev 本地开发环境 Vite 热加载 / 默认密钥" local n0="dev" n1="main" n2="release"
"main 内网测试环境 预发布验证 / 生产模式"
"release 生产环境 HTTPS / RSA 密钥"
)
if [[ -n "$env" ]]; then if [[ -n "$env" ]]; then
case "$default_index" in case "$default_index" in
0) opts[0]="dev(默认) 本地开发环境 Vite 热加载 / 默认密钥" ;; 0) n0="dev(默认)" ;;
1) opts[1]="main(默认) 内网测试环境 预发布验证 / 生产模式" ;; 1) n1="main(默认)" ;;
2) opts[2]="release(默认) 生产环境 HTTPS / RSA 密钥" ;; 2) n2="release(默认)" ;;
esac esac
fi fi
local -a opts=(
"$(ctms_pad_right "$n0" 14)$(ctms_pad_right "本地开发环境" 16)Vite 热加载 / 默认密钥"
"$(ctms_pad_right "$n1" 14)$(ctms_pad_right "内网测试环境" 16)预发布验证 / 生产模式"
"$(ctms_pad_right "$n2" 14)$(ctms_pad_right "生产环境" 16)HTTPS / RSA 密钥"
)
local menu_title="选择环境" local menu_title="选择环境"
[[ -n "$env" ]] && menu_title="选择环境 (已识别: ${env}" [[ -n "$env" ]] && menu_title="选择环境 (已识别: ${env}"
BREADCRUMB="${OP_LABEL:-操作} 选择环境"
select_menu "$menu_title" "$default_index" "${opts[@]}" select_menu "$menu_title" "$default_index" "${opts[@]}"
case "$SELECTED_INDEX" in case "$SELECTED_INDEX" in
0) SELECTED_ENV="dev" ;; 0) SELECTED_ENV="dev" ;;
1) SELECTED_ENV="main" ;; 1) SELECTED_ENV="main" ;;
2) SELECTED_ENV="release" ;; 2) SELECTED_ENV="release" ;;
esac esac
# 手动选择与配置不一致时更新 .ctms-env }
if [[ -n "$env" && "$SELECTED_ENV" != "$env" ]]; then
# 把环境持久化到 .ctms-env,仅在操作成功完成后调用。
# 文件不存在则创建(首次安装场景);存在则就地更新 env 行。
# 若本次收集到健康检查地址(SELECTED_BASE_URL),一并写入/更新 base-url。
persist_env() {
local env_to_save="$1"
local file="$ROOT_DIR/.ctms-env" local file="$ROOT_DIR/.ctms-env"
sed -i.bak "s/^env *=.*/env = $SELECTED_ENV/" "$file" && rm -f "${file}.bak"
ctms_info "已将 .ctms-env 中的环境更新为: $SELECTED_ENV" if [[ ! -f "$file" ]]; then
{
printf 'env = %s\n' "$env_to_save"
[[ -n "${SELECTED_BASE_URL:-}" ]] && printf 'base-url = %s\n' "$SELECTED_BASE_URL"
} >"$file"
ctms_info "已创建 .ctms-env,环境记录为: $env_to_save"
return 0
fi fi
local current
current="$(ctms_config_get "env" || true)"
if [[ "$current" != "$env_to_save" ]]; then
sed -i.bak "s/^env *=.*/env = $env_to_save/" "$file" && rm -f "${file}.bak"
ctms_info "已将 .ctms-env 中的环境更新为: $env_to_save"
fi
# 同步 base-url:存在则替换该行,不存在则追加
if [[ -n "${SELECTED_BASE_URL:-}" ]]; then
local current_url
current_url="$(ctms_config_get "base-url" || true)"
if [[ "$current_url" != "$SELECTED_BASE_URL" ]]; then
if grep -q '^base-url *=' "$file"; then
sed -i.bak "s|^base-url *=.*|base-url = $SELECTED_BASE_URL|" "$file" && rm -f "${file}.bak"
else
printf 'base-url = %s\n' "$SELECTED_BASE_URL" >>"$file"
fi
ctms_info "已将 .ctms-env 中的健康检查地址更新为: $SELECTED_BASE_URL"
fi
fi
}
# 卸载专用:仅在“实际检测到容器”的环境中选择,避免选到空环境走完确认却什么都没卸。
# 始终弹出选择菜单(哪怕只有一个环境),作为危险确认前的显式安全闸门。
# 返回 0 表示已选定 SELECTED_ENV,返回 1 表示无可卸载环境(调用方应跳过卸载)。
pick_deployed_env() {
# DEPLOYED_ENVS 由 build_env_status 填充(空格分隔)
local -a envs=()
read -r -a envs <<<"${DEPLOYED_ENVS:-}"
if [[ "${#envs[@]}" -eq 0 ]]; then
printf '\n %s%s%s %s未检测到任何已部署的 CTMS 环境,无需卸载。%s\n' \
"${CC_WARN}${C_BOLD}" "$ICON_WARN" "${C_RESET}" "${CC_MUTED}" "${C_RESET}"
return 1
fi
# 卸载是破坏性操作:即使只有一个环境也显式选择一次,作为危险确认前的安全闸门。
local -a opts=()
local e
for e in "${envs[@]}"; do
opts+=("$(ctms_pad_right "$e" 14)$(ctms_pad_right "Compose 项目" 16)$(ctms_project_name "$e")")
done
BREADCRUMB="${OP_LABEL:-安全卸载} 选择环境"
select_menu "选择要卸载的环境" "" "${opts[@]}"
SELECTED_ENV="${envs[$SELECTED_INDEX]}"
return 0
} }
collect_install_options() { collect_install_options() {
@@ -216,6 +286,166 @@ collect_install_options() {
fi fi
} }
# 读取当前仓库 origin 的远程地址(供输入仓库地址时作默认值回填)
ctms_current_remote_url() {
git -C "$ROOT_DIR" remote get-url origin 2>/dev/null || true
}
# 滚动更新专用:选环境后立即检测工作区变更,脏则就地处理。
# 返回 0 表示可继续,返回 1 表示用户取消本次更新(调用方应跳过后续步骤)。
# 处理干净后,update.sh 会以 --skip-dirty-check 跳过重复检测。
check_workspace_clean() {
STASHED_BEFORE_UPDATE=0
cd "$ROOT_DIR" || return 1
command -v git >/dev/null 2>&1 || return 0 # 无 git 交给 update.sh 报错
git rev-parse --git-dir >/dev/null 2>&1 || return 0
local dirty=0
git diff-index --quiet HEAD -- 2>/dev/null || dirty=1
[[ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]] && dirty=1
if [[ "$dirty" -eq 0 ]]; then
ctms_ok "工作目录干净"
return 0
fi
printf '\n %s%s%s 检测到本地有未提交/未跟踪的更改,请选择处理方式:\n' \
"${CC_WARN}${C_BOLD}" "$ICON_WARN" "${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)
if git stash push -m "auto-stash before update $(date +%Y%m%d-%H%M%S)"; then
ctms_ok "已暂存本地更改(可用 'git stash pop' 恢复)"
STASHED_BEFORE_UPDATE=1
else
ctms_warn "暂存失败,已取消本次更新"
return 1
fi
;;
2)
printf ' 确认输入 %syes%s 放弃所有本地更改: ' "${C_BOLD}" "${C_RESET}"
local confirm
read -r confirm || confirm=""
[[ "$confirm" == "yes" ]] || { ctms_info "已取消本次更新"; return 1; }
git reset --hard HEAD || { ctms_warn "重置失败,已取消"; return 1; }
git clean -fd || true
ctms_ok "已放弃本地更改"
;;
*)
ctms_info "已取消本次更新"
return 1
;;
esac
return 0
}
# 滚动更新善后:根据更新前后 HEAD 是否变化,决定如何处理之前暂存的本地改动。
# 仅当 check_workspace_clean 走了「暂存」分支(STASHED_BEFORE_UPDATE=1)时才动作。
# · HEAD 未变(更新在拉取代码前就失败/中断)→ stash 基线仍匹配,自动 pop,无冲突风险;
# · HEAD 已变(代码已更新,无论成功失败)→ 不自动 pop,避免与新代码冲突,仅提示手动恢复。
restore_stash_after_update() {
[[ "$STASHED_BEFORE_UPDATE" -eq 1 ]] || return 0
local head_before="$1" head_after
head_after="$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true)"
if [[ -n "$head_before" && "$head_before" == "$head_after" ]]; then
ctms_info "代码未发生变化,正在自动恢复你暂存的本地改动..."
if git -C "$ROOT_DIR" stash pop >/dev/null 2>&1; then
ctms_ok "已自动恢复暂存的本地改动"
STASHED_BEFORE_UPDATE=0
return 0
fi
ctms_warn "自动恢复失败(可能存在冲突),改动仍保留在 stash 栈中"
fi
printf '\n %s%s%s 你更新前暂存的本地改动仍保留在 stash 栈中。\n' \
"${CC_WARN}${C_BOLD}" "$ICON_WARN" "${C_RESET}"
printf ' 恢复请执行: %sgit stash pop%s 查看请执行: %sgit stash list%s\n' \
"${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
}
# SSH 访问专用:收集私钥来源,写入 SELECTED_SSH_KEY(私钥文件路径)。
# 两种来源:① 输入已有私钥文件路径;② 留空则改为粘贴私钥内容,写入 chmod 600 临时文件
# SELECTED_SSH_KEY_TEMP=1 标记,run_update_script 调用后删除)。两者都留空则回退系统默认 SSH 配置。
collect_ssh_key() {
local key_path
key_path="$(ctms_ask_line "请输入 SSH 私钥文件路径(留空则改为粘贴私钥内容)")"
# 展开起始的 ~ 为 $HOME
[[ "$key_path" == "~"* ]] && key_path="${key_path/#\~/$HOME}"
if [[ -n "$key_path" ]]; then
[[ -f "$key_path" ]] || ctms_fail "SSH 私钥文件不存在: $key_path"
[[ -r "$key_path" ]] || ctms_fail "SSH 私钥文件不可读: $key_path"
SELECTED_SSH_KEY="$key_path"
ctms_info "将使用 SSH 私钥文件: $key_path"
return 0
fi
# 粘贴私钥内容:多行读取,以 Ctrl-D(EOF)结束
printf '\n %s%s%s 请粘贴 SSH 私钥内容,粘贴完成后按 %sCtrl-D%s 结束(留空则使用系统默认 SSH 配置):\n' \
"${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
local content
content="$(cat)"
if [[ -z "$content" ]]; then
ctms_info "未提供私钥,将使用系统默认 SSH 配置(~/.ssh/"
return 0
fi
local tmp_key
tmp_key="$(mktemp "/tmp/ctms-ssh-key-$$.XXXXXX")" || ctms_fail "无法创建临时私钥文件"
chmod 600 "$tmp_key"
printf '%s\n' "$content" >"$tmp_key"
SELECTED_SSH_KEY="$tmp_key"
SELECTED_SSH_KEY_TEMP=1
ctms_info "私钥内容已写入临时文件(用后即删)"
}
# 滚动更新专用:选择 Git 访问方式(HTTPS 登录 / SSH 密钥),并手动输入对应格式的仓库地址。
# 选好的地址写入 SELECTED_REPO_URL,由 run_update_script 透传给 update.sh --repo-url。
# update.sh 会按 URL 协议自动区分认证:https:// 走账号/Tokengit@/ssh:// 走 SSH 密钥。
pick_access_method() {
SELECTED_REPO_URL=""
SELECTED_SSH_KEY=""
SELECTED_SSH_KEY_TEMP=0
local -a opts=(
"$(ctms_pad_right "HTTPS 登录" 16)$(ctms_pad_right "账号 / Token" 18)https:// 仓库地址"
"$(ctms_pad_right "SSH 访问" 16)$(ctms_pad_right "本机 SSH 密钥" 18)git@ 或 ssh:// 仓库地址"
)
BREADCRUMB="${OP_LABEL:-滚动更新} ${SELECTED_ENV} 选择访问方式"
select_menu "选择 Git 访问方式" "" "${opts[@]}"
# 当前 remote 地址:与所选协议匹配时作为默认值回填,留空即沿用
local current_url
current_url="$(ctms_current_remote_url)"
local url default=""
case "$SELECTED_INDEX" in
0)
[[ "$current_url" =~ ^https?:// ]] && default="$current_url"
url="$(ctms_ask_line "请输入仓库地址" "$default")"
[[ -n "$url" ]] || ctms_fail "仓库地址不能为空"
[[ "$url" =~ ^https?:// ]] || ctms_fail "HTTPS 方式仓库地址需以 http:// 或 https:// 开头"
;;
1)
[[ "$current_url" =~ ^(git@|ssh://) ]] && default="$current_url"
url="$(ctms_ask_line "请输入仓库地址" "$default")"
[[ -n "$url" ]] || ctms_fail "仓库地址不能为空"
[[ "$url" =~ ^(git@|ssh://) ]] || ctms_fail "SSH 方式仓库地址需以 git@ 或 ssh:// 开头"
collect_ssh_key
;;
esac
SELECTED_REPO_URL="$url"
}
run_script() { run_script() {
local script="$1" local script="$1"
shift shift
@@ -234,6 +464,25 @@ run_install_like_script() {
fi fi
} }
# 滚动更新专用:在 install 透传基础上追加 --repo-url / --ssh-key,决定 update.sh 使用的访问方式。
run_update_script() {
local env="$1"
# 工作区脏检查已在 check_workspace_clean 处理过,让 update.sh 跳过重复检测
local -a args=("$env" --skip-dirty-check)
[[ -n "$SELECTED_REPO_URL" ]] && args+=(--repo-url "$SELECTED_REPO_URL")
[[ -n "$SELECTED_SSH_KEY" ]] && args+=(--ssh-key "$SELECTED_SSH_KEY")
[[ -n "$SELECTED_BASE_URL" ]] && args+=(--base-url "$SELECTED_BASE_URL")
local rc=0
run_script "update.sh" "${args[@]}" || rc=$?
# 清理本次粘贴生成的临时私钥
if [[ "$SELECTED_SSH_KEY_TEMP" -eq 1 && -n "$SELECTED_SSH_KEY" ]]; then
rm -f "$SELECTED_SSH_KEY" 2>/dev/null || true
fi
return "$rc"
}
# 操作完成后暂停,等用户确认再返回菜单 # 操作完成后暂停,等用户确认再返回菜单
pause_return() { pause_return() {
printf '\n %s%s%s 按 Enter 返回主菜单…' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" printf '\n %s%s%s 按 Enter 返回主菜单…' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}"
@@ -241,6 +490,7 @@ pause_return() {
} }
interactive_main() { interactive_main() {
BREADCRUMB="请选择操作"
select_menu "选择操作" "" \ select_menu "选择操作" "" \
"安装部署 配置环境、构建容器、迁移数据库、健康检查" \ "安装部署 配置环境、构建容器、迁移数据库、健康检查" \
"滚动更新 重建服务、执行迁移、复用安装健康检查" \ "滚动更新 重建服务、执行迁移、复用安装健康检查" \
@@ -251,28 +501,53 @@ interactive_main() {
case "$SELECTED_INDEX" in case "$SELECTED_INDEX" in
0) 0)
OP_LABEL="安装部署"
pick_env pick_env
collect_install_options "$SELECTED_ENV" collect_install_options "$SELECTED_ENV"
run_install_like_script "install.sh" "$SELECTED_ENV" BREADCRUMB="${OP_LABEL} ${SELECTED_ENV} 执行中"
if run_install_like_script "install.sh" "$SELECTED_ENV"; then
persist_env "$SELECTED_ENV"
fi
pause_return pause_return
;; ;;
1) 1)
OP_LABEL="滚动更新"
pick_env pick_env
if check_workspace_clean; then
pick_access_method
collect_install_options "$SELECTED_ENV" collect_install_options "$SELECTED_ENV"
run_install_like_script "update.sh" "$SELECTED_ENV" BREADCRUMB="${OP_LABEL} ${SELECTED_ENV} 执行中"
# 记录更新前 HEAD,用于结束后判断代码是否被改动(决定 stash 能否安全自动恢复)
local head_before_update
head_before_update="$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true)"
if run_update_script "$SELECTED_ENV"; then
persist_env "$SELECTED_ENV"
fi
restore_stash_after_update "$head_before_update"
fi
pause_return pause_return
;; ;;
2) 2)
pick_env OP_LABEL="安全卸载"
if pick_deployed_env; then
BREADCRUMB="${OP_LABEL} ${SELECTED_ENV} 执行中"
run_script "uninstall.sh" "$SELECTED_ENV" run_script "uninstall.sh" "$SELECTED_ENV"
fi
pause_return pause_return
;; ;;
3) 3)
pick_env OP_LABEL="资源状态"
run_script "status.sh" "$SELECTED_ENV" # 只读快照,无需选环境:直接用 .ctms-env 识别的环境(无配置回退 dev)
pause_return local env_for_status
env_for_status="$(ctms_config_get "env" || true)"
env_for_status="${env_for_status:-dev}"
BREADCRUMB="${OP_LABEL} ${env_for_status}"
# status.sh 自身是交互循环(q/Enter 退出并清屏),正常退出直接回菜单;
# 仅在出错时暂停,避免错误信息被菜单重绘一闪而过。
run_script "status.sh" "$env_for_status" || pause_return
;; ;;
4) 4)
BREADCRUMB="使用帮助"
usage usage
pause_return pause_return
;; ;;
+4 -2
View File
@@ -485,10 +485,12 @@ ctms_ask_line() {
local default_value="${2:-}" local default_value="${2:-}"
local value="" local value=""
# 提示语输出到 stderr:调用方多用 $(ctms_ask_line ...) 捕获返回值,
# 若提示语走 stdout 会被一并吞掉,导致终端只剩光标、提示语还混进返回值。
if [[ -n "$default_value" ]]; then if [[ -n "$default_value" ]]; then
printf '\n%s%s%s %s %s[%s]%s: ' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "$prompt" "${CC_MUTED}" "$default_value" "${C_RESET}" printf '\n%s%s%s %s %s[%s]%s: ' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "$prompt" "${CC_MUTED}" "$default_value" "${C_RESET}" >&2
else else
printf '\n%s%s%s %s: ' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "$prompt" printf '\n%s%s%s %s: ' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "$prompt" >&2
fi fi
IFS= read -r value || value="" IFS= read -r value || value=""
printf '%s' "${value:-$default_value}" printf '%s' "${value:-$default_value}"
+61 -4
View File
@@ -14,13 +14,18 @@ ${C_BOLD}环境:${C_RESET}
dev | main | release dev | main | release
${C_BOLD}说明:${C_RESET} ${C_BOLD}说明:${C_RESET}
使用 docker compose stats --no-stream 获取资源快照,并输出整理后的状态面板。 交互终端下动态刷新容器资源面板(按 q 或 Enter 返回);
非交互终端或带 --once 时只输出一次快照。
${C_BOLD}选项:${C_RESET}
--once 仅输出一次快照,不进入动态刷新
EOF EOF
} }
STATS_OUTPUT="" STATS_OUTPUT=""
STATS_ERROR="" STATS_ERROR=""
STATUS_BOX_WIDTH=127 STATUS_BOX_WIDTH=127
STATUS_REFRESH_INTERVAL=2
collect_compose_stats() { collect_compose_stats() {
local env="$1" local env="$1"
@@ -76,7 +81,10 @@ render_stats_panel() {
fi fi
ctms_box_top "${CC_ACCENT}${C_BOLD}" "容器资源" "$STATUS_BOX_WIDTH" ctms_box_top "${CC_ACCENT}${C_BOLD}" "容器资源" "$STATUS_BOX_WIDTH"
ctms_box_line "${CC_ACCENT}${C_BOLD}" "$STATUS_BOX_WIDTH" "$(printf '%-22s %-9s %-27s %-8s %-21s %-21s %-6s' "容器" "CPU" "内存使用 / 限制" "内存%" "网络 I/O" "磁盘 I/O" "PIDs")" "${CC_MUTED}$(printf '%-22s %-9s %-27s %-8s %-21s %-21s %-6s' "容器" "CPU" "内存使用 / 限制" "内存%" "网络 I/O" "磁盘 I/O" "PIDs")${C_RESET}" # 表头含中文(双宽),用 ctms_pad_right 按显示宽度对齐,列宽与下方数据行的 %-Ns 一致
local header
header="$(ctms_pad_right "容器" 22) $(ctms_pad_right "CPU" 9) $(ctms_pad_right "内存使用 / 限制" 27) $(ctms_pad_right "内存%" 8) $(ctms_pad_right "网络 I/O" 21) $(ctms_pad_right "磁盘 I/O" 21) $(ctms_pad_right "PIDs" 6)"
ctms_box_line "${CC_ACCENT}${C_BOLD}" "$STATUS_BOX_WIDTH" "$header" "${CC_MUTED}${header}${C_RESET}"
printf '%s├%s┤%s\n' "${CC_ACCENT}${C_BOLD}" "$(ctms_repeat_char '─' "$(( STATUS_BOX_WIDTH - 2 ))")" "${C_RESET}" printf '%s├%s┤%s\n' "${CC_ACCENT}${C_BOLD}" "$(ctms_repeat_char '─' "$(( STATUS_BOX_WIDTH - 2 ))")" "${C_RESET}"
printf '%s\n' "$STATS_OUTPUT" | while IFS='|' read -r name cpu mem mem_percent net_io block_io pids; do printf '%s\n' "$STATS_OUTPUT" | while IFS='|' read -r name cpu mem mem_percent net_io block_io pids; do
@@ -89,21 +97,70 @@ render_stats_panel() {
printf ' %s数据来源%s docker compose stats --all --no-stream\n\n' "${CC_MUTED}" "${C_RESET}" printf ' %s数据来源%s docker compose stats --all --no-stream\n\n' "${CC_MUTED}" "${C_RESET}"
} }
run_dynamic() {
local env="$1" project="$2"
local key=""
# 隐藏光标,退出时(含 Ctrl-C)务必恢复并清屏,避免终端残留
printf '\033[?25l'
trap 'ctms_restore_cursor; printf "\033[2J\033[H"; exit 0' INT TERM
while true; do
collect_compose_stats "$env" || true
# 定位左上角重绘,再清除光标之后的残留行(容器数变化时不留尾巴)
printf '\033[H'
render_stats_panel "$env" "$project" || true
printf ' %s每 %ss 自动刷新%s %s按 q 或 Enter 返回菜单%s\n' \
"${CC_MUTED}" "$STATUS_REFRESH_INTERVAL" "${C_RESET}" \
"${CC_INFO}${C_BOLD}" "${C_RESET}"
printf '\033[J'
# 刷新间隔内监听退出键;超时(无输入)则继续刷新
key=""
if IFS= read -rsn1 -t "$STATUS_REFRESH_INTERVAL" key; then
case "$key" in
q|Q|'') break ;; # q/Q 或 Enter 退出
esac
fi
done
trap - INT TERM
ctms_restore_cursor
printf '\033[2J\033[H'
}
main() { main() {
if [[ $# -ne 1 ]]; then local once=0
local -a positional=()
while [[ $# -gt 0 ]]; do
case "$1" in
--once) once=1; shift ;;
*) positional+=("$1"); shift ;;
esac
done
if [[ "${#positional[@]}" -ne 1 ]]; then
usage usage
exit 1 exit 1
fi fi
local env="$1" local env="${positional[0]}"
local project="" local project=""
ctms_require_env "$env" ctms_require_env "$env"
project="$(ctms_project_name "$env")" project="$(ctms_project_name "$env")"
ctms_require_docker_compose ctms_require_docker_compose
cd "$CTMS_ROOT_DIR" cd "$CTMS_ROOT_DIR"
# 非交互终端或显式 --once:只输出一次快照
if [[ "$once" -eq 1 || ! -t 1 || ! -t 0 ]]; then
collect_compose_stats "$env" || true collect_compose_stats "$env" || true
render_stats_panel "$env" "$project" render_stats_panel "$env" "$project"
return
fi
printf '\033[2J'
run_dynamic "$env" "$project"
} }
main "$@" main "$@"
+24 -1
View File
@@ -9,8 +9,10 @@ source "$SCRIPT_DIR/common.sh"
TARGET_ENV="" TARGET_ENV=""
REPO_URL="" REPO_URL=""
GIT_BRANCH="" GIT_BRANCH=""
SSH_KEY=""
ASSUME_YES=0 ASSUME_YES=0
SKIP_BACKUP=0 SKIP_BACKUP=0
SKIP_DIRTY_CHECK=0
FORCE_PULL=0 FORCE_PULL=0
INSTALL_ARGS=() INSTALL_ARGS=()
@@ -55,8 +57,11 @@ ${C_BOLD}选项:${C_RESET}
${C_WHITE}--repo-url <url>${C_RESET} Git 仓库地址(HTTPS 或 SSH ${C_WHITE}--repo-url <url>${C_RESET} Git 仓库地址(HTTPS 或 SSH
不指定时会提示输入或使用当前远程地址 不指定时会提示输入或使用当前远程地址
${C_WHITE}--branch <name>${C_RESET} 指定拉取的分支(默认为当前分支) ${C_WHITE}--branch <name>${C_RESET} 指定拉取的分支(默认为当前分支)
${C_WHITE}--ssh-key <path>${C_RESET} SSH 私钥文件路径(仅 SSH 仓库地址时生效)
不指定时使用本机默认 SSH 配置(~/.ssh/
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行 ${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
${C_WHITE}--skip-backup${C_RESET} 跳过更新前自动备份 tag(不推荐) ${C_WHITE}--skip-backup${C_RESET} 跳过更新前自动备份 tag(不推荐)
${C_WHITE}--skip-dirty-check${C_RESET} 跳过工作目录变更检测(由 install.sh 预检后透传)
${C_WHITE}--force${C_RESET} 强制覆盖本地更改(git reset --hard ${C_WHITE}--force${C_RESET} 强制覆盖本地更改(git reset --hard
${C_WHITE}-h, --help${C_RESET} 显示此帮助信息 ${C_WHITE}-h, --help${C_RESET} 显示此帮助信息
@@ -121,8 +126,14 @@ parse_args() {
GIT_BRANCH="$2" GIT_BRANCH="$2"
shift 2 shift 2
;; ;;
--ssh-key)
[[ $# -ge 2 ]] || ctms_fail "--ssh-key 需要提供私钥文件路径"
SSH_KEY="$2"
shift 2
;;
--yes) ASSUME_YES=1; shift ;; --yes) ASSUME_YES=1; shift ;;
--skip-backup) SKIP_BACKUP=1; shift ;; --skip-backup) SKIP_BACKUP=1; shift ;;
--skip-dirty-check) SKIP_DIRTY_CHECK=1; shift ;;
--force) FORCE_PULL=1; shift ;; --force) FORCE_PULL=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
--skip-build|--skip-migrate|--verbose|--base-url) --skip-build|--skip-migrate|--verbose|--base-url)
@@ -200,6 +211,10 @@ verify_project_identity() {
# ── 工作目录状态检查 ───────────────────────── # ── 工作目录状态检查 ─────────────────────────
check_working_directory() { check_working_directory() {
if [[ "$SKIP_DIRTY_CHECK" -eq 1 ]]; then
return 0
fi
ctms_step "检查工作目录状态" ctms_step "检查工作目录状态"
if [[ "$FORCE_PULL" -eq 1 ]]; then if [[ "$FORCE_PULL" -eq 1 ]]; then
@@ -363,7 +378,14 @@ pick_remote_ref() {
setup_git_credentials() { setup_git_credentials() {
# SSH 协议不需要额外配置 # SSH 协议不需要额外配置
if [[ "$REPO_URL" =~ ^(git@|ssh://) ]]; then if [[ "$REPO_URL" =~ ^(git@|ssh://) ]]; then
ctms_info "使用 SSH 协议,跳过凭证配置" if [[ -n "$SSH_KEY" ]]; then
[[ -f "$SSH_KEY" ]] || ctms_fail "指定的 SSH 私钥文件不存在: $SSH_KEY"
# 通过 GIT_SSH_COMMAND 指定私钥;IdentitiesOnly 确保只用这把钥匙
export GIT_SSH_COMMAND="ssh -i '$SSH_KEY' -o IdentitiesOnly=yes"
ctms_info "使用 SSH 协议,已指定私钥: $SSH_KEY"
else
ctms_info "使用 SSH 协议,使用本机默认 SSH 配置"
fi
return 0 return 0
fi fi
@@ -415,6 +437,7 @@ cleanup_git_credentials() {
rm -f "$GIT_ASKPASS" 2>/dev/null || true rm -f "$GIT_ASKPASS" 2>/dev/null || true
fi fi
unset GIT_ASKPASS GIT_ASKPASS_USERNAME GIT_ASKPASS_PASSWORD unset GIT_ASKPASS GIT_ASKPASS_USERNAME GIT_ASKPASS_PASSWORD
unset GIT_SSH_COMMAND
} }
# ── 备份当前版本 ───────────────────────────── # ── 备份当前版本 ─────────────────────────────