Files
ctms/scripts/common.sh
T
Cheng Zhou 24d5b7445f 滚动更新新增 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>
2026-06-24 09:26:54 +08:00

498 lines
17 KiB
Bash
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
# 公共脚本函数。被 install/update/uninstall/status 入口 source,不直接执行。
# 这是整个安装体系唯一的共享库:颜色、UI、环境派生、compose 调用、
# 流程输出、依赖检查、.env 读写、交互菜单全部集中在此,避免重复实现。
CTMS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CTMS_ROOT_DIR="$(cd "$CTMS_SCRIPT_DIR/.." && pwd)"
CTMS_ENV_FILE="$CTMS_ROOT_DIR/.env"
CTMS_LOG_DIR="$CTMS_ROOT_DIR/.install-logs"
# ── 配色体系 ─────────────────────────────────
# 优先使用 256 色语义色板;终端仅支持基础 8 色时回退到 ANSI 原色;
# 无色彩能力(管道/CI)时全部置空。C_* 为兼容旧脚本保留的别名。
_ctms_color_depth=0
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
_ctms_color_depth="$(tput colors 2>/dev/null || printf 0)"
fi
if [[ "$_ctms_color_depth" -ge 256 ]]; then
C_RESET="$(tput sgr0)"
C_BOLD="$(tput bold)"
C_DIM="$(tput dim 2>/dev/null || printf '')"
# 语义色板(xterm-256 索引)
CC_PRIMARY="$(tput setaf 39)" # 主色:明亮天蓝(边框/标题)
CC_ACCENT="$(tput setaf 213)" # 强调:粉紫(次级面板/高亮)
CC_SUCCESS="$(tput setaf 78)" # 成功:清新绿
CC_WARN="$(tput setaf 214)" # 警告:琥珀橙
CC_DANGER="$(tput setaf 203)" # 危险:珊瑚红
CC_INFO="$(tput setaf 117)" # 信息:淡青
CC_MUTED="$(tput setaf 245)" # 弱化:中灰
CC_TEXT="$(tput setaf 252)" # 正文:近白
elif [[ "$_ctms_color_depth" -ge 8 ]]; then
C_RESET="$(tput sgr0)"
C_BOLD="$(tput bold)"
C_DIM="$(tput dim 2>/dev/null || printf '')"
CC_PRIMARY="$(tput setaf 4)"
CC_ACCENT="$(tput setaf 5)"
CC_SUCCESS="$(tput setaf 2)"
CC_WARN="$(tput setaf 3)"
CC_DANGER="$(tput setaf 1)"
CC_INFO="$(tput setaf 6)"
CC_MUTED="$(tput setaf 7)"
CC_TEXT="$(tput setaf 7)"
else
C_RESET="" C_BOLD="" C_DIM=""
CC_PRIMARY="" CC_ACCENT="" CC_SUCCESS="" CC_WARN="" CC_DANGER="" CC_INFO="" CC_MUTED="" CC_TEXT=""
fi
# C_* 兼容别名(旧脚本/面板仍在引用)
C_BLUE="$CC_PRIMARY"
C_MAGENTA="$CC_ACCENT"
C_CYAN="$CC_INFO"
C_GREEN="$CC_SUCCESS"
C_YELLOW="$CC_WARN"
C_RED="$CC_DANGER"
C_WHITE="$CC_TEXT"
# ── 图标体系 ─────────────────────────────────
# 统一的状态/装饰图标,集中定义便于风格一致。
ICON_OK="✔"
ICON_FAIL="✖"
ICON_WARN="⚠"
ICON_INFO="•"
ICON_STEP="▸"
ICON_PROMPT=""
ICON_BULLET="◆"
ICON_BULLET_OFF="◇"
ICON_RUNNING="●"
ICON_STOPPED="○"
ICON_ARROW="→"
# ── 基础输出 ─────────────────────────────────
ctms_fail() {
printf '\n %s%s 出错%s %s%s%s\n' "${CC_DANGER}${C_BOLD}" "$ICON_FAIL" "${C_RESET}" "${CC_DANGER}" "$*" "${C_RESET}" >&2
exit 1
}
ctms_info() {
printf ' %s%s%s %s\n' "${CC_MUTED}" "$ICON_INFO" "${C_RESET}" "$*"
}
ctms_ok() {
printf ' %s%s%s %s\n' "${CC_SUCCESS}${C_BOLD}" "$ICON_OK" "${C_RESET}" "$*"
}
ctms_warn() {
printf ' %s%s%s %s\n' "${CC_WARN}${C_BOLD}" "$ICON_WARN" "${C_RESET}" "$*" >&2
}
# 带步骤计数的分节标题
ctms_step() {
_CTMS_STEP_IDX=$(( ${_CTMS_STEP_IDX:-0} + 1 ))
printf '\n %s%s%s %s步骤 %s%s %s%s%s\n' \
"${CC_PRIMARY}${C_BOLD}" "$ICON_STEP" "${C_RESET}" \
"${CC_MUTED}" "$_CTMS_STEP_IDX" "${C_RESET}" \
"${C_BOLD}" "$*" "${C_RESET}"
}
# 回显并执行命令
ctms_run() {
printf ' %s$%s %s%s%s\n' "${CC_MUTED}" "${C_RESET}" "${CC_TEXT}" "$*" "${C_RESET}"
"$@"
}
# ── box UI ───────────────────────────────────
ctms_repeat_char() {
local char="$1"
local count="$2"
local out=""
while [[ "$count" -gt 0 ]]; do
out="${out}${char}"
count=$(( count - 1 ))
done
printf '%s' "$out"
}
# 计算字符串的终端显示宽度(列数)。先剥离 ANSI 转义。
# 优先用 perl 精确判断:东亚宽字符/emoji 计 2 列,其余(含 ◆✔▸ 等几何符号)计 1 列。
# 无 perl 时回退到字节估算(中文准确,几何符号会高估 1 列)。
if command -v perl >/dev/null 2>&1; then _CTMS_HAS_PERL=1; else _CTMS_HAS_PERL=0; fi
ctms_text_width() {
local text="$1"
if [[ "$_CTMS_HAS_PERL" -eq 1 ]]; then
printf '%s' "$text" | perl -CSD -e '
local $/; my $s = <STDIN>;
$s =~ s/\e\[[0-9;?]*[ -\/]*[@-~]//g; # CSI 序列(颜色等)
$s =~ s/\e[()][0-9A-Za-z]//g; # 字符集切换,如 sgr0 的 \e(B
$s =~ s/\e[]PX^_].*?(\e\\|\a)//g; # OSC/DCS 等带终止符的序列
my $w = 0;
for my $c (split //, $s) {
my $o = ord $c;
next if $o == 10 || $o == 13 || $o == 27;
if (($o>=0x1100&&$o<=0x115F)||($o>=0x2E80&&$o<=0x303E)||($o>=0x3041&&$o<=0x33FF)||
($o>=0x3400&&$o<=0x4DBF)||($o>=0x4E00&&$o<=0x9FFF)||($o>=0xA000&&$o<=0xA4CF)||
($o>=0xAC00&&$o<=0xD7A3)||($o>=0xF900&&$o<=0xFAFF)||($o>=0xFE30&&$o<=0xFE4F)||
($o>=0xFF00&&$o<=0xFF60)||($o>=0xFFE0&&$o<=0xFFE6)||($o>=0x1F300&&$o<=0x1FAFF)||
($o>=0x20000&&$o<=0x3FFFD)) { $w += 2 } else { $w += 1 }
}
print $w;
'
return
fi
local esc chars bytes extra
esc="$(printf '\033')"
text="$(printf '%s' "$text" | sed -e "s/${esc}\\[[0-9;?]*[A-Za-z]//g" -e "s/${esc}[()][0-9A-Za-z]//g")"
chars="$(printf '%s' "$text" | wc -m | tr -d ' ')"
bytes="$(printf '%s' "$text" | LC_ALL=C wc -c | tr -d ' ')"
extra=$(( (bytes - chars) / 2 ))
printf '%s' "$(( chars + extra ))"
}
# 按终端显示宽度右填充字符串到指定列数(中文按 2 列计),用于对齐含中文的标签列。
ctms_pad_right() {
local text="$1" target="$2"
local w pad
w="$(ctms_text_width "$text")"
pad=$(( target - w ))
[[ "$pad" -lt 0 ]] && pad=0
printf '%s%s' "$text" "$(ctms_repeat_char ' ' "$pad")"
}
ctms_box_top() {
local color="$1"
local title="$2"
local width="$3"
local label="─ ${title} "
local label_width fill_width
label_width="$(ctms_text_width "$label")"
fill_width=$(( width - 2 - label_width ))
[[ "$fill_width" -lt 0 ]] && fill_width=0
printf '%s╭%s%s╮%s\n' "$color" "$label" "$(ctms_repeat_char '─' "$fill_width")" "$C_RESET"
}
ctms_box_bottom() {
local color="$1"
local width="$2"
printf '%s╰%s╯%s\n' "$color" "$(ctms_repeat_char '─' "$(( width - 2 ))")" "$C_RESET"
}
ctms_box_line() {
local color="$1"
local width="$2"
local plain="$3"
local styled="${4:-$3}"
local max_content_width=$(( width - 4 ))
local content=" ${plain}"
local content_width fill_width
local chunk chunk_width
local prefix=" "
content_width="$(ctms_text_width "$content")"
if [[ "$content_width" -le "$(( width - 2 ))" ]]; then
fill_width=$(( width - 2 - content_width ))
[[ "$fill_width" -lt 0 ]] && fill_width=0
printf '%s│%s %b%s%s│%s\n' "$color" "$C_RESET" "$styled" "$(ctms_repeat_char ' ' "$fill_width")" "$color" "$C_RESET"
return 0
fi
while [[ -n "$plain" ]]; do
chunk="${plain:0:$max_content_width}"
while [[ -n "$chunk" && "$(ctms_text_width " ${chunk}")" -gt "$(( width - 2 ))" ]]; do
chunk="${chunk%?}"
done
[[ -n "$chunk" ]] || chunk="${plain:0:1}"
chunk_width="$(ctms_text_width "${prefix}${chunk}")"
fill_width=$(( width - 2 - chunk_width ))
[[ "$fill_width" -lt 0 ]] && fill_width=0
printf '%s│%s%s%s%s%s│%s\n' "$color" "$C_RESET" "$prefix" "$chunk" "$(ctms_repeat_char ' ' "$fill_width")" "$color" "$C_RESET"
plain="${plain:${#chunk}}"
prefix=" "
done
}
# ── 环境校验与派生 ───────────────────────────
ctms_require_env() {
case "${1:-}" in
dev|main|release) ;;
*) ctms_fail "未知环境 \"${1:-}\",有效值为 dev、main、release" ;;
esac
}
ctms_project_name() {
ctms_require_env "$1"
case "$1" in
dev) printf 'ctms_dev' ;;
main) printf 'ctms_main' ;;
release) printf 'ctms_release' ;;
esac
}
ctms_compose_file() {
ctms_require_env "$1"
if [[ "$1" == "dev" ]]; then
printf '%s/docker-compose.dev.yaml' "$CTMS_ROOT_DIR"
else
printf '%s/docker-compose.yaml' "$CTMS_ROOT_DIR"
fi
}
# 运行模式:dev → development,其余 → production
ctms_app_env() {
ctms_require_env "$1"
[[ "$1" == "dev" ]] && printf 'development' || printf 'production'
}
# 登录 RSA 密钥 ID
ctms_key_id() {
ctms_require_env "$1"
local today
today="$(date +%Y%m%d)"
case "$1" in
dev) printf 'default' ;;
main) printf 'main-%s' "$today" ;;
release) printf 'release-%s' "$today" ;;
esac
}
# 默认健康检查地址;release 无默认值(必须显式提供 HTTPS)
ctms_default_base_url() {
ctms_require_env "$1"
case "$1" in
dev|main) printf 'http://127.0.0.1:8888' ;;
release) printf '' ;;
esac
}
# 统一的 compose 调用:始终显式 -f <file> -p <project>,确保环境隔离
# 用法: ctms_compose <env> <compose 参数...>
ctms_compose() {
local env="$1"; shift
docker compose -f "$(ctms_compose_file "$env")" -p "$(ctms_project_name "$env")" "$@"
}
# ── 依赖检查 ─────────────────────────────────
ctms_require_command() {
local cmd="$1"
local hint="$2"
if ! command -v "$cmd" >/dev/null 2>&1; then
ctms_warn "缺少依赖: ${C_BOLD}${cmd}${C_RESET}"
ctms_warn "安装方式: $hint"
return 1
fi
}
ctms_require_docker_compose() {
local missing=0
ctms_require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
[[ "$missing" -eq 0 ]] || ctms_fail "系统依赖不完整,请按上方提示安装后重试"
if ! docker compose version >/dev/null 2>&1; then
ctms_fail "未检测到 Docker Compose v2,请确认 docker compose version 可正常执行"
fi
}
# 安装/更新所需依赖。openssl、curl 仅在非 dev 环境要求(dev 用默认密钥、不做 RSA 生成)。
ctms_require_dependencies() {
local env="$1"
ctms_require_env "$env"
local missing=0
ctms_require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
ctms_require_command curl "Linux: sudo apt-get install -y curl | macOS: brew install curl" || missing=1
# openssl 仅非 dev 需要:dev 用 dev-secret 与空 RSA key,不生成密钥
if [[ "$env" != "dev" ]]; then
ctms_require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1
fi
[[ "$missing" -eq 0 ]] || ctms_fail "系统依赖不完整,请按上方提示安装后重试"
if ! docker compose version >/dev/null 2>&1; then
ctms_fail "未检测到 Docker Compose v2,请确认 docker compose version 可正常执行"
fi
}
# ── 长命令执行(折叠输出 + spinner) ──────────
# 用法: ctms_run_quiet <提示语> -- <命令...>
# VERBOSE=1 时展开实时输出;否则折叠为 spinner,失败时回放完整日志。
ctms_run_quiet() {
local label="$1"; shift
[[ "${1:-}" == "--" ]] && shift
if [[ "${VERBOSE:-0}" -eq 1 ]]; then
printf ' %s$%s %s%s%s\n' "${CC_MUTED}" "${C_RESET}" "${CC_TEXT}" "$*" "${C_RESET}"
"$@"
return $?
fi
mkdir -p "$CTMS_LOG_DIR"
local log_file
log_file="$(mktemp "$CTMS_LOG_DIR/cmd_XXXXXXXX")"
local start_ts elapsed exit_code
start_ts="$(date +%s)"
set +e
if [[ -t 1 ]]; then
"$@" >"$log_file" 2>&1 &
local cmd_pid=$!
_ctms_spinner "$cmd_pid" "$label" "$start_ts" &
local spin_pid=$!
wait "$cmd_pid"
exit_code=$?
kill "$spin_pid" 2>/dev/null || true
wait "$spin_pid" 2>/dev/null || true
printf '\r\033[K'
else
printf ' %s%s%s %s …\n' "${CC_MUTED}" "$ICON_INFO" "${C_RESET}" "$label"
"$@" >"$log_file" 2>&1
exit_code=$?
fi
set -e
elapsed=$(( $(date +%s) - start_ts ))
if [[ "$exit_code" -eq 0 ]]; then
printf ' %s%s%s %s %s(%ss)%s\n' \
"${CC_SUCCESS}${C_BOLD}" "$ICON_OK" "${C_RESET}" "$label" "${CC_MUTED}" "$elapsed" "${C_RESET}"
return 0
fi
printf '\n %s%s%s %s%s 失败(耗时 %ss),完整输出:%s\n\n' \
"${CC_DANGER}${C_BOLD}" "$ICON_FAIL" "${C_RESET}" "${CC_DANGER}" "$label" "$elapsed" "${C_RESET}" >&2
sed 's/^/ /' "$log_file" >&2
printf '\n' >&2
ctms_fail "命令执行失败,详细日志保留于:${log_file#"$CTMS_ROOT_DIR"/}(追加 --verbose 可看实时输出)"
}
_ctms_spinner() {
local cmd_pid="$1" label="$2" start_ts="$3"
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while kill -0 "$cmd_pid" 2>/dev/null; do
local elapsed=$(( $(date +%s) - start_ts ))
printf '\r\033[K %s%s%s %s %s(%ss)%s' \
"${CC_INFO}${C_BOLD}" "${frames[i]}" "${C_RESET}" \
"$label" "${CC_MUTED}" "$elapsed" "${C_RESET}"
i=$(( (i + 1) % ${#frames[@]} ))
sleep 0.1
done
}
# ── .env 读写 ─────────────────────────────────
ctms_read_env_value() {
local key="$1"
[[ -f "$CTMS_ENV_FILE" ]] || return 0
awk -v key="$key" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 {
value = substr($0, length(prefix) + 1)
if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) {
value = substr(value, 2, length(value) - 2)
}
print value
exit
}
' "$CTMS_ENV_FILE"
}
ctms_upsert_env_value() {
local key="$1" value="$2"
local temp_file
temp_file="$(mktemp "$CTMS_ROOT_DIR/.env.tmp.XXXXXX")"
if [[ -f "$CTMS_ENV_FILE" && -n "$(ctms_read_env_value "$key" || true)" ]]; then
awk -v key="$key" -v value="$value" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print key "=" value; next }
{ print }
' "$CTMS_ENV_FILE" > "$temp_file"
else
[[ -f "$CTMS_ENV_FILE" ]] && cat "$CTMS_ENV_FILE" > "$temp_file"
printf '%s=%s\n' "$key" "$value" >> "$temp_file"
fi
mv "$temp_file" "$CTMS_ENV_FILE"
}
ctms_write_env_file() {
local project_name="$1" runtime_env="$2" login_key_id="$3"
local jwt_secret="$4" rsa_private_key="$5"
local allow_insecure_dev_login="false"
[[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true"
local temp_file
temp_file="$(mktemp "$CTMS_ROOT_DIR/.env.tmp.XXXXXX")"
{
printf 'COMPOSE_PROJECT_NAME=%s\n' "$project_name"
printf 'ENV=%s\n' "$runtime_env"
printf 'VITE_RUNTIME_ENV=%s\n' "$runtime_env"
printf 'VITE_ALLOW_INSECURE_DEV_LOGIN=%s\n' "$allow_insecure_dev_login"
printf 'JWT_SECRET_KEY=%s\n' "$jwt_secret"
printf 'LOGIN_RSA_KEY_ID=%s\n' "$login_key_id"
printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$rsa_private_key"
} >> "$temp_file"
mv "$temp_file" "$CTMS_ENV_FILE"
}
# ── 危险操作确认 ─────────────────────────────
ctms_confirm_danger() {
local operation="$1"
local scope="$2"
local risk="$3"
local answer=""
local width=72
printf '\n'
ctms_box_top "${CC_DANGER}${C_BOLD}" "$ICON_WARN 危险操作确认" "$width"
ctms_box_line "${CC_DANGER}${C_BOLD}" "$width" "操作类型 $operation" "${CC_MUTED}操作类型${C_RESET} $operation"
ctms_box_line "${CC_DANGER}${C_BOLD}" "$width" "影响范围 $scope" "${CC_MUTED}影响范围${C_RESET} $scope"
ctms_box_line "${CC_DANGER}${C_BOLD}" "$width" "风险评估 $risk" "${CC_MUTED}风险评估${C_RESET} $risk"
ctms_box_bottom "${CC_DANGER}${C_BOLD}" "$width"
printf '\n %s%s%s 输入 %s是%s / %s确认%s / %s继续%s 以执行,其它任意键取消: ' \
"${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" \
"${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
IFS= read -r answer || answer=""
case "$answer" in
|确认|继续) ;;
*) ctms_fail "用户取消操作" ;;
esac
}
# ── 交互输入 ─────────────────────────────────
ctms_restore_cursor() {
printf '\033[?25h'
}
ctms_read_key() {
local key=""
IFS= read -rsn1 key || true
if [[ "$key" == $'\x1b' ]]; then
key=""
IFS= read -rsn2 -t 1 key || true
printf '%s' "$key"
return
fi
printf '%s' "$key"
}
# 单行文本输入,带可选默认值
ctms_ask_line() {
local prompt="$1"
local default_value="${2:-}"
local value=""
# 提示语输出到 stderr:调用方多用 $(ctms_ask_line ...) 捕获返回值,
# 若提示语走 stdout 会被一并吞掉,导致终端只剩光标、提示语还混进返回值。
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}" >&2
else
printf '\n%s%s%s %s: ' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "$prompt" >&2
fi
IFS= read -r value || value=""
printf '%s' "${value:-$default_value}"
}