"""Start/stop the Vite dev server from the web UI. Dev-only convenience: production serves the built interface/web/dist from this same backend, but hot-reload editing of the frontend needs Vite running separately. Shares management/ncp.py's PID file (runtime/pids/frontend.pid) and process patterns so `ncp status` / `ncp stop --frontend` see the same process regardless of which side started it. """ from __future__ import annotations import os import shutil import subprocess import urllib.request try: import psutil except ImportError: # optional in the portable/Termux core install psutil = None from . import proc_util from .nexus_config import FRONTEND_SOURCE_DIR, RUNTIME_DIR FRONTEND_DIR = FRONTEND_SOURCE_DIR PID_FILE = RUNTIME_DIR / "pids" / "frontend.pid" LOG_FILE = RUNTIME_DIR / "frontend.log" PORT = 5173 def _npm() -> str | None: return shutil.which("npm.cmd" if os.name == "nt" else "npm") def _read_pid() -> int | None: try: return int(PID_FILE.read_text().strip()) except (OSError, ValueError): return None def _is_ours(pid: int) -> bool: # On Windows, npm.cmd can't run as a CreateProcess image directly, so the # OS wraps it as `cmd.exe /c ...npm.cmd run dev ...` - the ".cmd" splits # "npm" from "run dev", so a literal "npm run dev" substring never matches # the tracked PID's own cmdline. Loosen to "vite" (matches once the tree # gets that far) or "npm"+"dev" both present (matches the wrapper hop too). try: if psutil is not None: cmd = " ".join(psutil.Process(pid).cmdline()) else: cmd = proc_util.pid_cmdline(pid) except Exception: return False return "vite" in cmd or ("npm" in cmd and "dev" in cmd) def _alive(pid: int | None) -> bool: if pid is None: return False if psutil is not None: return psutil.pid_exists(pid) # proc_util rather than os.kill(pid, 0): same probe, but it also works # when the PID belongs to another user. return proc_util.pid_alive(pid) def _http_up() -> bool: try: with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1.5) as r: return r.status == 200 except Exception: return False def is_running() -> bool: """Mirrors ncp.py's own status check: PID liveness first, then whether the port actually answers (covers Vite started by another process, or a lost PID file) - not the stricter pattern match `stop()` uses before killing.""" pid = _read_pid() if _alive(pid): return True return _http_up() def start() -> dict: if is_running(): return {"status": "already_running"} if not FRONTEND_DIR.is_dir(): return {"status": "unavailable", "detail": "Vite source is not included in wheel installs"} npm = _npm() if not npm: return {"status": "error", "detail": "npm not found - install Node.js"} argv = [npm, "run", "dev", "--", "--host", "0.0.0.0"] PID_FILE.parent.mkdir(parents=True, exist_ok=True) LOG_FILE.write_text("") # CREATE_NO_WINDOW (not DETACHED_PROCESS): a detached child has no console # of its own, so Windows hands one to any console program it spawns in # turn - npm.cmd -> node -> vite would each flash a window. CREATE_NO_WINDOW # gives it a console that's never shown, inherited down the chain. kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "CREATE_NO_WINDOW", 0)} if os.name == "nt" else {"start_new_session": True}) with open(LOG_FILE, "ab") as log: proc = subprocess.Popen(argv, cwd=str(FRONTEND_DIR), stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, **kwargs) PID_FILE.write_text(str(proc.pid)) return {"status": "started", "pid": proc.pid} def stop() -> dict: pid = _read_pid() if _alive(pid) and _is_ours(pid): try: if psutil is not None: proc = psutil.Process(pid) # npm.cmd -> node -> vite is a multi-hop tree; kill it depth-first # so the parent doesn't outlive its children as an orphaned shell. for child in proc.children(recursive=True): try: child.terminate() except Exception: pass proc.terminate() else: proc_util.terminate_pid(pid) except Exception: pass PID_FILE.unlink(missing_ok=True) return {"status": "stopped"}