install Echo Core as systemd service, update CLI for systemctl

- Created echo-core.service and echo-whatsapp-bridge.service (user units)
- CLI status/doctor now use systemctl --user show instead of PID file
- CLI restart uses kill+start pattern for reliability
- Added echo stop command
- CLI shebang uses venv python directly for keyring support
- Updated tests to mock _get_service_status instead of PID file
- 440 tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 22:41:56 +00:00
parent 624eb095f1
commit 6454f0f83c
2 changed files with 157 additions and 103 deletions

View File

@@ -2,7 +2,7 @@
import argparse
import json
import signal
import time
from contextlib import ExitStack
from unittest.mock import patch, MagicMock
@@ -53,31 +53,18 @@ def iso(tmp_path, monkeypatch):
class TestStatus:
def test_offline_no_pid(self, iso, capsys):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "OFFLINE" in out
assert "no PID file" in out
def _mock_service(self, active="active", pid="1234", ts="Fri 2026-02-13 22:00:00 UTC"):
return {"ActiveState": active, "MainPID": pid, "ActiveEnterTimestamp": ts}
def test_offline_invalid_pid(self, iso, capsys):
iso["pid_file"].write_text("garbage")
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "OFFLINE" in out
assert "invalid PID file" in out
def test_offline_stale_pid(self, iso, capsys):
iso["pid_file"].write_text("999999")
with patch("os.kill", side_effect=OSError):
def test_offline(self, iso, capsys):
with patch("cli._get_service_status", return_value=self._mock_service(active="inactive", pid="0")):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "OFFLINE" in out
assert "not running" in out
def test_online(self, iso, capsys):
iso["pid_file"].write_text("1234")
iso["sessions_file"].write_text(json.dumps({"ch1": {}}))
with patch("os.kill"):
with patch("cli._get_service_status", return_value=self._mock_service()):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "ONLINE" in out
@@ -85,10 +72,20 @@ class TestStatus:
assert "1 active" in out
def test_sessions_count_zero(self, iso, capsys):
cli.cmd_status(_args())
with patch("cli._get_service_status", return_value=self._mock_service(active="inactive")):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "0 active" in out
def test_bridge_status(self, iso, capsys):
with patch("cli._get_service_status", side_effect=[
self._mock_service(),
self._mock_service(pid="5678"),
]):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "WA Bridge: ONLINE" in out
# ---------------------------------------------------------------------------
# cmd_doctor
@@ -119,14 +116,13 @@ class TestDoctor:
patch("os.statvfs", return_value=stat),
patch("subprocess.run", return_value=mock_proc),
patch("urllib.request.urlopen", return_value=mock_resp),
patch("cli._get_service_status", return_value={"ActiveState": "active", "MainPID": "123"}),
]
if setup_full:
# Create .gitignore with required entries
gi_path = cli.PROJECT_ROOT / ".gitignore"
gi_path.write_text("sessions/\nlogs/\n.env\n*.sqlite\n")
# Create PID file with current PID
iso["pid_file"].write_text(str(_os.getpid()))
# Set config.json not world-readable
iso["config_file"].chmod(0o600)
# Create sessions dir not world-readable
@@ -205,33 +201,33 @@ class TestDoctor:
class TestRestart:
def test_no_pid_file(self, iso, capsys):
with pytest.raises(SystemExit):
cli.cmd_restart(_args())
assert "no PID file" in capsys.readouterr().out
def test_restart_success(self, iso, capsys):
with patch("cli._systemctl", return_value=(0, "")), \
patch("cli._get_service_status", return_value={"ActiveState": "active", "MainPID": "999"}), \
patch("time.sleep"):
cli.cmd_restart(_args(bridge=False))
out = capsys.readouterr().out
assert "restarted" in out.lower()
assert "999" in out
def test_invalid_pid(self, iso, capsys):
iso["pid_file"].write_text("nope")
with pytest.raises(SystemExit):
cli.cmd_restart(_args())
assert "invalid PID" in capsys.readouterr().out
def test_dead_process(self, iso, capsys):
iso["pid_file"].write_text("99999")
with patch("os.kill", side_effect=OSError):
with pytest.raises(SystemExit):
cli.cmd_restart(_args())
assert "not running" in capsys.readouterr().out
def test_sends_sigterm(self, iso, capsys):
iso["pid_file"].write_text("42")
def test_restart_with_bridge(self, iso, capsys):
calls = []
with patch("os.kill", side_effect=lambda p, s: calls.append((p, s))):
cli.cmd_restart(_args())
assert (42, 0) in calls
assert (42, signal.SIGTERM) in calls
assert "SIGTERM" in capsys.readouterr().out
assert "42" in capsys.readouterr().out or True # already consumed above
def mock_ctl(*args):
calls.append(args)
return (0, "")
with patch("cli._systemctl", side_effect=mock_ctl), \
patch("cli._get_service_status", return_value={"ActiveState": "active", "MainPID": "100"}), \
patch("time.sleep"):
cli.cmd_restart(_args(bridge=True))
# Should have called kill+start for both bridge and core
assert len(calls) == 4
def test_restart_fails(self, iso, capsys):
with patch("cli._systemctl", return_value=(0, "")), \
patch("cli._get_service_status", return_value={"ActiveState": "failed"}), \
patch("time.sleep"):
with pytest.raises(SystemExit):
cli.cmd_restart(_args(bridge=False))
# ---------------------------------------------------------------------------