#!/bin/bash # ========================================== # VHectorLab 3D - CONTROL PANEL & CLI SETUP # ========================================== # ANSI Color Palette RESET="\033[0m" BOLD="\033[1m" CYAN="\033[1;36m" GREEN="\033[1;32m" YELLOW="\033[1;33m" BLUE="\033[1;34m" MAGENTA="\033[1;35m" RED="\033[1;31m" DIM="\033[2m" LOG_FILE="vhectorlab_dev.log" # Created & tested on macOS 26.5.1 (Darwin 25.5.0, arm64). Not prepared for Windows/Linux. SUPPORTED_OS_LABEL="macOS 26.5.1 (Darwin 25.5.0)" # Panel phase for SIGINT: menu | logs | confirm # Ctrl+C must NEVER stop backend/frontend — only option 10 does that. PANEL_PHASE="menu" exit_panel_keep_services() { echo -e "\n${CYAN}${BOLD}Goodbye! Services keep running (use option 10 to stop).${RESET}" exit 0 } handle_sigint() { case "$PANEL_PHASE" in logs) # Interrupt tail -f only; follow_backend_logs continues to the confirm prompt. echo -e "\n${YELLOW}${BOLD}⏸ Log follow paused. Services still running.${RESET}" PANEL_PHASE="confirm" ;; confirm) exit_panel_keep_services ;; *) exit_panel_keep_services ;; esac } trap 'handle_sigint' INT # Launch a command in a new session (no controlling TTY). # Avoids: (1) panel Ctrl+C SIGINT killing services, (2) SIGTTOU stop (STAT T) from set -m/job control. # Usage: launch_detached ("-" logfile → /dev/null) launch_detached() { local workdir="$1" local logfile="$2" shift 2 if [ "$logfile" = "-" ]; then logfile="/dev/null" fi case "$workdir" in /*) ;; *) workdir="$(pwd)/$workdir" ;; esac case "$logfile" in /*) ;; *) logfile="$(pwd)/$logfile" ;; esac python3 - "$workdir" "$logfile" "$@" <<'PY' import os, sys, subprocess workdir, logfile, *cmd = sys.argv[1:] os.chdir(workdir) log = open(logfile, "a", buffering=1) subprocess.Popen( cmd, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True, ) PY } refresh_path() { # uv installer lands in ~/.local/bin; Homebrew on Apple Silicon uses /opt/homebrew. export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" # shellcheck disable=SC1090 [ -f "$HOME/.local/bin/env" ] && source "$HOME/.local/bin/env" } warn_if_unsupported_platform() { local os_name os_name="$(uname -s 2>/dev/null || echo unknown)" if [ "$os_name" != "Darwin" ]; then echo -e "${YELLOW}${BOLD}⚠️ Unsupported platform: ${os_name}${RESET}" echo -e "${YELLOW} This project was created and tested on ${SUPPORTED_OS_LABEL}.${RESET}" echo -e "${YELLOW} It was not prepared for Windows or Linux. Small adaptations may make it work.${RESET}" echo -e "${YELLOW} Auto-install of missing tools is macOS-only (Homebrew + official uv installer).${RESET}" return 1 fi return 0 } ensure_homebrew() { if command -v brew &> /dev/null; then echo -e " ${GREEN}✓ Homebrew detected.${RESET}" return 0 fi echo -e "${YELLOW}▶ Homebrew not found. Installing Homebrew (may ask for your password)...${RESET}" /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" if [ -x /opt/homebrew/bin/brew ]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [ -x /usr/local/bin/brew ]; then eval "$(/usr/local/bin/brew shellenv)" fi refresh_path if ! command -v brew &> /dev/null; then echo -e "${RED}❌ Failed to install Homebrew. Install it from https://brew.sh and re-run.${RESET}" return 1 fi echo -e " ${GREEN}✓ Homebrew installed.${RESET}" return 0 } ensure_uv() { refresh_path if command -v uv &> /dev/null; then echo -e " ${GREEN}✓ Python package manager 'uv' detected ($(uv --version 2>/dev/null | head -1)).${RESET}" return 0 fi echo -e "${YELLOW}▶ 'uv' not found. Installing via official installer...${RESET}" curl -LsSf https://astral.sh/uv/install.sh | sh refresh_path if ! command -v uv &> /dev/null; then echo -e "${RED}❌ Failed to install 'uv'. See https://docs.astral.sh/uv/getting-started/installation/${RESET}" return 1 fi echo -e " ${GREEN}✓ 'uv' installed ($(uv --version 2>/dev/null | head -1)).${RESET}" return 0 } ensure_node_npm() { refresh_path if command -v npm &> /dev/null && command -v node &> /dev/null; then echo -e " ${GREEN}✓ Node.js $(node --version) / npm $(npm --version) detected.${RESET}" return 0 fi if [ "$(uname -s)" != "Darwin" ]; then echo -e "${RED}❌ Node.js/npm missing. On this unsupported OS, install Node.js LTS yourself, then re-run.${RESET}" return 1 fi ensure_homebrew || return 1 echo -e "${YELLOW}▶ Node.js/npm not found. Installing Node via Homebrew...${RESET}" brew install node refresh_path if ! command -v npm &> /dev/null || ! command -v node &> /dev/null; then echo -e "${RED}❌ Failed to install Node.js/npm. Install the LTS from https://nodejs.org and re-run.${RESET}" return 1 fi echo -e " ${GREEN}✓ Node.js $(node --version) / npm $(npm --version) installed.${RESET}" return 0 } ensure_project_deps() { if [ ! -f .env ] && [ -f .env.example ]; then echo -e "${YELLOW}▶ Creating .env from .env.example...${RESET}" cp .env.example .env echo -e " ${GREEN}✓ .env created (edit values if needed).${RESET}" elif [ -f .env ]; then echo -e " ${GREEN}✓ .env present.${RESET}" fi # After renaming/moving the repo folder, entrypoint scripts in .venv keep absolute # shebangs to the old path → `uv run pytest` fails with "No such file or directory". if [ -f backend/.venv/bin/pytest ]; then local pytest_python pytest_python="$(head -1 backend/.venv/bin/pytest | sed 's/^#![[:space:]]*//')" if [ -n "$pytest_python" ] && [ ! -e "$pytest_python" ]; then echo -e "${YELLOW}▶ Backend .venv is stale (scripts still point to a missing path).${RESET}" echo -e "${YELLOW} Recreating virtualenv after folder move/rename...${RESET}" rm -rf backend/.venv fi fi echo -e "${BLUE}▶ Syncing backend Python deps (uv sync --extra dev)...${RESET}" (cd backend && uv sync --extra dev) || { echo -e "${RED}❌ Backend dependency sync failed.${RESET}" return 1 } echo -e " ${GREEN}✓ Backend deps ready.${RESET}" if [ ! -d node_modules ]; then echo -e "${YELLOW}▶ Frontend node_modules missing. Running npm install...${RESET}" npm install || { echo -e "${RED}❌ npm install failed.${RESET}" return 1 } echo -e " ${GREEN}✓ Frontend deps installed.${RESET}" else echo -e " ${GREEN}✓ Frontend node_modules present.${RESET}" fi return 0 } ensure_prerequisites() { echo -e "\n${CYAN}${BOLD}====================================================${RESET}" echo -e "${CYAN}${BOLD}🔍 CHECKING / INSTALLING PREREQUISITES...${RESET}" echo -e "${CYAN}${BOLD}====================================================${RESET}" echo -e " ${DIM}Supported / tested on: ${SUPPORTED_OS_LABEL}${RESET}" warn_if_unsupported_platform # On macOS, missing tools are installed automatically. Elsewhere, require them preinstalled. ensure_uv || return 1 ensure_node_npm || return 1 ensure_project_deps || return 1 return 0 } # --- Idempotent service probes (process + health; ports detect "sick") --- # healthy = matching process AND health OK # down = no process, no health, port free # sick = anything else (partial / port held / health failing) BACKEND_URL="http://127.0.0.1:8000" FRONTEND_URL="http://127.0.0.1:5173" BACKEND_PORT=8000 FRONTEND_PORT=5173 port_listening() { local port="$1" lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 } backend_process_running() { pgrep -f "server:app|python -m server" >/dev/null 2>&1 } frontend_process_running() { # Prefer the launch signature from this script; avoid bare "vite" (matches vitest). pgrep -f "vite --port ${FRONTEND_PORT}" >/dev/null 2>&1 } backend_health_ok() { local body body="$(curl -sf --max-time 2 "${BACKEND_URL}/health" 2>/dev/null)" || return 1 echo "$body" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' } frontend_health_ok() { curl -sf --max-time 2 -o /dev/null "${FRONTEND_URL}/" 2>/dev/null } # Prints: healthy | sick | down probe_backend() { local proc=0 health=0 port=0 backend_process_running && proc=1 backend_health_ok && health=1 port_listening "$BACKEND_PORT" && port=1 if [ "$proc" -eq 1 ] && [ "$health" -eq 1 ]; then echo "healthy" elif [ "$proc" -eq 0 ] && [ "$health" -eq 0 ] && [ "$port" -eq 0 ]; then echo "down" else echo "sick" fi } # Prints: healthy | sick | down probe_frontend() { local proc=0 health=0 port=0 frontend_process_running && proc=1 frontend_health_ok && health=1 port_listening "$FRONTEND_PORT" && port=1 if [ "$proc" -eq 1 ] && [ "$health" -eq 1 ]; then echo "healthy" elif [ "$proc" -eq 0 ] && [ "$health" -eq 0 ] && [ "$port" -eq 0 ]; then echo "down" else echo "sick" fi } describe_service_state() { local name="$1" local state="$2" local port="$3" case "$state" in healthy) echo -e " ${GREEN}✓ ${name}: healthy (process + health OK on :${port})${RESET}" ;; down) echo -e " ${DIM}· ${name}: down${RESET}" ;; sick) echo -e " ${YELLOW}⚠ ${name}: sick (process/port/health mismatch on :${port})${RESET}" ;; *) echo -e " ${RED}? ${name}: unknown (${state})${RESET}" ;; esac } ensure_vocab() { if [ ! -f "public/vocab.txt" ]; then echo -e "${YELLOW}⚠️ Vocabulary missing at public/vocab.txt. Generating 10,000 words...${RESET}" python3 scripts/generate_vocab.py --count 10000 fi echo -e " ${GREEN}✓ public/vocab.txt OK ($(wc -l < public/vocab.txt | tr -d ' ') words).${RESET}" } run_full_test_suite() { echo -e "\n${CYAN}${BOLD}====================================================${RESET}" echo -e "${CYAN}${BOLD}🧪 RUNNING FULL TEST SUITE...${RESET}" echo -e "${CYAN}${BOLD}====================================================${RESET}" echo -e "${BLUE}${BOLD}▶ [1/2] Backend unit tests (pytest)...${RESET}" echo -e "${DIM}Loading PyTorch SentenceTransformer model into memory...${RESET}" (cd backend && uv run python -m pytest -v -s) if [ $? -ne 0 ]; then echo -e "${RED}${BOLD}❌ ERROR: Backend tests failed. Fix them before deploying.${RESET}" return 1 fi echo -e " ${GREEN}${BOLD}✓ Backend tests GREEN.${RESET}" echo -e "\n${BLUE}${BOLD}▶ [2/2] Frontend unit tests (Vitest)...${RESET}" npx vitest run --reporter=verbose if [ $? -ne 0 ]; then echo -e "${RED}${BOLD}❌ ERROR: Frontend tests failed. Fix them before deploying.${RESET}" return 1 fi echo -e " ${GREEN}${BOLD}✓ Frontend tests GREEN.${RESET}" return 0 } kill_stack() { pkill -f "server:app|python -m server" 2>/dev/null pkill -f "vite --port ${FRONTEND_PORT}" 2>/dev/null # Fallback for older/manual vite launches on the same port pkill -f "vite" 2>/dev/null sleep 1 } wait_backend_healthy() { echo -n " Loading model [waiting for /health status=ok]" local i for i in {1..60}; do if backend_health_ok; then echo -e " ${GREEN}${BOLD}MODEL LOADED OK!${RESET}" return 0 fi echo -n " ⏳" sleep 1 done echo -e " ${RED}${BOLD}TIMEOUT${RESET}" return 1 } start_backend() { echo -e "${BLUE}▶ Starting Backend FastAPI (${BACKEND_URL})...${RESET}" launch_detached "backend" "$LOG_FILE" uv run python -m server wait_backend_healthy } start_frontend() { echo -e "${BLUE}▶ Starting Frontend WebGL (Vite Dev Server)...${RESET}" launch_detached "." "-" npx vite --port "$FRONTEND_PORT" --host 127.0.0.1 local i for i in {1..20}; do if frontend_health_ok; then echo -e " ${GREEN}✓ Frontend responding on ${FRONTEND_URL}${RESET}" return 0 fi sleep 0.5 done echo -e "${YELLOW} Frontend started but not responding yet on ${FRONTEND_URL}${RESET}" return 1 } open_app_browser() { echo -e "${YELLOW}🌐 Opening browser at ${FRONTEND_URL}...${RESET}" if command -v open &> /dev/null; then open "$FRONTEND_URL" elif command -v xdg-open &> /dev/null; then xdg-open "$FRONTEND_URL" fi } follow_backend_logs() { echo -e "\n${MAGENTA}${BOLD}====================================================${RESET}" echo -e "${MAGENTA}${BOLD}📜 LIVE BACKEND LOGS${RESET}" echo -e "${MAGENTA}${BOLD}====================================================${RESET}" echo -e " ${DIM}Ctrl+C pauses logs (does NOT stop services).${RESET}\n" PANEL_PHASE="logs" # SIGINT interrupts this pipeline; handle_sigint keeps the panel alive. tail -f "$LOG_FILE" 2>/dev/null | sed \ -e "s/INFO/${GREEN}INFO${RESET}/g" \ -e "s/WARNING/${YELLOW}WARNING${RESET}/g" \ -e "s/ERROR/${RED}ERROR${RESET}/g" \ -e "s/200 OK/${GREEN}200 OK${RESET}/g" \ -e "s/POST /${CYAN}POST ${RESET}/g" \ -e "s/GET /${CYAN}GET ${RESET}/g" \ || true PANEL_PHASE="confirm" echo -e "\n${YELLOW}${BOLD}Press Enter to return to the menu, or Ctrl+C again to exit the panel.${RESET}" echo -e "${DIM}Services stay up either way. Use option 10 to stop them.${RESET}" if read -r _; then PANEL_PHASE="menu" return 0 fi exit_panel_keep_services } print_ready_banner() { echo -e "\n${GREEN}${BOLD}====================================================${RESET}" echo -e "${GREEN}${BOLD} 🎉 Ready to go!${RESET}" echo -e "${GREEN}${BOLD}====================================================${RESET}" echo -e " ${BOLD}The tool is running at:${RESET}" echo -e " ${CYAN}${BOLD}👉 ${FRONTEND_URL}${RESET}\n" } show_menu() { clear echo -e "${CYAN}${BOLD}====================================================${RESET}" echo -e "${CYAN}${BOLD} 🌐 VHectorLab 3D - CONTROL PANEL${RESET}" echo -e "${CYAN}${BOLD}====================================================${RESET}" echo -e " ${GREEN}${BOLD}1. 🚀 Deploy / Start Tool${RESET} ${DIM}(Idempotent: skip start if already healthy)${RESET}" echo -e " ${GREEN}2.${RESET} ${BOLD}Start Bare-metal Backend${RESET} ${DIM}(FastAPI on 127.0.0.1:8000 · skip if healthy)${RESET}" echo -e " ${BLUE}3.${RESET} ${BOLD}Run System Heartbeat${RESET} ${DIM}(Health Check & Arithmetic)${RESET}" echo -e " ${BLUE}4.${RESET} ${BOLD}Run Frontend Unit Tests${RESET} ${DIM}(Vitest)${RESET}" echo -e " ${BLUE}5.${RESET} ${BOLD}Run Backend Unit Tests${RESET} ${DIM}(pytest)${RESET}" echo -e " ${MAGENTA}6.${RESET} ${BOLD}Manage Vocabulary${RESET} ${DIM}(Custom vocab.txt / N words)${RESET}" echo -e " ${YELLOW}7.${RESET} ${BOLD}Build Hugging Face Space Docker Image${RESET} ${DIM}(Docker Desktop · torch CPU · :7860)${RESET}" echo -e " ${YELLOW}8.${RESET} ${BOLD}Publish Hugging Face Space${RESET} ${DIM}(hf CLI · docker sdk · cpu-basic)${RESET}" echo -e " ${CYAN}9.${RESET} ${BOLD}View Logs${RESET} ${DIM}(Live backend logs)${RESET}" echo -e " ${RED}10.${RESET} ${BOLD}Stop / Clean Services${RESET}" echo -e " ${DIM}0. Exit${RESET}" echo -e "${CYAN}${BOLD}====================================================${RESET}" } deploy_and_start() { local be_state fe_state force_restart=0 be_state="$(probe_backend)" fe_state="$(probe_frontend)" echo -e "\n${CYAN}${BOLD}====================================================${RESET}" echo -e "${CYAN}${BOLD}📡 SERVICE STATUS${RESET}" echo -e "${CYAN}${BOLD}====================================================${RESET}" describe_service_state "Backend " "$be_state" "$BACKEND_PORT" describe_service_state "Frontend" "$fe_state" "$FRONTEND_PORT" # Both already healthy: skip prereqs, tests, and start — just open the app. if [ "$be_state" = "healthy" ] && [ "$fe_state" = "healthy" ]; then echo -e "\n${GREEN}${BOLD}✓ Stack already up — skipping prerequisites, tests, and start.${RESET}" print_ready_banner open_app_browser echo -e "${DIM}Services left running as-is. Use options 4/5 for tests, 9 for logs, 10 to stop.${RESET}" read -p "Press Enter to return to menu..." return fi # Sick or partial → recycle both (don't leave the user stuck on a frozen port). if [ "$be_state" = "sick" ] || [ "$fe_state" = "sick" ]; then force_restart=1 echo -e "\n${YELLOW}${BOLD}⚠ Sick stack detected (e.g. frozen process on a live port). Recycling BOTH services.${RESET}" elif { [ "$be_state" = "healthy" ] && [ "$fe_state" = "down" ]; } || \ { [ "$be_state" = "down" ] && [ "$fe_state" = "healthy" ]; }; then force_restart=1 echo -e "\n${YELLOW}${BOLD}⚠ Partial stack detected — restarting BOTH services.${RESET}" fi echo -e "\n${CYAN}${BOLD}====================================================${RESET}" echo -e "${CYAN}${BOLD}🔍 VERIFYING ENVIRONMENT & REQUIREMENTS...${RESET}" echo -e "${CYAN}${BOLD}====================================================${RESET}" if ! ensure_prerequisites; then read -p "Press Enter..." return fi ensure_vocab if ! run_full_test_suite; then read -p "Press Enter..." return fi echo -e "\n${CYAN}${BOLD}====================================================${RESET}" echo -e "${CYAN}${BOLD}🚀 STARTING SERVICES (BACKEND + FRONTEND)...${RESET}" echo -e "${CYAN}${BOLD}====================================================${RESET}" if [ "$force_restart" -eq 1 ]; then echo -e "${YELLOW}▶ Stopping existing services before full restart...${RESET}" fi kill_stack if ! start_backend; then echo -e "${RED}${BOLD}❌ Backend failed to become healthy. Check ${LOG_FILE}.${RESET}" read -p "Press Enter..." return fi start_frontend print_ready_banner open_app_browser follow_backend_logs } start_bare_metal_backend() { local be_state be_state="$(probe_backend)" describe_service_state "Backend " "$be_state" "$BACKEND_PORT" if [ "$be_state" = "healthy" ]; then echo -e "${GREEN}${BOLD}✓ Backend already healthy — skipping start.${RESET}" read -p "Press Enter..." return fi if [ "$be_state" = "sick" ]; then echo -e "\n${YELLOW}${BOLD}⚠ Backend sick — stopping and restarting.${RESET}" pkill -f "server:app|python -m server" 2>/dev/null sleep 1 fi echo -e "${GREEN}Starting Bare-metal Backend...${RESET}" if ! ensure_prerequisites; then read -p "Press Enter..." return fi start_backend echo -e "${GREEN}Backend started in background (Logs: $LOG_FILE)${RESET}" read -p "Press Enter..." } manage_vocab() { echo "" echo -e "${MAGENTA}${BOLD}--- Manage Vocabulary ---${RESET}" echo -e " ${GREEN}1)${RESET} Load Custom vocab.txt file" echo -e " ${GREEN}2)${RESET} Generate new vocab.txt with custom word count" read -p "Select option [1-2]: " vopt case $vopt in 1) read -p "Enter path to your custom vocab.txt: " custom_path if [ -f "$custom_path" ]; then cp "$custom_path" public/vocab.txt echo -e "${GREEN}✅ Successfully copied $custom_path to public/vocab.txt${RESET}" else echo -e "${RED}❌ Error: File $custom_path does not exist.${RESET}" fi ;; 2) read -p "Enter target word count (default 10000): " count count=${count:-10000} python3 scripts/generate_vocab.py --count "$count" ;; *) echo -e "${RED}Invalid option.${RESET}" ;; esac read -p "Press Enter to return to menu..." } ensure_docker() { refresh_path if command -v docker &> /dev/null && docker info &> /dev/null; then echo -e " ${GREEN}✓ Docker is available ($(docker --version 2>/dev/null)).${RESET}" return 0 fi if command -v docker &> /dev/null && ! docker info &> /dev/null; then echo -e "${YELLOW}▶ Docker CLI found but the daemon is not running.${RESET}" if [ "$(uname -s)" = "Darwin" ]; then echo -e "${YELLOW} Opening Docker Desktop — wait until it says running, then retry option 7.${RESET}" open -a Docker 2>/dev/null || true fi echo -e "${RED}❌ Start Docker Desktop (whale icon in the menu bar), then re-run option 7.${RESET}" return 1 fi if [ "$(uname -s)" != "Darwin" ]; then echo -e "${RED}❌ Docker is required for this option but is not installed.${RESET}" echo -e "${RED} Local daily use (option 1) does not need Docker. Install Docker yourself on this OS.${RESET}" return 1 fi ensure_homebrew || return 1 echo -e "${YELLOW}▶ Docker not found. Installing Docker Desktop via Homebrew (cask)...${RESET}" echo -e "${DIM} Note: Docker Desktop is optional — only needed for HF Space image builds (option 7).${RESET}" brew install --cask docker || { echo -e "${RED}❌ Failed to install Docker Desktop. Install from https://www.docker.com/products/docker-desktop/${RESET}" return 1 } echo -e "${YELLOW} Opening Docker Desktop for first-time setup...${RESET}" open -a Docker 2>/dev/null || true echo -e "${YELLOW} Wait until Docker Desktop finishes starting, then re-run option 7.${RESET}" return 1 } ensure_hf_cli() { refresh_path if command -v hf &> /dev/null; then echo -e " ${GREEN}✓ Hugging Face CLI available ($(hf version 2>/dev/null | head -1 || echo hf)).${RESET}" return 0 fi echo -e "${YELLOW}▶ Installing Hugging Face Hub CLI via uv…${RESET}" uv tool install huggingface_hub[cli] 2>/dev/null || uv pip install --system 'huggingface_hub[cli]' 2>/dev/null || { echo -e "${RED}❌ Could not install hf CLI. Run: uv tool install 'huggingface_hub[cli]' then hf auth login${RESET}" return 1 } refresh_path if ! command -v hf &> /dev/null; then echo -e "${RED}❌ hf not on PATH after install. Open a new terminal or run: uv tool update-shell${RESET}" return 1 fi return 0 } ensure_hf_auth() { local out ec=0 out="$(hf auth whoami 2>&1)" || ec=$? if [ "$ec" -ne 0 ] || echo "$out" | grep -qiE 'invalid user token|not logged in|no token|unauthorized|401|error:'; then echo -e "${RED}❌ Hugging Face auth failed.${RESET}" echo -e "${DIM}$(echo "$out" | head -4)${RESET}" echo -e "${YELLOW} Fix: hf auth login --force --add-to-git-credential${RESET}" echo -e "${DIM} Create a Write token at https://huggingface.co/settings/tokens${RESET}" return 1 fi echo -e " ${GREEN}✓ HF auth OK${RESET} ${DIM}$(echo "$out" | head -3 | tr '\n' ' ')${RESET}" return 0 } build_hf_docker() { echo -e "${YELLOW}${BOLD}Compiling Hugging Face Spaces Docker Image (cpu-basic / torch CPU)…${RESET}" if ! ensure_docker; then read -p "Press Enter to return to menu..." return fi echo -e "${DIM} UV_TORCH_BACKEND=cpu · port 7860 · precomputes vocab_embeddings.npz in-image${RESET}" docker build -t vhectorlab-3d:latest . if [ $? -ne 0 ]; then echo -e "${RED}${BOLD}❌ Docker build failed.${RESET}" read -p "Press Enter to return to menu..." return fi echo -e "${GREEN}${BOLD}✅ Image tagged vhectorlab-3d:latest${RESET}" read -p "Smoke-test locally on :7860 now? [y/N]: " smoke if [[ "$smoke" =~ ^[Yy]$ ]]; then echo -e "${CYAN}▶ docker run --rm -p 7860:7860 vhectorlab-3d:latest${RESET}" echo -e "${DIM} Open http://127.0.0.1:7860 — Ctrl+C stops the container.${RESET}" docker run --rm -p 7860:7860 vhectorlab-3d:latest fi read -p "Press Enter to return to menu..." } # Normalize user input to Hugging Face Space id: "namespace/name". # Accepts bare id, or URLs like https://huggingface.co/spaces/ns/name normalize_hf_space_id() { local raw="$1" raw="$(echo "$raw" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" # Strip common URL prefixes (order matters: spaces/ before bare hub host) raw="$(echo "$raw" | sed -E \ -e 's#^https?://huggingface\.co/spaces/##' \ -e 's#^https?://hf\.co/spaces/##' \ -e 's#^https?://huggingface\.co/##' \ -e 's#^https?://hf\.co/##' \ -e 's#/$##')" echo "$raw" } validate_hf_space_id() { local id="$1" if [[ ! "$id" =~ ^[A-Za-z0-9_-]+/[A-Za-z0-9._-]+$ ]]; then return 1 fi return 0 } load_dotenv() { # Export KEY=VAL from .env (ignore comments / blank lines). Used for option 8 defaults. if [ ! -f .env ]; then return 0 fi local line key val while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|\#*) continue ;; esac if [[ "$line" != *=* ]]; then continue fi key="${line%%=*}" val="${line#*=}" key="$(echo "$key" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" val="$(echo "$val" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")" if [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then export "$key=$val" fi done < .env } publish_hf_space() { load_dotenv local default_space="${HF_SPACE_ID:-hbauzan/llm-semantic-visualizer}" local force_push="${HF_SPACE_FORCE_PUSH:-1}" echo -e "${YELLOW}${BOLD}--- Hugging Face Spaces Publisher (Docker · cpu-basic) ---${RESET}" echo -e "${DIM} Defaults from .env — press Enter to accept.${RESET}" echo -e "${DIM} Space: ${default_space} · force-push: ${force_push}${RESET}" if ! ensure_hf_cli; then read -p "Press Enter..." return fi if ! ensure_hf_auth; then read -p "Press Enter..." return fi local space_raw read -p "Enter Space id [${default_space}]: " space_raw space_raw="${space_raw:-$default_space}" local space_id space_id="$(normalize_hf_space_id "$space_raw")" if [ -z "$space_id" ]; then echo -e "${RED}Space ID required.${RESET}" read -p "Press Enter..." return fi if ! validate_hf_space_id "$space_id"; then echo -e "${RED}❌ Invalid Space id: ${space_raw}${RESET}" echo -e "${RED} Use namespace/name only, e.g. hbauzan/llm-semantic-visualizer${RESET}" echo -e "${DIM} (You pasted a profile/URL; that is not a Space repository.)${RESET}" read -p "Press Enter..." return fi if [ "$space_id" != "$space_raw" ]; then echo -e "${CYAN} Normalized to: ${BOLD}${space_id}${RESET}" fi # Prefer reusing an existing Space. `hf repos create` now returns 402 without PRO # even with --exist-ok, so never call create when the Space is already there. if hf spaces info "$space_id" &> /dev/null; then echo -e " ${GREEN}✓ Space exists — skipping create (push-only).${RESET}" echo -e "${DIM} https://huggingface.co/spaces/${space_id}${RESET}" else echo -e "${CYAN}▶ Space not found. Creating (sdk=docker, flavor=cpu-basic)…${RESET}" echo -e "${DIM} Note: new Gradio/Docker Spaces require HF PRO since mid-2026.${RESET}" if ! hf repos create "$space_id" --type space --space-sdk docker --flavor cpu-basic --exist-ok; then if ! hf repos create "$space_id" --type space --space-sdk docker --exist-ok; then echo -e "${RED}❌ Cannot create Space (often HTTP 402 without PRO).${RESET}" echo -e "${YELLOW} Reuse an existing Docker Space id, or subscribe: https://huggingface.co/pro${RESET}" read -p "Press Enter..." return fi echo -e "${YELLOW}⚠ Created without --flavor; set hardware to cpu-basic in Space Settings if needed.${RESET}" fi fi echo -e "${CYAN}Building production frontend bundle…${RESET}" npm run build || { echo -e "${RED}❌ Frontend build failed.${RESET}" read -p "Press Enter..." return } local space_url="https://huggingface.co/spaces/${space_id}" echo -e "${CYAN}Pushing HEAD → ${space_url} (main)…${RESET}" echo -e "${DIM} HF builds the Docker image from this repo (Dockerfile + README sdk: docker).${RESET}" # Space git history is independent of GitHub. Default force-push replaces the old # Space tree (e.g. predecessor app) with this repo — does NOT touch origin/main. if [ "$force_push" = "1" ] || [ "$force_push" = "true" ] || [ "$force_push" = "yes" ]; then echo -e "${DIM} Using --force (HF_SPACE_FORCE_PUSH=${force_push}); GitHub main is untouched.${RESET}" if ! git push --force "$space_url" HEAD:main; then echo -e "${RED}❌ git push --force to Space failed.${RESET}" echo -e "${DIM} Tip: hf auth login --force --add-to-git-credential ; token needs Write scope.${RESET}" read -p "Press Enter..." return fi else if ! git push "$space_url" HEAD:main; then echo -e "${RED}❌ git push to Space failed (divergent history?).${RESET}" echo -e "${DIM} Set HF_SPACE_FORCE_PUSH=1 in .env to overwrite the Space remote.${RESET}" read -p "Press Enter..." return fi fi echo -e "${GREEN}${BOLD}✅ Published.${RESET}" echo -e " Space URL: ${CYAN}${space_url}${RESET}" echo -e "${DIM} Build may take several minutes (torch CPU + model + vocab NPZ).${RESET}" read -p "Press Enter to return to menu..." } stop_services() { echo -e "${RED}${BOLD}Stopping background services...${RESET}" kill_stack echo -e "${GREEN}✅ Services stopped.${RESET}" read -p "Press Enter to return to menu..." } view_logs() { if [ -f "$LOG_FILE" ]; then echo -e "${MAGENTA}${BOLD}📜 Mostrando últimos logs del backend...${RESET}" tail -n 40 "$LOG_FILE" | sed \ -e "s/INFO/${GREEN}INFO${RESET}/g" \ -e "s/WARNING/${YELLOW}WARNING${RESET}/g" \ -e "s/ERROR/${RED}ERROR${RESET}/g" \ -e "s/200 OK/${GREEN}200 OK${RESET}/g" else echo -e "${RED}No log file found.${RESET}" fi read -p "Press Enter..." } while true; do show_menu echo -ne "${BOLD}Choose an option [0-10]: ${RESET}" read choice case $choice in 1) deploy_and_start ;; 2) start_bare_metal_backend ;; 3) (cd backend && uv run python perform_tests.py) read -p "Press Enter..." ;; 4) npx vitest run --reporter=verbose read -p "Press Enter..." ;; 5) (cd backend && uv run python -m pytest -v -s) read -p "Press Enter..." ;; 6) manage_vocab ;; 7) build_hf_docker ;; 8) publish_hf_space ;; 9) view_logs ;; 10) stop_services ;; 0) exit_panel_keep_services ;; *) echo -e "${RED}Invalid choice.${RESET}" sleep 1 ;; esac done