feat(packaging): move the CLI into nexusos_cli and make the wheel self-sufficient

The CLI shipped from `management/`, which also holds desktop-only pieces (the
Tk control panel, the XFCE panel wiring, the shell wrappers). Packaging that
directory meant the wheel either dragged in tkinter or shipped a broken import.
Split it: `nexusos_cli/` is what the wheel ships and what `nexus`/`ncp`/
`nexusos` dispatch to, `management/` keeps the desktop half.

Alongside the move:

* hatch_build.py decides the interface/web/dist include at build time. dist/
  is gitignored, so a static force-include aborts `pip install -e .` on a
  fresh clone - before the reader reaches the `npm run build` step. Editable
  installs now skip a missing dist; wheels and sdists hard-error naming the
  command to run.
* synapse/proc_util.py gives frontend_manager and ncp process inspection and
  termination without psutil, which became an optional extra when the wheel
  landed. It routes around Windows having no signals, where os.kill(pid, 15)
  is an unblockable TerminateProcess rather than a polite request.
* nexusos_cli/monitor.py adds `ncp monitor`, an ASCII dashboard with no curses
  or rich dependency so it works in Termux, plain SSH and Windows Terminal.
  Collector and renderer are separate so tests feed fixtures, no stack needed.
* tests/test_packaging_deps.py fails the gate when synapse or nexusos_cli
  import a distribution pyproject does not declare, and when an optional
  dependency is imported at module scope instead of lazily.
* bin/check.sh now builds the wheel, twine-checks it, and asserts the compiled
  UI and seed playbooks are actually inside it. A wheel that builds but ships
  no dist/ serves a blank page, which only shows up after release.

