Files
ctms/install.sh
Cheng Zhou f9a9f7eafe 环境配置统一以 .env 为单一事实来源,移除 .ctms-env
- 删除 .ctms-env:环境名由 .env 的 COMPOSE_PROJECT_NAME 反推
  (ctms_detect_env),健康检查地址改存 .env 的 CTMS_BASE_URL
- prepare_env_file 由“已存在则整体跳过”改为逐项探测补全:
  身份类缺失补目标值、密钥类缺失/空才生成、已有值一律不动
- resolve_effective_config 放宽为“冲突才致命”:身份类缺失交给
  补全,仅 COMPOSE_PROJECT_NAME/ENV 与目标环境冲突时报错停止
  (防止覆盖后孤立旧环境容器)
- 新增 ctms_env_has_key 区分“键缺失”与“键存在但空值”
  (dev 的 LOGIN_RSA_PRIVATE_KEY= 是合法空值)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:48:27 +08:00

530 lines
20 KiB
Bash
Executable File
Raw Permalink 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
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh
source "$ROOT_DIR/scripts/common.sh"
SELECTED_INDEX=0
SELECTED_ENV=""
SELECTED_BASE_URL=""
SELECTED_REPO_URL=""
SELECTED_SSH_KEY=""
SELECTED_SSH_KEY_TEMP=0
STASHED_BEFORE_UPDATE=0
ENV_STATUS_LINES=""
DEPLOYED_ENVS=""
BREADCRUMB="请选择操作"
OP_LABEL=""
UI_BOX_WIDTH=76
usage() {
cat <<EOF
${CC_PRIMARY}${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}
${CC_PRIMARY}${C_BOLD} CTMS 部署控制台 - 使用帮助${C_RESET}
${CC_PRIMARY}${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}
${C_BOLD}【主入口】${C_RESET} ${CC_INFO}./install.sh${C_RESET} ${CC_MUTED}↑/↓ 选择 Enter 确认 Q 退出${C_RESET}
${C_BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C_RESET}
${C_BOLD}【底层脚本(CI/CD)】${C_RESET}
${CC_INFO}bash scripts/install.sh <env> [--base-url <url>] [--yes] [--skip-build] [--skip-migrate]${C_RESET}
${CC_INFO}bash scripts/update.sh <env> [--repo-url <url>] [--branch <name>] [--yes] [--force]${C_RESET}
${CC_INFO}bash scripts/uninstall.sh <env>${C_RESET}
${CC_INFO}bash scripts/status.sh <env>${C_RESET}
${CC_MUTED}env: dev(开发)| main(内网测试)| release(生产)${C_RESET}
${CC_MUTED}update.sh 认证:export GIT_USERNAME=xxx GIT_PASSWORD=yyy${C_RESET}
${CC_MUTED}查看脚本帮助:${C_RESET} bash scripts/<script>.sh --help
${CC_PRIMARY}${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}
EOF
}
require_interactive_terminal() {
[[ -t 0 && -t 1 ]] || ctms_fail "install.sh 需要在交互式终端中运行"
}
banner() {
printf '\n'
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} ${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_bottom "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH"
printf '\n'
render_status_panel
}
render_status_panel() {
ctms_box_top "${C_MAGENTA}${C_BOLD}" "环境状态" "$UI_BOX_WIDTH"
printf '%b\n' "$ENV_STATUS_LINES" | while IFS= read -r line; do
ctms_box_line "${C_MAGENTA}${C_BOLD}" "$UI_BOX_WIDTH" "$line" "$line"
done
ctms_box_bottom "${C_MAGENTA}${C_BOLD}" "$UI_BOX_WIDTH"
printf '\n'
}
build_env_status() {
local output=""
local detected=0
local docker_error=""
DEPLOYED_ENVS=""
if ! command -v docker >/dev/null 2>&1; then
ENV_STATUS_LINES=" ${C_YELLOW}Docker 未安装或 docker 命令不可用${C_RESET}"
return
fi
if ! docker ps >/dev/null 2>&1; then
docker_error="$(docker ps 2>&1 >/dev/null || true)"
ENV_STATUS_LINES=" ${C_YELLOW}Docker 未运行或当前用户无权限访问 Docker${C_RESET}"
[[ -n "$docker_error" ]] && ENV_STATUS_LINES="${ENV_STATUS_LINES}\n ${C_DIM}${docker_error}${C_RESET}"
return
fi
local env project rows total running stopped status_label
for env in dev main release; do
project="$(ctms_project_name "$env")"
rows="$(docker ps -a \
--filter "label=com.docker.compose.project=$project" \
--format '{{.Names}}|{{.State}}|{{.Status}}' 2>/dev/null || true)"
[[ -n "$rows" ]] || continue
detected=1
DEPLOYED_ENVS="${DEPLOYED_ENVS}${env} "
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 }')"
stopped=$(( total - running ))
if [[ "$running" -eq "$total" && "$total" -gt 0 ]]; then
status_label="${CC_SUCCESS}${ICON_RUNNING} 运行中${C_RESET}"
elif [[ "$running" -gt 0 ]]; then
status_label="${CC_WARN}${ICON_RUNNING} 部分运行${C_RESET}"
else
status_label="${CC_DANGER}${ICON_STOPPED} 已停止${C_RESET}"
fi
output="${output} ${CC_INFO}${C_BOLD}${env}${C_RESET} ${CC_MUTED}(${project})${C_RESET} ${status_label} ${CC_MUTED}${running}/${total} 运行"
[[ "$stopped" -gt 0 ]] && output="${output}${stopped} 已停止"
output="${output}${C_RESET}\n"
output="${output}$(printf '%s\n' "$rows" | awk -F'|' '{ printf " %-18s %-8s %s\n", $1, $2, $3 }')\n"
done
if [[ "$detected" -eq 0 ]]; then
rows="$(docker ps -a \
--filter "name=ctms_" \
--format '{{.Names}}|{{.State}}|{{.Status}}' 2>/dev/null || true)"
if [[ -n "$rows" ]]; then
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 }')"
ENV_STATUS_LINES=" ${C_YELLOW}检测到 CTMS 命名容器,但无法从 Compose 标签确认环境${C_RESET}\n"
ENV_STATUS_LINES="${ENV_STATUS_LINES} ${running}/${total} 运行\n"
ENV_STATUS_LINES="${ENV_STATUS_LINES}$(printf '%s\n' "$rows" | awk -F'|' '{ printf " - %-18s %-8s %s\n", $1, $2, $3 }')"
else
ENV_STATUS_LINES=" ${C_DIM}未检测到 CTMS Compose 容器(ctms_dev / ctms_main / ctms_release${C_RESET}"
fi
else
ENV_STATUS_LINES="${output%\\n}"
fi
}
select_menu() {
local title="$1"
local default_index="$2"
shift 2
local options=("$@")
local selected="${default_index:-0}"
local key=""
[[ "${#options[@]}" -gt 0 ]] || ctms_fail "菜单没有可选项"
trap ctms_restore_cursor EXIT INT TERM
printf '\033[?25l\033[2J'
while true; do
printf '\033[H'
banner
ctms_box_top "${C_BLUE}${C_BOLD}" "$title" "$UI_BOX_WIDTH"
local i
for i in "${!options[@]}"; do
if [[ "$i" -eq "$selected" ]]; then
ctms_box_line "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH" "${ICON_BULLET} ${options[$i]}" "${CC_INFO}${C_BOLD}${ICON_BULLET} ${options[$i]}${C_RESET}"
else
ctms_box_line "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH" "${ICON_BULLET_OFF} ${options[$i]}" "${CC_MUTED}${ICON_BULLET_OFF} ${options[$i]}${C_RESET}"
fi
done
ctms_box_bottom "${C_BLUE}${C_BOLD}" "$UI_BOX_WIDTH"
printf '\033[J'
key="$(ctms_read_key)"
case "$key" in
"[A") selected=$(( (selected - 1 + ${#options[@]}) % ${#options[@]} )) ;;
"[B") selected=$(( (selected + 1) % ${#options[@]} )) ;;
"") ctms_restore_cursor; SELECTED_INDEX="$selected"; return ;;
q|Q) ctms_restore_cursor; printf '\033[2J\033[H'; exit 0 ;;
esac
done
}
pick_env() {
local env default_index=0
env="$(ctms_detect_env)"
case "$env" in
main) default_index=1 ;;
release) default_index=2 ;;
esac
# 按显示宽度对齐三列(中文双宽,半角空格凑不齐)
local n0="dev" n1="main" n2="release"
if [[ -n "$env" ]]; then
case "$default_index" in
0) n0="dev(默认)" ;;
1) n1="main(默认)" ;;
2) n2="release(默认)" ;;
esac
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="选择环境"
[[ -n "$env" ]] && menu_title="选择环境 (已识别: ${env}"
BREADCRUMB="${OP_LABEL:-操作} 选择环境"
select_menu "$menu_title" "$default_index" "${opts[@]}"
case "$SELECTED_INDEX" in
0) SELECTED_ENV="dev" ;;
1) SELECTED_ENV="main" ;;
2) SELECTED_ENV="release" ;;
esac
}
# 卸载专用:仅在“实际检测到容器”的环境中选择,避免选到空环境走完确认却什么都没卸。
# 始终弹出选择菜单(哪怕只有一个环境),作为危险确认前的显式安全闸门。
# 返回 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() {
local env="$1"
SELECTED_BASE_URL="$(ctms_read_env_value CTMS_BASE_URL || true)"
if [[ "$env" == "release" && -z "$SELECTED_BASE_URL" ]]; then
SELECTED_BASE_URL="$(ctms_ask_line "请输入健康检查地址,例如 https://ctms.example.com")"
[[ -n "$SELECTED_BASE_URL" ]] || ctms_fail "release 环境必须提供健康检查地址"
elif [[ "$env" == "main" && -z "$SELECTED_BASE_URL" ]]; then
SELECTED_BASE_URL="$(ctms_ask_line "请输入健康检查地址" "http://127.0.0.1:8888")"
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() {
local script="$1"
shift
[[ -x "$ROOT_DIR/scripts/$script" ]] || ctms_fail "脚本不可执行: $ROOT_DIR/scripts/$script"
"$ROOT_DIR/scripts/$script" "$@"
}
run_install_like_script() {
local script="$1"
local env="$2"
if [[ -n "$SELECTED_BASE_URL" ]]; then
run_script "$script" "$env" --base-url "$SELECTED_BASE_URL"
else
run_script "$script" "$env"
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() {
printf '\n %s%s%s 按 Enter 返回主菜单…' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}"
read -r _ || true
}
interactive_main() {
BREADCRUMB="请选择操作"
select_menu "选择操作" "" \
"安装部署 配置环境、构建容器、迁移数据库、健康检查" \
"滚动更新 重建服务、执行迁移、复用安装健康检查" \
"安全卸载 停止并移除容器,保留 .env 与数据文件" \
"资源状态 查看 CPU、内存、网络与磁盘 I/O 快照" \
"使用帮助 查看入口和底层脚本说明" \
"退出控制台"
case "$SELECTED_INDEX" in
0)
OP_LABEL="安装部署"
pick_env
collect_install_options "$SELECTED_ENV"
BREADCRUMB="${OP_LABEL} ${SELECTED_ENV} 执行中"
# 环境与健康检查地址由底层 install.sh 写入 .env(单一事实来源);失败不退出控制台。
run_install_like_script "install.sh" "$SELECTED_ENV" || true
pause_return
;;
1)
OP_LABEL="滚动更新"
pick_env
if check_workspace_clean; then
pick_access_method
collect_install_options "$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)"
# 环境与健康检查地址由底层 update→install.sh 写入 .env;失败也要继续做 stash 善后。
run_update_script "$SELECTED_ENV" || true
restore_stash_after_update "$head_before_update"
fi
pause_return
;;
2)
OP_LABEL="安全卸载"
if pick_deployed_env; then
BREADCRUMB="${OP_LABEL} ${SELECTED_ENV} 执行中"
run_script "uninstall.sh" "$SELECTED_ENV"
fi
pause_return
;;
3)
OP_LABEL="资源状态"
# 只读快照,无需选环境:从 .env 反推环境(无配置回退 dev)
local env_for_status
env_for_status="$(ctms_detect_env)"
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)
BREADCRUMB="使用帮助"
usage
pause_return
;;
5)
return 1
;;
esac
return 0
}
main() {
if [[ $# -gt 0 ]]; then
usage
ctms_fail "install.sh 不接受参数,请直接执行 ./install.sh"
fi
require_interactive_terminal
while true; do
build_env_status
interactive_main || break
done
printf '\033[2J\033[H'
exit 0
}
main "$@"