From 3786df01358e554d99787a925ce2150c0e48650e Mon Sep 17 00:00:00 2001 From: bpavlina Date: Sun, 4 Oct 2026 15:29:40 -0400 Subject: [PATCH 1/3] fix(doctor): report when a newer version exists and when auto-upgrade is blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cs doctor` printed a green ✓ next to the installed version no matter how far behind it was, and said nothing about auto-upgrade. A uv install stuck on 3.41.0 by the shadow-install misread (#51) looked fully healthy here while 3.44.0 was out and every daily upgrade was being vetoed. Two new lines, both read-only and offline: - latest: compares against the latest_version.json cache the updater already writes; silent when there is no cache. - auto-upgrade: on / off (env or config) / blocked by is_shadow_install(). --- src/claude_statusbar/doctor.py | 28 ++++++++++++++++++++++++++ tests/test_doctor.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/claude_statusbar/doctor.py b/src/claude_statusbar/doctor.py index 65147b3..6f4a58f 100644 --- a/src/claude_statusbar/doctor.py +++ b/src/claude_statusbar/doctor.py @@ -199,6 +199,34 @@ 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. Read the cache the + # updater already writes rather than hitting the network from here. + try: + from .updater import LATEST_VERSION_CACHE, compare_versions, get_current_version + latest = json.loads(LATEST_VERSION_CACHE.read_text(encoding="utf-8"))["version"] + if compare_versions(get_current_version(), 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 + try: + from .config import load_config + from .updater import is_shadow_install + if os.environ.get("CLAUDE_STATUSBAR_NO_UPDATE", "").lower() in ("1", "true", "yes"): + _line("auto-upgrade", "off (CLAUDE_STATUSBAR_NO_UPDATE)") + elif not load_config().auto_upgrade: + _line("auto-upgrade", "off (config auto_upgrade=false)") + elif is_shadow_install(): + _line("auto-upgrade", _yellow( + "blocked — `cs` on PATH belongs to another install"), ok=None) + else: + _line("auto-upgrade", "on") + except Exception as e: + _line("auto-upgrade", _dim(f"check skipped: {e}")) + # --- python --- _line("python", f"{sys.version.split()[0]} ({sys.executable})") diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 527a43c..68cb795 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -177,3 +177,39 @@ def test_shell_check_uses_patchable_windows_probe( out = capsys.readouterr().out assert "backslash path" in out assert "run: cs --setup" in out + + +def _latest_cache(monkeypatch, tmp_path, version): + from claude_statusbar import updater + cache = tmp_path / "latest_version.json" + cache.write_text(json.dumps({"version": version, "checked_at": 0}), encoding="utf-8") + monkeypatch.setattr(updater, "LATEST_VERSION_CACHE", cache) + monkeypatch.setattr(updater, "get_current_version", lambda: "3.41.0") + + +def test_doctor_flags_newer_version_available(capsys, _isolated, monkeypatch, tmp_path): + _latest_cache(monkeypatch, tmp_path, "3.44.0") + doctor.run() + assert "3.44.0 available — run: cs upgrade" in capsys.readouterr().out + + +def test_doctor_says_up_to_date(capsys, _isolated, monkeypatch, tmp_path): + _latest_cache(monkeypatch, tmp_path, "3.41.0") + doctor.run() + assert "3.41.0 (up to date)" in capsys.readouterr().out + + +def test_doctor_flags_blocked_auto_upgrade(capsys, _isolated, 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.delenv("CLAUDE_STATUSBAR_NO_UPDATE", raising=False) + 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_reports_auto_upgrade_off_by_env(capsys, _isolated, monkeypatch): + monkeypatch.setenv("CLAUDE_STATUSBAR_NO_UPDATE", "1") + doctor.run() + assert "off (CLAUDE_STATUSBAR_NO_UPDATE)" in capsys.readouterr().out From 47d86cef4400a059cf2c3b637d1a3057b65f2abf Mon Sep 17 00:00:00 2001 From: bpavlina Date: Sun, 4 Oct 2026 15:45:38 -0400 Subject: [PATCH 2/3] fix(doctor): distrust a stale latest cache, mirror the Windows in-place gate A blocked updater never refreshes latest_version.json, so a month-old cache naming the installed version read as "up to date" in exactly the stuck case this line exists for. Apply the bar's own 7-day cutoff and say "unknown" past it. The auto-upgrade line now also reports the Windows pip/pipx gate auto_upgrade() enforces, and tests pin load_config instead of reading the real config through the import-time CONFIG_PATH. --- src/claude_statusbar/doctor.py | 52 +++++++++++++++++---------- tests/test_doctor.py | 64 +++++++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 30 deletions(-) diff --git a/src/claude_statusbar/doctor.py b/src/claude_statusbar/doctor.py index 6f4a58f..83ffc7d 100644 --- a/src/claude_statusbar/doctor.py +++ b/src/claude_statusbar/doctor.py @@ -201,31 +201,47 @@ def run() -> int: # --- 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. Read the cache the - # updater already writes rather than hitting the network from here. - try: - from .updater import LATEST_VERSION_CACHE, compare_versions, get_current_version - latest = json.loads(LATEST_VERSION_CACHE.read_text(encoding="utf-8"))["version"] - if compare_versions(get_current_version(), 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 + # 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 is_shadow_install + from .updater import _windows_in_place, is_shadow_install if os.environ.get("CLAUDE_STATUSBAR_NO_UPDATE", "").lower() in ("1", "true", "yes"): - _line("auto-upgrade", "off (CLAUDE_STATUSBAR_NO_UPDATE)") + auto = ("off (CLAUDE_STATUSBAR_NO_UPDATE)", True) elif not load_config().auto_upgrade: - _line("auto-upgrade", "off (config auto_upgrade=false)") + auto = ("off (config auto_upgrade=false)", True) elif is_shadow_install(): - _line("auto-upgrade", _yellow( - "blocked — `cs` on PATH belongs to another install"), ok=None) + 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: - _line("auto-upgrade", "on") + auto, auto_on = ("on", True), True except Exception as e: - _line("auto-upgrade", _dim(f"check skipped: {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 68cb795..9726c07 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -179,37 +179,77 @@ def test_shell_check_uses_patchable_windows_probe( assert "run: cs --setup" in out -def _latest_cache(monkeypatch, tmp_path, version): - from claude_statusbar import updater + +@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" - cache.write_text(json.dumps({"version": version, "checked_at": 0}), encoding="utf-8") monkeypatch.setattr(updater, "LATEST_VERSION_CACHE", cache) - monkeypatch.setattr(updater, "get_current_version", lambda: "3.41.0") + 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, monkeypatch, tmp_path): - _latest_cache(monkeypatch, tmp_path, "3.44.0") + +def test_doctor_flags_newer_version_available(capsys, _isolated, _updates): + _updates("3.44.0") doctor.run() - assert "3.44.0 available — run: cs upgrade" in capsys.readouterr().out + out = capsys.readouterr().out + assert "3.44.0 available — run: cs upgrade" in out + assert "auto-upgrade" in out and " on" in out -def test_doctor_says_up_to_date(capsys, _isolated, monkeypatch, tmp_path): - _latest_cache(monkeypatch, tmp_path, "3.41.0") +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_flags_blocked_auto_upgrade(capsys, _isolated, monkeypatch): +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.delenv("CLAUDE_STATUSBAR_NO_UPDATE", raising=False) 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_reports_auto_upgrade_off_by_env(capsys, _isolated, monkeypatch): +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 From a9ce8ab832e475f610111f56d42822dd3de011bc Mon Sep 17 00:00:00 2001 From: bpavlina Date: Sun, 4 Oct 2026 15:49:39 -0400 Subject: [PATCH 3/3] test(doctor): assert the auto-upgrade line itself, not any " on" --- tests/test_doctor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9726c07..9a44a40 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -205,7 +205,7 @@ def test_doctor_flags_newer_version_available(capsys, _isolated, _updates): doctor.run() out = capsys.readouterr().out assert "3.44.0 available — run: cs upgrade" in out - assert "auto-upgrade" in out and " on" 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):