Files
github-spec-kit/src/specify_cli/_version.py
김준호 ac2cb5daf5 feat(cli): implement specify self upgrade (#2475)
* feat(cli): implement specify self upgrade

* fix(cli): normalize self-upgrade prerelease tags

* fix(cli): tighten self-upgrade diagnostics

* fix(cli): harden self-upgrade verification parsing

* fix(cli): sanitize self-check fallback tags

* fix(cli): harden self-check release display

* fix(cli): validate resolved upgrade tags

* fix(cli): tolerate invalid install metadata

* test(cli): align upgrade network mocks

* fix(cli): respect relative installer paths

* fix(cli): tighten upgrade failure handling

* fix(cli): align installer path diagnostics

* fix(cli): validate release and version output

* fix(cli): clarify source checkout guidance

* fix(cli): harden upgrade detection helpers

* fix(cli): avoid echoing invalid release tags

* fix(cli): tolerate argv path resolve failures

* chore: remove self-upgrade formatting-only diffs

* fix: address self-upgrade review feedback

* fix: address self-upgrade review followups

* fix: address self-upgrade review edge cases

* fix: address self-upgrade review docs

* fix: refine self-upgrade review followups

* fix: address self-upgrade review cleanup

* fix: handle self-upgrade review edge cases

* fix: address self-upgrade review nits

* fix: address follow-up self-upgrade review

* fix: resolve self-upgrade review and Windows CI failures

- README: promote "Optional Commands" to ### so it is a sibling of
  "Core Commands" under "Available Slash Commands" (consistent heading
  levels; avoids the h2->h4 jump a revert would create).
- _version: allow --tag prerelease/dev and build-metadata suffixes to
  compose (e.g. v1.0.0-rc1+build.42), matching PEP 440 / semver; the
  Version() check still enforces canonical validity.
- tests: compare resolved argv0 as Path objects instead of POSIX strings
  so the assertion holds on Windows; skip the relative-installer-path
  executable-bit tests on Windows via a new requires_posix marker (they
  rely on chmod/X_OK semantics and chdir-into-tmp teardown that do not
  hold there). Add a combined prerelease+build-metadata tag test.

* fix: address second self-upgrade review round

- self_check: clarify that the "up to date" branch is reached only for
  parseable latest tags (the unparseable case returns earlier), so the
  InvalidVersion fallback assumption is not reintroduced.
- self_upgrade: compare target/current as Version instances directly
  instead of re-parsing the canonical strings through _is_newer; the
  empty-current case stays explicit via the not-None guard.
- tests: document the intentional broad GH_/GITHUB_ env scrub with a test
  asserting non-credential context vars (GH_HOST, GITHUB_REPOSITORY, …) are
  stripped from the installer subprocess env — a deliberate fail-safe that
  also catches credential-adjacent names without a recognized suffix.

* fix: address third self-upgrade review round

- self_upgrade: unify the no-op short-circuits on packaging Version
  equality instead of canonical-string equality. Version("1.0") equals
  Version("1.0.0") but their str() forms differ, so the old check could
  misreport an equal install as "already on latest release or newer".
  Both the unpinned and pinned branches now use Version comparison.
- self_upgrade: compare the verified version as a parsed Version against
  the target so a non-version verifier result is a mismatch (exit 2)
  rather than a coincidental canonical-string match.
- resolver: map HTTP 429 (Too Many Requests / secondary rate limit) to
  the rate-limited category so users get the same actionable token hint
  as 403.
- _is_github_credential_env_key: document the precise (intentionally
  broad) scrub matching contract in the docstring.
- tests: add a trailing-zero Version-equality regression test and a
  parametrized HTTP-status categorization test (429 -> rate limited;
  404/502 -> verbatim).

* fix: address fourth self-upgrade review round

- self_upgrade: label a pinned target older than the installed version as
  "Downgrading" rather than "Upgrading" so `--tag <older>` is not mistaken
  for a forward upgrade.
- resolver: drop the unused `typing.Optional` import and annotate the
  `--tag` option as `str | None`, consistent with the rest of the module
  (verified Typer resolves it on the supported Python versions).
- _is_github_credential_env_key: add `_PASSWORD` and `_CREDENTIALS` to the
  recognized credential suffixes and document that only these shapes are
  scrubbed (not blanket coverage).
- tests: assert the precise exit code (1) for the re-raised transient
  OSError path; skip the InvalidMetadataError test on Pythons where the
  real exception is absent instead of fabricating it; update the pinned
  downgrade test to expect the "Downgrading" label.

* fix: accept uppercase V prefix in --tag

Fold a leading uppercase `V` (a common paste) to the canonical lowercase
`v` before validating `--tag`. The remainder of the tag stays
case-sensitive on purpose: the validated value is used verbatim as a git
ref, which is case-sensitive on GitHub, so rewriting label/build-metadata
casing could point at a tag that does not exist. Adds a normalization test.
2026-06-03 12:04:54 -05:00

1430 lines
52 KiB
Python

"""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<rest>.*)$",
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 <older>` 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,
)