tests/test_nexus_api.py moves to tests/ with the module it covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Athena Kaminsky
2026-08-26 08:11:39 -05:00
committed by Athena
co-authored by Claude Opus 5
parent d579502a5b
commit 9104c724c4
25 changed files with 1412 additions and 76 deletions
+64 -4
View File
@@ -18,7 +18,7 @@ def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
env["NEXUS_HOME"] = str(tmp_path / "state")
env["NEXUS_CONFIG_DIR"] = str(tmp_path / "config")
return subprocess.run(
[sys.executable, "-m", "management.cli", *args],
[sys.executable, "-m", "nexusos_cli.cli", *args],
cwd=ROOT,
env=env,
text=True,
@@ -31,17 +31,22 @@ def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
def test_help_exposes_portable_command_tree(tmp_path):
result = run_cli(tmp_path, "--help")
assert result.returncode == 0, result.stderr
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat"):
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor"):
assert command in result.stdout
def test_legacy_cli_spellings_remain_compatible():
from management.cli import _normalize_legacy_argv
from nexusos_cli.cli import _normalize_legacy_argv
assert _normalize_legacy_argv(["start", "-b"]) == ["start", "backend"]
assert _normalize_legacy_argv(["stop", "--ai"]) == ["stop", "ai"]
assert _normalize_legacy_argv(["logs", "-m", "--follow"]) == ["logs", "memory", "--follow"]
assert _normalize_legacy_argv(["backup", "full"]) == ["backup", "--full"]
# -f is --follow for `logs`, but --frontend for start/stop. Translating it
# for logs turned `logs -f` into a one-shot tail of the frontend log.
assert _normalize_legacy_argv(["logs", "-f"]) == ["logs", "-f"]
assert _normalize_legacy_argv(["logs", "-m", "-f"]) == ["logs", "memory", "-f"]
assert _normalize_legacy_argv(["start", "-f"]) == ["start", "frontend"]
assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"]
assert _normalize_legacy_argv(["help"]) == ["--help"]
@@ -92,7 +97,7 @@ def test_serve_rejects_invalid_ports_before_startup(tmp_path):
def test_remote_provider_is_never_stopped_or_force_killed(monkeypatch):
from management import ncp
from nexusos_cli import ncp
killed_ports = []
killed_patterns = []
@@ -108,3 +113,58 @@ def test_remote_provider_is_never_stopped_or_force_killed(monkeypatch):
assert 11434 not in killed_ports
assert "ollama serve" not in killed_patterns
def test_allow_lan_names_addresses_instead_of_disabling_the_host_check():
"""ALLOWED_HOSTS=* would switch TrustedHostMiddleware off entirely, and that
middleware is the DNS-rebinding defense for an unauthenticated API."""
from nexusos_cli.cli import _lan_hostnames
assert _lan_hostnames("192.168.1.20") == ["192.168.1.20"]
wildcard = _lan_hostnames("0.0.0.0")
assert wildcard, "a wildcard bind must resolve to concrete host names"
assert "*" not in wildcard
# IPv6 literals need to match a Host header written either way.
for name in wildcard:
if ":" in name and not name.startswith("["):
assert f"[{name}]" in wildcard
def test_empty_platform_dir_variables_do_not_put_state_in_the_cwd(tmp_path, monkeypatch):
"""os.getenv's default only fires when a variable is *unset*; an exported
but empty XDG_DATA_HOME / LOCALAPPDATA made Path("") == "." the base, so
state landed in whatever directory the command happened to run from.
Patching os.name to exercise the other platform's branch is not an option -
pathlib dispatches on it - so this checks the branch this host actually
takes."""
from synapse import nexus_config
if os.name == "nt":
monkeypatch.setenv("LOCALAPPDATA", "")
expected = Path.home() / "AppData" / "Local" / "NexusOS"
else:
monkeypatch.setenv("XDG_DATA_HOME", "")
expected = Path.home() / ".local/share" / "nexusos"
monkeypatch.delenv("NEXUS_HOME", raising=False)
monkeypatch.chdir(tmp_path)
resolved = nexus_config._user_dir("NEXUS_HOME", "NexusOS", "XDG_DATA_HOME", ".local/share")
assert resolved.is_absolute()
assert tmp_path.resolve() != resolved.parent
assert resolved == expected.resolve()
def test_config_set_warns_before_orphaning_the_database(tmp_path):
"""Repointing the DB does not move it - say so, or history looks deleted."""
assert run_cli(tmp_path, "init").returncode == 0
db = tmp_path / "state" / "data" / "memory.db"
db.parent.mkdir(parents=True, exist_ok=True)
db.write_bytes(b"SQLite format 3" + bytes(1))
result = run_cli(tmp_path, "config", "set", "memory_db", str(tmp_path / "elsewhere.db"), "--json")
assert result.returncode == 0, result.stderr
assert "warning" in json.loads(result.stdout)
same = run_cli(tmp_path, "config", "set", "memory_db", str(db), "--json")
assert "warning" not in json.loads(same.stdout)
+78
View File
@@ -0,0 +1,78 @@
"""Tests for the ASCII monitor — renderer + collectors, no live stack required."""
from __future__ import annotations
from nexusos_cli.monitor import _bar, _fmt_bytes, _recent_tools, render_frame
def test_bar_bounds():
assert _bar(0, 10, unicode=False) == "-" * 10
assert _bar(1, 10, unicode=False) == "#" * 10
assert _bar(0.5, 10, unicode=False).count("#") == 5
def test_fmt_bytes():
assert _fmt_bytes(None) == ""
assert _fmt_bytes(512) == "512B"
assert _fmt_bytes(2048).endswith("K")
def test_render_frame_contains_sections():
snap = {
"ts": "2026-08-20T12:00:00-05:00",
"version": "0.0.0",
"services": {
"backend": {"running": True, "pid": 11, "url": "http://127.0.0.1:8000"},
"memory": {"running": False, "pid": None, "url": "http://127.0.0.1:8001"},
"frontend": {"running": False, "pid": None, "url": "http://127.0.0.1:5173"},
"provider": {
"provider": "ollama",
"url": "http://127.0.0.1:11434",
"reachable": True,
},
},
"api": {
"online": True,
"version": "0.0.0",
"ollama": "running",
"memories": 3,
"conversations": 2,
"playbooks": 1,
"models": 4,
"action_tool_policy": "ask",
},
"host": {"cpu_pct": 12.5, "mem_used": 1_000_000_000, "mem_total": 8_000_000_000, "mem_pct": 12.5},
"procs": {"cpu_pct": 1.0, "rss": 50_000_000, "pids": [11]},
"toolchains": [
{"lang": "python", "ready": True, "tool": "/usr/bin/python", "summary": "python"},
{"lang": "rust", "ready": False, "tool": None, "summary": "rust"},
],
"recent_tools": ["run_snippet", "render_preview"],
"paths": {},
}
frame = render_frame(snap, width=72, unicode=False)
assert "SERVICES" in frame
assert "RESOURCES" in frame
assert "DATA / TOOLS" in frame
assert "RUN TOOLCHAINS" in frame
assert "backend" in frame and "UP" in frame
assert "memory" in frame and "DOWN" in frame
assert "run_snippet" in frame
assert "ready python" in frame
assert "missing rust" in frame
# Fixed-width box: every content line same length.
lengths = {len(line) for line in frame.splitlines()}
assert len(lengths) == 1
def test_recent_tools_parses_status_sentinels(tmp_path):
log = tmp_path / "chat.log"
log.write_text(
"noise\n__status__tools\n__status__run_snippet\n"
'payload {"name": "render_preview"}\n__status__remember\n',
encoding="utf-8",
)
found = _recent_tools(log, limit=5)
assert "run_snippet" in found
assert "remember" in found
assert "render_preview" in found
assert "tools" not in found
+22
View File
@@ -0,0 +1,22 @@
"""Pin the SSE parser in nexus_api. Run: python tests/test_nexus_api.py"""
from nexusos_cli.nexus_api import iter_chunks
# A realistic /chat/stream frame: two token chunks, a meta block, then done.
lines = [
'data: "Hello"', "",
'data: " world"', "",
"event: meta", 'data: {"model":"mistral"}', "",
"event: done", "data: {}", "",
]
out = list(iter_chunks(lines))
assert out == [
("chunk", "Hello"),
("chunk", " world"),
("meta", '{"model":"mistral"}'),
("done", "{}"),
], out
# error frame surfaces as its own kind, not a chunk
assert list(iter_chunks(["event: error", 'data: {"detail":"boom"}'])) == [
("error", '{"detail":"boom"}')
]
print("ok")
+144
View File
@@ -0,0 +1,144 @@
"""Guard the wheel's dependency list against drift.
There are now two dependency declarations: requirements-base.txt (what the
desktop installers pip -r) and pyproject.toml (what the wheel ships). They will
drift. What actually breaks a user is narrower than "they differ", though: it
is an import that no declared distribution provides, so that is what this pins.
"""
from __future__ import annotations
import ast
import sys
import tomllib
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SHIPPED_PACKAGES = ("synapse", "nexusos_cli")
# Import name -> distribution name, where PyPI disagrees with the module.
DISTRIBUTION_OF = {
"docx": "python-docx",
"dotenv": "python-dotenv",
"faster_whisper": "faster-whisper",
"imap_tools": "imap-tools",
"sqlite_vec": "sqlite-vec",
"yaml": "pyyaml",
"PIL": "pillow",
}
# Provided by another declared distribution rather than named directly.
TRANSITIVE = {"starlette", "socketio", "engineio"}
# Modules that ship inside this repo.
FIRST_PARTY = {"synapse", "nexusos_cli", "management", "bin", "tests"}
def _pyproject() -> dict:
return tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
def _requirement_name(spec: str) -> str:
"""'pypdf>=5,<7' -> 'pypdf'; strips extras and environment markers."""
head = spec.split(";", 1)[0].strip()
for sep in ("[", "=", ">", "<", "!", "~", " "):
head = head.split(sep, 1)[0]
return head.strip().lower().replace("_", "-")
def _declared() -> set[str]:
project = _pyproject()["project"]
specs = list(project.get("dependencies", []))
for extra in project.get("optional-dependencies", {}).values():
specs.extend(extra)
return {_requirement_name(s) for s in specs}
def _imported_modules() -> set[str]:
"""Top-level module names imported anywhere in the shipped packages."""
found: set[str] = set()
for package in SHIPPED_PACKAGES:
for path in (ROOT / package).rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
found.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom):
# level > 0 is a relative (first-party) import.
if node.level == 0 and node.module:
found.add(node.module.split(".")[0])
return found
def _third_party() -> set[str]:
return {
module for module in _imported_modules()
if module not in sys.stdlib_module_names
and module not in FIRST_PARTY
and module not in TRANSITIVE
and not module.startswith("_")
}
def test_every_third_party_import_is_a_declared_dependency():
declared = _declared()
missing = sorted(
module for module in _third_party()
if DISTRIBUTION_OF.get(module, module).lower().replace("_", "-") not in declared
)
assert not missing, (
"synapse/nexusos_cli import these, but pyproject.toml declares no "
f"distribution for them: {missing}. Add them to [project] dependencies "
"or an extra (and to DISTRIBUTION_OF here if the names differ)."
)
def test_all_extra_is_the_union_of_the_capability_extras():
extras = _pyproject()["project"]["optional-dependencies"]
combined: set[str] = set()
for name, specs in extras.items():
if name in ("all", "dev", "standard"):
continue
combined.update(_requirement_name(s) for s in specs)
everything = {_requirement_name(s) for s in extras["all"]}
assert combined == everything, (
"the 'all' extra drifted from the capability extras; "
f"missing={sorted(combined - everything)} extra={sorted(everything - combined)}"
)
@pytest.mark.parametrize("name", ["fastapi", "uvicorn", "httpx", "pydantic", "pyyaml"])
def test_core_runtime_is_a_hard_dependency_not_an_extra(name):
"""These are imported at module scope, so the base install must carry them."""
base = {_requirement_name(s) for s in _pyproject()["project"]["dependencies"]}
assert name in base
def test_optional_imports_are_lazy():
"""Anything only in an extra must not be imported at module scope.
A base `pip install nexusos-ai` has none of the extras, so a top-level
`import psutil` in synapse would make the backend unimportable.
"""
base = {_requirement_name(s) for s in _pyproject()["project"]["dependencies"]}
offenders: list[str] = []
for package in SHIPPED_PACKAGES:
for path in (ROOT / package).rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in tree.body: # module scope only
names: list[str] = []
if isinstance(node, ast.Import):
names = [a.name.split(".")[0] for a in node.names]
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
names = [node.module.split(".")[0]]
for module in names:
if module in sys.stdlib_module_names or module in FIRST_PARTY:
continue
dist = DISTRIBUTION_OF.get(module, module).lower().replace("_", "-")
if dist not in base and module not in TRANSITIVE:
offenders.append(f"{path.relative_to(ROOT)}: {module}")
assert not offenders, (
"optional dependencies imported at module scope (wrap in try/ImportError "
f"or import inside the function): {offenders}"
)
+94
View File
@@ -0,0 +1,94 @@
"""Pin the psutil-free process helpers.
psutil is an optional extra, so on a base install these are the only way
`ncp stop` / `nexus status` can see or stop a service. Windows previously had
no fallback at all: pid_is_ours returned False, kill_port returned False, and
the terminate branch was POSIX-only, so stop was a no-op there.
Probing must also stay side-effect free. That is easy to get wrong on Windows,
where os.kill(pid, sig) is TerminateProcess for every sig except 0 and the two
console-control events - os.kill(pid, 15) kills instead of asking.
"""
from __future__ import annotations
import subprocess
import sys
import time
import pytest
from synapse import proc_util
def _spawn():
return subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
def _wait_gone(proc, timeout=10.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if proc.poll() is not None:
return True
time.sleep(0.05)
return False
@pytest.fixture
def victim():
proc = _spawn()
try:
yield proc
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=10)
def test_pid_alive_does_not_kill_the_process(victim):
"""Probing must be side-effect free, however it is implemented."""
for _ in range(5):
assert proc_util.pid_alive(victim.pid) is True
time.sleep(0.3)
assert victim.poll() is None, "pid_alive() terminated the process it probed"
def test_pid_alive_is_false_for_a_dead_pid(victim):
victim.kill()
victim.wait(timeout=10)
assert proc_util.pid_alive(victim.pid) is False
@pytest.mark.parametrize("pid", [None, 0, -1, "not-a-pid"])
def test_pid_alive_rejects_junk(pid):
assert proc_util.pid_alive(pid) is False
def test_terminate_pid_actually_stops_it(victim):
assert proc_util.terminate_pid(victim.pid) is True
assert _wait_gone(victim), "terminate_pid() did not stop the process"
assert proc_util.pid_alive(victim.pid) is False
def test_terminate_pid_is_false_when_already_gone(victim):
victim.kill()
victim.wait(timeout=10)
assert proc_util.terminate_pid(victim.pid) is False
def test_pid_cmdline_identifies_the_process(victim):
cmd = proc_util.pid_cmdline(victim.pid)
if not cmd:
pytest.skip("no command-line source on this host")
assert "time.sleep" in cmd or "python" in cmd.lower()
def test_iter_processes_includes_this_interpreter():
import os
entries = proc_util.iter_processes()
if not entries:
pytest.skip("no process enumeration available on this host")
assert os.getpid() in {pid for pid, _ in entries}