diff --git a/CHANGELOG.md b/CHANGELOG.md index 928bc74b9b..517cc53f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## [Unreleased] + +### Added + +- feat(cli): warn on launch when a newer spec-kit release is available; cached for 24h and suppressed with `SPECIFY_SKIP_UPDATE_CHECK=1`, non-interactive shells, or `CI=1` (#1320) + ## [0.6.2] - 2026-04-13 ### Changed diff --git a/docs/installation.md b/docs/installation.md index 5d560b6e33..a00aa30c7e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -77,6 +77,16 @@ After initialization, you should see the following commands available in your AI The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts. +### Update Notifications + +On each launch, `specify` checks once per 24 hours whether a newer release is available on GitHub and prints an upgrade hint if so. The check is silent when: + +- `SPECIFY_SKIP_UPDATE_CHECK=1` (or `true`/`yes`/`on`) is set +- stdout is not a TTY (piped output, redirected to a file, etc.) +- the `CI` environment variable is set + +Network failures and rate-limit responses are swallowed — the check never blocks the command you ran. + ## Troubleshooting ### Enterprise / Air-Gapped Installation diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0bbf42ad5a..ab9799ff71 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -327,6 +327,10 @@ def callback(ctx: typer.Context): show_banner() console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) console.print() + # Addresses #1320: nudge users running outdated CLIs. The `version` subcommand + # already surfaces the version, so skip there to avoid double-printing. + if ctx.invoked_subcommand not in (None, "version"): + _check_for_updates() def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]: """Run a shell command and optionally capture output.""" @@ -1586,6 +1590,143 @@ def get_speckit_version() -> str: return "unknown" +# ===== Update check (addresses #1320) ===== +# +# Cached once per 24h in the platform user-cache dir. Triggered from the top-level +# callback. Never blocks the user — every failure path swallows the exception. + +_UPDATE_CHECK_URL = "https://api.github.com/repos/github/spec-kit/releases/latest" +_UPDATE_CHECK_CACHE_TTL_SECONDS = 24 * 60 * 60 +_UPDATE_CHECK_TIMEOUT_SECONDS = 2.0 + + +def _parse_version_tuple(version: str) -> tuple[int, ...] | None: + """Parse `v0.6.2` / `0.6.2` / `0.6.2.dev0` → tuple of ints. Returns None if unparseable.""" + if not version: + return None + s = version.strip().lstrip("vV") + # Drop PEP 440 pre/post/dev/local segments; we only compare release numbers. + for sep in ("-", "+", "a", "b", "rc", ".dev", ".post"): + idx = s.find(sep) + if idx != -1: + s = s[:idx] + parts: list[int] = [] + for piece in s.split("."): + if not piece.isdigit(): + return None + parts.append(int(piece)) + return tuple(parts) if parts else None + + +def _update_check_cache_path() -> Path | None: + try: + from platformdirs import user_cache_dir + return Path(user_cache_dir("specify-cli")) / "version_check.json" + except Exception: + return None + + +def _read_update_check_cache(path: Path) -> dict | None: + try: + import time + if not path.exists(): + return None + data = json.loads(path.read_text()) + checked_at = float(data.get("checked_at", 0)) + if time.time() - checked_at > _UPDATE_CHECK_CACHE_TTL_SECONDS: + return None + return data + except Exception: + return None + + +def _write_update_check_cache(path: Path, latest: str) -> None: + try: + import time + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"checked_at": time.time(), "latest": latest})) + except Exception: + # Cache write failures are non-fatal. + pass + + +def _fetch_latest_version() -> str | None: + """Query GitHub for the latest release tag. Returns None on any failure.""" + try: + import urllib.request + req = urllib.request.Request( + _UPDATE_CHECK_URL, + headers={"Accept": "application/vnd.github+json", "User-Agent": "specify-cli"}, + ) + with urllib.request.urlopen(req, timeout=_UPDATE_CHECK_TIMEOUT_SECONDS) as resp: + payload = json.loads(resp.read().decode("utf-8")) + tag = payload.get("tag_name") + return tag if isinstance(tag, str) and tag else None + except Exception: + return None + + +def _should_skip_update_check() -> bool: + if os.environ.get("SPECIFY_SKIP_UPDATE_CHECK", "").strip().lower() in ("1", "true", "yes", "on"): + return True + if os.environ.get("CI"): + return True + try: + if not sys.stdout.isatty(): + return True + except Exception: + return True + return False + + +def _check_for_updates() -> None: + """Print a one-line upgrade hint when a newer spec-kit release is available. + + Fully best-effort — any error (offline, rate-limited, parse failure) is + swallowed so the command the user actually invoked is never blocked. + """ + if _should_skip_update_check(): + return + try: + current_str = get_speckit_version() + current = _parse_version_tuple(current_str) + if current is None: + return + + cache_path = _update_check_cache_path() + latest_str: str | None = None + if cache_path is not None: + cached = _read_update_check_cache(cache_path) + if cached: + latest_str = cached.get("latest") + + if latest_str is None: + latest_str = _fetch_latest_version() + if latest_str and cache_path is not None: + _write_update_check_cache(cache_path, latest_str) + + latest = _parse_version_tuple(latest_str) if latest_str else None + if latest is None or latest <= current: + return + + current_display = current_str.lstrip("vV") + latest_display = latest_str.lstrip("vV") + console.print( + f"[yellow]⚠ A new spec-kit version is available: " + f"v{latest_display} (you have v{current_display})[/yellow]" + ) + console.print( + f"[dim] Upgrade: uv tool install specify-cli --force " + f"--from git+https://github.com/github/spec-kit.git@v{latest_display}[/dim]" + ) + console.print( + "[dim] (set SPECIFY_SKIP_UPDATE_CHECK=1 to silence this check)[/dim]" + ) + except Exception: + # Update check must never surface an error to the user. + return + + # ===== Integration Commands ===== integration_app = typer.Typer( diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000000..adf8a156b7 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,221 @@ +"""Tests for the update-check helper in specify_cli.__init__. + +Covers issue https://github.com/github/spec-kit/issues/1320 — the CLI should +nudge users who are running outdated releases toward an upgrade, without +blocking any command when offline or rate-limited. +""" + +import json +import time +import urllib.error +from io import StringIO +from unittest.mock import patch + +import pytest + +from specify_cli import ( + _check_for_updates, + _fetch_latest_version, + _parse_version_tuple, + _read_update_check_cache, + _write_update_check_cache, +) + + +class TestParseVersionTuple: + @pytest.mark.parametrize( + "raw,expected", + [ + ("v0.6.2", (0, 6, 2)), + ("0.6.2", (0, 6, 2)), + ("V1.2.3.4", (1, 2, 3, 4)), + ("0.6.2.dev0", (0, 6, 2)), + ("1.0.0-rc.1", (1, 0, 0)), + ("1.0.0+meta", (1, 0, 0)), + ], + ) + def test_parses_common_version_strings(self, raw, expected): + assert _parse_version_tuple(raw) == expected + + @pytest.mark.parametrize("raw", ["", "abc", "v.", None]) + def test_returns_none_on_unparseable(self, raw): + assert _parse_version_tuple(raw) is None + + def test_ordering_matches_semver_intuition(self): + assert _parse_version_tuple("v0.6.2") < _parse_version_tuple("v0.6.3") + assert _parse_version_tuple("v0.6.2") < _parse_version_tuple("v0.7.0") + assert _parse_version_tuple("v0.6.2") == _parse_version_tuple("0.6.2") + + +class TestCache: + def test_fresh_cache_is_returned(self, tmp_path): + cache_file = tmp_path / "version_check.json" + cache_file.write_text(json.dumps({"checked_at": time.time(), "latest": "v0.7.0"})) + data = _read_update_check_cache(cache_file) + assert data is not None + assert data["latest"] == "v0.7.0" + + def test_stale_cache_is_ignored(self, tmp_path): + cache_file = tmp_path / "version_check.json" + very_old = time.time() - (48 * 60 * 60) + cache_file.write_text(json.dumps({"checked_at": very_old, "latest": "v0.5.0"})) + assert _read_update_check_cache(cache_file) is None + + def test_missing_cache_returns_none(self, tmp_path): + assert _read_update_check_cache(tmp_path / "missing.json") is None + + def test_corrupt_cache_returns_none(self, tmp_path): + cache_file = tmp_path / "version_check.json" + cache_file.write_text("{not json") + assert _read_update_check_cache(cache_file) is None + + def test_write_round_trips(self, tmp_path): + cache_file = tmp_path / "nested" / "version_check.json" + _write_update_check_cache(cache_file, "v0.9.9") + assert cache_file.exists() + data = json.loads(cache_file.read_text()) + assert data["latest"] == "v0.9.9" + assert float(data["checked_at"]) <= time.time() + + +class TestFetchLatestVersion: + def test_returns_tag_on_success(self): + payload = json.dumps({"tag_name": "v0.6.3"}).encode("utf-8") + + class FakeResp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return payload + + with patch("urllib.request.urlopen", return_value=FakeResp()): + assert _fetch_latest_version() == "v0.6.3" + + def test_returns_none_on_network_error(self): + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("offline")): + assert _fetch_latest_version() is None + + def test_returns_none_on_malformed_json(self): + class FakeResp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return b"not json" + + with patch("urllib.request.urlopen", return_value=FakeResp()): + assert _fetch_latest_version() is None + + def test_returns_none_when_tag_missing(self): + payload = json.dumps({"name": "unnamed"}).encode("utf-8") + + class FakeResp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return payload + + with patch("urllib.request.urlopen", return_value=FakeResp()): + assert _fetch_latest_version() is None + + +class TestCheckForUpdates: + """End-to-end-ish checks on `_check_for_updates` with skip conditions patched off.""" + + def _run_and_capture(self, monkeypatch) -> str: + """Force the skip-guard off so the helper runs, then capture console output.""" + # Guard returns False → helper proceeds. + monkeypatch.setattr("specify_cli._should_skip_update_check", lambda: False) + buf = StringIO() + import specify_cli + from rich.console import Console + captured = Console(file=buf, force_terminal=False, width=200) + monkeypatch.setattr(specify_cli, "console", captured) + _check_for_updates() + return buf.getvalue() + + def test_prints_warning_when_newer_release_available(self, monkeypatch, tmp_path): + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.2") + monkeypatch.setattr( + "specify_cli._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + monkeypatch.setattr("specify_cli._fetch_latest_version", lambda: "v0.7.0") + + out = self._run_and_capture(monkeypatch) + + assert "new spec-kit version is available" in out + assert "v0.7.0" in out + assert "v0.6.2" in out + assert "uv tool install specify-cli" in out + + def test_no_output_when_up_to_date(self, monkeypatch, tmp_path): + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.7.0") + monkeypatch.setattr( + "specify_cli._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + monkeypatch.setattr("specify_cli._fetch_latest_version", lambda: "v0.7.0") + + out = self._run_and_capture(monkeypatch) + + assert out == "" + + def test_uses_cache_when_fresh(self, monkeypatch, tmp_path): + cache_file = tmp_path / "vc.json" + cache_file.write_text(json.dumps({"checked_at": time.time(), "latest": "v0.7.0"})) + + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.2") + monkeypatch.setattr("specify_cli._update_check_cache_path", lambda: cache_file) + + call_counter = {"n": 0} + + def _should_not_be_called() -> str | None: + call_counter["n"] += 1 + return None + + monkeypatch.setattr("specify_cli._fetch_latest_version", _should_not_be_called) + + out = self._run_and_capture(monkeypatch) + + assert call_counter["n"] == 0 + assert "v0.7.0" in out + + def test_network_failure_is_silent(self, monkeypatch, tmp_path): + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.2") + monkeypatch.setattr( + "specify_cli._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + monkeypatch.setattr("specify_cli._fetch_latest_version", lambda: None) + + out = self._run_and_capture(monkeypatch) + + assert out == "" + + def test_skip_env_var_short_circuits(self, monkeypatch, tmp_path): + monkeypatch.setenv("SPECIFY_SKIP_UPDATE_CHECK", "1") + + fetched = {"called": False} + + def _fetch(): + fetched["called"] = True + return "v99.0.0" + + monkeypatch.setattr("specify_cli._fetch_latest_version", _fetch) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.0.1") + monkeypatch.setattr( + "specify_cli._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + + _check_for_updates() + + assert fetched["called"] is False