Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
nerve stop Stop the running Nerve daemon
nerve restart Restart the Nerve daemon
nerve status Show daemon status
nerve upgrade Pull latest code, install deps, rebuild frontend
nerve doctor Check config, DB, API keys, connectivity
nerve sync [source] Run sync manually
nerve cron [job_id] Run a cron job manually
Expand Down Expand Up @@ -547,6 +548,112 @@ def logs(ctx: click.Context) -> None:
click.echo(line)


# --- Upgrade helpers ---

def _find_source_root() -> Path:
"""Locate the Nerve source checkout (the directory containing pyproject.toml).

Nerve is installed in editable mode, so ``__file__`` points at the actual
source tree. The repo root is the parent of the ``nerve`` package
directory.
"""
return Path(__file__).resolve().parent.parent


def _pip_install_cmd(source_root: Path) -> list[str]:
"""Return the command to (re)install Nerve from source in the current venv.

Prefers ``uv pip`` when available (faster), falls back to ``python -m pip``.
"""
uv_bin = shutil.which("uv")
if uv_bin:
# ``uv pip`` targets the active venv via ``VIRTUAL_ENV`` / ``sys.executable``
return [uv_bin, "pip", "install", "-e", str(source_root), "--python", sys.executable]
return [sys.executable, "-m", "pip", "install", "-e", str(source_root)]


@main.command()
@click.option("--no-frontend", is_flag=True, help="Skip frontend rebuild")
@click.option("--no-deps", is_flag=True, help="Skip Python dependency install")
@click.option("--no-pull", is_flag=True, help="Skip git pull (use local changes only)")
@click.pass_context
def upgrade(ctx: click.Context, no_frontend: bool, no_deps: bool, no_pull: bool) -> None:
"""Upgrade Nerve: git pull, install Python deps, rebuild frontend.

Operates on the source checkout that this ``nerve`` binary was installed
from. Restart Nerve afterwards for the changes to take effect.
"""
config = ctx.obj["config"]

# Docker mode: the container is immutable — upgrading means rebuilding
# the image or pulling a new one. Bail out with a helpful hint.
if _is_docker_mode(config):
click.echo(
"Docker deployment detected. Upgrade the image instead:\n"
" docker compose pull && docker compose up -d\n"
" (or rebuild from source: docker compose build && docker compose up -d)"
)
ctx.exit(1)
return

source_root = _find_source_root()

if not (source_root / "pyproject.toml").exists():
raise click.ClickException(
f"pyproject.toml not found in {source_root}. "
"Nerve doesn't appear to be installed from a source checkout."
)

click.echo(f"Upgrading Nerve from source at {source_root}")

# Step 1: git pull
if no_pull:
click.echo("\n[1/3] Skipping git pull (--no-pull)")
elif not (source_root / ".git").exists():
click.echo(f"\n[1/3] Not a git repository ({source_root}) — skipping git pull")
elif not shutil.which("git"):
raise click.ClickException("git not found on PATH — install git or re-run with --no-pull")
else:
click.echo("\n[1/3] git pull --ff-only")
rc = subprocess.run(["git", "pull", "--ff-only"], cwd=source_root).returncode
if rc != 0:
raise click.ClickException(
"git pull failed. Resolve conflicts manually, then re-run 'nerve upgrade --no-pull'."
)

# Step 2: install Python dependencies
if no_deps:
click.echo("\n[2/3] Skipping Python dependency install (--no-deps)")
else:
cmd = _pip_install_cmd(source_root)
click.echo(f"\n[2/3] Installing Python dependencies: {' '.join(cmd)}")
rc = subprocess.run(cmd, cwd=source_root).returncode
if rc != 0:
raise click.ClickException("Python dependency install failed")

# Step 3: rebuild frontend
web_dir = source_root / "web"
if no_frontend:
click.echo("\n[3/3] Skipping frontend rebuild (--no-frontend)")
elif not web_dir.exists():
click.echo(f"\n[3/3] No web/ directory at {web_dir} — skipping frontend rebuild")
elif not shutil.which("npm"):
raise click.ClickException(
"npm not found on PATH. Install Node.js or re-run with --no-frontend."
)
else:
click.echo("\n[3/3] Rebuilding frontend")
rc = subprocess.run(["npm", "install"], cwd=web_dir).returncode
if rc != 0:
raise click.ClickException("npm install failed")
rc = subprocess.run(["npm", "run", "build"], cwd=web_dir).returncode
if rc != 0:
raise click.ClickException("npm run build failed")

click.echo("\nUpgrade complete. Restart Nerve for changes to take effect:")
click.echo(" nerve restart")


