#!/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}" PLANET_UV_TUNA_INDEX_URL="${PLANET_UV_TUNA_INDEX_URL:-https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/}" USBIPD_WIN_FALLBACK_VERSION="${USBIPD_WIN_FALLBACK_VERSION:-5.3.0}" USBIPD_WIN_FALLBACK_URL_X64="${USBIPD_WIN_FALLBACK_URL_X64:-https://github.com/dorssel/usbipd-win/releases/download/v${USBIPD_WIN_FALLBACK_VERSION}/usbipd-win_${USBIPD_WIN_FALLBACK_VERSION}_x64.msi}" USBIPD_WIN_FALLBACK_URL_ARM64="${USBIPD_WIN_FALLBACK_URL_ARM64:-https://github.com/dorssel/usbipd-win/releases/download/v${USBIPD_WIN_FALLBACK_VERSION}/usbipd-win_${USBIPD_WIN_FALLBACK_VERSION}_arm64.msi}" USBIPD_WIN_DOWNLOAD_DIR="${USBIPD_WIN_DOWNLOAD_DIR:-$SCRIPT_DIR/downloads/usbipd-win}" 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" FRONTEND_CA_SERVER_PID_FILE="$PLANET_STATE_DIR/frontend_ca_server.pid" FRONTEND_CA_SERVER_LOG_FILE="$PLANET_STATE_DIR/frontend_ca_server.log" FRONTEND_CERT_DIR="$PLANET_STATE_DIR/certs" FRONTEND_CA_CERT_FILE="$FRONTEND_CERT_DIR/planet-dev-ca.crt" FRONTEND_CA_KEY_FILE="$FRONTEND_CERT_DIR/planet-dev-ca.key" FRONTEND_HTTPS_CERT_FILE="$FRONTEND_CERT_DIR/planet-dev-server.crt" FRONTEND_HTTPS_KEY_FILE="$FRONTEND_CERT_DIR/planet-dev-server.key" FRONTEND_CA_DOWNLOAD_PORT="${FRONTEND_CA_DOWNLOAD_PORT:-8088}" 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" MOTION_AGENT_SKIPPED_FILE="$PLANET_STATE_DIR/motion_agent.skipped" 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}" PLANET_EMPTY_UV_CONFIG_FILE="$PLANET_STATE_DIR/uv.empty.toml" PLANET_TUNA_UV_CONFIG_FILE="$PLANET_STATE_DIR/uv.tuna.toml" PLANET_UV_CONFIG_FILE="${PLANET_UV_CONFIG_FILE:-}" AI_PROVIDER_RECREATE_REQUIRED=0 START_RUN_ACTIVE=0 START_RUN_COMPLETED=0 STARTED_BACKEND_THIS_RUN=0 MOTION_AGENT_SKIPPED_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 resolve_uv_config_file() { local candidate if [ -n "${UV_CONFIG_FILE:-}" ] && [ -f "$UV_CONFIG_FILE" ]; then printf "%s\n" "$UV_CONFIG_FILE" return 0 fi for candidate in \ "$SCRIPT_DIR/uv.toml" \ "${XDG_CONFIG_HOME:-$HOME/.config}/uv/uv.toml" \ "$HOME/.uv/uv.toml"; do if [ -f "$candidate" ]; then printf "%s\n" "$candidate" return 0 fi done return 1 } prepare_uv_build_config() { local uv_config_file uv_config_file="$(resolve_uv_config_file || true)" if [ -n "$uv_config_file" ]; then PLANET_UV_CONFIG_FILE="$uv_config_file" else : > "$PLANET_EMPTY_UV_CONFIG_FILE" PLANET_UV_CONFIG_FILE="$PLANET_EMPTY_UV_CONFIG_FILE" fi UV_CONFIG_FILE="$PLANET_UV_CONFIG_FILE" unset UV_DEFAULT_INDEX UV_INDEX_URL UV_EXTRA_INDEX_URL export UV_CONFIG_FILE export PLANET_UV_CONFIG_FILE } prepare_uv_build_config source "$SCRIPT_DIR/scripts/lib/docker-bootstrap.zsh" # 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 } start_detached_command() { local log_file="$1" shift if command -v setsid >/dev/null 2>&1; then setsid "$@" "$log_file" 2>&1 & else nohup "$@" "$log_file" 2>&1 & fi local detached_pid="$!" disown "$detached_pid" 2>/dev/null || true printf "%s" "$detached_pid" } 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 } https_ok_insecure() { local url="$1" curl -kfsS --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 } read_port_state_text_value() { local key="$1" local fallback="$2" local value="" if [ -f "$PLANET_PORT_STATE_FILE" ]; then value="$(sed -nE "s/^${key}=([A-Za-z0-9_.:-]+)$/\\1/p" "$PLANET_PORT_STATE_FILE" | tail -n 1)" fi if [ -n "$value" ]; then printf "%s" "$value" else printf "%s" "$fallback" fi } current_frontend_scheme() { if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then printf "https" else printf "http" fi } write_port_state() { local backend_port="$1" local frontend_port="$2" local ai_provider_port="$3" local motion_agent_port="$4" local frontend_scheme="${5:-$(current_frontend_scheme)}" validate_port "$backend_port" validate_port "$frontend_port" validate_port "$ai_provider_port" validate_port "$motion_agent_port" case "$frontend_scheme" in http|https) ;; *) frontend_scheme="http" ;; esac { printf "BACKEND_PORT=%s\n" "$backend_port" printf "FRONTEND_PORT=%s\n" "$frontend_port" printf "FRONTEND_SCHEME=%s\n" "$frontend_scheme" 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 } 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 } log_buildx_diagnostics_if_needed() { local current_buildx_version="" local required_buildx_version="$DOCKER_MIN_BUILDX_VERSION" 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 if ! compose_v1_available; then log_error "Docker Compose 执行失败: docker compose ${arg_text}" return 1 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 frontend_scheme="${4:-http}" local recommended_lan_ip="" if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then log_note "推荐访问地址: ${frontend_scheme}://${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" if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then log_note "手机 CA 证书下载: http://${recommended_lan_ip}:${FRONTEND_CA_DOWNLOAD_PORT}/planet-dev-ca.crt" fi 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 } run_uv_sync_with_mirror_fallback() { local log_file="$1" local lock_digest_before local sync_status lock_digest_before="$(uv_lock_digest)" if run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "uv sync 默认源失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次,准备切换清华源" \ "uv sync --frozen" \ run_command_quiet_unless_verbose "$log_file" uv sync --frozen --group dev; then assert_uv_lock_unchanged "$lock_digest_before" "uv sync --frozen" return 0 fi assert_uv_lock_unchanged "$lock_digest_before" "uv sync --frozen" configure_uv_tuna_index set_wait_detail "已准备临时清华源配置,重新执行 uv sync" lock_digest_before="$(uv_lock_digest)" run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "uv 环境初始化失败,清华源重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次后仍失败" \ "uv sync --frozen (清华源)" \ run_uv_sync_with_tuna_config "$log_file" sync_status=$? assert_uv_lock_unchanged "$lock_digest_before" "uv sync --frozen (清华源)" return "$sync_status" } run_uv_sync_with_tuna_config() { local log_file="$1" UV_CONFIG_FILE="$PLANET_TUNA_UV_CONFIG_FILE" \ run_command_quiet_unless_verbose "$log_file" uv sync --frozen --group dev } configure_uv_tuna_index() { local uv_config="$PLANET_TUNA_UV_CONFIG_FILE" if [ -f "$uv_config" ] && grep -Fq 'name = "tsinghua"' "$uv_config" 2>/dev/null && grep -Fq "$PLANET_UV_TUNA_INDEX_URL" "$uv_config" 2>/dev/null && grep -Eq '^[[:space:]]*default[[:space:]]*=[[:space:]]*true' "$uv_config" 2>/dev/null; then log_note "临时 uv 清华源配置已存在,直接重试 uv sync" return 0 fi { printf '[[index]]\n' printf 'name = "tsinghua"\n' printf 'url = "%s"\n' "$PLANET_UV_TUNA_INDEX_URL" printf 'default = true\n' } > "$uv_config" chmod 600 "$uv_config" 2>/dev/null || true log_note "已写入临时 uv 清华源配置: ${uv_config}" } uv_lock_digest() { local lock_file="$SCRIPT_DIR/uv.lock" if [ ! -f "$lock_file" ]; then printf "missing\n" return 0 fi if command -v sha256sum >/dev/null 2>&1; then sha256sum "$lock_file" | awk '{print $1}' return 0 fi cksum "$lock_file" | awk '{print $1 ":" $2}' } assert_uv_lock_unchanged() { local expected_digest="$1" local action_label="$2" local current_digest current_digest="$(uv_lock_digest)" if [ "$current_digest" = "$expected_digest" ]; then return 0 fi log_error "${action_label} 修改了 uv.lock;新环境依赖安装不允许污染 lockfile" log_note "请还原 uv.lock,并只在明确升级依赖时手动运行 uv lock" exit 1 } install_system_package() { local package_name="$1" local log_file="$PLANET_STATE_DIR/system_dependency_install.log" local -a sudo_prefix sudo_prefix=() if [ "$(id -u)" -ne 0 ]; then if ! command -v sudo >/dev/null 2>&1; then log_error "缺少 sudo,无法自动安装系统依赖: ${package_name}" return 1 fi sudo_prefix=(sudo) fi : > "$log_file" if command -v apt-get >/dev/null 2>&1; then set_wait_detail "安装系统依赖 ${package_name} (apt)" run_command_quiet_unless_verbose "$log_file" "${sudo_prefix[@]}" apt-get update && run_command_quiet_unless_verbose "$log_file" "${sudo_prefix[@]}" apt-get install -y "$package_name" return $? fi if command -v dnf >/dev/null 2>&1; then set_wait_detail "安装系统依赖 ${package_name} (dnf)" run_command_quiet_unless_verbose "$log_file" "${sudo_prefix[@]}" dnf install -y "$package_name" return $? fi if command -v yum >/dev/null 2>&1; then set_wait_detail "安装系统依赖 ${package_name} (yum)" run_command_quiet_unless_verbose "$log_file" "${sudo_prefix[@]}" yum install -y "$package_name" return $? fi if command -v pacman >/dev/null 2>&1; then set_wait_detail "安装系统依赖 ${package_name} (pacman)" run_command_quiet_unless_verbose "$log_file" "${sudo_prefix[@]}" pacman -Sy --noconfirm "$package_name" return $? fi if command -v apk >/dev/null 2>&1; then set_wait_detail "安装系统依赖 ${package_name} (apk)" run_command_quiet_unless_verbose "$log_file" "${sudo_prefix[@]}" apk add --no-cache "$package_name" return $? fi if command -v brew >/dev/null 2>&1; then set_wait_detail "安装系统依赖 ${package_name} (brew)" run_command_quiet_unless_verbose "$log_file" brew install "$package_name" return $? fi log_error "未识别系统包管理器,无法自动安装: ${package_name}" return 1 } ensure_system_command() { local command_name="$1" local package_name="$2" local label="$3" local log_file="$PLANET_STATE_DIR/system_dependency_install.log" if command -v "$command_name" >/dev/null 2>&1; then return 0 fi log_warn "缺少 ${label},准备自动安装 ${package_name}" if ! install_system_package "$package_name"; then tail -20 "$log_file" 2>/dev/null || true return 1 fi if ! command -v "$command_name" >/dev/null 2>&1; then log_error "${label} 安装后仍不可用,请手动安装 ${package_name}" return 1 fi } 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 } } ' } 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 { printf "PYTHON_IMAGE=%s\n" "${PYTHON_IMAGE:-python:3.14-slim}" printf "UV_IMAGE=%s\n" "${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}" sha256sum aiprovider/Dockerfile pyproject.toml uv.lock 2>/dev/null find aiprovider \ -type f \ ! -path '*/__pycache__/*' \ ! -name '.env' \ ! -name '.env.*' \ ! -name '*.pyc' \ ! -name '*.pyo' \ | LC_ALL=C sort \ | xargs -r sha256sum 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 } read_ai_provider_image_fingerprint() { docker image inspect \ --format '{{ index .Config.Labels "planet.aiprovider.build-fingerprint" }}' \ "$AI_PROVIDER_IMAGE_NAME" 2>/dev/null || true } 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="" prepare_uv_build_config current_fingerprint="$(compute_ai_provider_build_fingerprint)" previous_fingerprint="$(read_ai_provider_build_stamp || true)" if ai_provider_image_exists; then local image_fingerprint image_fingerprint="$(read_ai_provider_image_fingerprint)" if [ -n "$image_fingerprint" ] && [ "$current_fingerprint" = "$image_fingerprint" ]; then write_ai_provider_build_stamp "$current_fingerprint" AI_PROVIDER_RECREATE_REQUIRED=0 return 0 fi if [ -z "$image_fingerprint" ] && [ -n "$previous_fingerprint" ] && [ "$current_fingerprint" = "$previous_fingerprint" ]; then AI_PROVIDER_RECREATE_REQUIRED=0 return 0 fi fi set_wait_detail "检测到 AI Provider 代码或配置变化,重建镜像" AI_PROVIDER_BUILD_FINGERPRINT="$current_fingerprint" export AI_PROVIDER_BUILD_FINGERPRINT 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 if ! ensure_system_command unzip unzip "unzip"; then exit 1 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_uv_sync_with_mirror_fallback "$log_file"; 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_uv_sync_with_mirror_fallback "$log_file"; 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="" local cv2_devices="" cv2_devices="$(detect_motion_agent_camera_indexes_with_cv2 || true)" if [ -n "$cv2_devices" ]; then printf "%s" "$cv2_devices" return 0 fi 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, - } detect_motion_agent_camera_indexes_with_cv2() { local python_bin="$SCRIPT_DIR/.venv/bin/python" [ -x "$python_bin" ] || return 1 "$python_bin" - <<'PY' 2>/dev/null from __future__ import annotations import glob import re try: import cv2 # type: ignore except Exception: raise SystemExit(1) indexes: list[int] = [] for path in glob.glob("/dev/video*"): match = re.search(r"/dev/video(\d+)$", path) if match: indexes.append(int(match.group(1))) usable: list[str] = [] for index in sorted(set(indexes)): capture = cv2.VideoCapture(index, cv2.CAP_V4L2) try: if not capture or not capture.isOpened(): continue ok, _frame = capture.read() if ok: usable.append(str(index)) finally: if capture: capture.release() print(",".join(usable[:2])) PY } is_wsl_environment() { grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null } detect_windows_usbipd_camera_busids() { local line="" local busid="" command -v usbipd.exe >/dev/null 2>&1 || return 0 usbipd.exe list 2>/dev/null | tr -d '\r' | while IFS= read -r line; do [[ "$line" =~ ^[[:space:]]*([0-9]+-[0-9]+)[[:space:]] ]] || continue busid="${match[1]}" case "$line" in *[Cc]amera*|*[Ww]ebcam*|*"USB Video"*|*UVC*|*摄像头*|*相机*) printf "%s|%s\n" "$busid" "$line" ;; esac done } download_usbipd_win_msi() { local output_path="$1" local fallback_url="$2" mkdir -p "$(dirname "$output_path")" "$SCRIPT_DIR/.venv/bin/python" - "$output_path" "$fallback_url" <<'PY' from __future__ import annotations import json import sys import urllib.request from pathlib import Path output = Path(sys.argv[1]) fallback_url = sys.argv[2] arch = "arm64" if "aarch64" in __import__("platform").machine().lower() or "arm64" in __import__("platform").machine().lower() else "x64" latest_api = "https://api.github.com/repos/dorssel/usbipd-win/releases/latest" def download(url: str) -> None: request = urllib.request.Request(url, headers={"User-Agent": "planet-bootstrap"}) with urllib.request.urlopen(request, timeout=25) as response: output.write_bytes(response.read()) try: request = urllib.request.Request(latest_api, headers={"User-Agent": "planet-bootstrap"}) with urllib.request.urlopen(request, timeout=12) as response: release = json.loads(response.read().decode("utf-8")) assets = release.get("assets") or [] url = next( ( asset.get("browser_download_url") for asset in assets if isinstance(asset, dict) and str(asset.get("name", "")).endswith(".msi") and arch in str(asset.get("name", "")).lower() ), None, ) if not url: raise RuntimeError("latest release has no matching MSI asset") download(url) except Exception: download(fallback_url) PY } install_usbipd_win_from_msi() { local msi_path="$1" local msi_win_path="" local script_path="" msi_win_path="$(wslpath -w "$msi_path" 2>/dev/null || true)" if [ -z "$msi_win_path" ]; then log_warn "usbipd-win MSI 已下载,但无法转换 Windows 路径: ${msi_path}" return 1 fi script_path="$(mktemp "${TMPDIR:-/tmp}/planet-usbipd-install.XXXXXX.ps1")" cat > "$script_path" </dev/null || true return 0 fi rm -f "$script_path" 2>/dev/null || true return 1 } ensure_usbipd_win_for_wsl() { local auto_install="${PLANET_MOTION_AGENT_USBIPD_AUTO_INSTALL:-1}" local fallback_url="$USBIPD_WIN_FALLBACK_URL_X64" local msi_path="" local winget_status=0 command -v usbipd.exe >/dev/null 2>&1 && return 0 is_wsl_environment || return 1 case "$auto_install" in 0|false|no|off) log_warn "未找到 usbipd.exe,且 PLANET_MOTION_AGENT_USBIPD_AUTO_INSTALL=0" return 1 ;; esac if ! command -v powershell.exe >/dev/null 2>&1; then log_warn "未找到 powershell.exe,无法从 WSL 自动安装 usbipd-win" return 1 fi log_warn "未找到 usbipd.exe,准备尝试自动安装 usbipd-win" powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "\ \$ProgressPreference = 'SilentlyContinue'; \ if (Get-Command usbipd -ErrorAction SilentlyContinue) { exit 0 }; \ if (Get-Command winget -ErrorAction SilentlyContinue) { \ winget install -e --id dorssel.usbipd-win --accept-package-agreements --accept-source-agreements --silent; \ exit \$LASTEXITCODE \ }; \ exit 2" >/dev/null 2>&1 || winget_status=$? if [ "$winget_status" -eq 0 ]; then hash -r 2>/dev/null || true if command -v usbipd.exe >/dev/null 2>&1; then log_success "usbipd-win 已通过 winget 安装" return 0 fi log_warn "winget 已完成,但当前 WSL shell 暂未发现 usbipd.exe;继续尝试 MSI fallback" else log_warn "winget 安装 usbipd-win 未完成,准备下载 MSI fallback" fi case "$(uname -m 2>/dev/null | tr '[:upper:]' '[:lower:]')" in aarch64|arm64) fallback_url="$USBIPD_WIN_FALLBACK_URL_ARM64" ;; esac msi_path="$USBIPD_WIN_DOWNLOAD_DIR/usbipd-win-${USBIPD_WIN_FALLBACK_VERSION}.msi" if [ ! -s "$msi_path" ]; then log_note "下载 usbipd-win MSI 到: ${msi_path}" if ! download_usbipd_win_msi "$msi_path" "$fallback_url"; then log_warn "usbipd-win MSI 下载失败" return 1 fi fi if install_usbipd_win_from_msi "$msi_path"; then hash -r 2>/dev/null || true if command -v usbipd.exe >/dev/null 2>&1; then log_success "usbipd-win 已安装" return 0 fi log_warn "usbipd-win 已安装,但当前 WSL shell 仍未发现 usbipd.exe;请重新打开终端后重试" else log_warn "usbipd-win 自动安装未完成;MSI 已保留在 ${msi_path}" fi return 1 } prepare_motion_agent_host_dependencies() { [ "${MOTION_AGENT_REQUESTED:-1}" -eq 1 ] || return 0 is_wsl_environment || return 0 log_step "检查 Motion Agent WSL 宿主依赖" if command -v usbipd.exe >/dev/null 2>&1; then log_success "usbipd-win 已可用" return 0 fi if ensure_usbipd_win_for_wsl; then log_success "usbipd-win 已就绪" return 0 fi log_warn "usbipd-win 自动准备未完成;普通前后端初始化继续" log_note "如果要使用 Windows USB 摄像头,请稍后重开终端或执行:" log_note " ./planet.sh restart --motion-agent-wsl-usbipd" return 0 } print_motion_agent_wsl_usbipd_manual_steps() { local busid_hint="${1:-}" log_note "可在 Windows PowerShell 中查看并透传摄像头:" log_note " usbipd list" log_note " usbipd bind --busid ${busid_hint} # 需要管理员 PowerShell,仅首次共享时需要" log_note " usbipd attach --wsl --busid ${busid_hint}" log_note "透传后回到 WSL 执行:ls /dev/video*" } request_motion_agent_wsl_usbipd_bind() { local busid="$1" local script_path="" if ! [[ "$busid" =~ ^[0-9]+-[0-9]+$ ]]; then log_warn "usbipd BUSID 格式异常,跳过自动 bind: ${busid}" return 1 fi script_path="$(mktemp "${TMPDIR:-/tmp}/planet-usbipd-bind.XXXXXX.ps1")" cat > "$script_path" </dev/null || true return 0 fi rm -f "$script_path" 2>/dev/null || true return 1 } try_motion_agent_wsl_usbipd_camera() { local enabled="${MOTION_AGENT_WSL_USBIPD:-${PLANET_MOTION_AGENT_WSL_USBIPD:-0}}" local busid="${MOTION_AGENT_WSL_USBIPD_BUSID:-}" local candidates="" local candidate_count=0 local candidate_line="" local attach_output="" local attach_status=0 case "$enabled" in 1|true|yes|on) ;; *) return 1 ;; esac is_wsl_environment || return 1 if ! ensure_usbipd_win_for_wsl; then log_warn "未找到可用 usbipd.exe,无法自动把 Windows USB 摄像头透传到 WSL" log_note "也可在 Windows 手动安装:winget install -e --id dorssel.usbipd-win" return 1 fi if [ -z "$busid" ]; then candidates="$(detect_windows_usbipd_camera_busids || true)" candidate_count="$(printf "%s\n" "$candidates" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')" if [ "$candidate_count" -eq 1 ]; then candidate_line="$(printf "%s\n" "$candidates" | sed -n '1p')" busid="${candidate_line%%|*}" log_note "Motion Agent 在 Windows USB 设备中发现摄像头 BUSID: ${busid}" elif [ "$candidate_count" -gt 1 ]; then log_warn "发现多个疑似摄像头 USB 设备,脚本不会猜测使用哪一个" printf "%s\n" "$candidates" | sed 's/^/ /' log_note "请指定:./planet.sh restart -m --motion-agent-wsl-usbipd-busid " return 1 else log_warn "usbipd-win 未列出明确的摄像头设备" print_motion_agent_wsl_usbipd_manual_steps return 1 fi fi if ! [[ "$busid" =~ ^[0-9]+-[0-9]+$ ]]; then log_warn "usbipd BUSID 格式异常,无法自动透传摄像头: ${busid}" return 1 fi log_warn "准备通过 usbipd-win 将 Windows USB 摄像头 ${busid} 附加到 WSL" log_note "附加期间该摄像头通常会从 Windows 应用中暂时断开。" attach_output="$(usbipd.exe attach --wsl --busid "$busid" 2>&1 | tr -d '\r')" || attach_status=$? if [ "$attach_status" -ne 0 ]; then case "$attach_output" in *"Device is not shared"*|*"not shared"*|*"usbipd bind"*) if request_motion_agent_wsl_usbipd_bind "$busid"; then log_note "usbipd bind 已完成,重试 attach 摄像头 ${busid}" attach_status=0 attach_output="$(usbipd.exe attach --wsl --busid "$busid" 2>&1 | tr -d '\r')" || attach_status=$? fi ;; esac fi if [ "$attach_status" -ne 0 ]; then log_warn "usbipd attach 未完成" [ -z "$attach_output" ] || printf "%s\n" "$attach_output" | sed 's/^/ /' print_motion_agent_wsl_usbipd_manual_steps "$busid" return 1 fi sleep 2 MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)" if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then log_success "Motion Agent 已通过 usbipd-win 在 WSL 中发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}" return 0 fi log_warn "usbipd attach 已执行,但 WSL 仍未发现 /dev/video*" print_motion_agent_wsl_usbipd_manual_steps "$busid" return 1 } 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 "同步前端依赖" 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 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 failure_probe="${5:-}" 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 [ -n "$failure_probe" ] && "$failure_probe"; then clear_wait_spinner return 2 fi 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 ]; then if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then frontend_ready_ok() { https_ok_insecure "https://localhost:${frontend_port}" } else frontend_ready_ok() { http_ok "http://localhost:${frontend_port}" } fi if frontend_ready_ok; then if [ "$owns_wait_session" -eq 1 ]; then stop_wait_session finish_wait_spinner "前端已就绪" else reset_wait_spinner_state fi return 0 fi 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() { # Reconcile existing containers too; docker start cannot apply changed port bindings. compose_up up -d --no-deps postgres redis } restart_database_services() { start_database_services && docker restart planet_postgres planet_redis } start_postgres_service() { compose_up up -d --no-deps postgres } # Backend lifecycle helpers backend_startup_failed() { if ! kill -0 "$BACKEND_PID" 2>/dev/null; then return 0 fi # Uvicorn's reloader can stay alive after the application worker has failed. if log_matches "$BACKEND_LOG_FILE" 'Application startup failed[.]|Error loading ASGI app'; then return 0 fi log_matches "$BACKEND_LOG_FILE" '^Process (SpawnProcess|ForkProcess).*:' && log_matches "$BACKEND_LOG_FILE" '^(ModuleNotFoundError|ImportError|SyntaxError):' } 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" local readiness_status=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" BACKEND_PID="$(start_detached_command "$BACKEND_LOG_FILE" \ env PYTHONPATH="$SCRIPT_DIR/backend" \ PLANET_CACHE_DIR="$PLANET_CACHE_DIR" \ PLANET_DOWNLOAD_CACHE_DIR="${PLANET_DOWNLOAD_CACHE_DIR:-$PLANET_CACHE_DIR/downloads}" \ uv run --frozen --project "$SCRIPT_DIR" python -m uvicorn app.main:app \ --host "$backend_bind_host" --port "$backend_port" --reload)" write_pid_file "$BACKEND_PID_FILE" "$BACKEND_PID" if [ "$VERBOSE" -eq 1 ]; then WAIT_VERBOSE_LOG_FILE="$BACKEND_LOG_FILE" fi readiness_status=0 wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端" backend_startup_failed || readiness_status=$? if [ "$readiness_status" -eq 0 ]; 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 if [ "$readiness_status" -eq 2 ]; then log_error "后端进程已退出或应用初始化失败,停止等待与重复启动" return 2 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_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_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}" local backend_start_status=0 ensure_uv_backend_deps set_wait_detail "启动数据库" ensure_database_services_healthy if ! verify_backend_database_connection; then log_error "后端数据库连接检查失败,停止启动" exit 1 fi 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 "启动后端" start_backend_with_retry "$backend_port" || backend_start_status=$? if [ "$backend_start_status" -ne 0 ]; then if [ "$backend_start_status" -eq 2 ]; then log_error "后端启动失败,请检查启动日志" else log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次" fi 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 python3 >/dev/null 2>&1; then pids="$(python3 - "$port" <<'PY' 2>/dev/null || true from __future__ import annotations import os import socket import sys target_port = int(sys.argv[1]) target_hex = f"{target_port:04X}" inodes: set[str] = set() for table in ("/proc/net/tcp", "/proc/net/tcp6"): try: lines = open(table, "r", encoding="utf-8").read().splitlines()[1:] except OSError: continue for line in lines: parts = line.split() if len(parts) < 10: continue local_address = parts[1] state = parts[3] inode = parts[9] if state != "0A": continue try: _address_hex, port_hex = local_address.rsplit(":", 1) except ValueError: continue if port_hex.upper() == target_hex: inodes.add(inode) if not inodes: raise SystemExit(0) for pid in filter(str.isdigit, os.listdir("/proc")): fd_dir = f"/proc/{pid}/fd" try: fds = os.listdir(fd_dir) except OSError: continue for fd in fds: try: target = os.readlink(f"{fd_dir}/{fd}") except OSError: continue if target.startswith("socket:[") and target[8:-1] in inodes: print(pid) break PY )" if [ -n "$pids" ]; then printf "%s\n" "$pids" | awk '!seen[$0]++' return 0 fi fi if command -v ss >/dev/null 2>&1; then pids="$(ss -ltnpH "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p; 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 } print_port_bind_probe_details() { local port="$1" command -v python3 >/dev/null 2>&1 || return 1 python3 - "$port" <<'PY' 2>/dev/null | while IFS= read -r line; do import socket import sys port = int(sys.argv[1]) for family, host, label in ( (socket.AF_INET, "0.0.0.0", "IPv4 0.0.0.0"), (socket.AF_INET, "127.0.0.1", "IPv4 127.0.0.1"), (socket.AF_INET6, "::", "IPv6 ::"), (socket.AF_INET6, "::1", "IPv6 ::1"), ): sock = None 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)) print(f"bind probe: {label}:{port} ok") except OSError as exc: print(f"bind probe: {label}:{port} failed errno={getattr(exc, 'errno', 'unknown')} {exc}") finally: if sock is not None: try: sock.close() except OSError: pass PY [ -n "$line" ] || continue printf "${DIM} %s${NC}\n" "$line" done } 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} 的监听进程,可能被宿主机或外部网络命名空间占用。" print_port_bind_probe_details "$port" || true 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_ca_server() { local tracked_pid="" if [ -f "$FRONTEND_CA_SERVER_PID_FILE" ]; then if tracked_pid="$(read_pid_file "$FRONTEND_CA_SERVER_PID_FILE")"; then terminate_process_tree TERM "$tracked_pid" sleep 1 terminate_process_tree KILL "$tracked_pid" fi remove_pid_file "$FRONTEND_CA_SERVER_PID_FILE" fi pkill -f "python3 -m http.server ${FRONTEND_CA_DOWNLOAD_PORT} .*${FRONTEND_CERT_DIR}" 2>/dev/null || true } get_frontend_https_alt_names() { local candidate="" local alt_names="DNS:localhost,IP:127.0.0.1" while read -r candidate; do [ -z "$candidate" ] && continue case ",${alt_names}," in *",IP:${candidate},"*) continue ;; esac alt_names="${alt_names},IP:${candidate}" done </dev/null | tr ' ' '\n') EOF printf "%s" "$alt_names" } ensure_frontend_https_certs() { local common_name="" local alt_names="" local csr_file="$FRONTEND_CERT_DIR/planet-dev-server.csr" command -v openssl >/dev/null 2>&1 || { log_error "--allow-lan-https 需要 openssl 生成本地开发证书" exit 1 } mkdir -p "$FRONTEND_CERT_DIR" chmod 700 "$FRONTEND_CERT_DIR" 2>/dev/null || true if [ ! -f "$FRONTEND_CA_KEY_FILE" ] || [ ! -f "$FRONTEND_CA_CERT_FILE" ]; then set_wait_detail "生成 Planet 本地开发 CA" openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ -keyout "$FRONTEND_CA_KEY_FILE" \ -out "$FRONTEND_CA_CERT_FILE" \ -subj "/CN=Planet Dev Local CA" >/dev/null 2>&1 fi common_name="$(get_recommended_lan_ipv4 || printf "localhost")" alt_names="$(get_frontend_https_alt_names)" set_wait_detail "生成局域网 HTTPS 证书: ${common_name}" openssl req -newkey rsa:2048 -nodes \ -keyout "$FRONTEND_HTTPS_KEY_FILE" \ -out "$csr_file" \ -subj "/CN=${common_name}" \ -addext "subjectAltName=${alt_names}" >/dev/null 2>&1 openssl x509 -req -in "$csr_file" \ -CA "$FRONTEND_CA_CERT_FILE" \ -CAkey "$FRONTEND_CA_KEY_FILE" \ -CAcreateserial \ -out "$FRONTEND_HTTPS_CERT_FILE" \ -days 825 -sha256 -copy_extensions copy >/dev/null 2>&1 chmod 600 "$FRONTEND_CA_KEY_FILE" "$FRONTEND_HTTPS_KEY_FILE" 2>/dev/null || true chmod 644 "$FRONTEND_CA_CERT_FILE" "$FRONTEND_HTTPS_CERT_FILE" 2>/dev/null || true } start_frontend_ca_server() { [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ] || return 0 cleanup_frontend_ca_server : > "$FRONTEND_CA_SERVER_LOG_FILE" local ca_server_pid="" ca_server_pid="$(start_detached_command "$FRONTEND_CA_SERVER_LOG_FILE" \ python3 -m http.server "$FRONTEND_CA_DOWNLOAD_PORT" \ --bind 0.0.0.0 \ --directory "$FRONTEND_CERT_DIR")" write_pid_file "$FRONTEND_CA_SERVER_PID_FILE" "$ca_server_pid" } 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 cleanup_frontend_ca_server } 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) if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then FRONTEND_PID="$(start_detached_command "$FRONTEND_LOG_FILE" \ env PLANET_BACKEND_PORT="$backend_port" \ PLANET_FRONTEND_HTTPS=1 \ PLANET_FRONTEND_HTTPS_KEY="$FRONTEND_HTTPS_KEY_FILE" \ PLANET_FRONTEND_HTTPS_CERT="$FRONTEND_HTTPS_CERT_FILE" \ "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}")" else FRONTEND_PID="$(start_detached_command "$FRONTEND_LOG_FILE" \ env PLANET_BACKEND_PORT="$backend_port" \ "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}")" fi 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 if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then ensure_frontend_https_certs fi stop_wait_session log_success "前端依赖已就绪" start_wait_session "启动前端服务" if [ "$frontend_lan_enabled" -eq 1 ]; then if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then set_wait_detail "启动 Vite HTTPS 开发服务器(局域网开放)" else set_wait_detail "启动 Vite 开发服务器(局域网开放)" fi 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 start_frontend_ca_server 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:-}" MOTION_AGENT_MODE="${MOTION_AGENT_MODE:-auto}" MOTION_AGENT_WSL_USBIPD="${MOTION_AGENT_WSL_USBIPD:-${PLANET_MOTION_AGENT_WSL_USBIPD:-0}}" MOTION_AGENT_WSL_USBIPD_BUSID="${MOTION_AGENT_WSL_USBIPD_BUSID:-}" BACKEND_PORT_REQUESTED=0 FRONTEND_PORT_REQUESTED=0 AI_PROVIDER_REQUESTED=0 MOTION_AGENT_REQUESTED=1 MOTION_AGENT_EXPLICIT_REQUESTED=0 MOTION_AGENT_DISABLED=0 MOTION_AGENT_DRY_RUN=0 DATABASE_REQUESTED=0 FRONTEND_LAN_ENABLED=0 FRONTEND_LAN_HTTPS_ENABLED=0 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 MOTION_AGENT_EXPLICIT_REQUESTED=1 MOTION_AGENT_DISABLED=0 shift 1 ;; --non-motion-agent) MOTION_AGENT_REQUESTED=0 MOTION_AGENT_DISABLED=1 shift 1 ;; --motion-agent-port) MOTION_AGENT_REQUESTED=1 MOTION_AGENT_EXPLICIT_REQUESTED=1 MOTION_AGENT_DISABLED=0 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_EXPLICIT_REQUESTED=1 MOTION_AGENT_DISABLED=0 MOTION_AGENT_DRY_RUN=1 shift 1 ;; --motion-agent-camera-indexes) MOTION_AGENT_REQUESTED=1 MOTION_AGENT_EXPLICIT_REQUESTED=1 MOTION_AGENT_DISABLED=0 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 MOTION_AGENT_EXPLICIT_REQUESTED=1 MOTION_AGENT_DISABLED=0 if [ -n "$2" ]; then MOTION_AGENT_CAMERA_URLS="$2" shift 2 else log_error "--motion-agent-camera-urls 需要逗号分隔的 RTSP/HTTP 摄像头 URL" exit 1 fi ;; --motion-agent-mode) MOTION_AGENT_REQUESTED=1 MOTION_AGENT_EXPLICIT_REQUESTED=1 MOTION_AGENT_DISABLED=0 if [ -n "$2" ] && [[ "$2" =~ ^(auto|single|dual|dual_redundant|single_fallback|calibrated_3d)$ ]]; then MOTION_AGENT_MODE="$2" shift 2 else log_error "--motion-agent-mode 需要 auto/single/dual/dual_redundant/single_fallback/calibrated_3d" exit 1 fi ;; --motion-agent-wsl-usbipd) MOTION_AGENT_REQUESTED=1 MOTION_AGENT_EXPLICIT_REQUESTED=1 MOTION_AGENT_DISABLED=0 MOTION_AGENT_WSL_USBIPD=1 shift 1 ;; --motion-agent-wsl-usbipd-busid) MOTION_AGENT_REQUESTED=1 MOTION_AGENT_EXPLICIT_REQUESTED=1 MOTION_AGENT_DISABLED=0 MOTION_AGENT_WSL_USBIPD=1 if [ -n "$2" ]; then MOTION_AGENT_WSL_USBIPD_BUSID="$2" shift 2 else log_error "--motion-agent-wsl-usbipd-busid 需要 usbipd list 中的 BUSID,例如 3-2" exit 1 fi ;; -d|--database) DATABASE_REQUESTED=1 shift 1 ;; --allow-lan) FRONTEND_LAN_ENABLED=1 shift 1 ;; --allow-lan-https) FRONTEND_LAN_ENABLED=1 FRONTEND_LAN_HTTPS_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" } 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_frontend_health_status() { local frontend_port="$1" local frontend_scheme="${2:-$(read_port_state_text_value FRONTEND_SCHEME http)}" if [ "$frontend_scheme" = "https" ]; then if https_ok_insecure "https://localhost:${frontend_port}"; then echo -e "${DIM} 前端:${NC} ${GREEN}online${NC} ${DIM}(https://localhost:${frontend_port})${NC}" else echo -e "${DIM} 前端:${NC} ${RED}offline${NC} ${DIM}(https://localhost:${frontend_port})${NC}" fi return fi if http_ok "http://localhost:${frontend_port}"; then echo -e "${DIM} 前端:${NC} ${GREEN}online${NC} ${DIM}(http://localhost:${frontend_port})${NC}" elif https_ok_insecure "https://localhost:${frontend_port}"; then echo -e "${DIM} 前端:${NC} ${GREEN}online${NC} ${DIM}(https://localhost:${frontend_port})${NC}" else echo -e "${DIM} 前端:${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 } print_motion_agent_health_status() { local detail="" local tracked_pid="" if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || { [ -f "$MOTION_AGENT_PID_FILE" ] && tracked_pid="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)" && [ -n "$tracked_pid" ] && kill -0 "$tracked_pid" 2>/dev/null }; then echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}" return fi if [ -f "$MOTION_AGENT_SKIPPED_FILE" ]; then detail="$(sed -n 's/^reason=//p' "$MOTION_AGENT_SKIPPED_FILE" 2>/dev/null | head -n 1)" if [ -n "$detail" ]; then echo -e "${DIM} Motion Agent:${NC} ${YELLOW}skipped${NC} ${DIM}(${detail})${NC}" else echo -e "${DIM} Motion Agent:${NC} ${YELLOW}skipped${NC}" fi return fi echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}" } 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" } mark_motion_agent_skipped() { local reason="$1" local detail="${2:-}" mkdir -p "$PLANET_STATE_DIR" { printf "status=skipped\n" printf "time=%s\n" "$(date '+%Y-%m-%d %H:%M:%S')" printf "reason=%s\n" "$reason" if [ -n "$detail" ]; then printf "detail=%s\n" "$detail" fi } > "$MOTION_AGENT_SKIPPED_FILE" typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=1 remove_pid_file "$MOTION_AGENT_PID_FILE" log_warn "Motion Agent skipped: ${reason}" if [ -n "$detail" ]; then log_note "$detail" fi } clear_motion_agent_skipped_state() { typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=0 rm -f "$MOTION_AGENT_SKIPPED_FILE" } release_motion_agent_port_for_start() { local port="$1" local pids="" local pid="" cleanup_motion_agent_processes TERM if wait_for_port_release "$port" 25 0.2; then return 0 fi log_warn "Motion Agent 端口 ${port} 清理后仍不可绑定,尝试强制清理" cleanup_motion_agent_processes KILL if wait_for_port_release "$port" 10 0.2; then return 0 fi pids="$(collect_port_pids "$port" || true)" if [ -n "$pids" ]; then log_warn "发现 Motion Agent 端口 ${port} 仍有监听进程,正在强制终止" for pid in $pids; do terminate_process_group KILL "$pid" terminate_process_tree KILL "$pid" done if wait_for_port_release "$port" 10 0.2; then return 0 fi fi if force_cleanup_external_port_listener "$port" "Motion Agent"; then if wait_for_port_release "$port" 10 0.2; then return 0 fi fi print_port_listener_details "$port" mark_motion_agent_skipped \ "端口 ${port} 被外部环境占用,已跳过 Motion Agent 启动" \ "其他服务会继续启动;需要动捕时请释放端口或改用 --motion-agent-port <端口>。" return 1 } 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 auto_detected_camera_indexes=0 local validated_camera_indexes="" local -a motion_args clear_motion_agent_skipped_state 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 auto_detected_camera_indexes=1 log_note "Motion Agent 自动发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}" else if is_wsl_environment; then if try_motion_agent_wsl_usbipd_camera; then auto_detected_camera_indexes=1 : else 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. 用 --motion-agent-wsl-usbipd 尝试通过 usbipd-win 自动透传唯一 USB 摄像头" log_note " 3. 用 --motion-agent-wsl-usbipd-busid 指定 usbipd list 中的摄像头" log_note " 4. 仅验证 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 ;; *) if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then log_warn "默认启动 Motion Agent 未找到摄像头,降级为 dry-run 协议服务" dry_run=1 else MOTION_AGENT_CAMERA_MISSING=1 fi ;; esac fi else if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then log_warn "默认启动 Motion Agent 未自动发现摄像头,降级为 dry-run 协议服务" dry_run=1 else log_warn "Motion Agent 未自动发现 /dev/video* 摄像头,默认尝试 index 0" fi 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 "WSL USB 示例:./planet.sh restart -m --motion-agent-wsl-usbipd" log_note "或仅调试协议:./planet.sh restart -m --motion-agent-dry-run" exit 1 fi if [ "$dry_run" -eq 0 ]; then ensure_motion_agent_live_deps if [ "$auto_detected_camera_indexes" -eq 1 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ]; then validated_camera_indexes="$(detect_motion_agent_camera_indexes_with_cv2 || true)" if [ -n "$validated_camera_indexes" ]; then if [ "$validated_camera_indexes" != "$MOTION_AGENT_CAMERA_INDEXES" ]; then log_warn "Motion Agent 过滤不可打开的摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES} -> ${validated_camera_indexes}" fi MOTION_AGENT_CAMERA_INDEXES="$validated_camera_indexes" else if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then log_warn "默认启动 Motion Agent 未找到可读帧摄像头,降级为 dry-run 协议服务" dry_run=1 MOTION_AGENT_CAMERA_INDEXES="" else log_error "Motion Agent 未找到可打开并能读帧的摄像头 index" log_note "可手动指定:--motion-agent-camera-indexes " log_note "WSL 下可改用:--motion-agent-camera-urls http://<手机IP>:8080/video" exit 1 fi fi fi fi if ! release_motion_agent_port_for_start "$motion_agent_port"; then return 0 fi if ! can_bind_port "$motion_agent_port"; then print_port_listener_details "$motion_agent_port" mark_motion_agent_skipped \ "端口 ${motion_agent_port} 已被占用" \ "未能释放 Motion Agent 端口,已跳过动捕服务;其他服务继续启动。" return 0 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" --mode "$MOTION_AGENT_MODE") 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 MOTION_AGENT_PID="$(start_detached_command "$MOTION_AGENT_LOG_FILE" \ "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}")" 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 "输入模式可用 --motion-agent-mode auto/single/dual_redundant/single_fallback 指定。" 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 clear_motion_agent_skipped_state } 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 } verify_backend_database_connection() { local check_status=0 local check_command=("$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/scripts/check_database_connection.py") set_wait_detail "验证后端实际 PostgreSQL 地址、端口与认证" run_command_with_spinner "验证后端数据库连接" "${check_command[@]}" || check_status=$? # Exit 2 means missing published ports; credentials and other failures must not recreate data services. [ "$check_status" -eq 2 ] || return "$check_status" log_warn "PostgreSQL 实际端口映射不匹配,保留数据卷并按 Compose 配置重建一次" compose_up up -d --no-deps --force-recreate postgres || return 1 wait_for_postgres_health || return 1 run_command_with_spinner "重新验证后端数据库连接" "${check_command[@]}" } planet_app_services_running() { local backend_port="" local frontend_port="" local frontend_scheme="" 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")" frontend_scheme="$(read_port_state_text_value FRONTEND_SCHEME http)" 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 [ "$frontend_scheme" = "https" ]; then if https_ok_insecure "https://localhost:${frontend_port}"; then return 0 fi else if http_ok "http://localhost:${frontend_port}" || https_ok_insecure "https://localhost:${frontend_port}"; then return 0 fi 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() { parse_service_args "$@" if ! guard_init_when_services_running; then return 0 fi print_splash start_wait_session "准备 Docker 运行环境" if ! ensure_docker_runtime "${PLANET_COMMAND_ARGS[@]}"; then stop_wait_session exit 1 fi stop_wait_session log_success "Docker / Compose / Buildx 已就绪" 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 "环境变量文件已就绪" prepare_motion_agent_host_dependencies start_wait_session "启动数据库服务" ensure_database_services_healthy if ! verify_backend_database_connection; then stop_wait_session log_error "后端数据库连接检查失败,尚未执行建表和默认数据初始化" exit 1 fi 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" if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then release_requested_ports "前端 CA 证书下载" "$FRONTEND_CA_DOWNLOAD_PORT" fi if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then request_windows_portproxy_cleanup_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" "$FRONTEND_CA_DOWNLOAD_PORT" || true request_windows_firewall_rules_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" "$FRONTEND_CA_DOWNLOAD_PORT" || true else 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 fi } 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 if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then log_note "检查本机访问: https://localhost:${FRONTEND_PORT}" https_ok_insecure "https://localhost:${FRONTEND_PORT}" || { log_error "本机访问不可访问: https://localhost:${FRONTEND_PORT}" exit 1 } verify_http_endpoint "本机 CA 下载" "http://localhost:${FRONTEND_CA_DOWNLOAD_PORT}/planet-dev-ca.crt" || exit 1 else verify_http_endpoint "本机访问" "http://localhost:${FRONTEND_PORT}" || exit 1 fi 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 if [ "${FRONTEND_LAN_HTTPS_ENABLED:-0}" -eq 1 ]; then log_note "检查局域网访问: https://${lan_ip}:${FRONTEND_PORT}" https_ok_insecure "https://${lan_ip}:${FRONTEND_PORT}" || { log_error "局域网访问不可访问: https://${lan_ip}:${FRONTEND_PORT}" exit 1 } verify_http_endpoint "局域网 CA 下载" "http://${lan_ip}:${FRONTEND_CA_DOWNLOAD_PORT}/planet-dev-ca.crt" || exit 1 else verify_http_endpoint "局域网访问" "http://${lan_ip}:${FRONTEND_PORT}" || exit 1 fi 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 "$@" prepare_allow_lan_public_ports typeset -g START_RUN_ACTIVE=1 typeset -g START_RUN_COMPLETED=0 typeset -g STARTED_BACKEND_THIS_RUN=0 typeset -g STARTED_FRONTEND_THIS_RUN=0 typeset -g STARTED_MOTION_AGENT_THIS_RUN=0 print_splash start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" typeset -g STARTED_BACKEND_THIS_RUN=1 start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED" "$BACKEND_PORT" typeset -g 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" if [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; then typeset -g STARTED_MOTION_AGENT_THIS_RUN=1 fi fi local frontend_scheme="" frontend_scheme="$(current_frontend_scheme)" write_port_state "$BACKEND_PORT" "$FRONTEND_PORT" "$AI_PROVIDER_PORT" "$MOTION_AGENT_PORT" "$frontend_scheme" verify_allow_lan_access typeset -g START_RUN_COMPLETED=1 typeset -g START_RUN_ACTIVE=0 log_success "启动完成" log_note "智能星球计划: ${frontend_scheme}://localhost:${FRONTEND_PORT}/earth" log_note "智能星球仪表盘: ${frontend_scheme}://localhost:${FRONTEND_PORT}/admin" log_note "AI Playground: ${frontend_scheme}://localhost:${FRONTEND_PORT}/playground" log_note "智能星球文档: ${frontend_scheme}://localhost:${FRONTEND_PORT}/docs" if [ "$MOTION_AGENT_REQUESTED" -eq 1 ] && [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; 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" "$AI_PROVIDER_PORT" "$frontend_scheme" 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" if [ -d "$PLANET_CACHE_DIR" ]; then find "$PLANET_CACHE_DIR" -mindepth 1 -maxdepth 1 ! -name downloads -exec rm -rf {} + fi 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 "$@" local state_backend_port="" local state_frontend_port="" local state_frontend_scheme="" 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_EXPLICIT_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_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then stop_motion_agent_service sleep 1 typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=0 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_frontend_scheme="$(read_port_state_text_value FRONTEND_SCHEME "$(current_frontend_scheme)")" 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" state_frontend_scheme="$(current_frontend_scheme)" fi if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then state_ai_provider_port="$AI_PROVIDER_PORT" fi if [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then state_motion_agent_port="$MOTION_AGENT_PORT" fi local frontend_scheme="" frontend_scheme="$state_frontend_scheme" write_port_state "$state_backend_port" "$state_frontend_port" "$state_ai_provider_port" "$state_motion_agent_port" "$frontend_scheme" 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 "前端: ${frontend_scheme}://localhost:${FRONTEND_PORT}" if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" "$frontend_scheme" fi fi if [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ] && [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; 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_frontend_health_status "$frontend_port" print_motion_agent_health_status } 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 PLANET_COMMAND_ARGS=("$@") parse_global_args "$@" set -- "${GLOBAL_ARG_REMAINDER[@]}" case "$1" in init|start|restart|stop|health|log|createuser) refresh_docker_group "${PLANET_COMMAND_ARGS[@]}" || exit 1 ;; esac case "$1" in init) shift 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 启动服务,默认包含 Motion Agent;可选: -b <后端端口> -f <前端端口> -a --non-motion-agent -m/--motion-agent --motion-agent-port <端口> --motion-agent-mode auto|single|dual_redundant|single_fallback --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-wsl-usbipd --motion-agent-wsl-usbipd-busid --motion-agent-dry-run --allow-lan --verbose" log_note "stop 停止服务" log_note "destroy 删除 Planet 容器、卷、镜像和本地编译状态,执行前需要输入 Y 确认" log_note "restart 重启服务,默认包含 Motion Agent;可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] --non-motion-agent -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