personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add cross-platform background service management (sol service, sol up/down)

New module think/service.py provides install/uninstall/start/stop/restart/status/logs
subcommands for managing solstone as a persistent user-level background service via
launchd (macOS) or systemd --user (Linux). sol up/down aliases provide quick access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+723 -2
+11 -2
Makefile
··· 1 1 # solstone Makefile 2 2 # Python-based AI-driven desktop journaling toolkit 3 3 4 - .PHONY: install uninstall test test-apps test-app test-only test-integration test-integration-only test-all format ci clean clean-install coverage watch versions update update-prices pre-commit skills dev all sail sandbox sandbox-stop install-pinchtab verify-browser update-browser-baselines review verify-api update-api-baselines 4 + .PHONY: install uninstall test test-apps test-app test-only test-integration test-integration-only test-all format ci clean clean-install coverage watch versions update update-prices pre-commit skills dev all sail sandbox sandbox-stop install-pinchtab verify-browser update-browser-baselines review verify-api update-api-baselines install-service uninstall-service 5 5 6 6 # Default target - install package in editable mode 7 7 all: install ··· 363 363 find . -type f -name ".DS_Store" -delete 364 364 rm -f .installed 365 365 366 + # Service management 367 + install-service: .installed 368 + $(VENV_BIN)/sol service install 369 + $(VENV_BIN)/sol service start 370 + $(VENV_BIN)/sol service status 371 + 372 + uninstall-service: 373 + -$(VENV_BIN)/sol service uninstall 374 + 366 375 # Uninstall - remove venv and sol symlink 367 - uninstall: clean 376 + uninstall: uninstall-service clean 368 377 @echo "Removing virtual environment..." 369 378 rm -rf $(VENV) 370 379 @if [ -L $(USER_BIN)/sol ]; then \
+4
sol.py
··· 77 77 "restart-convey": "convey.restart", 78 78 "screenshot": "convey.screenshot", 79 79 "maint": "convey.maint_cli", 80 + "service": "think.service", 80 81 } 81 82 82 83 # ============================================================================= ··· 91 92 92 93 ALIASES: dict[str, tuple[str, list[str]]] = { 93 94 "start": ("think.supervisor", []), 95 + "up": ("think.service", ["up"]), 96 + "down": ("think.service", ["down"]), 94 97 } 95 98 96 99 # Command groupings for help display ··· 108 111 "notify", 109 112 "heartbeat", 110 113 ], 114 + "Service": ["service"], 111 115 "Observe (capture)": [ 112 116 "transcribe", 113 117 "describe",
+216
tests/test_service.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for think/service.py - cross-platform service management.""" 5 + 6 + from __future__ import annotations 7 + 8 + import plistlib 9 + import sys 10 + from pathlib import Path 11 + from unittest.mock import MagicMock, patch 12 + 13 + import pytest 14 + 15 + from think import service 16 + 17 + 18 + class TestPlatform: 19 + def test_darwin(self, monkeypatch): 20 + monkeypatch.setattr(sys, "platform", "darwin") 21 + assert service._platform() == "darwin" 22 + 23 + def test_linux(self, monkeypatch): 24 + monkeypatch.setattr(sys, "platform", "linux") 25 + assert service._platform() == "linux" 26 + 27 + def test_unsupported(self, monkeypatch, capsys): 28 + monkeypatch.setattr(sys, "platform", "win32") 29 + with pytest.raises(SystemExit): 30 + service._platform() 31 + assert "unsupported platform" in capsys.readouterr().err 32 + 33 + 34 + class TestPlistGeneration: 35 + def test_round_trip(self): 36 + env = { 37 + "HOME": "/Users/test", 38 + "PATH": "/usr/bin", 39 + "_SOLSTONE_JOURNAL_OVERRIDE": "/Users/test/journal", 40 + } 41 + data = service._generate_plist(env) 42 + plist = plistlib.loads(data) 43 + assert plist["Label"] == "org.solpbc.solstone" 44 + assert plist["ProgramArguments"][1] == "supervisor" 45 + assert plist["EnvironmentVariables"] == env 46 + assert plist["KeepAlive"] is True 47 + assert plist["RunAtLoad"] is True 48 + assert "launchd-stdout.log" in plist["StandardOutPath"] 49 + assert "launchd-stderr.log" in plist["StandardErrorPath"] 50 + 51 + 52 + class TestSystemdUnit: 53 + def test_unit_content(self): 54 + env = { 55 + "HOME": "/home/test", 56 + "PATH": "/usr/bin", 57 + "_SOLSTONE_JOURNAL_OVERRIDE": "/home/test/journal", 58 + } 59 + unit = service._generate_systemd_unit(env) 60 + lines = unit.splitlines() 61 + 62 + # Section headers must start at column 0 (no leading whitespace) 63 + assert "[Unit]" == lines[0] 64 + assert any(line == "[Service]" for line in lines) 65 + assert any(line == "[Install]" for line in lines) 66 + 67 + assert "Type=simple" in unit 68 + assert "Restart=on-failure" in unit 69 + assert "ExecStart=" in unit 70 + assert "supervisor" in unit 71 + assert "Environment=HOME=/home/test" in unit 72 + assert "Environment=_SOLSTONE_JOURNAL_OVERRIDE=/home/test/journal" in unit 73 + assert "WantedBy=default.target" in unit 74 + 75 + 76 + class TestEnvCollection: 77 + def test_captures_api_keys(self, monkeypatch, tmp_path): 78 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 79 + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") 80 + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") 81 + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) 82 + monkeypatch.delenv("REVAI_ACCESS_TOKEN", raising=False) 83 + monkeypatch.delenv("PLAUD_ACCESS_TOKEN", raising=False) 84 + 85 + env = service._collect_env() 86 + assert env["ANTHROPIC_API_KEY"] == "sk-test" 87 + assert env["OPENAI_API_KEY"] == "sk-openai" 88 + assert "GOOGLE_API_KEY" not in env 89 + 90 + def test_includes_venv_in_path(self, monkeypatch, tmp_path): 91 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 92 + for key in service._API_KEYS: 93 + monkeypatch.delenv(key, raising=False) 94 + 95 + env = service._collect_env() 96 + venv_bin = str(Path(sys.executable).parent) 97 + assert env["PATH"].startswith(venv_bin) 98 + 99 + def test_journal_path_is_absolute(self, monkeypatch, tmp_path): 100 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 101 + for key in service._API_KEYS: 102 + monkeypatch.delenv(key, raising=False) 103 + 104 + env = service._collect_env() 105 + assert Path(env["_SOLSTONE_JOURNAL_OVERRIDE"]).is_absolute() 106 + 107 + 108 + class TestStatus: 109 + def test_not_installed_linux(self, monkeypatch, tmp_path, capsys): 110 + monkeypatch.setattr(sys, "platform", "linux") 111 + monkeypatch.setattr( 112 + service, "_unit_path", lambda: tmp_path / "nonexistent.service" 113 + ) 114 + 115 + result = service._status() 116 + assert result == 1 117 + output = capsys.readouterr().out 118 + assert "not installed" in output 119 + 120 + def test_not_installed_darwin(self, monkeypatch, tmp_path, capsys): 121 + monkeypatch.setattr(sys, "platform", "darwin") 122 + monkeypatch.setattr( 123 + service, "_plist_path", lambda: tmp_path / "nonexistent.plist" 124 + ) 125 + 126 + result = service._status() 127 + assert result == 1 128 + output = capsys.readouterr().out 129 + assert "not installed" in output 130 + 131 + 132 + class TestInstall: 133 + def test_linux_idempotent(self, monkeypatch, tmp_path, capsys): 134 + monkeypatch.setattr(sys, "platform", "linux") 135 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 136 + for key in service._API_KEYS: 137 + monkeypatch.delenv(key, raising=False) 138 + 139 + unit_path = tmp_path / "solstone.service" 140 + monkeypatch.setattr(service, "_unit_path", lambda: unit_path) 141 + 142 + with patch("think.service.subprocess.run") as mock_run: 143 + mock_run.return_value = MagicMock(returncode=0) 144 + 145 + result = service._install() 146 + assert result == 0 147 + assert unit_path.exists() 148 + 149 + result = service._install() 150 + assert result == 0 151 + assert unit_path.exists() 152 + 153 + assert "Wrote" in capsys.readouterr().out 154 + 155 + 156 + class TestLingerCheck: 157 + def test_warns_when_linger_disabled(self, capsys): 158 + mock_result = MagicMock(returncode=0, stdout="Linger=no\n") 159 + with patch("think.service.subprocess.run", return_value=mock_result): 160 + service._check_linger() 161 + output = capsys.readouterr().out 162 + assert "linger is not enabled" in output.lower() 163 + 164 + def test_silent_when_linger_enabled(self, capsys): 165 + mock_result = MagicMock(returncode=0, stdout="Linger=yes\n") 166 + with patch("think.service.subprocess.run", return_value=mock_result): 167 + service._check_linger() 168 + output = capsys.readouterr().out 169 + assert "linger" not in output.lower() 170 + 171 + def test_silent_when_loginctl_missing(self, capsys): 172 + with patch("think.service.subprocess.run", side_effect=FileNotFoundError): 173 + service._check_linger() 174 + output = capsys.readouterr().out 175 + assert output == "" 176 + 177 + 178 + class TestRegistry: 179 + def test_service_command_registered(self): 180 + import sol 181 + 182 + assert "service" in sol.COMMANDS 183 + assert sol.COMMANDS["service"] == "think.service" 184 + 185 + def test_up_alias(self): 186 + import sol 187 + 188 + assert "up" in sol.ALIASES 189 + assert sol.ALIASES["up"] == ("think.service", ["up"]) 190 + 191 + def test_down_alias(self): 192 + import sol 193 + 194 + assert "down" in sol.ALIASES 195 + assert sol.ALIASES["down"] == ("think.service", ["down"]) 196 + 197 + def test_service_group_exists(self): 198 + import sol 199 + 200 + assert "Service" in sol.GROUPS 201 + assert "service" in sol.GROUPS["Service"] 202 + 203 + 204 + class TestMain: 205 + def test_no_args_shows_usage(self, monkeypatch, capsys): 206 + monkeypatch.setattr(sys, "argv", ["sol service"]) 207 + with pytest.raises(SystemExit): 208 + service.main() 209 + output = capsys.readouterr().out 210 + assert "Usage:" in output 211 + 212 + def test_unknown_subcommand(self, monkeypatch, capsys): 213 + monkeypatch.setattr(sys, "argv", ["sol service", "bogus"]) 214 + with pytest.raises(SystemExit): 215 + service.main() 216 + assert "Unknown subcommand" in capsys.readouterr().err
+492
think/service.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Cross-platform background service management for solstone. 5 + 6 + Usage: 7 + sol service install Install solstone as a background service 8 + sol service uninstall Remove the background service 9 + sol service start Start the background service 10 + sol service stop Stop the background service 11 + sol service restart Restart the background service 12 + sol service status Show service installation and runtime status 13 + sol service logs View service logs 14 + sol service logs -f Follow service logs 15 + 16 + sol up Install (if needed), start, and show status 17 + sol down Stop the background service 18 + """ 19 + 20 + from __future__ import annotations 21 + 22 + import os 23 + import plistlib 24 + import subprocess 25 + import sys 26 + from pathlib import Path 27 + from think.utils import get_journal, get_journal_info 28 + 29 + SERVICE_LABEL = "org.solpbc.solstone" 30 + SYSTEMD_UNIT = "solstone" 31 + 32 + 33 + def _platform() -> str: 34 + """Return 'darwin', 'linux', or raise on unsupported.""" 35 + if sys.platform == "darwin": 36 + return "darwin" 37 + elif sys.platform.startswith("linux"): 38 + return "linux" 39 + else: 40 + print(f"Error: unsupported platform '{sys.platform}'", file=sys.stderr) 41 + sys.exit(1) 42 + 43 + 44 + def _plist_path() -> Path: 45 + return Path.home() / "Library" / "LaunchAgents" / f"{SERVICE_LABEL}.plist" 46 + 47 + 48 + def _unit_path() -> Path: 49 + return Path.home() / ".config" / "systemd" / "user" / f"{SYSTEMD_UNIT}.service" 50 + 51 + 52 + def _sol_bin() -> str: 53 + """Return absolute path to the sol binary in the current venv.""" 54 + return str(Path(sys.executable).parent / "sol") 55 + 56 + 57 + _API_KEYS = [ 58 + "ANTHROPIC_API_KEY", 59 + "OPENAI_API_KEY", 60 + "GOOGLE_API_KEY", 61 + "REVAI_ACCESS_TOKEN", 62 + "PLAUD_ACCESS_TOKEN", 63 + ] 64 + 65 + 66 + def _collect_env() -> dict[str, str]: 67 + """Collect environment variables for the service file. 68 + 69 + Captures: HOME, PATH (with venv bin), _SOLSTONE_JOURNAL_OVERRIDE, 70 + and any API keys present in the current environment. 71 + """ 72 + journal_path = str(Path(get_journal()).resolve()) 73 + venv_bin = str(Path(sys.executable).parent) 74 + 75 + env = { 76 + "HOME": str(Path.home()), 77 + "PATH": f"{venv_bin}:/usr/local/bin:/usr/bin:/bin", 78 + "_SOLSTONE_JOURNAL_OVERRIDE": journal_path, 79 + } 80 + 81 + missing_keys = [] 82 + for key in _API_KEYS: 83 + val = os.environ.get(key) 84 + if val: 85 + env[key] = val 86 + else: 87 + missing_keys.append(key) 88 + 89 + if missing_keys: 90 + print( 91 + "Note: these API keys are not set and won't be in the service: " 92 + f"{', '.join(missing_keys)}" 93 + ) 94 + 95 + return env 96 + 97 + 98 + def _generate_plist(env: dict[str, str]) -> bytes: 99 + """Generate a launchd plist for the solstone supervisor.""" 100 + journal_path = env["_SOLSTONE_JOURNAL_OVERRIDE"] 101 + sol = _sol_bin() 102 + 103 + plist = { 104 + "Label": SERVICE_LABEL, 105 + "ProgramArguments": [sol, "supervisor"], 106 + "EnvironmentVariables": env, 107 + "RunAtLoad": True, 108 + "KeepAlive": True, 109 + "StandardOutPath": f"{journal_path}/health/launchd-stdout.log", 110 + "StandardErrorPath": f"{journal_path}/health/launchd-stderr.log", 111 + } 112 + return plistlib.dumps(plist) 113 + 114 + 115 + def _generate_systemd_unit(env: dict[str, str]) -> str: 116 + """Generate a systemd user unit for the solstone supervisor.""" 117 + sol = _sol_bin() 118 + env_lines = "\n".join(f"Environment={k}={v}" for k, v in sorted(env.items())) 119 + 120 + return ( 121 + f"[Unit]\n" 122 + f"Description=Solstone Supervisor\n" 123 + f"After=default.target\n" 124 + f"\n" 125 + f"[Service]\n" 126 + f"Type=simple\n" 127 + f"ExecStart={sol} supervisor\n" 128 + f"Restart=on-failure\n" 129 + f"RestartSec=5\n" 130 + f"{env_lines}\n" 131 + f"\n" 132 + f"[Install]\n" 133 + f"WantedBy=default.target\n" 134 + ) 135 + 136 + 137 + def _check_linger() -> None: 138 + """Warn if systemd linger is not enabled for the current user.""" 139 + try: 140 + result = subprocess.run( 141 + ["loginctl", "show-user", os.environ.get("USER", ""), "--property=Linger"], 142 + capture_output=True, 143 + text=True, 144 + timeout=5, 145 + ) 146 + if result.returncode == 0 and "Linger=no" in result.stdout: 147 + print( 148 + "Warning: systemd linger is not enabled. " 149 + "The service will stop when you log out.\n" 150 + "Enable it with: sudo loginctl enable-linger $USER" 151 + ) 152 + except (subprocess.TimeoutExpired, FileNotFoundError): 153 + pass 154 + 155 + 156 + def _install() -> int: 157 + platform = _platform() 158 + env = _collect_env() 159 + 160 + journal_path, _source = get_journal_info() 161 + Path(journal_path, "health").mkdir(parents=True, exist_ok=True) 162 + 163 + if platform == "darwin": 164 + plist_data = _generate_plist(env) 165 + path = _plist_path() 166 + path.parent.mkdir(parents=True, exist_ok=True) 167 + 168 + uid = os.getuid() 169 + subprocess.run( 170 + ["launchctl", "bootout", f"gui/{uid}", str(path)], 171 + capture_output=True, 172 + ) 173 + 174 + path.write_bytes(plist_data) 175 + print(f"Wrote {path}") 176 + 177 + result = subprocess.run( 178 + ["launchctl", "bootstrap", f"gui/{uid}", str(path)], 179 + capture_output=True, 180 + text=True, 181 + ) 182 + if result.returncode != 0: 183 + print(f"Error loading service: {result.stderr.strip()}", file=sys.stderr) 184 + return 1 185 + print("Service loaded into launchd") 186 + 187 + else: 188 + unit_content = _generate_systemd_unit(env) 189 + path = _unit_path() 190 + path.parent.mkdir(parents=True, exist_ok=True) 191 + path.write_text(unit_content) 192 + print(f"Wrote {path}") 193 + 194 + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) 195 + subprocess.run(["systemctl", "--user", "enable", SYSTEMD_UNIT], check=True) 196 + print("Service enabled") 197 + 198 + _check_linger() 199 + 200 + return 0 201 + 202 + 203 + def _uninstall() -> int: 204 + platform = _platform() 205 + 206 + if platform == "darwin": 207 + path = _plist_path() 208 + uid = os.getuid() 209 + subprocess.run( 210 + ["launchctl", "bootout", f"gui/{uid}", str(path)], 211 + capture_output=True, 212 + ) 213 + if path.exists(): 214 + path.unlink() 215 + print(f"Removed {path}") 216 + else: 217 + print("Service was not installed") 218 + 219 + else: 220 + path = _unit_path() 221 + subprocess.run( 222 + ["systemctl", "--user", "stop", SYSTEMD_UNIT], 223 + capture_output=True, 224 + ) 225 + subprocess.run( 226 + ["systemctl", "--user", "disable", SYSTEMD_UNIT], 227 + capture_output=True, 228 + ) 229 + if path.exists(): 230 + path.unlink() 231 + subprocess.run( 232 + ["systemctl", "--user", "daemon-reload"], 233 + capture_output=True, 234 + ) 235 + print(f"Removed {path}") 236 + else: 237 + print("Service was not installed") 238 + 239 + return 0 240 + 241 + 242 + def _start() -> int: 243 + platform = _platform() 244 + if platform == "darwin": 245 + uid = os.getuid() 246 + path = _plist_path() 247 + if not path.exists(): 248 + print( 249 + "Error: service not installed. Run 'sol service install' first.", 250 + file=sys.stderr, 251 + ) 252 + return 1 253 + result = subprocess.run( 254 + ["launchctl", "kickstart", f"gui/{uid}/{SERVICE_LABEL}"], 255 + capture_output=True, 256 + text=True, 257 + ) 258 + if result.returncode != 0: 259 + print(f"Error starting service: {result.stderr.strip()}", file=sys.stderr) 260 + return 1 261 + else: 262 + if not _unit_path().exists(): 263 + print( 264 + "Error: service not installed. Run 'sol service install' first.", 265 + file=sys.stderr, 266 + ) 267 + return 1 268 + result = subprocess.run( 269 + ["systemctl", "--user", "start", SYSTEMD_UNIT], 270 + capture_output=True, 271 + text=True, 272 + ) 273 + if result.returncode != 0: 274 + print(f"Error starting service: {result.stderr.strip()}", file=sys.stderr) 275 + return 1 276 + 277 + print("Service started") 278 + return 0 279 + 280 + 281 + def _stop() -> int: 282 + platform = _platform() 283 + if platform == "darwin": 284 + uid = os.getuid() 285 + result = subprocess.run( 286 + ["launchctl", "kill", "SIGTERM", f"gui/{uid}/{SERVICE_LABEL}"], 287 + capture_output=True, 288 + text=True, 289 + ) 290 + if result.returncode != 0: 291 + print(f"Error stopping service: {result.stderr.strip()}", file=sys.stderr) 292 + return 1 293 + else: 294 + result = subprocess.run( 295 + ["systemctl", "--user", "stop", SYSTEMD_UNIT], 296 + capture_output=True, 297 + text=True, 298 + ) 299 + if result.returncode != 0: 300 + print(f"Error stopping service: {result.stderr.strip()}", file=sys.stderr) 301 + return 1 302 + 303 + print("Service stopped") 304 + return 0 305 + 306 + 307 + def _restart() -> int: 308 + platform = _platform() 309 + if platform == "darwin": 310 + uid = os.getuid() 311 + subprocess.run( 312 + ["launchctl", "kill", "SIGTERM", f"gui/{uid}/{SERVICE_LABEL}"], 313 + capture_output=True, 314 + ) 315 + result = subprocess.run( 316 + ["launchctl", "kickstart", f"gui/{uid}/{SERVICE_LABEL}"], 317 + capture_output=True, 318 + text=True, 319 + ) 320 + if result.returncode != 0: 321 + print(f"Error restarting service: {result.stderr.strip()}", file=sys.stderr) 322 + return 1 323 + else: 324 + result = subprocess.run( 325 + ["systemctl", "--user", "restart", SYSTEMD_UNIT], 326 + capture_output=True, 327 + text=True, 328 + ) 329 + if result.returncode != 0: 330 + print(f"Error restarting service: {result.stderr.strip()}", file=sys.stderr) 331 + return 1 332 + 333 + print("Service restarted") 334 + return 0 335 + 336 + 337 + def _status() -> int: 338 + platform = _platform() 339 + 340 + if platform == "darwin": 341 + installed = _plist_path().exists() 342 + else: 343 + installed = _unit_path().exists() 344 + 345 + if not installed: 346 + print("Service: not installed") 347 + print("Run 'sol service install' to install, or 'sol up' to install and start.") 348 + return 1 349 + 350 + print("Service: installed") 351 + 352 + if platform == "darwin": 353 + uid = os.getuid() 354 + result = subprocess.run( 355 + ["launchctl", "print", f"gui/{uid}/{SERVICE_LABEL}"], 356 + capture_output=True, 357 + text=True, 358 + ) 359 + if result.returncode == 0: 360 + print("State: running (launchd)") 361 + else: 362 + print("State: stopped") 363 + return 0 364 + else: 365 + result = subprocess.run( 366 + ["systemctl", "--user", "is-active", SYSTEMD_UNIT], 367 + capture_output=True, 368 + text=True, 369 + ) 370 + state = result.stdout.strip() 371 + if state == "active": 372 + print("State: running (systemd)") 373 + else: 374 + print(f"State: {state}") 375 + return 0 376 + 377 + print() 378 + from think.health_cli import health_check 379 + 380 + return health_check() 381 + 382 + 383 + def _logs(follow: bool = False) -> int: 384 + platform = _platform() 385 + 386 + if platform == "linux": 387 + cmd = ["journalctl", "--user", "-u", SYSTEMD_UNIT, "--no-pager", "-n", "100"] 388 + if follow: 389 + cmd.append("--follow") 390 + result = subprocess.run(cmd) 391 + return result.returncode 392 + else: 393 + journal_path = Path(get_journal()) 394 + stdout_log = journal_path / "health" / "launchd-stdout.log" 395 + stderr_log = journal_path / "health" / "launchd-stderr.log" 396 + 397 + if follow: 398 + logs_to_follow = [str(p) for p in [stdout_log, stderr_log] if p.exists()] 399 + if not logs_to_follow: 400 + print("No service log files found", file=sys.stderr) 401 + return 1 402 + result = subprocess.run(["/usr/bin/tail", "-f"] + logs_to_follow) 403 + return result.returncode 404 + else: 405 + for log_path in [stdout_log, stderr_log]: 406 + if log_path.exists(): 407 + print(f"=== {log_path.name} ===") 408 + print(log_path.read_text(errors="replace")[-10000:]) 409 + else: 410 + print(f"=== {log_path.name} === (not found)") 411 + return 0 412 + 413 + 414 + def _up() -> int: 415 + """Install if needed, start if not running, show status.""" 416 + platform = _platform() 417 + 418 + if platform == "darwin": 419 + installed = _plist_path().exists() 420 + else: 421 + installed = _unit_path().exists() 422 + 423 + if not installed: 424 + print("Installing service...") 425 + rc = _install() 426 + if rc != 0: 427 + return rc 428 + 429 + if platform == "darwin": 430 + uid = os.getuid() 431 + result = subprocess.run( 432 + ["launchctl", "print", f"gui/{uid}/{SERVICE_LABEL}"], 433 + capture_output=True, 434 + text=True, 435 + ) 436 + running = result.returncode == 0 437 + else: 438 + result = subprocess.run( 439 + ["systemctl", "--user", "is-active", SYSTEMD_UNIT], 440 + capture_output=True, 441 + text=True, 442 + ) 443 + running = result.stdout.strip() == "active" 444 + 445 + if not running: 446 + print("Starting service...") 447 + rc = _start() 448 + if rc != 0: 449 + return rc 450 + 451 + return _status() 452 + 453 + 454 + def _down() -> int: 455 + """Stop the service.""" 456 + return _stop() 457 + 458 + 459 + _SUBCOMMANDS = { 460 + "install": _install, 461 + "uninstall": _uninstall, 462 + "start": _start, 463 + "stop": _stop, 464 + "restart": _restart, 465 + "status": _status, 466 + "up": lambda: _up(), 467 + "down": lambda: _down(), 468 + } 469 + 470 + 471 + def main() -> None: 472 + """Entry point for ``sol service``.""" 473 + args = sys.argv[1:] 474 + 475 + if args and args[0] == "logs": 476 + follow = "-f" in args[1:] or "--follow" in args[1:] 477 + sys.exit(_logs(follow=follow)) 478 + 479 + if not args: 480 + print("Usage: sol service <install|uninstall|start|stop|restart|status|logs>") 481 + print(" sol up (install + start + status)") 482 + print(" sol down (stop)") 483 + sys.exit(1) 484 + 485 + subcmd = args[0] 486 + 487 + if subcmd in _SUBCOMMANDS: 488 + sys.exit(_SUBCOMMANDS[subcmd]()) 489 + 490 + print(f"Unknown subcommand: {subcmd}", file=sys.stderr) 491 + print("Available: install, uninstall, start, stop, restart, status, logs") 492 + sys.exit(1)