Files
planet/planet.sh

1392 lines
41 KiB
Bash
Executable File
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 zsh
set -e
SCRIPT_PATH="${(%):-%N}"
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
cd "$SCRIPT_DIR"
RED='\033[38;5;203m'
GREEN='\033[38;5;114m'
YELLOW='\033[38;5;221m'
BLUE='\033[38;5;111m'
CYAN='\033[38;5;117m'
WHITE='\033[1;37m'
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}"
BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}"
DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}"
DEPENDENCY_INSTALL_RETRY_INTERVAL="${DEPENDENCY_INSTALL_RETRY_INTERVAL:-5}"
AI_PROVIDER_HEALTH_CHECK_ATTEMPTS="${AI_PROVIDER_HEALTH_CHECK_ATTEMPTS:-10}"
AI_PROVIDER_HEALTH_CHECK_INTERVAL="${AI_PROVIDER_HEALTH_CHECK_INTERVAL:-2}"
AI_PROVIDER_START_MAX_RETRIES="${AI_PROVIDER_START_MAX_RETRIES:-3}"
AI_PROVIDER_RETRY_INTERVAL="${AI_PROVIDER_RETRY_INTERVAL:-5}"
DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}"
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 "$@"
else
docker-compose "$@"
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]}"
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() {
local message="$1"
local duration="${2:-1}"
local ticks=$(( duration * WAIT_SPINNER_TICKS_PER_SECOND ))
local tick=0
if [ "$ticks" -lt 1 ]; then
ticks=1
fi
while [ "$tick" -lt "$ticks" ]; do
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
render_wait_spinner "$message" "$WAIT_SPINNER_STEP"
sleep 0.125
tick=$((tick + 1))
done
}
clear_wait_spinner() {
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() {
log_line "run" "$BLUE" "$1"
}
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"
}
log_success() {
log_line "done" "$GREEN" "$1"
}
print_splash() {
clear_wait_spinner
printf "%b" "$CYAN"
cat <<'EOF'
___ _ _ _ _ _
|_ _|_ __ | |_ ___| | (_) __ _ ___ _ __ | |_
| || '_ \| __/ _ \ | | |/ _` |/ _ \ '_ \| __|
| || | | | || __/ | | | (_| | __/ | | | |_
|___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__|
|___/
EOF
printf "%b" "$BLUE"
cat <<'EOF'
____ _ _
| _ \| | __ _ _ __ ___ | |_
| |_) | |/ _` | '_ \ / _ \| __|
| __/| | (_| | | | | __/| |_
|_| |_|\__,_|_| |_|\___| \__|
EOF
printf "%b" "$WHITE"
cat <<'EOF'
____ _
| _ \| | __ _ _ __
| |_) | |/ _` | '_ \
| __/| | (_| | | | |
|_| |_|\__,_|_| |_|
EOF
printf "%b\n\n" "$NC"
}
# Retry / dependency helpers
run_with_retry() {
local max_retries="$1"
local interval="$2"
local failure_message="$3"
local retry_label="$4"
shift 4
local attempt=1
while [ "$attempt" -le "$max_retries" ]; do
if run_command_with_spinner "$retry_label" "$@"; then
WAIT_SPINNER_DETAIL=""
return 0
fi
if [ "$attempt" -eq "$max_retries" ]; then
log_error "${failure_message}"
return 1
fi
animate_wait_spinner "${retry_label}${attempt}/${max_retries} 次失败,${interval} 秒后重试" "$interval"
attempt=$((attempt + 1))
done
return 1
}
ensure_uv_backend_deps() {
set_wait_detail "检查后端 uv 环境"
if ! command -v uv >/dev/null 2>&1; then
log_error "未找到 uv请先安装 uv 并加入 PATH"
exit 1
fi
cd "$SCRIPT_DIR"
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
log_warn "未检测到 .venv正在执行 uv sync"
if ! run_with_retry \
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
"uv 环境初始化失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES}" \
"uv sync" \
uv sync --group dev; then
exit 1
fi
fi
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
log_error "uv 环境初始化失败,未找到 .venv/bin/python"
exit 1
fi
}
# Frontend runtime resolution
resolve_frontend_runtime() {
local shell_bin
local candidate
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 (${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" \
"$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 --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1; then
finish_wait_spinner "${service_name}已就绪"
return 0
fi
set_wait_detail "${wait_detail_prefix} ${url} (${attempt}/${attempts})"
animate_wait_spinner "${wait_message}" "$interval"
attempt=$((attempt + 1))
done
clear_wait_spinner
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 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
health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)"
if [ "$health_status" = "healthy" ] || [ "$health_status" = "running" ]; then
finish_wait_spinner "${service_name}容器健康检查已通过"
return 0
fi
set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (${attempt}/${attempts})"
animate_wait_spinner "${wait_message}" "$interval"
attempt=$((attempt + 1))
done
clear_wait_spinner
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"
}
wait_for_postgres_health() {
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL"
}
start_database_services() {
docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
}
restart_database_services() {
docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
}
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
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=$!
if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
return 0
fi
kill "$BACKEND_PID" 2>/dev/null || true
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
retry=$((retry + 1))
done
clear_wait_spinner
return 1
}
start_ai_provider_service() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
local retry=1
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
if wait_for_container_health "planet_aiprovider" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
return 0
fi
fi
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then
log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES}"
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
exit 1
fi
docker restart planet_aiprovider >/dev/null 2>&1 || true
animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL"
retry=$((retry + 1))
done
log_error "AI Provider 启动失败"
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
exit 1
}
ensure_database_services_healthy() {
local retry=1
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
if start_database_services && wait_for_database_health; then
return 0
fi
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES}"
docker logs --tail 20 planet_postgres 2>/dev/null || true
docker logs --tail 20 planet_redis 2>/dev/null || true
exit 1
fi
docker restart planet_postgres planet_redis >/dev/null 2>&1 || true
animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
retry=$((retry + 1))
done
}
ensure_postgres_service_healthy() {
local retry=1
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
if start_postgres_service && wait_for_postgres_health; then
return 0
fi
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES}"
docker logs --tail 20 planet_postgres 2>/dev/null || true
exit 1
fi
docker restart planet_postgres >/dev/null 2>&1 || true
animate_wait_spinner "PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
retry=$((retry + 1))
done
}
restart_database_service() {
local retry=1
set_wait_detail "重启数据库"
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
if restart_database_services && wait_for_database_health; then
sleep 3
return 0
fi
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES}"
docker logs --tail 20 planet_postgres 2>/dev/null || true
docker logs --tail 20 planet_redis 2>/dev/null || true
exit 1
fi
animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试" "$DATABASE_RETRY_INTERVAL"
retry=$((retry + 1))
done
}
start_backend_service() {
local backend_port="$1"
local backend_port_requested="$2"
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
set_wait_detail "启动数据库"
ensure_database_services_healthy
log_success "启动数据库已就绪"
sleep 3
start_ai_provider_service "$ai_provider_port"
if [ "$backend_port_requested" -eq 1 ]; then
kill_port_if_requested "$backend_port" "后端"
fi
set_wait_detail "启动后端"
ensure_uv_backend_deps
if ! start_backend_with_retry "$backend_port"; then
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES}"
tail -10 /tmp/planet_backend.log
exit 1
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"
if [ "$frontend_port_requested" -eq 1 ]; then
kill_port_if_requested "$frontend_port" "前端"
fi
start_wait_session "检查前端依赖"
ensure_frontend_deps
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 "前端已就绪"
}
# Argument parsing / command helpers
parse_service_args() {
BACKEND_PORT="$DEFAULT_BACKEND_PORT"
FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
AI_PROVIDER_PORT="$DEFAULT_AI_PROVIDER_PORT"
BACKEND_PORT_REQUESTED=0
FRONTEND_PORT_REQUESTED=0
AI_PROVIDER_REQUESTED=0
DATABASE_REQUESTED=0
while [ "$#" -gt 0 ]; do
case "$1" in
-b|--backend-port)
BACKEND_PORT_REQUESTED=1
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
BACKEND_PORT="$2"
shift 2
else
shift 1
fi
;;
-f|--frontend-port)
FRONTEND_PORT_REQUESTED=1
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
FRONTEND_PORT="$2"
shift 2
else
shift 1
fi
;;
-a|--ai-provider-port)
AI_PROVIDER_REQUESTED=1
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
AI_PROVIDER_PORT="$2"
shift 2
else
shift 1
fi
;;
-d|--database)
DATABASE_REQUESTED=1
shift 1
;;
*)
log_error "未知参数: $1"
exit 1
;;
esac
done
validate_port "$BACKEND_PORT"
validate_port "$FRONTEND_PORT"
validate_port "$AI_PROVIDER_PORT"
}
cleanup_exit_containers() {
local exit_containers
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
if [ -n "$exit_containers" ]; then
log_step "清理残留 Exit 容器"
echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
log_success "残留容器已清理"
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() {
if pgrep -f "uvicorn" >/dev/null 2>&1; then
cleanup_backend_processes
log_halt "后端服务已停止"
fi
}
stop_ai_provider_service() {
stop_container_if_running "planet_aiprovider" "AI Provider"
}
stop_frontend_service() {
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() {
local username
local password
local password_confirm
local email
local generated_email
local role="viewer"
local is_admin
ensure_uv_backend_deps
log_step "启动数据库"
ensure_postgres_service_healthy
sleep 2
log_step "创建用户"
read -r -p "用户名: " username
if [ -z "$username" ]; then
log_error "用户名不能为空"
exit 1
fi
generated_email="${username}@planet.local"
read -r -p "邮箱(留空自动使用 ${generated_email}): " email
if [ -z "$email" ]; then
email="$generated_email"
fi
read -r -s -p "密码: " password
echo ""
read -r -s -p "确认密码: " password_confirm
echo ""
if [ -z "$password" ]; then
log_error "密码不能为空"
exit 1
fi
if [ "${#password}" -lt 8 ]; then
log_error "密码长度不能少于 8 位"
exit 1
fi
if [ "$password" != "$password_confirm" ]; then
log_error "两次输入的密码不一致"
exit 1
fi
read -r -p "是否为管理员? (Y/N): " is_admin
if [[ "$is_admin" =~ ^[Yy]$ ]]; then
role="super_admin"
fi
cd "$SCRIPT_DIR/backend"
PLANET_CREATEUSER_USERNAME="$username" \
PLANET_CREATEUSER_PASSWORD="$password" \
PLANET_CREATEUSER_EMAIL="$email" \
PLANET_CREATEUSER_ROLE="$role" \
PYTHONPATH="$SCRIPT_DIR/backend" \
"$SCRIPT_DIR/.venv/bin/python" - <<'PY'
import asyncio
import os
from sqlalchemy import text
from app.core.security import get_password_hash
from app.db.session import async_session_factory
from app.models.user import User
async def main():
username = os.environ["PLANET_CREATEUSER_USERNAME"].strip()
password = os.environ["PLANET_CREATEUSER_PASSWORD"]
email = os.environ["PLANET_CREATEUSER_EMAIL"].strip()
role = os.environ["PLANET_CREATEUSER_ROLE"].strip()
async with async_session_factory() as session:
result = await session.execute(
text("SELECT id FROM users WHERE username = :username OR email = :email"),
{"username": username, "email": email},
)
if result.fetchone():
raise SystemExit("用户名已存在")
user = User(
username=username,
email=email,
password_hash=get_password_hash(password),
role=role,
is_active=True,
)
session.add(user)
await session.commit()
asyncio.run(main())
PY
log_success "用户创建成功"
log_note "用户名: ${username}"
log_note "角色: ${role}"
log_note "邮箱: ${email}"
}
# Top-level commands
start() {
parse_service_args "$@"
cleanup_exit_containers
print_splash
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
log_success "启动完成"
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
}
stop() {
log_warn "停止服务"
stop_backend_service
stop_ai_provider_service
stop_frontend_service
stop_container_if_running "planet_postgres" "PostgreSQL"
stop_container_if_running "planet_redis" "Redis"
log_success "已停止"
}
restart() {
parse_service_args "$@"
cleanup_exit_containers
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop
sleep 1
start
return 0
fi
log_warn "按需重启服务"
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
restart_database_service
fi
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
stop_ai_provider_service
sleep 1
start_ai_provider_service "$AI_PROVIDER_PORT"
fi
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
stop_backend_service
sleep 1
start_backend_service "$BACKEND_PORT" 1 "$AI_PROVIDER_PORT"
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
stop_frontend_service
sleep 1
start_frontend_service "$FRONTEND_PORT" 1
fi
echo ""
log_success "重启完成"
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
log_note "数据库: planet_postgres, planet_redis"
fi
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}"
fi
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
log_note "后端: http://localhost:${BACKEND_PORT}"
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
log_note "前端: http://localhost:${FRONTEND_PORT}"
fi
}
health() {
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}"
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
echo ""
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}"
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} 后端:${NC} ${RED}offline${NC}"
fi
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}"
fi
if curl -s http://localhost:3000 > /dev/null 2>&1; then
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
fi
}
log() {
case "$1" in
-f|--frontend)
log_step "查看前端日志"
log_note "按 Ctrl+C 退出"
tail -f /tmp/planet_frontend.log
;;
-b|--backend)
log_step "查看后端日志"
log_note "按 Ctrl+C 退出"
tail -f /tmp/planet_backend.log
;;
-a|--ai-provider)
log_step "查看 AI Provider 日志"
log_note "按 Ctrl+C 退出"
docker logs -f planet_aiprovider
;;
*)
log_step "查看最近日志"
log_note "后端"
tail -20 /tmp/planet_backend.log 2>/dev/null || log_note "无日志"
log_note "AI Provider"
docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志"
log_note "前端"
tail -20 /tmp/planet_frontend.log 2>/dev/null || log_note "无日志"
;;
esac
}
case "$1" in
start)
shift
start "$@"
;;
stop)
stop
;;
restart)
shift
restart "$@"
;;
createuser)
create_user
;;
health)
health
;;
log)
log "$2"
;;
*)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>"
log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
log_note "createuser 交互创建用户"
log_note "health 检查健康状态"
log_note "log 查看日志"
log_note "log -f 查看前端日志"
log_note "log -b 查看后端日志"
log_note "log -a 查看 AI Provider 日志"
;;
esac