hbauzan's picture
fix(setup): default HF Space id and force-push publish path
182139f
Raw
History Blame Contribute Delete
32.2 kB
#!/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 <workdir> <logfile|-> <cmd...> ("-" 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