personal memory agent
0
fork

Configure Feed

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

doctor: add pre-install diagnostic and wire into install / install-service

scripts/doctor.py is stdlib-only and runs on system python before `uv
sync` has ever run. It implements a 14-check battery (python/uv/venv
consistency, sol importability, npx availability, port 5015 ownership
by executable path, disk space, config-dir writability, alias symlink
state, plus macOS-only advisories) with --verbose / --json / --port
flags and blocker-only exit semantics.

Makefile: add `doctor` target (python3, not $(PYTHON)) and wire it as
the first regular prerequisite of `install` and `install-service`, so
sequential make evaluation runs doctor before `.installed`'s `uv
sync`. The top-level uv guard now skips doctor-only invocations via a
MAKECMDGOALS filter so a uv-less machine can still run diagnostics.

think/install_guard.py: guard the `import userpath` at module top
with try/except so scripts/doctor.py can import check_alias() from
system python; _ensure_user_bin_on_path hard-fails if reached without
userpath (only possible from outside the venv, which cmd_install
never is).

Decisions recorded in scripts/doctor.py's module docstring: uv floor
0.7.12, disk threshold 10 GiB, MAKECMDGOALS-filter UV-guard strategy.

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

+1572 -4
+11 -3
Makefile
··· 7 7 # all runs to one path and pytest wipes it on startup, destroying concurrent state. 8 8 export TMPDIR := /var/tmp 9 9 10 - .PHONY: install uninstall test test-apps test-app test-only test-integration test-integration-only test-all format format-check install-checks ci clean clean-install coverage watch versions update update-prices pre-commit skills dev all sandbox sandbox-stop install-pinchtab verify-browser update-browser-baselines review verify verify-api update-api-baselines install-service uninstall-service service-logs gate-agents-rename check-layer-hygiene 10 + .PHONY: install uninstall test test-apps test-app test-only test-integration test-integration-only test-all format format-check install-checks ci clean clean-install coverage watch versions update update-prices pre-commit skills dev all sandbox sandbox-stop install-pinchtab verify-browser update-browser-baselines review verify verify-api update-api-baselines install-service uninstall-service service-logs gate-agents-rename check-layer-hygiene doctor 11 11 12 12 # Default target - install package in editable mode 13 13 all: install ··· 19 19 20 20 # Require uv 21 21 UV := $(shell command -v uv 2>/dev/null) 22 + ifeq (,$(filter-out doctor,$(or $(MAKECMDGOALS),all))) 23 + # doctor-only invocation — skip uv requirement so a uv-less machine can run diagnostics 24 + else 22 25 ifndef UV 23 26 $(error uv is not installed. Install it: curl -LsSf https://astral.sh/uv/install.sh | sh) 27 + endif 24 28 endif 25 29 26 30 # Node — add nvm bin dir to PATH if npx isn't already available ··· 52 56 $(UV) lock 53 57 54 58 # Install package in editable mode with isolated venv 55 - install: skills .installed 59 + install: doctor skills .installed 56 60 57 61 # Directories where AI coding agents look for skills 58 62 SKILL_DIRS := journal/.agents/skills journal/.claude/skills ··· 391 395 find . -type f -name ".DS_Store" -delete 392 396 rm -f .installed 393 397 398 + # Pre-install diagnostic — stdlib-only; runs on system python without uv/venv 399 + doctor: 400 + @python3 scripts/doctor.py $(if $(VERBOSE),--verbose) $(if $(JSON),--json) $(if $(PORT),--port $(PORT)) 401 + 394 402 # Service management (override port: make install-service PORT=8000) 395 - install-service: .installed 403 + install-service: doctor .installed 396 404 @MODE=$$($(PYTHON) -m think.install_guard check); \ 397 405 RC=$$?; \ 398 406 case "$$MODE" in \
+896
scripts/doctor.py
··· 1 + #!/usr/bin/env python3 2 + # SPDX-License-Identifier: AGPL-3.0-only 3 + # Copyright (c) 2026 sol pbc 4 + 5 + """Pre-install diagnostics for solstone. 6 + 7 + Runs a fixed battery of blocker and advisory checks using only the Python 8 + standard library so a fresh clone can be diagnosed before `uv sync`. Exit code 9 + `0` means no blocker failed; exit code `1` means at least one blocker-severity 10 + check failed. 11 + 12 + Decision log: 13 + - uv floor: 0.7.12 — `uv.lock` revision=3 requires >= 0.7.12 per 14 + astral-sh/uv#15220. 15 + - disk threshold: 10 GiB — measured `.venv`=7.88 GiB + playwright=0.61 GiB + 16 + uv-cache first-install growth ~1 GiB + buffer. 17 + - Makefile UV-guard strategy: MAKECMDGOALS filter; prep verified the 18 + doctor-only matrix on GNU make. 19 + - Ramon triage docs are absent in this worktree; the battery follows the task 20 + spec directly. 21 + """ 22 + 23 + from __future__ import annotations 24 + 25 + import argparse 26 + import importlib 27 + import json 28 + import os 29 + import plistlib 30 + import re 31 + import shutil 32 + import subprocess 33 + import sys 34 + from dataclasses import dataclass 35 + from pathlib import Path 36 + from typing import Callable, Literal, Sequence 37 + 38 + ROOT = Path(__file__).resolve().parent.parent 39 + MIN_UV = (0, 7, 12) 40 + MIN_FREE_GIB = 10.0 41 + 42 + Severity = Literal["blocker", "advisory"] 43 + Status = Literal["ok", "fail", "warn", "skip"] 44 + Platform = Literal["linux", "darwin"] 45 + 46 + 47 + @dataclass(frozen=True) 48 + class Args: 49 + verbose: bool 50 + json: bool 51 + port: int 52 + 53 + 54 + @dataclass(frozen=True) 55 + class Check: 56 + name: str 57 + severity: Severity 58 + platforms: tuple[Platform, ...] 59 + 60 + 61 + @dataclass(frozen=True) 62 + class CheckResult: 63 + name: str 64 + severity: Severity 65 + status: Status 66 + detail: str 67 + fix: str | None 68 + platform: str | None = None 69 + 70 + 71 + @dataclass(frozen=True) 72 + class ProbeOutput: 73 + stdout: str 74 + stderr: str 75 + returncode: int 76 + 77 + 78 + def platform_tag() -> Platform: 79 + if sys.platform == "darwin": 80 + return "darwin" 81 + return "linux" 82 + 83 + 84 + def make_result( 85 + check: Check, 86 + status: Status, 87 + detail: str, 88 + fix: str | None = None, 89 + *, 90 + platform: str | None = None, 91 + ) -> CheckResult: 92 + return CheckResult( 93 + name=check.name, 94 + severity=check.severity, 95 + status=status, 96 + detail=detail, 97 + fix=fix, 98 + platform=platform, 99 + ) 100 + 101 + 102 + def truncate(text: str, limit: int) -> str: 103 + text = " ".join(text.split()) 104 + if len(text) <= limit: 105 + return text 106 + return text[: limit - 3] + "..." 107 + 108 + 109 + def version_text(version: tuple[int, int, int]) -> str: 110 + return ".".join(str(part) for part in version) 111 + 112 + 113 + def parse_version(text: str) -> tuple[int, int, int] | None: 114 + match = re.search(r"(\d+)\.(\d+)\.(\d+)", text) 115 + if not match: 116 + return None 117 + return tuple(int(part) for part in match.groups()) 118 + 119 + 120 + def compare_versions(left: tuple[int, int, int], right: tuple[int, int, int]) -> int: 121 + if left < right: 122 + return -1 123 + if left > right: 124 + return 1 125 + return 0 126 + 127 + 128 + def unexpected_output_result( 129 + check: Check, 130 + output: str, 131 + *, 132 + fix: str | None = None, 133 + ) -> CheckResult: 134 + snippet = truncate(output or "<empty>", 80) 135 + return make_result( 136 + check, 137 + "fail", 138 + f"probe returned unexpected output: {snippet}", 139 + fix, 140 + ) 141 + 142 + 143 + def command_text(cmd: Sequence[str]) -> str: 144 + return " ".join(cmd) 145 + 146 + 147 + def run_probe( 148 + check: Check, 149 + cmd: Sequence[str], 150 + *, 151 + timeout: float, 152 + cwd: Path | None = None, 153 + env: dict[str, str] | None = None, 154 + ok_returncodes: tuple[int, ...] = (0,), 155 + allow_nonzero: bool = False, 156 + allow_empty_stdout: bool = False, 157 + fix: str | None = None, 158 + ) -> ProbeOutput | CheckResult: 159 + merged_env = os.environ.copy() 160 + if env: 161 + merged_env.update(env) 162 + try: 163 + completed = subprocess.run( 164 + list(cmd), 165 + capture_output=True, 166 + text=True, 167 + timeout=timeout, 168 + cwd=str(cwd) if cwd else None, 169 + env=merged_env, 170 + check=False, 171 + ) 172 + except FileNotFoundError: 173 + return make_result(check, "fail", f"probe command not found: {cmd[0]}", fix) 174 + except subprocess.TimeoutExpired: 175 + return make_result( 176 + check, 177 + "fail", 178 + f"probe timed out after {timeout:g}s: {command_text(cmd)}", 179 + fix, 180 + ) 181 + except OSError as exc: 182 + return make_result( 183 + check, 184 + "fail", 185 + f"probe failed: {type(exc).__name__}: {exc}", 186 + fix, 187 + ) 188 + 189 + if completed.returncode not in ok_returncodes and not allow_nonzero: 190 + detail = completed.stderr.strip() or completed.stdout.strip() or "<empty>" 191 + return make_result( 192 + check, 193 + "fail", 194 + f"probe exited {completed.returncode}: {truncate(detail, 80)}", 195 + fix, 196 + ) 197 + 198 + if not allow_empty_stdout and not completed.stdout.strip(): 199 + return unexpected_output_result( 200 + check, 201 + completed.stderr.strip() or completed.stdout.strip(), 202 + fix=fix, 203 + ) 204 + 205 + return ProbeOutput( 206 + stdout=completed.stdout, 207 + stderr=completed.stderr, 208 + returncode=completed.returncode, 209 + ) 210 + 211 + 212 + def python_version_check(args: Args) -> CheckResult: 213 + del args 214 + check = CHECK_MAP["python_version"] 215 + pyproject = ROOT / "pyproject.toml" 216 + try: 217 + text = pyproject.read_text(encoding="utf-8") 218 + except OSError as exc: 219 + return make_result( 220 + check, 221 + "fail", 222 + f"could not read {pyproject.name}: {type(exc).__name__}: {exc}", 223 + "install Python >=3.10, then retry", 224 + ) 225 + match = re.search(r'^requires-python\s*=\s*"([^"]+)"', text, re.MULTILINE) 226 + if not match: 227 + return make_result( 228 + check, 229 + "fail", 230 + "could not parse requires-python from pyproject.toml", 231 + "install Python >=3.10, then retry", 232 + ) 233 + spec = match.group(1) 234 + min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) 235 + if not min_match: 236 + return make_result( 237 + check, 238 + "fail", 239 + f"unsupported requires-python specifier: {spec}", 240 + "install Python >=3.10, then retry", 241 + ) 242 + minimum = ( 243 + int(min_match.group(1)), 244 + int(min_match.group(2)), 245 + int(min_match.group(3) or 0), 246 + ) 247 + current = sys.version_info[:3] 248 + if compare_versions(current, minimum) < 0: 249 + return make_result( 250 + check, 251 + "fail", 252 + f"python {version_text(current)} does not satisfy {spec}", 253 + "install Python >=3.10, then `rm -rf .venv .installed && make install`", 254 + ) 255 + return make_result( 256 + check, 257 + "ok", 258 + f"python {version_text(current)} satisfies {spec}", 259 + ) 260 + 261 + 262 + def uv_installed_check(args: Args) -> CheckResult: 263 + del args 264 + check = CHECK_MAP["uv_installed"] 265 + fix = "curl -LsSf https://astral.sh/uv/install.sh | sh" 266 + probe = run_probe(check, ["uv", "--version"], timeout=0.5, fix=fix) 267 + if isinstance(probe, CheckResult): 268 + return probe 269 + version = parse_version(probe.stdout) 270 + if version is None: 271 + return unexpected_output_result(check, probe.stdout, fix=fix) 272 + if compare_versions(version, MIN_UV) < 0: 273 + return make_result( 274 + check, 275 + "fail", 276 + f"uv {version_text(version)} is older than required {version_text(MIN_UV)}", 277 + fix, 278 + ) 279 + return make_result( 280 + check, 281 + "ok", 282 + f"uv {version_text(version)} >= {version_text(MIN_UV)}", 283 + ) 284 + 285 + 286 + def venv_consistent_check(args: Args) -> CheckResult: 287 + del args 288 + check = CHECK_MAP["venv_consistent"] 289 + python_bin = ROOT / ".venv" / "bin" / "python" 290 + expected = (ROOT / ".venv").resolve() 291 + if not python_bin.exists(): 292 + return make_result( 293 + check, 294 + "skip", 295 + ".venv absent; run make install", 296 + ) 297 + probe = run_probe( 298 + check, 299 + [str(python_bin), "-c", "import sys; print(sys.prefix)"], 300 + timeout=0.5, 301 + fix="rm -rf .venv .installed && make install", 302 + ) 303 + if isinstance(probe, CheckResult): 304 + return probe 305 + prefix_text = probe.stdout.strip() 306 + if not prefix_text: 307 + return unexpected_output_result( 308 + check, 309 + probe.stdout, 310 + fix="rm -rf .venv .installed && make install", 311 + ) 312 + actual = Path(prefix_text).resolve() 313 + if actual != expected: 314 + return make_result( 315 + check, 316 + "fail", 317 + f".venv points at {actual}, expected {expected}", 318 + "rm -rf .venv .installed && make install", 319 + ) 320 + return make_result(check, "ok", f".venv points at this repo ({expected})") 321 + 322 + 323 + def sol_importable_check(args: Args) -> CheckResult: 324 + del args 325 + check = CHECK_MAP["sol_importable"] 326 + python_bin = ROOT / ".venv" / "bin" / "python" 327 + fix = "rm -rf .venv .installed && make install" 328 + if not python_bin.exists(): 329 + return make_result(check, "skip", ".venv absent; run make install") 330 + probe = run_probe( 331 + check, 332 + [str(python_bin), "-c", "import sol"], 333 + cwd=Path("/"), 334 + timeout=2.0, 335 + allow_nonzero=True, 336 + allow_empty_stdout=True, 337 + fix=fix, 338 + ) 339 + if isinstance(probe, CheckResult): 340 + return probe 341 + if probe.returncode == 0: 342 + return make_result(check, "ok", "import sol succeeded outside repo cwd") 343 + stderr = probe.stderr.strip() 344 + if "ModuleNotFoundError: No module named 'sol'" in stderr: 345 + return make_result( 346 + check, 347 + "fail", 348 + "ModuleNotFoundError: No module named 'sol'", 349 + fix, 350 + ) 351 + first_line = next((line for line in stderr.splitlines() if line.strip()), "") 352 + detail = truncate( 353 + first_line or f"import sol failed with exit {probe.returncode}", 120 354 + ) 355 + return make_result(check, "fail", detail, fix) 356 + 357 + 358 + def npx_on_path_check(args: Args) -> CheckResult: 359 + del args 360 + check = CHECK_MAP["npx_on_path"] 361 + npx = shutil.which("npx") 362 + if npx is None: 363 + return make_result( 364 + check, 365 + "fail", 366 + "npx not found on PATH", 367 + "install Node/npm so `npx` is on PATH, then rerun doctor", 368 + ) 369 + return make_result(check, "ok", f"npx on PATH at {npx}") 370 + 371 + 372 + def npx_non_interactive_check(args: Args) -> CheckResult: 373 + del args 374 + check = CHECK_MAP["npx_non_interactive"] 375 + fix = "repair npm/npx, then rerun `CI=true npx --yes --version`" 376 + probe = run_probe( 377 + check, 378 + ["npx", "--yes", "--version"], 379 + timeout=2.0, 380 + env={"CI": "true"}, 381 + fix=fix, 382 + ) 383 + if isinstance(probe, CheckResult): 384 + return probe 385 + if not probe.stdout.strip(): 386 + return unexpected_output_result(check, probe.stdout, fix=fix) 387 + return make_result(check, "ok", "npx --yes is non-interactive") 388 + 389 + 390 + def resolve_alias_target() -> Path | None: 391 + alias = Path.home() / ".local" / "bin" / "sol" 392 + if not alias.is_symlink(): 393 + return None 394 + target = Path(os.readlink(alias)) 395 + if not target.is_absolute(): 396 + target = alias.parent / target 397 + return target.resolve() 398 + 399 + 400 + def resolve_darwin_exe( 401 + check: Check, 402 + pid: int, 403 + *, 404 + expected_repo_sol: Path, 405 + alias_target: Path | None, 406 + ) -> Path | CheckResult: 407 + probe = run_probe( 408 + check, 409 + ["lsof", "-p", str(pid), "-Fn"], 410 + timeout=1.0, 411 + allow_empty_stdout=False, 412 + fix=f"kill {pid} # or run 'sol service stop' if this is your install", 413 + ) 414 + if isinstance(probe, CheckResult): 415 + return probe 416 + paths: list[Path] = [] 417 + for line in probe.stdout.splitlines(): 418 + if not line.startswith("n"): 419 + continue 420 + value = line[1:].strip() 421 + if not value.startswith("/"): 422 + continue 423 + paths.append(Path(value).resolve()) 424 + if not paths: 425 + return make_result( 426 + check, 427 + "fail", 428 + f"could not verify ownership (pid={pid}): no executable path from lsof", 429 + f"kill {pid} # or run 'sol service stop' if this is your install", 430 + ) 431 + for candidate in paths: 432 + if candidate == expected_repo_sol or candidate == alias_target: 433 + return candidate 434 + sol_paths = [candidate for candidate in paths if candidate.name == "sol"] 435 + if len(sol_paths) == 1: 436 + return sol_paths[0] 437 + return make_result( 438 + check, 439 + "fail", 440 + f"could not verify ownership (pid={pid}): ambiguous executable paths", 441 + f"kill {pid} # or run 'sol service stop' if this is your install", 442 + ) 443 + 444 + 445 + def port_5015_free_check(args: Args) -> CheckResult: 446 + check = CHECK_MAP["port_5015_free"] 447 + port = args.port 448 + if shutil.which("lsof") is None: 449 + return make_result( 450 + check, 451 + "skip", 452 + "lsof not available; cannot probe port ownership", 453 + ) 454 + probe = run_probe( 455 + check, 456 + ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-Fpn"], 457 + timeout=1.0, 458 + ok_returncodes=(0, 1), 459 + allow_empty_stdout=True, 460 + fix="kill <pid> # or run 'sol service stop' if this is your install", 461 + ) 462 + if isinstance(probe, CheckResult): 463 + return probe 464 + pids = [ 465 + line[1:].strip() for line in probe.stdout.splitlines() if line.startswith("p") 466 + ] 467 + if not pids: 468 + return make_result(check, "ok", f"port {port} is free") 469 + pid_text = pids[0] 470 + try: 471 + pid = int(pid_text) 472 + except ValueError: 473 + return unexpected_output_result( 474 + check, 475 + probe.stdout, 476 + fix="kill <pid> # or run 'sol service stop' if this is your install", 477 + ) 478 + expected_repo_sol = (ROOT / ".venv" / "bin" / "sol").resolve() 479 + alias_target = resolve_alias_target() 480 + if platform_tag() == "darwin": 481 + resolved = resolve_darwin_exe( 482 + check, 483 + pid, 484 + expected_repo_sol=expected_repo_sol, 485 + alias_target=alias_target, 486 + ) 487 + if isinstance(resolved, CheckResult): 488 + return resolved 489 + exe_path = resolved 490 + else: 491 + try: 492 + exe_path = Path(os.readlink(f"/proc/{pid}/exe")).resolve() 493 + except OSError as exc: 494 + return make_result( 495 + check, 496 + "fail", 497 + f"could not verify ownership (pid={pid}): {type(exc).__name__}: {exc}", 498 + f"kill {pid} # or run 'sol service stop' if this is your install", 499 + ) 500 + if exe_path == expected_repo_sol: 501 + return make_result( 502 + check, 503 + "ok", 504 + f"port {port} owned by this repo's solstone ({exe_path})", 505 + ) 506 + if alias_target is not None and exe_path == alias_target: 507 + return make_result( 508 + check, 509 + "ok", 510 + f"port {port} owned by installed solstone ({exe_path})", 511 + ) 512 + return make_result( 513 + check, 514 + "fail", 515 + f"port {port} held by pid {pid} ({exe_path})", 516 + f"kill {pid} # or run 'sol service stop' if this is your install", 517 + ) 518 + 519 + 520 + def disk_space_check(args: Args) -> CheckResult: 521 + del args 522 + check = CHECK_MAP["disk_space"] 523 + usage = shutil.disk_usage(ROOT) 524 + free_gib = usage.free / (1024**3) 525 + if free_gib < MIN_FREE_GIB: 526 + return make_result( 527 + check, 528 + "warn", 529 + f"only {free_gib:.1f} GiB free on the repo filesystem (<{MIN_FREE_GIB:.0f} GiB)", 530 + "free disk on the repo filesystem before `make install`", 531 + ) 532 + return make_result( 533 + check, 534 + "ok", 535 + f"{free_gib:.1f} GiB free (>= {MIN_FREE_GIB:.0f} GiB)", 536 + ) 537 + 538 + 539 + def config_dir_readable_check(args: Args) -> CheckResult: 540 + del args 541 + check = CHECK_MAP["config_dir_readable"] 542 + home = Path.home() 543 + if not home.exists(): 544 + return make_result( 545 + check, 546 + "fail", 547 + f"home directory does not exist: {home}", 548 + f"fix ownership/permissions of {home}", 549 + ) 550 + required_access = os.R_OK | os.W_OK | os.X_OK 551 + if not os.access(home, required_access): 552 + return make_result( 553 + check, 554 + "fail", 555 + f"home directory is not readable and writable: {home}", 556 + f"fix ownership/permissions of {home}", 557 + ) 558 + current_platform = platform_tag() 559 + if current_platform == "darwin": 560 + config_dir = home / "Library" / "LaunchAgents" 561 + else: 562 + config_dir = home / ".config" 563 + if config_dir.exists() and not os.access(config_dir, required_access): 564 + return make_result( 565 + check, 566 + "fail", 567 + f"service config directory is not writable: {config_dir}", 568 + f"fix ownership/permissions of {config_dir}", 569 + ) 570 + if config_dir.exists(): 571 + detail = f"home and service config dir are writable ({config_dir})" 572 + else: 573 + detail = f"home is writable; install will create {config_dir}" 574 + return make_result(check, "ok", detail) 575 + 576 + 577 + def import_install_guard() -> tuple[object, object]: 578 + root_text = str(ROOT) 579 + if root_text not in sys.path: 580 + sys.path.insert(0, root_text) 581 + module = importlib.import_module("think.install_guard") 582 + return module.AliasState, module.check_alias 583 + 584 + 585 + def stale_alias_symlink_check(args: Args) -> CheckResult: 586 + del args 587 + check = CHECK_MAP["stale_alias_symlink"] 588 + try: 589 + alias_state_cls, check_alias = import_install_guard() 590 + except Exception as exc: 591 + return make_result( 592 + check, 593 + "skip", 594 + f"could not import think.install_guard: {type(exc).__name__}: {exc}", 595 + ) 596 + state, other = check_alias(ROOT) 597 + worktree = alias_state_cls.WORKTREE 598 + absent = alias_state_cls.ABSENT 599 + owned = alias_state_cls.OWNED 600 + cross_repo = alias_state_cls.CROSS_REPO 601 + dangling = alias_state_cls.DANGLING 602 + not_symlink = alias_state_cls.NOT_SYMLINK 603 + if state is worktree: 604 + return make_result( 605 + check, 606 + "skip", 607 + "git worktree; run doctor from the primary clone", 608 + ) 609 + if state in {absent, owned}: 610 + return make_result( 611 + check, 612 + "ok", 613 + "sol alias absent or owned by this repo", 614 + ) 615 + if state is cross_repo: 616 + detail = f"~/.local/bin/sol points at another repo ({other})" 617 + elif state is dangling: 618 + detail = f"~/.local/bin/sol is dangling ({other})" 619 + elif state is not_symlink: 620 + detail = "~/.local/bin/sol exists but is not a symlink" 621 + else: 622 + detail = f"unexpected alias state: {state}" 623 + return make_result( 624 + check, 625 + "fail", 626 + detail, 627 + "run `make uninstall-service` from the installed repo, or remove `~/.local/bin/sol` manually if the repo is gone", 628 + ) 629 + 630 + 631 + def macos_firewall_localhost_check(args: Args) -> CheckResult: 632 + del args 633 + check = CHECK_MAP["macos_firewall_localhost"] 634 + if platform_tag() != "darwin": 635 + return make_result(check, "skip", "not supported on linux", platform="linux") 636 + tool = "/usr/libexec/ApplicationFirewall/socketfilterfw" 637 + if not Path(tool).exists(): 638 + return make_result(check, "skip", "socketfilterfw not available") 639 + global_probe = run_probe( 640 + check, 641 + [tool, "--getglobalstate"], 642 + timeout=1.0, 643 + fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 644 + ) 645 + if isinstance(global_probe, CheckResult): 646 + return global_probe 647 + global_text = global_probe.stdout.lower() 648 + if "disabled" in global_text or "state = 0" in global_text: 649 + return make_result( 650 + check, 651 + "ok", 652 + "firewall settings will not block localhost service access", 653 + ) 654 + if "enabled" not in global_text and "state = 1" not in global_text: 655 + return unexpected_output_result( 656 + check, 657 + global_probe.stdout, 658 + fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 659 + ) 660 + block_probe = run_probe( 661 + check, 662 + [tool, "--getblockall"], 663 + timeout=1.0, 664 + fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 665 + ) 666 + if isinstance(block_probe, CheckResult): 667 + return block_probe 668 + block_text = block_probe.stdout.lower() 669 + if "enabled" in block_text or "state = 1" in block_text: 670 + return make_result( 671 + check, 672 + "warn", 673 + "firewall enabled with block-all incoming; localhost service access may fail", 674 + "sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 675 + ) 676 + if "disabled" in block_text or "state = 0" in block_text: 677 + return make_result( 678 + check, 679 + "ok", 680 + "firewall enabled but block-all incoming is off", 681 + ) 682 + return unexpected_output_result( 683 + check, 684 + block_probe.stdout, 685 + fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 686 + ) 687 + 688 + 689 + def launchd_stale_plist_check(args: Args) -> CheckResult: 690 + del args 691 + check = CHECK_MAP["launchd_stale_plist"] 692 + if platform_tag() != "darwin": 693 + return make_result(check, "skip", "not supported on linux", platform="linux") 694 + plist_path = Path.home() / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" 695 + if not plist_path.exists(): 696 + return make_result(check, "skip", "launchd plist absent") 697 + try: 698 + with plist_path.open("rb") as handle: 699 + data = plistlib.load(handle) 700 + except Exception as exc: 701 + return make_result( 702 + check, 703 + "fail", 704 + f"could not parse plist: {type(exc).__name__}: {exc}", 705 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 706 + ) 707 + program_arguments = data.get("ProgramArguments") 708 + if not isinstance(program_arguments, list) or not program_arguments: 709 + return make_result( 710 + check, 711 + "fail", 712 + "plist is missing ProgramArguments[0]", 713 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 714 + ) 715 + executable = Path(str(program_arguments[0])) 716 + if not executable.exists(): 717 + return make_result( 718 + check, 719 + "fail", 720 + f"plist points to missing executable: {executable}", 721 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 722 + ) 723 + return make_result(check, "ok", f"launchd plist target exists ({executable})") 724 + 725 + 726 + def screen_recording_permission_check(args: Args) -> CheckResult: 727 + del args 728 + check = CHECK_MAP["screen_recording_permission"] 729 + if platform_tag() != "darwin": 730 + return make_result(check, "skip", "not supported on linux", platform="linux") 731 + return make_result( 732 + check, 733 + "skip", 734 + "no adopted non-prompting probe for CLI-scoped macOS TCC state", 735 + "System Settings → Privacy & Security → Screen Recording / Screen & System Audio Recording", 736 + ) 737 + 738 + 739 + def microphone_permission_check(args: Args) -> CheckResult: 740 + del args 741 + check = CHECK_MAP["microphone_permission"] 742 + if platform_tag() != "darwin": 743 + return make_result(check, "skip", "not supported on linux", platform="linux") 744 + return make_result( 745 + check, 746 + "skip", 747 + "no adopted non-prompting probe for CLI-scoped macOS TCC state", 748 + "System Settings → Privacy & Security → Microphone", 749 + ) 750 + 751 + 752 + CHECKS: list[tuple[Check, Callable[[Args], CheckResult]]] = [ 753 + (Check("python_version", "blocker", ("linux", "darwin")), python_version_check), 754 + (Check("uv_installed", "blocker", ("linux", "darwin")), uv_installed_check), 755 + (Check("venv_consistent", "blocker", ("linux", "darwin")), venv_consistent_check), 756 + (Check("sol_importable", "blocker", ("linux", "darwin")), sol_importable_check), 757 + (Check("npx_on_path", "blocker", ("linux", "darwin")), npx_on_path_check), 758 + ( 759 + Check("npx_non_interactive", "advisory", ("linux", "darwin")), 760 + npx_non_interactive_check, 761 + ), 762 + (Check("port_5015_free", "blocker", ("linux", "darwin")), port_5015_free_check), 763 + (Check("disk_space", "advisory", ("linux", "darwin")), disk_space_check), 764 + ( 765 + Check("config_dir_readable", "blocker", ("linux", "darwin")), 766 + config_dir_readable_check, 767 + ), 768 + ( 769 + Check("stale_alias_symlink", "blocker", ("linux", "darwin")), 770 + stale_alias_symlink_check, 771 + ), 772 + ( 773 + Check("macos_firewall_localhost", "advisory", ("darwin",)), 774 + macos_firewall_localhost_check, 775 + ), 776 + ( 777 + Check("launchd_stale_plist", "advisory", ("darwin",)), 778 + launchd_stale_plist_check, 779 + ), 780 + ( 781 + Check("screen_recording_permission", "advisory", ("darwin",)), 782 + screen_recording_permission_check, 783 + ), 784 + ( 785 + Check("microphone_permission", "advisory", ("darwin",)), 786 + microphone_permission_check, 787 + ), 788 + ] 789 + 790 + CHECK_MAP = {check.name: check for check, _func in CHECKS} 791 + 792 + 793 + def parse_args(argv: Sequence[str] | None = None) -> Args: 794 + parser = argparse.ArgumentParser( 795 + prog="doctor", 796 + description="Run pre-install diagnostics for solstone.", 797 + ) 798 + parser.add_argument( 799 + "--verbose", action="store_true", help="print every check result" 800 + ) 801 + parser.add_argument("--json", action="store_true", help="emit JSON instead of text") 802 + parser.add_argument( 803 + "--port", type=int, default=5015, help="port to probe (default: 5015)" 804 + ) 805 + namespace = parser.parse_args(argv) 806 + return Args( 807 + verbose=namespace.verbose, 808 + json=namespace.json, 809 + port=namespace.port, 810 + ) 811 + 812 + 813 + def run_checks(args: Args) -> list[CheckResult]: 814 + current_platform = platform_tag() 815 + results: list[CheckResult] = [] 816 + for check, func in CHECKS: 817 + if current_platform not in check.platforms: 818 + results.append( 819 + make_result( 820 + check, 821 + "skip", 822 + f"not supported on {current_platform}", 823 + platform=current_platform, 824 + ) 825 + ) 826 + continue 827 + results.append(func(args)) 828 + return results 829 + 830 + 831 + def print_result_line(result: CheckResult) -> None: 832 + label = result.status.upper() 833 + print(f" {label} {result.name} — {result.detail}") 834 + if result.fix: 835 + print(f" → {result.fix}") 836 + 837 + 838 + def summary_counts(results: Sequence[CheckResult]) -> dict[str, int]: 839 + return { 840 + "total": len(results), 841 + "failed": sum(1 for result in results if result.status == "fail"), 842 + "warnings": sum(1 for result in results if result.status == "warn"), 843 + "skipped": sum(1 for result in results if result.status == "skip"), 844 + } 845 + 846 + 847 + def emit_text(results: Sequence[CheckResult], *, verbose: bool) -> None: 848 + if verbose: 849 + for result in results: 850 + print_result_line(result) 851 + else: 852 + for result in results: 853 + if result.status in {"fail", "warn"}: 854 + print_result_line(result) 855 + summary = summary_counts(results) 856 + print( 857 + "doctor: " 858 + f"{summary['total']} checks, " 859 + f"{summary['failed']} failed, " 860 + f"{summary['warnings']} warnings, " 861 + f"{summary['skipped']} skipped" 862 + ) 863 + 864 + 865 + def emit_json(results: Sequence[CheckResult]) -> None: 866 + payload = { 867 + "checks": [ 868 + { 869 + "name": result.name, 870 + "severity": result.severity, 871 + "status": result.status, 872 + "detail": result.detail, 873 + "fix": result.fix, 874 + } 875 + for result in results 876 + ], 877 + "summary": summary_counts(results), 878 + } 879 + print(json.dumps(payload)) 880 + 881 + 882 + def main(argv: Sequence[str] | None = None) -> int: 883 + args = parse_args(argv) 884 + results = run_checks(args) 885 + if args.json: 886 + emit_json(results) 887 + else: 888 + emit_text(results, verbose=args.verbose) 889 + blocker_failed = any( 890 + result.severity == "blocker" and result.status == "fail" for result in results 891 + ) 892 + return 1 if blocker_failed else 0 893 + 894 + 895 + if __name__ == "__main__": 896 + raise SystemExit(main())
+654
tests/test_doctor.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import importlib.util 7 + import json 8 + import os 9 + import plistlib 10 + import shutil 11 + import socket 12 + import subprocess 13 + import sys 14 + import uuid 15 + from pathlib import Path 16 + from types import SimpleNamespace 17 + 18 + import pytest 19 + 20 + from think import install_guard 21 + 22 + ROOT = Path(__file__).resolve().parent.parent 23 + 24 + 25 + @pytest.fixture 26 + def doctor(): 27 + path = ROOT / "scripts" / "doctor.py" 28 + name = f"doctor_test_{uuid.uuid4().hex}" 29 + spec = importlib.util.spec_from_file_location(name, path) 30 + assert spec is not None 31 + assert spec.loader is not None 32 + module = importlib.util.module_from_spec(spec) 33 + sys.modules[name] = module 34 + spec.loader.exec_module(module) 35 + try: 36 + yield module 37 + finally: 38 + sys.modules.pop(name, None) 39 + 40 + 41 + @pytest.fixture 42 + def home_root(monkeypatch, tmp_path): 43 + home = tmp_path / "home" 44 + home.mkdir() 45 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) 46 + return home 47 + 48 + 49 + def args(doctor, *, port: int = 5015): 50 + return doctor.Args(verbose=False, json=False, port=port) 51 + 52 + 53 + def make_repo(tmp_path: Path, *, worktree: bool = False) -> Path: 54 + repo = tmp_path / "repo" 55 + repo.mkdir() 56 + if worktree: 57 + (repo / ".git").write_text("gitdir: /tmp/worktree\n", encoding="utf-8") 58 + else: 59 + (repo / ".git").mkdir() 60 + return repo 61 + 62 + 63 + def ensure_expected_target(repo: Path) -> Path: 64 + target = repo / ".venv" / "bin" / "sol" 65 + target.parent.mkdir(parents=True, exist_ok=True) 66 + target.write_text("", encoding="utf-8") 67 + return target 68 + 69 + 70 + def make_alias(home_root: Path, target: Path | str) -> Path: 71 + alias = home_root / ".local" / "bin" / "sol" 72 + alias.parent.mkdir(parents=True, exist_ok=True) 73 + alias.symlink_to(target) 74 + return alias 75 + 76 + 77 + def other_target(tmp_path: Path) -> Path: 78 + target = tmp_path / "other" / ".venv" / "bin" / "sol" 79 + target.parent.mkdir(parents=True, exist_ok=True) 80 + target.write_text("", encoding="utf-8") 81 + return target 82 + 83 + 84 + class TestPythonVersion: 85 + def test_ok(self, doctor): 86 + result = doctor.python_version_check(args(doctor)) 87 + assert result.status == "ok" 88 + 89 + def test_fail_when_too_old(self, doctor, monkeypatch): 90 + monkeypatch.setattr(doctor.sys, "version_info", (3, 9, 18)) 91 + result = doctor.python_version_check(args(doctor)) 92 + assert result.status == "fail" 93 + assert "does not satisfy" in result.detail 94 + 95 + 96 + class TestUvInstalled: 97 + def test_ok(self, doctor, monkeypatch): 98 + monkeypatch.setattr( 99 + doctor, 100 + "run_probe", 101 + lambda *_args, **_kwargs: doctor.ProbeOutput("uv 0.10.0\n", "", 0), 102 + ) 103 + result = doctor.uv_installed_check(args(doctor)) 104 + assert result.status == "ok" 105 + 106 + def test_missing(self, doctor, monkeypatch): 107 + def raise_missing(*_args, **_kwargs): 108 + raise FileNotFoundError 109 + 110 + monkeypatch.setattr(doctor.subprocess, "run", raise_missing) 111 + result = doctor.uv_installed_check(args(doctor)) 112 + assert result.status == "fail" 113 + assert "probe command not found" in result.detail 114 + 115 + def test_fail_when_too_old(self, doctor, monkeypatch): 116 + monkeypatch.setattr( 117 + doctor, 118 + "run_probe", 119 + lambda *_args, **_kwargs: doctor.ProbeOutput("uv 0.7.0\n", "", 0), 120 + ) 121 + result = doctor.uv_installed_check(args(doctor)) 122 + assert result.status == "fail" 123 + assert "older than required" in result.detail 124 + 125 + 126 + class TestVenvConsistent: 127 + def test_skip_when_absent(self, doctor, monkeypatch, tmp_path): 128 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 129 + result = doctor.venv_consistent_check(args(doctor)) 130 + assert result.status == "skip" 131 + 132 + def test_ok_when_consistent(self, doctor, monkeypatch, tmp_path): 133 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 134 + python_bin = tmp_path / ".venv" / "bin" / "python" 135 + python_bin.parent.mkdir(parents=True) 136 + python_bin.write_text("", encoding="utf-8") 137 + monkeypatch.setattr( 138 + doctor, 139 + "run_probe", 140 + lambda *_args, **_kwargs: doctor.ProbeOutput( 141 + f"{tmp_path / '.venv'}\n", "", 0 142 + ), 143 + ) 144 + result = doctor.venv_consistent_check(args(doctor)) 145 + assert result.status == "ok" 146 + 147 + def test_fail_when_inconsistent(self, doctor, monkeypatch, tmp_path): 148 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 149 + python_bin = tmp_path / ".venv" / "bin" / "python" 150 + python_bin.parent.mkdir(parents=True) 151 + python_bin.write_text("", encoding="utf-8") 152 + monkeypatch.setattr( 153 + doctor, 154 + "run_probe", 155 + lambda *_args, **_kwargs: doctor.ProbeOutput("/tmp/elsewhere\n", "", 0), 156 + ) 157 + result = doctor.venv_consistent_check(args(doctor)) 158 + assert result.status == "fail" 159 + 160 + 161 + class TestSolImportable: 162 + def test_skip_when_absent(self, doctor, monkeypatch, tmp_path): 163 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 164 + result = doctor.sol_importable_check(args(doctor)) 165 + assert result.status == "skip" 166 + 167 + def test_ok(self, doctor, monkeypatch, tmp_path): 168 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 169 + python_bin = tmp_path / ".venv" / "bin" / "python" 170 + python_bin.parent.mkdir(parents=True) 171 + python_bin.write_text("", encoding="utf-8") 172 + monkeypatch.setattr( 173 + doctor, 174 + "run_probe", 175 + lambda *_args, **_kwargs: doctor.ProbeOutput("", "", 0), 176 + ) 177 + result = doctor.sol_importable_check(args(doctor)) 178 + assert result.status == "ok" 179 + 180 + def test_fail_on_module_not_found(self, doctor, monkeypatch, tmp_path): 181 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 182 + python_bin = tmp_path / ".venv" / "bin" / "python" 183 + python_bin.parent.mkdir(parents=True) 184 + python_bin.write_text("", encoding="utf-8") 185 + monkeypatch.setattr( 186 + doctor, 187 + "run_probe", 188 + lambda *_args, **_kwargs: doctor.ProbeOutput( 189 + "", 190 + "Traceback (most recent call last):\nModuleNotFoundError: No module named 'sol'\n", 191 + 1, 192 + ), 193 + ) 194 + result = doctor.sol_importable_check(args(doctor)) 195 + assert result.status == "fail" 196 + assert "ModuleNotFoundError" in result.detail 197 + 198 + def test_fail_on_other_exception(self, doctor, monkeypatch, tmp_path): 199 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 200 + python_bin = tmp_path / ".venv" / "bin" / "python" 201 + python_bin.parent.mkdir(parents=True) 202 + python_bin.write_text("", encoding="utf-8") 203 + monkeypatch.setattr( 204 + doctor, 205 + "run_probe", 206 + lambda *_args, **_kwargs: doctor.ProbeOutput( 207 + "", "SyntaxError: broken import\n", 1 208 + ), 209 + ) 210 + result = doctor.sol_importable_check(args(doctor)) 211 + assert result.status == "fail" 212 + assert result.detail == "SyntaxError: broken import" 213 + 214 + 215 + class TestNpxChecks: 216 + def test_npx_on_path_ok(self, doctor, monkeypatch): 217 + monkeypatch.setattr(doctor.shutil, "which", lambda _name: "/usr/bin/npx") 218 + result = doctor.npx_on_path_check(args(doctor)) 219 + assert result.status == "ok" 220 + 221 + def test_npx_on_path_fail(self, doctor, monkeypatch): 222 + monkeypatch.setattr(doctor.shutil, "which", lambda _name: None) 223 + result = doctor.npx_on_path_check(args(doctor)) 224 + assert result.status == "fail" 225 + 226 + def test_npx_non_interactive_ok(self, doctor, monkeypatch): 227 + monkeypatch.setattr( 228 + doctor, 229 + "run_probe", 230 + lambda *_args, **_kwargs: doctor.ProbeOutput("10.1.0\n", "", 0), 231 + ) 232 + result = doctor.npx_non_interactive_check(args(doctor)) 233 + assert result.status == "ok" 234 + 235 + def test_npx_non_interactive_fail_on_nonzero(self, doctor, monkeypatch): 236 + monkeypatch.setattr( 237 + doctor, 238 + "run_probe", 239 + lambda *_args, **_kwargs: doctor.make_result( 240 + doctor.CHECK_MAP["npx_non_interactive"], 241 + "fail", 242 + "probe exited 1: boom", 243 + ), 244 + ) 245 + result = doctor.npx_non_interactive_check(args(doctor)) 246 + assert result.status == "fail" 247 + 248 + def test_npx_non_interactive_fail_on_timeout(self, doctor, monkeypatch): 249 + def raise_timeout(*_args, **_kwargs): 250 + raise subprocess.TimeoutExpired(["npx"], timeout=2.0) 251 + 252 + monkeypatch.setattr(doctor.subprocess, "run", raise_timeout) 253 + result = doctor.npx_non_interactive_check(args(doctor)) 254 + assert result.status == "fail" 255 + assert "timed out" in result.detail 256 + 257 + def test_npx_non_interactive_fail_on_empty_stdout(self, doctor, monkeypatch): 258 + monkeypatch.setattr( 259 + doctor, 260 + "run_probe", 261 + lambda *_args, **_kwargs: doctor.ProbeOutput("", "", 0), 262 + ) 263 + result = doctor.npx_non_interactive_check(args(doctor)) 264 + assert result.status == "fail" 265 + assert "unexpected output" in result.detail 266 + 267 + 268 + class TestPortCheck: 269 + def test_skip_when_lsof_missing(self, doctor, monkeypatch): 270 + monkeypatch.setattr(doctor.shutil, "which", lambda _name: None) 271 + result = doctor.port_5015_free_check(args(doctor)) 272 + assert result.status == "skip" 273 + 274 + def test_ok_when_port_free(self, doctor, monkeypatch): 275 + monkeypatch.setattr(doctor.shutil, "which", lambda _name: "/usr/bin/lsof") 276 + monkeypatch.setattr( 277 + doctor, 278 + "run_probe", 279 + lambda *_args, **_kwargs: doctor.ProbeOutput("", "", 1), 280 + ) 281 + result = doctor.port_5015_free_check(args(doctor)) 282 + assert result.status == "ok" 283 + assert "is free" in result.detail 284 + 285 + def test_ok_when_owned_by_repo_sol(self, doctor, monkeypatch, tmp_path): 286 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 287 + sol_bin = tmp_path / ".venv" / "bin" / "sol" 288 + sol_bin.parent.mkdir(parents=True) 289 + sol_bin.write_text("", encoding="utf-8") 290 + monkeypatch.setattr(doctor.shutil, "which", lambda _name: "/usr/bin/lsof") 291 + monkeypatch.setattr( 292 + doctor, 293 + "run_probe", 294 + lambda *_args, **_kwargs: doctor.ProbeOutput("p123\n", "", 0), 295 + ) 296 + monkeypatch.setattr(doctor, "resolve_alias_target", lambda: None) 297 + monkeypatch.setattr(doctor.os, "readlink", lambda _path: str(sol_bin)) 298 + result = doctor.port_5015_free_check(args(doctor)) 299 + assert result.status == "ok" 300 + assert "this repo's solstone" in result.detail 301 + 302 + def test_fail_when_exe_not_owned_even_if_name_mentions_sol( 303 + self, doctor, monkeypatch, tmp_path 304 + ): 305 + monkeypatch.setattr(doctor, "ROOT", tmp_path) 306 + monkeypatch.setattr(doctor.shutil, "which", lambda _name: "/usr/bin/lsof") 307 + monkeypatch.setattr( 308 + doctor, 309 + "run_probe", 310 + lambda *_args, **_kwargs: doctor.ProbeOutput("p123\n", "", 0), 311 + ) 312 + monkeypatch.setattr(doctor, "resolve_alias_target", lambda: None) 313 + monkeypatch.setattr(doctor.os, "readlink", lambda _path: "/usr/bin/python3") 314 + result = doctor.port_5015_free_check(args(doctor)) 315 + assert result.status == "fail" 316 + assert "/usr/bin/python3" in result.detail 317 + 318 + def test_fail_on_lsof_timeout(self, doctor, monkeypatch): 319 + monkeypatch.setattr(doctor.shutil, "which", lambda _name: "/usr/bin/lsof") 320 + 321 + def raise_timeout(*_args, **_kwargs): 322 + raise subprocess.TimeoutExpired(["lsof"], timeout=1.0) 323 + 324 + monkeypatch.setattr(doctor.subprocess, "run", raise_timeout) 325 + result = doctor.port_5015_free_check(args(doctor)) 326 + assert result.status == "fail" 327 + assert "timed out" in result.detail 328 + 329 + 330 + class TestDiskSpace: 331 + def test_warn_when_low(self, doctor, monkeypatch): 332 + monkeypatch.setattr( 333 + doctor.shutil, 334 + "disk_usage", 335 + lambda _root: SimpleNamespace(total=100, used=95, free=5 * 1024**3), 336 + ) 337 + result = doctor.disk_space_check(args(doctor)) 338 + assert result.status == "warn" 339 + 340 + def test_ok_when_sufficient(self, doctor, monkeypatch): 341 + monkeypatch.setattr( 342 + doctor.shutil, 343 + "disk_usage", 344 + lambda _root: SimpleNamespace(total=100, used=80, free=20 * 1024**3), 345 + ) 346 + result = doctor.disk_space_check(args(doctor)) 347 + assert result.status == "ok" 348 + 349 + 350 + class TestConfigDirReadable: 351 + def test_ok(self, doctor, monkeypatch, home_root): 352 + config_dir = home_root / ".config" 353 + config_dir.mkdir() 354 + result = doctor.config_dir_readable_check(args(doctor)) 355 + assert result.status == "ok" 356 + 357 + def test_fail_when_home_unwritable(self, doctor, monkeypatch, home_root): 358 + def fake_access(path, mode): 359 + if Path(path) == home_root: 360 + return False 361 + return True 362 + 363 + monkeypatch.setattr(doctor.os, "access", fake_access) 364 + result = doctor.config_dir_readable_check(args(doctor)) 365 + assert result.status == "fail" 366 + 367 + 368 + class TestStaleAliasSymlink: 369 + def setup_import(self, doctor, monkeypatch): 370 + monkeypatch.setattr( 371 + doctor, 372 + "import_install_guard", 373 + lambda: (install_guard.AliasState, install_guard.check_alias), 374 + ) 375 + 376 + def test_absent_ok(self, doctor, monkeypatch, home_root, tmp_path): 377 + self.setup_import(doctor, monkeypatch) 378 + repo = make_repo(tmp_path) 379 + monkeypatch.setattr(doctor, "ROOT", repo) 380 + result = doctor.stale_alias_symlink_check(args(doctor)) 381 + assert result.status == "ok" 382 + 383 + def test_owned_ok(self, doctor, monkeypatch, home_root, tmp_path): 384 + self.setup_import(doctor, monkeypatch) 385 + repo = make_repo(tmp_path) 386 + make_alias(home_root, ensure_expected_target(repo)) 387 + monkeypatch.setattr(doctor, "ROOT", repo) 388 + result = doctor.stale_alias_symlink_check(args(doctor)) 389 + assert result.status == "ok" 390 + 391 + def test_cross_repo_fail(self, doctor, monkeypatch, home_root, tmp_path): 392 + self.setup_import(doctor, monkeypatch) 393 + repo = make_repo(tmp_path) 394 + make_alias(home_root, other_target(tmp_path)) 395 + monkeypatch.setattr(doctor, "ROOT", repo) 396 + result = doctor.stale_alias_symlink_check(args(doctor)) 397 + assert result.status == "fail" 398 + 399 + def test_dangling_fail(self, doctor, monkeypatch, home_root, tmp_path): 400 + self.setup_import(doctor, monkeypatch) 401 + repo = make_repo(tmp_path) 402 + missing = tmp_path / "missing" / ".venv" / "bin" / "sol" 403 + make_alias(home_root, missing) 404 + monkeypatch.setattr(doctor, "ROOT", repo) 405 + result = doctor.stale_alias_symlink_check(args(doctor)) 406 + assert result.status == "fail" 407 + 408 + def test_not_symlink_fail(self, doctor, monkeypatch, home_root, tmp_path): 409 + self.setup_import(doctor, monkeypatch) 410 + repo = make_repo(tmp_path) 411 + alias = home_root / ".local" / "bin" / "sol" 412 + alias.parent.mkdir(parents=True, exist_ok=True) 413 + alias.write_text("not a symlink", encoding="utf-8") 414 + monkeypatch.setattr(doctor, "ROOT", repo) 415 + result = doctor.stale_alias_symlink_check(args(doctor)) 416 + assert result.status == "fail" 417 + 418 + def test_worktree_skip(self, doctor, monkeypatch, home_root, tmp_path): 419 + self.setup_import(doctor, monkeypatch) 420 + repo = make_repo(tmp_path, worktree=True) 421 + monkeypatch.setattr(doctor, "ROOT", repo) 422 + result = doctor.stale_alias_symlink_check(args(doctor)) 423 + assert result.status == "skip" 424 + 425 + def test_import_failure_skips(self, doctor, monkeypatch): 426 + monkeypatch.setattr( 427 + doctor, 428 + "import_install_guard", 429 + lambda: (_ for _ in ()).throw(ImportError("boom")), 430 + ) 431 + result = doctor.stale_alias_symlink_check(args(doctor)) 432 + assert result.status == "skip" 433 + assert "could not import think.install_guard" in result.detail 434 + 435 + 436 + class TestMacosFirewall: 437 + def test_skip_on_linux(self, doctor, monkeypatch): 438 + monkeypatch.setattr(doctor, "platform_tag", lambda: "linux") 439 + result = doctor.macos_firewall_localhost_check(args(doctor)) 440 + assert result.status == "skip" 441 + 442 + def test_ok_when_off(self, doctor, monkeypatch): 443 + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") 444 + monkeypatch.setattr(doctor.Path, "exists", lambda self: True) 445 + monkeypatch.setattr( 446 + doctor, 447 + "run_probe", 448 + lambda *_args, **_kwargs: doctor.ProbeOutput( 449 + "Firewall is disabled.\n", "", 0 450 + ), 451 + ) 452 + result = doctor.macos_firewall_localhost_check(args(doctor)) 453 + assert result.status == "ok" 454 + 455 + def test_warn_when_blockall_on(self, doctor, monkeypatch): 456 + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") 457 + monkeypatch.setattr(doctor.Path, "exists", lambda self: True) 458 + outputs = iter( 459 + [ 460 + doctor.ProbeOutput("Firewall is enabled. (State = 1)\n", "", 0), 461 + doctor.ProbeOutput( 462 + "Block all incoming connections is enabled.\n", "", 0 463 + ), 464 + ] 465 + ) 466 + monkeypatch.setattr( 467 + doctor, "run_probe", lambda *_args, **_kwargs: next(outputs) 468 + ) 469 + result = doctor.macos_firewall_localhost_check(args(doctor)) 470 + assert result.status == "warn" 471 + 472 + def test_ok_when_blockall_off(self, doctor, monkeypatch): 473 + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") 474 + monkeypatch.setattr(doctor.Path, "exists", lambda self: True) 475 + outputs = iter( 476 + [ 477 + doctor.ProbeOutput("Firewall is enabled. (State = 1)\n", "", 0), 478 + doctor.ProbeOutput( 479 + "Block all incoming connections is disabled.\n", "", 0 480 + ), 481 + ] 482 + ) 483 + monkeypatch.setattr( 484 + doctor, "run_probe", lambda *_args, **_kwargs: next(outputs) 485 + ) 486 + result = doctor.macos_firewall_localhost_check(args(doctor)) 487 + assert result.status == "ok" 488 + 489 + 490 + class TestLaunchdStalePlist: 491 + def test_skip_on_linux(self, doctor, monkeypatch): 492 + monkeypatch.setattr(doctor, "platform_tag", lambda: "linux") 493 + result = doctor.launchd_stale_plist_check(args(doctor)) 494 + assert result.status == "skip" 495 + 496 + def test_skip_when_absent(self, doctor, monkeypatch, home_root): 497 + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") 498 + result = doctor.launchd_stale_plist_check(args(doctor)) 499 + assert result.status == "skip" 500 + 501 + def test_fail_when_target_missing(self, doctor, monkeypatch, home_root): 502 + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") 503 + plist_path = ( 504 + home_root / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" 505 + ) 506 + plist_path.parent.mkdir(parents=True) 507 + plist_path.write_bytes( 508 + plistlib.dumps({"ProgramArguments": ["/tmp/missing-sol"]}) 509 + ) 510 + result = doctor.launchd_stale_plist_check(args(doctor)) 511 + assert result.status == "fail" 512 + 513 + def test_ok_when_target_exists(self, doctor, monkeypatch, home_root, tmp_path): 514 + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") 515 + exe = tmp_path / "sol" 516 + exe.write_text("", encoding="utf-8") 517 + plist_path = ( 518 + home_root / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" 519 + ) 520 + plist_path.parent.mkdir(parents=True) 521 + plist_path.write_bytes(plistlib.dumps({"ProgramArguments": [str(exe)]})) 522 + result = doctor.launchd_stale_plist_check(args(doctor)) 523 + assert result.status == "ok" 524 + 525 + 526 + class TestTccChecks: 527 + def test_screen_skip_on_linux(self, doctor, monkeypatch): 528 + monkeypatch.setattr(doctor, "platform_tag", lambda: "linux") 529 + result = doctor.screen_recording_permission_check(args(doctor)) 530 + assert result.status == "skip" 531 + 532 + def test_screen_skip_on_darwin(self, doctor, monkeypatch): 533 + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") 534 + result = doctor.screen_recording_permission_check(args(doctor)) 535 + assert result.status == "skip" 536 + assert "no adopted non-prompting probe" in result.detail 537 + 538 + def test_microphone_skip_on_linux(self, doctor, monkeypatch): 539 + monkeypatch.setattr(doctor, "platform_tag", lambda: "linux") 540 + result = doctor.microphone_permission_check(args(doctor)) 541 + assert result.status == "skip" 542 + 543 + def test_microphone_skip_on_darwin(self, doctor, monkeypatch): 544 + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") 545 + result = doctor.microphone_permission_check(args(doctor)) 546 + assert result.status == "skip" 547 + assert "no adopted non-prompting probe" in result.detail 548 + 549 + 550 + class TestJsonAndExitCodes: 551 + def test_json_output(self, doctor, monkeypatch, capsys): 552 + monkeypatch.setattr( 553 + doctor, 554 + "run_checks", 555 + lambda _args: [ 556 + doctor.CheckResult("a", "blocker", "ok", "fine", None), 557 + doctor.CheckResult("b", "advisory", "warn", "careful", "fix me"), 558 + ], 559 + ) 560 + rc = doctor.main(["--json"]) 561 + payload = json.loads(capsys.readouterr().out) 562 + assert rc == 0 563 + assert sorted(payload) == ["checks", "summary"] 564 + assert set(payload["checks"][0]) == { 565 + "name", 566 + "severity", 567 + "status", 568 + "detail", 569 + "fix", 570 + } 571 + 572 + def test_exit_code_matrix(self, doctor, monkeypatch, capsys): 573 + monkeypatch.setattr( 574 + doctor, 575 + "run_checks", 576 + lambda _args: [doctor.CheckResult("a", "blocker", "fail", "boom", None)], 577 + ) 578 + assert doctor.main([]) == 1 579 + capsys.readouterr() 580 + 581 + monkeypatch.setattr( 582 + doctor, 583 + "run_checks", 584 + lambda _args: [doctor.CheckResult("a", "advisory", "fail", "boom", None)], 585 + ) 586 + assert doctor.main([]) == 0 587 + capsys.readouterr() 588 + 589 + monkeypatch.setattr( 590 + doctor, 591 + "run_checks", 592 + lambda _args: [doctor.CheckResult("a", "blocker", "skip", "skip", None)], 593 + ) 594 + assert doctor.main([]) == 0 595 + 596 + def test_summary_line_format(self, doctor, monkeypatch, capsys): 597 + monkeypatch.setattr( 598 + doctor, 599 + "run_checks", 600 + lambda _args: [ 601 + doctor.CheckResult("a", "blocker", "fail", "boom", None), 602 + doctor.CheckResult("b", "advisory", "warn", "warn", None), 603 + doctor.CheckResult("c", "blocker", "skip", "skip", None), 604 + ], 605 + ) 606 + doctor.main([]) 607 + output = capsys.readouterr().out.strip().splitlines() 608 + assert output[-1] == "doctor: 3 checks, 1 failed, 1 warnings, 1 skipped" 609 + 610 + 611 + class TestMakefileIntegration: 612 + def test_dry_run_orders_doctor_before_uv_sync(self): 613 + result = subprocess.run( 614 + ["make", "--dry-run", "-B", "install"], 615 + cwd=ROOT, 616 + capture_output=True, 617 + text=True, 618 + check=False, 619 + timeout=10, 620 + ) 621 + assert result.returncode == 0 622 + lines = result.stdout.splitlines() 623 + doctor_idx = next( 624 + index 625 + for index, line in enumerate(lines) 626 + if "python3 scripts/doctor.py" in line 627 + ) 628 + uv_idx = next(index for index, line in enumerate(lines) if "uv sync" in line) 629 + assert doctor_idx < uv_idx 630 + 631 + def test_install_service_aborts_before_running_when_doctor_fails(self, tmp_path): 632 + if shutil.which("lsof") is None: 633 + pytest.skip("lsof not available") 634 + installed = ROOT / ".installed" 635 + if not installed.exists(): 636 + pytest.skip(".installed missing") 637 + before = installed.stat().st_mtime 638 + with socket.socket() as server: 639 + server.bind(("127.0.0.1", 0)) 640 + server.listen(1) 641 + port = server.getsockname()[1] 642 + env = os.environ.copy() 643 + env["HOME"] = str(tmp_path / "home") 644 + result = subprocess.run( 645 + ["make", "install-service", f"PORT={port}"], 646 + cwd=ROOT, 647 + capture_output=True, 648 + text=True, 649 + check=False, 650 + timeout=15, 651 + env=env, 652 + ) 653 + assert result.returncode != 0 654 + assert installed.stat().st_mtime == before
+11 -1
think/install_guard.py
··· 10 10 from enum import Enum 11 11 from pathlib import Path 12 12 13 - import userpath 13 + try: 14 + import userpath # type: ignore[import-not-found] 15 + except ImportError: # system python without the venv: doctor.stale_alias_symlink path 16 + userpath = None # type: ignore[assignment] 14 17 15 18 16 19 class AliasState(Enum): ··· 92 95 93 96 94 97 def _ensure_user_bin_on_path(user_bin: Path) -> None: 98 + # `userpath` is imported at module top with an ImportError guard, so this 99 + # module is importable from system python (where doctor runs) even when 100 + # `userpath` is not installed. This code path is only reached via 101 + # `cmd_install`, which only runs from inside the venv where `userpath` is 102 + # present; if somehow reached without `userpath`, we want a hard failure. 103 + if userpath is None: 104 + raise RuntimeError("userpath is not available; run `make install` first") 95 105 user_bin_str = str(user_bin) 96 106 try: 97 107 if userpath.in_current_path(user_bin_str):