personal memory agent
0
fork

Configure Feed

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

feat(observer): sol observer install for linux/tmux/macos

Install now writes observer-owned configs, delegates service setup to the platform repos, and keeps reruns idempotent through XDG markers.

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

+2172 -3
+64 -3
observe/observer_cli.py
··· 288 288 return 0 289 289 290 290 291 + def cmd_install(args: argparse.Namespace) -> int: 292 + """Install an observer for this host.""" 293 + from observe.observer_install import run_install 294 + 295 + return run_install(args) 296 + 297 + 291 298 def cmd_rename(args: argparse.Namespace) -> int: 292 299 """Rename an observer (affects future stream names).""" 293 300 identifier = args.identifier ··· 383 390 print(f" Bytes: {_fmt_bytes(stats.get('bytes_received', 0))}") 384 391 if stats.get("duplicates_rejected"): 385 392 print(f" Duplicates: {stats['duplicates_rejected']} rejected") 393 + _print_install_status(name) 386 394 387 395 # Today's sync history 388 396 today = datetime.date.today().strftime("%Y%m%d") ··· 412 420 return 0 413 421 414 422 423 + def _print_install_status(name: str) -> None: 424 + """Print install marker details for human status output.""" 425 + from observe.observer_install.common import ( 426 + SERVICE_UNITS, 427 + find_marker_for_observer, 428 + run_probe, 429 + ) 430 + 431 + marker = find_marker_for_observer(name) 432 + if marker is None: 433 + return 434 + 435 + _path, data = marker 436 + platform_name = data.get("platform", "unknown") 437 + version = data.get("version") or "unknown" 438 + short_version = version[:12] if version != "unknown" else version 439 + installed_at = data.get("installed_at") or "unknown" 440 + print(f" Installed: {installed_at} ({platform_name}, version {short_version})") 441 + 442 + unit_name = SERVICE_UNITS.get(platform_name) 443 + if not unit_name: 444 + return 445 + process = run_probe(["systemctl", "--user", "is-active", unit_name]) 446 + service_status = process.stdout.strip() 447 + if not service_status: 448 + service_status = "missing" if process.returncode == 127 else "inactive" 449 + print(f" Service: {unit_name} — {service_status}") 450 + 451 + 415 452 def _status_all(json_output: bool = False) -> int: 416 453 """Health overview for all observers.""" 417 454 observers = list_observers() ··· 512 549 help="Observer name or key prefix (omit for overview)", 513 550 ) 514 551 552 + # install 553 + p_install = sub.add_parser("install", help="Install an observer for this host") 554 + p_install.add_argument( 555 + "name", 556 + nargs="?", 557 + default=None, 558 + help="Observer name (defaults to this host)", 559 + ) 560 + p_install.add_argument( 561 + "--platform", 562 + choices=["linux", "tmux", "macos"], 563 + default=None, 564 + help="Observer platform (default: auto-detect)", 565 + ) 566 + p_install.add_argument("--server-url", default=None, help="solstone server URL") 567 + p_install.add_argument( 568 + "--dry-run", action="store_true", help="Show the install plan without writes" 569 + ) 570 + p_install.add_argument( 571 + "--force", action="store_true", help="Recreate registration and rerun install" 572 + ) 573 + 515 574 args = setup_cli(parser) 516 - require_solstone() 517 575 518 - # Bridge journal path to convey.state so apps.utils resolves correctly 519 - # (setup_cli initializes the journal, but convey.state needs it too) 576 + # Keep app helpers aligned with the active CLI journal. 520 577 import convey.state 521 578 from think.utils import get_journal 522 579 523 580 convey.state.journal_root = get_journal() 581 + 582 + if args.command != "install": 583 + require_solstone() 524 584 525 585 if not args.command: 526 586 parser.print_help() ··· 532 592 "rename": cmd_rename, 533 593 "revoke": cmd_revoke, 534 594 "status": cmd_status, 595 + "install": cmd_install, 535 596 } 536 597 537 598 sys.exit(handlers[args.command](args))
+53
observe/observer_install/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Observer install command dispatcher.""" 5 + 6 + from __future__ import annotations 7 + 8 + from .common import InstallError, detect_platform, emit_json, print_summary 9 + 10 + 11 + def run_install(args) -> int: 12 + """Run the platform-specific observer installer.""" 13 + try: 14 + platform_name = detect_platform(args.platform) 15 + if platform_name == "macos": 16 + from .macos import MacosDriver 17 + 18 + driver = MacosDriver() 19 + elif platform_name == "linux": 20 + from .linux import LinuxDriver 21 + 22 + driver = LinuxDriver() 23 + elif platform_name == "tmux": 24 + from .tmux import TmuxDriver 25 + 26 + driver = TmuxDriver() 27 + else: 28 + raise InstallError( 29 + "unsupported observer platform", 30 + hint="pass --platform linux, --platform tmux, or install the macOS app", 31 + ) 32 + return driver.run(args) 33 + except InstallError as exc: 34 + result = { 35 + "platform": getattr(args, "platform", None), 36 + "name": getattr(args, "name", None), 37 + "source_path": None, 38 + "service_unit": None, 39 + "key_prefix": None, 40 + "server_url": getattr(args, "server_url", None), 41 + "config_path": None, 42 + "marker_path": None, 43 + "status": "error", 44 + "version": None, 45 + "dry_run": bool(getattr(args, "dry_run", False)), 46 + "error": str(exc), 47 + "hint": exc.hint, 48 + } 49 + if getattr(args, "json_output", False): 50 + emit_json(result) 51 + else: 52 + print_summary(result) 53 + return exc.code
+310
observe/observer_install/common.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Shared helpers for observer installers.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + import re 10 + import socket 11 + import subprocess 12 + import sys 13 + import time 14 + from dataclasses import dataclass 15 + from pathlib import Path 16 + from typing import Literal, Sequence 17 + 18 + from apps.observer.utils import find_observer_by_name, list_observers 19 + from think.utils import read_service_port 20 + 21 + Platform = Literal["linux", "tmux", "macos", "unsupported"] 22 + 23 + STREAM_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") 24 + SERVICE_UNITS = { 25 + "linux": "solstone-linux.service", 26 + "tmux": "solstone-tmux.service", 27 + } 28 + 29 + 30 + class InstallError(Exception): 31 + """Install failure with an optional operator hint.""" 32 + 33 + def __init__(self, message: str, *, hint: str | None = None, code: int = 1): 34 + super().__init__(message) 35 + self.hint = hint 36 + self.code = code 37 + 38 + 39 + @dataclass(frozen=True) 40 + class ObserverRecord: 41 + record: dict 42 + key: str 43 + prefix: str 44 + 45 + 46 + @dataclass(frozen=True) 47 + class StepResult: 48 + process: subprocess.CompletedProcess | None 49 + skipped: bool = False 50 + 51 + 52 + def detect_platform(override: str | None = None) -> Platform: 53 + """Detect the observer platform.""" 54 + if override in {"linux", "tmux", "macos"}: 55 + return override # type: ignore[return-value] 56 + if sys.platform == "darwin": 57 + return "macos" 58 + if sys.platform.startswith("linux"): 59 + return "linux" 60 + return "unsupported" 61 + 62 + 63 + def default_server_url(explicit: str | None = None) -> str: 64 + """Return the explicit or locally discovered solstone server URL.""" 65 + if explicit: 66 + return explicit 67 + port = read_service_port("convey") 68 + if port is None: 69 + raise InstallError( 70 + "could not determine solstone server URL", 71 + hint=( 72 + "start solstone with 'sol up' or pass --server-url " 73 + "http://127.0.0.1:<port>" 74 + ), 75 + ) 76 + return f"http://127.0.0.1:{port}" 77 + 78 + 79 + def default_stream( 80 + platform: Literal["linux", "tmux"], override: str | None = None 81 + ) -> str: 82 + """Return the normalized observer stream name.""" 83 + raw = override or socket.gethostname() 84 + if override is None and platform == "tmux": 85 + raw = f"{raw}-tmux" 86 + return normalize_stream_name(raw) 87 + 88 + 89 + def normalize_stream_name(value: str) -> str: 90 + """Normalize a stream name to the observer name regex.""" 91 + normalized = re.sub(r"[^a-z0-9._-]+", "-", value.strip().lower()) 92 + normalized = re.sub(r"^[^a-z0-9]+", "", normalized) 93 + if not normalized or not STREAM_RE.match(normalized): 94 + raise InstallError( 95 + f"invalid observer name: {value}", 96 + hint="use lowercase letters, numbers, dots, underscores, or hyphens", 97 + ) 98 + return normalized 99 + 100 + 101 + def install_root() -> Path: 102 + """Return the observer install root.""" 103 + return Path.home() / ".local" / "share" / "solstone" / "observers" 104 + 105 + 106 + def xdg_install_dir(install_name: str) -> Path: 107 + """Return the source clone directory for an observer package.""" 108 + return install_root() / install_name 109 + 110 + 111 + def marker_path(install_name: str) -> Path: 112 + """Return the marker path for an observer package.""" 113 + return xdg_install_dir(install_name) / ".installed.json" 114 + 115 + 116 + def read_marker(install_name: str) -> dict | None: 117 + """Read an observer install marker.""" 118 + path = marker_path(install_name) 119 + try: 120 + data = json.loads(path.read_text(encoding="utf-8")) 121 + except (FileNotFoundError, json.JSONDecodeError, OSError): 122 + return None 123 + return data if isinstance(data, dict) else None 124 + 125 + 126 + def write_marker(install_name: str, data: dict) -> None: 127 + """Write an observer install marker.""" 128 + path = marker_path(install_name) 129 + path.parent.mkdir(parents=True, exist_ok=True) 130 + tmp_path = path.with_suffix(".json.tmp") 131 + tmp_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") 132 + tmp_path.replace(path) 133 + 134 + 135 + def find_marker_for_observer(name: str) -> tuple[Path, dict] | None: 136 + """Find the marker associated with a stream name.""" 137 + root = install_root() 138 + if not root.exists(): 139 + return None 140 + for path in root.glob("*/.installed.json"): 141 + try: 142 + data = json.loads(path.read_text(encoding="utf-8")) 143 + except (json.JSONDecodeError, OSError): 144 + continue 145 + if isinstance(data, dict) and data.get("name") == name: 146 + return path, data 147 + return None 148 + 149 + 150 + def _active_observers_by_name(name: str) -> list[dict]: 151 + return [ 152 + observer 153 + for observer in list_observers() 154 + if observer.get("name") == name and not observer.get("revoked", False) 155 + ] 156 + 157 + 158 + def create_or_reuse_registration(name: str, *, force: bool) -> ObserverRecord: 159 + """Return an active observer registration, creating one when needed.""" 160 + from observe.observer_cli import create_observer_record, revoke_observer_record 161 + 162 + if force: 163 + for observer in _active_observers_by_name(name): 164 + revoke_observer_record(observer.get("key", "")[:8]) 165 + record, key = create_observer_record(name, permit_duplicate_name=True) 166 + return ObserverRecord(record=record, key=key, prefix=key[:8]) 167 + 168 + active = _active_observers_by_name(name) 169 + if active: 170 + key = active[0].get("key") 171 + if not key: 172 + raise InstallError( 173 + f"observer registration for {name} is missing its key", 174 + hint="run sol observer install --force to recreate it", 175 + ) 176 + return ObserverRecord(record=active[0], key=key, prefix=key[:8]) 177 + 178 + permit_duplicate = find_observer_by_name(name) is not None 179 + record, key = create_observer_record(name, permit_duplicate_name=permit_duplicate) 180 + return ObserverRecord(record=record, key=key, prefix=key[:8]) 181 + 182 + 183 + def run_step( 184 + label: str, 185 + cmd: Sequence[str], 186 + *, 187 + cwd: Path | None = None, 188 + dry_run: bool = False, 189 + capture: bool = False, 190 + stream: bool = True, 191 + json_output: bool = False, 192 + check: bool = True, 193 + ) -> StepResult: 194 + """Run an install step with consistent human output.""" 195 + if dry_run: 196 + if not json_output: 197 + print(f"would {label}") 198 + return StepResult(process=None, skipped=True) 199 + 200 + effective_capture = capture or json_output or not stream 201 + if not json_output: 202 + print(f"→ {label}") 203 + 204 + try: 205 + process = subprocess.run( 206 + list(cmd), 207 + cwd=cwd, 208 + check=False, 209 + capture_output=effective_capture, 210 + text=True, 211 + ) 212 + except OSError as exc: 213 + if not json_output: 214 + print(f"✗ {label} failed", file=sys.stderr) 215 + raise InstallError(f"{label} failed", hint=str(exc)) from exc 216 + 217 + if check and process.returncode != 0: 218 + hint = _first_output_line(process.stderr) or _first_output_line(process.stdout) 219 + if not json_output: 220 + print(f"✗ {label} failed", file=sys.stderr) 221 + raise InstallError(f"{label} failed", hint=hint) 222 + 223 + if not json_output: 224 + print("✓ done") 225 + return StepResult(process=process) 226 + 227 + 228 + def run_probe( 229 + cmd: Sequence[str], *, cwd: Path | None = None 230 + ) -> subprocess.CompletedProcess: 231 + """Run a predicate command without raising.""" 232 + try: 233 + return subprocess.run( 234 + list(cmd), 235 + cwd=cwd, 236 + check=False, 237 + capture_output=True, 238 + text=True, 239 + ) 240 + except OSError as exc: 241 + return subprocess.CompletedProcess(list(cmd), 127, "", str(exc)) 242 + 243 + 244 + def poll_status_until( 245 + name: str, timeout: float = 30.0, interval: float = 2.0 246 + ) -> Literal["connected", "disconnected", "revoked", "missing"]: 247 + """Poll existing observer status until connected or timeout.""" 248 + from observe.observer_cli import _status_label 249 + 250 + deadline = time.monotonic() + timeout 251 + last_status: Literal["connected", "disconnected", "revoked", "missing"] = "missing" 252 + while True: 253 + observer = find_observer_by_name(name) 254 + if observer is None: 255 + last_status = "missing" 256 + else: 257 + status = _status_label(observer) 258 + if status in {"connected", "revoked"}: 259 + return status # type: ignore[return-value] 260 + last_status = "disconnected" 261 + if time.monotonic() >= deadline: 262 + return last_status 263 + time.sleep(interval) 264 + 265 + 266 + def print_summary(result: dict) -> None: 267 + """Print a human summary for an install result.""" 268 + if result.get("status") == "error": 269 + print(f"Error: {result.get('error')}", file=sys.stderr) 270 + if result.get("hint"): 271 + print(result["hint"], file=sys.stderr) 272 + return 273 + 274 + if result.get("status") == "redirected": 275 + return 276 + 277 + if result.get("status") == "already_installed": 278 + print(f"{result.get('service_unit')} is already installed.") 279 + else: 280 + print("Observer install complete:") 281 + print(f" Name: {result.get('name')}") 282 + print(f" Service: {result.get('service_unit')}") 283 + print(f" Config: {result.get('config_path')}") 284 + print(f" Marker: {result.get('marker_path')}") 285 + print(f" Status: {result.get('status')}") 286 + print(f" Version: {result.get('version')}") 287 + 288 + 289 + def emit_json(result: dict) -> None: 290 + """Emit a single JSON result object.""" 291 + print(json.dumps(result, indent=2)) 292 + 293 + 294 + def _first_output_line(value: str | None) -> str | None: 295 + if not value: 296 + return None 297 + for line in value.splitlines(): 298 + if line.strip(): 299 + return line.strip() 300 + return None 301 + 302 + 303 + def observer_key_prefix_from_config(config_path: Path) -> str | None: 304 + """Read the configured key prefix if a config file exists.""" 305 + try: 306 + data = json.loads(config_path.read_text(encoding="utf-8")) 307 + except (FileNotFoundError, json.JSONDecodeError, OSError): 308 + return None 309 + key = data.get("key") 310 + return key[:8] if isinstance(key, str) and key else None
+550
observe/observer_install/linux.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Linux observer installer.""" 5 + 6 + from __future__ import annotations 7 + 8 + import datetime as dt 9 + import json 10 + import shutil 11 + from pathlib import Path 12 + 13 + from apps.observer.utils import list_observers 14 + 15 + from .common import ( 16 + InstallError, 17 + create_or_reuse_registration, 18 + default_server_url, 19 + default_stream, 20 + emit_json, 21 + marker_path, 22 + observer_key_prefix_from_config, 23 + poll_status_until, 24 + print_summary, 25 + read_marker, 26 + run_probe, 27 + run_step, 28 + write_marker, 29 + xdg_install_dir, 30 + ) 31 + 32 + PLATFORM = "linux" 33 + INSTALL_NAME = "solstone-linux" 34 + SOURCE_URL = "https://github.com/solpbc/solstone-linux.git" 35 + UNIT_NAME = "solstone-linux.service" 36 + CONFIG_PATH = ( 37 + Path.home() / ".local" / "share" / "solstone-linux" / "config" / "config.json" 38 + ) 39 + OS_RELEASE_PATH = Path("/etc/os-release") 40 + DEFAULT_CONFIG = { 41 + "server_url": "", 42 + "key": "", 43 + "stream": "", 44 + "segment_interval": 300, 45 + "sync_retry_delays": [5, 30, 120, 300], 46 + "sync_max_retries": 10, 47 + "cache_retention_days": 7, 48 + } 49 + 50 + DISTRO_PACKAGES = { 51 + "fedora": ( 52 + [ 53 + "python3-gobject", 54 + "gtk4", 55 + "gstreamer1-plugins-base", 56 + "gstreamer1-plugin-pipewire", 57 + "pipewire-gstreamer", 58 + "alsa-lib-devel", 59 + "pulseaudio-utils", 60 + "pipewire-pulseaudio", 61 + "xdg-desktop-portal", 62 + "pipx", 63 + ], 64 + "sudo dnf install python3-gobject gtk4 gstreamer1-plugins-base gstreamer1-plugin-pipewire pipewire-gstreamer alsa-lib-devel pulseaudio-utils pipewire-pulseaudio xdg-desktop-portal pipx", 65 + "rpm", 66 + ), 67 + "debian-ubuntu": ( 68 + [ 69 + "python3-gi", 70 + "gir1.2-gdk-4.0", 71 + "gir1.2-gtk-4.0", 72 + "gstreamer1.0-pipewire", 73 + "libasound2-dev", 74 + "pulseaudio-utils", 75 + "pipewire-pulse", 76 + "xdg-desktop-portal", 77 + "pipx", 78 + ], 79 + "sudo apt install python3-gi gir1.2-gdk-4.0 gir1.2-gtk-4.0 gstreamer1.0-pipewire libasound2-dev pulseaudio-utils pipewire-pulse xdg-desktop-portal pipx", 80 + "dpkg", 81 + ), 82 + "arch": ( 83 + [ 84 + "python-gobject", 85 + "gtk4", 86 + "gstreamer", 87 + "gst-plugin-pipewire", 88 + "libpulse", 89 + "alsa-lib", 90 + "xdg-desktop-portal", 91 + "pipx", 92 + ], 93 + "sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib xdg-desktop-portal pipx", 94 + "pacman", 95 + ), 96 + "opensuse": ( 97 + [ 98 + "python3-gobject", 99 + "python3-gobject-Gdk", 100 + "typelib-1_0-Gtk-4_0", 101 + "gtk4-tools", 102 + "gstreamer-plugins-base", 103 + "gstreamer-plugin-pipewire", 104 + "pipewire-pulseaudio", 105 + "pulseaudio-utils", 106 + "alsa-devel", 107 + "xdg-desktop-portal", 108 + "python3-pipx", 109 + ], 110 + "sudo zypper install python3-gobject python3-gobject-Gdk typelib-1_0-Gtk-4_0 \\\n gtk4-tools gstreamer-plugins-base gstreamer-plugin-pipewire \\\n pipewire-pulseaudio pulseaudio-utils alsa-devel \\\n xdg-desktop-portal python3-pipx", 111 + "rpm", 112 + ), 113 + } 114 + 115 + 116 + def detect_distro() -> str | None: 117 + """Detect the Linux package family.""" 118 + values = _read_os_release() 119 + distro_id = values.get("id", "") 120 + id_like = values.get("id_like", "").split() 121 + for candidate in [distro_id]: 122 + mapped = _map_os_release_id(candidate) 123 + if mapped: 124 + return mapped 125 + for candidate in id_like: 126 + mapped = _map_os_release_like(candidate) 127 + if mapped: 128 + return mapped 129 + for binary, distro in ( 130 + ("zypper", "opensuse"), 131 + ("dnf", "fedora"), 132 + ("dpkg", "debian-ubuntu"), 133 + ("pacman", "arch"), 134 + ("rpm", "fedora"), 135 + ): 136 + if shutil.which(binary): 137 + return distro 138 + return None 139 + 140 + 141 + class LinuxDriver: 142 + """Install and manage the Linux observer service.""" 143 + 144 + def run(self, args) -> int: 145 + distro = detect_distro() 146 + if distro is None: 147 + raise InstallError( 148 + "unsupported Linux distribution", 149 + hint="install the observer dependencies from solstone-linux/INSTALL.md", 150 + ) 151 + if distro not in DISTRO_PACKAGES: 152 + raise InstallError("unsupported Linux distribution") 153 + 154 + server_url = default_server_url(args.server_url) 155 + name = default_stream("linux", args.name) 156 + clone_dir = xdg_install_dir(INSTALL_NAME) 157 + marker = read_marker(INSTALL_NAME) 158 + if marker and marker.get("name") != name and not args.force: 159 + raise InstallError( 160 + f"{INSTALL_NAME} is already installed for {marker.get('name')}", 161 + hint="rerun with --force to replace the existing install marker", 162 + ) 163 + 164 + tool_statuses = _check_tools() 165 + package_statuses = _check_packages(distro) 166 + if args.dry_run: 167 + if not args.json_output: 168 + _print_dry_run( 169 + name, 170 + server_url, 171 + clone_dir, 172 + distro, 173 + tool_statuses, 174 + package_statuses, 175 + ) 176 + if args.json_output: 177 + emit_json( 178 + _result(name, server_url, clone_dir, "planned", None, None, True) 179 + ) 180 + return 0 181 + 182 + _raise_for_preflight(tool_statuses, package_statuses, distro) 183 + version, changed = _prepare_source(clone_dir, args) 184 + active = _active_registration(name) 185 + service_active = _service_is_active() 186 + config_prefix = observer_key_prefix_from_config(CONFIG_PATH) 187 + 188 + if ( 189 + marker 190 + and not args.force 191 + and not changed 192 + and active 193 + and config_prefix == active.get("key", "")[:8] 194 + ): 195 + if service_active: 196 + result = _result( 197 + name, 198 + server_url, 199 + clone_dir, 200 + "already_installed", 201 + active.get("key", "")[:8], 202 + version, 203 + False, 204 + ) 205 + _output_result(result, args.json_output) 206 + return 0 207 + run_step( 208 + f"restart {UNIT_NAME}", 209 + ["systemctl", "--user", "restart", UNIT_NAME], 210 + json_output=args.json_output, 211 + ) 212 + status = poll_status_until(name) 213 + result = _result( 214 + name, 215 + server_url, 216 + clone_dir, 217 + status, 218 + active.get("key", "")[:8], 219 + version, 220 + False, 221 + ) 222 + _output_result(result, args.json_output) 223 + return 0 224 + 225 + registration = create_or_reuse_registration(name, force=args.force) 226 + _write_config(server_url, registration.key, name) 227 + run_step( 228 + "run make install-service", 229 + ["make", "install-service"], 230 + cwd=clone_dir, 231 + json_output=args.json_output, 232 + ) 233 + run_step( 234 + f"restart {UNIT_NAME}", 235 + ["systemctl", "--user", "restart", UNIT_NAME], 236 + json_output=args.json_output, 237 + ) 238 + status = poll_status_until(name) 239 + version = _git_stdout(clone_dir, ["rev-parse", "HEAD"]) or version 240 + _write_install_marker(marker, name, version) 241 + result = _result( 242 + name, 243 + server_url, 244 + clone_dir, 245 + status, 246 + registration.prefix, 247 + version, 248 + False, 249 + ) 250 + _output_result(result, args.json_output) 251 + return 0 252 + 253 + 254 + def _read_os_release() -> dict[str, str]: 255 + try: 256 + lines = OS_RELEASE_PATH.read_text(encoding="utf-8").splitlines() 257 + except OSError: 258 + return {} 259 + values: dict[str, str] = {} 260 + for line in lines: 261 + if "=" not in line or line.startswith("#"): 262 + continue 263 + key, value = line.split("=", 1) 264 + values[key.lower()] = value.strip().strip('"').strip("'").lower() 265 + return values 266 + 267 + 268 + def _map_os_release_id(value: str) -> str | None: 269 + if value == "fedora": 270 + return "fedora" 271 + if value in {"debian", "ubuntu", "pop", "linuxmint"}: 272 + return "debian-ubuntu" 273 + if value in {"arch", "manjaro"}: 274 + return "arch" 275 + if value in {"opensuse", "opensuse-leap", "opensuse-tumbleweed", "sles", "suse"}: 276 + return "opensuse" 277 + return None 278 + 279 + 280 + def _map_os_release_like(value: str) -> str | None: 281 + if value in {"fedora", "rhel", "centos"}: 282 + return "fedora" 283 + if value in {"debian", "ubuntu"}: 284 + return "debian-ubuntu" 285 + if value == "arch": 286 + return "arch" 287 + if value in {"suse", "opensuse"}: 288 + return "opensuse" 289 + return None 290 + 291 + 292 + def _check_tools() -> list[tuple[str, bool, str | None]]: 293 + checks = [ 294 + ( 295 + "git", 296 + ["sh", "-c", "command -v git"], 297 + "install git with your package manager", 298 + ), 299 + ("uv", ["sh", "-c", "command -v uv"], "install uv: https://docs.astral.sh/uv/"), 300 + ( 301 + "pipx", 302 + ["sh", "-c", "command -v pipx"], 303 + "install pipx with your package manager", 304 + ), 305 + ( 306 + "make", 307 + ["sh", "-c", "command -v make"], 308 + "install make with your package manager", 309 + ), 310 + ( 311 + "systemctl --user", 312 + ["systemctl", "--user", "--version"], 313 + "systemd user services are required for this observer", 314 + ), 315 + ] 316 + return [ 317 + (label, run_probe(cmd).returncode == 0, hint) for label, cmd, hint in checks 318 + ] 319 + 320 + 321 + def _check_packages(distro: str) -> list[tuple[str, bool]]: 322 + packages, _install_command, query_method = DISTRO_PACKAGES[distro] 323 + result = [] 324 + for package in packages: 325 + if query_method == "dpkg": 326 + cmd = ["dpkg", "-s", package] 327 + elif query_method == "pacman": 328 + cmd = ["pacman", "-Q", package] 329 + else: 330 + cmd = ["rpm", "-q", package] 331 + result.append((package, run_probe(cmd).returncode == 0)) 332 + return result 333 + 334 + 335 + def _raise_for_preflight( 336 + tool_statuses: list[tuple[str, bool, str | None]], 337 + package_statuses: list[tuple[str, bool]], 338 + distro: str, 339 + ) -> None: 340 + missing_tools = [(label, hint) for label, ok, hint in tool_statuses if not ok] 341 + missing_packages = [package for package, ok in package_statuses if not ok] 342 + if missing_tools: 343 + label, hint = missing_tools[0] 344 + raise InstallError(f"missing required tool: {label}", hint=hint) 345 + if missing_packages: 346 + _packages, install_command, _query_method = DISTRO_PACKAGES[distro] 347 + raise InstallError( 348 + "missing required system packages: " + ", ".join(missing_packages), 349 + hint=install_command, 350 + ) 351 + 352 + 353 + def _prepare_source(clone_dir: Path, args) -> tuple[str | None, bool]: 354 + if not clone_dir.exists(): 355 + run_step( 356 + f"clone {SOURCE_URL} into {clone_dir}", 357 + ["git", "clone", SOURCE_URL, str(clone_dir)], 358 + json_output=args.json_output, 359 + ) 360 + return _git_stdout(clone_dir, ["rev-parse", "HEAD"]), True 361 + 362 + if not (clone_dir / ".git").exists(): 363 + raise InstallError( 364 + f"{clone_dir} exists but is not a git repository", 365 + hint="move it aside or choose --force after restoring the observer clone", 366 + ) 367 + origin = _git_stdout(clone_dir, ["remote", "get-url", "origin"]) 368 + if origin != SOURCE_URL: 369 + raise InstallError( 370 + f"{clone_dir} has unexpected origin {origin}", 371 + hint=f"expected {SOURCE_URL}", 372 + ) 373 + dirty = run_probe(["git", "status", "--porcelain"], cwd=clone_dir) 374 + if dirty.stdout.strip(): 375 + raise InstallError( 376 + f"{clone_dir} has local changes", 377 + hint="commit, clean, or move the clone before rerunning install", 378 + ) 379 + 380 + run_step( 381 + "fetch observer updates", 382 + ["git", "fetch", "origin"], 383 + cwd=clone_dir, 384 + json_output=args.json_output, 385 + ) 386 + local = _git_stdout(clone_dir, ["rev-parse", "HEAD"]) 387 + upstream = _git_stdout(clone_dir, ["rev-parse", "@{u}"]) or _git_stdout( 388 + clone_dir, ["rev-parse", "origin/HEAD"] 389 + ) 390 + if not local or not upstream or local == upstream: 391 + return local, False 392 + 393 + ancestor = run_probe( 394 + ["git", "merge-base", "--is-ancestor", local, upstream], cwd=clone_dir 395 + ) 396 + if ancestor.returncode != 0: 397 + raise InstallError( 398 + f"{clone_dir} has diverged from upstream", 399 + hint="resolve the clone manually before rerunning install", 400 + ) 401 + run_step( 402 + "pull observer updates", 403 + ["git", "pull", "--ff-only"], 404 + cwd=clone_dir, 405 + json_output=args.json_output, 406 + ) 407 + return _git_stdout(clone_dir, ["rev-parse", "HEAD"]), True 408 + 409 + 410 + def _write_config(server_url: str, key: str, name: str) -> None: 411 + config = dict(DEFAULT_CONFIG) 412 + config.update({"server_url": server_url, "key": key, "stream": name}) 413 + try: 414 + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) 415 + with CONFIG_PATH.open("w", encoding="utf-8") as handle: 416 + json.dump(config, handle, indent=2) 417 + handle.write("\n") 418 + except OSError as exc: 419 + raise InstallError(f"failed to write {CONFIG_PATH}", hint=str(exc)) from exc 420 + 421 + 422 + def _write_install_marker(marker: dict | None, name: str, version: str | None) -> None: 423 + now = _now_utc() 424 + write_marker( 425 + INSTALL_NAME, 426 + { 427 + "name": name, 428 + "platform": PLATFORM, 429 + "source": SOURCE_URL, 430 + "installed_at": (marker.get("installed_at") if marker else None) or now, 431 + "last_run": now, 432 + "version": version, 433 + }, 434 + ) 435 + 436 + 437 + def _active_registration(name: str) -> dict | None: 438 + for observer in list_observers(): 439 + if observer.get("name") == name and not observer.get("revoked", False): 440 + return observer 441 + return None 442 + 443 + 444 + def _service_is_active() -> bool: 445 + process = run_probe(["systemctl", "--user", "is-active", UNIT_NAME]) 446 + return process.returncode == 0 and process.stdout.strip() == "active" 447 + 448 + 449 + def _git_stdout(cwd: Path, args: list[str]) -> str | None: 450 + process = run_probe(["git", *args], cwd=cwd) 451 + if process.returncode != 0: 452 + return None 453 + return process.stdout.strip() or None 454 + 455 + 456 + def _now_utc() -> str: 457 + return ( 458 + dt.datetime.now(dt.timezone.utc) 459 + .replace(microsecond=0) 460 + .isoformat() 461 + .replace("+00:00", "Z") 462 + ) 463 + 464 + 465 + def _result( 466 + name: str, 467 + server_url: str, 468 + clone_dir: Path, 469 + status: str, 470 + key_prefix: str | None, 471 + version: str | None, 472 + dry_run: bool, 473 + ) -> dict: 474 + return { 475 + "platform": PLATFORM, 476 + "name": name, 477 + "source_path": str(clone_dir), 478 + "service_unit": UNIT_NAME, 479 + "key_prefix": key_prefix, 480 + "server_url": server_url, 481 + "config_path": str(CONFIG_PATH), 482 + "marker_path": str(marker_path(INSTALL_NAME)), 483 + "status": status, 484 + "version": version, 485 + "dry_run": dry_run, 486 + } 487 + 488 + 489 + def _output_result(result: dict, json_output: bool) -> None: 490 + if json_output: 491 + emit_json(result) 492 + else: 493 + print_summary(result) 494 + 495 + 496 + def _print_dry_run( 497 + name: str, 498 + server_url: str, 499 + clone_dir: Path, 500 + distro: str, 501 + tool_statuses: list[tuple[str, bool, str | None]], 502 + package_statuses: list[tuple[str, bool]], 503 + ) -> None: 504 + print("Dry-run: would install solstone-linux observer") 505 + print() 506 + print("Platform: linux") 507 + print(f"Stream: {name}") 508 + print(f"Server: {server_url}") 509 + print(f"Source: {SOURCE_URL}") 510 + print(f"Target: {clone_dir}") 511 + print(f"Config: {CONFIG_PATH}") 512 + print(f"Service: {UNIT_NAME}") 513 + print(f"Marker: {marker_path(INSTALL_NAME)}") 514 + print() 515 + print("Preflight:") 516 + for label, ok, hint in tool_statuses: 517 + display = ( 518 + "systemctl --user available" 519 + if label == "systemctl --user" 520 + else f"{label} found" 521 + ) 522 + if ok: 523 + print(f" ✓ {display}") 524 + else: 525 + print(f" ✗ {label} missing") 526 + if hint: 527 + print(f" {hint}") 528 + print(f" ✓ distro detected: {distro}") 529 + _packages, install_command, _query_method = DISTRO_PACKAGES[distro] 530 + for package, ok in package_statuses: 531 + if ok: 532 + print(f" ✓ package {package} installed") 533 + else: 534 + print(f" ✗ package {package} missing") 535 + print(f" {install_command}") 536 + print() 537 + print("Plan:") 538 + print(f" would clone {SOURCE_URL} into {clone_dir}") 539 + print(f" would create observer registration '{name}'") 540 + print(f" would write {CONFIG_PATH}") 541 + print(" would run: make install-service") 542 + print(" would wait up to 30s for observer status") 543 + print(f" would write marker {marker_path(INSTALL_NAME)}") 544 + print() 545 + print("Summary:") 546 + print(" Key prefix: <not generated in dry-run>") 547 + print(f" Logs: journalctl --user -u {UNIT_NAME} -f") 548 + print(f" Status: sol observer status {name}") 549 + print() 550 + print("Dry-run complete; no files were written.")
+43
observe/observer_install/macos.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """macOS observer install redirect.""" 5 + 6 + from __future__ import annotations 7 + 8 + from .common import emit_json 9 + 10 + REDIRECT_TEXT = """solstone for macOS is delivered as a signed app bundle. 11 + Install it from https://solstone.app/observers 12 + 13 + After installing, the macOS app pairs itself with this solstone host 14 + using the same registration flow surfaced via 'sol observer create'.""" 15 + 16 + 17 + class MacosDriver: 18 + """Redirect macOS users to the signed app bundle.""" 19 + 20 + def run(self, args) -> int: 21 + if args.json_output: 22 + emit_json( 23 + { 24 + "platform": "macos", 25 + "name": args.name, 26 + "source_path": None, 27 + "service_unit": None, 28 + "key_prefix": None, 29 + "server_url": args.server_url, 30 + "config_path": None, 31 + "marker_path": None, 32 + "status": "redirected", 33 + "version": None, 34 + "dry_run": bool(args.dry_run), 35 + } 36 + ) 37 + return 0 38 + 39 + if args.dry_run: 40 + print("Dry-run: would direct you to download solstone-macos:") 41 + print() 42 + print(REDIRECT_TEXT) 43 + return 0
+384
observe/observer_install/tmux.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """tmux observer installer.""" 5 + 6 + from __future__ import annotations 7 + 8 + import datetime as dt 9 + import json 10 + from pathlib import Path 11 + 12 + from apps.observer.utils import list_observers 13 + 14 + from .common import ( 15 + InstallError, 16 + create_or_reuse_registration, 17 + default_server_url, 18 + default_stream, 19 + emit_json, 20 + marker_path, 21 + observer_key_prefix_from_config, 22 + poll_status_until, 23 + print_summary, 24 + read_marker, 25 + run_probe, 26 + run_step, 27 + write_marker, 28 + xdg_install_dir, 29 + ) 30 + 31 + PLATFORM = "tmux" 32 + INSTALL_NAME = "solstone-tmux" 33 + SOURCE_URL = "https://github.com/solpbc/solstone-tmux.git" 34 + UNIT_NAME = "solstone-tmux.service" 35 + CONFIG_PATH = ( 36 + Path.home() / ".local" / "share" / "solstone-tmux" / "config" / "config.json" 37 + ) 38 + DEFAULT_CONFIG = { 39 + "server_url": "", 40 + "key": "", 41 + "stream": "", 42 + "capture_interval": 5, 43 + "segment_interval": 300, 44 + "sync_retry_delays": [5, 30, 120, 300], 45 + "sync_max_retries": 10, 46 + "cache_retention_days": 7, 47 + "status_indicator": True, 48 + } 49 + 50 + 51 + class TmuxDriver: 52 + """Install and manage the tmux observer service.""" 53 + 54 + def run(self, args) -> int: 55 + server_url = default_server_url(args.server_url) 56 + name = default_stream("tmux", args.name) 57 + clone_dir = xdg_install_dir(INSTALL_NAME) 58 + marker = read_marker(INSTALL_NAME) 59 + if marker and marker.get("name") != name and not args.force: 60 + raise InstallError( 61 + f"{INSTALL_NAME} is already installed for {marker.get('name')}", 62 + hint="rerun with --force to replace the existing install marker", 63 + ) 64 + 65 + tool_statuses = _check_tools() 66 + tmux_present = run_probe(["sh", "-c", "command -v tmux"]).returncode == 0 67 + if args.dry_run: 68 + if not args.json_output: 69 + _print_dry_run(name, server_url, clone_dir, tool_statuses, tmux_present) 70 + if args.json_output: 71 + emit_json( 72 + _result(name, server_url, clone_dir, "planned", None, None, True) 73 + ) 74 + return 0 75 + 76 + _raise_for_preflight(tool_statuses) 77 + if not tmux_present and not args.json_output: 78 + print( 79 + "warning: tmux not detected on PATH; observer will start when tmux is launched" 80 + ) 81 + 82 + version, changed = _prepare_source(clone_dir, args) 83 + active = _active_registration(name) 84 + service_active = _service_is_active() 85 + config_prefix = observer_key_prefix_from_config(CONFIG_PATH) 86 + 87 + if ( 88 + marker 89 + and not args.force 90 + and not changed 91 + and active 92 + and config_prefix == active.get("key", "")[:8] 93 + ): 94 + if service_active: 95 + result = _result( 96 + name, 97 + server_url, 98 + clone_dir, 99 + "already_installed", 100 + active.get("key", "")[:8], 101 + version, 102 + False, 103 + ) 104 + _output_result(result, args.json_output) 105 + return 0 106 + run_step( 107 + f"restart {UNIT_NAME}", 108 + ["systemctl", "--user", "restart", UNIT_NAME], 109 + json_output=args.json_output, 110 + ) 111 + status = poll_status_until(name) 112 + result = _result( 113 + name, 114 + server_url, 115 + clone_dir, 116 + status, 117 + active.get("key", "")[:8], 118 + version, 119 + False, 120 + ) 121 + _output_result(result, args.json_output) 122 + return 0 123 + 124 + registration = create_or_reuse_registration(name, force=args.force) 125 + _write_config(server_url, registration.key, name) 126 + run_step( 127 + "run make install-service", 128 + ["make", "install-service"], 129 + cwd=clone_dir, 130 + json_output=args.json_output, 131 + ) 132 + run_step( 133 + f"restart {UNIT_NAME}", 134 + ["systemctl", "--user", "restart", UNIT_NAME], 135 + json_output=args.json_output, 136 + ) 137 + status = poll_status_until(name) 138 + version = _git_stdout(clone_dir, ["rev-parse", "HEAD"]) or version 139 + _write_install_marker(marker, name, version) 140 + result = _result( 141 + name, 142 + server_url, 143 + clone_dir, 144 + status, 145 + registration.prefix, 146 + version, 147 + False, 148 + ) 149 + _output_result(result, args.json_output) 150 + return 0 151 + 152 + 153 + def _check_tools() -> list[tuple[str, bool, str | None]]: 154 + checks = [ 155 + ( 156 + "git", 157 + ["sh", "-c", "command -v git"], 158 + "install git with your package manager", 159 + ), 160 + ("uv", ["sh", "-c", "command -v uv"], "install uv: https://docs.astral.sh/uv/"), 161 + ( 162 + "pipx", 163 + ["sh", "-c", "command -v pipx"], 164 + "install pipx with your package manager", 165 + ), 166 + ( 167 + "make", 168 + ["sh", "-c", "command -v make"], 169 + "install make with your package manager", 170 + ), 171 + ( 172 + "systemctl --user", 173 + ["systemctl", "--user", "--version"], 174 + "systemd user services are required for this observer", 175 + ), 176 + ] 177 + return [ 178 + (label, run_probe(cmd).returncode == 0, hint) for label, cmd, hint in checks 179 + ] 180 + 181 + 182 + def _raise_for_preflight(tool_statuses: list[tuple[str, bool, str | None]]) -> None: 183 + missing_tools = [(label, hint) for label, ok, hint in tool_statuses if not ok] 184 + if missing_tools: 185 + label, hint = missing_tools[0] 186 + raise InstallError(f"missing required tool: {label}", hint=hint) 187 + 188 + 189 + def _prepare_source(clone_dir: Path, args) -> tuple[str | None, bool]: 190 + if not clone_dir.exists(): 191 + run_step( 192 + f"clone {SOURCE_URL} into {clone_dir}", 193 + ["git", "clone", SOURCE_URL, str(clone_dir)], 194 + json_output=args.json_output, 195 + ) 196 + return _git_stdout(clone_dir, ["rev-parse", "HEAD"]), True 197 + 198 + if not (clone_dir / ".git").exists(): 199 + raise InstallError( 200 + f"{clone_dir} exists but is not a git repository", 201 + hint="move it aside or choose --force after restoring the observer clone", 202 + ) 203 + origin = _git_stdout(clone_dir, ["remote", "get-url", "origin"]) 204 + if origin != SOURCE_URL: 205 + raise InstallError( 206 + f"{clone_dir} has unexpected origin {origin}", 207 + hint=f"expected {SOURCE_URL}", 208 + ) 209 + dirty = run_probe(["git", "status", "--porcelain"], cwd=clone_dir) 210 + if dirty.stdout.strip(): 211 + raise InstallError( 212 + f"{clone_dir} has local changes", 213 + hint="commit, clean, or move the clone before rerunning install", 214 + ) 215 + 216 + run_step( 217 + "fetch observer updates", 218 + ["git", "fetch", "origin"], 219 + cwd=clone_dir, 220 + json_output=args.json_output, 221 + ) 222 + local = _git_stdout(clone_dir, ["rev-parse", "HEAD"]) 223 + upstream = _git_stdout(clone_dir, ["rev-parse", "@{u}"]) or _git_stdout( 224 + clone_dir, ["rev-parse", "origin/HEAD"] 225 + ) 226 + if not local or not upstream or local == upstream: 227 + return local, False 228 + 229 + ancestor = run_probe( 230 + ["git", "merge-base", "--is-ancestor", local, upstream], cwd=clone_dir 231 + ) 232 + if ancestor.returncode != 0: 233 + raise InstallError( 234 + f"{clone_dir} has diverged from upstream", 235 + hint="resolve the clone manually before rerunning install", 236 + ) 237 + run_step( 238 + "pull observer updates", 239 + ["git", "pull", "--ff-only"], 240 + cwd=clone_dir, 241 + json_output=args.json_output, 242 + ) 243 + return _git_stdout(clone_dir, ["rev-parse", "HEAD"]), True 244 + 245 + 246 + def _write_config(server_url: str, key: str, name: str) -> None: 247 + config = dict(DEFAULT_CONFIG) 248 + config.update({"server_url": server_url, "key": key, "stream": name}) 249 + try: 250 + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) 251 + with CONFIG_PATH.open("w", encoding="utf-8") as handle: 252 + json.dump(config, handle, indent=2) 253 + handle.write("\n") 254 + except OSError as exc: 255 + raise InstallError(f"failed to write {CONFIG_PATH}", hint=str(exc)) from exc 256 + 257 + 258 + def _write_install_marker(marker: dict | None, name: str, version: str | None) -> None: 259 + now = _now_utc() 260 + write_marker( 261 + INSTALL_NAME, 262 + { 263 + "name": name, 264 + "platform": PLATFORM, 265 + "source": SOURCE_URL, 266 + "installed_at": (marker.get("installed_at") if marker else None) or now, 267 + "last_run": now, 268 + "version": version, 269 + }, 270 + ) 271 + 272 + 273 + def _active_registration(name: str) -> dict | None: 274 + for observer in list_observers(): 275 + if observer.get("name") == name and not observer.get("revoked", False): 276 + return observer 277 + return None 278 + 279 + 280 + def _service_is_active() -> bool: 281 + process = run_probe(["systemctl", "--user", "is-active", UNIT_NAME]) 282 + return process.returncode == 0 and process.stdout.strip() == "active" 283 + 284 + 285 + def _git_stdout(cwd: Path, args: list[str]) -> str | None: 286 + process = run_probe(["git", *args], cwd=cwd) 287 + if process.returncode != 0: 288 + return None 289 + return process.stdout.strip() or None 290 + 291 + 292 + def _now_utc() -> str: 293 + return ( 294 + dt.datetime.now(dt.timezone.utc) 295 + .replace(microsecond=0) 296 + .isoformat() 297 + .replace("+00:00", "Z") 298 + ) 299 + 300 + 301 + def _result( 302 + name: str, 303 + server_url: str, 304 + clone_dir: Path, 305 + status: str, 306 + key_prefix: str | None, 307 + version: str | None, 308 + dry_run: bool, 309 + ) -> dict: 310 + return { 311 + "platform": PLATFORM, 312 + "name": name, 313 + "source_path": str(clone_dir), 314 + "service_unit": UNIT_NAME, 315 + "key_prefix": key_prefix, 316 + "server_url": server_url, 317 + "config_path": str(CONFIG_PATH), 318 + "marker_path": str(marker_path(INSTALL_NAME)), 319 + "status": status, 320 + "version": version, 321 + "dry_run": dry_run, 322 + } 323 + 324 + 325 + def _output_result(result: dict, json_output: bool) -> None: 326 + if json_output: 327 + emit_json(result) 328 + else: 329 + print_summary(result) 330 + 331 + 332 + def _print_dry_run( 333 + name: str, 334 + server_url: str, 335 + clone_dir: Path, 336 + tool_statuses: list[tuple[str, bool, str | None]], 337 + tmux_present: bool, 338 + ) -> None: 339 + print("Dry-run: would install solstone-tmux observer") 340 + print() 341 + print("Platform: tmux") 342 + print(f"Stream: {name}") 343 + print(f"Server: {server_url}") 344 + print(f"Source: {SOURCE_URL}") 345 + print(f"Target: {clone_dir}") 346 + print(f"Config: {CONFIG_PATH}") 347 + print(f"Service: {UNIT_NAME}") 348 + print(f"Marker: {marker_path(INSTALL_NAME)}") 349 + print() 350 + print("Preflight:") 351 + for label, ok, hint in tool_statuses: 352 + display = ( 353 + "systemctl --user available" 354 + if label == "systemctl --user" 355 + else f"{label} found" 356 + ) 357 + if ok: 358 + print(f" ✓ {display}") 359 + else: 360 + print(f" ✗ {label} missing") 361 + if hint: 362 + print(f" {hint}") 363 + if tmux_present: 364 + print(" ✓ tmux found") 365 + else: 366 + print(" ✗ tmux missing") 367 + print( 368 + " warning: tmux not detected on PATH; observer will start when tmux is launched" 369 + ) 370 + print() 371 + print("Plan:") 372 + print(f" would clone {SOURCE_URL} into {clone_dir}") 373 + print(f" would create observer registration '{name}'") 374 + print(f" would write {CONFIG_PATH}") 375 + print(" would run: make install-service") 376 + print(" would wait up to 30s for observer status") 377 + print(f" would write marker {marker_path(INSTALL_NAME)}") 378 + print() 379 + print("Summary:") 380 + print(" Key prefix: <not generated in dry-run>") 381 + print(f" Logs: journalctl --user -u {UNIT_NAME} -f") 382 + print(f" Status: sol observer status {name}") 383 + print() 384 + print("Dry-run complete; no files were written.")
+2
tests/observer_install/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc
+53
tests/observer_install/conftest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from pathlib import Path 7 + from types import SimpleNamespace 8 + 9 + import pytest 10 + 11 + 12 + @pytest.fixture(autouse=True) 13 + def observer_install_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): 14 + home = tmp_path / "home" 15 + journal = tmp_path / "journal" 16 + home.mkdir() 17 + journal.mkdir() 18 + monkeypatch.setattr(Path, "home", lambda: home) 19 + monkeypatch.setenv("HOME", str(home)) 20 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) 21 + 22 + import convey.state 23 + from observe.observer_install import linux, tmux 24 + 25 + convey.state.journal_root = "" 26 + monkeypatch.setattr( 27 + linux, 28 + "CONFIG_PATH", 29 + home / ".local" / "share" / "solstone-linux" / "config" / "config.json", 30 + ) 31 + monkeypatch.setattr( 32 + tmux, 33 + "CONFIG_PATH", 34 + home / ".local" / "share" / "solstone-tmux" / "config" / "config.json", 35 + ) 36 + return SimpleNamespace(home=home, journal=journal) 37 + 38 + 39 + @pytest.fixture 40 + def args_factory(): 41 + def build(**overrides): 42 + data = { 43 + "name": "archon", 44 + "platform": "linux", 45 + "server_url": "http://127.0.0.1:5015", 46 + "dry_run": False, 47 + "force": False, 48 + "json_output": False, 49 + } 50 + data.update(overrides) 51 + return SimpleNamespace(**data) 52 + 53 + return build
+43
tests/observer_install/snapshots/linux_dry_run.txt
··· 1 + Dry-run: would install solstone-linux observer 2 + 3 + Platform: linux 4 + Stream: archon 5 + Server: http://127.0.0.1:5015 6 + Source: https://github.com/solpbc/solstone-linux.git 7 + Target: /home/jer/.local/share/solstone/observers/solstone-linux 8 + Config: /home/jer/.local/share/solstone-linux/config/config.json 9 + Service: solstone-linux.service 10 + Marker: /home/jer/.local/share/solstone/observers/solstone-linux/.installed.json 11 + 12 + Preflight: 13 + ✓ git found 14 + ✓ uv found 15 + ✓ pipx found 16 + ✓ make found 17 + ✓ systemctl --user available 18 + ✓ distro detected: fedora 19 + ✓ package python3-gobject installed 20 + ✓ package gtk4 installed 21 + ✓ package gstreamer1-plugins-base installed 22 + ✓ package gstreamer1-plugin-pipewire installed 23 + ✓ package pipewire-gstreamer installed 24 + ✓ package alsa-lib-devel installed 25 + ✓ package pulseaudio-utils installed 26 + ✓ package pipewire-pulseaudio installed 27 + ✓ package xdg-desktop-portal installed 28 + ✓ package pipx installed 29 + 30 + Plan: 31 + would clone https://github.com/solpbc/solstone-linux.git into /home/jer/.local/share/solstone/observers/solstone-linux 32 + would create observer registration 'archon' 33 + would write /home/jer/.local/share/solstone-linux/config/config.json 34 + would run: make install-service 35 + would wait up to 30s for observer status 36 + would write marker /home/jer/.local/share/solstone/observers/solstone-linux/.installed.json 37 + 38 + Summary: 39 + Key prefix: <not generated in dry-run> 40 + Logs: journalctl --user -u solstone-linux.service -f 41 + Status: sol observer status archon 42 + 43 + Dry-run complete; no files were written.
+33
tests/observer_install/snapshots/tmux_dry_run.txt
··· 1 + Dry-run: would install solstone-tmux observer 2 + 3 + Platform: tmux 4 + Stream: archon 5 + Server: http://127.0.0.1:5015 6 + Source: https://github.com/solpbc/solstone-tmux.git 7 + Target: /home/jer/.local/share/solstone/observers/solstone-tmux 8 + Config: /home/jer/.local/share/solstone-tmux/config/config.json 9 + Service: solstone-tmux.service 10 + Marker: /home/jer/.local/share/solstone/observers/solstone-tmux/.installed.json 11 + 12 + Preflight: 13 + ✓ git found 14 + ✓ uv found 15 + ✓ pipx found 16 + ✓ make found 17 + ✓ systemctl --user available 18 + ✓ tmux found 19 + 20 + Plan: 21 + would clone https://github.com/solpbc/solstone-tmux.git into /home/jer/.local/share/solstone/observers/solstone-tmux 22 + would create observer registration 'archon' 23 + would write /home/jer/.local/share/solstone-tmux/config/config.json 24 + would run: make install-service 25 + would wait up to 30s for observer status 26 + would write marker /home/jer/.local/share/solstone/observers/solstone-tmux/.installed.json 27 + 28 + Summary: 29 + Key prefix: <not generated in dry-run> 30 + Logs: journalctl --user -u solstone-tmux.service -f 31 + Status: sol observer status archon 32 + 33 + Dry-run complete; no files were written.
+100
tests/observer_install/test_common.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import pytest 7 + 8 + from observe.observer_install import common 9 + 10 + 11 + def test_detect_platform_override(): 12 + assert common.detect_platform("linux") == "linux" 13 + 14 + 15 + @pytest.mark.parametrize( 16 + ("sys_platform", "expected"), 17 + [ 18 + ("linux", "linux"), 19 + ("linux2", "linux"), 20 + ("darwin", "macos"), 21 + ("win32", "unsupported"), 22 + ], 23 + ) 24 + def test_detect_platform_honors_sys_platform( 25 + monkeypatch: pytest.MonkeyPatch, sys_platform: str, expected: str 26 + ): 27 + monkeypatch.setattr(common.sys, "platform", sys_platform) 28 + assert common.detect_platform() == expected 29 + 30 + 31 + def test_default_server_url_explicit(): 32 + assert common.default_server_url("http://example.com") == "http://example.com" 33 + 34 + 35 + def test_default_server_url_reads_port_file(observer_install_env): 36 + health = observer_install_env.journal / "health" 37 + health.mkdir() 38 + (health / "convey.port").write_text("5015\n", encoding="utf-8") 39 + 40 + assert common.default_server_url(None) == "http://127.0.0.1:5015" 41 + 42 + 43 + def test_default_server_url_missing_port_raises(): 44 + with pytest.raises(common.InstallError) as exc_info: 45 + common.default_server_url(None) 46 + 47 + assert "could not determine solstone server URL" in str(exc_info.value) 48 + assert "pass --server-url" in exc_info.value.hint 49 + 50 + 51 + def test_normalize_stream_name(): 52 + assert common.normalize_stream_name("My-Host.local") == "my-host.local" 53 + 54 + 55 + def test_marker_path(observer_install_env): 56 + assert common.marker_path("solstone-linux") == ( 57 + observer_install_env.home 58 + / ".local" 59 + / "share" 60 + / "solstone" 61 + / "observers" 62 + / "solstone-linux" 63 + / ".installed.json" 64 + ) 65 + 66 + 67 + def test_marker_round_trip(): 68 + data = { 69 + "name": "archon", 70 + "platform": "linux", 71 + "source": "https://github.com/solpbc/solstone-linux.git", 72 + "installed_at": "2026-05-02T00:00:00Z", 73 + "last_run": "2026-05-02T00:00:00Z", 74 + "version": "abc123", 75 + } 76 + 77 + common.write_marker("solstone-linux", data) 78 + 79 + assert common.read_marker("solstone-linux") == data 80 + 81 + 82 + def test_find_marker_for_observer_matches_name(): 83 + common.write_marker( 84 + "solstone-linux", 85 + { 86 + "name": "archon", 87 + "platform": "linux", 88 + "source": "https://github.com/solpbc/solstone-linux.git", 89 + "installed_at": "2026-05-02T00:00:00Z", 90 + "last_run": "2026-05-02T00:00:00Z", 91 + "version": "abc123", 92 + }, 93 + ) 94 + 95 + result = common.find_marker_for_observer("archon") 96 + 97 + assert result is not None 98 + path, data = result 99 + assert path == common.marker_path("solstone-linux") 100 + assert data["name"] == "archon"
+66
tests/observer_install/test_dry_run.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import subprocess 7 + from pathlib import Path 8 + 9 + from observe.observer_install import linux, tmux 10 + 11 + 12 + def test_linux_dry_run_snapshot(monkeypatch, args_factory, capsys): 13 + monkeypatch.setattr(Path, "home", lambda: Path("/home/jer")) 14 + monkeypatch.setattr( 15 + linux, 16 + "CONFIG_PATH", 17 + Path("/home/jer/.local/share/solstone-linux/config/config.json"), 18 + ) 19 + monkeypatch.setattr(linux, "detect_distro", lambda: "fedora") 20 + monkeypatch.setattr( 21 + linux, 22 + "run_probe", 23 + lambda cmd, cwd=None: subprocess.CompletedProcess(cmd, 0, "ok\n", ""), 24 + ) 25 + 26 + assert linux.LinuxDriver().run(args_factory(dry_run=True)) == 0 27 + 28 + expected = Path("tests/observer_install/snapshots/linux_dry_run.txt").read_text( 29 + encoding="utf-8" 30 + ) 31 + assert capsys.readouterr().out == expected 32 + 33 + 34 + def test_tmux_dry_run_snapshot(monkeypatch, args_factory, capsys): 35 + monkeypatch.setattr(Path, "home", lambda: Path("/home/jer")) 36 + monkeypatch.setattr( 37 + tmux, 38 + "CONFIG_PATH", 39 + Path("/home/jer/.local/share/solstone-tmux/config/config.json"), 40 + ) 41 + monkeypatch.setattr( 42 + tmux, 43 + "run_probe", 44 + lambda cmd, cwd=None: subprocess.CompletedProcess(cmd, 0, "ok\n", ""), 45 + ) 46 + 47 + assert tmux.TmuxDriver().run(args_factory(platform="tmux", dry_run=True)) == 0 48 + 49 + expected = Path("tests/observer_install/snapshots/tmux_dry_run.txt").read_text( 50 + encoding="utf-8" 51 + ) 52 + assert capsys.readouterr().out == expected 53 + 54 + 55 + def test_dry_run_writes_no_files(monkeypatch, observer_install_env, args_factory): 56 + monkeypatch.setattr(linux, "detect_distro", lambda: "fedora") 57 + monkeypatch.setattr( 58 + linux, 59 + "run_probe", 60 + lambda cmd, cwd=None: subprocess.CompletedProcess(cmd, 0, "ok\n", ""), 61 + ) 62 + 63 + assert linux.LinuxDriver().run(args_factory(dry_run=True)) == 0 64 + 65 + assert not (observer_install_env.home / ".local" / "share" / "solstone").exists() 66 + assert not linux.CONFIG_PATH.exists()
+250
tests/observer_install/test_linux.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import subprocess 8 + from pathlib import Path 9 + 10 + import pytest 11 + 12 + from apps.observer.utils import list_observers, save_observer 13 + from observe.observer_install import common, linux 14 + 15 + 16 + @pytest.mark.parametrize( 17 + ("content", "expected"), 18 + [ 19 + ("ID=fedora\n", "fedora"), 20 + ("ID=ubuntu\n", "debian-ubuntu"), 21 + ("ID=arch\n", "arch"), 22 + ("ID=opensuse-tumbleweed\n", "opensuse"), 23 + ("ID=unknown\nID_LIKE=debian\n", "debian-ubuntu"), 24 + ], 25 + ) 26 + def test_detect_distro_from_os_release( 27 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, content: str, expected: str 28 + ): 29 + os_release = tmp_path / "os-release" 30 + os_release.write_text(content, encoding="utf-8") 31 + monkeypatch.setattr(linux, "OS_RELEASE_PATH", os_release) 32 + 33 + assert linux.detect_distro() == expected 34 + 35 + 36 + def test_missing_uv_raises(monkeypatch: pytest.MonkeyPatch, args_factory): 37 + monkeypatch.setattr(linux, "detect_distro", lambda: "fedora") 38 + 39 + def fake_probe(cmd, *, cwd=None): 40 + text = " ".join(cmd) 41 + code = 1 if "command -v uv" in text else 0 42 + return subprocess.CompletedProcess(cmd, code, "", "") 43 + 44 + monkeypatch.setattr(linux, "run_probe", fake_probe) 45 + 46 + with pytest.raises(common.InstallError) as exc_info: 47 + linux.LinuxDriver().run(args_factory()) 48 + 49 + assert "missing required tool: uv" in str(exc_info.value) 50 + assert "https://docs.astral.sh/uv/" in exc_info.value.hint 51 + 52 + 53 + def test_missing_system_package_reports_install_command( 54 + monkeypatch: pytest.MonkeyPatch, args_factory 55 + ): 56 + monkeypatch.setattr(linux, "detect_distro", lambda: "fedora") 57 + 58 + def fake_probe(cmd, *, cwd=None): 59 + text = " ".join(cmd) 60 + if text == "rpm -q gtk4": 61 + return subprocess.CompletedProcess(cmd, 1, "", "") 62 + return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 63 + 64 + monkeypatch.setattr(linux, "run_probe", fake_probe) 65 + 66 + with pytest.raises(common.InstallError) as exc_info: 67 + linux.LinuxDriver().run(args_factory()) 68 + 69 + assert "gtk4" in str(exc_info.value) 70 + assert ( 71 + "sudo dnf install python3-gobject gtk4 gstreamer1-plugins-base" 72 + in exc_info.value.hint 73 + ) 74 + 75 + 76 + def test_happy_path_writes_config_and_marker( 77 + monkeypatch: pytest.MonkeyPatch, args_factory 78 + ): 79 + monkeypatch.setattr(linux, "detect_distro", lambda: "fedora") 80 + monkeypatch.setattr(linux, "poll_status_until", lambda name: "connected") 81 + 82 + def fake_probe(cmd, *, cwd=None): 83 + text = " ".join(cmd) 84 + if text == "git rev-parse HEAD": 85 + return subprocess.CompletedProcess(cmd, 0, "abc123\n", "") 86 + if text == "git remote get-url origin": 87 + return subprocess.CompletedProcess(cmd, 0, f"{linux.SOURCE_URL}\n", "") 88 + return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 89 + 90 + steps: list[str] = [] 91 + 92 + def fake_step(label, cmd, **kwargs): 93 + steps.append(label) 94 + return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) 95 + 96 + monkeypatch.setattr(linux, "run_probe", fake_probe) 97 + monkeypatch.setattr(linux, "run_step", fake_step) 98 + 99 + assert linux.LinuxDriver().run(args_factory()) == 0 100 + 101 + assert "run make install-service" in steps 102 + config = json.loads(linux.CONFIG_PATH.read_text(encoding="utf-8")) 103 + assert config["server_url"] == "http://127.0.0.1:5015" 104 + assert config["stream"] == "archon" 105 + assert config["key"] 106 + marker = common.read_marker(linux.INSTALL_NAME) 107 + assert marker["name"] == "archon" 108 + assert marker["version"] == "abc123" 109 + 110 + 111 + def test_marker_present_no_upstream_changes_is_noop( 112 + monkeypatch: pytest.MonkeyPatch, observer_install_env, args_factory, capsys 113 + ): 114 + monkeypatch.setattr(linux, "detect_distro", lambda: "fedora") 115 + clone_dir = common.xdg_install_dir(linux.INSTALL_NAME) 116 + (clone_dir / ".git").mkdir(parents=True) 117 + common.write_marker( 118 + linux.INSTALL_NAME, 119 + { 120 + "name": "archon", 121 + "platform": "linux", 122 + "source": linux.SOURCE_URL, 123 + "installed_at": "2026-05-02T00:00:00Z", 124 + "last_run": "2026-05-02T00:00:00Z", 125 + "version": "abc123", 126 + }, 127 + ) 128 + save_observer( 129 + { 130 + "key": "abcdefgh", 131 + "name": "archon", 132 + "created_at": None, 133 + "last_seen": None, 134 + "last_segment": None, 135 + "enabled": True, 136 + "stats": {"segments_received": 0, "bytes_received": 0}, 137 + } 138 + ) 139 + linux.CONFIG_PATH.parent.mkdir(parents=True) 140 + linux.CONFIG_PATH.write_text('{"key": "abcdefgh"}\n', encoding="utf-8") 141 + 142 + def fake_probe(cmd, *, cwd=None): 143 + text = " ".join(cmd) 144 + if text == "git remote get-url origin": 145 + return subprocess.CompletedProcess(cmd, 0, f"{linux.SOURCE_URL}\n", "") 146 + if text == "git status --porcelain": 147 + return subprocess.CompletedProcess(cmd, 0, "", "") 148 + if text in {"git rev-parse HEAD", "git rev-parse @{u}"}: 149 + return subprocess.CompletedProcess(cmd, 0, "abc123\n", "") 150 + if text == f"systemctl --user is-active {linux.UNIT_NAME}": 151 + return subprocess.CompletedProcess(cmd, 0, "active\n", "") 152 + return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 153 + 154 + steps: list[str] = [] 155 + 156 + def fake_step(label, cmd, **kwargs): 157 + steps.append(label) 158 + return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) 159 + 160 + monkeypatch.setattr(linux, "run_probe", fake_probe) 161 + monkeypatch.setattr(linux, "run_step", fake_step) 162 + 163 + assert linux.LinuxDriver().run(args_factory()) == 0 164 + 165 + assert "run make install-service" not in steps 166 + assert "already installed" in capsys.readouterr().out 167 + assert common.read_marker(linux.INSTALL_NAME)["last_run"] == "2026-05-02T00:00:00Z" 168 + 169 + 170 + def test_second_run_after_install_is_noop( 171 + monkeypatch: pytest.MonkeyPatch, args_factory, capsys 172 + ): 173 + monkeypatch.setattr(linux, "detect_distro", lambda: "fedora") 174 + monkeypatch.setattr(linux, "poll_status_until", lambda name: "connected") 175 + 176 + def fake_probe(cmd, *, cwd=None): 177 + text = " ".join(cmd) 178 + if text == "git remote get-url origin": 179 + return subprocess.CompletedProcess(cmd, 0, f"{linux.SOURCE_URL}\n", "") 180 + if text == "git status --porcelain": 181 + return subprocess.CompletedProcess(cmd, 0, "", "") 182 + if text in {"git rev-parse HEAD", "git rev-parse @{u}"}: 183 + return subprocess.CompletedProcess(cmd, 0, "abc123\n", "") 184 + if text == f"systemctl --user is-active {linux.UNIT_NAME}": 185 + return subprocess.CompletedProcess(cmd, 0, "active\n", "") 186 + return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 187 + 188 + steps: list[str] = [] 189 + 190 + def fake_step(label, cmd, **kwargs): 191 + steps.append(label) 192 + if label.startswith("clone "): 193 + (common.xdg_install_dir(linux.INSTALL_NAME) / ".git").mkdir(parents=True) 194 + return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) 195 + 196 + config_writes = 0 197 + marker_writes = 0 198 + original_write_config = linux._write_config 199 + original_write_marker = linux.write_marker 200 + 201 + def count_config_write(server_url, key, name): 202 + nonlocal config_writes 203 + config_writes += 1 204 + original_write_config(server_url, key, name) 205 + 206 + def count_marker_write(install_name, data): 207 + nonlocal marker_writes 208 + marker_writes += 1 209 + original_write_marker(install_name, data) 210 + 211 + monkeypatch.setattr(linux, "run_probe", fake_probe) 212 + monkeypatch.setattr(linux, "run_step", fake_step) 213 + monkeypatch.setattr(linux, "_write_config", count_config_write) 214 + monkeypatch.setattr(linux, "write_marker", count_marker_write) 215 + 216 + assert linux.LinuxDriver().run(args_factory()) == 0 217 + assert linux.LinuxDriver().run(args_factory()) == 0 218 + 219 + assert steps.count("run make install-service") == 1 220 + assert config_writes == 1 221 + assert marker_writes == 1 222 + assert "already installed" in capsys.readouterr().out 223 + 224 + 225 + def test_force_revokes_and_recreates_registration(monkeypatch: pytest.MonkeyPatch): 226 + save_observer( 227 + { 228 + "key": "old-key", 229 + "name": "archon", 230 + "created_at": 1, 231 + "last_seen": None, 232 + "last_segment": None, 233 + "enabled": True, 234 + "stats": {"segments_received": 0, "bytes_received": 0}, 235 + } 236 + ) 237 + monkeypatch.setattr("observe.observer_cli._generate_key", lambda: "new-key") 238 + 239 + result = common.create_or_reuse_registration("archon", force=True) 240 + 241 + observers = list_observers() 242 + assert result.key == "new-key" 243 + assert any( 244 + observer.get("key") == "old-key" and observer.get("revoked") 245 + for observer in observers 246 + ) 247 + assert any( 248 + observer.get("key") == "new-key" and not observer.get("revoked") 249 + for observer in observers 250 + )
+25
tests/observer_install/test_macos.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from observe.observer_install.macos import REDIRECT_TEXT, MacosDriver 7 + 8 + 9 + def test_macos_redirect(args_factory, capsys): 10 + args = args_factory(platform="macos") 11 + 12 + assert MacosDriver().run(args) == 0 13 + 14 + assert capsys.readouterr().out.strip() == REDIRECT_TEXT 15 + 16 + 17 + def test_macos_dry_run_redirect(args_factory, capsys, observer_install_env): 18 + args = args_factory(platform="macos", dry_run=True) 19 + 20 + assert MacosDriver().run(args) == 0 21 + 22 + output = capsys.readouterr().out 23 + assert output.startswith("Dry-run: would direct you to download solstone-macos:") 24 + assert REDIRECT_TEXT in output 25 + assert not (observer_install_env.home / ".local" / "share" / "solstone").exists()
+66
tests/observer_install/test_status_extension.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import subprocess 7 + 8 + from apps.observer.utils import save_observer 9 + from observe import observer_cli 10 + from observe.observer_install import common, linux 11 + 12 + 13 + def _save_status_observer() -> None: 14 + save_observer( 15 + { 16 + "key": "abcdefgh", 17 + "name": "archon", 18 + "created_at": None, 19 + "last_seen": None, 20 + "last_segment": None, 21 + "enabled": True, 22 + "stats": {"segments_received": 0, "bytes_received": 0}, 23 + } 24 + ) 25 + 26 + 27 + def test_status_includes_install_marker(monkeypatch, capsys): 28 + _save_status_observer() 29 + common.write_marker( 30 + linux.INSTALL_NAME, 31 + { 32 + "name": "archon", 33 + "platform": "linux", 34 + "source": linux.SOURCE_URL, 35 + "installed_at": "2026-05-02T00:00:00Z", 36 + "last_run": "2026-05-02T00:00:00Z", 37 + "version": "abcdef1234567890", 38 + }, 39 + ) 40 + monkeypatch.setattr( 41 + common, 42 + "run_probe", 43 + lambda cmd: subprocess.CompletedProcess(cmd, 0, "active\n", ""), 44 + ) 45 + 46 + assert observer_cli._status_single("archon") == 0 47 + 48 + output = capsys.readouterr().out 49 + assert " Installed: 2026-05-02T00:00:00Z (linux, version abcdef123456)" in output 50 + assert f" Service: {linux.UNIT_NAME} — active" in output 51 + 52 + 53 + def test_status_without_marker_matches_baseline(capsys): 54 + _save_status_observer() 55 + 56 + assert observer_cli._status_single("archon") == 0 57 + 58 + assert capsys.readouterr().out == ( 59 + "Observer: archon\n" 60 + " Prefix: abcdefgh\n" 61 + " Status: disconnected\n" 62 + " Created: never\n" 63 + " Last seen: never\n" 64 + " Segments: 0\n" 65 + " Bytes: 0 B\n" 66 + )
+130
tests/observer_install/test_tmux.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import subprocess 8 + 9 + from observe.observer_install import common, tmux 10 + 11 + 12 + def test_tmux_happy_path_writes_config_and_marker(monkeypatch, args_factory): 13 + monkeypatch.setattr(tmux, "poll_status_until", lambda name: "connected") 14 + 15 + def fake_probe(cmd, *, cwd=None): 16 + text = " ".join(cmd) 17 + if text == "git rev-parse HEAD": 18 + return subprocess.CompletedProcess(cmd, 0, "tmuxsha\n", "") 19 + return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 20 + 21 + steps: list[str] = [] 22 + 23 + def fake_step(label, cmd, **kwargs): 24 + steps.append(label) 25 + return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) 26 + 27 + monkeypatch.setattr(tmux, "run_probe", fake_probe) 28 + monkeypatch.setattr(tmux, "run_step", fake_step) 29 + 30 + assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 31 + 32 + assert "run make install-service" in steps 33 + config = json.loads(tmux.CONFIG_PATH.read_text(encoding="utf-8")) 34 + assert config["stream"] == "archon" 35 + assert config["status_indicator"] is True 36 + assert common.read_marker(tmux.INSTALL_NAME)["version"] == "tmuxsha" 37 + 38 + 39 + def test_tmux_missing_tmux_warns_but_continues(monkeypatch, args_factory, capsys): 40 + monkeypatch.setattr(tmux, "poll_status_until", lambda name: "connected") 41 + 42 + def fake_probe(cmd, *, cwd=None): 43 + text = " ".join(cmd) 44 + if text == "sh -c command -v tmux": 45 + return subprocess.CompletedProcess(cmd, 1, "", "") 46 + if text == "git rev-parse HEAD": 47 + return subprocess.CompletedProcess(cmd, 0, "tmuxsha\n", "") 48 + return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 49 + 50 + monkeypatch.setattr(tmux, "run_probe", fake_probe) 51 + monkeypatch.setattr( 52 + tmux, 53 + "run_step", 54 + lambda label, cmd, **kwargs: common.StepResult( 55 + subprocess.CompletedProcess(cmd, 0, "", "") 56 + ), 57 + ) 58 + 59 + assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 60 + 61 + assert "warning: tmux not detected on PATH" in capsys.readouterr().out 62 + 63 + 64 + def test_tmux_missing_uv_raises(monkeypatch, args_factory): 65 + def fake_probe(cmd, *, cwd=None): 66 + text = " ".join(cmd) 67 + code = 1 if "command -v uv" in text else 0 68 + return subprocess.CompletedProcess(cmd, code, "", "") 69 + 70 + monkeypatch.setattr(tmux, "run_probe", fake_probe) 71 + 72 + try: 73 + tmux.TmuxDriver().run(args_factory(platform="tmux")) 74 + except common.InstallError as exc: 75 + assert "missing required tool: uv" in str(exc) 76 + assert "https://docs.astral.sh/uv/" in exc.hint 77 + else: 78 + raise AssertionError("expected InstallError") 79 + 80 + 81 + def test_tmux_second_run_after_install_is_noop(monkeypatch, args_factory, capsys): 82 + monkeypatch.setattr(tmux, "poll_status_until", lambda name: "connected") 83 + 84 + def fake_probe(cmd, *, cwd=None): 85 + text = " ".join(cmd) 86 + if text == "git remote get-url origin": 87 + return subprocess.CompletedProcess(cmd, 0, f"{tmux.SOURCE_URL}\n", "") 88 + if text == "git status --porcelain": 89 + return subprocess.CompletedProcess(cmd, 0, "", "") 90 + if text in {"git rev-parse HEAD", "git rev-parse @{u}"}: 91 + return subprocess.CompletedProcess(cmd, 0, "tmuxsha\n", "") 92 + if text == f"systemctl --user is-active {tmux.UNIT_NAME}": 93 + return subprocess.CompletedProcess(cmd, 0, "active\n", "") 94 + return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 95 + 96 + steps: list[str] = [] 97 + 98 + def fake_step(label, cmd, **kwargs): 99 + steps.append(label) 100 + if label.startswith("clone "): 101 + (common.xdg_install_dir(tmux.INSTALL_NAME) / ".git").mkdir(parents=True) 102 + return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) 103 + 104 + config_writes = 0 105 + marker_writes = 0 106 + original_write_config = tmux._write_config 107 + original_write_marker = tmux.write_marker 108 + 109 + def count_config_write(server_url, key, name): 110 + nonlocal config_writes 111 + config_writes += 1 112 + original_write_config(server_url, key, name) 113 + 114 + def count_marker_write(install_name, data): 115 + nonlocal marker_writes 116 + marker_writes += 1 117 + original_write_marker(install_name, data) 118 + 119 + monkeypatch.setattr(tmux, "run_probe", fake_probe) 120 + monkeypatch.setattr(tmux, "run_step", fake_step) 121 + monkeypatch.setattr(tmux, "_write_config", count_config_write) 122 + monkeypatch.setattr(tmux, "write_marker", count_marker_write) 123 + 124 + assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 125 + assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 126 + 127 + assert steps.count("run make install-service") == 1 128 + assert config_writes == 1 129 + assert marker_writes == 1 130 + assert "already installed" in capsys.readouterr().out