diff --git a/README.md b/README.md index 6fa3e34a..eec935b1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # CTMS 项目快速上手 +## 交互安装入口 +- 推荐执行 `./install.sh`,通过键盘 `↑/↓` 选择安装、更新、卸载、资源状态等操作;该入口不接受命令行参数。 +- 菜单顶部会常驻显示当前 CTMS 容器部署状态,包括已检测到的环境、Compose 项目和容器运行数量。 +- 底层脚本位于 `scripts/`:`install.sh`、`update.sh`、`uninstall.sh`、`status.sh`;状态脚本使用 `docker compose stats --no-stream` 采集资源快照并美化展示。 + ## 生产部署 - 生产入口:`docker-compose.yaml` - 初始化方式:`docker compose run --rm backend-init` diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 458ae0b5..7f637013 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -32,8 +32,15 @@ services: fi npm run dev -- --host 0.0.0.0 environment: + NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com} VITE_RUNTIME_ENV: ${ENV:-development} VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-true} + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5173/"] + interval: 2s + timeout: 2s + retries: 90 + start_period: 5s volumes: - ./frontend:/app - frontend_node_modules:/app/node_modules @@ -53,8 +60,10 @@ services: - ./nginx/certs:/etc/nginx/certs:ro - ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro depends_on: - - backend - - frontend-dev + backend: + condition: service_started + frontend-dev: + condition: service_healthy networks: - ctms_net diff --git a/docs/guides/branch-environment-installation.md b/docs/guides/branch-environment-installation.md index 2b60b5e8..09a2489c 100644 --- a/docs/guides/branch-environment-installation.md +++ b/docs/guides/branch-environment-installation.md @@ -94,18 +94,44 @@ grep -n "^\\.env\\.\\*$" .gitignore ## 推荐脚本安装 -安装脚本入口: +推荐使用根目录交互入口: ```bash -bash scripts/install-ctms.sh dev|main|release [选项] +./install.sh +``` + +执行后通过键盘 `↑/↓` 选择安装、更新、卸载、资源状态等操作;`install.sh` 不接受命令行参数。菜单顶部会常驻显示当前 CTMS 容器部署状态,包括已检测到的环境、Compose 项目和容器运行数量。 + +底层脚本入口(适合自动化或排障时直接调用): + +```text +scripts/install.sh 安装:依赖检查、配置准备、构建启动、迁移、健康检查 +scripts/update.sh 更新:复用安装流程,不执行 git pull +scripts/uninstall.sh 卸载:默认仅停止并移除容器,不删除数据 +scripts/status.sh 状态:使用 docker compose stats --no-stream 采集资源快照并美化展示 +scripts/common.sh 公共函数:环境映射、输出样式、危险操作确认 +``` + +安装脚本直接调用格式: + +```bash +bash scripts/install.sh dev|main|release [选项] +``` + +更新、卸载、状态脚本直接调用格式: + +```bash +bash scripts/update.sh dev|main|release [安装脚本选项] +bash scripts/uninstall.sh dev|main|release +bash scripts/status.sh dev|main|release ``` 常用示例: ```bash -bash scripts/install-ctms.sh dev -bash scripts/install-ctms.sh main --base-url http://127.0.0.1:8888 -bash scripts/install-ctms.sh release --base-url https://ctms.example.com +bash scripts/install.sh dev +bash scripts/install.sh main --base-url http://127.0.0.1:8888 +bash scripts/install.sh release --base-url https://ctms.example.com ``` 可选参数: @@ -143,7 +169,7 @@ docker compose down -v 如果只想查看参数说明: ```bash -bash scripts/install-ctms.sh --help +bash scripts/install.sh --help ``` ## 手工安装/排障:dev 分支 diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..97328f77 --- /dev/null +++ b/install.sh @@ -0,0 +1,252 @@ +#!/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="" +ENV_STATUS_LINES="" +UI_BOX_WIDTH=76 + +usage() { + cat </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 + 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" + shift + local options=("$@") + local selected=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; exit 0 ;; + esac + done +} + +pick_env() { + select_menu "选择环境" \ + "dev 本地开发环境 Vite 热加载 / 默认密钥" \ + "main 内网测试环境 预发布验证 / 生产模式" \ + "release 生产环境 HTTPS / RSA 密钥" + case "$SELECTED_INDEX" in + 0) SELECTED_ENV="dev" ;; + 1) SELECTED_ENV="main" ;; + 2) SELECTED_ENV="release" ;; + esac +} + +collect_install_options() { + local env="$1" + SELECTED_BASE_URL="" + + if [[ "$env" == "release" ]]; then + SELECTED_BASE_URL="$(ctms_ask_line "请输入健康检查地址,例如 https://ctms.example.com")" + [[ -n "$SELECTED_BASE_URL" ]] || ctms_fail "release 环境必须提供健康检查地址" + elif [[ "$env" == "main" ]]; then + SELECTED_BASE_URL="$(ctms_ask_line "请输入健康检查地址" "http://127.0.0.1:8888")" + fi +} + +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 +} + +# 操作完成后暂停,等用户确认再返回菜单 +pause_return() { + printf '\n %s%s%s 按 Enter 返回主菜单…' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" + read -r _ || true +} + +interactive_main() { + select_menu "选择操作" \ + "安装部署 配置环境、构建容器、迁移数据库、健康检查" \ + "滚动更新 重建服务、执行迁移、复用安装健康检查" \ + "安全卸载 停止并移除容器,保留 .env 与数据文件" \ + "资源状态 查看 CPU、内存、网络与磁盘 I/O 快照" \ + "使用帮助 查看入口和底层脚本说明" \ + "退出控制台" + + case "$SELECTED_INDEX" in + 0) + pick_env + collect_install_options "$SELECTED_ENV" + run_install_like_script "install.sh" "$SELECTED_ENV" + pause_return + ;; + 1) + pick_env + collect_install_options "$SELECTED_ENV" + run_install_like_script "update.sh" "$SELECTED_ENV" + pause_return + ;; + 2) + pick_env + run_script "uninstall.sh" "$SELECTED_ENV" + pause_return + ;; + 3) + pick_env + run_script "status.sh" "$SELECTED_ENV" + pause_return + ;; + 4) + 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 + exit 0 +} + +main "$@" diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100644 index 00000000..2abf073b --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,495 @@ +#!/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 = ; + $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 -p ,确保环境隔离 +# 用法: ctms_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="" + + 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}" + else + printf '\n%s%s%s %s: ' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "$prompt" + fi + IFS= read -r value || value="" + printf '%s' "${value:-$default_value}" +} diff --git a/scripts/install-ctms.sh b/scripts/install.sh similarity index 53% rename from scripts/install-ctms.sh rename to scripts/install.sh index 8a3ae14d..18adc661 100755 --- a/scripts/install-ctms.sh +++ b/scripts/install.sh @@ -3,152 +3,57 @@ set -euo pipefail # ───────────────────────────────────────────── # CTMS 一键安装脚本 +# 复用 scripts/common.sh 的共享库(颜色、UI、compose、依赖、.env)。 # ───────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/common.sh +source "$SCRIPT_DIR/common.sh" + TARGET_ENV="" BASE_URL="" ASSUME_YES=0 SKIP_BUILD=0 SKIP_MIGRATE=0 VERBOSE=0 -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ENV_FILE="$ROOT_DIR/.env" -LOG_DIR="$ROOT_DIR/.install-logs" - -# ── 颜色 & 样式 ────────────────────────────── -_has_color() { [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null)" -ge 8 ]]; } - -if _has_color; then - C_RESET="$(tput sgr0)" - C_BOLD="$(tput bold)" - C_DIM="$(tput dim 2>/dev/null || printf '')" - C_BLUE="$(tput setaf 4)" - C_CYAN="$(tput setaf 6)" - C_GREEN="$(tput setaf 2)" - C_YELLOW="$(tput setaf 3)" - C_RED="$(tput setaf 1)" - C_WHITE="$(tput setaf 7)" -else - C_RESET="" C_BOLD="" C_DIM="" C_BLUE="" C_CYAN="" - C_GREEN="" C_YELLOW="" C_RED="" C_WHITE="" -fi - -# ── 输出函数 ───────────────────────────────── +ROOT_DIR="$CTMS_ROOT_DIR" +ENV_FILE="$CTMS_ENV_FILE" +# ── Banner ─────────────────────────────────── _banner() { + local b="${CC_PRIMARY}${C_BOLD}" # 边框主色 + local a="${CC_INFO}" # 字形填充色 + local r="${C_RESET}" printf '\n' - printf '%s╔══════════════════════════════════════════════╗%s\n' "${C_BLUE}${C_BOLD}" "${C_RESET}" - printf '%s║%s ██████╗████████╗███╗ ███╗███████╗ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}" - printf '%s║%s ██╔════╝╚══██╔══╝████╗ ████║██╔════╝ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}" - printf '%s║%s ██║ ██║ ██╔████╔██║███████╗ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}" - printf '%s║%s ██║ ██║ ██║╚██╔╝██║╚════██║ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}" - printf '%s║%s ╚██████╗ ██║ ██║ ╚═╝ ██║███████║ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}" - printf '%s║%s ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}" - printf '%s║%s 临床试验管理系统 安装程序 %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_DIM}${C_WHITE}" "${C_BLUE}" "${C_RESET}" - printf '%s╚══════════════════════════════════════════════╝%s\n' "${C_BLUE}${C_BOLD}" "${C_RESET}" + 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' } -log() { printf ' %s·%s %s\n' "${C_DIM}" "${C_RESET}" "$*"; } -ok() { printf ' %s✔%s %s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}" "$*"; } -warn() { printf ' %s⚠%s %s\n' "${C_YELLOW}${C_BOLD}" "${C_RESET}" "$*" >&2; } - -step() { - local idx="${_STEP_IDX:-0}" - _STEP_IDX=$(( idx + 1 )) - printf '\n%s┌─ 步骤 %s · %s%s\n' \ - "${C_BLUE}${C_BOLD}" "$_STEP_IDX" "$*" "${C_RESET}" -} - -fail() { - printf '\n %s✖ 安装中止%s\n' "${C_RED}${C_BOLD}" "${C_RESET}" >&2 - printf ' %s%s%s\n\n' "${C_RED}" "$*" "${C_RESET}" >&2 - exit 1 -} - -run() { - printf ' %s$%s %s%s%s\n' "${C_DIM}" "${C_RESET}" "${C_WHITE}" "$*" "${C_RESET}" - "$@" -} +# ── 简短别名(统一走 common 的 ctms_*) ──────── +log() { ctms_info "$@"; } +ok() { ctms_ok "$@"; } +warn() { ctms_warn "$@"; } +fail() { ctms_fail "$@"; } +step() { ctms_step "$@"; } +# 当前环境的 compose 调用 compose_cmd() { - if [[ "$TARGET_ENV" == "dev" ]]; then - docker compose -f "$ROOT_DIR/docker-compose.dev.yaml" "$@" - else - docker compose "$@" - fi -} - -# 运行长命令,默认折叠输出。失败或 --verbose 时回放完整日志。 -# 用法: run_quiet <提示语> -- <命令...> -run_quiet() { - local label="$1"; shift - [[ "${1:-}" == "--" ]] && shift - - if [[ "$VERBOSE" -eq 1 ]]; then - printf ' %s$%s %s%s%s\n' "${C_DIM}" "${C_RESET}" "${C_WHITE}" "$*" "${C_RESET}" - "$@" - return $? - fi - - mkdir -p "$LOG_DIR" - local log_file - log_file="$(mktemp "$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=$! - _spinner_for "$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 …\n' "$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(%ss)%s\n' \ - "${C_GREEN}${C_BOLD}" "${C_RESET}" "$label" "${C_DIM}" "$elapsed" "${C_RESET}" - return 0 - fi - - printf '\n %s✖%s %s 失败(耗时 %ss),完整输出:\n\n' \ - "${C_RED}${C_BOLD}" "${C_RESET}" "$label" "$elapsed" >&2 - sed 's/^/ /' "$log_file" >&2 - printf '\n' >&2 - fail "命令执行失败,详细日志保留于:${log_file#$ROOT_DIR/}(追加 --verbose 可看实时输出)" -} - -_spinner_for() { - 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' \ - "${C_CYAN}${C_BOLD}" "${frames[i]}" "${C_RESET}" \ - "$label" "${C_DIM}" "$elapsed" "${C_RESET}" - i=$(( (i + 1) % ${#frames[@]} )) - sleep 0.1 - done + ctms_compose "$TARGET_ENV" "$@" } # ── 帮助 ───────────────────────────────────── - usage() { cat <${C_RESET} [选项] + bash scripts/install.sh ${C_CYAN}<环境>${C_RESET} [选项] ${C_BOLD}环境:${C_RESET} ${C_CYAN}dev${C_RESET} 本地开发环境(使用默认密钥,不生成 RSA 密钥对) @@ -167,14 +72,13 @@ ${C_BOLD}选项:${C_RESET} ${C_WHITE}-h, --help${C_RESET} 显示此帮助信息 ${C_BOLD}示例:${C_RESET} - bash scripts/install-ctms.sh dev - bash scripts/install-ctms.sh main --base-url http://127.0.0.1:8888 - bash scripts/install-ctms.sh release --base-url https://ctms.example.com --yes + bash scripts/install.sh dev + bash scripts/install.sh main --base-url http://127.0.0.1:8888 + bash scripts/install.sh release --base-url https://ctms.example.com --yes EOF } # ── 参数解析 ───────────────────────────────── - parse_args() { if [[ $# -eq 0 ]]; then usage @@ -200,11 +104,11 @@ parse_args() { BASE_URL="$2" shift 2 ;; - --yes) ASSUME_YES=1; shift ;; - --skip-build) SKIP_BUILD=1; shift ;; + --yes) ASSUME_YES=1; shift ;; + --skip-build) SKIP_BUILD=1; shift ;; --skip-migrate) SKIP_MIGRATE=1; shift ;; - --verbose) VERBOSE=1; shift ;; - -h|--help) usage; exit 0 ;; + --verbose) VERBOSE=1; shift ;; + -h|--help) usage; exit 0 ;; *) usage fail "未知参数: $1" @@ -214,79 +118,20 @@ parse_args() { } # ── 依赖检查 ───────────────────────────────── - -_require_command() { - local cmd="$1" - local hint="$2" - if ! command -v "$cmd" >/dev/null 2>&1; then - warn "缺少依赖: ${C_BOLD}${cmd}${C_RESET}" - warn "安装方式: $hint" - return 1 - fi -} - check_dependencies() { step "检查系统依赖" - local missing=0 - _require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1 - _require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1 - _require_command curl "Linux: sudo apt-get install -y curl | macOS: brew install curl" || missing=1 - [[ "$missing" -eq 0 ]] || fail "系统依赖不完整,请按上方提示安装后重试" - - if ! docker compose version >/dev/null 2>&1; then - fail "未检测到 Docker Compose v2,请确认 docker compose version 可正常执行" - fi + ctms_require_dependencies "$TARGET_ENV" ok "系统依赖检查通过" } # ── 环境参数计算 ────────────────────────────── +compose_project_name() { ctms_project_name "$TARGET_ENV"; } +app_env() { ctms_app_env "$TARGET_ENV"; } +key_id() { ctms_key_id "$TARGET_ENV"; } +default_base_url() { ctms_default_base_url "$TARGET_ENV"; } -compose_project_name() { - case "$TARGET_ENV" in - dev) printf 'ctms_dev' ;; - main) printf 'ctms_main' ;; - release) printf 'ctms_release' ;; - esac -} - -app_env() { - [[ "$TARGET_ENV" == "dev" ]] && printf 'development' || printf 'production' -} - -key_id() { - local today - today="$(date +%Y%m%d)" - case "$TARGET_ENV" in - dev) printf 'default' ;; - main) printf 'main-%s' "$today" ;; - release) printf 'release-%s' "$today" ;; - esac -} - -default_base_url() { - case "$TARGET_ENV" in - dev|main) printf 'http://127.0.0.1:8888' ;; - release) printf '' ;; - esac -} - -# ── .env 读写 ───────────────────────────────── - -read_env_value() { - local key="$1" - [[ -f "$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 - } - ' "$ENV_FILE" -} +# ── .env 读写(复用 common) ────────────────── +read_env_value() { ctms_read_env_value "$@"; } resolve_effective_config() { local desired_project_name="$1" @@ -320,9 +165,10 @@ resolve_effective_config() { EFFECTIVE_LOGIN_KEY_ID="$env_login_key_id" } +# 生成单行转义 PEM:行间用 \n 连接,末尾不追加多余 \n generate_escaped_private_key() { openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null \ - | awk '{printf "%s\\n", $0}' + | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }' } generate_jwt_secret() { @@ -330,14 +176,13 @@ generate_jwt_secret() { } # ── 地址解析 ───────────────────────────────── - resolve_base_url() { [[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)" if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then printf ' %s▸%s 请输入 release 健康检查域名(例如 https://ctms.example.com): ' \ "${C_YELLOW}${C_BOLD}" "${C_RESET}" - read -r BASE_URL + read -r BASE_URL || true fi [[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址" @@ -348,86 +193,60 @@ resolve_base_url() { } # ── 安装确认 ───────────────────────────────── - confirm_install() { local project_name="$1" local runtime_env="$2" step "确认安装参数" - local env_status build_status migrate_status + local env_status build_status migrate_status pg_status env_status="$([[ -f "$ENV_FILE" ]] && printf '已存在' || printf '新建')" build_status="$([[ "$SKIP_BUILD" -eq 1 ]] && printf '跳过' || printf '执行')" migrate_status="$([[ "$SKIP_MIGRATE" -eq 1 ]] && printf '跳过' || printf '执行')" + pg_status="$([[ -d "$ROOT_DIR/pg_data" ]] && printf '已存在' || printf '新建')" + + 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' - printf ' %s%-18s%s %s\n' "${C_BOLD}" "目标环境" "${C_RESET}" "$TARGET_ENV" - printf ' %s%-18s%s %s\n' "${C_BOLD}" "运行模式" "${C_RESET}" "$runtime_env" - printf ' %s%-18s%s %s\n' "${C_BOLD}" "Compose 项目" "${C_RESET}" "$project_name" - printf ' %s%-18s%s %s\n' "${C_BOLD}" "健康检查地址" "${C_RESET}" "$BASE_URL" - printf ' %s%-18s%s %s\n' "${C_BOLD}" ".env 文件" "${C_RESET}" "$env_status" - printf ' %s%-18s%s %s\n' "${C_BOLD}" "pg_data 目录" "${C_RESET}" "$([[ -d "$ROOT_DIR/pg_data" ]] && printf '已存在' || printf '新建')" - printf ' %s%-18s%s %s\n' "${C_BOLD}" "镜像构建" "${C_RESET}" "$build_status" - printf ' %s%-18s%s %s\n' "${C_BOLD}" "数据库迁移" "${C_RESET}" "$migrate_status" - printf '\n' + ctms_box_top "${CC_PRIMARY}${C_BOLD}" "安装参数预览" "$w" + row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}" + row "运行模式" "$runtime_env" + row "Compose 项目" "$project_name" + row "健康检查地址" "$BASE_URL" "${CC_INFO}" + row ".env 文件" "$env_status" + row "pg_data 目录" "$pg_status" + row "镜像构建" "$build_status" + row "数据库迁移" "$migrate_status" + ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$w" if [[ "$ASSUME_YES" -eq 1 ]]; then log "已启用 --yes,跳过确认" return fi - printf ' %s▸%s 确认以上参数并继续安装?输入 %syes%s 继续: ' \ - "${C_YELLOW}${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}" + printf '\n %s%s%s 确认以上参数并继续安装?输入 %syes%s 继续: ' \ + "${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_BOLD}" "${C_RESET}" local answer - read -r answer + read -r answer || answer="" [[ "$answer" == "yes" ]] || fail "用户取消安装" } -# ── .env 写入 ───────────────────────────────── - -write_env_file() { - local project_name="$1" runtime_env="$2" login_key_id="$3" - local jwt_secret="$4" rsa_private_key="$5" - local temp_file - temp_file="$(mktemp "$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' "$([[ "$runtime_env" == "development" ]] && printf 'true' || printf 'false')" - 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" "$ENV_FILE" - ok ".env 写入完成" -} - -upsert_env_value() { - local key="$1" value="$2" - local temp_file - temp_file="$(mktemp "$ROOT_DIR/.env.tmp.XXXXXX")" - if [[ -f "$ENV_FILE" && -n "$(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 } - ' "$ENV_FILE" > "$temp_file" - else - [[ -f "$ENV_FILE" ]] && cat "$ENV_FILE" > "$temp_file" - printf '%s=%s\n' "$key" "$value" >> "$temp_file" - fi - mv "$temp_file" "$ENV_FILE" -} - +# ── .env 准备 ───────────────────────────────── sync_frontend_build_env() { local runtime_env="$1" local allow_insecure_dev_login="false" [[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true" - upsert_env_value VITE_RUNTIME_ENV "$runtime_env" - upsert_env_value VITE_ALLOW_INSECURE_DEV_LOGIN "$allow_insecure_dev_login" + ctms_upsert_env_value VITE_RUNTIME_ENV "$runtime_env" + ctms_upsert_env_value VITE_ALLOW_INSECURE_DEV_LOGIN "$allow_insecure_dev_login" ok "前端构建环境变量已同步" } @@ -453,36 +272,35 @@ prepare_env_file() { ok "已生成新的 LOGIN_RSA_PRIVATE_KEY" fi - write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key" + ctms_write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key" + ok ".env 写入完成" } # ── Docker 流程 ─────────────────────────────── - run_compose_config() { step "校验 Docker Compose 配置" - run_quiet "Docker Compose 配置语法校验" -- compose_cmd config + ctms_run_quiet "Docker Compose 配置语法校验" -- compose_cmd config } run_backend_init() { - [[ "$TARGET_ENV" != "dev" ]] || return 0 step "初始化数据库与管理员账号" - run_quiet "运行 backend-init 容器" -- compose_cmd run --rm backend-init + ctms_run_quiet "运行 backend-init 容器" -- compose_cmd run --rm backend-init } run_build_and_start() { if [[ "$SKIP_BUILD" -eq 1 ]]; then step "启动服务(跳过镜像构建)" if [[ "$TARGET_ENV" == "dev" ]]; then - run_quiet "启动开发容器并刷新后端/Nginx/Vite" -- compose_cmd up -d --force-recreate backend nginx frontend-dev + ctms_run_quiet "启动开发容器并刷新后端/Nginx/Vite" -- compose_cmd up -d --force-recreate backend nginx frontend-dev else - run_quiet "启动容器" -- compose_cmd up -d + ctms_run_quiet "启动容器" -- compose_cmd up -d fi else step "构建镜像并启动服务" if [[ "$TARGET_ENV" == "dev" ]]; then - run_quiet "构建并刷新开发容器(首次较慢)" -- compose_cmd up -d --build --force-recreate backend nginx frontend-dev + ctms_run_quiet "构建并刷新开发容器(首次较慢)" -- compose_cmd up -d --build --force-recreate backend nginx frontend-dev else - run_quiet "构建镜像并启动容器(首次较慢)" -- compose_cmd up -d --build + ctms_run_quiet "构建镜像并启动容器(首次较慢)" -- compose_cmd up -d --build fi fi } @@ -493,23 +311,23 @@ run_migrations() { return fi step "执行数据库迁移" - run_quiet "Alembic upgrade head" -- compose_cmd run --rm backend python -m alembic upgrade head + ctms_run_quiet "Alembic upgrade head" -- compose_cmd run --rm backend python -m alembic upgrade head } # ── 健康检查 ───────────────────────────────── - check_container_status() { step "检查容器运行状态" local running running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)" local services=(db backend nginx) [[ "$TARGET_ENV" != "dev" ]] || services+=(frontend-dev) + local svc for svc in "${services[@]}"; do if printf '%s\n' "$running" | grep -qx "$svc"; then - printf ' %s✔%s %-8s 运行中\n' "${C_GREEN}${C_BOLD}" "${C_RESET}" "$svc" + printf ' %s%s%s %-12s %s运行中%s\n' "${CC_SUCCESS}${C_BOLD}" "$ICON_RUNNING" "${C_RESET}" "$svc" "${CC_SUCCESS}" "${C_RESET}" else - printf ' %s✖%s %-8s %s未运行%s\n' "${C_RED}${C_BOLD}" "${C_RESET}" "$svc" "${C_RED}" "${C_RESET}" - fail "$svc 容器未正常运行,请执行 'bash scripts/install-ctms.sh $TARGET_ENV --skip-build --skip-migrate --verbose' 或对应 compose ps 命令排查" + printf ' %s%s%s %-12s %s未运行%s\n' "${CC_DANGER}${C_BOLD}" "$ICON_STOPPED" "${C_RESET}" "$svc" "${CC_DANGER}" "${C_RESET}" + fail "$svc 容器未正常运行,请执行 'bash scripts/install.sh $TARGET_ENV --skip-build --skip-migrate --verbose' 或对应 compose ps 命令排查" fi done } @@ -543,7 +361,7 @@ if expect_rsa: ) PY ) - run_quiet "执行后端配置自检" -- compose_cmd exec -T \ + ctms_run_quiet "执行后端配置自检" -- compose_cmd exec -T \ -e EXPECTED_ENV="$expected_env" \ -e EXPECTED_KEY_ID="$expected_key_id" \ -e EXPECT_RSA="$expect_rsa" \ @@ -575,6 +393,18 @@ check_http_endpoint() { fail "接口探测失败: $url" } +check_dev_nginx_mode() { + [[ "$TARGET_ENV" == "dev" ]] || return 0 + + step "校验开发 Nginx 代理配置" + if compose_cmd exec -T nginx sh -c 'grep -q "frontend_dev" /etc/nginx/nginx.conf && grep -q "proxy_pass http://frontend_dev" /etc/nginx/nginx.conf' >/dev/null 2>&1; then + ok "Nginx 已代理到 Vite dev server" + return + fi + + fail "当前 Nginx 未加载开发配置,请重试:bash scripts/install.sh dev --skip-migrate --verbose" +} + run_health_checks() { local runtime_env="$1" login_key_id="$2" local expect_rsa=0 @@ -591,33 +421,25 @@ run_health_checks() { ok "HTTP 接口全部就绪" } -check_dev_nginx_mode() { - [[ "$TARGET_ENV" == "dev" ]] || return 0 - - step "校验开发 Nginx 代理配置" - if compose_cmd exec -T nginx sh -c 'grep -q "frontend_dev" /etc/nginx/nginx.conf && grep -q "proxy_pass http://frontend_dev" /etc/nginx/nginx.conf' >/dev/null 2>&1; then - ok "Nginx 已代理到 Vite dev server" - return - fi - - fail "当前 Nginx 未加载开发配置,请重试:bash scripts/install-ctms.sh dev --skip-migrate --verbose" -} - # ── 完成提示 ───────────────────────────────── - show_success() { + local w=66 + local pa pe + pa="$(ctms_pad_right "访问地址" 14)" + pe="$(ctms_pad_right "环境模式" 14)" printf '\n' - printf '%s┌──────────────────────────────────────────────┐%s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}" - printf '%s│%s ✔ CTMS 安装成功 %s│%s\n' "${C_GREEN}${C_BOLD}" "${C_GREEN}" "${C_GREEN}${C_BOLD}" "${C_RESET}" - printf '%s└──────────────────────────────────────────────┘%s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}" - printf '\n' - printf ' %s访问地址%s %s%s%s\n' "${C_BOLD}" "${C_RESET}" "${C_CYAN}" "$BASE_URL" "${C_RESET}" - printf ' %s环境模式%s %s\n' "${C_BOLD}" "${C_RESET}" "$TARGET_ENV" + ctms_box_top "${CC_SUCCESS}${C_BOLD}" "$ICON_OK CTMS 安装成功" "$w" + ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \ + "${pa}${BASE_URL}" \ + "${CC_MUTED}${pa}${C_RESET}${CC_INFO}${BASE_URL}${C_RESET}" + ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \ + "${pe}${TARGET_ENV}" \ + "${CC_MUTED}${pe}${C_RESET}${CC_TEXT}${TARGET_ENV}${C_RESET}" + ctms_box_bottom "${CC_SUCCESS}${C_BOLD}" "$w" printf '\n' } # ── 入口 ───────────────────────────────────── - main() { _banner parse_args "$@" diff --git a/scripts/status.sh b/scripts/status.sh new file mode 100755 index 00000000..7f9fce8d --- /dev/null +++ b/scripts/status.sh @@ -0,0 +1,109 @@ +#!/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" + +usage() { + cat < + +${C_BOLD}环境:${C_RESET} + dev | main | release + +${C_BOLD}说明:${C_RESET} + 使用 docker compose stats --no-stream 获取资源快照,并输出整理后的状态面板。 +EOF +} + +STATS_OUTPUT="" +STATS_ERROR="" +STATUS_BOX_WIDTH=127 + +collect_compose_stats() { + local env="$1" + local output="" exit_code=0 + STATS_OUTPUT="" + STATS_ERROR="" + + set +e + output="$(ctms_compose "$env" stats --all --no-stream \ + --format '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}|{{.PIDs}}' 2>&1)" + exit_code=$? + set -e + + if [[ "$exit_code" -ne 0 ]]; then + set +e + output="$(ctms_compose "$env" stats --all --no-stream \ + --format '{{.Container}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}|{{.PIDs}}' 2>&1)" + exit_code=$? + set -e + fi + + if [[ "$exit_code" -ne 0 ]]; then + STATS_ERROR="$output" + return "$exit_code" + fi + + STATS_OUTPUT="$output" +} + +render_stats_panel() { + local env="$1" + local project="$2" + local generated_at + generated_at="$(date '+%Y-%m-%d %H:%M:%S')" + + printf '\n' + ctms_box_top "${CC_PRIMARY}${C_BOLD}" "CTMS 资源状态快照" "$STATUS_BOX_WIDTH" + ctms_box_line "${CC_PRIMARY}${C_BOLD}" "$STATUS_BOX_WIDTH" "环境 ${env} Compose 项目 ${project} 时间 ${generated_at}" "${CC_MUTED}环境${C_RESET} ${CC_INFO}${C_BOLD}${env}${C_RESET} ${CC_MUTED}Compose 项目${C_RESET} ${CC_INFO}${C_BOLD}${project}${C_RESET} ${CC_MUTED}时间 ${generated_at}${C_RESET}" + ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$STATUS_BOX_WIDTH" + printf '\n' + + if [[ -n "$STATS_ERROR" ]]; then + printf ' %s%s%s %s无法获取 docker compose stats 快照%s\n' "${CC_DANGER}${C_BOLD}" "$ICON_FAIL" "${C_RESET}" "${CC_DANGER}" "${C_RESET}" + printf ' %s环境%s %s %sCompose 项目%s %s\n' "${CC_MUTED}" "${C_RESET}" "$env" "${CC_MUTED}" "${C_RESET}" "$project" + printf ' %s原因%s %s\n\n' "${CC_MUTED}" "${C_RESET}" "$STATS_ERROR" + return 1 + fi + + if [[ -z "$STATS_OUTPUT" ]]; then + printf ' %s%s%s %s未获取到容器资源数据%s\n' "${CC_WARN}${C_BOLD}" "$ICON_WARN" "${C_RESET}" "${CC_WARN}" "${C_RESET}" + printf ' %s可能原因:该环境尚未部署,或所有容器已被移除。%s\n\n' "${CC_MUTED}" "${C_RESET}" + return + fi + + 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}" + 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 + [[ -n "$name" ]] || continue + ctms_box_line "${CC_ACCENT}${C_BOLD}" "$STATUS_BOX_WIDTH" "$(printf '%-22.22s %-9.9s %-27.27s %-8.8s %-21.21s %-21.21s %-6.6s' "$name" "$cpu" "$mem" "$mem_percent" "$net_io" "$block_io" "$pids")" + done + + ctms_box_bottom "${CC_ACCENT}${C_BOLD}" "$STATUS_BOX_WIDTH" + printf '\n' + printf ' %s数据来源%s docker compose stats --all --no-stream\n\n' "${CC_MUTED}" "${C_RESET}" +} + +main() { + if [[ $# -ne 1 ]]; then + usage + exit 1 + fi + + local env="$1" + local project="" + ctms_require_env "$env" + project="$(ctms_project_name "$env")" + + ctms_require_docker_compose + cd "$CTMS_ROOT_DIR" + collect_compose_stats "$env" || true + render_stats_panel "$env" "$project" +} + +main "$@" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 00000000..8ba54c70 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,44 @@ +#!/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" + +usage() { + cat < + +${C_BOLD}环境:${C_RESET} + dev | main | release + +${C_BOLD}说明:${C_RESET} + 默认仅停止并移除对应 Compose 项目的容器和网络。 + 不删除 .env、pg_data、Docker volume 或镜像。 +EOF +} + +main() { + if [[ $# -ne 1 ]]; then + usage + exit 1 + fi + + local env="$1" + local project="" + ctms_require_env "$env" + project="$(ctms_project_name "$env")" + + ctms_require_docker_compose + ctms_confirm_danger \ + "停止并移除 CTMS 容器" \ + "Compose 项目 ${project};不会删除 .env、pg_data、volume 或镜像。" \ + "服务会停止,对外访问中断;数据默认保留。" + + cd "$CTMS_ROOT_DIR" + ctms_compose "$env" down + ctms_ok "容器已停止并移除,数据文件未删除" +} + +main "$@" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 00000000..6795d916 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,40 @@ +#!/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" + +usage() { + cat < [安装脚本选项] + +${C_BOLD}环境:${C_RESET} + dev | main | release + +${C_BOLD}说明:${C_RESET} + 更新流程复用 scripts/install.sh:校验配置、重建/刷新容器、执行迁移并健康检查。 + 本脚本不会执行 git pull,请先确认代码已处于目标版本。 +EOF +} + +main() { + if [[ $# -eq 0 ]]; then + usage + exit 1 + fi + + local env="$1" + shift + ctms_require_env "$env" + + local install_script="$CTMS_ROOT_DIR/scripts/install.sh" + [[ -x "$install_script" ]] || ctms_fail "安装脚本不可执行: $install_script" + + ctms_info "更新会复用现有安装流程:校验配置、重建容器、执行迁移并健康检查。" + ctms_info "该命令不会执行 git pull;请先确认代码已处于目标版本。" + "$install_script" "$env" "$@" +} + +main "$@" diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 8a78d5dc..00000000 --- a/skills-lock.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 1, - "skills": { - "design-taste-frontend": { - "source": "Leonxlnx/taste-skill", - "sourceType": "github", - "skillPath": "skills/taste-skill/SKILL.md", - "computedHash": "6d838b246d0e35d0b53f4f23f98ba7a1dd561937e64f7d0c7553b0928e376c3e" - } - } -}