4728 lines
155 KiB
Bash
Executable File
4728 lines
155 KiB
Bash
Executable File
#!/usr/bin/env zsh
|
||
|
||
set -e
|
||
set -o pipefail
|
||
|
||
SCRIPT_PATH="${(%):-%N}"
|
||
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||
cd "$SCRIPT_DIR"
|
||
|
||
load_user_zshrc_exported_env() {
|
||
local load_mode="${PLANET_LOAD_ZSHRC_ENV:-1}"
|
||
[ "$load_mode" != "0" ] || return 0
|
||
[ "$load_mode" != "false" ] || return 0
|
||
[ -f "$HOME/.zshrc" ] || return 0
|
||
|
||
local env_line env_name env_value
|
||
if [ "$load_mode" = "source" ]; then
|
||
command -v zsh >/dev/null 2>&1 || return 0
|
||
while IFS= read -r env_line; do
|
||
env_name="${env_line%%=*}"
|
||
env_value="${env_line#*=}"
|
||
case "$env_name" in
|
||
AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy)
|
||
export "${env_name}=${env_value}"
|
||
;;
|
||
esac
|
||
done < <(zsh -fc 'source ~/.zshrc >/dev/null 2>&1; env' 2>/dev/null || true)
|
||
return 0
|
||
fi
|
||
|
||
while IFS= read -r env_line; do
|
||
env_line="$(printf "%s" "$env_line" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')"
|
||
case "$env_line" in
|
||
""|\#*) continue ;;
|
||
export\ *) env_line="${env_line#export }" ;;
|
||
esac
|
||
env_name="${env_line%%=*}"
|
||
env_value="${env_line#*=}"
|
||
[ "$env_name" != "$env_value" ] || continue
|
||
env_name="$(printf "%s" "$env_name" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')"
|
||
env_value="$(printf "%s" "$env_value" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//;s/^"//;s/"$//;s/^'\''//;s/'\''$//')"
|
||
case "$env_name" in
|
||
AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy)
|
||
export "${env_name}=${env_value}"
|
||
;;
|
||
esac
|
||
done < "$HOME/.zshrc"
|
||
}
|
||
|
||
load_user_zshrc_exported_env
|
||
|
||
RED='\033[38;5;203m'
|
||
GREEN='\033[38;5;114m'
|
||
YELLOW='\033[38;5;221m'
|
||
BLUE='\033[38;5;111m'
|
||
CYAN='\033[38;5;117m'
|
||
WHITE='\033[1;37m'
|
||
DIM='\033[2m'
|
||
NC='\033[0m'
|
||
WAIT_SPINNER_FRAMES=(
|
||
'⠉⠁'
|
||
'⠈⠁'
|
||
'⠈⠉'
|
||
' ⠙'
|
||
' ⠸'
|
||
' ⢰'
|
||
' ⣠'
|
||
'⢀⣀'
|
||
'⣀⡀'
|
||
'⣄ '
|
||
'⡆ '
|
||
'⠇ '
|
||
'⠋ '
|
||
'⠋⠁'
|
||
)
|
||
WAIT_SPINNER_STEP=0
|
||
WAIT_SPINNER_TICKS_PER_SECOND=8
|
||
WAIT_SPINNER_DETAIL=""
|
||
WAIT_SPINNER_MESSAGE=""
|
||
WAIT_SESSION_ACTIVE=0
|
||
PORT_CHECK_STATUS_ACTIVE=0
|
||
VERBOSE=0
|
||
WAIT_VERBOSE_LOG_FILE=""
|
||
WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}"
|
||
WAIT_VERBOSE_TAIL_LINE_COUNT="${WAIT_VERBOSE_TAIL_LINE_COUNT:-40}"
|
||
WAIT_VERBOSE_LAST_LOG_FILE=""
|
||
WAIT_VERBOSE_EMITTED_LINES=0
|
||
HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-2}"
|
||
|
||
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
|
||
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-60}"
|
||
BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}"
|
||
DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}"
|
||
DEPENDENCY_INSTALL_RETRY_INTERVAL="${DEPENDENCY_INSTALL_RETRY_INTERVAL:-5}"
|
||
AI_PROVIDER_HEALTH_CHECK_ATTEMPTS="${AI_PROVIDER_HEALTH_CHECK_ATTEMPTS:-10}"
|
||
AI_PROVIDER_HEALTH_CHECK_INTERVAL="${AI_PROVIDER_HEALTH_CHECK_INTERVAL:-2}"
|
||
AI_PROVIDER_START_MAX_RETRIES="${AI_PROVIDER_START_MAX_RETRIES:-3}"
|
||
AI_PROVIDER_RETRY_INTERVAL="${AI_PROVIDER_RETRY_INTERVAL:-5}"
|
||
DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}"
|
||
DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}"
|
||
DATABASE_HEALTH_CHECK_ATTEMPTS="${DATABASE_HEALTH_CHECK_ATTEMPTS:-10}"
|
||
DATABASE_HEALTH_CHECK_INTERVAL="${DATABASE_HEALTH_CHECK_INTERVAL:-$DATABASE_RETRY_INTERVAL}"
|
||
FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}"
|
||
FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}"
|
||
FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}"
|
||
PORT_RELEASE_ATTEMPTS="${PORT_RELEASE_ATTEMPTS:-15}"
|
||
PORT_RELEASE_INTERVAL="${PORT_RELEASE_INTERVAL:-0.2}"
|
||
PORT_PRESTART_RETRIES="${PORT_PRESTART_RETRIES:-3}"
|
||
PORT_PRESTART_RETRY_INTERVAL="${PORT_PRESTART_RETRY_INTERVAL:-2}"
|
||
DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}"
|
||
DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}"
|
||
DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
|
||
DEFAULT_MOTION_AGENT_PORT="${DEFAULT_MOTION_AGENT_PORT:-8765}"
|
||
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
|
||
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
|
||
PLANET_STATE_DIR="${PLANET_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/planet}"
|
||
PLANET_CACHE_DIR="${PLANET_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/planet}"
|
||
PLANET_UV_TUNA_INDEX_URL="${PLANET_UV_TUNA_INDEX_URL:-https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/}"
|
||
USBIPD_WIN_FALLBACK_VERSION="${USBIPD_WIN_FALLBACK_VERSION:-5.3.0}"
|
||
USBIPD_WIN_FALLBACK_URL_X64="${USBIPD_WIN_FALLBACK_URL_X64:-https://github.com/dorssel/usbipd-win/releases/download/v${USBIPD_WIN_FALLBACK_VERSION}/usbipd-win_${USBIPD_WIN_FALLBACK_VERSION}_x64.msi}"
|
||
USBIPD_WIN_FALLBACK_URL_ARM64="${USBIPD_WIN_FALLBACK_URL_ARM64:-https://github.com/dorssel/usbipd-win/releases/download/v${USBIPD_WIN_FALLBACK_VERSION}/usbipd-win_${USBIPD_WIN_FALLBACK_VERSION}_arm64.msi}"
|
||
USBIPD_WIN_DOWNLOAD_DIR="${USBIPD_WIN_DOWNLOAD_DIR:-$SCRIPT_DIR/downloads/usbipd-win}"
|
||
BACKEND_PID_FILE="$PLANET_STATE_DIR/backend.pid"
|
||
BACKEND_LOG_FILE="$PLANET_STATE_DIR/backend.log"
|
||
FRONTEND_PID_FILE="$PLANET_STATE_DIR/frontend.pid"
|
||
FRONTEND_LOG_FILE="$PLANET_STATE_DIR/frontend.log"
|
||
FRONTEND_CA_SERVER_PID_FILE="$PLANET_STATE_DIR/frontend_ca_server.pid"
|
||
FRONTEND_CA_SERVER_LOG_FILE="$PLANET_STATE_DIR/frontend_ca_server.log"
|
||
FRONTEND_CERT_DIR="$PLANET_STATE_DIR/certs"
|
||
FRONTEND_CA_CERT_FILE="$FRONTEND_CERT_DIR/planet-dev-ca.crt"
|
||
FRONTEND_CA_KEY_FILE="$FRONTEND_CERT_DIR/planet-dev-ca.key"
|
||
FRONTEND_HTTPS_CERT_FILE="$FRONTEND_CERT_DIR/planet-dev-server.crt"
|
||
FRONTEND_HTTPS_KEY_FILE="$FRONTEND_CERT_DIR/planet-dev-server.key"
|
||
FRONTEND_CA_DOWNLOAD_PORT="${FRONTEND_CA_DOWNLOAD_PORT:-8088}"
|
||
PLANET_PORT_STATE_FILE="$PLANET_STATE_DIR/ports.env"
|
||
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
|
||
MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid"
|
||
MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log"
|
||
MOTION_AGENT_SKIPPED_FILE="$PLANET_STATE_DIR/motion_agent.skipped"
|
||
AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256"
|
||
AI_PROVIDER_BUILD_LOG_FILE="$PLANET_STATE_DIR/aiprovider_build.log"
|
||
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet-aiprovider:latest}"
|
||
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}"
|
||
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-$PLANET_STATE_DIR/aiprovider_runtime.env}"
|
||
PLANET_EMPTY_UV_CONFIG_FILE="$PLANET_STATE_DIR/uv.empty.toml"
|
||
PLANET_TUNA_UV_CONFIG_FILE="$PLANET_STATE_DIR/uv.tuna.toml"
|
||
PLANET_UV_CONFIG_FILE="${PLANET_UV_CONFIG_FILE:-}"
|
||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||
START_RUN_ACTIVE=0
|
||
START_RUN_COMPLETED=0
|
||
STARTED_BACKEND_THIS_RUN=0
|
||
MOTION_AGENT_SKIPPED_THIS_RUN=0
|
||
STARTED_FRONTEND_THIS_RUN=0
|
||
STARTED_MOTION_AGENT_THIS_RUN=0
|
||
AI_PROVIDER_RUNTIME_ENV_NAMES=(
|
||
SERVICE_NAME
|
||
SERVICE_VERSION
|
||
AI_PROVIDER
|
||
AI_PROVIDER_API
|
||
AI_BASE_URL
|
||
AI_API_KEY
|
||
AI_MODEL
|
||
AI_TIMEOUT_SECONDS
|
||
AI_HTTP_RETRY_ATTEMPTS
|
||
AI_MAX_TOKENS
|
||
AI_ANTHROPIC_VERSION
|
||
AI_ANALYSIS_SYSTEM_PROMPT
|
||
AI_PROVIDER_SERVICE_TOKEN
|
||
)
|
||
|
||
mkdir -p "$PLANET_STATE_DIR" "$PLANET_CACHE_DIR"
|
||
chmod 700 "$PLANET_STATE_DIR" "$PLANET_CACHE_DIR" 2>/dev/null || true
|
||
|
||
prepend_path_once() {
|
||
local path_entry="$1"
|
||
[ -n "$path_entry" ] || return 0
|
||
[ -d "$path_entry" ] || return 0
|
||
|
||
case ":$PATH:" in
|
||
*":$path_entry:"*) ;;
|
||
*) export PATH="$path_entry:$PATH" ;;
|
||
esac
|
||
}
|
||
|
||
ensure_local_runtime_bins_on_path() {
|
||
prepend_path_once "$HOME/.local/bin"
|
||
prepend_path_once "$HOME/.bun/bin"
|
||
}
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
resolve_uv_config_file() {
|
||
local candidate
|
||
|
||
if [ -n "${UV_CONFIG_FILE:-}" ] && [ -f "$UV_CONFIG_FILE" ]; then
|
||
printf "%s\n" "$UV_CONFIG_FILE"
|
||
return 0
|
||
fi
|
||
|
||
for candidate in \
|
||
"$SCRIPT_DIR/uv.toml" \
|
||
"${XDG_CONFIG_HOME:-$HOME/.config}/uv/uv.toml" \
|
||
"$HOME/.uv/uv.toml"; do
|
||
if [ -f "$candidate" ]; then
|
||
printf "%s\n" "$candidate"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
prepare_uv_build_config() {
|
||
local uv_config_file
|
||
|
||
uv_config_file="$(resolve_uv_config_file || true)"
|
||
if [ -n "$uv_config_file" ]; then
|
||
PLANET_UV_CONFIG_FILE="$uv_config_file"
|
||
else
|
||
: > "$PLANET_EMPTY_UV_CONFIG_FILE"
|
||
PLANET_UV_CONFIG_FILE="$PLANET_EMPTY_UV_CONFIG_FILE"
|
||
fi
|
||
|
||
UV_CONFIG_FILE="$PLANET_UV_CONFIG_FILE"
|
||
unset UV_DEFAULT_INDEX UV_INDEX_URL UV_EXTRA_INDEX_URL
|
||
export UV_CONFIG_FILE
|
||
export PLANET_UV_CONFIG_FILE
|
||
}
|
||
|
||
prepare_uv_build_config
|
||
|
||
# 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
|
||
}
|
||
|
||
compose_available() {
|
||
docker compose version >/dev/null 2>&1
|
||
}
|
||
|
||
compose_v1_available() {
|
||
command -v docker-compose >/dev/null 2>&1
|
||
}
|
||
|
||
report_missing_compose() {
|
||
clear_wait_spinner
|
||
log_error "未检测到可用的 Docker Compose"
|
||
log_note '优先使用 `docker compose`'
|
||
log_note "如果当前机器仍只装有旧版 `docker-compose`,也请确认该命令可执行"
|
||
log_note "推荐安装 Docker Compose 插件:"
|
||
log_note ' mkdir -p ~/.docker/cli-plugins'
|
||
log_note ' curl -SL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose'
|
||
log_note ' chmod +x ~/.docker/cli-plugins/docker-compose'
|
||
log_note ' docker compose version'
|
||
return 1
|
||
}
|
||
|
||
buildx_version() {
|
||
docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//'
|
||
}
|
||
|
||
buildx_meets_minimum() {
|
||
local current_version="$1"
|
||
local required_version="$2"
|
||
|
||
[ -n "$current_version" ] || return 1
|
||
[ -n "$required_version" ] || return 1
|
||
|
||
if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then
|
||
return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
log_buildx_diagnostics_if_needed() {
|
||
local current_buildx_version=""
|
||
local required_buildx_version="0.17.0"
|
||
|
||
current_buildx_version="$(buildx_version)"
|
||
|
||
if [ -z "$current_buildx_version" ]; then
|
||
log_note "未检测到 docker buildx,AI Provider 镜像构建依赖 buildx。"
|
||
log_note "请先执行: docker buildx version"
|
||
return 0
|
||
fi
|
||
|
||
if ! buildx_meets_minimum "$current_buildx_version" "$required_buildx_version"; then
|
||
log_note "检测到 docker buildx 当前版本为 v${current_buildx_version},但 compose build 需要 v${required_buildx_version} 或更高。"
|
||
log_note "可先升级 buildx 插件,再重新执行 ./planet.sh start"
|
||
log_note "请先执行: docker buildx version"
|
||
fi
|
||
}
|
||
|
||
compose_up() {
|
||
local args=("$@")
|
||
local arg_text="$*"
|
||
|
||
if compose_available; then
|
||
set_wait_detail "正在用 docker compose 执行: ${arg_text}"
|
||
if docker compose "${args[@]}"; then
|
||
return 0
|
||
fi
|
||
log_warn "docker compose 执行失败,回退到 docker-compose v1"
|
||
fi
|
||
|
||
if compose_v1_available; then
|
||
set_wait_detail "正在用 docker-compose v1 执行: ${arg_text}"
|
||
if docker-compose "${args[@]}"; then
|
||
return 0
|
||
fi
|
||
clear_wait_spinner
|
||
log_error "docker compose 与 docker-compose v1 均执行失败"
|
||
log_note "最后尝试的命令: docker-compose ${arg_text}"
|
||
return 1
|
||
fi
|
||
|
||
report_missing_compose
|
||
}
|
||
|
||
compose_supports_build() {
|
||
if compose_available; then
|
||
docker compose build --help >/dev/null 2>&1 && return 0
|
||
fi
|
||
if compose_v1_available; then
|
||
docker-compose build --help >/dev/null 2>&1 && return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# Terminal rendering helpers
|
||
render_wait_spinner() {
|
||
local message="$1"
|
||
local step="${2:-1}"
|
||
local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} ))
|
||
local frame="${WAIT_SPINNER_FRAMES[$frame_index]}"
|
||
local detail="${WAIT_SPINNER_DETAIL:- }"
|
||
flush_wait_verbose_log
|
||
|
||
printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2
|
||
printf "\033[1A\r" >&2
|
||
}
|
||
|
||
animate_wait_spinner() {
|
||
local message="$1"
|
||
local duration="${2:-1}"
|
||
local ticks=$(( duration * WAIT_SPINNER_TICKS_PER_SECOND ))
|
||
local tick=0
|
||
|
||
if [ "$ticks" -lt 1 ]; then
|
||
ticks=1
|
||
fi
|
||
|
||
while [ "$tick" -lt "$ticks" ]; do
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
render_wait_spinner "$message" "$WAIT_SPINNER_STEP"
|
||
sleep 0.125
|
||
tick=$((tick + 1))
|
||
done
|
||
}
|
||
|
||
clear_wait_spinner() {
|
||
local line_count=0
|
||
|
||
printf "\r\033[K" >&2
|
||
line_count=0
|
||
while [ "$line_count" -lt 1 ]; do
|
||
printf "\n\033[K" >&2
|
||
line_count=$((line_count + 1))
|
||
done
|
||
|
||
printf "\033[1A\r" >&2
|
||
flush_wait_verbose_log
|
||
}
|
||
|
||
start_wait_session() {
|
||
WAIT_SESSION_ACTIVE=1
|
||
WAIT_SPINNER_MESSAGE="$1"
|
||
WAIT_SPINNER_DETAIL=""
|
||
WAIT_SPINNER_STEP=1
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
}
|
||
|
||
resume_wait_session() {
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
fi
|
||
}
|
||
|
||
stop_wait_session() {
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||
clear_wait_spinner
|
||
fi
|
||
WAIT_SESSION_ACTIVE=0
|
||
reset_wait_spinner_state
|
||
}
|
||
|
||
close_wait_session_context() {
|
||
local owns_wait_session="$1"
|
||
|
||
if [ "$owns_wait_session" -eq 1 ]; then
|
||
stop_wait_session
|
||
else
|
||
reset_wait_spinner_state
|
||
fi
|
||
}
|
||
|
||
reset_wait_spinner_state() {
|
||
WAIT_SPINNER_DETAIL=""
|
||
WAIT_SPINNER_MESSAGE=""
|
||
WAIT_VERBOSE_LOG_FILE=""
|
||
WAIT_VERBOSE_LAST_LOG_FILE=""
|
||
WAIT_VERBOSE_EMITTED_LINES=0
|
||
}
|
||
|
||
sanitize_wait_detail() {
|
||
local line="$1"
|
||
|
||
line="$(printf "%s" "$line" | tr '\t' ' ' | tr -d '\r')"
|
||
line="$(printf "%s" "$line" | sed -E 's/\x1B\[[0-9;]*[[:alpha:]]//g')"
|
||
line="$(printf "%s" "$line" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')"
|
||
|
||
if [ "${#line}" -gt 140 ]; then
|
||
line="${line:0:137}..."
|
||
fi
|
||
|
||
printf "%s" "$line"
|
||
}
|
||
|
||
flush_wait_verbose_log() {
|
||
local line=""
|
||
local line_number=0
|
||
local total_lines=0
|
||
|
||
[ "$VERBOSE" -eq 1 ] || return 0
|
||
[ -n "$WAIT_VERBOSE_LOG_FILE" ] || return 0
|
||
[ -f "$WAIT_VERBOSE_LOG_FILE" ] || return 0
|
||
|
||
if [ "$WAIT_VERBOSE_LAST_LOG_FILE" != "$WAIT_VERBOSE_LOG_FILE" ]; then
|
||
WAIT_VERBOSE_LAST_LOG_FILE="$WAIT_VERBOSE_LOG_FILE"
|
||
WAIT_VERBOSE_EMITTED_LINES=0
|
||
fi
|
||
|
||
total_lines="$(wc -l < "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null | tr -d '[:space:]')"
|
||
[ -n "$total_lines" ] || total_lines=0
|
||
if [ "$total_lines" -lt "$WAIT_VERBOSE_EMITTED_LINES" ]; then
|
||
WAIT_VERBOSE_EMITTED_LINES=0
|
||
fi
|
||
[ "$total_lines" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || return 0
|
||
|
||
printf "\r\033[K" >&2
|
||
while IFS= read -r line; do
|
||
line_number=$((line_number + 1))
|
||
[ "$line_number" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || continue
|
||
line="$(sanitize_wait_detail "$line")"
|
||
[ -n "$line" ] || continue
|
||
printf "${DIM} %s${NC}\n" "$line" >&2
|
||
done < "$WAIT_VERBOSE_LOG_FILE"
|
||
|
||
WAIT_VERBOSE_EMITTED_LINES="$total_lines"
|
||
}
|
||
|
||
set_wait_detail() {
|
||
WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")"
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
fi
|
||
}
|
||
|
||
run_command_with_spinner() {
|
||
local message="$1"
|
||
shift
|
||
local verbose_log_file=""
|
||
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||
set_wait_detail "$message"
|
||
fi
|
||
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
verbose_log_file="$(mktemp "$PLANET_STATE_DIR/verbose.XXXXXX.log")"
|
||
WAIT_VERBOSE_LOG_FILE="$verbose_log_file"
|
||
"$@" > "$verbose_log_file" 2>&1 &
|
||
else
|
||
"$@" &
|
||
fi
|
||
local command_pid=$!
|
||
local exit_code=0
|
||
|
||
while kill -0 "$command_pid" 2>/dev/null; do
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
else
|
||
render_wait_spinner "$message" "$WAIT_SPINNER_STEP"
|
||
fi
|
||
sleep 0.125
|
||
done
|
||
|
||
wait "$command_pid" || exit_code=$?
|
||
if [ "$VERBOSE" -eq 1 ] && [ -n "$verbose_log_file" ]; then
|
||
WAIT_VERBOSE_LOG_FILE="$verbose_log_file"
|
||
fi
|
||
return "$exit_code"
|
||
}
|
||
|
||
run_command_quiet_unless_verbose() {
|
||
local log_file="$1"
|
||
shift
|
||
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
"$@"
|
||
return $?
|
||
fi
|
||
|
||
"$@" > "$log_file" 2>&1
|
||
}
|
||
|
||
resolve_bun_from_shell_configs() {
|
||
local shell_name="$1"
|
||
shift
|
||
|
||
local config_file
|
||
local candidate=""
|
||
local bun_install=""
|
||
|
||
for config_file in "$@"; do
|
||
[ -f "$config_file" ] || continue
|
||
|
||
if grep -Eq 'BUN_INSTALL|\.bun/bin|bun ' "$config_file" 2>/dev/null; then
|
||
bun_install="$(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?BUN_INSTALL=["'\'']?([^"'\'']+)["'\'']?/\2/p' "$config_file" | tail -n 1)"
|
||
|
||
if [ -n "$bun_install" ]; then
|
||
bun_install="${bun_install/#\$HOME/$HOME}"
|
||
bun_install="${bun_install/#\~/$HOME}"
|
||
candidate="$bun_install/bin/bun"
|
||
if [ -x "$candidate" ]; then
|
||
FRONTEND_RUNTIME_BIN="$candidate"
|
||
FRONTEND_RUNTIME_SOURCE="${shell_name}-config"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
candidate="$HOME/.bun/bin/bun"
|
||
if [ -x "$candidate" ]; then
|
||
FRONTEND_RUNTIME_BIN="$candidate"
|
||
FRONTEND_RUNTIME_SOURCE="${shell_name}-config"
|
||
return 0
|
||
fi
|
||
fi
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
finish_wait_spinner() {
|
||
log_line "done" "$GREEN" "$1"
|
||
}
|
||
|
||
# Logging helpers
|
||
log_timestamp() {
|
||
date '+%Y-%m-%d %H:%M:%S'
|
||
}
|
||
|
||
log_line() {
|
||
local label="$1"
|
||
local color="$2"
|
||
local message="$3"
|
||
clear_wait_spinner
|
||
printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC} ${DIM}%s${NC}\n" "$label" "$message" "$(log_timestamp)"
|
||
resume_wait_session
|
||
}
|
||
|
||
log_step() {
|
||
log_line "run" "$BLUE" "$1"
|
||
}
|
||
|
||
log_warn() {
|
||
log_line "warn" "$YELLOW" "$1"
|
||
}
|
||
|
||
log_halt() {
|
||
log_line "stopped" "$RED" "$1"
|
||
}
|
||
|
||
log_error() {
|
||
log_line "fail" "$RED" "$1"
|
||
}
|
||
|
||
log_note() {
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||
clear_wait_spinner
|
||
fi
|
||
printf "${DIM} %s${NC}\n" "$1"
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||
resume_wait_session
|
||
fi
|
||
}
|
||
|
||
log_success() {
|
||
log_line "done" "$GREEN" "$1"
|
||
}
|
||
|
||
get_recommended_lan_ipv4() {
|
||
local candidate
|
||
|
||
while read -r candidate; do
|
||
[ -z "$candidate" ] && continue
|
||
case "$candidate" in
|
||
127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*)
|
||
continue
|
||
;;
|
||
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
|
||
printf "%s" "$candidate"
|
||
return 0
|
||
;;
|
||
esac
|
||
done <<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
|
||
}
|
||
}
|
||
'
|
||
}
|
||
|
||
docker_daemon_available() {
|
||
docker info >/dev/null 2>&1
|
||
}
|
||
|
||
log_docker_daemon_diagnostics() {
|
||
local docker_service_status=""
|
||
local docker_socket_status=""
|
||
local service_hint="sudo systemctl enable --now docker.socket && sudo systemctl restart docker"
|
||
|
||
docker_service_status="$(systemctl is-active docker 2>/dev/null || true)"
|
||
docker_socket_status="$(systemctl is-active docker.socket 2>/dev/null || true)"
|
||
|
||
if [ -S /var/run/docker.sock ] && [ ! -r /var/run/docker.sock ]; then
|
||
log_note "检测到 /var/run/docker.sock 存在,但当前用户无法访问。"
|
||
log_note "请确认当前用户已加入 docker 组,并重新登录会话。"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$docker_service_status" = "active" ]; then
|
||
log_note "Docker CLI 可执行,但当前会话仍无法连接 daemon。"
|
||
log_note "请先执行: docker info"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$docker_service_status" = "failed" ] || [ "$docker_socket_status" = "failed" ]; then
|
||
log_note "检测到 Docker daemon 当前未正常运行。"
|
||
log_note "可先执行: ${service_hint}"
|
||
log_note "如仍失败,继续查看: journalctl -u docker.service -n 50 --no-pager"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$docker_socket_status" != "active" ]; then
|
||
log_note "检测到 Docker socket 未激活,daemon 可能无法通过 systemd socket activation 启动。"
|
||
log_note "可先执行: ${service_hint}"
|
||
return 0
|
||
fi
|
||
|
||
log_note "当前无法连接 Docker daemon,请先执行: docker info"
|
||
log_note "若 daemon 未启动,可尝试: ${service_hint}"
|
||
}
|
||
|
||
print_database_failure_diagnostics() {
|
||
if ! docker_daemon_available; then
|
||
log_note "数据库容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。"
|
||
log_docker_daemon_diagnostics
|
||
return 0
|
||
fi
|
||
|
||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||
docker logs --tail 20 planet_redis 2>/dev/null || true
|
||
}
|
||
|
||
compute_ai_provider_build_fingerprint() {
|
||
(
|
||
cd "$SCRIPT_DIR" || exit 1
|
||
{
|
||
printf "PYTHON_IMAGE=%s\n" "${PYTHON_IMAGE:-python:3.14-slim}"
|
||
printf "UV_IMAGE=%s\n" "${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}"
|
||
sha256sum aiprovider/Dockerfile pyproject.toml uv.lock 2>/dev/null
|
||
find aiprovider \
|
||
-type f \
|
||
! -path '*/__pycache__/*' \
|
||
! -name '.env' \
|
||
! -name '.env.*' \
|
||
! -name '*.pyc' \
|
||
! -name '*.pyo' \
|
||
| LC_ALL=C sort \
|
||
| xargs -r sha256sum 2>/dev/null
|
||
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null
|
||
}
|
||
) | sha256sum | awk '{print $1}'
|
||
}
|
||
|
||
read_ai_provider_build_stamp() {
|
||
[ -f "$AI_PROVIDER_BUILD_STAMP_FILE" ] || return 1
|
||
tr -d '[:space:]' < "$AI_PROVIDER_BUILD_STAMP_FILE"
|
||
}
|
||
|
||
write_ai_provider_build_stamp() {
|
||
local fingerprint="$1"
|
||
mkdir -p "$(dirname "$AI_PROVIDER_BUILD_STAMP_FILE")"
|
||
printf "%s\n" "$fingerprint" > "$AI_PROVIDER_BUILD_STAMP_FILE"
|
||
}
|
||
|
||
ai_provider_image_exists() {
|
||
docker image inspect "$AI_PROVIDER_IMAGE_NAME" >/dev/null 2>&1
|
||
}
|
||
|
||
read_ai_provider_image_fingerprint() {
|
||
docker image inspect \
|
||
--format '{{ index .Config.Labels "planet.aiprovider.build-fingerprint" }}' \
|
||
"$AI_PROVIDER_IMAGE_NAME" 2>/dev/null || true
|
||
}
|
||
|
||
list_ai_provider_containers() {
|
||
docker ps -aq --filter "name=planet_aiprovider" 2>/dev/null || true
|
||
}
|
||
|
||
remove_ai_provider_containers() {
|
||
local container_ids
|
||
container_ids="$(list_ai_provider_containers)"
|
||
[ -n "$container_ids" ] || return 0
|
||
printf "%s\n" "$container_ids" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||
}
|
||
|
||
run_ai_provider_container_manually() {
|
||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
local env_file_args=()
|
||
|
||
if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then
|
||
env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env")
|
||
fi
|
||
if [ -s "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" ]; then
|
||
env_file_args+=(--env-file "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE")
|
||
fi
|
||
|
||
docker run -d \
|
||
--name "$AI_PROVIDER_CONTAINER_NAME" \
|
||
"${env_file_args[@]}" \
|
||
-p "${ai_provider_port}:8010" \
|
||
"$AI_PROVIDER_IMAGE_NAME" >/dev/null
|
||
}
|
||
|
||
write_ai_provider_runtime_env_file() {
|
||
local env_name env_value
|
||
|
||
: > "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE"
|
||
chmod 600 "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" 2>/dev/null || true
|
||
|
||
for env_name in "${AI_PROVIDER_RUNTIME_ENV_NAMES[@]}"; do
|
||
if [ -n "${(P)env_name+x}" ]; then
|
||
env_value="${(P)env_name}"
|
||
printf "%s=%s\n" "$env_name" "$env_value" >> "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE"
|
||
fi
|
||
done
|
||
|
||
export PLANET_AI_PROVIDER_RUNTIME_ENV_FILE
|
||
}
|
||
|
||
recreate_ai_provider_container() {
|
||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
|
||
remove_ai_provider_containers
|
||
|
||
if compose_up up -d aiprovider >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
|
||
run_ai_provider_container_manually "$ai_provider_port"
|
||
}
|
||
|
||
ensure_ai_provider_image_current() {
|
||
local current_fingerprint=""
|
||
local previous_fingerprint=""
|
||
|
||
prepare_uv_build_config
|
||
|
||
current_fingerprint="$(compute_ai_provider_build_fingerprint)"
|
||
previous_fingerprint="$(read_ai_provider_build_stamp || true)"
|
||
|
||
if ai_provider_image_exists; then
|
||
local image_fingerprint
|
||
image_fingerprint="$(read_ai_provider_image_fingerprint)"
|
||
if [ -n "$image_fingerprint" ] && [ "$current_fingerprint" = "$image_fingerprint" ]; then
|
||
write_ai_provider_build_stamp "$current_fingerprint"
|
||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||
return 0
|
||
fi
|
||
if [ -z "$image_fingerprint" ] && [ -n "$previous_fingerprint" ] && [ "$current_fingerprint" = "$previous_fingerprint" ]; then
|
||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
set_wait_detail "检测到 AI Provider 代码或配置变化,重建镜像"
|
||
AI_PROVIDER_BUILD_FINGERPRINT="$current_fingerprint"
|
||
export AI_PROVIDER_BUILD_FINGERPRINT
|
||
|
||
if ! compose_supports_build; then
|
||
log_error "当前 docker compose 不支持 build,请检查 Docker / Compose 环境"
|
||
exit 1
|
||
fi
|
||
|
||
: > "$AI_PROVIDER_BUILD_LOG_FILE"
|
||
|
||
if ! build_ai_provider_image_with_fallback; then
|
||
clear_wait_spinner
|
||
log_error "AI Provider 镜像构建失败"
|
||
tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true
|
||
log_buildx_diagnostics_if_needed
|
||
exit 1
|
||
fi
|
||
log_success "AI Provider 镜像已构建"
|
||
|
||
write_ai_provider_build_stamp "$current_fingerprint"
|
||
AI_PROVIDER_RECREATE_REQUIRED=1
|
||
}
|
||
|
||
build_ai_provider_image_with_fallback() {
|
||
if compose_available; then
|
||
set_wait_detail "使用 docker compose 构建 AI Provider 镜像"
|
||
if run_ai_provider_build_command "docker compose"; then
|
||
return 0
|
||
fi
|
||
if compose_v1_available; then
|
||
log_warn "docker compose 构建失败,回退到 docker-compose v1"
|
||
else
|
||
log_warn "docker compose 构建失败,当前环境未安装 docker-compose v1,无法继续回退"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
if compose_v1_available; then
|
||
set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像"
|
||
if run_ai_provider_build_command "docker-compose"; then
|
||
return 0
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
report_missing_compose
|
||
}
|
||
|
||
run_ai_provider_build_command() {
|
||
local compose_command="$1"
|
||
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider 2>&1 | tee \"$AI_PROVIDER_BUILD_LOG_FILE\""
|
||
return $?
|
||
fi
|
||
|
||
run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"
|
||
}
|
||
|
||
install_uv_if_needed() {
|
||
if command -v uv >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
|
||
set_wait_detail "未找到 uv,准备自动安装"
|
||
mkdir -p "$HOME/.local/bin"
|
||
|
||
if ! run_with_retry \
|
||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||
"uv 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
|
||
"安装 uv" \
|
||
sh -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'; then
|
||
exit 1
|
||
fi
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
if ! command -v uv >/dev/null 2>&1; then
|
||
log_error "uv 安装完成后仍未找到命令,请检查 ~/.local/bin"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
install_bun_if_needed() {
|
||
if command -v bun >/dev/null 2>&1 || [ -x "$HOME/.bun/bin/bun" ]; then
|
||
ensure_local_runtime_bins_on_path
|
||
return 0
|
||
fi
|
||
|
||
if ! ensure_system_command unzip unzip "unzip"; then
|
||
exit 1
|
||
fi
|
||
|
||
set_wait_detail "未找到 bun,准备自动安装"
|
||
mkdir -p "$HOME/.bun"
|
||
|
||
if ! run_with_retry \
|
||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||
"bun 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
|
||
"安装 bun" \
|
||
sh -c 'curl -fsSL https://bun.sh/install | bash'; then
|
||
exit 1
|
||
fi
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
if [ ! -x "$HOME/.bun/bin/bun" ] && ! command -v bun >/dev/null 2>&1; then
|
||
log_error "bun 安装完成后仍未找到命令,请检查 ~/.bun/bin"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
ensure_uv_backend_deps() {
|
||
local log_file="$PLANET_STATE_DIR/uv_sync.log"
|
||
|
||
set_wait_detail "检查后端 uv 环境"
|
||
|
||
if ! command -v uv >/dev/null 2>&1; then
|
||
install_uv_if_needed
|
||
fi
|
||
|
||
cd "$SCRIPT_DIR"
|
||
: > "$log_file"
|
||
|
||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||
log_warn "未检测到 .venv,正在执行 uv sync"
|
||
if ! run_uv_sync_with_mirror_fallback "$log_file"; then
|
||
tail -20 "$log_file" 2>/dev/null || true
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||
log_error "uv 环境初始化失败,未找到 .venv/bin/python"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
ensure_python_runtime() {
|
||
local log_file="$PLANET_STATE_DIR/uv_python_install.log"
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
if ! command -v uv >/dev/null 2>&1; then
|
||
install_uv_if_needed
|
||
fi
|
||
|
||
cd "$SCRIPT_DIR"
|
||
: > "$log_file"
|
||
|
||
set_wait_detail "安装 Python 3.14 运行时"
|
||
if ! run_with_retry \
|
||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||
"Python 3.14 运行时安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
|
||
"uv python install 3.14" \
|
||
run_command_quiet_unless_verbose "$log_file" uv python install 3.14; then
|
||
tail -20 "$log_file" 2>/dev/null || true
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
sync_python_deps() {
|
||
local log_file="$PLANET_STATE_DIR/uv_sync.log"
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
if ! command -v uv >/dev/null 2>&1; then
|
||
install_uv_if_needed
|
||
fi
|
||
|
||
cd "$SCRIPT_DIR"
|
||
: > "$log_file"
|
||
|
||
set_wait_detail "同步 Python 依赖"
|
||
if ! run_uv_sync_with_mirror_fallback "$log_file"; then
|
||
tail -20 "$log_file" 2>/dev/null || true
|
||
exit 1
|
||
fi
|
||
|
||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||
log_error "uv 环境初始化失败,未找到 .venv/bin/python"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
motion_agent_live_deps_ready() {
|
||
"$SCRIPT_DIR/.venv/bin/python" - <<'PY' >/dev/null 2>&1
|
||
import cv2 # noqa: F401
|
||
import mediapipe # noqa: F401
|
||
PY
|
||
}
|
||
|
||
detect_motion_agent_camera_indexes() {
|
||
local devices=""
|
||
local device=""
|
||
local index=""
|
||
local cv2_devices=""
|
||
|
||
cv2_devices="$(detect_motion_agent_camera_indexes_with_cv2 || true)"
|
||
if [ -n "$cv2_devices" ]; then
|
||
printf "%s" "$cv2_devices"
|
||
return 0
|
||
fi
|
||
|
||
if command -v v4l2-ctl >/dev/null 2>&1; then
|
||
devices="$(v4l2-ctl --list-devices 2>/dev/null | sed -nE 's/^[[:space:]]*\\/dev\\/video([0-9]+).*/\\1/p' || true)"
|
||
fi
|
||
|
||
if [ -z "$devices" ]; then
|
||
for device in /dev/video*(N); do
|
||
[ -e "$device" ] || continue
|
||
index="${device#/dev/video}"
|
||
[[ "$index" =~ ^[0-9]+$ ]] || continue
|
||
printf "%s\n" "$index"
|
||
done | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, -
|
||
return 0
|
||
fi
|
||
|
||
printf "%s\n" "$devices" | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, -
|
||
}
|
||
|
||
detect_motion_agent_camera_indexes_with_cv2() {
|
||
local python_bin="$SCRIPT_DIR/.venv/bin/python"
|
||
|
||
[ -x "$python_bin" ] || return 1
|
||
|
||
"$python_bin" - <<'PY' 2>/dev/null
|
||
from __future__ import annotations
|
||
|
||
import glob
|
||
import re
|
||
|
||
try:
|
||
import cv2 # type: ignore
|
||
except Exception:
|
||
raise SystemExit(1)
|
||
|
||
|
||
indexes: list[int] = []
|
||
for path in glob.glob("/dev/video*"):
|
||
match = re.search(r"/dev/video(\d+)$", path)
|
||
if match:
|
||
indexes.append(int(match.group(1)))
|
||
|
||
usable: list[str] = []
|
||
for index in sorted(set(indexes)):
|
||
capture = cv2.VideoCapture(index, cv2.CAP_V4L2)
|
||
try:
|
||
if not capture or not capture.isOpened():
|
||
continue
|
||
ok, _frame = capture.read()
|
||
if ok:
|
||
usable.append(str(index))
|
||
finally:
|
||
if capture:
|
||
capture.release()
|
||
|
||
print(",".join(usable[:2]))
|
||
PY
|
||
}
|
||
|
||
is_wsl_environment() {
|
||
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
|
||
}
|
||
|
||
detect_windows_usbipd_camera_busids() {
|
||
local line=""
|
||
local busid=""
|
||
|
||
command -v usbipd.exe >/dev/null 2>&1 || return 0
|
||
|
||
usbipd.exe list 2>/dev/null | tr -d '\r' | while IFS= read -r line; do
|
||
[[ "$line" =~ ^[[:space:]]*([0-9]+-[0-9]+)[[:space:]] ]] || continue
|
||
busid="${match[1]}"
|
||
case "$line" in
|
||
*[Cc]amera*|*[Ww]ebcam*|*"USB Video"*|*UVC*|*摄像头*|*相机*)
|
||
printf "%s|%s\n" "$busid" "$line"
|
||
;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
download_usbipd_win_msi() {
|
||
local output_path="$1"
|
||
local fallback_url="$2"
|
||
|
||
mkdir -p "$(dirname "$output_path")"
|
||
|
||
"$SCRIPT_DIR/.venv/bin/python" - "$output_path" "$fallback_url" <<'PY'
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
output = Path(sys.argv[1])
|
||
fallback_url = sys.argv[2]
|
||
arch = "arm64" if "aarch64" in __import__("platform").machine().lower() or "arm64" in __import__("platform").machine().lower() else "x64"
|
||
latest_api = "https://api.github.com/repos/dorssel/usbipd-win/releases/latest"
|
||
|
||
|
||
def download(url: str) -> None:
|
||
request = urllib.request.Request(url, headers={"User-Agent": "planet-bootstrap"})
|
||
with urllib.request.urlopen(request, timeout=25) as response:
|
||
output.write_bytes(response.read())
|
||
|
||
|
||
try:
|
||
request = urllib.request.Request(latest_api, headers={"User-Agent": "planet-bootstrap"})
|
||
with urllib.request.urlopen(request, timeout=12) as response:
|
||
release = json.loads(response.read().decode("utf-8"))
|
||
assets = release.get("assets") or []
|
||
url = next(
|
||
(
|
||
asset.get("browser_download_url")
|
||
for asset in assets
|
||
if isinstance(asset, dict)
|
||
and str(asset.get("name", "")).endswith(".msi")
|
||
and arch in str(asset.get("name", "")).lower()
|
||
),
|
||
None,
|
||
)
|
||
if not url:
|
||
raise RuntimeError("latest release has no matching MSI asset")
|
||
download(url)
|
||
except Exception:
|
||
download(fallback_url)
|
||
PY
|
||
}
|
||
|
||
install_usbipd_win_from_msi() {
|
||
local msi_path="$1"
|
||
local msi_win_path=""
|
||
local script_path=""
|
||
|
||
msi_win_path="$(wslpath -w "$msi_path" 2>/dev/null || true)"
|
||
if [ -z "$msi_win_path" ]; then
|
||
log_warn "usbipd-win MSI 已下载,但无法转换 Windows 路径: ${msi_path}"
|
||
return 1
|
||
fi
|
||
|
||
script_path="$(mktemp "${TMPDIR:-/tmp}/planet-usbipd-install.XXXXXX.ps1")"
|
||
cat > "$script_path" <<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 attempt=1
|
||
local wait_message="检查${service_name}健康检查"
|
||
local wait_detail_prefix="请求"
|
||
local elapsed_seconds=""
|
||
|
||
set_wait_detail "${wait_detail_prefix} ${url},已等待 0 秒"
|
||
|
||
while [ "$attempt" -le "$attempts" ]; do
|
||
if http_ok "$url"; then
|
||
finish_wait_spinner "${service_name}已就绪"
|
||
return 0
|
||
fi
|
||
|
||
elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$interval")"
|
||
set_wait_detail "${wait_detail_prefix} ${url},已等待 ${elapsed_seconds} 秒"
|
||
animate_wait_spinner "${wait_message}" "$interval"
|
||
attempt=$((attempt + 1))
|
||
done
|
||
|
||
clear_wait_spinner
|
||
return 1
|
||
}
|
||
|
||
get_log_tail_line() {
|
||
local log_file="$1"
|
||
local fallback="$2"
|
||
local last_line=""
|
||
local filtered_line=""
|
||
|
||
if [ -f "$log_file" ]; then
|
||
while IFS= read -r last_line; do
|
||
filtered_line="$(sanitize_wait_detail "$last_line")"
|
||
if [ -n "$filtered_line" ] &&
|
||
[[ ! "$filtered_line" =~ ^\$[[:space:]] ]] &&
|
||
[[ "$filtered_line" != "nohup: ignoring input" ]] &&
|
||
[[ "$filtered_line" != The\ CJS\ build\ of\ Vite\'s\ Node\ API\ is\ deprecated.* ]]; then
|
||
printf "%s" "$filtered_line"
|
||
return 0
|
||
fi
|
||
done < <(tail -n 20 "$log_file" 2>/dev/null | tac)
|
||
fi
|
||
|
||
if [ -z "$last_line" ]; then
|
||
printf "%s" "$fallback"
|
||
return 0
|
||
fi
|
||
|
||
sanitize_wait_detail "$last_line"
|
||
}
|
||
|
||
wait_for_frontend_ready() {
|
||
local frontend_port="$1"
|
||
local frontend_pid="$2"
|
||
local log_file="$3"
|
||
local attempts="$4"
|
||
local interval="$5"
|
||
local owns_wait_session=0
|
||
local tick=0
|
||
local max_ticks=$(( attempts * interval * WAIT_SPINNER_TICKS_PER_SECOND ))
|
||
local probe_every_ticks=$(( WAIT_SPINNER_TICKS_PER_SECOND / 2 ))
|
||
local detail_every_ticks=2
|
||
local detail=""
|
||
|
||
if [ "$max_ticks" -lt 1 ]; then
|
||
max_ticks=1
|
||
fi
|
||
if [ "$probe_every_ticks" -lt 1 ]; then
|
||
probe_every_ticks=1
|
||
fi
|
||
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then
|
||
start_wait_session "启动前端服务"
|
||
owns_wait_session=1
|
||
else
|
||
set_wait_detail "启动 Vite 开发服务器"
|
||
fi
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
WAIT_VERBOSE_LOG_FILE="$log_file"
|
||
fi
|
||
|
||
while [ "$tick" -lt "$max_ticks" ]; do
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
|
||
if ! kill -0 "$frontend_pid" 2>/dev/null; then
|
||
detail="$(get_log_tail_line "$log_file" "前端进程已退出")"
|
||
WAIT_SPINNER_DETAIL="$detail"
|
||
clear_wait_spinner
|
||
if [ -n "$detail" ]; then
|
||
log_note "$detail"
|
||
fi
|
||
close_wait_session_context "$owns_wait_session"
|
||
return 1
|
||
fi
|
||
|
||
if [ $(( tick % detail_every_ticks )) -eq 0 ]; then
|
||
detail="$(get_log_tail_line "$log_file" "等待 Vite 输出启动日志")"
|
||
WAIT_SPINNER_DETAIL="$detail"
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
fi
|
||
|
||
if [ $(( tick % probe_every_ticks )) -eq 0 ]; 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() {
|
||
docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||
}
|
||
|
||
restart_database_services() {
|
||
docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||
}
|
||
|
||
start_postgres_service() {
|
||
docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1
|
||
}
|
||
|
||
# Backend lifecycle helpers
|
||
cleanup_backend_processes() {
|
||
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
|
||
local tracked_pid=""
|
||
|
||
if [ -f "$BACKEND_PID_FILE" ]; then
|
||
if tracked_pid="$(read_pid_file "$BACKEND_PID_FILE")"; then
|
||
terminate_process_group TERM "$tracked_pid"
|
||
terminate_process_tree TERM "$tracked_pid"
|
||
sleep 1
|
||
terminate_process_group KILL "$tracked_pid"
|
||
terminate_process_tree KILL "$tracked_pid"
|
||
fi
|
||
remove_pid_file "$BACKEND_PID_FILE"
|
||
fi
|
||
|
||
terminate_backend_processes TERM "$backend_port"
|
||
|
||
if ! wait_for_port_release "$backend_port" 15 0.2; then
|
||
terminate_backend_processes KILL "$backend_port"
|
||
if ! wait_for_port_release "$backend_port" 15 0.2; then
|
||
if force_cleanup_external_port_listener "$backend_port" "后端"; then
|
||
return 0
|
||
fi
|
||
wait_for_port_release "$backend_port" 15 0.2 || return 1
|
||
fi
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
start_backend_with_retry() {
|
||
local backend_port="$1"
|
||
local retry=1
|
||
local backend_bind_host="0.0.0.0"
|
||
|
||
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
|
||
if ! cleanup_backend_processes "$backend_port"; then
|
||
fail_unreleased_port "$backend_port"
|
||
fi
|
||
cd "$SCRIPT_DIR/backend"
|
||
: > "$BACKEND_LOG_FILE"
|
||
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
|
||
|
||
if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
|
||
return 0
|
||
fi
|
||
|
||
kill "$BACKEND_PID" 2>/dev/null || true
|
||
if backend_log_indicates_port_conflict "$BACKEND_LOG_FILE"; then
|
||
cleanup_backend_processes "$backend_port" || fail_unreleased_port "$backend_port"
|
||
if wait_for_port_release "$backend_port" 5 0.2; then
|
||
log_warn "后端端口冲突已清理,立即重试启动"
|
||
retry=$((retry + 1))
|
||
continue
|
||
fi
|
||
report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE"
|
||
return 1
|
||
fi
|
||
animate_wait_spinner "后端启动未就绪,准备再次尝试" "$BACKEND_HEALTH_CHECK_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
|
||
clear_wait_spinner
|
||
report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE" || true
|
||
return 1
|
||
}
|
||
|
||
start_ai_provider_service() {
|
||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
local retry=1
|
||
set_wait_detail "启动 AI Provider"
|
||
|
||
write_ai_provider_runtime_env_file
|
||
ensure_ai_provider_image_current
|
||
|
||
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do
|
||
if [ "$AI_PROVIDER_RECREATE_REQUIRED" -eq 1 ]; then
|
||
if recreate_ai_provider_container "$ai_provider_port"; then
|
||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||
if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
||
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||
return 0
|
||
fi
|
||
fi
|
||
elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then
|
||
if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
||
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then
|
||
log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次"
|
||
docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true
|
||
exit 1
|
||
fi
|
||
|
||
remove_ai_provider_containers
|
||
AI_PROVIDER_RECREATE_REQUIRED=1
|
||
animate_wait_spinner "AI Provider 启动后仍未健康,正在重启容器并准备再次尝试" "$AI_PROVIDER_RETRY_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
|
||
log_error "AI Provider 启动失败"
|
||
docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true
|
||
exit 1
|
||
}
|
||
|
||
ai_provider_service_healthy() {
|
||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
|
||
docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
|
||
http_ok "http://localhost:${ai_provider_port}/health"
|
||
}
|
||
|
||
ensure_database_services_healthy() {
|
||
local retry=1
|
||
|
||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||
if start_database_services && wait_for_database_health; then
|
||
return 0
|
||
fi
|
||
|
||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||
log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||
print_database_failure_diagnostics
|
||
exit 1
|
||
fi
|
||
|
||
docker restart planet_postgres planet_redis >/dev/null 2>&1 || true
|
||
animate_wait_spinner "数据库启动后仍未健康,正在重启容器并准备再次尝试" "$DATABASE_RETRY_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
}
|
||
|
||
ensure_postgres_service_healthy() {
|
||
local retry=1
|
||
|
||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||
if start_postgres_service && wait_for_postgres_health; then
|
||
return 0
|
||
fi
|
||
|
||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||
log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||
if ! docker_daemon_available; then
|
||
log_note "PostgreSQL 容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。"
|
||
log_docker_daemon_diagnostics
|
||
else
|
||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||
fi
|
||
exit 1
|
||
fi
|
||
|
||
docker restart planet_postgres >/dev/null 2>&1 || true
|
||
animate_wait_spinner "PostgreSQL 启动后仍未健康,正在重启容器并准备再次尝试" "$DATABASE_RETRY_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
}
|
||
|
||
restart_database_service() {
|
||
local retry=1
|
||
set_wait_detail "重启数据库"
|
||
|
||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||
if restart_database_services && wait_for_database_health; then
|
||
return 0
|
||
fi
|
||
|
||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||
log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||
print_database_failure_diagnostics
|
||
exit 1
|
||
fi
|
||
|
||
animate_wait_spinner "数据库重启后仍未健康,准备再次尝试" "$DATABASE_RETRY_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
}
|
||
|
||
start_backend_service() {
|
||
local backend_port="$1"
|
||
local backend_port_requested="$2"
|
||
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
|
||
set_wait_detail "启动数据库"
|
||
ensure_database_services_healthy
|
||
log_success "启动数据库已就绪"
|
||
|
||
# Backend depends on AI Provider reachability, but a backend-only restart
|
||
# should reuse the existing healthy provider instead of rebuilding or
|
||
# restarting it.
|
||
if ai_provider_service_healthy "$ai_provider_port"; then
|
||
log_note "AI Provider 已健康,复用现有服务,跳过启动/重建"
|
||
else
|
||
log_note "AI Provider 未就绪,正在启动本地代理服务"
|
||
start_ai_provider_service "$ai_provider_port"
|
||
fi
|
||
|
||
if [ "$backend_port_requested" -eq 1 ]; then
|
||
kill_port_if_requested "$backend_port" "后端"
|
||
fi
|
||
|
||
set_wait_detail "启动后端"
|
||
ensure_uv_backend_deps
|
||
if ! start_backend_with_retry "$backend_port"; then
|
||
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次"
|
||
tail -10 "$BACKEND_LOG_FILE"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# Port / process helpers
|
||
validate_port() {
|
||
local port="$1"
|
||
|
||
if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
||
log_error "非法端口: ${port}"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
collect_port_pids() {
|
||
local port="$1"
|
||
local pids=""
|
||
local pid=""
|
||
|
||
if command -v lsof >/dev/null 2>&1; then
|
||
pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||
if [ -n "$pids" ]; then
|
||
printf "%s\n" "$pids" | awk '!seen[$0]++'
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if command -v fuser >/dev/null 2>&1; then
|
||
pids="$(fuser "${port}/tcp" 2>/dev/null || true)"
|
||
if [ -n "$pids" ]; then
|
||
for pid in $pids; do
|
||
printf "%s\n" "$pid"
|
||
done | awk '!seen[$0]++'
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if command -v 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
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=0
|
||
MOTION_AGENT_DISABLED=0
|
||
MOTION_AGENT_DRY_RUN=0
|
||
DATABASE_REQUESTED=0
|
||
FRONTEND_LAN_ENABLED=0
|
||
FRONTEND_LAN_HTTPS_ENABLED=0
|
||
|
||
case "${MOTION_AGENT_DRY_RUN:-0}" in
|
||
1|true|yes|on)
|
||
MOTION_AGENT_DRY_RUN=1
|
||
;;
|
||
esac
|
||
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
-b|--backend-port)
|
||
BACKEND_PORT_REQUESTED=1
|
||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||
BACKEND_PORT="$2"
|
||
shift 2
|
||
else
|
||
shift 1
|
||
fi
|
||
;;
|
||
-f|--frontend-port)
|
||
FRONTEND_PORT_REQUESTED=1
|
||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||
FRONTEND_PORT="$2"
|
||
shift 2
|
||
else
|
||
shift 1
|
||
fi
|
||
;;
|
||
-a|--ai-provider-port)
|
||
AI_PROVIDER_REQUESTED=1
|
||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||
AI_PROVIDER_PORT="$2"
|
||
shift 2
|
||
else
|
||
shift 1
|
||
fi
|
||
;;
|
||
-m|--motion-agent)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||
MOTION_AGENT_DISABLED=0
|
||
shift 1
|
||
;;
|
||
--non-motion-agent)
|
||
MOTION_AGENT_REQUESTED=0
|
||
MOTION_AGENT_DISABLED=1
|
||
shift 1
|
||
;;
|
||
--motion-agent-port)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||
MOTION_AGENT_DISABLED=0
|
||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||
MOTION_AGENT_PORT="$2"
|
||
shift 2
|
||
else
|
||
log_error "--motion-agent-port 需要端口号"
|
||
exit 1
|
||
fi
|
||
;;
|
||
--motion-agent-dry-run)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||
MOTION_AGENT_DISABLED=0
|
||
MOTION_AGENT_DRY_RUN=1
|
||
shift 1
|
||
;;
|
||
--motion-agent-camera-indexes)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||
MOTION_AGENT_DISABLED=0
|
||
if [ -n "$2" ]; then
|
||
MOTION_AGENT_CAMERA_INDEXES="$2"
|
||
shift 2
|
||
else
|
||
log_error "--motion-agent-camera-indexes 需要逗号分隔的摄像头 index,例如 0,1"
|
||
exit 1
|
||
fi
|
||
;;
|
||
--motion-agent-camera-urls)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||
MOTION_AGENT_DISABLED=0
|
||
if [ -n "$2" ]; then
|
||
MOTION_AGENT_CAMERA_URLS="$2"
|
||
shift 2
|
||
else
|
||
log_error "--motion-agent-camera-urls 需要逗号分隔的 RTSP/HTTP 摄像头 URL"
|
||
exit 1
|
||
fi
|
||
;;
|
||
--motion-agent-mode)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||
MOTION_AGENT_DISABLED=0
|
||
if [ -n "$2" ] && [[ "$2" =~ ^(auto|single|dual|dual_redundant|single_fallback|calibrated_3d)$ ]]; then
|
||
MOTION_AGENT_MODE="$2"
|
||
shift 2
|
||
else
|
||
log_error "--motion-agent-mode 需要 auto/single/dual/dual_redundant/single_fallback/calibrated_3d"
|
||
exit 1
|
||
fi
|
||
;;
|
||
--motion-agent-wsl-usbipd)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||
MOTION_AGENT_DISABLED=0
|
||
MOTION_AGENT_WSL_USBIPD=1
|
||
shift 1
|
||
;;
|
||
--motion-agent-wsl-usbipd-busid)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||
MOTION_AGENT_DISABLED=0
|
||
MOTION_AGENT_WSL_USBIPD=1
|
||
if [ -n "$2" ]; then
|
||
MOTION_AGENT_WSL_USBIPD_BUSID="$2"
|
||
shift 2
|
||
else
|
||
log_error "--motion-agent-wsl-usbipd-busid 需要 usbipd list 中的 BUSID,例如 3-2"
|
||
exit 1
|
||
fi
|
||
;;
|
||
-d|--database)
|
||
DATABASE_REQUESTED=1
|
||
shift 1
|
||
;;
|
||
--allow-lan)
|
||
FRONTEND_LAN_ENABLED=1
|
||
shift 1
|
||
;;
|
||
--allow-lan-https)
|
||
FRONTEND_LAN_ENABLED=1
|
||
FRONTEND_LAN_HTTPS_ENABLED=1
|
||
shift 1
|
||
;;
|
||
-v|--verbose)
|
||
VERBOSE=1
|
||
shift 1
|
||
;;
|
||
*)
|
||
log_error "未知参数: $1"
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
validate_port "$BACKEND_PORT"
|
||
validate_port "$FRONTEND_PORT"
|
||
validate_port "$AI_PROVIDER_PORT"
|
||
validate_port "$MOTION_AGENT_PORT"
|
||
}
|
||
|
||
cleanup_exit_containers() {
|
||
local exit_containers
|
||
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
|
||
if [ -n "$exit_containers" ]; then
|
||
log_step "清理残留 Exit 容器"
|
||
printf "%s\n" "$exit_containers" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||
log_success "残留容器已清理"
|
||
fi
|
||
}
|
||
|
||
stop_container_if_running() {
|
||
local container_name="$1"
|
||
local service_name="$2"
|
||
local container_status=""
|
||
|
||
container_status="$(docker inspect --format '{{.State.Status}}' "$container_name" 2>/dev/null || true)"
|
||
if [ "$container_status" = "running" ]; then
|
||
docker stop "$container_name" >/dev/null 2>&1 || true
|
||
log_halt "${service_name}已停止"
|
||
fi
|
||
}
|
||
|
||
stop_backend_service() {
|
||
if pgrep -f "uvicorn .*--port ${DEFAULT_BACKEND_PORT}" >/dev/null 2>&1 || [ -f "$BACKEND_PID_FILE" ] || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
|
||
cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
|
||
log_halt "后端服务已停止"
|
||
fi
|
||
}
|
||
|
||
stop_ai_provider_service() {
|
||
stop_container_if_running "$AI_PROVIDER_CONTAINER_NAME" "AI Provider"
|
||
}
|
||
|
||
stop_frontend_service() {
|
||
if pgrep -f "${FRONTEND_VITE_ENTRY} .*--port ${DEFAULT_FRONTEND_PORT}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
|
||
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
|
||
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
||
cleanup_frontend_processes "$FRONTEND_PORT"
|
||
fi
|
||
log_halt "前端服务已停止"
|
||
return 0
|
||
fi
|
||
|
||
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
|
||
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
||
cleanup_frontend_processes "$FRONTEND_PORT"
|
||
fi
|
||
}
|
||
|
||
print_http_health_status() {
|
||
local service_name="$1"
|
||
local url="$2"
|
||
|
||
if http_ok "$url"; then
|
||
echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}"
|
||
else
|
||
echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}"
|
||
fi
|
||
}
|
||
|
||
print_frontend_health_status() {
|
||
local frontend_port="$1"
|
||
local frontend_scheme="${2:-$(read_port_state_text_value FRONTEND_SCHEME http)}"
|
||
|
||
if [ "$frontend_scheme" = "https" ]; then
|
||
if https_ok_insecure "https://localhost:${frontend_port}"; then
|
||
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC} ${DIM}(https://localhost:${frontend_port})${NC}"
|
||
else
|
||
echo -e "${DIM} 前端:${NC} ${RED}offline${NC} ${DIM}(https://localhost:${frontend_port})${NC}"
|
||
fi
|
||
return
|
||
fi
|
||
|
||
if http_ok "http://localhost:${frontend_port}"; then
|
||
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC} ${DIM}(http://localhost:${frontend_port})${NC}"
|
||
elif https_ok_insecure "https://localhost:${frontend_port}"; then
|
||
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC} ${DIM}(https://localhost:${frontend_port})${NC}"
|
||
else
|
||
echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
|
||
fi
|
||
}
|
||
|
||
print_process_health_status() {
|
||
local service_name="$1"
|
||
local pattern="$2"
|
||
local pid_file="${3:-}"
|
||
local tracked_pid=""
|
||
|
||
if [ -n "$pid_file" ]; then
|
||
tracked_pid="$(read_pid_file "$pid_file" || true)"
|
||
fi
|
||
|
||
if pgrep -f "$pattern" >/dev/null 2>&1 || { [ -n "$tracked_pid" ] && kill -0 "$tracked_pid" 2>/dev/null; }; then
|
||
echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}"
|
||
else
|
||
echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}"
|
||
fi
|
||
}
|
||
|
||
print_motion_agent_health_status() {
|
||
local detail=""
|
||
local tracked_pid=""
|
||
|
||
if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || {
|
||
[ -f "$MOTION_AGENT_PID_FILE" ] &&
|
||
tracked_pid="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)" &&
|
||
[ -n "$tracked_pid" ] &&
|
||
kill -0 "$tracked_pid" 2>/dev/null
|
||
}; then
|
||
echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}"
|
||
return
|
||
fi
|
||
|
||
if [ -f "$MOTION_AGENT_SKIPPED_FILE" ]; then
|
||
detail="$(sed -n 's/^reason=//p' "$MOTION_AGENT_SKIPPED_FILE" 2>/dev/null | head -n 1)"
|
||
if [ -n "$detail" ]; then
|
||
echo -e "${DIM} Motion Agent:${NC} ${YELLOW}skipped${NC} ${DIM}(${detail})${NC}"
|
||
else
|
||
echo -e "${DIM} Motion Agent:${NC} ${YELLOW}skipped${NC}"
|
||
fi
|
||
return
|
||
fi
|
||
|
||
echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}"
|
||
}
|
||
|
||
motion_agent_pids() {
|
||
local pids=""
|
||
|
||
if [ -f "$MOTION_AGENT_PID_FILE" ]; then
|
||
pids="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)"
|
||
if [ -n "$pids" ] && kill -0 "$pids" 2>/dev/null; then
|
||
printf "%s\n" "$pids"
|
||
fi
|
||
fi
|
||
|
||
pgrep -f "python.*-m motion_agent" 2>/dev/null || true
|
||
}
|
||
|
||
cleanup_motion_agent_processes() {
|
||
local signal="${1:-TERM}"
|
||
local pid
|
||
|
||
while IFS= read -r pid; do
|
||
[ -n "$pid" ] || continue
|
||
terminate_process_group "$signal" "$pid"
|
||
terminate_process_tree "$signal" "$pid"
|
||
done < <(motion_agent_pids | awk '!seen[$0]++')
|
||
|
||
remove_pid_file "$MOTION_AGENT_PID_FILE"
|
||
}
|
||
|
||
mark_motion_agent_skipped() {
|
||
local reason="$1"
|
||
local detail="${2:-}"
|
||
|
||
mkdir -p "$PLANET_STATE_DIR"
|
||
{
|
||
printf "status=skipped\n"
|
||
printf "time=%s\n" "$(date '+%Y-%m-%d %H:%M:%S')"
|
||
printf "reason=%s\n" "$reason"
|
||
if [ -n "$detail" ]; then
|
||
printf "detail=%s\n" "$detail"
|
||
fi
|
||
} > "$MOTION_AGENT_SKIPPED_FILE"
|
||
typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=1
|
||
remove_pid_file "$MOTION_AGENT_PID_FILE"
|
||
log_warn "Motion Agent skipped: ${reason}"
|
||
if [ -n "$detail" ]; then
|
||
log_note "$detail"
|
||
fi
|
||
}
|
||
|
||
clear_motion_agent_skipped_state() {
|
||
typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=0
|
||
rm -f "$MOTION_AGENT_SKIPPED_FILE"
|
||
}
|
||
|
||
release_motion_agent_port_for_start() {
|
||
local port="$1"
|
||
local pids=""
|
||
local pid=""
|
||
|
||
cleanup_motion_agent_processes TERM
|
||
if wait_for_port_release "$port" 25 0.2; then
|
||
return 0
|
||
fi
|
||
|
||
log_warn "Motion Agent 端口 ${port} 清理后仍不可绑定,尝试强制清理"
|
||
cleanup_motion_agent_processes KILL
|
||
if wait_for_port_release "$port" 10 0.2; then
|
||
return 0
|
||
fi
|
||
|
||
pids="$(collect_port_pids "$port" || true)"
|
||
if [ -n "$pids" ]; then
|
||
log_warn "发现 Motion Agent 端口 ${port} 仍有监听进程,正在强制终止"
|
||
for pid in $pids; do
|
||
terminate_process_group KILL "$pid"
|
||
terminate_process_tree KILL "$pid"
|
||
done
|
||
if wait_for_port_release "$port" 10 0.2; then
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if force_cleanup_external_port_listener "$port" "Motion Agent"; then
|
||
if wait_for_port_release "$port" 10 0.2; then
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
print_port_listener_details "$port"
|
||
mark_motion_agent_skipped \
|
||
"端口 ${port} 被外部环境占用,已跳过 Motion Agent 启动" \
|
||
"其他服务会继续启动;需要动捕时请释放端口或改用 --motion-agent-port <端口>。"
|
||
return 1
|
||
}
|
||
|
||
wait_for_motion_agent_ready() {
|
||
local port="$1"
|
||
local pid="$2"
|
||
local attempt=1
|
||
|
||
while [ "$attempt" -le 20 ]; do
|
||
if ! kill -0 "$pid" 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
if ! can_bind_port "$port"; then
|
||
return 0
|
||
fi
|
||
sleep 0.2
|
||
attempt=$((attempt + 1))
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
start_motion_agent_service() {
|
||
local motion_agent_port="$1"
|
||
local dry_run="$2"
|
||
local lan_enabled="${3:-0}"
|
||
local bind_host="127.0.0.1"
|
||
local auto_detected_camera_indexes=0
|
||
local validated_camera_indexes=""
|
||
local -a motion_args
|
||
|
||
clear_motion_agent_skipped_state
|
||
ensure_uv_backend_deps
|
||
if [ "$dry_run" -eq 0 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ] && [ -z "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||
MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)"
|
||
if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||
auto_detected_camera_indexes=1
|
||
log_note "Motion Agent 自动发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}"
|
||
else
|
||
if is_wsl_environment; then
|
||
if try_motion_agent_wsl_usbipd_camera; then
|
||
auto_detected_camera_indexes=1
|
||
:
|
||
else
|
||
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头"
|
||
log_note "当前看起来是 WSL;Windows 摄像头通常不会自动出现在 /dev/video*。"
|
||
log_note "真实识别请选择一种方式:"
|
||
log_note " 1. 用 --motion-agent-camera-urls 接手机/RTSP/HTTP 摄像头流"
|
||
log_note " 2. 用 --motion-agent-wsl-usbipd 尝试通过 usbipd-win 自动透传唯一 USB 摄像头"
|
||
log_note " 3. 用 --motion-agent-wsl-usbipd-busid <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
|
||
}
|
||
|
||
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 "准备 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
|
||
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 "$@"
|
||
cleanup_exit_containers
|
||
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 "$@"
|
||
cleanup_exit_containers
|
||
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
|
||
|
||
parse_global_args "$@"
|
||
set -- "${GLOBAL_ARG_REMAINDER[@]}"
|
||
|
||
case "$1" in
|
||
init)
|
||
shift
|
||
init "$@"
|
||
;;
|
||
start)
|
||
shift
|
||
start "$@"
|
||
;;
|
||
stop)
|
||
stop
|
||
;;
|
||
destroy)
|
||
destroy
|
||
;;
|
||
restart)
|
||
shift
|
||
restart "$@"
|
||
;;
|
||
createuser)
|
||
create_user
|
||
;;
|
||
health)
|
||
health
|
||
;;
|
||
log)
|
||
log "$2"
|
||
;;
|
||
*)
|
||
log_error "用法: ./planet.sh {init|start|stop|destroy|restart|createuser|health|log}"
|
||
log_note "全局参数: -v, --verbose 在状态提示之间增量输出命令日志"
|
||
log_note "init 首次初始化空项目: 同步 uv/bun 依赖、生成缺失 env、启动数据库并写入默认数据"
|
||
log_note "start 启动服务,默认包含 Motion Agent;可选: -b <后端端口> -f <前端端口> -a <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
|