Skip to content

Commit c5c2013

Browse files
authored
feat(cli): add specify self check and self upgrade stub (#2316)
* feat(cli): add specify self check and self upgrade stub (#2282) Introduce a new `specify self` Typer sub-app with two subcommands. `specify self check` performs a read-only lookup against the GitHub Releases API, compares the installed version to the latest tag with PEP 440 semantics, and prints one of four verdicts (newer-available, up-to-date, indeterminate, graceful-failure). When a newer stable release is available, the output includes a copy-pasteable `uv tool install --force --from git+...@<tag>` reinstall command. `GH_TOKEN` / `GITHUB_TOKEN` is attached as a bearer credential when set so users behind shared IPs escape the anonymous 60/hour rate limit. `specify self upgrade` is a documented non-destructive stub in this release: three-line guidance output, exit 0, no outbound call, no install-method detection. The real destructive implementation is planned as follow-up work. Failure categorization is a fixed three-entry enum (offline or timeout, rate limited, HTTP <code>). Anything outside those three categories propagates as a non-zero exit so bugs surface instead of being silently swallowed. No machine-readable output, no retries, no caching in this release — see issue #2282 discussion. Tests mock `urllib.request.urlopen`; the suite performs zero real network calls. Full regression suite: 1586 passed. * fix(cli): disable Rich highlight for deterministic output Rich's default `highlight=True` applies ANSI color to detected patterns (integers, version strings, paths) whenever stdout is deemed a TTY. This caused intermittent failures in existing pytest assertions in tests/test_cli_version.py and tests/test_extensions.py::TestExtensionRemoveCLI that compare plain-text output without passing through `strip_ansi()`. Setting `Console(highlight=False)` globally makes all CLI output deterministic and fixes the flake without modifying the affected tests. The numeric cyan highlighting was not a documented part of the CLI visual contract. * fix: address copilot review feedback * fix: tighten self-check token handling * fix: align self-check helpers and script metadata * fix: harden self-check version handling * fix: guard self-check failure rendering
1 parent 58f7a43 commit c5c2013

2 files changed

Lines changed: 538 additions & 17 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 167 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
# "platformdirs",
88
# "readchar",
99
# "json5",
10+
# "pyyaml",
11+
# "packaging",
1012
# ]
1113
# ///
1214
"""
@@ -34,8 +36,12 @@
3436
import json5
3537
import stat
3638
import shlex
39+
import urllib.error
40+
import urllib.request
3741
import yaml
3842
from pathlib import Path
43+
44+
from packaging.version import InvalidVersion, Version
3945
from typing import Any, Optional
4046

4147
import typer
@@ -51,6 +57,8 @@
5157
# For cross-platform keyboard input
5258
import readchar
5359

60+
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
61+
5462
def _build_agent_config() -> dict[str, dict[str, Any]]:
5563
"""Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
5664
from .integrations import INTEGRATION_REGISTRY
@@ -318,7 +326,7 @@ def run_selection_loop():
318326

319327
return selected_key
320328

321-
console = Console()
329+
console = Console(highlight=False)
322330

323331
class BannerGroup(TyperGroup):
324332
"""Custom group that shows banner before help."""
@@ -1599,25 +1607,10 @@ def check():
15991607
def version():
16001608
"""Display version and system information."""
16011609
import platform
1602-
import importlib.metadata
16031610

16041611
show_banner()
16051612

1606-
# Get CLI version from package metadata
1607-
cli_version = "unknown"
1608-
try:
1609-
cli_version = importlib.metadata.version("specify-cli")
1610-
except Exception:
1611-
# Fallback: try reading from pyproject.toml if running from source
1612-
try:
1613-
import tomllib
1614-
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
1615-
if pyproject_path.exists():
1616-
with open(pyproject_path, "rb") as f:
1617-
data = tomllib.load(f)
1618-
cli_version = data.get("project", {}).get("version", "unknown")
1619-
except Exception:
1620-
pass
1613+
cli_version = get_speckit_version()
16211614

16221615
info_table = Table(show_header=False, box=None, padding=(0, 2))
16231616
info_table.add_column("Key", style="cyan", justify="right")
@@ -1640,6 +1633,163 @@ def version():
16401633
console.print(panel)
16411634
console.print()
16421635

1636+
def _get_installed_version() -> str:
1637+
"""Return the installed specify-cli distribution version or 'unknown'.
1638+
1639+
Uses importlib.metadata so the value reflects what was actually installed
1640+
by pip/uv/pipx — not a value read from pyproject.toml. This is
1641+
intentional for `specify self check`, which should reason about the
1642+
installed distribution rather than a source-tree fallback. Callers must
1643+
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
1644+
"""
1645+
1646+
import importlib.metadata
1647+
1648+
metadata_errors = [importlib.metadata.PackageNotFoundError]
1649+
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
1650+
if invalid_metadata_error is not None:
1651+
metadata_errors.append(invalid_metadata_error)
1652+
1653+
try:
1654+
return importlib.metadata.version("specify-cli")
1655+
except tuple(metadata_errors):
1656+
return "unknown"
1657+
1658+
def _normalize_tag(tag: str) -> str:
1659+
"""Strip exactly one leading 'v' from a release tag.
1660+
1661+
Returns the rest of the string unchanged. This handles the common
1662+
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
1663+
aggressively (e.g., two leading 'v's keeps one).
1664+
"""
1665+
return tag[1:] if tag.startswith("v") else tag
1666+
1667+
def _is_newer(latest: str, current: str) -> bool:
1668+
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
1669+
1670+
Returns False whenever either side is 'unknown' or fails to parse; this
1671+
keeps the comparison indeterminate (rather than crashing or falsely
1672+
recommending a downgrade) on edge inputs.
1673+
"""
1674+
if latest == "unknown" or current == "unknown":
1675+
return False
1676+
try:
1677+
return Version(latest) > Version(current)
1678+
except InvalidVersion:
1679+
return False
1680+
1681+
1682+
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
1683+
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
1684+
1685+
On success: (tag_name, None).
1686+
On a documented network/HTTP failure (added in T029/T030): (None, category).
1687+
On anything else — including a malformed response body — the exception
1688+
propagates; there is no catch-all (research D-006).
1689+
"""
1690+
req = urllib.request.Request(
1691+
GITHUB_API_LATEST,
1692+
headers={"Accept": "application/vnd.github+json"},
1693+
)
1694+
token = None
1695+
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
1696+
candidate = os.environ.get(env_var)
1697+
if candidate is not None:
1698+
candidate = candidate.strip()
1699+
if candidate:
1700+
token = candidate
1701+
break
1702+
if token:
1703+
req.add_header("Authorization", f"Bearer {token}")
1704+
try:
1705+
with urllib.request.urlopen(req, timeout=5) as resp:
1706+
payload = json.loads(resp.read().decode("utf-8"))
1707+
tag = payload.get("tag_name")
1708+
if not isinstance(tag, str) or not tag:
1709+
raise ValueError("GitHub API response missing valid tag_name")
1710+
return tag, None
1711+
except urllib.error.HTTPError as e:
1712+
# Order matters: HTTPError is a subclass of URLError.
1713+
if e.code == 403:
1714+
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
1715+
return None, f"HTTP {e.code}"
1716+
except (urllib.error.URLError, OSError):
1717+
return None, "offline or timeout"
1718+
1719+
1720+
# ===== Self Commands =====
1721+
self_app = typer.Typer(
1722+
name="self",
1723+
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
1724+
add_completion=False,
1725+
)
1726+
app.add_typer(self_app, name="self")
1727+
1728+
@self_app.command("check")
1729+
def self_check() -> None:
1730+
"""Check whether a newer specify-cli release is available. Read-only.
1731+
1732+
This command only checks for updates; it does not modify your installation.
1733+
The reserved (and currently non-destructive) `specify self upgrade` command
1734+
is the name that a future release will use for actual self-upgrade — its
1735+
behavior is not implemented in this release and is intentionally out of
1736+
scope here. See `specify self upgrade --help` for its current status.
1737+
"""
1738+
1739+
installed = _get_installed_version()
1740+
tag, failure_reason = _fetch_latest_release_tag()
1741+
1742+
if tag is None:
1743+
# Graceful-failure path (FR-008). `failure_reason` is one of the
1744+
# enumerated strings produced by _fetch_latest_release_tag() — it
1745+
# never contains a URL, headers, response body, or traceback.
1746+
assert failure_reason is not None
1747+
console.print(f"Installed: {installed}")
1748+
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
1749+
return
1750+
1751+
latest_normalized = _normalize_tag(tag)
1752+
1753+
if installed == "unknown":
1754+
# FR-020: surface the latest release and the recovery action even
1755+
# when the local distribution metadata is unavailable.
1756+
console.print("Current version could not be determined.")
1757+
console.print(f"Latest release: {latest_normalized}")
1758+
console.print("\nTo reinstall:")
1759+
console.print(" uv tool install specify-cli --force \\")
1760+
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
1761+
return
1762+
1763+
if _is_newer(latest_normalized, installed):
1764+
console.print(f"[green]Update available:[/green] {installed}{latest_normalized}")
1765+
console.print("\nTo upgrade:")
1766+
console.print(" uv tool install specify-cli --force \\")
1767+
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
1768+
return
1769+
1770+
# Installed is parseable AND is >= latest → "up to date" (FR-006).
1771+
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
1772+
# returns False, and the up-to-date branch is the safer default per
1773+
# FR-004 / test T016.
1774+
console.print(f"[green]Up to date:[/green] {installed}")
1775+
1776+
1777+
@self_app.command("upgrade")
1778+
def self_upgrade() -> None:
1779+
"""Reserved command surface for self-upgrade; not implemented in this release.
1780+
1781+
This command is a documented non-destructive stub in this release: it
1782+
performs no outbound network request, no install-method detection, and
1783+
invokes no installer. It prints a three-line guidance message and exits 0.
1784+
Actual self-upgrade is planned as follow-up work.
1785+
1786+
Use `specify self check` today to see whether a newer release is available
1787+
and to get a copy-pasteable reinstall command.
1788+
"""
1789+
console.print("specify self upgrade is not implemented yet.")
1790+
console.print("Run 'specify self check' to see whether a newer release is available.")
1791+
console.print("Actual self-upgrade is planned as follow-up work.")
1792+
16431793

16441794
# ===== Extension Commands =====
16451795

0 commit comments

Comments
 (0)