-
Notifications
You must be signed in to change notification settings - Fork 7.8k
feat(cli): warn when a newer spec-kit release is available (#1320) #2212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -366,6 +366,16 @@ def callback( | |
| 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; also | ||
| # skip help invocations. Runs on bare `specify` too so the banner launch | ||
| # benefits from the nudge when the user has opted in. | ||
| if ( | ||
| ctx.invoked_subcommand != "version" | ||
| and "--help" not in sys.argv | ||
| and "-h" not in sys.argv | ||
| ): | ||
| _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.""" | ||
|
|
@@ -1694,6 +1704,157 @@ def get_speckit_version() -> str: | |
| return "unknown" | ||
|
|
||
|
|
||
| # ===== Update check (addresses #1320) ===== | ||
| # | ||
| # Opt-in only (set SPECIFY_ENABLE_UPDATE_CHECK=1). Air-gapped / network-constrained | ||
| # environments never reach GitHub, so the check is off by default. When enabled, | ||
| # it is cached once per 24h in the platform user-cache dir and triggered from the | ||
| # top-level callback. Best-effort: every failure path swallows the exception so | ||
| # the check never fails the command, though cache misses may add a small startup | ||
| # delay (bounded by the fetch timeout) while contacting GitHub. | ||
|
|
||
| _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 | None) -> tuple[int, ...] | None: | ||
| """Parse `v0.6.2` / `0.6.2` / `0.6.2.dev0` → tuple of ints. Returns None if unparseable.""" | ||
| if not isinstance(version, str) or 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(encoding="utf-8")) | ||
| checked_at = float(data.get("checked_at", 0)) | ||
|
mnriem marked this conversation as resolved.
|
||
| 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}), | ||
| encoding="utf-8", | ||
| ) | ||
| 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: | ||
| # Opt-in only: skip unless the user has explicitly enabled the check. | ||
| # Air-gapped / network-constrained environments cannot reach GitHub, so a | ||
| # default-on network call is a non-starter; keeping this off by default | ||
| # also means users never pay the fetch latency unless they asked for it. | ||
| if os.environ.get("SPECIFY_ENABLE_UPDATE_CHECK", "").strip().lower() not in ("1", "true", "yes", "on"): | ||
| return True | ||
| # Belt-and-suspenders: even when opted in, suppress in CI and when the | ||
|
Comment on lines
+1790
to
+1797
|
||
| # caller isn't a TTY (piped output, redirected logs, etc.) so we don't | ||
| # dirty machine-readable output with a human-facing warning. | ||
| 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] (unset SPECIFY_ENABLE_UPDATE_CHECK to disable this check)[/dim]" | ||
| ) | ||
| except Exception: | ||
| # Update check must never surface an error to the user. | ||
| return | ||
|
|
||
|
|
||
| # ===== Integration Commands ===== | ||
|
|
||
| integration_app = typer.Typer( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.