Files
planet/planet.sh
rayd1o 83a10a6c34
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
ci / delivery (push) Blocked by required conditions
release / images (push) Waiting to run
release: bump version to 0.74.6
2026-09-16 21:05:40 +08:00

4739 lines
155 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env zsh
set -e
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
AI_PROVIDER_NO_BUILD=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"
source "$SCRIPT_DIR/scripts/lib/docker-proxy.zsh"
source "$SCRIPT_DIR/scripts/lib/error-diagnostics.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 "$@" </dev/null > "$log_file" 2>&1 &
else
nohup "$@" </dev/null > "$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"
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
report_error_reason "未检测到 docker buildx"
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} 或更高。"
report_error_reason "buildx v${current_buildx_version} 低于要求"
fi
}
compose_up() {
local args=("$@")
local arg_text="$*"
if compose_available; then
set_wait_detail "正在用 docker compose 执行: ${arg_text}"
if docker compose "${args[@]}"; then
return 0
fi
log_error "Docker Compose 执行失败: docker compose ${arg_text}"
return 1
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 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 $?
fi
if compose_v1_available; then
docker-compose build --help >/dev/null 2>&1
return $?
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"
report_error_reason "$1" "${2:-/dev/null}"
}
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 <<EOF
$(hostname -I 2>/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" <<EOF
\$ErrorActionPreference = 'Stop'
\$ports = @(${missing_ports})
foreach (\$port in \$ports) {
\$displayName = "WSL Planet \$port"
\$exists = Get-NetFirewallRule -DisplayName \$displayName -ErrorAction SilentlyContinue |
Where-Object { \$_.Enabled -eq 'True' -and \$_.Direction -eq 'Inbound' -and \$_.Action -eq 'Allow' }
if (-not \$exists) {
New-NetFirewallRule -DisplayName \$displayName -Direction Inbound -Action Allow -Protocol TCP -LocalPort \$port | Out-Null
}
}
Write-Host "Planet firewall rules are ready for ports: \$((\$ports -join ', '))"
Start-Sleep -Seconds 2
EOF
log_warn "检测到 Windows 防火墙可能未放行端口 ${missing_ports},即将请求管理员 PowerShell 添加规则"
run_windows_admin_powershell_script "$script_path" "检测到 Windows 防火墙缺少端口 ${missing_ports} 放行规则" || {
log_warn "管理员 PowerShell 未完成防火墙规则创建;局域网设备可能仍无法访问"
return 1
}
}
windows_portproxy_conflict_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})
\$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" <<EOF
\$ErrorActionPreference = 'Continue'
\$ports = @(${ports_csv})
\$netsh = Join-Path \$env:WINDIR 'System32\netsh.exe'
if (-not (Test-Path \$netsh)) { \$netsh = 'netsh' }
\$lines = @(& \$netsh interface portproxy show v4tov4)
\$removed = @()
foreach (\$line in \$lines) {
if (\$line -match '^\\s*(\\S+)\\s+(\\d+)\\s+(\\S+)\\s+(\\d+)\\s*$') {
\$listenAddress = \$Matches[1]
\$listenPort = [int]\$Matches[2]
if (\$ports -contains \$listenPort) {
& \$netsh interface portproxy delete v4tov4 listenaddress=\$listenAddress listenport=\$listenPort | Out-Null
\$removed += (\$listenAddress + ':' + \$listenPort)
}
}
}
Write-Host "Planet removed stale portproxy rules: \$((\$removed -join ', '))"
Start-Sleep -Seconds 2
EOF
}
request_windows_portproxy_cleanup_if_needed() {
local conflict_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_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" <<EOF
\$ErrorActionPreference = 'Continue'
\$ports = @(${ports_csv})
\$netsh = Join-Path \$env:WINDIR 'System32\netsh.exe'
if (-not (Test-Path \$netsh)) { \$netsh = 'netsh' }
foreach (\$port in \$ports) {
\$lines = @(& \$netsh interface portproxy show v4tov4)
foreach (\$line in \$lines) {
if (\$line -match '^\\s*(\\S+)\\s+(\\d+)\\s+(\\S+)\\s+(\\d+)\\s*$') {
\$listenAddress = \$Matches[1]
\$listenPort = [int]\$Matches[2]
if (\$listenPort -eq \$port) {
& \$netsh interface portproxy delete v4tov4 listenaddress=\$listenAddress listenport=\$listenPort | Out-Null
Write-Host "Planet removed portproxy \$($listenAddress):\$listenPort"
}
}
}
\$connections = @(Get-NetTCPConnection -LocalPort \$port -State Listen -ErrorAction SilentlyContinue)
\$seen = @{}
foreach (\$connection in \$connections) {
\$pidValue = [int]\$connection.OwningProcess
if (\$pidValue -le 0 -or \$seen.ContainsKey(\$pidValue)) { continue }
\$seen[\$pidValue] = \$true
\$process = Get-CimInstance Win32_Process -Filter "ProcessId=\$pidValue" -ErrorAction SilentlyContinue
\$processName = if (\$process.Name) { \$process.Name } else { 'unknown' }
\$services = @(Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | Where-Object { \$_.ProcessId -eq \$pidValue })
foreach (\$service in \$services) {
try {
Stop-Service -Name \$service.Name -Force -ErrorAction Stop
Write-Host "Planet stopped service \$($service.Name) on port \$port"
} catch {
Write-Host "Planet failed to stop service \$($service.Name) on port \$port: \$($_.Exception.Message)"
}
}
try {
Stop-Process -Id \$pidValue -Force -ErrorAction Stop
Write-Host "Planet stopped process \$pidValue (\$processName) on port \$port"
} catch {
Write-Host "Planet failed to stop process \$pidValue (\$processName) on port \$port: \$($_.Exception.Message)"
}
}
}
Start-Sleep -Seconds 1
EOF
}
request_windows_port_cleanup() {
local script_path="$PLANET_STATE_DIR/windows_port_cleanup_planet.ps1"
local ports_label
[ "$#" -gt 0 ] || return 0
is_wsl_environment || return 1
command -v powershell.exe >/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
}
compose_up_ai_provider() {
local args=(up -d)
if [ "${AI_PROVIDER_NO_BUILD:-0}" -eq 1 ]; then
args+=(--no-build)
fi
compose_up "${args[@]}" aiprovider
}
recreate_ai_provider_container() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
remove_ai_provider_containers
if compose_up_ai_provider >/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=""
if [ "${AI_PROVIDER_NO_BUILD:-0}" -eq 1 ]; then
if ! ai_provider_image_exists; then
log_error "已指定 --no-build但本地没有 AI Provider 镜像: ${AI_PROVIDER_IMAGE_NAME}"
exit 1
fi
log_note "跳过 AI Provider 镜像构建,使用本地镜像: ${AI_PROVIDER_IMAGE_NAME}"
AI_PROVIDER_RECREATE_REQUIRED=0
return 0
fi
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
prepare_docker_build_proxy || exit 1
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; then
clear_wait_spinner
log_error "AI Provider 镜像构建失败" "$AI_PROVIDER_BUILD_LOG_FILE"
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() {
if compose_available; then
set_wait_detail "使用 docker compose 构建 AI Provider 镜像"
run_ai_provider_build_command "docker compose"
return $?
fi
if compose_v1_available; then
set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像"
run_ai_provider_build_command "docker-compose"
return $?
fi
report_missing_compose
}
run_ai_provider_build_command() {
local compose_command="$1"
local compose_args=("${(@s: :)compose_command}")
if [ "$VERBOSE" -eq 1 ]; then
run_command_with_spinner "构建 AI Provider 镜像" zsh -o pipefail -c \
'log_file="$1"; shift; "$@" 2>&1 | tee "$log_file"' \
planet-build "$AI_PROVIDER_BUILD_LOG_FILE" "${compose_args[@]}" build aiprovider
return $?
fi
run_command_with_spinner "构建 AI Provider 镜像" zsh -c \
'log_file="$1"; shift; "$@" > "$log_file" 2>&1' \
planet-build "$AI_PROVIDER_BUILD_LOG_FILE" "${compose_args[@]}" build aiprovider
}
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" <<EOF
\$ErrorActionPreference = 'Stop'
Start-Process msiexec.exe -Wait -ArgumentList '/i', '${msi_win_path}', '/qn', '/norestart'
EOF
log_warn "准备请求管理员 PowerShell 安装 usbipd-win"
if run_windows_admin_powershell_script "$script_path" "无法请求管理员 PowerShell 安装 usbipd-win"; then
rm -f "$script_path" 2>/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:-<BUSID>}"
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" <<EOF
\$ErrorActionPreference = 'Stop'
usbipd bind --busid '${busid}'
EOF
log_warn "摄像头 ${busid} 尚未共享,准备弹出管理员 PowerShell 执行 usbipd bind"
if run_windows_admin_powershell_script "$script_path" "无法请求管理员 PowerShell 执行 usbipd bind"; then
rm -f "$script_path" 2>/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 <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 <PID>\"${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_ai_provider >/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 <<EOF
$output
EOF
return 0
}
print_port_listener_details() {
local port="$1"
local found=0
local pid=""
local command_line=""
local lsof_output=""
local ss_output=""
local line=""
if command -v lsof >/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 <<EOF
$ss_output
EOF
fi
for pid in $(collect_port_pids "$port" || true); do
command_line="$(ps -p "$pid" -o args= 2>/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 <<EOF
$(hostname -I 2>/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
AI_PROVIDER_NO_BUILD=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
;;
--no-build)
AI_PROVIDER_NO_BUILD=1
shift 1
;;
-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 "当前看起来是 WSLWindows 摄像头通常不会自动出现在 /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 <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 <index>"
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 "start / restart 可加 --no-buildAI Provider 仅使用本地已有镜像,缺少镜像时报错"
log_note "init 首次初始化空项目: 同步 uv/bun 依赖、生成缺失 env、启动数据库并写入默认数据"
log_note "start 启动服务,默认包含 Motion Agent可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --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 <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