feat: add portable NexusOS CLI and packaging
This commit is contained in:
+94
-30
@@ -28,15 +28,20 @@ import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PID_DIR = ROOT / "runtime" / "pids"
|
||||
LOG_DIR = ROOT / "runtime"
|
||||
FRONTEND_DIR = ROOT / "interface" / "web"
|
||||
from synapse.nexus_config import SOURCE_CHECKOUT, settings
|
||||
|
||||
ROOT = settings.project_root
|
||||
PID_DIR = settings.runtime_dir / "pids"
|
||||
LOG_DIR = settings.runtime_dir
|
||||
FRONTEND_DIR = settings.frontend_source_dir
|
||||
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
|
||||
OLLAMA_MODELS_DIR = ROOT / "models"
|
||||
OLLAMA_MODELS_DIR = settings.models_dir
|
||||
|
||||
WINDOWS = os.name == "nt"
|
||||
PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
|
||||
_VENV_PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
|
||||
PYTHON = Path(os.getenv("NEXUS_PYTHON", "")) if os.getenv("NEXUS_PYTHON") else (
|
||||
_VENV_PYTHON if _VENV_PYTHON.exists() else Path(sys.executable)
|
||||
)
|
||||
|
||||
PID_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -93,7 +98,7 @@ def _psutil():
|
||||
import psutil
|
||||
return psutil
|
||||
except ImportError:
|
||||
sys.exit("psutil is missing - run ./install.sh (or install-windows.ps1) to rebuild the venv.")
|
||||
return None
|
||||
|
||||
|
||||
# -- services ------------------------------------------------------------------
|
||||
@@ -124,7 +129,7 @@ class Service:
|
||||
# Bind loopback by default: the backend REST API is unauthenticated, so
|
||||
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
|
||||
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
|
||||
BIND_HOST = os.environ.get("NEXUS_BIND_HOST", "127.0.0.1")
|
||||
BIND_HOST = settings.bind_host
|
||||
|
||||
|
||||
def _uvicorn(app: str, port: int):
|
||||
@@ -140,9 +145,12 @@ def _uvicorn(app: str, port: int):
|
||||
|
||||
|
||||
SERVICES = {
|
||||
"backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT,
|
||||
"memory": Service("memory", "NEXUS MEMORY SERVICE", settings.memory_port, settings.state_dir,
|
||||
["uvicorn synapse.memory"],
|
||||
lambda: _uvicorn("synapse.memory.service:app", settings.memory_port)),
|
||||
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
|
||||
["uvicorn synapse.main"],
|
||||
lambda: _uvicorn("synapse.main:sio_app", 8000)),
|
||||
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
|
||||
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
||||
["vite --host", "npm run dev"],
|
||||
lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
|
||||
@@ -158,7 +166,15 @@ def read_pid(svc: Service):
|
||||
|
||||
def alive(pid) -> bool:
|
||||
ps = _psutil()
|
||||
return pid is not None and ps.pid_exists(pid)
|
||||
if pid is None:
|
||||
return False
|
||||
if ps is not None:
|
||||
return ps.pid_exists(pid)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def pid_is_ours(pid, patterns) -> bool:
|
||||
@@ -168,7 +184,13 @@ def pid_is_ours(pid, patterns) -> bool:
|
||||
is not enough. TERMing a recycled PID can log the user out."""
|
||||
ps = _psutil()
|
||||
try:
|
||||
cmd = " ".join(ps.Process(pid).cmdline())
|
||||
if ps is not None:
|
||||
cmd = " ".join(ps.Process(pid).cmdline())
|
||||
elif os.name != "nt":
|
||||
cmd = (Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
.replace(b"\0", b" ").decode(errors="replace"))
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return any(p in cmd for p in patterns)
|
||||
@@ -255,6 +277,20 @@ def kill_matching(patterns, force=False) -> int:
|
||||
ps = _psutil()
|
||||
me = os.getpid()
|
||||
hit = 0
|
||||
if ps is None:
|
||||
if os.name == "nt":
|
||||
return 0
|
||||
for entry in Path("/proc").iterdir():
|
||||
if not entry.name.isdigit() or int(entry.name) == me:
|
||||
continue
|
||||
try:
|
||||
cmd = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
|
||||
if any(pattern in cmd for pattern in patterns):
|
||||
os.kill(int(entry.name), 9 if force else 15)
|
||||
hit += 1
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return hit
|
||||
for proc in ps.process_iter(["pid", "cmdline"]):
|
||||
if proc.info["pid"] == me:
|
||||
continue
|
||||
@@ -272,6 +308,8 @@ def kill_port(port: int) -> bool:
|
||||
"""Whatever holds the port IS the service - this is the backstop that makes
|
||||
stop reliable regardless of process-tree shape or PID-file accuracy."""
|
||||
ps = _psutil()
|
||||
if ps is None:
|
||||
return False
|
||||
killed = False
|
||||
try:
|
||||
conns = ps.net_connections(kind="inet")
|
||||
@@ -295,13 +333,16 @@ def stop_service(svc: Service) -> bool:
|
||||
if alive(pid) and pid_is_ours(pid, svc.patterns):
|
||||
ps = _psutil()
|
||||
try:
|
||||
proc = ps.Process(pid)
|
||||
for child in proc.children(recursive=True):
|
||||
try:
|
||||
child.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
proc.terminate()
|
||||
if ps is not None:
|
||||
proc = ps.Process(pid)
|
||||
for child in proc.children(recursive=True):
|
||||
try:
|
||||
child.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
proc.terminate()
|
||||
elif os.name != "nt":
|
||||
os.kill(pid, 15)
|
||||
except Exception:
|
||||
pass
|
||||
svc.pid_file.unlink(missing_ok=True)
|
||||
@@ -332,8 +373,15 @@ def start_ollama(background: bool = False) -> None:
|
||||
background=False (`ncp start --ai`): blocks until the model is warmed - weights
|
||||
read off disk into RAM/VRAM, routinely about a minute - so "started" means the
|
||||
AI can actually answer."""
|
||||
if not settings.manage_ollama:
|
||||
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags", timeout=3)
|
||||
print(
|
||||
f"REMOTE OLLAMA {'REACHABLE' if running else 'UNREACHABLE'} "
|
||||
f"({settings.ollama_host})"
|
||||
)
|
||||
return
|
||||
print("Starting OLLAMA...")
|
||||
url = "http://localhost:8000/ollama/start" + ("?background=true" if background else "")
|
||||
url = settings.api_url + "/ollama/start" + ("?background=true" if background else "")
|
||||
req = urllib.request.Request(url, method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=180).read(1)
|
||||
@@ -346,7 +394,10 @@ def stop_ollama() -> None:
|
||||
"""Prefer the backend endpoint for a clean OllamaManager shutdown; if the
|
||||
backend is already down, kill `ollama serve` directly so it never lingers
|
||||
holding VRAM/RAM. Must run BEFORE the backend is torn down."""
|
||||
req = urllib.request.Request("http://localhost:8000/ollama/stop", method="POST")
|
||||
if not settings.manage_ollama:
|
||||
print(f"REMOTE OLLAMA IS EXTERNALLY MANAGED ({settings.ollama_host})")
|
||||
return
|
||||
req = urllib.request.Request(settings.api_url + "/ollama/stop", method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=5).read(1)
|
||||
print("NEXUS OLLAMA STOPPED")
|
||||
@@ -367,6 +418,9 @@ def cmd_start(target) -> None:
|
||||
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
|
||||
start_ollama()
|
||||
elif target in ("--frontend", "-f"):
|
||||
if not SOURCE_CHECKOUT:
|
||||
print("Vite source is unavailable in wheel installs; the backend serves the bundled UI.")
|
||||
return
|
||||
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
|
||||
elif target in ("--ai", "-a"):
|
||||
start_ollama()
|
||||
@@ -389,7 +443,7 @@ def cmd_start(target) -> None:
|
||||
wait_for_port(SERVICES["backend"])
|
||||
t_services = time.perf_counter()
|
||||
start_ollama(background=True)
|
||||
if not WINDOWS:
|
||||
if SOURCE_CHECKOUT and not WINDOWS:
|
||||
launch(SERVICES["frontend"])
|
||||
t_bg = time.perf_counter()
|
||||
print("\nBoot timing:")
|
||||
@@ -405,6 +459,8 @@ def cmd_stop(target) -> None:
|
||||
stop_ollama(); stop_service(SERVICES["backend"])
|
||||
elif target in ("--frontend", "-f"):
|
||||
stop_service(SERVICES["frontend"])
|
||||
elif target in ("--ai", "-a"):
|
||||
stop_ollama()
|
||||
elif target in (None, "", "all"):
|
||||
stop_ollama()
|
||||
stop_service(SERVICES["backend"])
|
||||
@@ -415,14 +471,21 @@ def cmd_stop(target) -> None:
|
||||
|
||||
def cmd_kill() -> None:
|
||||
print("Force-killing all Nexus processes...")
|
||||
for port, name in ((8000, "SYNAPSE"),
|
||||
(5173, "INTERFACE"), (11434, "OLLAMA")):
|
||||
targets = [
|
||||
(settings.backend_port, "SYNAPSE"),
|
||||
(settings.memory_port, "MEMORY"),
|
||||
(5173, "INTERFACE"),
|
||||
]
|
||||
patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
|
||||
if settings.manage_ollama:
|
||||
targets.append((11434, "OLLAMA"))
|
||||
patterns.append("ollama serve")
|
||||
for port, name in targets:
|
||||
if kill_port(port):
|
||||
print(f" KILLED: {name} (:{port})")
|
||||
else:
|
||||
print(f" NOT RUNNING: {name} (:{port})")
|
||||
kill_matching(["uvicorn synapse", "npm run dev", "vite --host", "ollama serve"],
|
||||
force=True)
|
||||
kill_matching(patterns, force=True)
|
||||
for pid_file in PID_DIR.glob("*.pid"):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
print("Done.")
|
||||
@@ -445,8 +508,9 @@ def cmd_status() -> None:
|
||||
print("\nFrontend:")
|
||||
one("Vite ", SERVICES["frontend"])
|
||||
print("\nModel server:")
|
||||
running = http_ok("http://localhost:11434/api/tags")
|
||||
print(f" Ollama : {'RUNNING (:11434)' if running else 'STOPPED'}")
|
||||
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags")
|
||||
mode = "remote" if not settings.manage_ollama else "local"
|
||||
print(f" Ollama ({mode}) : {'RUNNING' if running else 'STOPPED'} ({settings.ollama_host})")
|
||||
|
||||
|
||||
def _tail(path: Path, n: int) -> None:
|
||||
@@ -687,10 +751,10 @@ MODEL_CATALOG = [
|
||||
|
||||
def cmd_models(action, name) -> None:
|
||||
if action == "list":
|
||||
if not http_ok("http://localhost:11434/api/tags"):
|
||||
if not http_ok(settings.ollama_host.rstrip("/") + "/api/tags"):
|
||||
print("Ollama is not running. Start the backend first with: ncp start -b")
|
||||
return
|
||||
raw = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=5).read()
|
||||
raw = urllib.request.urlopen(settings.ollama_host.rstrip("/") + "/api/tags", timeout=5).read()
|
||||
models = json.loads(raw).get("models", [])
|
||||
print("Installed models:\n")
|
||||
if not models:
|
||||
|
||||
Reference in New Issue
Block a user