/simple/ agreement check for tray update discovery
Tray-triggered "Update" can land at a version different from the one the tray advertised. Surfaced live on the
v0.45.2 → v0.45.3 upgrade: the tray's "Update available" badge said 0.45.3, but after install the message read
"Update complete — jacked is now v0.45.2". The user gets a confusing "you clicked install but didn't get what was promised"
experience and is left wondering whether the update actually worked.
The tray's "Update available" indicator must be a truthful promise: when the user clicks it, the target version is guaranteed to be installable. Never show a phantom update.
/simple/ cache propagation (not in our control)/pypi/json but not /simple/ (user explicitly opted for silent behavior during the gap)get_latest_from_simple_index
Probes https://pypi.org/simple/<package>/ using the PEP 691 JSON variant (single Accept header, single small response,
no HTML parsing required), extracts versions from the files[].filename entries, returns the max via the existing
tuple-comparison parser.
def get_latest_from_simple_index(package: str = "claude-jacked", timeout: float = 3.0) -> str | None:
"""Query PEP 691 JSON variant of /simple/ index. Returns max version, or None on failure.
This is THE SAME index uv reads (uv consumes /simple/, not /pypi/json), so a version's
presence here guarantees `uv tool install --refresh` will resolve to it.
"""
try:
url = f"https://pypi.org/simple/{package}/"
req = urllib.request.Request(
url, headers={"Accept": "application/vnd.pypi.simple.v1+json"}
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
versions = set()
for entry in data.get("files", []):
fn = entry.get("filename", "")
# claude_jacked-0.45.3-py3-none-any.whl or claude_jacked-0.45.3.tar.gz
m = _VERSION_FROM_FILENAME.search(fn)
if m:
versions.add(m.group(1))
if not versions:
return None
# Pick max using the existing is_newer tuple-compare parser
return max(versions, key=lambda v: _parse_version_tuple(v))
except Exception:
return None
check_version_cached
Reads BOTH endpoints, treats the lesser as the "installable latest". outdated is true only when the installable
latest is newer than the currently-installed version.
def check_version_cached(current_version: str, force: bool = False) -> dict | None:
# ... existing cache-read shortcut unchanged ...
pypi_latest = get_latest_pypi_version()
simple_latest = get_latest_from_simple_index()
# Conservative: if either probe failed, do not surface an update.
if not pypi_latest or not simple_latest:
installable = current_version
else:
# The lesser of the two is what uv would actually install.
installable = pypi_latest if not is_newer(simple_latest, pypi_latest) else simple_latest
# i.e. min(pypi, simple) by version order
outdated = is_newer(installable, current_version)
cache.update({
"checked_at": now,
"pypi_latest": pypi_latest,
"simple_latest": simple_latest,
"installable_latest": installable,
"current": current_version,
"outdated": outdated,
})
# ... persist, return ...
Old cache schema:
{"latest": "0.45.2", "checked_at": 1747768320}
New schema:
{"pypi_latest": "0.45.3", "simple_latest": "0.45.2", "installable_latest": "0.45.2",
"current": "0.45.2", "outdated": false, "checked_at": 1747768320}
Cache reader returns None when it can't find the new fields (treated as "no cache, must re-fetch"). One free
re-fetch on first run after upgrade; subsequent reads work normally. No explicit migration code needed.
| Scenario | Behavior |
|---|---|
| Both endpoints OK, both agree | outdated = true if newer than current |
Both endpoints OK, /pypi/json ahead of /simple/ | outdated = false (use the lower) |
/simple/ unreachable | outdated = false (conservative) |
/pypi/json unreachable | outdated = false (conservative) |
| Both unreachable | outdated = false |
/simple/ returns empty file list (network ok, but no wheels) | outdated = false (defensive) |
Zero. The tray reads cache["outdated"] via the existing version_text_fn / version_enabled_fn
callbacks. The fact that "outdated" is now derived from two endpoints instead of one is opaque to the tray. No new menu items,
no new visual states.
| Test | Asserts |
|---|---|
test_get_latest_from_simple_parses_pep691 | given a real-shaped /simple/ JSON response, returns the max version |
test_get_latest_from_simple_handles_network_error | returns None on URL error, timeout, JSON decode error |
test_get_latest_from_simple_empty_files_returns_none | defensive — empty files array → None |
test_get_latest_from_simple_picks_max_not_first | multiple versions in files[]; must return the max |
test_outdated_false_when_simple_lags_pypi | regression for v0.45.2→0.45.3 propagation lag |
test_outdated_true_when_both_endpoints_agree | happy path |
test_outdated_false_when_simple_unreachable | conservative behavior on network failure |
test_outdated_false_when_pypi_unreachable | same, other endpoint |
test_old_cache_schema_triggers_refetch | migration — old-format cache returns None / forces re-poll |
| File | Change |
|---|---|
jacked/version_check.py | Add get_latest_from_simple_index + filename regex + tuple parser export; modify check_version_cached to use both endpoints; update cache schema |
tests/unit/test_version_check.py | Add the 9 tests above |
jacked/__init__.py | Bump version to 0.45.4 |
/simple/ catches up (typically minutes later), the button appears normally