Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
877 lines
34 KiB
Python
877 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""ncp - the NexusOS management CLI, one implementation for Linux and Windows.
|
|
|
|
This replaces the logic that lived in management/nexus-cli.sh. That script was
|
|
bash + pkill + fuser + /proc, so the Windows box had no `ncp` at all - the same
|
|
split that made bin/install.sh rot until it was rsyncing from a path retired
|
|
months earlier. Same fix as bin/sync.py: the portable half is Python, and the
|
|
per-platform pieces are small and explicit.
|
|
|
|
nexus-cli.sh is now a two-line wrapper, so `ncp`, launch_nexus.sh,
|
|
bin/restore-linux.sh, controlpanel.py, nexus-popup.py and nexus-app.sh all keep
|
|
calling what they always called.
|
|
|
|
psutil does the process work (cmdlines, pattern sweeps, port owners). It is
|
|
declared in requirements-base.txt AND requirements-windows.txt, so both boxes have
|
|
it - but it is imported lazily so `ncp backup`/`restore` still run before the
|
|
venv exists, exactly as they did when they shelled out to sync.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
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"
|
|
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
|
|
OLLAMA_MODELS_DIR = ROOT / "models"
|
|
|
|
WINDOWS = os.name == "nt"
|
|
PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
|
|
|
|
PID_DIR.mkdir(parents=True, exist_ok=True)
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# The output carries em-dashes and check marks (matching what nexus-cli.sh
|
|
# printed). On Windows a redirected stdout defaults to cp1252, which raises
|
|
# UnicodeEncodeError on both - so pin UTF-8 rather than degrade the output.
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
|
|
|
|
# -- small helpers -------------------------------------------------------------
|
|
|
|
def http_ok(url: str, timeout: float = 1.0) -> bool:
|
|
"""True if the URL answers at all. Any HTTP status counts - a 404 still
|
|
proves something is listening, which is all the port checks care about."""
|
|
try:
|
|
urllib.request.urlopen(url, timeout=timeout).read(1)
|
|
return True
|
|
except urllib.error.HTTPError:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def ollama_bin() -> str | None:
|
|
"""The bundled binary if we have one, else whatever is on PATH.
|
|
|
|
Only Linux ships a copy in the repo (bin/fetch-ollama.sh); on Windows the
|
|
installer gets Ollama from winget, which puts it in %LOCALAPPDATA%\\Programs
|
|
and on PATH. Checking only the bundled path made `ncp doctor` report a red
|
|
"Ollama binary missing" on every Windows box, and made `ncp models install`
|
|
refuse to run there at all. Mirrors _ollama_bin() in ollama_manager.py."""
|
|
if OLLAMA_BIN.exists():
|
|
return str(OLLAMA_BIN)
|
|
return shutil.which("ollama")
|
|
|
|
|
|
def npm() -> str | None:
|
|
"""npm, resolving through nvm. The bash version sourced ~/.nvm/nvm.sh; a
|
|
non-login shell has neither, so fall back to globbing the nvm install."""
|
|
found = shutil.which("npm") # resolves npm.cmd on Windows
|
|
if found:
|
|
return found
|
|
versions = sorted((Path.home() / ".nvm" / "versions" / "node").glob("*/bin/npm"))
|
|
return str(versions[-1]) if versions else None
|
|
|
|
|
|
def _psutil():
|
|
try:
|
|
import psutil
|
|
return psutil
|
|
except ImportError:
|
|
sys.exit("psutil is missing - run ./install.sh (or install-windows.ps1) to rebuild the venv.")
|
|
|
|
|
|
# -- services ------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Service:
|
|
key: str
|
|
label: str
|
|
port: int
|
|
cwd: Path
|
|
patterns: list
|
|
_argv: object = None
|
|
pid_file: Path = field(init=False)
|
|
log_file: Path = field(init=False)
|
|
|
|
def __post_init__(self):
|
|
self.pid_file = PID_DIR / f"{self.key}.pid"
|
|
self.log_file = LOG_DIR / f"{self.key}.log"
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
return f"http://localhost:{self.port}/"
|
|
|
|
def argv(self):
|
|
return self._argv() if callable(self._argv) else self._argv
|
|
|
|
|
|
# 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")
|
|
|
|
|
|
def _uvicorn(app: str, port: int):
|
|
# No --reload. It is a dev-loop flag: uvicorn's reloader runs a supervisor
|
|
# that spawns the real server as a CHILD, so every service became two
|
|
# processes - and on Windows that child, spawned from a parent with no
|
|
# console, got handed a brand new console WINDOW. `ncp web` popped two black
|
|
# terminals for what should have been a silent start. launch_nexus.sh still
|
|
# passes --reload for the Linux dev loop, where a visible console is the
|
|
# point; this launcher is the one users run.
|
|
return [str(PYTHON), "-m", "uvicorn", app, "--host", BIND_HOST,
|
|
"--port", str(port)]
|
|
|
|
|
|
SERVICES = {
|
|
"backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT,
|
|
["uvicorn synapse.main"],
|
|
lambda: _uvicorn("synapse.main:sio_app", 8000)),
|
|
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
|
["vite --host", "npm run dev"],
|
|
lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
|
|
}
|
|
|
|
|
|
def read_pid(svc: Service):
|
|
try:
|
|
return int(svc.pid_file.read_text().strip())
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def alive(pid) -> bool:
|
|
ps = _psutil()
|
|
return pid is not None and ps.pid_exists(pid)
|
|
|
|
|
|
def pid_is_ours(pid, patterns) -> bool:
|
|
"""True only if the live PID's command line matches one of the service
|
|
patterns. PID files outlive reboots and the OS recycles the number onto an
|
|
unrelated process - often a desktop-session one - so a bare liveness check
|
|
is not enough. TERMing a recycled PID can log the user out."""
|
|
ps = _psutil()
|
|
try:
|
|
cmd = " ".join(ps.Process(pid).cmdline())
|
|
except Exception:
|
|
return False
|
|
return any(p in cmd for p in patterns)
|
|
|
|
|
|
def launch(svc: Service) -> None:
|
|
pid = read_pid(svc)
|
|
if alive(pid) and pid_is_ours(pid, svc.patterns):
|
|
return
|
|
argv = svc.argv()
|
|
if argv[0] is None:
|
|
print(f" {svc.label}: npm not found - install Node, or run ./install.sh")
|
|
return
|
|
svc.log_file.write_text("")
|
|
with open(svc.log_file, "ab") as log:
|
|
# Detach so the service outlives this process, on both platforms.
|
|
# CREATE_NO_WINDOW, not DETACHED_PROCESS: detached means the process has
|
|
# NO console, and Windows then gives a console window to any console
|
|
# program it starts in turn. CREATE_NO_WINDOW gives it a console that is
|
|
# never shown, which its children inherit - so nothing flashes up.
|
|
kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
|
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0)}
|
|
if WINDOWS else {"start_new_session": True})
|
|
proc = subprocess.Popen(argv, cwd=str(svc.cwd), stdout=log,
|
|
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
|
**kwargs)
|
|
svc.pid_file.write_text(str(proc.pid))
|
|
|
|
|
|
def check(svc: Service) -> bool:
|
|
pid = read_pid(svc)
|
|
if alive(pid):
|
|
print(f"{svc.label} STARTED")
|
|
return True
|
|
print(f"{svc.label} FAILED TO START")
|
|
try:
|
|
tail = svc.log_file.read_text(errors="replace").splitlines()[-8:]
|
|
for line in tail:
|
|
print(f" {line}")
|
|
except OSError:
|
|
pass
|
|
svc.pid_file.unlink(missing_ok=True)
|
|
return False
|
|
|
|
|
|
# Poll granularity for both waiters below. 1s steps used to mean every
|
|
# start/stop paid up to a full second of dead latency per service on top of
|
|
# however long the process actually took - three services in sequence could
|
|
# lose several seconds to nothing but sleep(). 0.25s still amounts to one
|
|
# cheap local HTTP HEAD every quarter second, not a busy-loop.
|
|
_POLL_STEP = 0.25
|
|
|
|
|
|
def wait_for_port(svc: Service, timeout: int = 30) -> bool:
|
|
if http_ok(svc.url):
|
|
# "READY", not "already running": `ncp start` launches its services and
|
|
# only then waits on each, so by the time a service's turn comes it is
|
|
# normally up - and reporting "already running" for a service this same
|
|
# command started two seconds ago reads like a stale process.
|
|
print(f" {svc.label} READY (:{svc.port})")
|
|
return True
|
|
for _ in range(int(timeout / _POLL_STEP)):
|
|
time.sleep(_POLL_STEP)
|
|
if http_ok(svc.url):
|
|
print(f"{svc.label} STARTED")
|
|
return True
|
|
print(f" {svc.label} timed out after {timeout}s")
|
|
return check(svc)
|
|
|
|
|
|
def wait_for_port_close(port: int, timeout: int = 15) -> bool:
|
|
for _ in range(int(timeout / _POLL_STEP)):
|
|
if not http_ok(f"http://localhost:{port}/"):
|
|
return True
|
|
time.sleep(_POLL_STEP)
|
|
return False
|
|
|
|
|
|
def kill_matching(patterns, force=False) -> int:
|
|
"""The pkill -f sweep: catches reparented grandchildren (npm -> sh -> node
|
|
vite), uvicorn --reload workers, and instances started outside this CLI.
|
|
Returns how many processes it signalled, so callers can stay quiet when
|
|
there was nothing to kill."""
|
|
ps = _psutil()
|
|
me = os.getpid()
|
|
hit = 0
|
|
for proc in ps.process_iter(["pid", "cmdline"]):
|
|
if proc.info["pid"] == me:
|
|
continue
|
|
cmd = " ".join(proc.info["cmdline"] or [])
|
|
if any(p in cmd for p in patterns):
|
|
try:
|
|
proc.kill() if force else proc.terminate()
|
|
hit += 1
|
|
except Exception:
|
|
pass
|
|
return hit
|
|
|
|
|
|
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()
|
|
killed = False
|
|
try:
|
|
conns = ps.net_connections(kind="inet")
|
|
except Exception:
|
|
return False
|
|
for conn in conns:
|
|
if conn.laddr and conn.laddr.port == port and conn.status == "LISTEN" and conn.pid:
|
|
try:
|
|
ps.Process(conn.pid).kill()
|
|
killed = True
|
|
except Exception:
|
|
pass
|
|
return killed
|
|
|
|
|
|
def stop_service(svc: Service) -> bool:
|
|
"""Three escalating passes, with the PORT as the source of truth for
|
|
whether the service is actually down."""
|
|
pid = read_pid(svc)
|
|
if pid is not None:
|
|
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()
|
|
except Exception:
|
|
pass
|
|
svc.pid_file.unlink(missing_ok=True)
|
|
|
|
kill_matching(svc.patterns)
|
|
|
|
if not wait_for_port_close(svc.port):
|
|
kill_port(svc.port)
|
|
kill_matching(svc.patterns, force=True)
|
|
wait_for_port_close(svc.port)
|
|
if http_ok(svc.url):
|
|
print(f"{svc.label} STILL RUNNING (:{svc.port}) — try 'ncp kill'")
|
|
return False
|
|
print(f"{svc.label} STOPPED")
|
|
return True
|
|
|
|
|
|
# -- ollama --------------------------------------------------------------------
|
|
|
|
def start_ollama(background: bool = False) -> None:
|
|
"""Driven through the backend endpoint (the path the control panel uses)
|
|
rather than launching the binary, because OllamaManager owns model and GPU
|
|
selection. Requires the backend to be up.
|
|
|
|
background=True (`ncp start`): the endpoint returns as soon as `ollama serve`
|
|
is up and warms the model in a background task, so boot finishes in seconds
|
|
and the model loads concurrently into the first chat.
|
|
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."""
|
|
print("Starting OLLAMA...")
|
|
url = "http://localhost:8000/ollama/start" + ("?background=true" if background else "")
|
|
req = urllib.request.Request(url, method="POST")
|
|
try:
|
|
urllib.request.urlopen(req, timeout=180).read(1)
|
|
print("NEXUS OLLAMA WARMING (background)" if background else "NEXUS OLLAMA STARTED")
|
|
except Exception as e:
|
|
print(f" OLLAMA start failed: {e}")
|
|
|
|
|
|
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")
|
|
try:
|
|
urllib.request.urlopen(req, timeout=5).read(1)
|
|
print("NEXUS OLLAMA STOPPED")
|
|
return
|
|
except Exception:
|
|
pass
|
|
# Silent when there was nothing to stop - the bash version only announced a
|
|
# direct kill if `pgrep ollama serve` actually matched, and claiming to have
|
|
# stopped a service that was never running is worse than saying nothing.
|
|
if kill_matching(["ollama serve"]):
|
|
print("NEXUS OLLAMA STOPPED (direct)")
|
|
|
|
|
|
# -- commands ------------------------------------------------------------------
|
|
|
|
def cmd_start(target) -> None:
|
|
if target in ("--backend", "-b"):
|
|
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
|
|
start_ollama()
|
|
elif target in ("--frontend", "-f"):
|
|
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
|
|
elif target in ("--ai", "-a"):
|
|
start_ollama()
|
|
elif target in (None, "", "all"):
|
|
# Bring the UI up first, then kick off Ollama and (on Linux) Vite in the
|
|
# background without waiting on either — neither gates the app being
|
|
# usable. The backend already serves the built interface/web/dist at
|
|
# :8000 on both platforms (single-process design), and that's the URL
|
|
# nexus-app.sh/ncp web actually opens; nothing points a user at :5173.
|
|
# Vite only exists for whoever is hot-reload-editing the frontend, and
|
|
# they'll open :5173 themselves once it's ready - polling for it here
|
|
# just delayed "boot done" for a benefit nobody in the critical path
|
|
# gets. Skipped outright on Windows, where it's not part of the normal
|
|
# workflow at all and is the slow part of `ncp stop` to boot (npm's
|
|
# cmd.exe -> node -> esbuild tree doesn't die from a plain terminate()
|
|
# and falls through to the ~30s force-kill path). Still available on
|
|
# demand via `ncp start --frontend`.
|
|
t0 = time.perf_counter()
|
|
launch(SERVICES["backend"])
|
|
wait_for_port(SERVICES["backend"])
|
|
t_services = time.perf_counter()
|
|
start_ollama(background=True)
|
|
if not WINDOWS:
|
|
launch(SERVICES["frontend"])
|
|
t_bg = time.perf_counter()
|
|
print("\nBoot timing:")
|
|
print(f" backend : {t_services - t0:5.1f}s")
|
|
print(f" ollama + frontend (bg kickoff) : {t_bg - t_services:5.1f}s")
|
|
print(f" total to interactive : {t_bg - t0:5.1f}s")
|
|
else:
|
|
show_help()
|
|
|
|
|
|
def cmd_stop(target) -> None:
|
|
if target in ("--backend", "-b"):
|
|
stop_ollama(); stop_service(SERVICES["backend"])
|
|
elif target in ("--frontend", "-f"):
|
|
stop_service(SERVICES["frontend"])
|
|
elif target in (None, "", "all"):
|
|
stop_ollama()
|
|
stop_service(SERVICES["backend"])
|
|
stop_service(SERVICES["frontend"])
|
|
else:
|
|
show_help()
|
|
|
|
|
|
def cmd_kill() -> None:
|
|
print("Force-killing all Nexus processes...")
|
|
for port, name in ((8000, "SYNAPSE"),
|
|
(5173, "INTERFACE"), (11434, "OLLAMA")):
|
|
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)
|
|
for pid_file in PID_DIR.glob("*.pid"):
|
|
pid_file.unlink(missing_ok=True)
|
|
print("Done.")
|
|
|
|
|
|
def cmd_status() -> None:
|
|
print("Nexus Service Status:\n")
|
|
|
|
def one(name, svc):
|
|
pid = read_pid(svc)
|
|
if alive(pid):
|
|
print(f" {name}: RUNNING (PID {pid})")
|
|
elif http_ok(svc.url):
|
|
print(f" {name}: RUNNING (port :{svc.port}, PID stale — consider ncp kill)")
|
|
else:
|
|
print(f" {name}: STOPPED")
|
|
|
|
print("Backend:")
|
|
one("Synapse ", SERVICES["backend"])
|
|
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'}")
|
|
|
|
|
|
def _tail(path: Path, n: int) -> None:
|
|
try:
|
|
for line in path.read_text(errors="replace").splitlines()[-n:]:
|
|
print(line)
|
|
except OSError:
|
|
print(f" (no log at {path})")
|
|
|
|
|
|
LOG_HEADINGS = {"backend": "BACKEND", "frontend": "FRONTEND"}
|
|
|
|
|
|
def cmd_logs(target) -> None:
|
|
named = {"--frontend": "frontend", "-f": "frontend",
|
|
"--backend": "backend", "-b": "backend"}
|
|
if target in named:
|
|
key = named[target]
|
|
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
|
|
_tail(SERVICES[key].log_file, 50)
|
|
elif target in (None, "", "all"):
|
|
for i, key in enumerate(("backend", "frontend")):
|
|
if i:
|
|
print()
|
|
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
|
|
_tail(SERVICES[key].log_file, 30)
|
|
else:
|
|
print(f"Unknown logs target: '{target}'")
|
|
print("Usage: ncp logs [frontend|backend|all]")
|
|
|
|
|
|
def _apply_fixes() -> None:
|
|
"""Opt-in safe repairs (ncp doctor --fix). Only idempotent, non-destructive
|
|
actions: build the UI, install deps, fetch the Ollama binary. Anything that
|
|
could lose data or needs a decision is left to the user with a hint."""
|
|
print("\nApplying safe fixes...\n")
|
|
did = False
|
|
web, npm_path = FRONTEND_DIR, npm()
|
|
|
|
if not PYTHON.exists():
|
|
print(" ✘ Promethean venv missing — run ./install.sh to build it (skipped: heavy).")
|
|
else:
|
|
importable = subprocess.run(
|
|
[str(PYTHON), "-c", "from synapse.main import sio_app"],
|
|
cwd=str(ROOT), capture_output=True).returncode == 0
|
|
if not importable:
|
|
print(" Backend import failed — reinstalling Python dependencies...")
|
|
sys.path.insert(0, str(ROOT / "bin"))
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location("sync", ROOT / "bin" / "sync.py")
|
|
sync = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(sync)
|
|
subprocess.run([str(PYTHON), "-m", "pip", "install", "-r", sync.requirements()], cwd=str(ROOT))
|
|
did = True
|
|
|
|
if npm_path and web.is_dir() and not (web / "node_modules").is_dir():
|
|
print(" node_modules missing — running npm install...")
|
|
subprocess.run([npm_path, "install"], cwd=str(web))
|
|
did = True
|
|
|
|
if npm_path and web.is_dir() and not (web / "dist" / "index.html").exists():
|
|
print(" Built UI missing — running npm run build...")
|
|
subprocess.run([npm_path, "run", "build"], cwd=str(web))
|
|
did = True
|
|
|
|
if not ollama_bin() and os.name != "nt" and (ROOT / "bin" / "fetch-ollama.sh").exists():
|
|
print(" Ollama binary missing — fetching...")
|
|
subprocess.run(["bash", str(ROOT / "bin" / "fetch-ollama.sh")])
|
|
did = True
|
|
|
|
print("\n" + ("Fixes applied — re-run 'ncp doctor' to confirm." if did
|
|
else "Nothing to fix (all safe-repairable checks already pass)."))
|
|
|
|
|
|
def cmd_doctor(fix: bool = False) -> None:
|
|
def mark(ok, good, bad):
|
|
print(f" {'✔' if ok else '✘'} {good if ok else bad}")
|
|
|
|
print("Running Nexus Diagnostics...\n")
|
|
print("Checking directories...")
|
|
mark(ROOT.is_dir(), "Nexus root found", "Missing Nexus root")
|
|
mark(FRONTEND_DIR.is_dir(), "Frontend directory found", "Missing frontend directory")
|
|
|
|
print("\nChecking Python venv...")
|
|
if PYTHON.exists():
|
|
ver = subprocess.run([str(PYTHON), "--version"], capture_output=True,
|
|
text=True).stdout.strip()
|
|
mark(True, f"Promethean venv found ({ver})", "")
|
|
else:
|
|
mark(False, "", f"Promethean venv missing at {PYTHON}")
|
|
|
|
print("\nChecking Node & npm...")
|
|
|
|
def version(exe):
|
|
if not exe:
|
|
return ""
|
|
try:
|
|
return subprocess.run([exe, "-v"], capture_output=True, text=True).stdout.strip()
|
|
except OSError:
|
|
return ""
|
|
|
|
def node_ok(ver: str) -> bool:
|
|
# Vite needs Node 20.19+ (or 22.12+); Debian's apt 'nodejs' is EOL 18.
|
|
try:
|
|
parts = ver.lstrip("v").split(".")
|
|
major, minor = int(parts[0]), int(parts[1])
|
|
except (ValueError, IndexError):
|
|
return False
|
|
return major >= 21 or (major == 20 and minor >= 19)
|
|
|
|
node, npm_path = shutil.which("node"), npm()
|
|
node_ver = version(node)
|
|
mark(bool(node), f"Node installed ({node_ver})", "Node missing")
|
|
if node:
|
|
mark(node_ok(node_ver),
|
|
f"Node version OK ({node_ver} >= 20.19, Vite requirement)",
|
|
f"Node {node_ver} too old for Vite (needs 20.19+). Install Node 20 "
|
|
f"(re-run ./install.sh, or NodeSource), then rebuild with: ncp update")
|
|
mark(bool(npm_path), f"npm installed ({version(npm_path)})", "npm missing")
|
|
mark((FRONTEND_DIR / "node_modules").is_dir(),
|
|
"Frontend node_modules installed",
|
|
"Frontend node_modules missing (run: ncp update)")
|
|
|
|
print("\nChecking Uvicorn...")
|
|
uvicorn_ok = subprocess.run([str(PYTHON), "-m", "uvicorn", "--version"],
|
|
capture_output=True).returncode == 0
|
|
mark(uvicorn_ok, "Uvicorn installed", "Uvicorn missing")
|
|
|
|
def importable(stmt):
|
|
return subprocess.run([str(PYTHON), "-c", stmt], cwd=str(ROOT),
|
|
capture_output=True).returncode == 0
|
|
|
|
print("\nChecking backend service...")
|
|
mark(importable("from synapse.main import sio_app"),
|
|
"Backend module importable", "Backend module failed to import")
|
|
|
|
print("\nChecking memory...")
|
|
mark((ROOT / "synapse" / "memory").is_dir(),
|
|
"Memory module directory found", "Missing memory module directory")
|
|
mark(importable("from synapse.memory.curator import extract_for_conversation"),
|
|
"Memory curator importable", "Memory curator failed to import")
|
|
mark(os.access(ROOT / "synapse" / "memory", os.W_OK),
|
|
"Memory database directory writable", "Memory database directory not writable")
|
|
|
|
print("\nChecking Ollama...")
|
|
_obin = ollama_bin()
|
|
mark(bool(_obin), f"Ollama binary found ({_obin})",
|
|
f"Ollama binary missing (not at {OLLAMA_BIN}, not on PATH)")
|
|
mark(OLLAMA_MODELS_DIR.is_dir(), f"Ollama models directory found ({OLLAMA_MODELS_DIR})",
|
|
f"Ollama models directory missing at {OLLAMA_MODELS_DIR}")
|
|
print()
|
|
cmd_status()
|
|
if fix:
|
|
_apply_fixes()
|
|
|
|
|
|
def cmd_update() -> None:
|
|
print("Updating Project Nexus...\n")
|
|
print("Skipping git pull — update only refreshes dependencies.\n")
|
|
# Same overlay choice sync.py makes, so update and restore cannot install
|
|
# different PyTorch builds on the same host.
|
|
sys.path.insert(0, str(ROOT / "bin"))
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location("sync", ROOT / "bin" / "sync.py")
|
|
sync = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(sync)
|
|
req = sync.requirements()
|
|
print(f"Updating backend Python dependencies ({req})...")
|
|
subprocess.run([str(PYTHON), "-m", "pip", "install", "-r", req], cwd=str(ROOT))
|
|
|
|
print("\nUpdating frontend dependencies...")
|
|
npm_path = npm()
|
|
if npm_path and FRONTEND_DIR.is_dir():
|
|
subprocess.run([npm_path, "install"], cwd=str(FRONTEND_DIR))
|
|
else:
|
|
print("npm or frontend directory missing — skipping npm install.")
|
|
|
|
print("\nRunning post-update diagnostics...")
|
|
cmd_doctor()
|
|
|
|
|
|
def cmd_upgrade() -> int:
|
|
"""Pull + rebuild + restart the backend. This is what the web UI's "install
|
|
update" button runs, so it must be spawned DETACHED from the backend - it
|
|
stops the very server that asked for it.
|
|
|
|
Ollama is deliberately left alone (stop_service, not cmd_stop): it is a
|
|
separate process on :11434, the restore does not touch a present binary, and
|
|
reloading a multi-GB model is the slowest part of a restart.
|
|
"""
|
|
print("Stopping backend...")
|
|
stop_service(SERVICES["backend"])
|
|
# --no-desktop: an in-app update should not rewrite XFCE panels/theme.
|
|
rc = sync_py("restore", "--no-desktop")
|
|
print("\nRestarting backend...")
|
|
launch(SERVICES["backend"])
|
|
ok = wait_for_port(SERVICES["backend"])
|
|
print("Backend is back up." if ok else "Backend did not come back - see runtime/backend.log")
|
|
return rc if ok else 1
|
|
|
|
|
|
def cmd_clean() -> None:
|
|
print("Cleaning Nexus runtime files...\n")
|
|
print("Removing PID files...")
|
|
for f in PID_DIR.glob("*.pid"):
|
|
f.unlink(missing_ok=True)
|
|
print("Removing logs...")
|
|
for f in LOG_DIR.glob("*.log"):
|
|
f.unlink(missing_ok=True)
|
|
print("Removing Python cache...")
|
|
for d in ROOT.rglob("__pycache__"):
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
print("Removing Node/Vite cache...")
|
|
for d in FRONTEND_DIR.rglob(".vite"):
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
print("\nCleanup complete.")
|
|
|
|
|
|
MODEL_CATALOG = [
|
|
("gemma3:1b", "~815 MB", "Google Gemma 3 — fast, lightweight"),
|
|
("gemma3:4b", "~3.3 GB", "Google Gemma 3 — balanced"),
|
|
("gemma3:12b", "~8.1 GB", "Google Gemma 3 — capable"),
|
|
("llama3.2:1b", "~1.3 GB", "Meta Llama 3.2 — fast, lightweight"),
|
|
("llama3.2:3b", "~2.0 GB", "Meta Llama 3.2 — compact, capable"),
|
|
("llama3.1:8b", "~4.7 GB", "Meta Llama 3.1 — strong general use"),
|
|
("mistral:latest", "~4.1 GB", "Mistral 7B — solid all-rounder"),
|
|
("mistral-nemo", "~7.1 GB", "Mistral Nemo 12B — strong reasoning"),
|
|
("qwen2.5:3b", "~2.0 GB", "Alibaba Qwen 2.5 — great at code"),
|
|
("qwen2.5:7b", "~4.7 GB", "Alibaba Qwen 2.5 — strong coder"),
|
|
("phi4-mini", "~2.5 GB", "Microsoft Phi-4 Mini — efficient"),
|
|
("phi4:14b", "~8.9 GB", "Microsoft Phi-4 — strong reasoning"),
|
|
("deepseek-r1:7b", "~4.7 GB", "DeepSeek R1 — reasoning model"),
|
|
("deepseek-r1:14b", "~9.0 GB", "DeepSeek R1 — strong reasoning"),
|
|
("codellama:7b", "~3.8 GB", "Meta Code Llama — code focused"),
|
|
("nomic-embed-text", "~274 MB", "Text embeddings model"),
|
|
]
|
|
|
|
|
|
def cmd_models(action, name) -> None:
|
|
if action == "list":
|
|
if not http_ok("http://localhost:11434/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()
|
|
models = json.loads(raw).get("models", [])
|
|
print("Installed models:\n")
|
|
if not models:
|
|
print(" No models installed.")
|
|
return
|
|
for m in models:
|
|
mb = m["size"] // 1024 // 1024
|
|
size = f"{mb / 1024:.1f} GB" if mb >= 1024 else f"{mb} MB"
|
|
print(f" {m['name']:<35} {size}")
|
|
elif action in ("available", "search"):
|
|
print("Available models (via Ollama library):\n")
|
|
print(f" {'MODEL':<30} {'SIZE':<10} DESCRIPTION")
|
|
print(f" {'-----':<30} {'----':<10} -----------")
|
|
for model, size, desc in MODEL_CATALOG:
|
|
print(f" {model:<30} {size:<10} {desc}")
|
|
print("\nInstall any model with: ncp models install <model>")
|
|
print("Browse more at: https://ollama.com/library")
|
|
elif action == "install":
|
|
if not name:
|
|
print("Usage: ncp models install <model>")
|
|
print("Run 'ncp models available' to see options.")
|
|
return
|
|
obin = ollama_bin()
|
|
if not obin:
|
|
print(f"Ollama binary not found at {OLLAMA_BIN}, and no 'ollama' on PATH")
|
|
return
|
|
print(f"Pulling '{name}' into {OLLAMA_MODELS_DIR} ...\n")
|
|
subprocess.run([obin, "pull", name],
|
|
env={**os.environ, "OLLAMA_MODELS": str(OLLAMA_MODELS_DIR)})
|
|
print("\nDone. Run 'ncp models list' to verify.")
|
|
else:
|
|
print("Usage: ncp models <list|available|install <model>>")
|
|
|
|
|
|
def sync_py(*args) -> int:
|
|
"""sync.py is stdlib-only, so system python works before the venv exists."""
|
|
py = str(PYTHON) if PYTHON.exists() else sys.executable
|
|
return subprocess.run([py, str(ROOT / "bin" / "sync.py"), *args]).returncode
|
|
|
|
|
|
def cmd_restore(flag) -> int:
|
|
if flag in ("-c", "--claude", "check"):
|
|
return sync_py("restore", "--check") # dry run: no prompt, changes nothing
|
|
print("This pulls the latest backup from Gitea and rebuilds the environment")
|
|
print("(venv, npm, theme, panel). Local commits must be pushed or stashed first.")
|
|
if input("Continue? [y/N] ").strip().lower() != "y":
|
|
print("Restore cancelled.")
|
|
return 0
|
|
return sync_py("restore")
|
|
|
|
|
|
def cmd_web() -> int:
|
|
"""Per-platform UI launcher. On Linux nexus-app.sh already raises an existing
|
|
window, acts as a viewer when the stack is up, and only stops services it
|
|
started. Windows uses the pywebview window launch_nexus.ps1 opens."""
|
|
if WINDOWS:
|
|
if not http_ok(SERVICES["backend"].url):
|
|
launch(SERVICES["backend"])
|
|
wait_for_port(SERVICES["backend"])
|
|
return subprocess.run([str(PYTHON), str(ROOT / "bin" / "nexus_window.py")]).returncode
|
|
return subprocess.run([str(ROOT / "management" / "nexus-app.sh")]).returncode
|
|
|
|
|
|
def show_help() -> None:
|
|
print("""Nexus Command Tree
|
|
|
|
Usage: ncp <command> [options]
|
|
|
|
Commands:
|
|
panel Launch the Nexus Control Panel (tkinter)
|
|
web Open the web interface (starts the stack if needed)
|
|
|
|
chat <message> Send a message, stream the reply
|
|
memory list List memory facts
|
|
add <text> Add a fact (--section <name>)
|
|
rm <id> Delete a fact by id
|
|
playbook list List playbooks (* = active)
|
|
show <id> Print a playbook's goal + instructions
|
|
history [query] Recent conversations (optional keyword)
|
|
|
|
start Start ALL Nexus services (backend + frontend)
|
|
--frontend,-f Start only the frontend
|
|
--backend, -b Start only the backend
|
|
--ai, -a Start only the AI (Ollama); `start`/`start -b` already include it
|
|
|
|
stop Stop ALL Nexus services
|
|
--frontend,-f Stop only the frontend
|
|
--backend, -b Stop only the backend
|
|
|
|
kill Force-kill all Nexus processes by port (nuclear option)
|
|
refresh Restart all services
|
|
|
|
status Show service status
|
|
logs Show logs for all services
|
|
--frontend,-f Frontend logs
|
|
--backend, -b Backend logs
|
|
|
|
doctor [--fix] Run Nexus diagnostics (--fix applies safe repairs)
|
|
update Update Nexus dependencies
|
|
upgrade Pull, rebuild and restart the backend (what the UI's update button runs)
|
|
clean Remove runtime files and caches
|
|
|
|
models
|
|
list List installed models
|
|
available Show models available to install
|
|
install <name> Pull a model into Nexus
|
|
|
|
backup Backup Nexus to Gitea (git commit + push)
|
|
backup -f, full Backup + snapshot live desktop wiring/notes
|
|
backup -c Dry run: what a backup would commit/push
|
|
restore Restore Nexus from Gitea (git pull + rebuild)
|
|
restore -c Dry run: what a restore would apply
|
|
|
|
help, -h Show this help message""")
|
|
|
|
|
|
def main(argv) -> int:
|
|
cmd = argv[0] if argv else ""
|
|
rest = argv[1:]
|
|
arg = rest[0] if rest else None
|
|
|
|
if cmd in ("help", "-h", ""):
|
|
show_help()
|
|
elif cmd == "panel":
|
|
return subprocess.run([str(PYTHON), str(ROOT / "management" / "controlpanel.py")]).returncode
|
|
elif cmd == "web":
|
|
return cmd_web()
|
|
elif cmd in ("chat", "memory", "playbook", "history"):
|
|
return subprocess.run([str(PYTHON), str(ROOT / "management" / "nexus_api.py"),
|
|
cmd, *rest]).returncode
|
|
elif cmd == "start":
|
|
cmd_start(arg)
|
|
elif cmd == "stop":
|
|
cmd_stop(arg)
|
|
elif cmd == "refresh":
|
|
cmd_stop(None)
|
|
cmd_start(None)
|
|
elif cmd == "kill":
|
|
cmd_kill()
|
|
elif cmd == "status":
|
|
cmd_status()
|
|
elif cmd == "logs":
|
|
cmd_logs(arg)
|
|
elif cmd == "doctor":
|
|
cmd_doctor(fix="--fix" in rest)
|
|
elif cmd == "update":
|
|
cmd_update()
|
|
elif cmd == "upgrade":
|
|
return cmd_upgrade()
|
|
elif cmd == "clean":
|
|
cmd_clean()
|
|
elif cmd == "nvidia-reqs":
|
|
return subprocess.run([str(PYTHON), str(ROOT / "bin" / "gen-nvidia-reqs.py")]).returncode
|
|
elif cmd == "backup":
|
|
if arg in ("-f", "full"):
|
|
return sync_py("backup", "--full")
|
|
if arg in ("-c", "--claude", "check"):
|
|
return sync_py("backup", "--check")
|
|
if arg in (None, ""):
|
|
return sync_py("backup")
|
|
print("Usage: ncp backup [-f|full|-c]")
|
|
elif cmd == "restore":
|
|
if arg in (None, "", "-f", "full", "-c", "--claude", "check"):
|
|
return cmd_restore(arg)
|
|
print("Usage: ncp restore [-c]")
|
|
elif cmd == "models":
|
|
cmd_models(arg, rest[1] if len(rest) > 1 else None)
|
|
else:
|
|
print(f"Unknown Nexus command: '{cmd}'")
|
|
print("Use 'ncp help' for available commands.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main(sys.argv[1:]))
|
|
except KeyboardInterrupt:
|
|
# Ctrl+C is how you quit `ncp web` - it waits on the UI window. Without
|
|
# this it printed a six-frame traceback ending in WaitForSingleObject,
|
|
# which reads as a crash rather than "you pressed Ctrl+C". 130 is the
|
|
# conventional exit status for SIGINT.
|
|
print()
|
|
sys.exit(130)
|