Files
ctms/scripts/common.sh
T
2026-07-01 11:22:39 +08:00

620 lines
21 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
}
# 从 .env 的 COMPOSE_PROJECT_NAME 反推环境名(dev/main/release)。
# .env 是部署环境的唯一事实来源(main/release 均为 production,仅项目名可区分)。
# 无 .env 或无法识别时输出空字符串,由调用方决定回退策略。
ctms_detect_env() {
case "$(ctms_read_env_value COMPOSE_PROJECT_NAME)" in
ctms_dev) printf 'dev' ;;
ctms_main) printf 'main' ;;
ctms_release) printf 'release' ;;
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 <提示语> -- <命令...>
# CTMS_LIVE_OUTPUT=1 时显示最近几行;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 streamed_output=0
start_ts="$(date +%s)"
set +e
if [[ -t 1 ]]; then
"$@" >"$log_file" 2>&1 &
local cmd_pid=$!
if [[ "${CTMS_LIVE_OUTPUT:-0}" -eq 1 ]]; then
_ctms_live_log "$cmd_pid" "$label" "$start_ts" "$log_file" &
else
_ctms_spinner "$cmd_pid" "$label" "$start_ts" &
fi
local display_pid=$!
wait "$cmd_pid"
exit_code=$?
kill "$display_pid" 2>/dev/null || true
wait "$display_pid" 2>/dev/null || true
if [[ "${CTMS_LIVE_OUTPUT:-0}" -eq 1 ]]; then
_ctms_clear_live_log
else
printf '\r\033[K'
fi
else
if [[ "${CTMS_LIVE_OUTPUT:-0}" -eq 1 ]]; then
printf ' %s%s%s %s(实时输出)\n' "${CC_INFO}${C_BOLD}" "$ICON_INFO" "${C_RESET}" "$label"
"$@" 2>&1 | tee "$log_file"
exit_code="${PIPESTATUS[0]}"
streamed_output=1
else
printf ' %s%s%s %s …\n' "${CC_MUTED}" "$ICON_INFO" "${C_RESET}" "$label"
"$@" >"$log_file" 2>&1
exit_code=$?
fi
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
if [[ "$streamed_output" -eq 1 ]]; then
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
ctms_fail "命令执行失败,详细日志保留于:${log_file#"$CTMS_ROOT_DIR"/}"
else
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 可看实时输出)"
fi
}
# 固定占用 9 行:上边框 + 6 行日志 + 下边框 + 状态行。
# 日志行会截断,避免终端自动换行破坏 ANSI 光标定位。
_CTMS_LIVE_LOG_ROWS=6
_CTMS_LIVE_LOG_HEIGHT=9
_ctms_spinner() {
local cmd_pid="$1" label="$2" start_ts="$3"
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0 elapsed
while kill -0 "$cmd_pid" 2>/dev/null; do
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
}
_ctms_live_log() {
local cmd_pid="$1" label="$2" start_ts="$3" log_file="$4"
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0 first=1
local columns box_width content_width elapsed line row
local -a lines=()
columns="$(tput cols 2>/dev/null || printf 80)"
[[ "$columns" =~ ^[0-9]+$ ]] || columns=80
box_width=$(( columns - 4 ))
[[ "$box_width" -gt 160 ]] && box_width=160
[[ "$box_width" -lt 50 ]] && box_width=50
content_width=$(( box_width - 4 ))
while kill -0 "$cmd_pid" 2>/dev/null; do
lines=()
while IFS= read -r line; do
lines+=("$line")
done < <(_ctms_recent_log_lines "$log_file" "$_CTMS_LIVE_LOG_ROWS" "$content_width")
if [[ "$first" -eq 0 ]]; then
printf '\r\033[%dA' "$(( _CTMS_LIVE_LOG_HEIGHT - 1 ))"
fi
printf '\033[2K %s╭─ 实时输出 %s╮%s\n' \
"${CC_PRIMARY}${C_BOLD}" "$(ctms_repeat_char '─' "$(( box_width - 13 ))")" "${C_RESET}"
for ((row=0; row<_CTMS_LIVE_LOG_ROWS; row++)); do
line="${lines[$row]:-}"
printf '\033[2K %s│%s %-*s %s│%s\n' \
"${CC_PRIMARY}" "${C_RESET}${CC_MUTED}" "$content_width" "$line" "${CC_PRIMARY}" "${C_RESET}"
done
printf '\033[2K %s╰%s╯%s\n' \
"${CC_PRIMARY}" "$(ctms_repeat_char '─' "$(( box_width - 2 ))")" "${C_RESET}"
elapsed=$(( $(date +%s) - start_ts ))
printf '\033[2K %s%s%s %s %s(%ss)%s' \
"${CC_INFO}${C_BOLD}" "${frames[i]}" "${C_RESET}" \
"$label" "${CC_MUTED}" "$elapsed" "${C_RESET}"
first=0
i=$(( (i + 1) % ${#frames[@]} ))
sleep 0.2
done
}
_ctms_recent_log_lines() {
local log_file="$1" rows="$2" width="$3"
[[ -s "$log_file" ]] || return 0
if [[ "$_CTMS_HAS_PERL" -eq 1 ]]; then
# 只读取文件尾部,清除颜色/光标控制符,并把 Docker 的 CR 进度输出拆成独立行。
tail -n 80 "$log_file" | perl -CSDA -pe '
s/\e\[[0-9;?]*[ -\/]*[@-~]//g;
s/\e[()][0-9A-Za-z]//g;
s/\r/\n/g;
' | tail -n "$rows" | perl -CSDA -Mutf8 -pe \
'BEGIN { $width = shift @ARGV } chomp; $_ = substr($_, 0, $width) . "\n";' "$width"
else
local esc
esc="$(printf '\033')"
tail -n 80 "$log_file" \
| tr '\r' '\n' \
| sed -e "s/${esc}\\[[0-9;?]*[A-Za-z]//g" -e "s/${esc}[()][0-9A-Za-z]//g" \
| tail -n "$rows" \
| cut -c "1-${width}"
fi
}
_ctms_clear_live_log() {
printf '\r\033[%dA' "$(( _CTMS_LIVE_LOG_HEIGHT - 1 ))"
local row
for ((row=0; row<_CTMS_LIVE_LOG_HEIGHT-1; row++)); do
printf '\033[2K\n'
done
printf '\033[2K\033[%dA\r' "$(( _CTMS_LIVE_LOG_HEIGHT - 1 ))"
}
# ── .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"
}
# 判断 .env 中是否存在某个键(区分“键缺失”与“键存在但值为空”:
# dev 的 LOGIN_RSA_PRIVATE_KEY= 是合法空值,不能当作缺失去重新生成)。
ctms_env_has_key() {
local key="$1"
[[ -f "$CTMS_ENV_FILE" ]] || return 1
grep -q "^${key}=" "$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}"
}