"""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 errno import json import math import os import re import shlex import shutil import subprocess import sys import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from enum import Enum from pathlib import Path 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" _RESOLUTION_FAILURE_OFFLINE = "offline or timeout" _RESOLUTION_FAILURE_RATE_LIMITED = ( "rate limited (configure ~/.specify/auth.json with a GitHub token)" ) _RESOLUTION_FAILURE_HTTP_PREFIX = "HTTP " _FAILURE_INSTALLER_MISSING = "installer-missing" _FAILURE_INSTALLER_INVALID = "installer-invalid" _FAILURE_TARGET_TAG_UNPARSEABLE = "target-tag-unparseable" _FAILURE_INSTALLER_TIMEOUT = "installer-timeout" _FAILURE_INSTALLER_FAILED = "installer-failed" _FAILURE_VERIFICATION_MISMATCH = "verification-mismatch" _PRERELEASE_TAG_PATTERN = re.compile( r"^([0-9]+\.[0-9]+\.[0-9]+)[-.]?(alpha|beta|a|b|rc)[-.]?([0-9]+)(.*)$", flags=re.IGNORECASE, ) _TIER3_REGISTRY_TIMEOUT_SECS = 5 _VERIFY_TIMEOUT_SECS = 10 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: """Normalize common git release-tag spellings into PEP 440 text. Any trailing text after a recognized prerelease marker is preserved; callers still validate the returned value with `packaging.version.Version`. """ normalized = tag[1:] if tag.startswith("v") else tag prerelease_match = _PRERELEASE_TAG_PATTERN.match(normalized) if prerelease_match is None: return normalized base, label, number, rest = prerelease_match.groups() pep440_label = {"alpha": "a", "beta": "b"}.get(label.lower(), label.lower()) return f"{base}{pep440_label}{number}{rest}" 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. # 403 (primary rate limit / abuse detection) and 429 (Too Many Requests / # secondary rate limit) both get the actionable "configure a token" hint; # every other status is surfaced verbatim as "HTTP {code}". if e.code in (403, 429): return None, _RESOLUTION_FAILURE_RATE_LIMITED return None, f"{_RESOLUTION_FAILURE_HTTP_PREFIX}{e.code}" except (urllib.error.URLError, OSError): return None, _RESOLUTION_FAILURE_OFFLINE def _parse_version_text(value: str) -> Version | None: """Parse version-like text after tag normalization, or return None.""" normalized = _normalize_tag(value) try: return Version(normalized) except InvalidVersion: return None def _canonicalize_version_text(value: str) -> str: """Normalize version-like text for equality checks when parseable.""" parsed = _parse_version_text(value) return str(parsed) if parsed is not None else _normalize_tag(value) def _stable_release_tag_for_version(version_text: str) -> str | None: """Return `vX.Y.Z` only for exact stable release versions.""" parsed = _parse_version_text(version_text) if parsed is None: return None if parsed.pre or parsed.post or parsed.dev or parsed.local: return None release = parsed.release if len(release) != 3: return None return f"v{release[0]}.{release[1]}.{release[2]}" def _render_argv(argv: list[str]) -> str: """Render argv as POSIX shell text, or cmd.exe-style text on Windows.""" return subprocess.list2cmdline(argv) if os.name == "nt" else shlex.join(argv) _INSTALLER_PATH_PREFIXES: dict[str, list[str]] = { "uv-tool": [ "~/.local/share/uv/tools/specify-cli/", "%LOCALAPPDATA%\\uv\\tools\\specify-cli\\", ], "pipx": [ "~/.local/pipx/venvs/specify-cli/", "%LOCALAPPDATA%\\pipx\\venvs\\specify-cli\\", ], "uvx-ephemeral": [ "~/.cache/uv/archive-v0/", "%LOCALAPPDATA%\\uv\\cache\\archive-v0\\", ], } _RESOLUTION_FAILURE_CATEGORIES: frozenset[str] = frozenset( { _RESOLUTION_FAILURE_OFFLINE, _RESOLUTION_FAILURE_RATE_LIMITED, } ) class _InstallMethod(str, Enum): """Install-method classification for `specify self upgrade`.""" UV_TOOL = "uv-tool" PIPX = "pipx" UVX_EPHEMERAL = "uvx-ephemeral" SOURCE_CHECKOUT = "source-checkout" UNSUPPORTED = "unsupported" class _InstallerResultKind(str, Enum): """Installer subprocess outcome, separated from real process exit codes.""" EXITED = "exited" MISSING = "missing" INVALID = "invalid" TIMEOUT = "timeout" @dataclass(frozen=True) class _InstallerResult: """Normalized installer result returned by _run_installer().""" kind: _InstallerResultKind returncode: int | None = None @dataclass(frozen=True) class _UpgradePlan: """Resolved upgrade decision shared by preview and apply paths.""" method: _InstallMethod current_version: str target_tag: str | None installer_argv: list[str] | None preview_summary: str pre_upgrade_snapshot: str @dataclass(frozen=True) class _DetectionSignals: """Diagnostic record of which detection tier fired.""" sys_argv0: str matched_tier: int | None matched_prefix: str | None editable_marker_seen: bool installer_registries_consulted: tuple[str, ...] resolved_method: _InstallMethod _GITHUB_CREDENTIAL_SUFFIXES = ( "_TOKEN", "_SECRET", "_KEY", "_PAT", "_PASSWORD", "_CREDENTIALS", ) _UNRESOLVED_ENV_VAR_RE = re.compile(r"\$\w+|\$\{\w+\}|%[^%]+%") def _is_github_credential_env_key(key: str) -> bool: """Return whether an env key should be scrubbed as a GitHub credential. Matching contract (case-insensitive): - Any key with a ``GH_`` or ``GITHUB_`` prefix is scrubbed unconditionally. This is deliberately broad: it catches credential-adjacent names that lack a recognized suffix (e.g. ``GH_TOKEN_FILE``, ``GITHUB_TOKEN_PATH``) at the cost of also dropping benign context vars (``GH_HOST``, ``GITHUB_REPOSITORY``) the installer subprocess does not consume. - Otherwise the key is scrubbed only when it contains an underscore-delimited ``_GITHUB_`` segment *and* ends with a credential suffix (``_TOKEN``/``_SECRET``/``_KEY``/``_PAT``/``_PASSWORD``/``_CREDENTIALS``) — e.g. ``HOMEBREW_GITHUB_API_TOKEN``. Un-delimited variants such as a hypothetical ``GITHUBTOKEN`` are not matched by this branch; no real tool sets such a name. Only these recognized shapes are scrubbed — this is not blanket coverage of every conceivable secret name. """ upper = key.upper() if upper.startswith(("GH_", "GITHUB_")): return True return "_GITHUB_" in upper and upper.endswith(_GITHUB_CREDENTIAL_SUFFIXES) def _scrubbed_env() -> dict[str, str]: """Return a copy of `os.environ` without known GitHub credential keys.""" return { k: v for k, v in os.environ.items() if not _is_github_credential_env_key(k) } # vMAJOR.MINOR.PATCH, then an optional dev/prerelease segment, then an # optional build-metadata segment. The two trailing segments are independent # so they can compose (e.g. v1.0.0-rc1+build.42) — matching PEP 440 /semver, # which the Version() check below then enforces canonically. _TAG_REGEX = re.compile( r"^v[0-9]+\.[0-9]+\.[0-9]+" r"(?:(?:\.?dev[0-9]+)|(?:[-.]?(?:a|b|rc|alpha|beta)[-.]?[0-9]+))?" r"(?:\+[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)*)?$" ) _INVALID_TAG_MESSAGE = "Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]" def _validate_tag(tag: str) -> str: """Validate a user-supplied --tag value. Accepts vX.Y.Z plus an optional dev or alpha/beta/rc suffix and/or an optional build-metadata suffix, which may combine (for example: v1.0.0-rc1, v0.8.0.dev0, v0.8.0+build.42, v1.0.0-rc1+build.42). An uppercase ``V`` prefix is accepted and folded to the canonical lowercase ``v``. Rejects everything else, including bare 'latest', hash refs, branch names, and numeric versions without the 'v' prefix. """ tag = tag.strip() if not tag: raise typer.BadParameter(_INVALID_TAG_MESSAGE) # Fold a leading uppercase `V` (a common paste) to the canonical lowercase # `v`. The remainder stays case-sensitive on purpose: the validated tag is # used verbatim as a git ref, which is case-sensitive on GitHub, so we must # not rewrite label/build-metadata casing into a ref that may not exist. if tag[:1] == "V": tag = "v" + tag[1:] if not _TAG_REGEX.match(tag): raise typer.BadParameter(_INVALID_TAG_MESSAGE) try: Version(_normalize_tag(tag)) except InvalidVersion as exc: raise typer.BadParameter(_INVALID_TAG_MESSAGE) from exc return tag def _expand_prefix(prefix: str) -> Path | None: """Expand `~` or `%LOCALAPPDATA%`-style tokens in a path prefix.""" expanded = os.path.expanduser(prefix) if "%LOCALAPPDATA%" in expanded: local_app_data = os.environ.get("LOCALAPPDATA") if not local_app_data: return None expanded = expanded.replace("%LOCALAPPDATA%", local_app_data) expanded = os.path.expandvars(expanded) if _UNRESOLVED_ENV_VAR_RE.search(expanded): return None try: expanded_path = Path(expanded) return expanded_path.resolve() if expanded_path.is_absolute() else expanded_path except OSError: return None def _path_is_within_prefix(path: Path, prefix: Path) -> bool: """Return whether absolute `path` is under absolute `prefix`.""" if not path.is_absolute() or not prefix.is_absolute(): return False try: common = os.path.commonpath( [os.path.normcase(str(path)), os.path.normcase(str(prefix))] ) except ValueError: return False return common == os.path.normcase(str(prefix)) def _resolve_path_or_original(path: Path) -> Path: try: return path.resolve() except OSError: return path def _resolved_argv0_path(argv0: str | None = None) -> Path: """Resolve the running entrypoint path, consulting PATH for bare commands.""" raw = argv0 or sys.argv[0] candidate = Path(raw) if candidate.is_absolute(): return _resolve_path_or_original(candidate) if candidate.exists(): return _resolve_path_or_original(candidate) lookup_names = [raw] if len(candidate.parts) > 1: lookup_names.append(candidate.name) if "specify" not in lookup_names: lookup_names.append("specify") for lookup_name in lookup_names: resolved = shutil.which(lookup_name) if resolved: return _resolve_path_or_original(Path(resolved)) return candidate def _looks_like_specify_entrypoint(path: Path) -> bool: """Return whether a path looks like the `specify` CLI entrypoint.""" return path.name.lower() in {"specify", "specify.exe", "specify-cli", "specify-cli.exe"} def _tier3_registry_lookup_allowed(argv0_path: Path) -> bool: """Return whether tier-3 registry reconciliation is safe for this entrypoint.""" return argv0_path.is_absolute() and not argv0_path.exists() def _uv_tool_list_contains_specify_cli(stdout: str) -> bool: """Return whether `uv tool list` output includes an exact `specify-cli` entry.""" for raw_line in stdout.splitlines(): line = raw_line.strip() if not line: continue first_token = line.split(None, 1)[0] if first_token == "specify-cli": return True return False def _git_ancestor(path: Path) -> Path | None: """Return the closest ancestor that looks like a git worktree root.""" for ancestor in [path, *path.parents]: if (ancestor / ".git").exists(): return ancestor return None def _editable_direct_url_path() -> Path | None: """Return the editable checkout root recorded in direct_url.json, if any.""" import importlib.metadata as _md metadata_errors = [_md.PackageNotFoundError] invalid_metadata_error = getattr(_md, "InvalidMetadataError", None) if invalid_metadata_error is not None: metadata_errors.append(invalid_metadata_error) try: dist = _md.distribution("specify-cli") except tuple(metadata_errors): return None payload = dist.read_text("direct_url.json") if not payload: return None try: data = json.loads(payload) except (TypeError, ValueError): return None if not data.get("dir_info", {}).get("editable"): return None url = data.get("url") if not isinstance(url, str): return None parsed = urllib.parse.urlsplit(url) if parsed.scheme != "file": return None url_path = urllib.request.url2pathname(urllib.parse.unquote(parsed.path)) if parsed.netloc and parsed.netloc not in {"", "localhost"}: url_path = f"//{parsed.netloc}{url_path}" try: return Path(url_path).resolve() except OSError: return None def _editable_marker_seen() -> bool: """Return whether the installed distribution is explicitly marked editable.""" editable_root = _editable_direct_url_path() return editable_root is not None and _git_ancestor(editable_root) is not None def _detect_install_method( argv0: str | None = None, include_signals: bool = False, ) -> "_InstallMethod | tuple[_InstallMethod, _DetectionSignals]": """Classify the current runtime into exactly one _InstallMethod. Detection order: 1. `sys.argv[0]` path prefix match against `_INSTALLER_PATH_PREFIXES` 2. editable-install marker 3. installer registry reconciliation (`uv tool list` / `pipx list`) When `include_signals=True`, also return `_DetectionSignals`. """ argv0_path = _resolved_argv0_path(argv0) argv0_resolved = str(argv0_path) # --- Tier 1: path prefix match --- for method_str, prefixes in _INSTALLER_PATH_PREFIXES.items(): for prefix in prefixes: expanded = _expand_prefix(prefix) if expanded is None: continue if _path_is_within_prefix(argv0_path, expanded): method = _InstallMethod(method_str) if include_signals: return method, _DetectionSignals( sys_argv0=argv0_resolved, matched_tier=1, matched_prefix=prefix, editable_marker_seen=False, installer_registries_consulted=(), resolved_method=method, ) return method # --- Tier 2: editable install marker --- if _editable_marker_seen(): method = _InstallMethod.SOURCE_CHECKOUT if include_signals: return method, _DetectionSignals( sys_argv0=argv0_resolved, matched_tier=2, matched_prefix=None, editable_marker_seen=True, installer_registries_consulted=(), resolved_method=method, ) return method # --- Tier 3: PATH + registry reconciliation --- consulted: list[str] = [] if _tier3_registry_lookup_allowed(argv0_path): uv_tool_match = False uv_bin = shutil.which("uv") if uv_bin is not None: consulted.append("uv tool list") try: result = subprocess.run( [uv_bin, "tool", "list"], capture_output=True, text=True, timeout=_TIER3_REGISTRY_TIMEOUT_SECS, env=_scrubbed_env(), check=False, ) if result.returncode == 0 and _uv_tool_list_contains_specify_cli( result.stdout or "" ): uv_tool_match = True except (subprocess.TimeoutExpired, OSError, ValueError): pass pipx_match = False pipx_bin = shutil.which("pipx") if pipx_bin is not None: consulted.append("pipx list --json") try: result = subprocess.run( [pipx_bin, "list", "--json"], capture_output=True, text=True, timeout=_TIER3_REGISTRY_TIMEOUT_SECS, env=_scrubbed_env(), check=False, ) if result.returncode == 0: payload = json.loads(result.stdout or "") venvs = payload.get("venvs") if isinstance(payload, dict) else None if isinstance(venvs, dict) and "specify-cli" in venvs: pipx_match = True except (subprocess.TimeoutExpired, OSError, ValueError): pass # If both registries claim ownership, the active entrypoint is ambiguous. # Treat it as unsupported rather than guessing and upgrading the wrong install. exactly_one_match = uv_tool_match != pipx_match if exactly_one_match: method = _InstallMethod.UV_TOOL if uv_tool_match else _InstallMethod.PIPX if include_signals: return method, _DetectionSignals( sys_argv0=argv0_resolved, matched_tier=3, matched_prefix=None, editable_marker_seen=False, installer_registries_consulted=tuple(consulted), resolved_method=method, ) return method # Fallthrough method = _InstallMethod.UNSUPPORTED if include_signals: return method, _DetectionSignals( sys_argv0=argv0_resolved, matched_tier=None, matched_prefix=None, editable_marker_seen=False, installer_registries_consulted=tuple(consulted), resolved_method=method, ) return method _GITHUB_SOURCE_URL = "git+https://github.com/github/spec-kit.git" _MANUAL_TAG_PLACEHOLDER = "vX.Y.Z" def _source_spec(target_tag: str | None) -> str: """Build a git source spec, optionally pinned to a release tag.""" return f"{_GITHUB_SOURCE_URL}@{target_tag}" if target_tag else _GITHUB_SOURCE_URL def _manual_source_spec(target_tag: str | None) -> str: """Build a stable-release-oriented source spec for manual guidance.""" return f"{_GITHUB_SOURCE_URL}@{target_tag or _MANUAL_TAG_PLACEHOLDER}" def _manual_tag_or_placeholder(tag: str | None) -> str | None: """Return a validated release tag for copy/paste guidance, or None.""" if tag is None: return None try: return _validate_tag(tag) except typer.BadParameter: return None def _assemble_installer_argv( method: _InstallMethod, target_tag: str | None ) -> list[str] | None: """Build the installer argv for an upgradable install method.""" source_spec = _source_spec(target_tag) if method == _InstallMethod.UV_TOOL: uv_bin = shutil.which("uv") if uv_bin is None: return None return [ uv_bin, "tool", "install", "specify-cli", "--force", "--from", source_spec, ] if method == _InstallMethod.PIPX: # pipx 1.5+ removed `--spec`; PACKAGE_SPEC is now positional and the # package name is auto-detected from the source's pyproject.toml. pipx_bin = shutil.which("pipx") if pipx_bin is None: return None return [ pipx_bin, "install", "--force", source_spec, ] return None def _installer_binary_name(method: _InstallMethod) -> str | None: """Return the installer executable name for upgradable methods.""" if method == _InstallMethod.UV_TOOL: return "uv" if method == _InstallMethod.PIPX: return "pipx" return None def _is_path_like_command(value: str) -> bool: """Return whether an argv[0] names a path rather than a bare command.""" return Path(value).parent != Path(".") or "/" in value or "\\" in value def _method_label(method: _InstallMethod) -> str: """Render the user-facing label for an install method.""" return { _InstallMethod.UV_TOOL: "uv tool", _InstallMethod.PIPX: "pipx", _InstallMethod.UVX_EPHEMERAL: "uvx (ephemeral)", _InstallMethod.SOURCE_CHECKOUT: "source checkout", _InstallMethod.UNSUPPORTED: "unsupported", }[method] def _build_upgrade_plan( target_tag_override: str | None, ) -> tuple[_UpgradePlan | None, str | None]: """Return a resolved upgrade plan or `(None, failure_reason)`. A valid `target_tag_override` skips network resolution entirely. A fetched target tag is validated before installer argv construction. """ method = _detect_install_method() if target_tag_override is not None: target_tag = target_tag_override elif method in (_InstallMethod.UV_TOOL, _InstallMethod.PIPX): tag, failure_reason = _fetch_latest_release_tag() if tag is None: return None, failure_reason # surfaces as exit 1 in the orchestrator try: target_tag = _validate_tag(tag) except typer.BadParameter: current = _get_installed_version() return ( _UpgradePlan( method=method, current_version=current, target_tag=tag, installer_argv=None, preview_summary="", pre_upgrade_snapshot=current, ), _FAILURE_TARGET_TAG_UNPARSEABLE, ) else: target_tag = None current = _get_installed_version() argv = _assemble_installer_argv(method, target_tag) if argv is None and method in (_InstallMethod.UV_TOOL, _InstallMethod.PIPX): command_preview = ( f"(installer {_installer_binary_name(method)} not found on PATH)" ) else: command_preview = ( _render_argv(argv) if argv is not None else "(none — non-upgradable path)" ) preview = ( f"Detected install method: {_method_label(method)}\n" f"Current version: {current}\n" f"Target version: {target_tag or '(not resolved for this install method)'}\n" f"Command that would be executed: {command_preview}" ) plan = _UpgradePlan( method=method, current_version=current, target_tag=target_tag, installer_argv=argv, preview_summary=preview, pre_upgrade_snapshot=current, ) return plan, None def _warn_invalid_upgrade_timeout(timeout_raw: str) -> None: """Warn that SPECIFY_UPGRADE_TIMEOUT_SECS could not be applied.""" console.print( f"Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS={timeout_raw!r}; " "running without a timeout.", soft_wrap=True, ) def _installer_exited_result( completed: subprocess.CompletedProcess, ) -> _InstallerResult: """Return the normalized result for a real installer process exit.""" return _InstallerResult(_InstallerResultKind.EXITED, completed.returncode) def _run_installer(plan: _UpgradePlan) -> _InstallerResult: """Invoke the installer subprocess. Returns a normalized `_InstallerResult` so internal states (missing, invalid, timeout) cannot be confused with real installer exit codes. stdout/stderr are inherited (not captured) so the user sees installer progress in real time. The child environment has GitHub credential-shaped variables removed. Timeout: by default the subprocess runs with no timeout — installer operations (dependency resolution, large wheel downloads) can legitimately take many minutes. Set the env var SPECIFY_UPGRADE_TIMEOUT_SECS to an integer/float to enforce a hard cap. On timeout, the orchestrator maps `_InstallerResultKind.TIMEOUT` to user-facing exit code `124`. A real installer process that exits 124 is returned as EXITED with returncode 124. An unparseable, non-positive, or non-finite timeout value emits a warning and runs without a timeout. """ if plan.installer_argv is None: # Internal routing error: the orchestrator must route non-upgradable # methods to _emit_guidance and never reach this function. Use a real # raise (not assert) so the guard survives `python -O`. raise RuntimeError( "internal routing error: _run_installer received a plan without an " "installer_argv (non-upgradable methods must route to _emit_guidance)" ) # Use the argv assembled at plan-build time verbatim. The pre-execution # notice and the actual subprocess argv must be byte-for-byte identical; # any re-resolution here would risk diverging from what the user just # saw printed. A lightweight pre-flight via `shutil.which` short-circuits # the obvious "binary disappeared" case before spawning, and the # try/except below catches the residual race window. installer_name = plan.installer_argv[0] installer_cmd = Path(installer_name) if installer_cmd.is_absolute(): if not installer_cmd.exists(): return _InstallerResult(_InstallerResultKind.MISSING) elif not installer_cmd.is_file() or not os.access(installer_cmd, os.X_OK): return _InstallerResult(_InstallerResultKind.INVALID) elif _is_path_like_command(installer_name): if not installer_cmd.exists(): return _InstallerResult(_InstallerResultKind.MISSING) if not installer_cmd.is_file() or not os.access(installer_cmd, os.X_OK): return _InstallerResult(_InstallerResultKind.INVALID) elif shutil.which(installer_name) is None: return _InstallerResult(_InstallerResultKind.MISSING) timeout_raw = os.environ.get("SPECIFY_UPGRADE_TIMEOUT_SECS") timeout: float | None = None if timeout_raw is not None: try: timeout = float(timeout_raw) if timeout <= 0 or not math.isfinite(timeout): _warn_invalid_upgrade_timeout(timeout_raw) timeout = None except ValueError: _warn_invalid_upgrade_timeout(timeout_raw) timeout = None try: completed = subprocess.run( plan.installer_argv, shell=False, check=False, env=_scrubbed_env(), timeout=timeout, ) return _installer_exited_result(completed) except subprocess.TimeoutExpired: return _InstallerResult(_InstallerResultKind.TIMEOUT) except FileNotFoundError: return _InstallerResult(_InstallerResultKind.MISSING) except (PermissionError, IsADirectoryError): return _InstallerResult(_InstallerResultKind.INVALID) except OSError as exc: if exc.errno in {errno.EACCES, errno.ENOEXEC, errno.EISDIR}: return _InstallerResult(_InstallerResultKind.INVALID) raise _VERIFY_VERSION_LINE_RE = re.compile( r"^\s*(?:specify|specify-cli)\b(?P.*)$", flags=re.IGNORECASE, ) def _parse_verify_version_output(output: str) -> str | None: """Return the first parseable version token from `specify --version` output.""" for line in output.splitlines(): match = _VERIFY_VERSION_LINE_RE.match(line) if not match: continue for token in match.group("rest").split(): if _parse_version_text(token) is not None: return token return None def _verify_upgrade(plan: _UpgradePlan) -> str | None: """Spawn a child `specify --version` and parse its output. Returns the version string on success, None on parse failure, timeout, or missing binary. Caller compares the returned version to plan.target_tag and raises verification-mismatch if they differ. Uses a child process (not in-process importlib.metadata) because Python cannot hot-swap the running module after the installer has replaced it — only a fresh process picks up the new binary. """ argv0 = _resolved_argv0_path() specify_bin = ( str(argv0) if ( argv0.exists() and argv0.is_file() and os.access(argv0, os.X_OK) and _looks_like_specify_entrypoint(argv0) ) else shutil.which("specify") ) if specify_bin is None: return None try: result = subprocess.run( [specify_bin, "--version"], shell=False, check=False, capture_output=True, text=True, timeout=_VERIFY_TIMEOUT_SECS, env=_scrubbed_env(), ) except (subprocess.TimeoutExpired, OSError): return None if result.returncode != 0: return None return _parse_verify_version_output(result.stdout or "") def _source_checkout_path() -> Path | None: """Return the working-tree root for an editable install when discoverable.""" import importlib.metadata as _md editable_root = _editable_direct_url_path() if editable_root is not None: git_root = _git_ancestor(editable_root) if git_root is not None: return git_root metadata_errors = [_md.PackageNotFoundError] invalid_metadata_error = getattr(_md, "InvalidMetadataError", None) if invalid_metadata_error is not None: metadata_errors.append(invalid_metadata_error) try: dist = _md.distribution("specify-cli") except tuple(metadata_errors): return None files = dist.files or [] for f in files: try: abs_path = Path(dist.locate_file(f)).resolve() except (OSError, RuntimeError, TypeError, ValueError): continue git_root = _git_ancestor(abs_path) if git_root is not None: return git_root return None def _emit_guidance(method: _InstallMethod, target_tag: str | None) -> None: """Print path-specific guidance for non-upgradable install methods.""" if method == _InstallMethod.UVX_EPHEMERAL: console.print( "Running via uvx (ephemeral); the next uvx invocation already " "resolves to latest — no upgrade action needed.", soft_wrap=True, ) return if method == _InstallMethod.SOURCE_CHECKOUT: tree = _source_checkout_path() if tree is None: console.print( "Running from a source checkout, but the checkout path could not " "be detected; upgrade by running the following commands from your " "checkout directory:", soft_wrap=True, ) else: console.print( f"Running from a source checkout at {tree}; " "upgrade by running the following commands from that directory:", soft_wrap=True, ) console.print(" git pull") console.print(" pip install -e .") return if method == _InstallMethod.UNSUPPORTED: console.print( "Could not identify your install method automatically; " "run one of the following manually:", soft_wrap=True, ) console.print( f" uv tool install specify-cli --force --from " f"{_manual_source_spec(target_tag)}", soft_wrap=True, ) console.print( f" pipx install --force {_manual_source_spec(target_tag)}", soft_wrap=True, ) return raise RuntimeError( f"internal routing error: _emit_guidance called on upgradable method: {method}" ) def _rollback_hint(plan: _UpgradePlan) -> str: """Build a manual rollback suggestion from the pre-upgrade version.""" if plan.pre_upgrade_snapshot == "unknown": return ( "Could not determine the previous version; " "reinstall manually from: https://github.com/github/spec-kit/releases" ) rollback_tag = _stable_release_tag_for_version(plan.pre_upgrade_snapshot) if rollback_tag is None: return ( "Previous version was not an exact stable release tag; " "reinstall manually from: https://github.com/github/spec-kit/releases" ) if plan.method == _InstallMethod.PIPX: return ( f"To pin back to the previous version: pipx install --force " f"git+https://github.com/github/spec-kit.git@{rollback_tag}" ) return ( f"To pin back to the previous version: uv tool install specify-cli --force " f"--from git+https://github.com/github/spec-kit.git@{rollback_tag}" ) def _emit_failure( category: str, plan: _UpgradePlan | None = None, installer_exit: int | None = None, installer_name: str | None = None, verified_version: str | None = None, ) -> None: """Render user-facing output for resolver, installer, or verification failures.""" if ( category in _RESOLUTION_FAILURE_CATEGORIES or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX) ): console.print(f"Upgrade aborted: {category}", soft_wrap=True) return if category == _FAILURE_INSTALLER_MISSING: if installer_name and ( os.path.isabs(installer_name) or _is_path_like_command(installer_name) ): console.print( f"Installer path {installer_name} no longer exists; reinstall it and retry.", soft_wrap=True, ) else: name = installer_name or "(unknown)" console.print( f"Installer {name} not found on PATH; reinstall it and retry.", soft_wrap=True, ) return if category == _FAILURE_INSTALLER_INVALID: name = installer_name or "(unknown)" if installer_name and ( os.path.isabs(installer_name) or _is_path_like_command(installer_name) ): message = ( f"Installer path {name} is not an executable file; " "fix the path or reinstall it and retry." ) else: message = ( f"Installer {name} is not executable; " "fix the command or reinstall it and retry." ) console.print(message, soft_wrap=True) return if category == _FAILURE_TARGET_TAG_UNPARSEABLE: if plan is None: raise RuntimeError( "internal routing error: target-tag-unparseable requires plan to be set" ) console.print( "Upgrade aborted: resolved release tag is not a comparable version.", soft_wrap=True, ) console.print( "Try again later or pin a stable release with --tag vX.Y.Z.", soft_wrap=True, ) return if category == _FAILURE_INSTALLER_TIMEOUT: if plan is None: raise RuntimeError( "internal routing error: installer-timeout requires plan to be set" ) argv_str = _render_argv(plan.installer_argv) if plan.installer_argv else "" timeout_value = os.environ.get("SPECIFY_UPGRADE_TIMEOUT_SECS", "(unknown)") console.print( "Upgrade timed out while waiting for the installer subprocess.", soft_wrap=True, ) console.print( f"Configured timeout: SPECIFY_UPGRADE_TIMEOUT_SECS={timeout_value}", soft_wrap=True, ) console.print( f"Try again or run the command manually: {argv_str}", soft_wrap=True, ) console.print(_rollback_hint(plan), soft_wrap=True) return if category == _FAILURE_INSTALLER_FAILED: if plan is None or installer_exit is None: raise RuntimeError( "internal routing error: installer-failed requires both " "plan and installer_exit to be set" ) argv_str = _render_argv(plan.installer_argv) if plan.installer_argv else "" console.print( f"Upgrade failed. Installer exit code: {installer_exit}.", soft_wrap=True, ) console.print( f"Try again or run the command manually: {argv_str}", soft_wrap=True, ) console.print(_rollback_hint(plan), soft_wrap=True) return if category == _FAILURE_VERIFICATION_MISMATCH: if plan is None: raise RuntimeError( "internal routing error: verification-mismatch requires plan to be set" ) verified_str = verified_version or "(unknown)" console.print( f"Verification failed: installer reported success but " f"'specify --version' resolves to {verified_str} " f"(expected {plan.target_tag}).", soft_wrap=True, ) console.print( "The new version may take effect on your next invocation.", soft_wrap=True, ) return raise RuntimeError(f"Unknown failure category: {category!r}") # ===== Self Commands ===== self_app = typer.Typer( name="self", help=( "Manage the specify CLI itself: check for newer releases, " "preview upgrades with --dry-run, and upgrade in place." ), 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. Use `specify self upgrade` to actually perform the upgrade once you've seen the result here, or `specify self upgrade --dry-run` to preview the installer command without running it. """ 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 manual_tag = _manual_tag_or_placeholder(tag) latest_display = manual_tag or _MANUAL_TAG_PLACEHOLDER if manual_tag is None: if installed == "unknown": console.print("Current version could not be determined.") console.print(f"Latest release: {latest_display}") else: console.print(f"Installed: {installed}") console.print(f"Latest release: {latest_display}") console.print("[yellow]Could not validate latest release tag from GitHub.[/yellow]") console.print("\nManual fallback:") console.print( f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" ) console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") return 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_display}") console.print("\nManual fallback:") console.print( f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" ) console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") console.print("\nIf this install can still be detected:") console.print(" specify self upgrade") return latest_normalized = _normalize_tag(manual_tag) if _is_newer(latest_normalized, installed): console.print(f"[green]Update available:[/green] {installed} → {latest_display}") console.print("\nTo upgrade:") console.print(" specify self upgrade") console.print("\nManual fallback:") console.print( f" uv tool install specify-cli --force --from {_manual_source_spec(manual_tag)}" ) console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") return # Reached only when manual_tag parsed cleanly — the unparseable-latest case # already returned at the `manual_tag is None` branch above — and installed # is parseable AND >= latest → "up to date" (FR-006). Do not reintroduce an # InvalidVersion-fallback assumption here. console.print(f"[green]Up to date:[/green] {installed}") @self_app.command("upgrade") def self_upgrade( dry_run: bool = typer.Option( False, "--dry-run", help="Print the preview (method, current, target, installer argv) and " "exit 0 without launching the installer subprocess.", ), tag: str | None = typer.Option( None, "--tag", help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the " "latest stable release is resolved via GitHub Releases.", ), ) -> None: """Upgrade specify-cli to the latest release (or a pinned --tag). Bare invocation executes immediately with no confirmation prompt, matching pip install -U / uv tool upgrade / npm update conventions. Use --dry-run to preview without mutating anything. See `specify self check` for the non-destructive read-only counterpart. Detection classifies the runtime into uv-tool / pipx / uvx (ephemeral) / source-checkout / unsupported. Only uv-tool and pipx are upgraded automatically; the other three paths print path-specific guidance and exit 0. Exit codes: 0 success or no-op-success (already on latest, --dry-run, or non-upgradable path with guidance shown) 1 target-tag resolution failure or --tag regex validation failure 2 verification mismatch when the installer exited 0 but `specify --version` does not resolve to the target tag; if the installer itself exits 2, that installer failure code is propagated verbatim 3 installer binary not found on PATH, or resolved installer path is missing / non-executable 124 internal installer timeout when SPECIFY_UPGRADE_TIMEOUT_SECS is set, or a real installer exit code 124 propagated verbatim; scripts should treat 124 as ambiguous and inspect the failure message other installer exit code propagated verbatim Environment variables: SPECIFY_UPGRADE_TIMEOUT_SECS Optional integer/float seconds. Caps how long the installer subprocess may run. Unset (default) means no timeout — interrupt with Ctrl+C if the installer hangs. """ if tag is not None: try: tag = _validate_tag(tag) except typer.BadParameter as exc: console.print(str(exc), soft_wrap=True) raise typer.Exit(1) from exc plan, failure_reason = _build_upgrade_plan(target_tag_override=tag) # Resolver could not produce a tag → surface the categorized failure # and exit non-zero so scripts notice (action-oriented unlike `self check`). if plan is None: if failure_reason is None: # _build_upgrade_plan's contract: if plan is None, failure_reason # is set. Defend explicitly so the guard survives `python -O`. raise RuntimeError( "internal contract violation: _build_upgrade_plan returned (None, None)" ) _emit_failure(failure_reason) raise typer.Exit(1) if failure_reason is not None: _emit_failure(failure_reason, plan=plan) raise typer.Exit(1) # --dry-run preview path. Non-upgradable methods still emit guidance # rather than a fake preview block — there is nothing to preview when # there is nothing the CLI would launch. if dry_run: if plan.method in ( _InstallMethod.UVX_EPHEMERAL, _InstallMethod.SOURCE_CHECKOUT, _InstallMethod.UNSUPPORTED, ): _emit_guidance(plan.method, plan.target_tag) raise typer.Exit(0) console.print("Dry run — no changes will be made.") for line in plan.preview_summary.splitlines(): console.print(line) raise typer.Exit(0) # Non-upgradable runtime: never launch an installer regardless of flags. if plan.method in ( _InstallMethod.UVX_EPHEMERAL, _InstallMethod.SOURCE_CHECKOUT, _InstallMethod.UNSUPPORTED, ): _emit_guidance(plan.method, plan.target_tag) raise typer.Exit(0) if plan.installer_argv is None: _emit_failure( _FAILURE_INSTALLER_MISSING, plan=plan, installer_name=_installer_binary_name(plan.method), ) raise typer.Exit(3) if plan.target_tag is None: raise RuntimeError("Upgrade target tag is required for upgradable install methods") target_tag = plan.target_tag target_version = _parse_version_text(target_tag) if target_version is None: # _build_upgrade_plan() and _validate_tag() should reject bad targets # before this point; keep this guard as a defensive invariant check. _emit_failure(_FAILURE_TARGET_TAG_UNPARSEABLE, plan=plan) raise typer.Exit(1) if plan.current_version != "unknown": current_version = _parse_version_text(plan.current_version) # target_version and current_version are Version instances here, so use # packaging's ordering/equality directly rather than comparing canonical # strings: Version("1.0") == Version("1.0.0") yet their str() forms # differ, so canonical-string equality would misreport equal versions as # "or newer". The unparseable-current case stays explicit via the # `current_version is not None` guard. if tag is None and current_version is not None and not ( target_version > current_version ): if target_version == current_version: console.print(f"Already on latest release: {target_tag}") else: console.print(f"Already on latest release or newer: {plan.current_version}") raise typer.Exit(0) # Pinned upgrades are no-ops only on an exact parseable match — the same # Version equality used by the unpinned branch above; an unparseable # current version deliberately proceeds to installation. if ( tag is not None and current_version is not None and target_version == current_version ): console.print(f"Already on requested release: {target_tag}") raise typer.Exit(0) # One-line pre-execution notice so the user sees exactly what will run # before the installer's own output starts streaming. A pinned target older # than the installed version is a downgrade — say so explicitly so # `--tag ` does not masquerade as a forward upgrade. installed_version = _parse_version_text(plan.current_version) verb = ( "Downgrading" if tag is not None and installed_version is not None and target_version < installed_version else "Upgrading" ) argv_str = _render_argv(plan.installer_argv) if plan.installer_argv else "" console.print( f"{verb} specify-cli {plan.current_version} → {plan.target_tag} " f"via {_method_label(plan.method)}: {argv_str}", soft_wrap=True, ) # Launch the installer. Stdout/stderr stream through (no capture) so the # user sees real-time progress. We never pass shell=True. installer_result = _run_installer(plan) installer_name = plan.installer_argv[0] if plan.installer_argv else None if installer_result.kind == _InstallerResultKind.MISSING: _emit_failure(_FAILURE_INSTALLER_MISSING, plan=plan, installer_name=installer_name) raise typer.Exit(3) if installer_result.kind == _InstallerResultKind.INVALID: _emit_failure(_FAILURE_INSTALLER_INVALID, plan=plan, installer_name=installer_name) raise typer.Exit(3) if installer_result.kind == _InstallerResultKind.TIMEOUT: _emit_failure(_FAILURE_INSTALLER_TIMEOUT, plan=plan) raise typer.Exit(124) if ( installer_result.kind != _InstallerResultKind.EXITED or installer_result.returncode is None ): raise RuntimeError(f"Unknown installer result: {installer_result!r}") if installer_result.returncode != 0: _emit_failure( _FAILURE_INSTALLER_FAILED, plan=plan, installer_exit=installer_result.returncode, ) raise typer.Exit(installer_result.returncode) # Verify in a child process: this Python process is still running the # pre-upgrade module, so importlib.metadata would lie. A fresh `specify # --version` is the only signal that the new binary is actually live. verified = _verify_upgrade(plan) # Compare as Version instances, not canonical strings: _canonicalize_version_text # falls back to _normalize_tag() on unparseable input, so two raw strings could # coincidentally match. Requiring a parseable verified version that equals the # (already-parsed) target makes a non-version verifier result a mismatch (exit 2) # rather than a silently-masked "success". verified_version = _parse_version_text(verified) if verified is not None else None if verified_version is None or verified_version != target_version: _emit_failure( _FAILURE_VERIFICATION_MISMATCH, plan=plan, verified_version=verified, ) raise typer.Exit(2) pre_upgrade_display = _canonicalize_version_text(plan.pre_upgrade_snapshot) verified_display = _canonicalize_version_text(verified) console.print( f"Upgraded specify-cli: {pre_upgrade_display} → {verified_display}", soft_wrap=True, )