From c8fe8cad594d26464a0f3ba682725b8b4528cad9 Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 8 Apr 2026 16:54:45 +0800 Subject: [PATCH] fix: refine planet startup script UX --- VERSION | 2 +- docs/CHANGELOG.md | 18 + docs/version-history.md | 3 +- frontend/package.json | 2 +- planet.sh | 742 ++++++++++++++++++++++++++++++++++------ 5 files changed, 656 insertions(+), 111 deletions(-) diff --git a/VERSION b/VERSION index fda96dcf..9e40e75c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.2 +0.23.3 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 976af428..4e1ad2e1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,24 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.23.3 + +Released: 2026-04-08 + +### Highlights + +- Refined `planet.sh` startup and restart presentation so service bring-up, health checks, and frontend dev-server readiness are easier to follow in real time. + +### Improved + +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by standardizing the script on `zsh`, tightening frontend startup stage rendering, and making spinner-driven subtask feedback show up step by step instead of bunching at the end. +- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) by clarifying health-check subtask copy, filtering noisy frontend startup lines, and aligning terminal output spacing across spinner and status rows. + +### Fixed + +- Fixed several `zsh` compatibility issues in [planet.sh](/home/ray/dev/linkong/planet/planet.sh), including reserved variable name collisions during `restart` and container health checks. +- Fixed [planet.sh](/home/ray/dev/linkong/planet/planet.sh) so frontend readiness animation no longer appears frozen while waiting for Vite startup and health probes. + ## 0.23.2 Released: 2026-04-08 diff --git a/docs/version-history.md b/docs/version-history.md index 7af737b9..ebc0b699 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.23.0` +- `dev` 当前开发分支历史推导到:`0.23.3` ## Timeline @@ -71,6 +71,7 @@ | `0.21.6` | bugfix | `dev` | `pending` | improve Earth legend generation, info-card interactions, and HUD messaging polish | | `0.22.9` | bugfix | `dev` | `6bfcd053` | simplify `planet.sh` readiness messaging and only show retry counts on actual restart | | `0.23.0` | feature | `dev` | `pending` | add dedicated `aiprovider` service, multi-protocol AI adapters, uv-only Python runtime, and AI Provider restart controls | +| `0.23.3` | bugfix | `dev` | `pending` | refine `planet.sh` zsh runtime compatibility, startup logging, and frontend readiness feedback | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 0a77af8a..f0bae64d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.23.2", + "version": "0.23.3", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/planet.sh b/planet.sh index 57d4318b..712a7a27 100755 --- a/planet.sh +++ b/planet.sh @@ -1,8 +1,9 @@ -#!/bin/bash +#!/usr/bin/env zsh set -e -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_PATH="${(%):-%N}" +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" cd "$SCRIPT_DIR" RED='\033[38;5;203m' @@ -15,20 +16,26 @@ DIM='\033[2m' NC='\033[0m' WAIT_SPINNER_FRAMES=( '⠉⠁' + '⠈⠁' '⠈⠉' - '⠀⠙' - '⠀⠸' - '⠀⢰' - '⠀⣠' + ' ⠙' + ' ⠸' + ' ⢰' + ' ⣠' '⢀⣀' '⣀⡀' - '⣄⠀' - '⡆⠀' - '⠇⠀' - '⠋⠀' + '⣄ ' + '⡆ ' + '⠇ ' + '⠋ ' + '⠋⠁' ) WAIT_SPINNER_STEP=0 WAIT_SPINNER_TICKS_PER_SECOND=8 +WAIT_SPINNER_DETAIL="" +WAIT_SPINNER_MESSAGE="" +WAIT_SESSION_ACTIVE=0 +HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-0.2}" BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}" @@ -44,10 +51,16 @@ DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}" FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}" FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}" FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}" +PORT_RELEASE_ATTEMPTS="${PORT_RELEASE_ATTEMPTS:-45}" +PORT_RELEASE_INTERVAL="${PORT_RELEASE_INTERVAL:-1}" DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}" DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}" DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}" +FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}" +FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}" +FRONTEND_PID_FILE="/tmp/planet_frontend.pid" +# Shell / Docker helpers compose_up() { if docker compose version >/dev/null 2>&1; then docker compose "$@" @@ -56,12 +69,14 @@ compose_up() { fi } +# Terminal rendering helpers render_wait_spinner() { local message="$1" local step="${2:-1}" local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} )) local frame="${WAIT_SPINNER_FRAMES[$frame_index]}" - printf "\r${DIM}·${NC} ${CYAN}%s${NC} ${WHITE}%s${NC}\033[K" "$frame" "$message" + local detail="${WAIT_SPINNER_DETAIL:- }" + printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K\033[1A\r" "$frame" "$message" "$detail" >&2 } animate_wait_spinner() { @@ -83,19 +98,144 @@ animate_wait_spinner() { } clear_wait_spinner() { - printf "\r\033[K" + printf "\r\033[K\n\033[K\033[1A\r" >&2 +} + +start_wait_session() { + WAIT_SESSION_ACTIVE=1 + WAIT_SPINNER_MESSAGE="$1" + WAIT_SPINNER_DETAIL="" + WAIT_SPINNER_STEP=1 + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" +} + +resume_wait_session() { + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + fi +} + +stop_wait_session() { + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then + clear_wait_spinner + fi + WAIT_SESSION_ACTIVE=0 + reset_wait_spinner_state +} + +close_wait_session_context() { + local owns_wait_session="$1" + + if [ "$owns_wait_session" -eq 1 ]; then + stop_wait_session + else + reset_wait_spinner_state + fi +} + +reset_wait_spinner_state() { + WAIT_SPINNER_DETAIL="" + WAIT_SPINNER_MESSAGE="" +} + +sanitize_wait_detail() { + local line="$1" + + line="$(printf "%s" "$line" | tr '\t' ' ' | tr -d '\r')" + line="$(printf "%s" "$line" | sed -E 's/\x1B\[[0-9;]*[[:alpha:]]//g')" + line="$(printf "%s" "$line" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')" + + if [ "${#line}" -gt 140 ]; then + line="${line:0:137}..." + fi + + printf "%s" "$line" +} + +set_wait_detail() { + WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")" + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + fi +} + +run_command_with_spinner() { + local message="$1" + shift + + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then + set_wait_detail "$message" + fi + + "$@" & + local command_pid=$! + local exit_code=0 + + while kill -0 "$command_pid" 2>/dev/null; do + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + else + render_wait_spinner "$message" "$WAIT_SPINNER_STEP" + fi + sleep 0.125 + done + + wait "$command_pid" || exit_code=$? + return "$exit_code" +} + +resolve_bun_from_shell_configs() { + local shell_name="$1" + shift + + local config_file + local candidate="" + local bun_install="" + + for config_file in "$@"; do + [ -f "$config_file" ] || continue + + if grep -Eq 'BUN_INSTALL|\.bun/bin|bun ' "$config_file" 2>/dev/null; then + bun_install="$(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?BUN_INSTALL=["'\'']?([^"'\'']+)["'\'']?/\2/p' "$config_file" | tail -n 1)" + + if [ -n "$bun_install" ]; then + bun_install="${bun_install/#\$HOME/$HOME}" + bun_install="${bun_install/#\~/$HOME}" + candidate="$bun_install/bin/bun" + if [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="${shell_name}-config" + return 0 + fi + fi + + candidate="$HOME/.bun/bin/bun" + if [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="${shell_name}-config" + return 0 + fi + fi + done + + return 1 } finish_wait_spinner() { log_line "done" "$GREEN" "$1" } +# Logging helpers log_line() { local label="$1" local color="$2" local message="$3" clear_wait_spinner printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC}\n" "$label" "$message" + resume_wait_session } log_step() { @@ -106,12 +246,16 @@ log_warn() { log_line "warn" "$YELLOW" "$1" } +log_halt() { + log_line "stopped" "$RED" "$1" +} + log_error() { log_line "fail" "$RED" "$1" } log_note() { - printf "${DIM} %s${NC}\n" "$1" + printf "${DIM} %s${NC}\n" "$1" } log_success() { @@ -129,7 +273,6 @@ print_splash() { |___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__| |___/ EOF - # printf "%b\n" "$NC" printf "%b" "$BLUE" cat <<'EOF' ____ _ _ @@ -138,7 +281,6 @@ EOF | __/| | (_| | | | | __/| |_ |_| |_|\__,_|_| |_|\___| \__| EOF - # printf "%b\n" "$NC" printf "%b" "$WHITE" cat <<'EOF' ____ _ @@ -150,6 +292,7 @@ EOF printf "%b\n\n" "$NC" } +# Retry / dependency helpers run_with_retry() { local max_retries="$1" local interval="$2" @@ -160,7 +303,8 @@ run_with_retry() { local attempt=1 while [ "$attempt" -le "$max_retries" ]; do - if "$@"; then + if run_command_with_spinner "$retry_label" "$@"; then + WAIT_SPINNER_DETAIL="" return 0 fi @@ -177,7 +321,7 @@ run_with_retry() { } ensure_uv_backend_deps() { - log_step "检查后端 uv 环境" + set_wait_detail "检查后端 uv 环境" if ! command -v uv >/dev/null 2>&1; then log_error "未找到 uv,请先安装 uv 并加入 PATH" @@ -204,48 +348,124 @@ ensure_uv_backend_deps() { fi } -ensure_frontend_deps() { - log_step "检查前端依赖" +# Frontend runtime resolution +resolve_frontend_runtime() { + local shell_bin + local candidate - if ! command -v bun >/dev/null 2>&1; then - log_error "未找到 bun,请先安装或加载 bun 到 PATH" + if [ -n "$FRONTEND_RUNTIME_BIN" ] && [ -x "$FRONTEND_RUNTIME_BIN" ]; then + return 0 + fi + + set_wait_detail "读取 zsh 配置中的 bun" + if resolve_bun_from_shell_configs "zsh" "$HOME/.zshrc" "$HOME/.zprofile"; then + WAIT_SPINNER_DETAIL="已找到 zsh 配置中的 bun" + return 0 + fi + + if command -v zsh >/dev/null 2>&1; then + set_wait_detail "检查 zsh 中的 bun" + candidate="$(zsh -ic 'command -v bun 2>/dev/null || true' 2>/dev/null | tail -n 1)" + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="zsh" + WAIT_SPINNER_DETAIL="已找到 zsh 中的 bun" + return 0 + fi + fi + + for shell_bin in pwsh powershell; do + if command -v "$shell_bin" >/dev/null 2>&1; then + set_wait_detail "检查 powershell 中的 bun" + candidate="$("$shell_bin" -Command "(Get-Command bun -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)" 2>/dev/null | tail -n 1 | tr -d '\r')" + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="powershell" + WAIT_SPINNER_DETAIL="已找到 powershell 中的 bun" + return 0 + fi + fi + done + + if command -v bun >/dev/null 2>&1; then + set_wait_detail "检查当前环境中的 bun" + candidate="$(command -v bun)" + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + FRONTEND_RUNTIME_BIN="$candidate" + FRONTEND_RUNTIME_SOURCE="current-shell" + WAIT_SPINNER_DETAIL="已找到当前环境中的 bun" + return 0 + fi + fi + + return 1 +} + +ensure_frontend_deps() { + local owns_wait_session=0 + + if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then + start_wait_session "检查前端依赖" + owns_wait_session=1 + else + set_wait_detail "检查前端依赖" + fi + + set_wait_detail "解析前端运行时" + if ! resolve_frontend_runtime; then + close_wait_session_context "$owns_wait_session" + log_error "未找到 bun,已按 zsh > powershell 顺序检查启动配置" exit 1 fi cd "$SCRIPT_DIR/frontend" + set_wait_detail "检查 vite 是否已安装" if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then - log_warn "前端依赖缺失,正在执行 bun install" + log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})" + set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "前端依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "bun install" \ - bun install; then + "$FRONTEND_RUNTIME_BIN" install; then exit 1 fi fi if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then + close_wait_session_context "$owns_wait_session" log_error "前端依赖安装失败,未找到 vite" exit 1 fi + + close_wait_session_context "$owns_wait_session" + if [ "$owns_wait_session" -eq 0 ]; then + resume_wait_session + fi } +# Health / readiness helpers wait_for_http() { local url="$1" local attempts="$2" local interval="$3" local service_name="$4" local attempt=1 + local wait_message="检查${service_name}健康检查" + local wait_detail_prefix="请求" + + set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})" while [ "$attempt" -le "$attempts" ]; do - if curl -s "$url" > /dev/null 2>&1; then + if curl -s --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1; then finish_wait_spinner "${service_name}已就绪" return 0 fi - animate_wait_spinner "等待${service_name}就绪" "$interval" + set_wait_detail "${wait_detail_prefix} ${url} (${attempt}/${attempts})" + animate_wait_spinner "${wait_message}" "$interval" attempt=$((attempt + 1)) done @@ -253,23 +473,127 @@ wait_for_http() { return 1 } +get_log_tail_line() { + local log_file="$1" + local fallback="$2" + local last_line="" + local filtered_line="" + + if [ -f "$log_file" ]; then + while IFS= read -r last_line; do + filtered_line="$(sanitize_wait_detail "$last_line")" + if [ -n "$filtered_line" ] && + [[ ! "$filtered_line" =~ ^\$[[:space:]] ]] && + [[ "$filtered_line" != "nohup: ignoring input" ]] && + [[ "$filtered_line" != The\ CJS\ build\ of\ Vite\'s\ Node\ API\ is\ deprecated.* ]]; then + printf "%s" "$filtered_line" + return 0 + fi + done < <(tail -n 20 "$log_file" 2>/dev/null | tac) + fi + + if [ -z "$last_line" ]; then + printf "%s" "$fallback" + return 0 + fi + + sanitize_wait_detail "$last_line" +} + +wait_for_frontend_ready() { + local frontend_port="$1" + local frontend_pid="$2" + local log_file="$3" + local attempts="$4" + local interval="$5" + local owns_wait_session=0 + local tick=0 + local max_ticks=$(( attempts * interval * WAIT_SPINNER_TICKS_PER_SECOND )) + local probe_every_ticks=$(( WAIT_SPINNER_TICKS_PER_SECOND / 2 )) + local detail_every_ticks=2 + local detail="" + + if [ "$max_ticks" -lt 1 ]; then + max_ticks=1 + fi + if [ "$probe_every_ticks" -lt 1 ]; then + probe_every_ticks=1 + fi + + if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then + start_wait_session "启动前端服务" + owns_wait_session=1 + else + set_wait_detail "启动 Vite 开发服务器" + fi + + while [ "$tick" -lt "$max_ticks" ]; do + WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + + if ! kill -0 "$frontend_pid" 2>/dev/null; then + detail="$(get_log_tail_line "$log_file" "前端进程已退出")" + WAIT_SPINNER_DETAIL="$detail" + clear_wait_spinner + if [ -n "$detail" ]; then + log_note "$detail" + fi + close_wait_session_context "$owns_wait_session" + return 1 + fi + + if [ $(( tick % detail_every_ticks )) -eq 0 ]; then + detail="$(get_log_tail_line "$log_file" "等待 Vite 输出启动日志")" + WAIT_SPINNER_DETAIL="$detail" + render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" + fi + + if [ $(( tick % probe_every_ticks )) -eq 0 ] && + curl -s --max-time "$HTTP_CHECK_MAX_TIME" "http://localhost:${frontend_port}" > /dev/null 2>&1; then + if [ "$owns_wait_session" -eq 1 ]; then + stop_wait_session + finish_wait_spinner "前端已就绪" + else + reset_wait_spinner_state + fi + return 0 + fi + + sleep 0.125 + tick=$((tick + 1)) + done + + WAIT_SPINNER_DETAIL="$(get_log_tail_line "$log_file" "等待前端就绪超时")" + clear_wait_spinner + if [ -n "$WAIT_SPINNER_DETAIL" ]; then + log_note "$WAIT_SPINNER_DETAIL" + fi + close_wait_session_context "$owns_wait_session" + return 1 +} + wait_for_container_health() { local container_name="$1" local attempts="$2" local interval="$3" local service_name="$4" local attempt=1 - local status + local health_status + local wait_message="检查${service_name}容器健康检查" + local wait_detail_prefix="读取" + + set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (1/${attempts})" while [ "$attempt" -le "$attempts" ]; do - status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" + health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" - if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then + if [ "$health_status" = "healthy" ] || [ "$health_status" = "running" ]; then finish_wait_spinner "${service_name}容器健康检查已通过" return 0 fi - animate_wait_spinner "等待${service_name}容器健康检查通过" "$interval" + set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (${attempt}/${attempts})" + animate_wait_spinner "${wait_message}" "$interval" attempt=$((attempt + 1)) done @@ -277,6 +601,24 @@ wait_for_container_health() { return 1 } +fail_unreleased_port() { + local port="$1" + + if [ -z "$(collect_port_pids "$port" || true)" ]; then + log_error "端口 ${port} 当前环境内未发现占用进程,但端口仍不可用,请检查宿主机或外部环境占用" + else + log_error "端口 ${port} 清理失败,请检查占用进程" + if command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"${port}" -sTCP:LISTEN -n -P 2>/dev/null || true + elif command -v ss >/dev/null 2>&1; then + ss -ltnp "( sport = :${port} )" 2>/dev/null || true + fi + fi + + exit 1 +} + +# Service startup helpers wait_for_database_health() { wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" && wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis" @@ -298,12 +640,17 @@ start_postgres_service() { docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1 } +# Backend lifecycle helpers +cleanup_backend_processes() { + pkill -f "uvicorn" 2>/dev/null || true +} + start_backend_with_retry() { local backend_port="$1" local retry=1 while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do - pkill -f "uvicorn" 2>/dev/null || true + cleanup_backend_processes cd "$SCRIPT_DIR/backend" PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 & BACKEND_PID=$! @@ -324,8 +671,7 @@ start_backend_with_retry() { start_ai_provider_service() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local retry=1 - - log_step "启动 AI Provider" + set_wait_detail "启动 AI Provider" while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do if docker start planet_aiprovider >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then @@ -351,30 +697,6 @@ start_ai_provider_service() { exit 1 } -start_frontend_with_retry() { - local frontend_port="$1" - local retry=1 - - while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do - pkill -f "vite" 2>/dev/null || true - pkill -f "bun run dev" 2>/dev/null || true - cd "$SCRIPT_DIR/frontend" - nohup bun run dev --port "$frontend_port" > /tmp/planet_frontend.log 2>&1 & - FRONTEND_PID=$! - - if wait_for_http "http://localhost:${frontend_port}" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL" "前端"; then - return 0 - fi - - kill "$FRONTEND_PID" 2>/dev/null || true - animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL" - retry=$((retry + 1)) - done - - clear_wait_spinner - return 1 -} - ensure_database_services_healthy() { local retry=1 @@ -418,8 +740,7 @@ ensure_postgres_service_healthy() { restart_database_service() { local retry=1 - - log_step "重启数据库" + set_wait_detail "重启数据库" while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do if restart_database_services && wait_for_database_health; then @@ -444,8 +765,9 @@ start_backend_service() { local backend_port_requested="$2" local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}" - log_step "启动数据库" + set_wait_detail "启动数据库" ensure_database_services_healthy + log_success "启动数据库已就绪" sleep 3 start_ai_provider_service "$ai_provider_port" @@ -454,7 +776,7 @@ start_backend_service() { kill_port_if_requested "$backend_port" "后端" fi - log_step "启动后端" + set_wait_detail "启动后端" ensure_uv_backend_deps if ! start_backend_with_retry "$backend_port"; then log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次" @@ -463,6 +785,210 @@ start_backend_service() { fi } +# Port / process helpers +validate_port() { + local port="$1" + + if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + log_error "非法端口: ${port}" + exit 1 + fi +} + +collect_port_pids() { + local port="$1" + local pids="" + local pid="" + + if command -v lsof >/dev/null 2>&1; then + pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [ -n "$pids" ]; then + printf "%s\n" "$pids" | awk '!seen[$0]++' + return 0 + fi + fi + + if command -v fuser >/dev/null 2>&1; then + pids="$(fuser "${port}/tcp" 2>/dev/null || true)" + if [ -n "$pids" ]; then + for pid in $pids; do + printf "%s\n" "$pid" + done | awk '!seen[$0]++' + return 0 + fi + fi + + if command -v ss >/dev/null 2>&1; then + pids="$(ss -ltnp "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p' || true)" + if [ -n "$pids" ]; then + printf "%s\n" "$pids" | awk '!seen[$0]++' + return 0 + fi + fi + + return 1 +} + +terminate_process_tree() { + local signal="$1" + local pid="$2" + local child_pid + + [ -n "$pid" ] || return 0 + kill -0 "$pid" 2>/dev/null || return 0 + + if command -v pgrep >/dev/null 2>&1; then + while IFS= read -r child_pid; do + [ -n "$child_pid" ] || continue + terminate_process_tree "$signal" "$child_pid" + done < <(pgrep -P "$pid" 2>/dev/null || true) + fi + + kill "-${signal}" "$pid" 2>/dev/null || true +} + +can_bind_port() { + local port="$1" + python3 - "$port" <<'PY' >/dev/null 2>&1 +import socket +import sys + +port = int(sys.argv[1]) +sockets = [] +try: + for family, host in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")): + try: + sock = socket.socket(family) + sock.bind((host, port)) + sockets.append(sock) + except OSError: + raise +finally: + for sock in sockets: + try: + sock.close() + except OSError: + pass +PY +} + +wait_for_port_release() { + local port="$1" + local attempt=1 + + while [ "$attempt" -le "$PORT_RELEASE_ATTEMPTS" ]; do + if can_bind_port "$port"; then + return 0 + fi + sleep "$PORT_RELEASE_INTERVAL" + attempt=$((attempt + 1)) + done + + return 1 +} + +kill_port_if_requested() { + local port="$1" + local service_name="$2" + local pids="" + local pid="" + + log_warn "检测 ${service_name} 端口 ${port} 占用" + + pids="$(collect_port_pids "$port" || true)" + + if [ -z "$pids" ] && can_bind_port "$port"; then + log_success "端口 ${port} 未被占用" + return 0 + fi + + log_step "发现端口 ${port} 占用,正在终止" + for pid in $pids; do + terminate_process_tree TERM "$pid" + done + + if wait_for_port_release "$port"; then + log_success "端口 ${port} 已释放" + return 0 + fi + + log_warn "端口 ${port} 仍被占用,正在强制终止" + pids="$(collect_port_pids "$port" || true)" + for pid in $pids; do + terminate_process_tree KILL "$pid" + done + + if wait_for_port_release "$port"; then + log_success "端口 ${port} 已释放" + return 0 + fi + + fail_unreleased_port "$port" +} + +frontend_log_indicates_port_conflict() { + local log_file="$1" + + [ -f "$log_file" ] || return 1 + grep -q "Port .* is already in use" "$log_file" 2>/dev/null +} + +# Frontend lifecycle helpers +cleanup_frontend_processes() { + local frontend_port="${1:-$DEFAULT_FRONTEND_PORT}" + local tracked_pid="" + + if [ -f "$FRONTEND_PID_FILE" ]; then + tracked_pid="$(cat "$FRONTEND_PID_FILE" 2>/dev/null || true)" + if [ -n "$tracked_pid" ]; then + terminate_process_tree TERM "$tracked_pid" + sleep 1 + terminate_process_tree KILL "$tracked_pid" + fi + rm -f "$FRONTEND_PID_FILE" + fi + + pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true + pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true + pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true + pkill -f "bun run dev" 2>/dev/null || true +} + +start_frontend_with_retry() { + local frontend_port="$1" + local frontend_port_requested="${2:-0}" + local retry=1 + + while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do + cleanup_frontend_processes "$frontend_port" + if ! wait_for_port_release "$frontend_port"; then + fail_unreleased_port "$frontend_port" + fi + cd "$SCRIPT_DIR/frontend" + : > /tmp/planet_frontend.log + nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 & + FRONTEND_PID=$! + printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE" + + if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "/tmp/planet_frontend.log" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then + return 0 + fi + + cleanup_frontend_processes "$frontend_port" + reset_wait_spinner_state + + if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "/tmp/planet_frontend.log"; then + kill_port_if_requested "$frontend_port" "前端" + fi + + animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL" + retry=$((retry + 1)) + done + + clear_wait_spinner + return 1 +} + start_frontend_service() { local frontend_port="$1" local frontend_port_requested="$2" @@ -471,51 +997,24 @@ start_frontend_service() { kill_port_if_requested "$frontend_port" "前端" fi - log_step "启动前端" + start_wait_session "检查前端依赖" ensure_frontend_deps - if ! start_frontend_with_retry "$frontend_port"; then + stop_wait_session + log_success "前端依赖已就绪" + + start_wait_session "启动前端服务" + set_wait_detail "启动 Vite 开发服务器" + if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then + stop_wait_session log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次" tail -10 /tmp/planet_frontend.log exit 1 fi + stop_wait_session + log_success "前端已就绪" } -validate_port() { - local port="$1" - - if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then - log_error "非法端口: ${port}" - exit 1 - fi -} - -kill_port_if_requested() { - local port="$1" - local service_name="$2" - - log_warn "检测 ${service_name} 端口 ${port} 占用" - - if command -v fuser >/dev/null 2>&1 && fuser "${port}/tcp" >/dev/null 2>&1; then - log_step "发现端口 ${port} 占用,正在终止" - fuser -k "${port}/tcp" >/dev/null 2>&1 || true - sleep 1 - return 0 - fi - - if command -v lsof >/dev/null 2>&1; then - local pids - pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)" - if [ -n "$pids" ]; then - log_step "发现端口 ${port} 占用,正在终止" - kill $pids 2>/dev/null || true - sleep 1 - return 0 - fi - fi - - log_success "端口 ${port} 未被占用" -} - +# Argument parsing / command helpers parse_service_args() { BACKEND_PORT="$DEFAULT_BACKEND_PORT" FRONTEND_PORT="$DEFAULT_FRONTEND_PORT" @@ -580,17 +1079,43 @@ cleanup_exit_containers() { fi } +stop_container_if_running() { + local container_name="$1" + local service_name="$2" + local container_status="" + + container_status="$(docker inspect --format '{{.State.Status}}' "$container_name" 2>/dev/null || true)" + if [ "$container_status" = "running" ]; then + docker stop "$container_name" >/dev/null 2>&1 || true + log_halt "${service_name}已停止" + fi +} + stop_backend_service() { - pkill -f "uvicorn" 2>/dev/null || true + if pgrep -f "uvicorn" >/dev/null 2>&1; then + cleanup_backend_processes + log_halt "后端服务已停止" + fi } stop_ai_provider_service() { - docker stop planet_aiprovider 2>/dev/null || true + stop_container_if_running "planet_aiprovider" "AI Provider" } stop_frontend_service() { - pkill -f "vite" 2>/dev/null || true - pkill -f "bun run dev" 2>/dev/null || true + if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then + cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" + if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then + cleanup_frontend_processes "$FRONTEND_PORT" + fi + log_halt "前端服务已停止" + return 0 + fi + + cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" + if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then + cleanup_frontend_processes "$FRONTEND_PORT" + fi } create_user() { @@ -698,21 +1223,21 @@ PY log_note "邮箱: ${email}" } +# Top-level commands start() { parse_service_args "$@" cleanup_exit_containers print_splash - log_step "启动智能星球计划" start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" - echo "" log_success "启动完成" - log_note "前端: http://localhost:${FRONTEND_PORT}" - log_note "后端: http://localhost:${BACKEND_PORT}" - log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}" + log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" + log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" + log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" + log_note "智能星球AI: http://localhost:${AI_PROVIDER_PORT}/chat" } stop() { @@ -720,7 +1245,8 @@ stop() { stop_backend_service stop_ai_provider_service stop_frontend_service - docker stop planet_postgres planet_redis 2>/dev/null || true + stop_container_if_running "planet_postgres" "PostgreSQL" + stop_container_if_running "planet_redis" "Redis" log_success "已停止" }