Skip to content

Commit 5185ebf

Browse files
committed
refactor(cli): move update-check into _version.py, reuse upstream primitives
Following PR #2550 which extracted version handling into _version.py, move the opt-in startup update-check helpers there too and replace our duplicates with upstream's: - _get_installed_version (was: local get_speckit_version with pyproject fallback) - _fetch_latest_release_tag (was: local _fetch_latest_version; gains auth via open_url) - _is_newer (was: local _parse_version_tuple; proper PEP 440 via packaging.Version) Behavior preserved: same opt-in env var, same 24h TTL, same negative caching, same CI/TTY skip guards. Cache file format unchanged.
1 parent 6045c92 commit 5185ebf

4 files changed

Lines changed: 166 additions & 117 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
- feat(cli): opt-in launch warning when a newer spec-kit release is available; enable with `SPECIFY_ENABLE_UPDATE_CHECK=1` (or `true`/`yes`/`on`), cached for 24h, and suppressed in non-interactive shells and `CI=1` (#1320)
1010
- fix(cli): cache update-check failures so transient outages don't trigger a network call on every CLI invocation (#1320)
11+
- refactor(cli): move update-check helpers into `_version.py` and reuse the existing `_get_installed_version` / `_fetch_latest_release_tag` / `_is_newer` primitives from #2550 (#1320)
1112

1213
## [0.8.11] - 2026-05-15
1314

src/specify_cli/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
self_app as _self_app,
9898
self_check as self_check,
9999
self_upgrade as self_upgrade,
100+
_check_for_updates,
100101
)
101102

102103
def _build_agent_config() -> dict[str, dict[str, Any]]:
@@ -200,6 +201,16 @@ def callback(
200201
show_banner()
201202
console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]"))
202203
console.print()
204+
# Addresses #1320: nudge users running outdated CLIs. The `version` subcommand
205+
# already surfaces the version, so skip there to avoid double-printing; also
206+
# skip help invocations. Runs on bare `specify` too so the banner launch
207+
# benefits from the nudge when the user has opted in.
208+
if (
209+
ctx.invoked_subcommand != "version"
210+
and "--help" not in sys.argv
211+
and "-h" not in sys.argv
212+
):
213+
_check_for_updates()
203214

204215
def _refresh_shared_templates(
205216
project_path: Path,

src/specify_cli/_version.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,119 @@ def self_upgrade() -> None:
171171
console.print("specify self upgrade is not implemented yet.")
172172
console.print("Run 'specify self check' to see whether a newer release is available.")
173173
console.print("Actual self-upgrade is planned as follow-up work.")
174+
175+
176+
# ===== Opt-in startup update check (addresses #1320) =====
177+
#
178+
# Silent companion to `specify self check`: when SPECIFY_ENABLE_UPDATE_CHECK=1
179+
# is set in an interactive non-CI shell, the top-level Typer callback prints a
180+
# one-line upgrade hint if a newer release is available. Result is cached for
181+
# 24h in the platform user-cache dir; cache misses are written even on fetch
182+
# failure (`latest=null`) so a transient outage doesn't trigger a network call
183+
# on every CLI invocation. Best-effort: every error path swallows the exception
184+
# so the helper never blocks the command the user actually invoked.
185+
186+
import os
187+
import sys
188+
import time
189+
from pathlib import Path
190+
191+
_UPDATE_CHECK_CACHE_TTL_SECONDS = 24 * 60 * 60
192+
193+
194+
def _update_check_cache_path() -> Path | None:
195+
try:
196+
from platformdirs import user_cache_dir
197+
return Path(user_cache_dir("specify-cli")) / "version_check.json"
198+
except Exception:
199+
return None
200+
201+
202+
def _read_update_check_cache(path: Path) -> dict | None:
203+
try:
204+
if not path.exists():
205+
return None
206+
data = json.loads(path.read_text(encoding="utf-8"))
207+
checked_at = float(data.get("checked_at", 0))
208+
if time.time() - checked_at > _UPDATE_CHECK_CACHE_TTL_SECONDS:
209+
return None
210+
return data
211+
except Exception:
212+
return None
213+
214+
215+
def _write_update_check_cache(path: Path, latest: str | None) -> None:
216+
try:
217+
path.parent.mkdir(parents=True, exist_ok=True)
218+
path.write_text(
219+
json.dumps({"checked_at": time.time(), "latest": latest}),
220+
encoding="utf-8",
221+
)
222+
except Exception:
223+
# Cache write failures are non-fatal.
224+
pass
225+
226+
227+
def _should_skip_update_check() -> bool:
228+
# Opt-in only: air-gapped / network-constrained environments cannot reach
229+
# GitHub, so the check is off by default.
230+
if os.environ.get("SPECIFY_ENABLE_UPDATE_CHECK", "").strip().lower() not in ("1", "true", "yes", "on"):
231+
return True
232+
# Belt-and-suspenders: even when opted in, suppress in CI and when the
233+
# caller isn't a TTY so we don't dirty machine-readable output.
234+
if os.environ.get("CI"):
235+
return True
236+
try:
237+
if not sys.stdout.isatty():
238+
return True
239+
except Exception:
240+
return True
241+
return False
242+
243+
244+
def _check_for_updates() -> None:
245+
"""Print a one-line upgrade hint when a newer spec-kit release is available.
246+
247+
Fully best-effort — any error (offline, rate-limited, parse failure) is
248+
swallowed so the command the user actually invoked is never blocked.
249+
"""
250+
if _should_skip_update_check():
251+
return
252+
try:
253+
current = _get_installed_version()
254+
if current == "unknown":
255+
return
256+
257+
cache_path = _update_check_cache_path()
258+
cached = _read_update_check_cache(cache_path) if cache_path is not None else None
259+
if cached is not None:
260+
# Fresh cache hit — may be a positive (`latest=v…`) or
261+
# negative (`latest=null`) entry; either way, no fetch.
262+
latest_tag = cached.get("latest")
263+
else:
264+
latest_tag, _reason = _fetch_latest_release_tag()
265+
if cache_path is not None:
266+
# Cache the attempt even on failure so transient outages
267+
# don't trigger a network call on every CLI invocation.
268+
_write_update_check_cache(cache_path, latest_tag)
269+
270+
if not latest_tag:
271+
return
272+
latest_display = _normalize_tag(latest_tag)
273+
if not _is_newer(latest_display, current):
274+
return
275+
276+
console.print(
277+
f"[yellow]⚠ A new spec-kit version is available: "
278+
f"v{latest_display} (you have v{current})[/yellow]"
279+
)
280+
console.print(
281+
f"[dim] Upgrade: uv tool install specify-cli --force "
282+
f"--from git+https://github.com/github/spec-kit.git@v{latest_display}[/dim]"
283+
)
284+
console.print(
285+
"[dim] (unset SPECIFY_ENABLE_UPDATE_CHECK to disable this check)[/dim]"
286+
)
287+
except Exception:
288+
# Update check must never surface an error to the user.
289+
return

0 commit comments

Comments
 (0)