"""Version checking and self-update commands for specify_cli. Pure helpers for comparing PEP 440 versions and fetching the latest GitHub release tag. The ``self_app`` Typer sub-command group is co-located here so all version-related logic lives in one place. Dependencies: stdlib + packaging + ._console only (no other internal imports at module level, keeping this layer thin and circular-import-safe). """ from __future__ import annotations import json import urllib.error import typer from packaging.version import InvalidVersion, Version from ._console import console GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" def _get_installed_version() -> str: """Return the installed specify-cli distribution version or 'unknown'. Uses importlib.metadata so the value reflects what was actually installed by pip/uv/pipx — not a value read from pyproject.toml. This is intentional for `specify self check`, which should reason about the installed distribution rather than a source-tree fallback. Callers must treat the sentinel string 'unknown' as an indeterminate value (see FR-020). """ import importlib.metadata metadata_errors = [importlib.metadata.PackageNotFoundError] invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) if invalid_metadata_error is not None: metadata_errors.append(invalid_metadata_error) try: return importlib.metadata.version("specify-cli") except tuple(metadata_errors): return "unknown" def _normalize_tag(tag: str) -> str: """Strip exactly one leading 'v' from a release tag. Returns the rest of the string unchanged. This handles the common 'vX.Y.Z' tag convention in this repo; it MUST NOT strip more aggressively (e.g., two leading 'v's keeps one). """ return tag[1:] if tag.startswith("v") else tag def _is_newer(latest: str, current: str) -> bool: """Return True iff `latest` is strictly greater than `current` under PEP 440. Returns False whenever either side is 'unknown' or fails to parse; this keeps the comparison indeterminate (rather than crashing or falsely recommending a downgrade) on edge inputs. """ if latest == "unknown" or current == "unknown": return False try: return Version(latest) > Version(current) except InvalidVersion: return False def _fetch_latest_release_tag() -> tuple[str | None, str | None]: """Return (tag, failure_category). Exactly one outbound call, 5 s timeout. On success: (tag_name, None). On a documented network/HTTP failure (added in T029/T030): (None, category). On anything else — including a malformed response body — the exception propagates; there is no catch-all (research D-006). """ from .authentication.http import open_url try: with open_url( GITHUB_API_LATEST, timeout=5, extra_headers={"Accept": "application/vnd.github+json"}, ) as resp: payload = json.loads(resp.read().decode("utf-8")) tag = payload.get("tag_name") if not isinstance(tag, str) or not tag: raise ValueError("GitHub API response missing valid tag_name") return tag, None except urllib.error.HTTPError as e: # Order matters: HTTPError is a subclass of URLError. if e.code == 403: return None, ( "rate limited (configure ~/.specify/auth.json with a GitHub token)" ) return None, f"HTTP {e.code}" except (urllib.error.URLError, OSError): return None, "offline or timeout" # ===== Self Commands ===== self_app = typer.Typer( name="self", help="Manage the specify CLI itself (read-only check and reserved upgrade command).", add_completion=False, ) @self_app.command("check") def self_check() -> None: """Check whether a newer specify-cli release is available. Read-only. This command only checks for updates; it does not modify your installation. The reserved (and currently non-destructive) `specify self upgrade` command is the name that a future release will use for actual self-upgrade — its behavior is not implemented in this release and is intentionally out of scope here. See `specify self upgrade --help` for its current status. """ installed = _get_installed_version() tag, failure_reason = _fetch_latest_release_tag() if tag is None: # Graceful-failure path (FR-008). `failure_reason` is one of the # enumerated strings produced by _fetch_latest_release_tag() — it # never contains a URL, headers, response body, or traceback. assert failure_reason is not None console.print(f"Installed: {installed}") console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}") return latest_normalized = _normalize_tag(tag) if installed == "unknown": # FR-020: surface the latest release and the recovery action even # when the local distribution metadata is unavailable. console.print("Current version could not be determined.") console.print(f"Latest release: {latest_normalized}") console.print("\nTo reinstall:") console.print(" uv tool install specify-cli --force \\") console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") return if _is_newer(latest_normalized, installed): console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}") console.print("\nTo upgrade:") console.print(" uv tool install specify-cli --force \\") console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") return # Installed is parseable AND is >= latest → "up to date" (FR-006). # Also reached when the tag is unparseable (InvalidVersion) → _is_newer # returns False, and the up-to-date branch is the safer default per # FR-004 / test T016. console.print(f"[green]Up to date:[/green] {installed}") @self_app.command("upgrade") def self_upgrade() -> None: """Reserved command surface for self-upgrade; not implemented in this release. This command is a documented non-destructive stub in this release: it performs no outbound network request, no install-method detection, and invokes no installer. It prints a three-line guidance message and exits 0. Actual self-upgrade is planned as follow-up work. Use `specify self check` today to see whether a newer release is available and to get a copy-pasteable reinstall command. """ console.print("specify self upgrade is not implemented yet.") console.print("Run 'specify self check' to see whether a newer release is available.") console.print("Actual self-upgrade is planned as follow-up work.")