diff --git a/src/claude_statusbar/doctor.py b/src/claude_statusbar/doctor.py index 65147b3..83ffc7d 100644 --- a/src/claude_statusbar/doctor.py +++ b/src/claude_statusbar/doctor.py @@ -199,6 +199,50 @@ def run() -> int: except Exception as e: _line("version", _red(str(e)), ok=False) + # --- update channel --- + # A version line that is always ✓ hid an install stuck for months: it was + # behind, and auto-upgrade was silently refusing to run. Mirror the gates + # in check_for_updates()/auto_upgrade(), and read the cache the updater + # already writes rather than hitting the network from here. + auto_on = False + try: + from .config import load_config + from .updater import _windows_in_place, is_shadow_install + if os.environ.get("CLAUDE_STATUSBAR_NO_UPDATE", "").lower() in ("1", "true", "yes"): + auto = ("off (CLAUDE_STATUSBAR_NO_UPDATE)", True) + elif not load_config().auto_upgrade: + auto = ("off (config auto_upgrade=false)", True) + elif is_shadow_install(): + auto = (_yellow("blocked — `cs` on PATH belongs to another install"), None) + elif _windows_in_place(): + auto = (_yellow("blocked — Windows pip/pipx can't upgrade in place; " + "run: cs upgrade"), None) + else: + auto, auto_on = ("on", True), True + except Exception as e: + auto = (_dim(f"check skipped: {e}"), True) + try: + import time as _time + from .updater import LATEST_VERSION_CACHE, compare_versions, get_current_version + data = json.loads(LATEST_VERSION_CACHE.read_text(encoding="utf-8")) + latest, current = str(data["version"]), get_current_version() + age_d = int((_time.time() - float(data.get("checked_at", 0))) // 86400) + if current == "0.0.0": + pass # source checkout without an installed dist: no real version + elif age_d > 7: + # Same cutoff as the bar's ↑ hint. A blocked updater stops + # refreshing this file, so an old "latest" is not evidence. + _line("latest", _yellow(f"unknown — last checked {age_d}d ago") + if auto_on else f"unknown — last checked {age_d}d ago", + ok=None if auto_on else True) + elif compare_versions(current, latest): + _line("latest", _yellow(f"{latest} available — run: cs upgrade"), ok=None) + else: + _line("latest", f"{latest} (up to date)") + except Exception: + pass # no cache yet or unreadable: say nothing rather than guess + _line("auto-upgrade", auto[0], ok=auto[1]) + # --- python --- _line("python", f"{sys.version.split()[0]} ({sys.executable})") diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 527a43c..9a44a40 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -177,3 +177,79 @@ def test_shell_check_uses_patchable_windows_probe( out = capsys.readouterr().out assert "backslash path" in out assert "run: cs --setup" in out + + + +@pytest.fixture +def _updates(monkeypatch, tmp_path): + """Pin every input of the update-channel lines. config.CONFIG_PATH is bound + at import, so the Path.home patch above can't redirect it; patch the loader.""" + import time + from claude_statusbar import config, updater + monkeypatch.delenv("CLAUDE_STATUSBAR_NO_UPDATE", raising=False) + monkeypatch.setattr(config, "load_config", lambda: config.StatusbarConfig()) + monkeypatch.setattr(updater, "is_shadow_install", lambda: False) + monkeypatch.setattr(updater, "_windows_in_place", lambda: False) + monkeypatch.setattr(updater, "get_current_version", lambda: "3.41.0") + cache = tmp_path / "latest_version.json" + monkeypatch.setattr(updater, "LATEST_VERSION_CACHE", cache) + + def write(version, age_s=0): + cache.write_text(json.dumps( + {"version": version, "checked_at": time.time() - age_s}), encoding="utf-8") + return write + + +def test_doctor_flags_newer_version_available(capsys, _isolated, _updates): + _updates("3.44.0") + doctor.run() + out = capsys.readouterr().out + assert "3.44.0 available — run: cs upgrade" in out + assert any(l.split()[1:] == ["auto-upgrade", "on"] for l in out.splitlines()) + + +def test_doctor_says_up_to_date(capsys, _isolated, _updates): + _updates("3.41.0") + doctor.run() + assert "3.41.0 (up to date)" in capsys.readouterr().out + + +def test_doctor_distrusts_stale_latest_cache(capsys, _isolated, _updates): + """A blocked updater stops refreshing the cache; a month-old "3.41.0" + must not be reported as up to date.""" + _updates("3.41.0", age_s=31 * 86400) + doctor.run() + out = capsys.readouterr().out + assert "up to date" not in out + assert "unknown — last checked 31d ago" in out + + +def test_doctor_silent_without_latest_cache(capsys, _isolated, _updates): + doctor.run() + assert "latest" not in capsys.readouterr().out + + +def test_doctor_flags_blocked_auto_upgrade(capsys, _isolated, _updates, monkeypatch): + """An install stuck behind for months showed a green ✓ everywhere because + is_shadow_install() silently vetoed every auto-upgrade.""" + from claude_statusbar import updater + monkeypatch.setattr(updater, "is_shadow_install", lambda: True) + doctor.run() + assert "blocked — `cs` on PATH belongs to another install" in capsys.readouterr().out + + +def test_doctor_flags_windows_in_place(capsys, _isolated, _updates, monkeypatch): + from claude_statusbar import updater + monkeypatch.setattr(updater, "_windows_in_place", lambda: True) + doctor.run() + assert "blocked — Windows pip/pipx" in capsys.readouterr().out + + +def test_doctor_reports_auto_upgrade_off(capsys, _isolated, _updates, monkeypatch): + from claude_statusbar import config + monkeypatch.setattr(config, "load_config", lambda: config.StatusbarConfig(auto_upgrade=False)) + doctor.run() + assert "off (config auto_upgrade=false)" in capsys.readouterr().out + monkeypatch.setenv("CLAUDE_STATUSBAR_NO_UPDATE", "1") + doctor.run() + assert "off (CLAUDE_STATUSBAR_NO_UPDATE)" in capsys.readouterr().out