def doctor_report(config) -> str:
"""Run doctor checks and return the report as a plain-text string.

Expand Down
254 changes: 254 additions & 0 deletions tests/test_cli_upgrade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
"""Tests for the ``nerve upgrade`` command."""

from __future__ import annotations

import sys
from dataclasses import dataclass
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
import click
from click.testing import CliRunner

from nerve.cli import _find_source_root, _pip_install_cmd, upgrade


@dataclass
class FakeConfig:
deployment: str = "server"


def _invoke(
runner: CliRunner,
config_dir: Path,
args: list[str],
config: FakeConfig | None = None,
) -> "click.testing.Result":
"""Invoke the upgrade command with a preset context.

The top-level ``main`` group loads config from disk, which is unnecessary
for unit tests. Instead we call the ``upgrade`` command directly with a
manually-populated context object.
"""
obj = {
"config": config or FakeConfig(),
"config_dir": str(config_dir),
"verbose": False,
}
return runner.invoke(upgrade, args, obj=obj, standalone_mode=False)


class TestFindSourceRoot:
"""`_find_source_root` should point at the checkout containing pyproject.toml."""

def test_points_at_pyproject(self) -> None:
root = _find_source_root()
# The repo we're running from should have a pyproject.toml at its root.
assert (root / "pyproject.toml").exists()
# And the ``nerve`` package should live one level down.
assert (root / "nerve" / "cli.py").exists()


class TestPipInstallCmd:
"""`_pip_install_cmd` prefers uv when available, falls back to pip."""

def test_uses_uv_when_available(self, tmp_path: Path) -> None:
with patch("nerve.cli.shutil.which", return_value="/usr/local/bin/uv"):
cmd = _pip_install_cmd(tmp_path)
assert cmd[0] == "/usr/local/bin/uv"
assert cmd[1:4] == ["pip", "install", "-e"]
assert str(tmp_path) in cmd
# uv needs to know which interpreter to install into
assert "--python" in cmd
assert sys.executable in cmd

def test_falls_back_to_pip(self, tmp_path: Path) -> None:
with patch("nerve.cli.shutil.which", return_value=None):
cmd = _pip_install_cmd(tmp_path)
assert cmd[:3] == [sys.executable, "-m", "pip"]
assert "install" in cmd
assert "-e" in cmd
assert str(tmp_path) in cmd


class TestUpgradeCommand:
"""End-to-end tests for the ``nerve upgrade`` click command."""

def _make_source_root(self, tmp_path: Path, with_git: bool = True, with_web: bool = True) -> Path:
"""Fabricate a minimal nerve-looking checkout under tmp_path."""
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'nerve'\n")
(tmp_path / "nerve").mkdir()
(tmp_path / "nerve" / "__init__.py").write_text("")
if with_git:
(tmp_path / ".git").mkdir()
if with_web:
(tmp_path / "web").mkdir()
(tmp_path / "web" / "package.json").write_text("{}")
return tmp_path

def test_docker_mode_bails_out(self, tmp_path: Path) -> None:
"""Docker deployments should be told to pull the image instead."""
runner = CliRunner()
# Patch subprocess.run so we can assert no install/build was attempted
# if the docker-mode guard somehow fails to short-circuit.
with patch("nerve.cli.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = _invoke(
runner,
tmp_path,
[],
config=FakeConfig(deployment="docker"),
)
assert "docker compose" in result.output.lower()
# No subprocess calls should have fired — docker mode must short-circuit
# before any git/pip/npm work.
mock_run.assert_not_called()

def test_full_flow_runs_all_steps(self, tmp_path: Path) -> None:
"""Default flow: git pull → pip install → npm install → npm run build."""
source_root = self._make_source_root(tmp_path)
runner = CliRunner()

with patch("nerve.cli._find_source_root", return_value=source_root), \
patch("nerve.cli.shutil.which", side_effect=lambda name: f"/usr/bin/{name}"), \
patch("nerve.cli.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = _invoke(runner, tmp_path, [])

assert result.exit_code == 0, result.output
# Collect the commands that were invoked (first positional arg)
invoked = [call.args[0] for call in mock_run.call_args_list]
assert ["git", "pull", "--ff-only"] in invoked
# pip-install step: exact command depends on which() mock, which
# returns "/usr/bin/uv" → uv pip install ...
assert any(cmd[:4] == ["/usr/bin/uv", "pip", "install", "-e"] for cmd in invoked)
assert ["npm", "install"] in invoked
assert ["npm", "run", "build"] in invoked

def test_no_pull_skips_git(self, tmp_path: Path) -> None:
source_root = self._make_source_root(tmp_path)
runner = CliRunner()

with patch("nerve.cli._find_source_root", return_value=source_root), \
patch("nerve.cli.shutil.which", side_effect=lambda name: f"/usr/bin/{name}"), \
patch("nerve.cli.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = _invoke(runner, tmp_path, ["--no-pull"])

assert result.exit_code == 0, result.output
invoked = [call.args[0] for call in mock_run.call_args_list]
assert not any(cmd[:2] == ["git", "pull"] for cmd in invoked)

def test_no_deps_skips_pip_install(self, tmp_path: Path) -> None:
source_root = self._make_source_root(tmp_path)
runner = CliRunner()

with patch("nerve.cli._find_source_root", return_value=source_root), \
patch("nerve.cli.shutil.which", side_effect=lambda name: f"/usr/bin/{name}"), \
patch("nerve.cli.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = _invoke(runner, tmp_path, ["--no-deps"])

assert result.exit_code == 0, result.output
invoked = [call.args[0] for call in mock_run.call_args_list]
assert not any("pip" in str(cmd) and "install" in cmd for cmd in invoked)

def test_no_frontend_skips_npm(self, tmp_path: Path) -> None:
source_root = self._make_source_root(tmp_path)
runner = CliRunner()

with patch("nerve.cli._find_source_root", return_value=source_root), \
patch("nerve.cli.shutil.which", side_effect=lambda name: f"/usr/bin/{name}"), \
patch("nerve.cli.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = _invoke(runner, tmp_path, ["--no-frontend"])

assert result.exit_code == 0, result.output
invoked = [call.args[0] for call in mock_run.call_args_list]
assert not any(cmd[:1] == ["npm"] for cmd in invoked)

def test_missing_pyproject_raises(self, tmp_path: Path) -> None:
"""If the source root doesn't look like a nerve checkout, bail out."""
(tmp_path / "nerve").mkdir()
runner = CliRunner()
with patch("nerve.cli._find_source_root", return_value=tmp_path):
result = _invoke(runner, tmp_path, [])
assert result.exit_code != 0
assert isinstance(result.exception, click.ClickException)
assert "pyproject.toml" in str(result.exception.message)

