#!/usr/bin/env zsh set -e set -o pipefail SCRIPT_PATH="${(%):-%N}" SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" cd "$SCRIPT_DIR" load_user_zshrc_exported_env() { local load_mode="${PLANET_LOAD_ZSHRC_ENV:-1}" [ "$load_mode" != "0" ] || return 0 [ "$load_mode" != "false" ] || return 0 [ -f "$HOME/.zshrc" ] || return 0 local env_line env_name env_value if [ "$load_mode" = "source" ]; then command -v zsh >/dev/null 2>&1 || return 0 while IFS= read -r env_line; do env_name="${env_line%%=*}" env_value="${env_line#*=}" case "$env_name" in AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy) export "${env_name}=${env_value}" ;; esac done < <(zsh -fc 'source ~/.zshrc >/dev/null 2>&1; env' 2>/dev/null || true) return 0 fi while IFS= read -r env_line; do env_line="$(printf "%s" "$env_line" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')" case "$env_line" in ""|\#*) continue ;; export\ *) env_line="${env_line#export }" ;; esac env_name="${env_line%%=*}" env_value="${env_line#*=}" [ "$env_name" != "$env_value" ] || continue env_name="$(printf "%s" "$env_name" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')" env_value="$(printf "%s" "$env_value" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//;s/^"//;s/"$//;s/^'\''//;s/'\''$//')" case "$env_name" in AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy) export "${env_name}=${env_value}" ;; esac done < "$HOME/.zshrc" } load_user_zshrc_exported_env 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 PORT_CHECK_STATUS_ACTIVE=0 VERBOSE=0 WAIT_VERBOSE_LOG_FILE="" WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}" WAIT_VERBOSE_TAIL_LINE_COUNT="${WAIT_VERBOSE_TAIL_LINE_COUNT:-40}" WAIT_VERBOSE_LAST_LOG_FILE="" WAIT_VERBOSE_EMITTED_LINES=0 HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-2}" BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-60}" 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}" DATABASE_HEALTH_CHECK_ATTEMPTS="${DATABASE_HEALTH_CHECK_ATTEMPTS:-10}" DATABASE_HEALTH_CHECK_INTERVAL="${DATABASE_HEALTH_CHECK_INTERVAL:-$DATABASE_RETRY_INTERVAL}" 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:-15}" PORT_RELEASE_INTERVAL="${PORT_RELEASE_INTERVAL:-0.2}" PORT_PRESTART_RETRIES="${PORT_PRESTART_RETRIES:-3}" PORT_PRESTART_RETRY_INTERVAL="${PORT_PRESTART_RETRY_INTERVAL:-2}" DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}" DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}" DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}" DEFAULT_MOTION_AGENT_PORT="${DEFAULT_MOTION_AGENT_PORT:-8765}" FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}" FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}" PLANET_STATE_DIR="${PLANET_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/planet}" PLANET_CACHE_DIR="${PLANET_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/planet}" BACKEND_PID_FILE="$PLANET_STATE_DIR/backend.pid" BACKEND_LOG_FILE="$PLANET_STATE_DIR/backend.log" FRONTEND_PID_FILE="$PLANET_STATE_DIR/frontend.pid" FRONTEND_LOG_FILE="$PLANET_STATE_DIR/frontend.log" PLANET_PORT_STATE_FILE="$PLANET_STATE_DIR/ports.env" FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js" MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid" MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log" AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256" AI_PROVIDER_BUILD_LOG_FILE="$PLANET_STATE_DIR/aiprovider_build.log" AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}" AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}" PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-$PLANET_STATE_DIR/aiprovider_runtime.env}" AI_PROVIDER_RECREATE_REQUIRED=0 START_RUN_ACTIVE=0 START_RUN_COMPLETED=0 STARTED_BACKEND_THIS_RUN=0 STARTED_FRONTEND_THIS_RUN=0 STARTED_MOTION_AGENT_THIS_RUN=0 AI_PROVIDER_RUNTIME_ENV_NAMES=( SERVICE_NAME SERVICE_VERSION AI_PROVIDER AI_PROVIDER_API AI_BASE_URL AI_API_KEY AI_MODEL AI_TIMEOUT_SECONDS AI_HTTP_RETRY_ATTEMPTS AI_MAX_TOKENS AI_ANTHROPIC_VERSION AI_ANALYSIS_SYSTEM_PROMPT AI_PROVIDER_SERVICE_TOKEN ) mkdir -p "$PLANET_STATE_DIR" "$PLANET_CACHE_DIR" chmod 700 "$PLANET_STATE_DIR" "$PLANET_CACHE_DIR" 2>/dev/null || true prepend_path_once() { local path_entry="$1" [ -n "$path_entry" ] || return 0 [ -d "$path_entry" ] || return 0 case ":$PATH:" in *":$path_entry:"*) ;; *) export PATH="$path_entry:$PATH" ;; esac } ensure_local_runtime_bins_on_path() { prepend_path_once "$HOME/.local/bin" prepend_path_once "$HOME/.bun/bin" } ensure_local_runtime_bins_on_path # Shell / Docker helpers is_pid() { [[ "${1:-}" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ] } is_safe_signal() { case "${1:-}" in TERM|KILL|INT|HUP) return 0 ;; *) return 1 ;; esac } read_pid_file() { local pid_file="$1" local pid="" [ -f "$pid_file" ] || return 1 pid="$(head -n 1 "$pid_file" 2>/dev/null | tr -d '[:space:]' || true)" is_pid "$pid" || return 1 printf "%s\n" "$pid" } write_pid_file() { local pid_file="$1" local pid="$2" is_pid "$pid" || return 1 mkdir -p "$(dirname "$pid_file")" printf "%s\n" "$pid" > "$pid_file" chmod 600 "$pid_file" 2>/dev/null || true } remove_pid_file() { local pid_file="$1" rm -f "$pid_file" } http_ok() { local url="$1" curl -fsS --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1 } log_matches() { local log_file="$1" shift [ -f "$log_file" ] || return 1 [ "$#" -gt 0 ] || return 1 grep -Eiq "$*" "$log_file" 2>/dev/null } read_port_state_value() { local key="$1" local fallback="$2" local value="" if [ -f "$PLANET_PORT_STATE_FILE" ]; then value="$(sed -nE "s/^${key}=([0-9]+)$/\\1/p" "$PLANET_PORT_STATE_FILE" | tail -n 1)" fi if [ -n "$value" ]; then printf "%s" "$value" else printf "%s" "$fallback" fi } write_port_state() { local backend_port="$1" local frontend_port="$2" local ai_provider_port="$3" local motion_agent_port="$4" validate_port "$backend_port" validate_port "$frontend_port" validate_port "$ai_provider_port" validate_port "$motion_agent_port" { printf "BACKEND_PORT=%s\n" "$backend_port" printf "FRONTEND_PORT=%s\n" "$frontend_port" printf "AI_PROVIDER_PORT=%s\n" "$ai_provider_port" printf "MOTION_AGENT_PORT=%s\n" "$motion_agent_port" } > "$PLANET_PORT_STATE_FILE" chmod 600 "$PLANET_PORT_STATE_FILE" 2>/dev/null || true } compose_available() { docker compose version >/dev/null 2>&1 } compose_v1_available() { command -v docker-compose >/dev/null 2>&1 } report_missing_compose() { clear_wait_spinner log_error "未检测到可用的 Docker Compose" log_note '优先使用 `docker compose`' log_note "如果当前机器仍只装有旧版 `docker-compose`,也请确认该命令可执行" log_note "推荐安装 Docker Compose 插件:" log_note ' mkdir -p ~/.docker/cli-plugins' log_note ' curl -SL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose' log_note ' chmod +x ~/.docker/cli-plugins/docker-compose' log_note ' docker compose version' return 1 } buildx_version() { docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//' } buildx_meets_minimum() { local current_version="$1" local required_version="$2" [ -n "$current_version" ] || return 1 [ -n "$required_version" ] || return 1 if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then return 0 fi return 1 } log_buildx_diagnostics_if_needed() { local current_buildx_version="" local required_buildx_version="0.17.0" current_buildx_version="$(buildx_version)" if [ -z "$current_buildx_version" ]; then log_note "未检测到 docker buildx,AI Provider 镜像构建依赖 buildx。" log_note "请先执行: docker buildx version" return 0 fi if ! buildx_meets_minimum "$current_buildx_version" "$required_buildx_version"; then log_note "检测到 docker buildx 当前版本为 v${current_buildx_version},但 compose build 需要 v${required_buildx_version} 或更高。" log_note "可先升级 buildx 插件,再重新执行 ./planet.sh start" log_note "请先执行: docker buildx version" fi } compose_up() { local args=("$@") local arg_text="$*" if compose_available; then set_wait_detail "正在用 docker compose 执行: ${arg_text}" if docker compose "${args[@]}"; then return 0 fi log_warn "docker compose 执行失败,回退到 docker-compose v1" fi if compose_v1_available; then set_wait_detail "正在用 docker-compose v1 执行: ${arg_text}" if docker-compose "${args[@]}"; then return 0 fi clear_wait_spinner log_error "docker compose 与 docker-compose v1 均执行失败" log_note "最后尝试的命令: docker-compose ${arg_text}" return 1 fi report_missing_compose } compose_supports_build() { if compose_available; then docker compose build --help >/dev/null 2>&1 && return 0 fi if compose_v1_available; then docker-compose build --help >/dev/null 2>&1 && return 0 fi return 1 } # 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:- }" flush_wait_verbose_log printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2 printf "\033[1A\r" >&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() { local line_count=0 printf "\r\033[K" >&2 line_count=0 while [ "$line_count" -lt 1 ]; do printf "\n\033[K" >&2 line_count=$((line_count + 1)) done printf "\033[1A\r" >&2 flush_wait_verbose_log } 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="" WAIT_VERBOSE_LOG_FILE="" WAIT_VERBOSE_LAST_LOG_FILE="" WAIT_VERBOSE_EMITTED_LINES=0 } 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" } flush_wait_verbose_log() { local line="" local line_number=0 local total_lines=0 [ "$VERBOSE" -eq 1 ] || return 0 [ -n "$WAIT_VERBOSE_LOG_FILE" ] || return 0 [ -f "$WAIT_VERBOSE_LOG_FILE" ] || return 0 if [ "$WAIT_VERBOSE_LAST_LOG_FILE" != "$WAIT_VERBOSE_LOG_FILE" ]; then WAIT_VERBOSE_LAST_LOG_FILE="$WAIT_VERBOSE_LOG_FILE" WAIT_VERBOSE_EMITTED_LINES=0 fi total_lines="$(wc -l < "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null | tr -d '[:space:]')" [ -n "$total_lines" ] || total_lines=0 if [ "$total_lines" -lt "$WAIT_VERBOSE_EMITTED_LINES" ]; then WAIT_VERBOSE_EMITTED_LINES=0 fi [ "$total_lines" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || return 0 printf "\r\033[K" >&2 while IFS= read -r line; do line_number=$((line_number + 1)) [ "$line_number" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || continue line="$(sanitize_wait_detail "$line")" [ -n "$line" ] || continue printf "${DIM} %s${NC}\n" "$line" >&2 done < "$WAIT_VERBOSE_LOG_FILE" WAIT_VERBOSE_EMITTED_LINES="$total_lines" } 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 local verbose_log_file="" if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then set_wait_detail "$message" fi if [ "$VERBOSE" -eq 1 ]; then verbose_log_file="$(mktemp "$PLANET_STATE_DIR/verbose.XXXXXX.log")" WAIT_VERBOSE_LOG_FILE="$verbose_log_file" "$@" > "$verbose_log_file" 2>&1 & else "$@" & 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=$? if [ "$VERBOSE" -eq 1 ] && [ -n "$verbose_log_file" ]; then WAIT_VERBOSE_LOG_FILE="$verbose_log_file" fi return "$exit_code" } run_command_quiet_unless_verbose() { local log_file="$1" shift if [ "$VERBOSE" -eq 1 ]; then "$@" return $? fi "$@" > "$log_file" 2>&1 } 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_timestamp() { date '+%Y-%m-%d %H:%M:%S' } log_line() { local label="$1" local color="$2" local message="$3" clear_wait_spinner printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC} ${DIM}%s${NC}\n" "$label" "$message" "$(log_timestamp)" 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() { if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then clear_wait_spinner fi printf "${DIM} %s${NC}\n" "$1" if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then resume_wait_session fi } log_success() { log_line "done" "$GREEN" "$1" } get_recommended_lan_ipv4() { local candidate while read -r candidate; do [ -z "$candidate" ] && continue case "$candidate" in 127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*) continue ;; 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*) printf "%s" "$candidate" return 0 ;; esac done </dev/null | tr ' ' '\n') EOF return 1 } log_lan_access_notes() { local frontend_port="$1" local backend_port="$2" local ai_provider_port="${3:-$AI_PROVIDER_PORT}" local recommended_lan_ip="" if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}" log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health" log_note "AI Provider 健康检查: http://${recommended_lan_ip}:${ai_provider_port}/health" else log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}" log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health" log_note "AI Provider 已对局域网开放,请使用本机局域网 IP 访问 :${ai_provider_port}/health" fi } run_windows_admin_powershell_script() { local script_path="$1" local warn_context="$2" local script_win_path="" chmod 600 "$script_path" 2>/dev/null || true script_win_path="$(wslpath -w "$script_path" 2>/dev/null || true)" if [ -z "$script_win_path" ]; then log_warn "${warn_context},但无法生成管理员 PowerShell 请求" return 1 fi powershell.exe -NoProfile -Command "Start-Process powershell.exe -Verb RunAs -Wait -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"${script_win_path}\"'" >/dev/null 2>&1 } join_csv() { local IFS="," printf "%s" "$*" } windows_firewall_missing_ports() { local ports_csv command -v powershell.exe >/dev/null 2>&1 || return 1 [ "$#" -gt 0 ] || return 0 ports_csv="$(join_csv "$@")" powershell.exe -NoProfile -Command " \$ErrorActionPreference = 'SilentlyContinue' \$ports = @(${ports_csv}) function Test-PortAllowed { param([int]\$Port) \$displayName = 'WSL Planet ' + \$Port \$rule = Get-NetFirewallRule -DisplayName \$displayName -ErrorAction SilentlyContinue | Where-Object { \$_.Enabled -eq 'True' -and \$_.Direction -eq 'Inbound' -and \$_.Action -eq 'Allow' } | Select-Object -First 1 if (-not \$rule) { return \$false } \$filter = \$rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue | Where-Object { \$_.Protocol -in @('TCP', 'Any') -and \$_.LocalPort -eq [string]\$Port } | Select-Object -First 1 if (\$filter) { return \$true } return \$false } \$missing = @() foreach (\$port in \$ports) { if (-not (Test-PortAllowed \$port)) { \$missing += \$port } } if (\$missing.Count -gt 0) { 'missing=' + (\$missing -join ',') } " 2>/dev/null | tr -d '\r' | sed -nE 's/^missing=//p' | tail -n 1 } request_windows_firewall_rules_if_needed() { local missing_ports="" local script_path="" [ "$#" -gt 0 ] || return 0 is_wsl_environment || return 0 command -v powershell.exe >/dev/null 2>&1 || return 0 case "${PLANET_WINDOWS_FIREWALL_AUTO_PROMPT:-1}" in 0|false|no|off) return 0 ;; esac missing_ports="$(windows_firewall_missing_ports "$@" || true)" [ -n "$missing_ports" ] || return 0 script_path="$PLANET_STATE_DIR/windows_firewall_planet.ps1" mkdir -p "$PLANET_STATE_DIR" cat > "$script_path" </dev/null 2>&1 || return 1 [ "$#" -gt 0 ] || return 0 ports_csv="$(join_csv "$@")" powershell.exe -NoProfile -Command " \$ErrorActionPreference = 'SilentlyContinue' \$ports = @(${ports_csv}) \$netsh = Join-Path \$env:WINDIR 'System32\netsh.exe' if (-not (Test-Path \$netsh)) { \$netsh = 'netsh' } \$lines = @(& \$netsh interface portproxy show v4tov4) \$found = @() foreach (\$line in \$lines) { if (\$line -match '^\\s*(\\S+)\\s+(\\d+)\\s+(\\S+)\\s+(\\d+)\\s*$') { \$listenPort = [int]\$Matches[2] if (\$ports -contains \$listenPort) { \$found += \$listenPort } } } if (\$found.Count -gt 0) { 'conflict=' + ((\$found | Sort-Object -Unique) -join ',') } " 2>/dev/null | tr -d '\r' | sed -nE 's/^conflict=//p' | tail -n 1 } write_windows_portproxy_cleanup_script() { local script_path="$1" shift local ports_csv ports_csv="$(join_csv "$@")" cat > "$script_path" </dev/null 2>&1 || return 0 case "${PLANET_WINDOWS_PORTPROXY_CLEANUP_PROMPT:-1}" in 0|false|no|off) return 0 ;; esac conflict_ports="$(windows_portproxy_conflict_ports "$@" || true)" [ -n "$conflict_ports" ] || return 0 script_path="$PLANET_STATE_DIR/windows_portproxy_cleanup_planet.ps1" mkdir -p "$PLANET_STATE_DIR" write_windows_portproxy_cleanup_script "$script_path" "$@" log_warn "检测到旧 Windows portproxy 占用端口 ${conflict_ports},即将请求管理员 PowerShell 删除" run_windows_admin_powershell_script "$script_path" "检测到旧 portproxy 占用端口 ${conflict_ports}" || { log_warn "管理员 PowerShell 未完成旧 portproxy 清理;端口 ${conflict_ports} 可能仍被 iphlpsvc 占用" return 1 } } write_windows_port_cleanup_script() { local script_path="$1" shift local ports_csv ports_csv="$(join_csv "$@")" cat > "$script_path" </dev/null 2>&1 || return 1 ports_label="$(join_csv "$@")" mkdir -p "$PLANET_STATE_DIR" write_windows_port_cleanup_script "$script_path" "$@" if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}" else log_warn "即将请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}" fi run_windows_admin_powershell_script "$script_path" "清理 Windows 侧端口 ${ports_label}" || { if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定" else log_warn "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定" fi return 1 } } log_motion_agent_access_notes() { local motion_agent_port="$1" local lan_enabled="${2:-0}" local recommended_lan_ip="" log_note "Motion Agent 本机: ws://127.0.0.1:${motion_agent_port}/ws/gestures" if [ "$lan_enabled" -eq 1 ]; then if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then log_note "Motion Agent 局域网: ws://${recommended_lan_ip}:${motion_agent_port}/ws/gestures" log_note "Earth 局域网页面可追加: ?motion=1&motionAgent=ws://${recommended_lan_ip}:${motion_agent_port}/ws/gestures" else log_note "Motion Agent 已对局域网开放,请使用本机局域网 IP 访问 :${motion_agent_port}/ws/gestures" fi fi } 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} 本次执行失败,${interval} 秒后进行第 $((attempt + 1)) 次尝试" "$interval" attempt=$((attempt + 1)) done return 1 } format_wait_elapsed_seconds() { local completed_intervals="${1:-0}" local interval="${2:-1}" awk -v completed="$completed_intervals" -v interval="$interval" ' BEGIN { value = completed * interval if (value == int(value)) { printf "%d", value } else { printf "%.1f", value } } ' } docker_daemon_available() { docker info >/dev/null 2>&1 } log_docker_daemon_diagnostics() { local docker_service_status="" local docker_socket_status="" local service_hint="sudo systemctl enable --now docker.socket && sudo systemctl restart docker" docker_service_status="$(systemctl is-active docker 2>/dev/null || true)" docker_socket_status="$(systemctl is-active docker.socket 2>/dev/null || true)" if [ -S /var/run/docker.sock ] && [ ! -r /var/run/docker.sock ]; then log_note "检测到 /var/run/docker.sock 存在,但当前用户无法访问。" log_note "请确认当前用户已加入 docker 组,并重新登录会话。" return 0 fi if [ "$docker_service_status" = "active" ]; then log_note "Docker CLI 可执行,但当前会话仍无法连接 daemon。" log_note "请先执行: docker info" return 0 fi if [ "$docker_service_status" = "failed" ] || [ "$docker_socket_status" = "failed" ]; then log_note "检测到 Docker daemon 当前未正常运行。" log_note "可先执行: ${service_hint}" log_note "如仍失败,继续查看: journalctl -u docker.service -n 50 --no-pager" return 0 fi if [ "$docker_socket_status" != "active" ]; then log_note "检测到 Docker socket 未激活,daemon 可能无法通过 systemd socket activation 启动。" log_note "可先执行: ${service_hint}" return 0 fi log_note "当前无法连接 Docker daemon,请先执行: docker info" log_note "若 daemon 未启动,可尝试: ${service_hint}" } print_database_failure_diagnostics() { if ! docker_daemon_available; then log_note "数据库容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。" log_docker_daemon_diagnostics return 0 fi docker logs --tail 20 planet_postgres 2>/dev/null || true docker logs --tail 20 planet_redis 2>/dev/null || true } compute_ai_provider_build_fingerprint() { ( cd "$SCRIPT_DIR" || exit 1 { find aiprovider \ -type f \ ! -path '*/__pycache__/*' \ ! -name '.env' \ ! -name '.env.*' \ ! -name '*.pyc' \ ! -name '*.pyo' \ | LC_ALL=C sort \ | xargs -r stat --format="%Y %s %n" 2>/dev/null sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null } ) | sha256sum | awk '{print $1}' } read_ai_provider_build_stamp() { [ -f "$AI_PROVIDER_BUILD_STAMP_FILE" ] || return 1 tr -d '[:space:]' < "$AI_PROVIDER_BUILD_STAMP_FILE" } write_ai_provider_build_stamp() { local fingerprint="$1" mkdir -p "$(dirname "$AI_PROVIDER_BUILD_STAMP_FILE")" printf "%s\n" "$fingerprint" > "$AI_PROVIDER_BUILD_STAMP_FILE" } ai_provider_image_exists() { docker image inspect "$AI_PROVIDER_IMAGE_NAME" >/dev/null 2>&1 } list_ai_provider_containers() { docker ps -aq --filter "name=planet_aiprovider" 2>/dev/null || true } remove_ai_provider_containers() { local container_ids container_ids="$(list_ai_provider_containers)" [ -n "$container_ids" ] || return 0 printf "%s\n" "$container_ids" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true } run_ai_provider_container_manually() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local env_file_args=() if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env") fi if [ -s "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" ]; then env_file_args+=(--env-file "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE") fi docker run -d \ --name "$AI_PROVIDER_CONTAINER_NAME" \ "${env_file_args[@]}" \ -p "${ai_provider_port}:8010" \ "$AI_PROVIDER_IMAGE_NAME" >/dev/null } write_ai_provider_runtime_env_file() { local env_name env_value : > "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" chmod 600 "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" 2>/dev/null || true for env_name in "${AI_PROVIDER_RUNTIME_ENV_NAMES[@]}"; do if [ -n "${(P)env_name+x}" ]; then env_value="${(P)env_name}" printf "%s=%s\n" "$env_name" "$env_value" >> "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" fi done export PLANET_AI_PROVIDER_RUNTIME_ENV_FILE } recreate_ai_provider_container() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" remove_ai_provider_containers if compose_up up -d aiprovider >/dev/null 2>&1; then return 0 fi run_ai_provider_container_manually "$ai_provider_port" } ensure_ai_provider_image_current() { local current_fingerprint="" local previous_fingerprint="" current_fingerprint="$(compute_ai_provider_build_fingerprint)" previous_fingerprint="$(read_ai_provider_build_stamp || true)" if ai_provider_image_exists && [ -n "$previous_fingerprint" ] && [ "$current_fingerprint" = "$previous_fingerprint" ]; then AI_PROVIDER_RECREATE_REQUIRED=0 return 0 fi set_wait_detail "检测到 AI Provider 代码或配置变化,重建镜像" if ! compose_supports_build; then log_error "当前 docker compose 不支持 build,请检查 Docker / Compose 环境" exit 1 fi : > "$AI_PROVIDER_BUILD_LOG_FILE" if ! build_ai_provider_image_with_fallback; then clear_wait_spinner log_error "AI Provider 镜像构建失败" tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true log_buildx_diagnostics_if_needed exit 1 fi log_success "AI Provider 镜像已构建" write_ai_provider_build_stamp "$current_fingerprint" AI_PROVIDER_RECREATE_REQUIRED=1 } build_ai_provider_image_with_fallback() { if compose_available; then set_wait_detail "使用 docker compose 构建 AI Provider 镜像" if run_ai_provider_build_command "docker compose"; then return 0 fi if compose_v1_available; then log_warn "docker compose 构建失败,回退到 docker-compose v1" else log_warn "docker compose 构建失败,当前环境未安装 docker-compose v1,无法继续回退" return 1 fi fi if compose_v1_available; then set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像" if run_ai_provider_build_command "docker-compose"; then return 0 fi return 1 fi report_missing_compose } run_ai_provider_build_command() { local compose_command="$1" if [ "$VERBOSE" -eq 1 ]; then run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider 2>&1 | tee \"$AI_PROVIDER_BUILD_LOG_FILE\"" return $? fi run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1" } install_uv_if_needed() { if command -v uv >/dev/null 2>&1; then return 0 fi set_wait_detail "未找到 uv,准备自动安装" mkdir -p "$HOME/.local/bin" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "uv 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "安装 uv" \ sh -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'; then exit 1 fi ensure_local_runtime_bins_on_path if ! command -v uv >/dev/null 2>&1; then log_error "uv 安装完成后仍未找到命令,请检查 ~/.local/bin" exit 1 fi } install_bun_if_needed() { if command -v bun >/dev/null 2>&1 || [ -x "$HOME/.bun/bin/bun" ]; then ensure_local_runtime_bins_on_path return 0 fi set_wait_detail "未找到 bun,准备自动安装" mkdir -p "$HOME/.bun" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "bun 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "安装 bun" \ sh -c 'curl -fsSL https://bun.sh/install | bash'; then exit 1 fi ensure_local_runtime_bins_on_path if [ ! -x "$HOME/.bun/bin/bun" ] && ! command -v bun >/dev/null 2>&1; then log_error "bun 安装完成后仍未找到命令,请检查 ~/.bun/bin" exit 1 fi } ensure_uv_backend_deps() { local log_file="$PLANET_STATE_DIR/uv_sync.log" set_wait_detail "检查后端 uv 环境" if ! command -v uv >/dev/null 2>&1; then install_uv_if_needed fi cd "$SCRIPT_DIR" : > "$log_file" 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" \ run_command_quiet_unless_verbose "$log_file" uv sync --group dev; then tail -20 "$log_file" 2>/dev/null || true exit 1 fi fi if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then log_error "uv 环境初始化失败,未找到 .venv/bin/python" exit 1 fi } ensure_python_runtime() { local log_file="$PLANET_STATE_DIR/uv_python_install.log" ensure_local_runtime_bins_on_path if ! command -v uv >/dev/null 2>&1; then install_uv_if_needed fi cd "$SCRIPT_DIR" : > "$log_file" set_wait_detail "安装 Python 3.14 运行时" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "Python 3.14 运行时安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "uv python install 3.14" \ run_command_quiet_unless_verbose "$log_file" uv python install 3.14; then tail -20 "$log_file" 2>/dev/null || true exit 1 fi } sync_python_deps() { local log_file="$PLANET_STATE_DIR/uv_sync.log" ensure_local_runtime_bins_on_path if ! command -v uv >/dev/null 2>&1; then install_uv_if_needed fi cd "$SCRIPT_DIR" : > "$log_file" set_wait_detail "同步 Python 依赖" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "uv 环境初始化失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "uv sync" \ run_command_quiet_unless_verbose "$log_file" uv sync --group dev; then tail -20 "$log_file" 2>/dev/null || true exit 1 fi if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then log_error "uv 环境初始化失败,未找到 .venv/bin/python" exit 1 fi } motion_agent_live_deps_ready() { "$SCRIPT_DIR/.venv/bin/python" - <<'PY' >/dev/null 2>&1 import cv2 # noqa: F401 import mediapipe # noqa: F401 PY } detect_motion_agent_camera_indexes() { local devices="" local device="" local index="" if command -v v4l2-ctl >/dev/null 2>&1; then devices="$(v4l2-ctl --list-devices 2>/dev/null | sed -nE 's/^[[:space:]]*\\/dev\\/video([0-9]+).*/\\1/p' || true)" fi if [ -z "$devices" ]; then for device in /dev/video*(N); do [ -e "$device" ] || continue index="${device#/dev/video}" [[ "$index" =~ ^[0-9]+$ ]] || continue printf "%s\n" "$index" done | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, - return 0 fi printf "%s\n" "$devices" | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, - } is_wsl_environment() { grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null } ensure_motion_agent_live_deps() { local auto_install="${PLANET_MOTION_AGENT_AUTO_INSTALL:-1}" motion_agent_live_deps_ready && return 0 case "$auto_install" in 0|false|no|off) log_error "Motion Agent live 模式缺少 mediapipe/opencv-python,且 PLANET_MOTION_AGENT_AUTO_INSTALL=0" exit 1 ;; esac set_wait_detail "Motion Agent 缺少 CV 依赖,准备用 uv 自动安装" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "Motion Agent CV 依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "uv add mediapipe opencv-python" \ uv add mediapipe opencv-python; then exit 1 fi if ! motion_agent_live_deps_ready; then log_error "Motion Agent CV 依赖安装后仍无法导入 mediapipe/opencv-python" log_note "可先用 --motion-agent-dry-run 验证 Web 端连接;真实摄像头识别需要当前 Python 版本可用的 mediapipe/opencv-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 ensure_local_runtime_bins_on_path if [ -x "$HOME/.bun/bin/bun" ]; then FRONTEND_RUNTIME_BIN="$HOME/.bun/bin/bun" FRONTEND_RUNTIME_SOURCE="local-install" WAIT_SPINNER_DETAIL="已找到 ~/.bun/bin/bun" 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 local log_file="$PLANET_STATE_DIR/bun_install.log" 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 install_bun_if_needed if ! resolve_frontend_runtime; then close_wait_session_context "$owns_wait_session" log_error "未找到 bun,自动安装后仍无法解析运行时" exit 1 fi fi cd "$SCRIPT_DIR/frontend" : > "$log_file" set_wait_detail "检查 Vite Bun 入口是否已安装" if [ ! -f "$FRONTEND_VITE_ENTRY" ]; 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" \ run_command_quiet_unless_verbose "$log_file" "$FRONTEND_RUNTIME_BIN" install; then tail -20 "$log_file" 2>/dev/null || true exit 1 fi fi if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then close_wait_session_context "$owns_wait_session" log_error "前端依赖安装失败,未找到 Vite Bun 入口" 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="请求" local elapsed_seconds="" set_wait_detail "${wait_detail_prefix} ${url},已等待 0 秒" while [ "$attempt" -le "$attempts" ]; do if http_ok "$url"; then finish_wait_spinner "${service_name}已就绪" return 0 fi elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$interval")" set_wait_detail "${wait_detail_prefix} ${url},已等待 ${elapsed_seconds} 秒" 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 if [ "$VERBOSE" -eq 1 ]; then WAIT_VERBOSE_LOG_FILE="$log_file" 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 ] && http_ok "http://localhost:${frontend_port}"; 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="读取" local elapsed_seconds="" set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态,已等待 0 秒" 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 elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$interval")" set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态,已等待 ${elapsed_seconds} 秒" 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} 当前环境内未发现占用进程,但端口仍不可用,请检查宿主机或外部环境占用" print_port_listener_details "$port" log_note "如果显示 Windows listener(如 svchost.exe / iphlpsvc),脚本会在需要时请求管理员 PowerShell 清理 Windows 侧端口。" 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 print_windows_port_listener_details "$port" || true log_note "请手动停止占用进程后重试。" fi exit 1 } retry_wait_for_port_release() { local port="$1" local service_name="$2" local attempt=1 local pids="" local elapsed_seconds="" while [ "$attempt" -le "$PORT_PRESTART_RETRIES" ]; do if wait_for_port_release "$port"; then return 0 fi pids="$(collect_port_pids "$port" || true)" elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$PORT_PRESTART_RETRY_INTERVAL")" if [ -n "$pids" ]; then if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理" else log_warn "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理" fi for pid in $pids; do terminate_process_tree KILL "$pid" done else if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放" else log_warn "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放" print_port_listener_details "$port" fi fi sleep "$PORT_PRESTART_RETRY_INTERVAL" attempt=$((attempt + 1)) done return 1 } print_windows_cleanup_recovery_steps() { local port="$1" local service_name="$2" local port_flag local command_example case "$service_name" in 前端) port_flag="-f" command_example="./planet.sh restart ${port_flag} ${port} --allow-lan" ;; 后端) port_flag="-b" command_example="./planet.sh restart ${port_flag} ${port} --allow-lan" ;; "AI Provider") port_flag="-a" command_example="./planet.sh restart ${port_flag} ${port}" ;; "Motion Agent") port_flag="--motion-agent-port" command_example="./planet.sh restart ${port_flag} ${port} --allow-lan" ;; *) port_flag="" command_example="./planet.sh restart --allow-lan" ;; esac log_note "Windows 侧端口被 svchost.exe / iphlpsvc 占用时,通常是管理员 PowerShell 中创建过持久 portproxy 规则,例如 listenport=${port}。" log_note "该规则会由 Windows IP Helper 持续监听端口,项目停止后也不会自动消失;下次启动前必须先删除,否则会与 Vite/后端绑定同一端口冲突。" log_note "请打开“管理员 PowerShell”,优先检查并删除冲突 portproxy:" printf "${DIM} netsh interface portproxy show all${NC}\n" printf "${DIM} netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=%s${NC}\n" "$port" log_note "如果没有 portproxy 规则,再确认 PID/服务并临时停止对应服务:" printf "${DIM} netstat -ano | findstr :%s${NC}\n" "$port" printf "${DIM} tasklist /svc /fi \"PID eq \"${NC}\n" printf "${DIM} Stop-Service iphlpsvc -Force${NC}\n" log_note "如需局域网仍使用 ${port},请清理冲突监听后重新运行 --allow-lan;脚本会直接用 0.0.0.0 绑定该端口。" log_note "重新启动示例:${command_example}" } force_cleanup_external_port_listener() { local port="$1" local service_name="$2" validate_port "$port" is_wsl_environment || return 1 command -v powershell.exe >/dev/null 2>&1 || return 1 if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "检查 Windows 侧端口 ${port}" else log_warn "检查 Windows 侧端口占用: ${port}" fi if ! print_windows_port_listener_details "$port"; then if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "Windows 侧未发现端口 ${port} 监听,继续检查" else log_note "Windows 侧未发现端口 ${port} 监听,继续启动" fi return 0 fi if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "Windows 侧端口 ${port} 存在监听者,准备请求管理员权限清理" else log_warn "Windows 侧端口 ${port} 存在监听者,准备请求管理员权限清理" fi request_windows_port_cleanup "$port" || return 1 if wait_for_port_release "$port" 10 0.3; then return 0 fi if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "Windows 侧端口 ${port} 清理后仍不可绑定" else log_warn "Windows 侧端口 ${port} 清理后仍不可绑定" fi print_windows_cleanup_recovery_steps "$port" "$service_name" return 1 } # Service startup helpers wait_for_database_health() { wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "PostgreSQL" && wait_for_container_health "planet_redis" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "Redis" } wait_for_postgres_health() { wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_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() { local backend_port="${1:-$DEFAULT_BACKEND_PORT}" local tracked_pid="" if [ -f "$BACKEND_PID_FILE" ]; then if tracked_pid="$(read_pid_file "$BACKEND_PID_FILE")"; then terminate_process_group TERM "$tracked_pid" terminate_process_tree TERM "$tracked_pid" sleep 1 terminate_process_group KILL "$tracked_pid" terminate_process_tree KILL "$tracked_pid" fi remove_pid_file "$BACKEND_PID_FILE" fi terminate_backend_processes TERM "$backend_port" if ! wait_for_port_release "$backend_port" 15 0.2; then terminate_backend_processes KILL "$backend_port" if ! wait_for_port_release "$backend_port" 15 0.2; then if force_cleanup_external_port_listener "$backend_port" "后端"; then return 0 fi wait_for_port_release "$backend_port" 15 0.2 || return 1 fi fi return 0 } start_backend_with_retry() { local backend_port="$1" local retry=1 local backend_bind_host="0.0.0.0" while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do if ! cleanup_backend_processes "$backend_port"; then fail_unreleased_port "$backend_port" fi cd "$SCRIPT_DIR/backend" : > "$BACKEND_LOG_FILE" PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host "$backend_bind_host" --port "$backend_port" --reload > "$BACKEND_LOG_FILE" 2>&1 & BACKEND_PID=$! write_pid_file "$BACKEND_PID_FILE" "$BACKEND_PID" if [ "$VERBOSE" -eq 1 ]; then WAIT_VERBOSE_LOG_FILE="$BACKEND_LOG_FILE" fi 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 if backend_log_indicates_port_conflict "$BACKEND_LOG_FILE"; then cleanup_backend_processes "$backend_port" || fail_unreleased_port "$backend_port" if wait_for_port_release "$backend_port" 5 0.2; then log_warn "后端端口冲突已清理,立即重试启动" retry=$((retry + 1)) continue fi report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE" return 1 fi animate_wait_spinner "后端启动未就绪,准备再次尝试" "$BACKEND_HEALTH_CHECK_INTERVAL" retry=$((retry + 1)) done clear_wait_spinner report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE" || true return 1 } start_ai_provider_service() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local retry=1 set_wait_detail "启动 AI Provider" write_ai_provider_runtime_env_file ensure_ai_provider_image_current while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do if [ "$AI_PROVIDER_RECREATE_REQUIRED" -eq 1 ]; then if recreate_ai_provider_container "$ai_provider_port"; then AI_PROVIDER_RECREATE_REQUIRED=0 if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$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 elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$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 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true exit 1 fi remove_ai_provider_containers AI_PROVIDER_RECREATE_REQUIRED=1 animate_wait_spinner "AI Provider 启动后仍未健康,正在重启容器并准备再次尝试" "$AI_PROVIDER_RETRY_INTERVAL" retry=$((retry + 1)) done log_error "AI Provider 启动失败" docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true exit 1 } ai_provider_service_healthy() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1 http_ok "http://localhost:${ai_provider_port}/health" } 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} 次" print_database_failure_diagnostics exit 1 fi docker restart planet_postgres planet_redis >/dev/null 2>&1 || true animate_wait_spinner "数据库启动后仍未健康,正在重启容器并准备再次尝试" "$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} 次" if ! docker_daemon_available; then log_note "PostgreSQL 容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。" log_docker_daemon_diagnostics else docker logs --tail 20 planet_postgres 2>/dev/null || true fi exit 1 fi docker restart planet_postgres >/dev/null 2>&1 || true animate_wait_spinner "PostgreSQL 启动后仍未健康,正在重启容器并准备再次尝试" "$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 return 0 fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" print_database_failure_diagnostics exit 1 fi animate_wait_spinner "数据库重启后仍未健康,准备再次尝试" "$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 "启动数据库已就绪" # Backend depends on AI Provider reachability, but a backend-only restart # should reuse the existing healthy provider instead of rebuilding or # restarting it. if ai_provider_service_healthy "$ai_provider_port"; then log_note "AI Provider 已健康,复用现有服务,跳过启动/重建" else log_note "AI Provider 未就绪,正在启动本地代理服务" start_ai_provider_service "$ai_provider_port" fi 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 "$BACKEND_LOG_FILE" 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_group() { local signal="$1" local pid="$2" local pgid="" is_safe_signal "$signal" || return 1 is_pid "$pid" || return 0 kill -0 "$pid" 2>/dev/null || return 0 pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')" is_pid "$pgid" || return 0 kill "-${signal}" -- "-${pgid}" 2>/dev/null || true } terminate_backend_processes() { local signal="$1" local backend_port="$2" local pids="" local pid="" pids="$(pgrep -f "uvicorn .*--port ${backend_port}" 2>/dev/null || true)" for pid in $pids; do terminate_process_group "$signal" "$pid" terminate_process_tree "$signal" "$pid" done pids="$(collect_port_pids "$backend_port" || true)" for pid in $pids; do terminate_process_group "$signal" "$pid" terminate_process_tree "$signal" "$pid" done } terminate_process_tree() { local signal="$1" local pid="$2" local child_pid is_safe_signal "$signal" || return 1 is_pid "$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 is_pid "$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" if command -v python3 >/dev/null 2>&1; then 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, "0.0.0.0"), (socket.AF_INET6, "::")): try: sock = socket.socket(family) if family == socket.AF_INET6 and hasattr(socket, "IPV6_V6ONLY"): sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) sock.bind((host, port)) sockets.append(sock) except OSError as exc: if getattr(exc, "errno", None) in (socket.EAFNOSUPPORT, getattr(socket, "EADDRNOTAVAIL", -1)): continue raise finally: for sock in sockets: try: sock.close() except OSError: pass PY return fi if command -v ss >/dev/null 2>&1; then ! ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE ":${port}$" return fi if command -v lsof >/dev/null 2>&1; then [ -z "$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null)" ] return fi } wait_for_port_release() { local port="$1" local max_attempts="${2:-$PORT_RELEASE_ATTEMPTS}" local interval="${3:-$PORT_RELEASE_INTERVAL}" local attempt=1 while [ "$attempt" -le "$max_attempts" ]; do if can_bind_port "$port"; then return 0 fi sleep "$interval" attempt=$((attempt + 1)) done return 1 } format_port_check_detail() { local service_name="$1" local port="$2" printf "检查 %s 端口 %s" "$service_name" "$port" | sed -E 's/检查 (前端|后端) /检查\1/' } kill_port_if_requested() { local port="$1" local service_name="$2" local pids="" local pid="" if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "$(format_port_check_detail "$service_name" "$port")" else log_warn "检测 ${service_name} 端口 ${port} 占用" fi pids="$(collect_port_pids "$port" || true)" if [ -z "$pids" ] && can_bind_port "$port"; then if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then log_success "端口 ${port} 未被占用" fi return 0 fi if [ -z "$pids" ]; then if force_cleanup_external_port_listener "$port" "$service_name"; then if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then log_success "端口 ${port} 已释放" fi return 0 fi fail_unreleased_port "$port" fi if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "${service_name}端口 ${port} 被占用,正在释放" else log_step "发现端口 ${port} 占用,正在终止" fi for pid in $pids; do terminate_process_tree TERM "$pid" done if retry_wait_for_port_release "$port" "$service_name"; then if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then log_success "端口 ${port} 已释放" fi return 0 fi if [ -z "$(collect_port_pids "$port" || true)" ]; then if force_cleanup_external_port_listener "$port" "$service_name"; then if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then log_success "端口 ${port} 已释放" fi return 0 fi fail_unreleased_port "$port" fi if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "端口 ${port} 仍被占用,正在强制释放" else log_warn "端口 ${port} 仍被占用,正在强制终止" fi pids="$(collect_port_pids "$port" || true)" for pid in $pids; do terminate_process_tree KILL "$pid" done if retry_wait_for_port_release "$port" "$service_name"; then if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then log_success "端口 ${port} 已释放" fi return 0 fi if [ -z "$(collect_port_pids "$port" || true)" ]; then if force_cleanup_external_port_listener "$port" "$service_name"; then if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then log_success "端口 ${port} 已释放" fi return 0 fi fail_unreleased_port "$port" fi fail_unreleased_port "$port" } release_requested_ports() { local service_name="" local port="" [ "$#" -gt 0 ] || return 0 start_wait_session "检测端口占用情况" PORT_CHECK_STATUS_ACTIVE=1 while [ "$#" -gt 0 ]; do service_name="$1" port="$2" kill_port_if_requested "$port" "$service_name" shift 2 done PORT_CHECK_STATUS_ACTIVE=0 stop_wait_session log_success "端口已全部释放" } frontend_log_indicates_port_conflict() { local log_file="$1" log_matches "$log_file" "Port .* is already in use" } backend_log_indicates_port_conflict() { local log_file="$1" log_matches "$log_file" "Address already in use|Errno 98" } print_windows_port_listener_details() { local port="$1" local output="" command -v powershell.exe >/dev/null 2>&1 || return 1 output="$( powershell.exe -NoProfile -Command " \$ErrorActionPreference = 'SilentlyContinue' \$port = [int]${port} \$connections = Get-NetTCPConnection -LocalPort \$port -State Listen foreach (\$connection in \$connections) { \$owningProcessId = \$connection.OwningProcess \$process = Get-CimInstance Win32_Process -Filter \"ProcessId=\$owningProcessId\" \$services = Get-CimInstance Win32_Service | Where-Object { \$_.ProcessId -eq \$owningProcessId } | Select-Object -ExpandProperty Name \$processName = if (\$process.Name) { \$process.Name } else { 'unknown' } \$serviceText = if (\$services) { ' services=' + (\$services -join ',') } else { '' } 'Windows listener: {0}:{1} pid={2} process={3}{4}' -f \$connection.LocalAddress, \$connection.LocalPort, \$owningProcessId, \$processName, \$serviceText } " 2>/dev/null | tr -d '\r' )" [ -n "$output" ] || return 1 if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then set_wait_detail "$(printf "%s\n" "$output" | head -n 1)" return 0 fi while IFS= read -r line; do [ -n "$line" ] || continue printf "${DIM} %s${NC}\n" "$line" done </dev/null 2>&1; then lsof_output="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" if [ -n "$lsof_output" ]; then printf "%s\n" "$lsof_output" | awk '{print " " $0}' found=1 fi fi if command -v ss >/dev/null 2>&1; then ss_output="$(ss -ltnpH "( sport = :${port} )" 2>/dev/null || true)" while IFS= read -r line; do [ -n "$line" ] || continue printf "${DIM} ss: %s${NC}\n" "$line" found=1 done </dev/null | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')" [ -n "$command_line" ] || command_line="未知命令" printf "${DIM} pid %s: %s${NC}\n" "$pid" "$command_line" found=1 done if print_windows_port_listener_details "$port"; then found=1 fi if [ "$found" -eq 0 ]; then log_note "未能在当前环境内定位端口 ${port} 的监听进程,可能被宿主机或外部网络命名空间占用。" fi } report_backend_port_conflict_if_needed() { local backend_port="$1" local log_file="$2" if backend_log_indicates_port_conflict "$log_file" || ! can_bind_port "$backend_port"; then log_error "后端地址已被占用: 0.0.0.0:${backend_port} / 127.0.0.1:${backend_port} / [::1]:${backend_port}" print_port_listener_details "$backend_port" return 0 fi return 1 } # Frontend lifecycle helpers cleanup_frontend_processes() { local frontend_port="${1:-$DEFAULT_FRONTEND_PORT}" local tracked_pid="" if [ -f "$FRONTEND_PID_FILE" ]; then if tracked_pid="$(read_pid_file "$FRONTEND_PID_FILE")"; then terminate_process_tree TERM "$tracked_pid" sleep 1 terminate_process_tree KILL "$tracked_pid" fi remove_pid_file "$FRONTEND_PID_FILE" fi pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY} --host 127.0.0.1 --port ${frontend_port} --strictPort" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true } start_frontend_with_retry() { local frontend_port="$1" local frontend_port_requested="${2:-0}" local frontend_lan_enabled="${3:-0}" local backend_port="${4:-$DEFAULT_BACKEND_PORT}" local retry=1 while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do cleanup_frontend_processes "$frontend_port" if [ -n "$(collect_port_pids "$frontend_port" || true)" ] && ! retry_wait_for_port_release "$frontend_port" "前端"; then log_warn "前端端口 ${frontend_port} 预清理后仍需由 Vite 启动流程确认" fi cd "$SCRIPT_DIR/frontend" : > "$FRONTEND_LOG_FILE" local -a frontend_args frontend_args=("$FRONTEND_VITE_ENTRY") if [ "$frontend_lan_enabled" -eq 1 ]; then frontend_args+=(--host 0.0.0.0) fi frontend_args+=(--port "$frontend_port" --strictPort) PLANET_BACKEND_PORT="$backend_port" nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > "$FRONTEND_LOG_FILE" 2>&1 & FRONTEND_PID=$! write_pid_file "$FRONTEND_PID_FILE" "$FRONTEND_PID" if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "$FRONTEND_LOG_FILE" "$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 "$FRONTEND_LOG_FILE"; then kill_port_if_requested "$frontend_port" "前端" fi animate_wait_spinner "前端启动未就绪,准备再次尝试" "$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" local frontend_lan_enabled="${3:-0}" local backend_port="${4:-$DEFAULT_BACKEND_PORT}" 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 "启动前端服务" if [ "$frontend_lan_enabled" -eq 1 ]; then set_wait_detail "启动 Vite 开发服务器(局域网开放)" else set_wait_detail "启动 Vite 开发服务器" fi if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled" "$backend_port"; then stop_wait_session log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次" tail -10 "$FRONTEND_LOG_FILE" print_port_listener_details "$frontend_port" if frontend_log_indicates_port_conflict "$FRONTEND_LOG_FILE"; then print_windows_cleanup_recovery_steps "$frontend_port" "前端" fi 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" MOTION_AGENT_PORT="$DEFAULT_MOTION_AGENT_PORT" MOTION_AGENT_CAMERA_INDEXES="${MOTION_AGENT_CAMERA_INDEXES:-}" MOTION_AGENT_CAMERA_URLS="${MOTION_AGENT_CAMERA_URLS:-}" BACKEND_PORT_REQUESTED=0 FRONTEND_PORT_REQUESTED=0 AI_PROVIDER_REQUESTED=0 MOTION_AGENT_REQUESTED=0 MOTION_AGENT_DRY_RUN=0 DATABASE_REQUESTED=0 FRONTEND_LAN_ENABLED=0 case "${PLANET_START_MOTION_AGENT:-0}" in 1|true|yes|on) MOTION_AGENT_REQUESTED=1 ;; esac case "${MOTION_AGENT_DRY_RUN:-0}" in 1|true|yes|on) MOTION_AGENT_DRY_RUN=1 ;; esac 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 ;; -m|--motion-agent) MOTION_AGENT_REQUESTED=1 shift 1 ;; --motion-agent-port) MOTION_AGENT_REQUESTED=1 if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then MOTION_AGENT_PORT="$2" shift 2 else log_error "--motion-agent-port 需要端口号" exit 1 fi ;; --motion-agent-dry-run) MOTION_AGENT_REQUESTED=1 MOTION_AGENT_DRY_RUN=1 shift 1 ;; --motion-agent-camera-indexes) MOTION_AGENT_REQUESTED=1 if [ -n "$2" ]; then MOTION_AGENT_CAMERA_INDEXES="$2" shift 2 else log_error "--motion-agent-camera-indexes 需要逗号分隔的摄像头 index,例如 0,1" exit 1 fi ;; --motion-agent-camera-urls) MOTION_AGENT_REQUESTED=1 if [ -n "$2" ]; then MOTION_AGENT_CAMERA_URLS="$2" shift 2 else log_error "--motion-agent-camera-urls 需要逗号分隔的 RTSP/HTTP 摄像头 URL" exit 1 fi ;; -d|--database) DATABASE_REQUESTED=1 shift 1 ;; --allow-lan) FRONTEND_LAN_ENABLED=1 shift 1 ;; -v|--verbose) VERBOSE=1 shift 1 ;; *) log_error "未知参数: $1" exit 1 ;; esac done validate_port "$BACKEND_PORT" validate_port "$FRONTEND_PORT" validate_port "$AI_PROVIDER_PORT" validate_port "$MOTION_AGENT_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 容器" printf "%s\n" "$exit_containers" | awk '/^[0-9a-f]{12,64}$/' | 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 .*--port ${DEFAULT_BACKEND_PORT}" >/dev/null 2>&1 || [ -f "$BACKEND_PID_FILE" ] || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then cleanup_backend_processes "$DEFAULT_BACKEND_PORT" log_halt "后端服务已停止" fi } stop_ai_provider_service() { stop_container_if_running "$AI_PROVIDER_CONTAINER_NAME" "AI Provider" } stop_frontend_service() { if pgrep -f "${FRONTEND_VITE_ENTRY} .*--port ${DEFAULT_FRONTEND_PORT}" >/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 } print_http_health_status() { local service_name="$1" local url="$2" if http_ok "$url"; then echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}" else echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}" fi } print_process_health_status() { local service_name="$1" local pattern="$2" local pid_file="${3:-}" local tracked_pid="" if [ -n "$pid_file" ]; then tracked_pid="$(read_pid_file "$pid_file" || true)" fi if pgrep -f "$pattern" >/dev/null 2>&1 || { [ -n "$tracked_pid" ] && kill -0 "$tracked_pid" 2>/dev/null; }; then echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}" else echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}" fi } motion_agent_pids() { local pids="" if [ -f "$MOTION_AGENT_PID_FILE" ]; then pids="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)" if [ -n "$pids" ] && kill -0 "$pids" 2>/dev/null; then printf "%s\n" "$pids" fi fi pgrep -f "python.*-m motion_agent" 2>/dev/null || true } cleanup_motion_agent_processes() { local signal="${1:-TERM}" local pid while IFS= read -r pid; do [ -n "$pid" ] || continue terminate_process_group "$signal" "$pid" terminate_process_tree "$signal" "$pid" done < <(motion_agent_pids | awk '!seen[$0]++') remove_pid_file "$MOTION_AGENT_PID_FILE" } wait_for_motion_agent_ready() { local port="$1" local pid="$2" local attempt=1 while [ "$attempt" -le 20 ]; do if ! kill -0 "$pid" 2>/dev/null; then return 1 fi if ! can_bind_port "$port"; then return 0 fi sleep 0.2 attempt=$((attempt + 1)) done return 1 } start_motion_agent_service() { local motion_agent_port="$1" local dry_run="$2" local lan_enabled="${3:-0}" local bind_host="127.0.0.1" local -a motion_args ensure_uv_backend_deps if [ "$dry_run" -eq 0 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ] && [ -z "$MOTION_AGENT_CAMERA_INDEXES" ]; then MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)" if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then log_note "Motion Agent 自动发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}" else if is_wsl_environment; then log_warn "Motion Agent 未自动发现 /dev/video* 摄像头" log_note "当前看起来是 WSL;Windows 摄像头通常不会自动出现在 /dev/video*。" log_note "真实识别请选择一种方式:" log_note " 1. 用 --motion-agent-camera-urls 接手机/RTSP/HTTP 摄像头流" log_note " 2. 用 usbipd-win 把 USB 摄像头透传到 WSL,再重试" log_note " 3. 仅验证 WebSocket/调试面板时,显式加 --motion-agent-dry-run" case "${PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK:-0}" in 1|true|yes|on) log_warn "已设置 PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1,降级为 dry-run" dry_run=1 ;; *) MOTION_AGENT_CAMERA_MISSING=1 ;; esac else log_warn "Motion Agent 未自动发现 /dev/video* 摄像头,默认尝试 index 0" fi fi fi if [ "${MOTION_AGENT_CAMERA_MISSING:-0}" -eq 1 ]; then log_error "Motion Agent live 模式缺少可用摄像头" log_note "WSL 示例:./planet.sh restart -m --motion-agent-camera-urls http://<手机IP>:8080/video" log_note "或仅调试协议:./planet.sh restart -m --motion-agent-dry-run" exit 1 fi if [ "$dry_run" -eq 0 ]; then ensure_motion_agent_live_deps fi cleanup_motion_agent_processes TERM wait_for_port_release "$motion_agent_port" 10 0.2 || true if ! can_bind_port "$motion_agent_port"; then log_error "Motion Agent 端口已被占用: ${motion_agent_port}" print_port_listener_details "$motion_agent_port" exit 1 fi : > "$MOTION_AGENT_LOG_FILE" if [ "$lan_enabled" -eq 1 ]; then bind_host="0.0.0.0" fi motion_args=(-m motion_agent --host "$bind_host" --port "$motion_agent_port") if [ -n "$MOTION_AGENT_CAMERA_URLS" ]; then motion_args+=(--camera-urls "$MOTION_AGENT_CAMERA_URLS") elif [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then motion_args+=(--camera-indexes "$MOTION_AGENT_CAMERA_INDEXES") fi if [ "$dry_run" -eq 1 ]; then motion_args+=(--dry-run) fi cd "$SCRIPT_DIR" nohup "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}" > "$MOTION_AGENT_LOG_FILE" 2>&1 & MOTION_AGENT_PID=$! write_pid_file "$MOTION_AGENT_PID_FILE" "$MOTION_AGENT_PID" if wait_for_motion_agent_ready "$motion_agent_port" "$MOTION_AGENT_PID"; then log_success "Motion Agent 已就绪" log_motion_agent_access_notes "$motion_agent_port" "$lan_enabled" return 0 fi log_error "Motion Agent 启动失败" tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || true print_port_listener_details "$motion_agent_port" log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。" log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。" log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。" log_note "依赖缺失时脚本会自动执行 uv add mediapipe opencv-python。" cleanup_motion_agent_processes TERM exit 1 } stop_motion_agent_service() { if [ -f "$MOTION_AGENT_PID_FILE" ] || pgrep -f "python.*-m motion_agent" >/dev/null 2>&1; then cleanup_motion_agent_processes TERM log_halt "Motion Agent 已停止" fi } cleanup_failed_start() { [ "$START_RUN_ACTIVE" -eq 1 ] || return 0 [ "$START_RUN_COMPLETED" -eq 0 ] || return 0 clear_wait_spinner log_warn "启动未完成,清理本轮已拉起的本地进程" if [ "$STARTED_MOTION_AGENT_THIS_RUN" -eq 1 ]; then cleanup_motion_agent_processes TERM fi if [ "$STARTED_FRONTEND_THIS_RUN" -eq 1 ]; then cleanup_frontend_processes "${FRONTEND_PORT:-$DEFAULT_FRONTEND_PORT}" fi if [ "$STARTED_BACKEND_THIS_RUN" -eq 1 ]; then cleanup_backend_processes "${BACKEND_PORT:-$DEFAULT_BACKEND_PORT}" || true fi } ensure_env_file() { local target_file="$1" local example_file="$2" local label="$3" if [ -f "$target_file" ]; then log_note "${label} 已存在,跳过" return 0 fi if [ ! -f "$example_file" ]; then log_warn "${label} 模板不存在,跳过: ${example_file}" return 0 fi cp "$example_file" "$target_file" chmod 600 "$target_file" 2>/dev/null || true log_note "已创建 ${label}: ${target_file}" } ensure_planet_env_files() { ensure_env_file "$SCRIPT_DIR/backend/.env" "$SCRIPT_DIR/backend/.env.example" "backend/.env" ensure_env_file "$SCRIPT_DIR/aiprovider/.env" "$SCRIPT_DIR/aiprovider/.env.example" "aiprovider/.env" ensure_env_file "$SCRIPT_DIR/frontend/.env.local" "$SCRIPT_DIR/frontend/.env.example" "frontend/.env.local" } sync_frontend_deps() { local log_file="$PLANET_STATE_DIR/bun_install.log" set_wait_detail "解析前端运行时" if ! resolve_frontend_runtime; then install_bun_if_needed if ! resolve_frontend_runtime; then log_error "未找到 bun,自动安装后仍无法解析运行时" exit 1 fi fi cd "$SCRIPT_DIR/frontend" : > "$log_file" set_wait_detail "同步前端依赖" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "前端依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "bun install" \ run_command_quiet_unless_verbose "$log_file" "$FRONTEND_RUNTIME_BIN" install; then tail -20 "$log_file" 2>/dev/null || true exit 1 fi } initialize_backend_database() { cd "$SCRIPT_DIR/backend" PYTHONPATH="$SCRIPT_DIR/backend" "$SCRIPT_DIR/.venv/bin/python" - <<'PY' import asyncio from app.db.session import init_db asyncio.run(init_db()) PY } planet_app_services_running() { local backend_port="" local frontend_port="" local ai_provider_port="" local motion_agent_port="" backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")" frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")" ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")" motion_agent_port="$(read_port_state_value MOTION_AGENT_PORT "$DEFAULT_MOTION_AGENT_PORT")" if http_ok "http://localhost:${backend_port}/health"; then return 0 fi if http_ok "http://localhost:${frontend_port}"; then return 0 fi if http_ok "http://localhost:${ai_provider_port}/health"; then return 0 fi if ! can_bind_port "$motion_agent_port"; then return 0 fi if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1; then return 0 fi return 1 } guard_init_when_services_running() { if ! planet_app_services_running; then return 0 fi log_warn "检测到 Planet 应用服务正在运行,跳过初始化" log_note "当前项目看起来已经完成初始化;重复 init 可能会打扰运行中的服务。" log_note "如需重新初始化,请先执行 ./planet.sh stop,再按需清理数据库卷或配置。" log_note "查看当前状态: ./planet.sh health" return 1 } init() { if ! guard_init_when_services_running; then return 0 fi print_splash start_wait_session "准备 Python 运行时" ensure_python_runtime stop_wait_session log_success "Python 运行时已就绪" start_wait_session "同步 Python 依赖" sync_python_deps stop_wait_session log_success "Python 依赖已就绪" start_wait_session "同步前端依赖" sync_frontend_deps stop_wait_session log_success "前端依赖已就绪" log_step "检查环境变量文件" ensure_planet_env_files log_success "环境变量文件已就绪" start_wait_session "启动数据库服务" ensure_database_services_healthy stop_wait_session log_success "数据库服务已就绪" start_wait_session "初始化数据库数据" set_wait_detail "创建表并写入默认数据" if ! run_command_with_spinner "初始化数据库数据" initialize_backend_database; then stop_wait_session log_error "数据库初始化失败" exit 1 fi stop_wait_session log_success "初始化完成" log_note "默认本地登录用户:" log_note " admin / admin123" log_note " linkong / LK12345678" log_note "下一步: ./planet.sh start" } 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}" } prepare_allow_lan_public_ports() { [ "$FRONTEND_LAN_ENABLED" -eq 1 ] || return 0 release_requested_ports \ "后端" "$BACKEND_PORT" \ "前端" "$FRONTEND_PORT" \ "AI Provider" "$AI_PROVIDER_PORT" request_windows_portproxy_cleanup_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" || true request_windows_firewall_rules_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" || true } verify_http_endpoint() { local label="$1" local url="$2" log_note "检查${label}: ${url}" if ! http_ok "$url"; then log_error "${label}不可访问: ${url}" return 1 fi } verify_allow_lan_access() { local lan_ip="" [ "$FRONTEND_LAN_ENABLED" -eq 1 ] || return 0 verify_http_endpoint "本机访问" "http://localhost:${FRONTEND_PORT}" || exit 1 verify_http_endpoint "本机访问" "http://localhost:${BACKEND_PORT}/health" || exit 1 verify_http_endpoint "本机访问" "http://localhost:${AI_PROVIDER_PORT}/health" || exit 1 if lan_ip="$(get_recommended_lan_ipv4)"; then verify_http_endpoint "局域网访问" "http://${lan_ip}:${FRONTEND_PORT}" || exit 1 verify_http_endpoint "局域网访问" "http://${lan_ip}:${BACKEND_PORT}/health" || exit 1 verify_http_endpoint "局域网访问" "http://${lan_ip}:${AI_PROVIDER_PORT}/health" || exit 1 else log_warn "未能识别局域网 IP,跳过局域网端到端健康检查" fi } # Top-level commands start() { parse_service_args "$@" cleanup_exit_containers prepare_allow_lan_public_ports START_RUN_ACTIVE=1 START_RUN_COMPLETED=0 STARTED_BACKEND_THIS_RUN=0 STARTED_FRONTEND_THIS_RUN=0 STARTED_MOTION_AGENT_THIS_RUN=0 print_splash start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" STARTED_BACKEND_THIS_RUN=1 start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED" "$BACKEND_PORT" STARTED_FRONTEND_THIS_RUN=1 if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED" STARTED_MOTION_AGENT_THIS_RUN=1 fi write_port_state "$BACKEND_PORT" "$FRONTEND_PORT" "$AI_PROVIDER_PORT" "$MOTION_AGENT_PORT" verify_allow_lan_access START_RUN_COMPLETED=1 START_RUN_ACTIVE=0 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" if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED" fi if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" fi } stop() { log_warn "停止服务" stop_backend_service stop_ai_provider_service stop_frontend_service stop_motion_agent_service stop_container_if_running "planet_postgres" "PostgreSQL" stop_container_if_running "planet_redis" "Redis" log_success "已停止" } confirm_destroy() { local answer="" clear_wait_spinner log_warn "即将执行破坏性清理" log_note "destroy 会停止服务并删除 Planet 的 Docker 容器、卷、镜像和本地编译/运行状态。" log_note "这会清空本地 PostgreSQL/Redis 数据卷;源码和现有 .env 配置文件会保留。" printf "%b请输入 Y 确认,N 取消 [Y/N]: %b" "$YELLOW" "$NC" read -r answer case "$answer" in Y|y) return 0 ;; *) log_warn "已取消 destroy" return 1 ;; esac } compose_destroy() { if compose_available; then docker compose down --volumes --rmi all --remove-orphans >/dev/null 2>&1 || true return 0 fi if compose_v1_available; then docker-compose down --volumes --rmi all --remove-orphans >/dev/null 2>&1 || true return 0 fi log_warn "未检测到 Docker Compose,跳过 compose 级清理" } remove_planet_docker_state() { local container_ids="" local volume_names="" compose_destroy container_ids="$(docker ps -aq --filter "name=^/planet_" 2>/dev/null || true)" if [ -n "$container_ids" ]; then printf "%s\n" "$container_ids" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true fi docker image rm "$AI_PROVIDER_IMAGE_NAME" planet-aiprovider:latest postgres:15 redis:7-alpine >/dev/null 2>&1 || true volume_names="$(docker volume ls -q --filter label=com.docker.compose.project=planet 2>/dev/null || true)" if [ -n "$volume_names" ]; then printf "%s\n" "$volume_names" | xargs -r docker volume rm -f >/dev/null 2>&1 || true fi docker volume rm -f planet_postgres_data planet_redis_data postgres_data redis_data >/dev/null 2>&1 || true } reset_planet_database_for_destroy() { local postgres_container="planet_postgres" docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$postgres_container" || return 0 docker exec "$postgres_container" psql -U postgres -d planet_db -v ON_ERROR_STOP=1 >/dev/null 2>&1 <<'SQL' || true DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public; SQL } remove_planet_build_state() { rm -rf \ "$SCRIPT_DIR/.venv" \ "$SCRIPT_DIR/.uv" \ "$SCRIPT_DIR/.uv-cache" \ "$SCRIPT_DIR/.pytest_cache" \ "$SCRIPT_DIR/.ruff_cache" \ "$SCRIPT_DIR/node_modules" \ "$SCRIPT_DIR/dist" \ "$SCRIPT_DIR/frontend/node_modules" \ "$SCRIPT_DIR/frontend/dist" \ "$SCRIPT_DIR/frontend/dist-ssr" \ "$PLANET_STATE_DIR" \ "$PLANET_CACHE_DIR" find "$SCRIPT_DIR" \ \( -path "$SCRIPT_DIR/.git" -o -path "$SCRIPT_DIR/frontend/node_modules" -o -path "$SCRIPT_DIR/node_modules" \) -prune \ -o \( -type d \( -name __pycache__ -o -name .pytest_cache -o -name .ruff_cache -o -name .mypy_cache -o -name .vite \) -print \) \ | xargs -r rm -rf } destroy() { confirm_destroy || return 0 log_warn "开始 destroy" stop_backend_service stop_ai_provider_service stop_frontend_service stop_motion_agent_service start_wait_session "重置数据库 OOBE 状态" reset_planet_database_for_destroy stop_wait_session log_success "数据库 OOBE 状态已重置" start_wait_session "清理 Docker 状态" remove_planet_docker_state stop_wait_session log_success "Docker 容器、卷和镜像已清理" start_wait_session "清理本地编译状态" remove_planet_build_state stop_wait_session log_success "本地编译和运行状态已清理" log_success "destroy 完成" log_note "已保留源码和 .env 配置文件;重新开始可执行 ./planet.sh init" } stop_local_services_for_restart() { log_warn "正在停止本地服务" stop_backend_service stop_ai_provider_service stop_frontend_service stop_motion_agent_service stop_container_if_running "planet_postgres" "PostgreSQL" stop_container_if_running "planet_redis" "Redis" } restart() { parse_service_args "$@" cleanup_exit_containers local state_backend_port="" local state_frontend_port="" local state_ai_provider_port="" local state_motion_agent_port="" if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then stop_local_services_for_restart 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 if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then kill_port_if_requested "$AI_PROVIDER_PORT" "AI Provider" fi 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 "$FRONTEND_LAN_ENABLED" "$BACKEND_PORT" fi if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then stop_motion_agent_service sleep 1 start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED" fi state_backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")" state_frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")" state_ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")" state_motion_agent_port="$(read_port_state_value MOTION_AGENT_PORT "$DEFAULT_MOTION_AGENT_PORT")" if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then state_backend_port="$BACKEND_PORT" fi if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then state_frontend_port="$FRONTEND_PORT" fi if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then state_ai_provider_port="$AI_PROVIDER_PORT" fi if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then state_motion_agent_port="$MOTION_AGENT_PORT" fi write_port_state "$state_backend_port" "$state_frontend_port" "$state_ai_provider_port" "$state_motion_agent_port" 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}" if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" fi fi if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED" fi } health() { local backend_port="" local frontend_port="" local ai_provider_port="" backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")" frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")" ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")" echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}" docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || log_note "Docker daemon 不可用,跳过容器状态" echo "" echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}" print_http_health_status "后端" "http://localhost:${backend_port}/health" print_http_health_status "AI Provider" "http://localhost:${ai_provider_port}/health" print_http_health_status "前端" "http://localhost:${frontend_port}" print_process_health_status "Motion Agent" "python.*-m motion_agent" "$MOTION_AGENT_PID_FILE" } log() { case "$1" in -f|--frontend) log_step "查看前端日志" log_note "按 Ctrl+C 退出" tail -f "$FRONTEND_LOG_FILE" ;; -b|--backend) log_step "查看后端日志" log_note "按 Ctrl+C 退出" tail -f "$BACKEND_LOG_FILE" ;; -a|--ai-provider) log_step "查看 AI Provider 日志" log_note "按 Ctrl+C 退出" docker logs -f planet_aiprovider ;; -m|--motion-agent) log_step "查看 Motion Agent 日志" log_note "按 Ctrl+C 退出" tail -f "$MOTION_AGENT_LOG_FILE" ;; *) log_step "查看最近日志" log_note "后端" tail -20 "$BACKEND_LOG_FILE" 2>/dev/null || log_note "无日志" log_note "AI Provider" docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志" log_note "前端" tail -20 "$FRONTEND_LOG_FILE" 2>/dev/null || log_note "无日志" log_note "Motion Agent" tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || log_note "无日志" ;; esac } parse_global_args() { while [ "$#" -gt 0 ]; do case "$1" in -v|--verbose) VERBOSE=1 shift 1 ;; *) break ;; esac done GLOBAL_ARG_REMAINDER=("$@") } trap cleanup_failed_start EXIT parse_global_args "$@" set -- "${GLOBAL_ARG_REMAINDER[@]}" case "$1" in init) init ;; start) shift start "$@" ;; stop) stop ;; destroy) destroy ;; restart) shift restart "$@" ;; createuser) create_user ;; health) health ;; log) log "$2" ;; *) log_error "用法: ./planet.sh {init|start|stop|destroy|restart|createuser|health|log}" log_note "全局参数: -v, --verbose 在状态提示之间增量输出命令日志" log_note "init 首次初始化空项目: 同步 uv/bun 依赖、生成缺失 env、启动数据库并写入默认数据" log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose" log_note "stop 停止服务" log_note "destroy 删除 Planet 容器、卷、镜像和本地编译状态,执行前需要输入 Y 确认" log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose" log_note "createuser 交互创建用户" log_note "health 检查健康状态" log_note "log 查看日志" log_note "log -f 查看前端日志" log_note "log -b 查看后端日志" log_note "log -a 查看 AI Provider 日志" log_note "log -m 查看 Motion Agent 日志" ;; esac