def test_missing_git_dir_skips_pull(self, tmp_path: Path) -> None:
"""Source checkout without .git should skip git pull, not fail."""
source_root = self._make_source_root(tmp_path, with_git=False)
runner = CliRunner()

with patch("nerve.cli._find_source_root", return_value=source_root), \
patch("nerve.cli.shutil.which", side_effect=lambda name: f"/usr/bin/{name}"), \
patch("nerve.cli.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = _invoke(runner, tmp_path, [])

assert result.exit_code == 0, result.output
invoked = [call.args[0] for call in mock_run.call_args_list]
assert not any(cmd[:2] == ["git", "pull"] for cmd in invoked)
# But pip install + npm build should still run
assert any(cmd == ["npm", "install"] for cmd in invoked)

def test_missing_web_dir_skips_frontend(self, tmp_path: Path) -> None:
"""No web/ directory means no frontend to build."""
source_root = self._make_source_root(tmp_path, with_web=False)
runner = CliRunner()

with patch("nerve.cli._find_source_root", return_value=source_root), \
patch("nerve.cli.shutil.which", side_effect=lambda name: f"/usr/bin/{name}"), \
patch("nerve.cli.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = _invoke(runner, tmp_path, [])

assert result.exit_code == 0, result.output
invoked = [call.args[0] for call in mock_run.call_args_list]
assert not any(cmd[:1] == ["npm"] for cmd in invoked)

def test_git_pull_failure_aborts(self, tmp_path: Path) -> None:
"""A failed git pull should raise ClickException and stop the upgrade."""
source_root = self._make_source_root(tmp_path)
runner = CliRunner()

def run_side_effect(cmd, **kwargs):
if cmd[:2] == ["git", "pull"]:
return MagicMock(returncode=1)
return MagicMock(returncode=0)

with patch("nerve.cli._find_source_root", return_value=source_root), \
patch("nerve.cli.shutil.which", side_effect=lambda name: f"/usr/bin/{name}"), \
patch("nerve.cli.subprocess.run", side_effect=run_side_effect) as mock_run:
result = _invoke(runner, tmp_path, [])

assert result.exit_code != 0
assert isinstance(result.exception, click.ClickException)
# Later steps must not have been attempted
invoked = [call.args[0] for call in mock_run.call_args_list]
assert not any("pip" in str(cmd) and "install" in cmd for cmd in invoked)
assert not any(cmd[:1] == ["npm"] for cmd in invoked)

def test_missing_npm_raises(self, tmp_path: Path) -> None:
"""Frontend rebuild requires npm; missing binary is a hard error."""
source_root = self._make_source_root(tmp_path)
runner = CliRunner()

# npm is not installed, everything else is
def which_fake(name: str) -> str | None:
if name == "npm":
return None
return f"/usr/bin/{name}"

with patch("nerve.cli._find_source_root", return_value=source_root), \
patch("nerve.cli.shutil.which", side_effect=which_fake), \
patch("nerve.cli.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = _invoke(runner, tmp_path, [])

assert result.exit_code != 0
assert isinstance(result.exception, click.ClickException)
assert "npm" in str(result.exception.message)