From 71435b5ff5b0d3a68adb8bcd2ff921c130e36048 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 10:21:53 +0900 Subject: [PATCH 01/27] feat(cli): implement specify self upgrade --- README.md | 18 + docs/installation.md | 2 + docs/upgrade.md | 50 +- src/specify_cli/_version.py | 1108 +++++++++++++++++++- tests/test_self_upgrade.py | 1889 +++++++++++++++++++++++++++++++++++ tests/test_upgrade.py | 41 +- 6 files changed, 3039 insertions(+), 69 deletions(-) create mode 100644 tests/test_self_upgrade.py diff --git a/README.md b/README.md index 2c9f7dd0de..96cf1d651a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,24 @@ specify init my-project --integration copilot cd my-project ``` +To check for updates or upgrade the installed CLI, use the self-management commands. See the [Upgrade Guide](./docs/upgrade.md) for detailed scenarios and customization options. + +```bash +# Check whether a newer release is available (read-only — does not modify anything) +specify self check + +# Preview what would run, without actually upgrading +specify self upgrade --dry-run + +# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install) +specify self upgrade + +# Or pin a specific release tag (replace vX.Y.Z with your desired release tag) +specify self upgrade --tag vX.Y.Z +``` + +Bare `specify self upgrade` executes immediately, matching the `pip install -U` / `uv tool upgrade` / `npm update` convention. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). + ### 3. Establish project principles Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. diff --git a/docs/installation.md b/docs/installation.md index 058303188f..99b37f0d9f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -88,6 +88,8 @@ specify version This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name. +**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md). + After initialization, you should see the following commands available in your coding agent: - `/speckit.specify` - Create specifications diff --git a/docs/upgrade.md b/docs/upgrade.md index 5355a0b576..4ddca19f71 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -8,10 +8,12 @@ | What to Upgrade | Command | When to Use | |----------------|---------|-------------| -| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files | -| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release | -| **Project Files** | `specify init --here --force --integration ` | Update slash commands, templates, and scripts in your project | -| **Both** | Run CLI upgrade, then project update | Recommended for major version updates | +| **CLI Tool (recommended)** | `specify self upgrade` | Latest stable release, in place. Auto-detects whether you installed via `uv tool` or `pipx`. | +| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z` | Upgrade to a specific release tag instead of the latest stable. Replace `vX.Y.Z` with the release tag you want. | +| **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. | +| **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. | +| **Project Files** | `specify init --here --force --integration ` | Update slash commands, templates, and scripts in your project. | +| **Both** | Run CLI upgrade, then project update | Recommended for major version updates. | --- @@ -19,12 +21,30 @@ The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes. -Before upgrading, you can check whether a newer released version is available: +### Recommended: `specify self upgrade` + +The CLI ships with two self-management commands that handle the common case automatically: ```bash +# Check whether a newer release is available (read-only — does not modify anything) specify self check + +# Preview what would run, without actually upgrading +specify self upgrade --dry-run + +# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install) +specify self upgrade + +# Or pin a specific release tag (replace vX.Y.Z with the release tag you want) +specify self upgrade --tag vX.Y.Z ``` +Bare `specify self upgrade` executes immediately, matching the `pip install -U` / `uv tool upgrade` / `npm update` convention. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; the other paths print path-specific guidance and exit 0 without touching anything. + +Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and prints `Upgrade timed out`. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases. + +If your installed CLI is older than the release that introduced `specify self upgrade`, use the manual equivalents below. These commands are also useful when you want explicit control over the installer command. + ### If you installed with `uv tool install` Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag): @@ -54,10 +74,14 @@ pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z ### Verify the upgrade ```bash +# Confirms the CLI is working and shows installed tools specify check + +# Confirms the installed version against the latest GitHub release +specify self check ``` -This shows installed tools and confirms the CLI is working. Use `specify version` to confirm which persistent CLI version is currently on your `PATH`. +`specify check` shows the surrounding tool environment; `specify self check` is read-only and tells you whether you're now on the latest release (`Up to date: X.Y.Z`) or if a newer one became available between releases. --- @@ -186,8 +210,8 @@ Restart your IDE to refresh the command list. ### Scenario 1: "I just want new slash commands" ```bash -# Upgrade CLI (if using persistent install) -uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git +# Upgrade CLI (auto-detects uv tool vs pipx install) +specify self upgrade # Update project files to get new commands specify init --here --force --integration copilot @@ -204,7 +228,7 @@ cp .specify/memory/constitution.md /tmp/constitution-backup.md cp -r .specify/templates /tmp/templates-backup # 2. Upgrade CLI -uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git +specify self upgrade # 3. Update project specify init --here --force --integration copilot @@ -388,15 +412,19 @@ Only Spec Kit infrastructure files: ### "CLI upgrade doesn't seem to work" -If a command behaves like an older Spec Kit version, first check for local CLI drift: +If a command behaves like an older Spec Kit version, first ask the CLI itself: ```bash +# Read-only — prints "Up to date: X.Y.Z" or "Update available: X.Y.Z → Y.Z.W" specify self check + +# Preview the install method, current version, and target tag the upgrade would use +specify self upgrade --dry-run ``` `specify check` is an offline environment scan; `specify self check` is the CLI version lookup. -Verify the installation: +If `self check` shows the wrong version, verify the installation: ```bash # Check installed tools diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 0a52ac7e80..5fe63f477b 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -9,8 +9,22 @@ """ 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 +from typing import Optional import typer from packaging.version import InvalidVersion, Version @@ -99,11 +113,887 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: return None, "offline or timeout" -# ===== Self Commands ===== +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.""" + try: + parsed = Version(version_text) + except InvalidVersion: + 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 _is_comparable_version_text(value: str) -> bool: + """Return whether version-like text parses under PEP 440 after tag normalization.""" + return _parse_version_text(value) is not None + + +def _render_argv(argv: list[str]) -> str: + """Render argv for copy/paste on the current platform.""" + 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( + { + "offline or timeout", + "rate limited (configure ~/.specify/auth.json with a GitHub token)", + } +) + + +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: + """Test-only record of which detection tier fired.""" + + sys_argv0: str + matched_tier: int | None + matched_prefix: str | None + editable_marker_seen: bool + installer_registries_consulted: list[str] + resolved_method: _InstallMethod + + +_GITHUB_CREDENTIAL_SUFFIXES = ("_TOKEN", "_SECRET", "_KEY", "_PAT") +_UNRESOLVED_ENV_VAR_RE = re.compile(r"\$\w+|\$\{\w+\}|%[^%]+%") + + +def _is_github_credential_env_key(key: str) -> bool: + """Return whether an env key looks like a GitHub credential.""" + upper = key.upper() + return ( + upper.startswith("GH_") or "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) + } + + +_TAG_REGEX = re.compile( + r"^v\d+\.\d+\.\d+" + r"(?:(?:\.?dev\d+)|(?:[-.]?(?:a|b|rc|alpha|beta)\d+)|" + r"(?:\+[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)*))?$" +) + + +def _validate_tag(tag: str) -> str: + """Validate a user-supplied --tag value. + + Accepts vX.Y.Z plus optional PEP-440-ish suffix (dev0, rc1, beta.1, + +build.42). Rejects everything else (including bare 'latest', hash refs, + branch names, or a numeric version without the 'v' prefix). + """ + tag = tag.strip() + if not tag: + raise typer.BadParameter("Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]") + if not _TAG_REGEX.match(tag): + raise typer.BadParameter("Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]") + try: + Version(_normalize_tag(tag)) + except InvalidVersion as exc: + raise typer.BadParameter( + "Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]" + ) 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 + return Path(expanded).resolve() if Path(expanded).is_absolute() else Path(expanded) + + +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 _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 candidate.resolve() + if candidate.exists(): + return candidate.resolve() + + 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 Path(resolved).resolve() + 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 + + try: + dist = _md.distribution("specify-cli") + except _md.PackageNotFoundError: + 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=5, + 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=5, + 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=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=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 _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 _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. + """ + 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 + target_tag = tag + 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_cmd = Path(plan.installer_argv[0]) + if installer_cmd.is_absolute(): + if installer_cmd.exists() and ( + not installer_cmd.is_file() or not os.access(installer_cmd, os.X_OK) + ): + return _InstallerResult(_InstallerResultKind.INVALID) + elif shutil.which(plan.installer_argv[0]) 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_REGEX = re.compile(r"specify (\S+)") + + +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=10, + env=_scrubbed_env(), + ) + except (subprocess.TimeoutExpired, OSError): + return None + if result.returncode != 0: + return None + match = _VERIFY_VERSION_REGEX.search(result.stdout or "") + return match.group(1) if match else None + + +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 + + try: + dist = _md.distribution("specify-cli") + except _md.PackageNotFoundError: + return None + files = dist.files or [] + for f in files: + try: + abs_path = Path(dist.locate_file(f)).resolve() + except Exception: + 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() + tree_str = str(tree) if tree else "(path unavailable)" + console.print( + f"Running from a source checkout at {tree_str}; " + "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("HTTP ") + ): + console.print(f"Upgrade aborted: {category}", soft_wrap=True) + return + + if category == "installer-missing": + if installer_name and os.path.isabs(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 == "installer-invalid": + name = installer_name or "(unknown)" + console.print( + f"Installer path {name} is not an executable file; fix the path or reinstall it and retry.", + soft_wrap=True, + ) + return + + if category == "target-tag-unparseable": + if plan is None: + raise RuntimeError( + "internal routing error: target-tag-unparseable requires plan to be set" + ) + console.print( + f"Upgrade aborted: resolved release tag {plan.target_tag!r} 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 == "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 == "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 == "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 (read-only check and reserved upgrade command).", + help=( + "Manage the specify CLI itself: check for newer releases, " + "preview upgrades with --dry-run, and upgrade in place." + ), add_completion=False, ) @@ -113,11 +1003,11 @@ 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. - The reserved (and currently non-destructive) `specify self upgrade` command - is the name that a future release will use for actual self-upgrade — its - behavior is not implemented in this release and is intentionally out of - scope here. See `specify self upgrade --help` for its current status. + 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() @@ -137,16 +1027,20 @@ def self_check() -> None: # when the local distribution metadata is unavailable. console.print("Current version could not be determined.") console.print(f"Latest release: {latest_normalized}") - console.print("\nTo reinstall:") - console.print(" uv tool install specify-cli --force \\") - console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + console.print("\nManual fallback:") + console.print(f" uv tool install specify-cli --force --from {_manual_source_spec(tag)}") + console.print(f" pipx install --force {_manual_source_spec(tag)}") + console.print("\nIf this install can still be detected:") + console.print(" specify self upgrade") return if _is_newer(latest_normalized, installed): console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}") console.print("\nTo upgrade:") - console.print(" uv tool install specify-cli --force \\") - console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + console.print(" specify self upgrade") + console.print("\nManual fallback:") + console.print(f" uv tool install specify-cli --force --from {_manual_source_spec(tag)}") + console.print(f" pipx install --force {_manual_source_spec(tag)}") return # Installed is parseable AND is >= latest → "up to date" (FR-006). @@ -157,17 +1051,187 @@ def self_check() -> None: @self_app.command("upgrade") -def self_upgrade() -> None: - """Reserved command surface for self-upgrade; not implemented in this release. +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: Optional[str] = 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. - This command is a documented non-destructive stub in this release: it - performs no outbound network request, no install-method detection, and - invokes no installer. It prints a three-line guidance message and exits 0. - Actual self-upgrade is planned as follow-up work. + 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. - Use `specify self check` today to see whether a newer release is available - and to get a copy-pasteable reinstall command. + 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 (installer exited 0 but `specify --version` + does not resolve to the target tag) + 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. """ - console.print("specify self upgrade is not implemented yet.") - console.print("Run 'specify self check' to see whether a newer release is available.") - console.print("Actual self-upgrade is planned as follow-up work.") + 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) + + # --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( + "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: + _emit_failure("target-tag-unparseable", plan=plan) + raise typer.Exit(1) + target_canonical = str(target_version) + + if plan.current_version != "unknown": + current_version = _parse_version_text(plan.current_version) + current_canonical = str(current_version) if current_version is not None else "" + # Both arguments are pre-canonicalized so the ordering check matches + # the exact-equality check used for pinned targets below. + if tag is None and current_version is not None and not _is_newer( + target_canonical, current_canonical + ): + if current_canonical == target_canonical: + 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) + if tag is not None and current_canonical == target_canonical: + 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. + argv_str = _render_argv(plan.installer_argv) if plan.installer_argv else "" + console.print( + f"Upgrading 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("installer-missing", plan=plan, installer_name=installer_name) + raise typer.Exit(3) + + if installer_result.kind == _InstallerResultKind.INVALID: + _emit_failure("installer-invalid", plan=plan, installer_name=installer_name) + raise typer.Exit(3) + + if installer_result.kind == _InstallerResultKind.TIMEOUT: + _emit_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( + "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) + if ( + verified is None + or _canonicalize_version_text(plan.target_tag) + != _canonicalize_version_text(verified) + ): + _emit_failure( + "verification-mismatch", + plan=plan, + verified_version=verified, + ) + raise typer.Exit(2) + + console.print( + f"Upgraded specify-cli: {plan.pre_upgrade_snapshot} → {verified}", + soft_wrap=True, + ) diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py new file mode 100644 index 0000000000..d7be1be0d8 --- /dev/null +++ b/tests/test_self_upgrade.py @@ -0,0 +1,1889 @@ +"""Tests for `specify self upgrade`. + +These cases patch subprocess, PATH lookup, and release-tag resolution so the +suite stays isolated from the real environment. +""" + +import errno +import json +import os +import subprocess +import urllib.error +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +import specify_cli +from specify_cli import app +from specify_cli._version import ( + _InstallMethod, + _UpgradePlan, + _assemble_installer_argv, + _detect_install_method, + _verify_upgrade, +) + +from tests.conftest import strip_ansi + +runner = CliRunner() + +SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" +SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" + + +def _mock_urlopen_response(payload: dict) -> MagicMock: + """Build a urlopen() context-manager mock whose .read() returns the JSON payload.""" + body = json.dumps(payload).encode("utf-8") + resp = MagicMock() + resp.read.return_value = body + cm = MagicMock() + cm.__enter__.return_value = resp + cm.__exit__.return_value = False + return cm + + +def _completed_process( + returncode: int, stdout: str = "", stderr: str = "" +) -> subprocess.CompletedProcess: + """Build a subprocess.CompletedProcess for installer / verification calls.""" + return subprocess.CompletedProcess( + args=["mocked"], + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) + + +@pytest.fixture +def clean_environ(monkeypatch): + """Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment.""" + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + +@pytest.fixture(autouse=True) +def route_open_url_through_urlopen(monkeypatch): + """Keep release-tag tests hermetic even when ~/.specify/auth.json exists.""" + + def _open_url(url, timeout=10, extra_headers=None): + req = specify_cli._version.urllib.request.Request(url) + for key, value in (extra_headers or {}).items(): + req.add_header(key, value) + return specify_cli._version.urllib.request.urlopen(req, timeout=timeout) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", _open_url) + + +@pytest.fixture +def uv_tool_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME. + + Sets the platform-specific home/tool root env so _expand_prefix() resolves + to a path that actually contains the fake binary. This avoids needing a + `_UV_TOOL_ROOT_OVERRIDE` knob in production code. + """ + if os.name == "nt": + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin" + else: + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin" + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + return fake_specify + + +@pytest.fixture +def pipx_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated pipx install path under tmp HOME.""" + if os.name == "nt": + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + fake_dir = tmp_path / "pipx" / "venvs" / "specify-cli" / "bin" + else: + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = tmp_path / ".local" / "pipx" / "venvs" / "specify-cli" / "bin" + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + return fake_specify + + +@pytest.fixture +def uvx_ephemeral_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME.""" + if os.name == "nt": + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + fake_dir = tmp_path / "uv" / "cache" / "archive-v0" / "abc123" / "bin" + else: + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = tmp_path / ".cache" / "uv" / "archive-v0" / "abc123" / "bin" + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + return fake_specify + + +@pytest.fixture +def unsupported_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a path that does not match any installer prefix.""" + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = tmp_path / "random" / "location" / "bin" + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + return fake_specify + + +class TestDetectionUvTool: + """Tier-1 path-prefix detection for uv-tool installs.""" + + def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UV_TOOL + assert signals.matched_tier == 1 + assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/") + + def test_detection_is_deterministic(self, uv_tool_argv0): + a = _detect_install_method() + b = _detect_install_method() + assert a == b == _InstallMethod.UV_TOOL + + def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0): + with patch("specify_cli._version.shutil.which", return_value=None), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0): + result = _detect_install_method(include_signals=False) + assert isinstance(result, _InstallMethod) + + def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path): + if os.name == "nt": + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin" + else: + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = ( + tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin" + ) + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + monkeypatch.setattr("sys.argv", ["specify"]) + with patch( + "specify_cli._version.shutil.which", + side_effect=lambda name: str(fake_specify) if name == "specify" else None, + ): + method = _detect_install_method() + assert method == _InstallMethod.UV_TOOL + + def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin" + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + with patch("specify_cli._version.shutil.which", return_value=None), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_uv_tool_when_registry_lists_exact_name( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + + def fake_which(name): + return "/usr/bin/uv" if name == "uv" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\nother-tool v1.2.3\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UV_TOOL + assert signals.matched_tier == 3 + assert "uv tool list" in signals.installer_registries_consulted + + def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["specify"]) + + def fake_which(name): + return "/usr/bin/uv" if name == "uv" else None + + def fake_run(argv, *args, **kwargs): + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UNSUPPORTED + assert signals.installer_registries_consulted == [] + + def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection( + self, monkeypatch, tmp_path + ): + missing_specify = tmp_path / "missing" / "specify" + monkeypatch.setattr("sys.argv", ["specify"]) + + def fake_which(name): + if name == "specify": + return str(missing_specify) + if name == "uv": + return "/usr/bin/uv" + return None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + + assert method == _InstallMethod.UV_TOOL + assert signals.matched_tier == 3 + assert "uv tool list" in signals.installer_registries_consulted + + def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup( + self, monkeypatch, tmp_path + ): + if os.name == "nt": + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin" + else: + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = ( + tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin" + ) + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + monkeypatch.setattr("sys.argv", ["./bin/specify"]) + + def fake_which(name): + return str(fake_specify) if name == "specify" else None + + with patch("specify_cli._version.shutil.which", side_effect=fake_which): + method = _detect_install_method() + + assert method == _InstallMethod.UV_TOOL + + def test_tier3_uv_tool_ignores_substring_false_positive( + self, + unsupported_argv0, + ): + def fake_which(name): + return "/usr/bin/uv" if name == "uv" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="my-specify-cli-helper v0.1.0\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint( + self, + unsupported_argv0, + ): + def fake_which(name): + return "/usr/bin/uv" if name == "uv" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint( + self, + monkeypatch, + tmp_path, + ): + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + fake_specify = venv_bin / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", ["specify"]) + + def fake_which(name): + if name == "specify": + return str(fake_specify) + if name == "uv": + return "/usr/bin/uv" + return None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UNSUPPORTED + assert signals.matched_tier is None + assert signals.installer_registries_consulted == [] + + +class TestPrefixExpansion: + """Path-prefix expansion edge cases.""" + + def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path): + prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli" + prefix = str(prefix_path) + + expanded = specify_cli._version._expand_prefix(prefix) + + assert expanded == prefix_path.resolve() + + def test_unresolved_posix_variable_is_rejected(self): + assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None + + +class TestArgvAssemblyUvTool: + """uv-tool installer argv shape.""" + + def test_stable_tag_produces_expected_argv(self): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"): + argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") + assert argv == [ + "/usr/bin/uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ] + + def test_dev_suffix_tag_embedded_literally(self): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"): + argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0") + assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv + assert ( + "upgrade" not in argv + ) # never `uv tool upgrade` — does not accept --tag pinning + + def test_missing_uv_returns_no_installer_argv(self): + with patch("specify_cli._version.shutil.which", return_value=None): + assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None + + +class TestBareUpgradeUvTool: + """uv-tool happy path, bare invocation.""" + + def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), # installer + _completed_process(0, stdout="specify 0.7.6\n"), # verify + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out + assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out + assert mock_run.call_count == 2 + for call in mock_run.call_args_list: + assert call.kwargs.get("shell", False) is False + + def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ): + # The single `invoke` represents the single user action — no prompt. + # If a prompt existed, runner.invoke would hang waiting for input. + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + + +class TestAlreadyLatestUvTool: + """already on latest, no installer launched.""" + + def test_already_latest_exits_zero_no_subprocess( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.6"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Already on latest release: v0.7.6" in strip_ansi(result.output) + assert mock_run.call_count == 0 + + def test_dev_build_ahead_of_release_reports_newer_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output) + assert mock_run.call_count == 0 + + def test_unparseable_current_version_does_not_false_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="release-main"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Already on latest release" not in out + assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out + assert mock_run.call_count == 2 + + def test_unparseable_resolved_target_fails_before_literal_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="release-main"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "release-main"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 1 + out = strip_ansi(result.output) + assert "not a comparable version" in out + assert "Already on latest release" not in out + assert mock_run.call_count == 0 + + def test_pinned_older_tag_still_runs_installer( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.6" + ): + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.5\n"), + ] + result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Already on latest release" not in out + assert "Upgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out + assert mock_run.call_count == 2 + + def test_pinned_rc_tag_uses_canonical_version_equality_for_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="1.0.0rc1" + ): + result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) + + assert result.exit_code == 0 + assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output) + + +class TestDryRunUvTool: + """--dry-run preview path + --dry-run combined with --tag.""" + + def test_dry_run_without_tag_resolves_network_but_no_subprocess( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Dry run — no changes will be made." in out + assert "Detected install method: uv tool" in out + assert "Current version: 0.7.5" in out + assert "Target version: v0.7.6" in out + assert "Command that would be executed:" in out + assert mock_run.call_count == 0 + + def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): + # --dry-run with --tag must NOT hit the network. + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ), patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.8.0"], + ) + assert result.exit_code == 0 + assert "Target version: v0.8.0" in strip_ansi(result.output) + mock_urlopen.assert_not_called() + + def test_dry_run_with_missing_uv_flags_unresolved_installer( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value=None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Command that would be executed: (installer uv not found on PATH)" in out + assert "uv tool install" not in out + assert mock_run.call_count == 0 + + +# =========================================================================== +# Phase 4 — User Story 2: `pipx` immediate upgrade (P2) +# =========================================================================== + + +class TestDetectionPipx: + """Pipx detection — tier 1 (path) and tier 3 (registry).""" + + def test_posix_pipx_prefix_matches(self, pipx_argv0): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.PIPX + assert signals.matched_tier == 1 + + def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + + def fake_which(name): + return "/usr/bin/pipx" if name == "pipx" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout='{"venvs":{"specify-cli":{}}}', + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.PIPX + assert signals.matched_tier == 3 + assert "pipx list --json" in signals.installer_registries_consulted + + def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint( + self, + unsupported_argv0, + ): + def fake_which(name): + return "/usr/bin/pipx" if name == "pipx" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout='{"venvs":{"specify-cli":{}}}', + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_pipx_ignores_malformed_json_output( + self, + unsupported_argv0, + ): + def fake_which(name): + return "/usr/bin/pipx" if name == "pipx" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="not json but mentions specify-cli", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + + def fake_which(name): + if name == "uv": + return "/usr/bin/uv" + if name == "pipx": + return "/usr/bin/pipx" + return None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout='{"venvs":{"specify-cli":{}}}', + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UNSUPPORTED + assert signals.matched_tier is None + assert "uv tool list" in signals.installer_registries_consulted + assert "pipx list --json" in signals.installer_registries_consulted + + +class TestEditableInstallMetadata: + def test_direct_url_editable_install_marks_source_checkout(self, tmp_path): + project_root = tmp_path / "spec-kit" + project_root.mkdir() + (project_root / ".git").mkdir() + + class FakeDist: + files = [] + + def read_text(self, name): + if name == "direct_url.json": + return json.dumps( + { + "dir_info": {"editable": True}, + "url": project_root.as_uri(), + } + ) + return None + + def locate_file(self, file): + return file + + with patch("importlib.metadata.distribution", return_value=FakeDist()): + assert specify_cli._version._editable_marker_seen() is True + assert specify_cli._version._source_checkout_path() == project_root.resolve() + + def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path): + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py" + venv_file.parent.mkdir(parents=True) + venv_file.write_text("# installed module\n") + + class FakeDist: + files = ["specify_cli.py"] + + def read_text(self, name): + return None + + def locate_file(self, file): + return venv_file + + with patch("importlib.metadata.distribution", return_value=FakeDist()): + assert specify_cli._version._editable_marker_seen() is False + + +class TestTagValidationWhitespace: + def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v9.9.9"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.8.0\n"), + ] + result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "]) + + assert result.exit_code == 0 + assert "v0.8.0" in strip_ansi(result.output) + + +class TestArgvAssemblyPipx: + """pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`.""" + + def test_pipx_argv_uses_install_force_positional_not_upgrade(self): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/pipx"): + argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") + assert argv == [ + "/usr/bin/pipx", + "install", + "--force", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ] + assert "upgrade" not in argv # pipx upgrade does not accept arbitrary refs + assert "--spec" not in argv # pipx 1.5+ dropped the --spec flag + + def test_missing_pipx_returns_no_installer_argv(self): + with patch("specify_cli._version.shutil.which", return_value=None): + assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None + + +class TestBareUpgradePipx: + """pipx happy path.""" + + def test_happy_path(self, pipx_argv0, clean_environ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "via pipx:" in out + assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out + + +class TestDetectionShortCircuit: + """Tier-1 path-prefix matches short-circuit before registry checks.""" + + def test_pipx_argv0_prefix_short_circuits_before_registry_checks( + self, + pipx_argv0, + clean_environ, + ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + method = _detect_install_method() + assert method == _InstallMethod.PIPX + mock_run.assert_not_called() + + +class TestDryRunPipx: + def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + assert result.exit_code == 0 + assert "Detected install method: pipx" in strip_ansi(result.output) + assert mock_run.call_count == 0 + + +# =========================================================================== +# Phase 5 — User Story 3: non-upgradable path guidance (P3) +# =========================================================================== + + +class TestUvxEphemeral: + """uvx ephemeral path emits exact one-liner, no installer call.""" + + def test_uvx_argv0_prints_exact_one_liner_and_exits_zero( + self, + uvx_ephemeral_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + expected = ( + "Running via uvx (ephemeral); the next uvx invocation already " + "resolves to latest — no upgrade action needed." + ) + assert expected in strip_ansi(result.output) + assert mock_run.call_count == 0 + + def test_offline_still_exits_zero_without_tag_resolution( + self, + uvx_ephemeral_argv0, + clean_environ, + ): + with patch( + "specify_cli._version.urllib.request.urlopen", + side_effect=AssertionError("non-upgradable uvx path must not hit network"), + ): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + assert "uvx (ephemeral)" in strip_ansi(result.output) + + +class TestSourceCheckout: + """Editable install path emits git pull guidance.""" + + def test_source_checkout_prints_git_pull_guidance( + self, + unsupported_argv0, + tmp_path, + clean_environ, + ): + fake_tree = tmp_path / "worktree" + fake_tree.mkdir() + (fake_tree / ".git").mkdir() + + with patch("specify_cli._version._editable_marker_seen", return_value=True), patch( + "specify_cli._version._source_checkout_path", return_value=fake_tree + ), patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert f"Running from a source checkout at {fake_tree}" in out + assert "git pull" in out + assert "pip install -e ." in out + assert mock_run.call_count == 0 + + +class TestUnsupported: + """Unsupported path enumerates manual reinstall commands.""" + + def test_unsupported_prints_both_reinstall_commands( + self, + unsupported_argv0, + clean_environ, + ): + with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( + "specify_cli._version.shutil.which", return_value=None + ), patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Could not identify your install method automatically" in out + assert ( + "uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@vX.Y.Z" + ) in out + assert ( + "pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z" + in out + ) + assert mock_run.call_count == 0 + + def test_unsupported_offline_degrades_to_placeholder_manual_commands( + self, + unsupported_argv0, + clean_environ, + ): + with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( + "specify_cli._version.shutil.which", return_value=None + ), patch( + "specify_cli._version.urllib.request.urlopen", + side_effect=AssertionError("unsupported guidance should not require network"), + ): + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Could not identify your install method automatically" in out + assert ( + "uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@vX.Y.Z" + ) in out + assert ( + "pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z" + in out + ) + + +class TestDryRunNonUpgradablePaths: + """--dry-run on non-upgradable paths emits guidance, not preview.""" + + def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview( + self, + uvx_ephemeral_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen: + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Dry run — no changes will be made." not in out + assert "uvx (ephemeral)" in out + + def test_dry_run_on_unsupported_emits_manual_commands( + self, + unsupported_argv0, + clean_environ, + ): + with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( + "specify_cli._version.shutil.which", return_value=None + ), patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen: + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + assert result.exit_code == 0 + assert "Could not identify your install method" in strip_ansi(result.output) + + +# =========================================================================== +# Phase 6 — User Story 4: failure recovery (P2) +# =========================================================================== + + +class TestInstallerMissing: + """Installer disappeared between detection and run → exit 3.""" + + def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ): + which_results = {"specify": "/usr/local/bin/specify"} + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + out = strip_ansi(result.output) + assert "Installer uv not found on PATH; reinstall it and retry." in out + assert "Upgrading specify-cli" not in out + + def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ): + which_results = {} + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + assert "Installer pipx not found on PATH" in strip_ansi(result.output) + + def test_absolute_installer_path_does_not_require_path_lookup( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o755) + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._verify_upgrade", return_value="0.7.6" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(0)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + + def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o755) + + def fake_run(argv, *args, **kwargs): + fake_uv.unlink() + raise FileNotFoundError(str(fake_uv)) + + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", + side_effect=lambda name: str(fake_uv) if name == "uv" else None, + ), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 3 + assert ( + f"Installer path {fake_uv} no longer exists; reinstall it and retry." + in strip_ansi(result.output) + ) + + def test_absolute_installer_path_not_executable_gets_specific_message( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o644) + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.os.access", return_value=False), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + assert ( + f"Installer path {fake_uv} is not an executable file; fix the path or reinstall it and retry." + in strip_ansi(result.output) + ) + + def test_real_installer_exit_126_is_not_treated_as_invalid_path( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(126)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 126 + out = strip_ansi(result.output) + assert "Upgrade failed. Installer exit code: 126." in out + assert "not an executable file" not in out + + def test_absolute_installer_path_missing_gets_path_specific_message( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "missing-installer" / "uv" + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + assert ( + f"Installer path {fake_uv} no longer exists; reinstall it and retry." + in strip_ansi(result.output) + ) + + def test_exec_oserror_is_treated_as_invalid_installer( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + fake_uv.chmod(0o755) + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ), patch( + "specify_cli._version.subprocess.run", + side_effect=PermissionError("Permission denied"), + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + out = strip_ansi(result.output) + assert f"Installer path {fake_uv} is not an executable file" in out + assert "not found on PATH" not in out + + def test_exec_oserror_errno_is_treated_as_invalid_installer( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + fake_uv.chmod(0o755) + invalid_error = OSError(errno.ENOEXEC, "Exec format error") + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ), patch("specify_cli._version.subprocess.run", side_effect=invalid_error): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + out = strip_ansi(result.output) + assert f"Installer path {fake_uv} is not an executable file" in out + assert "not found on PATH" not in out + + def test_transient_exec_oserror_is_not_treated_as_invalid_installer( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + fake_uv.chmod(0o755) + transient_error = OSError(errno.EMFILE, "Too many open files") + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ), patch("specify_cli._version.subprocess.run", side_effect=transient_error): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code != 3 + assert isinstance(result.exception, OSError) + + +class TestInstallerFailed: + """Installer non-zero exit → propagate code, print rollback hint.""" + + def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(2)] # installer fails + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Upgrade failed. Installer exit code: 2." in out + assert "Try again or run the command manually:" in out + assert "git+https://github.com/github/spec-kit.git@v0.7.6" in out + assert ( + "To pin back to the previous version: " + "uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@v0.7.5" + ) in out + # No verification attempted after a failed installer run. + assert mock_run.call_count == 1 + + def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(127)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 127 + + def test_installer_timeout_prints_timeout_specific_message( + self, uv_tool_argv0, clean_environ, monkeypatch + ): + monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12") + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + subprocess.TimeoutExpired(cmd=["uv"], timeout=12) + ] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 124 + out = strip_ansi(result.output) + assert "Upgrade timed out while waiting for the installer subprocess." in out + assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out + + def test_non_finite_timeout_warns_and_runs_without_timeout( + self, uv_tool_argv0, clean_environ, monkeypatch + ): + monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan") + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS='nan'" in strip_ansi( + result.output + ) + assert mock_run.call_args_list[0].kwargs["timeout"] is None + + def test_real_installer_exit_124_is_not_treated_as_timeout( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(124)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 124 + out = strip_ansi(result.output) + assert "Upgrade failed. Installer exit code: 124." in out + assert "Upgrade timed out while waiting for the installer subprocess." not in out + + def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(2)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert ( + "To pin back to the previous version: pipx install --force " + "git+https://github.com/github/spec-kit.git@v0.7.5" + ) in out + + def test_prerelease_failure_degrades_rollback_hint_to_releases_page( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="1.0.0rc1" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v1.0.0"}) + mock_run.side_effect = [_completed_process(2)] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Previous version was not an exact stable release tag" in out + assert "https://github.com/github/spec-kit/releases" in out + assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out + + +class TestVerificationMismatch: + """Installer says 0 but the binary is still the old version → exit 2.""" + + def test_installer_ok_but_verify_returns_old_version( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), # installer OK + _completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD! + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Verification failed" in out + assert "resolves to 0.7.5 (expected v0.7.6)" in out + assert "The new version may take effect on your next invocation." in out + + def test_verify_nonzero_exit_is_not_treated_as_success( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(1, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Verification failed" in out + assert "(unknown) (expected v0.7.6)" in out + + def test_verify_accepts_pep440_equivalent_rc_version( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.9.0" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v9.9.9"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 1.0.0rc1\n"), + ] + result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) + + assert result.exit_code == 0 + assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output) + + def test_verify_uses_current_entrypoint_when_not_on_path( + self, + uv_tool_argv0, + clean_environ, + ): + assert uv_tool_argv0.exists() + assert uv_tool_argv0.is_file() + + plan = _UpgradePlan( + method=_InstallMethod.UV_TOOL, + current_version="0.7.5", + target_tag="v0.7.6", + installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], + preview_summary="", + pre_upgrade_snapshot="0.7.5", + ) + + with patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.os.access", return_value=True + ): + mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") + verified = _verify_upgrade(plan) + + assert verified == "0.7.6" + assert mock_run.call_args.args[0][0] == str(uv_tool_argv0) + + def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable( + self, + uv_tool_argv0, + clean_environ, + ): + plan = _UpgradePlan( + method=_InstallMethod.UV_TOOL, + current_version="0.7.5", + target_tag="v0.7.6", + installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], + preview_summary="", + pre_upgrade_snapshot="0.7.5", + ) + + with patch( + "specify_cli._version.shutil.which", + side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None, + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version.os.access", return_value=False + ): + mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") + verified = _verify_upgrade(plan) + + assert verified == "0.7.6" + assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify" + + def test_verify_ignores_python_entrypoint_and_falls_back_to_specify( + self, + clean_environ, + tmp_path, + ): + fake_python = tmp_path / "python3" + fake_python.write_text("#!/bin/sh\n") + fake_python.chmod(0o755) + + plan = _UpgradePlan( + method=_InstallMethod.UV_TOOL, + current_version="0.7.5", + target_tag="v0.7.6", + installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], + preview_summary="", + pre_upgrade_snapshot="0.7.5", + ) + + with patch( + "specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version.sys.argv", [str(fake_python)] + ), patch( + "specify_cli._version.os.access", return_value=True + ): + mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") + verified = _verify_upgrade(plan) + + assert verified == "0.7.6" + assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify" + + def test_verify_accepts_specify_cli_named_current_entrypoint( + self, + clean_environ, + tmp_path, + ): + fake_specify_cli = tmp_path / "specify-cli" + fake_specify_cli.write_text("#!/bin/sh\n") + fake_specify_cli.chmod(0o755) + + plan = _UpgradePlan( + method=_InstallMethod.UV_TOOL, + current_version="0.7.5", + target_tag="v0.7.6", + installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], + preview_summary="", + pre_upgrade_snapshot="0.7.5", + ) + + with patch("specify_cli._version.shutil.which", return_value=None), patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch( + "specify_cli._version.os.access", return_value=True + ): + mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") + verified = _verify_upgrade(plan) + + assert verified == "0.7.6" + assert mock_run.call_args.args[0][0] == str(fake_specify_cli) + + +class TestResolutionFailures: + """Pre-installer resolution failure → exit 1, reusing the resolver category strings.""" + + def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ): + with patch( + "specify_cli._version.urllib.request.urlopen", + side_effect=urllib.error.URLError("nope"), + ): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 1 + assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output) + + def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ): + err = urllib.error.HTTPError( + url="https://api.github.com", + code=403, + msg="rate limited", + hdrs={}, # type: ignore[arg-type] + fp=None, + ) + with patch("specify_cli._version.urllib.request.urlopen", side_effect=err): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 1 + assert ( + "Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)" + in strip_ansi(result.output) + ) + + def test_http_500_exits_1(self, uv_tool_argv0, clean_environ): + err = urllib.error.HTTPError( + url="https://api.github.com", + code=500, + msg="srv err", + hdrs={}, # type: ignore[arg-type] + fp=None, + ) + with patch("specify_cli._version.urllib.request.urlopen", side_effect=err): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 1 + assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output) + + def test_unparseable_resolved_release_tag_exits_1_without_traceback( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "release-main"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 1 + out = strip_ansi(result.output) + assert "resolved release tag 'release-main' is not a comparable version" in out + assert "Traceback" not in out + assert mock_run.call_count == 0 + + +class TestTagValidation: + """--tag regex enforcement.""" + + def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.7.6"], + ) + assert result.exit_code == 0 + + def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"], + ) + assert result.exit_code == 0 + assert "Target version: v0.8.0.dev0" in strip_ansi(result.output) + + def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"], + ) + assert result.exit_code == 0 + + def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"], + ) + assert result.exit_code == 0 + assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) + + @pytest.mark.parametrize( + "bad_tag", + ["latest", "0.7.5", "main", "v7", "", "v1.2.3abc", "v1.2.3...", "v1.2.3++"], + ) + def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ): + result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag]) + assert result.exit_code == 1 + output = strip_ansi(result.output) + assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output + + +class TestUnknownCurrent: + """'unknown' current version renders literally in notice and success message.""" + + def test_unknown_current_renders_literal_in_notice( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="unknown" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out + assert "Upgraded specify-cli: unknown → 0.7.6" in out + + def test_unknown_current_rollback_hint_degrades( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="unknown" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(2)] # installer fails + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Could not determine the previous version" in out + assert "https://github.com/github/spec-kit/releases" in out + + +class TestTokenScrubbing: + """GH_TOKEN / GITHUB_TOKEN are stripped from every child env.""" + + def test_env_passed_to_subprocess_has_no_github_tokens( + self, + uv_tool_argv0, + monkeypatch, + ): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + runner.invoke(app, ["self", "upgrade"]) + + assert mock_run.call_count >= 1 + for call in mock_run.call_args_list: + env_kwarg = call.kwargs.get("env") or {} + assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}" + assert "GITHUB_TOKEN" not in env_kwarg + for v in env_kwarg.values(): + assert SENTINEL_GH_TOKEN not in v + assert SENTINEL_GITHUB_TOKEN not in v + + def test_env_scrubbing_is_case_insensitive( + self, + uv_tool_argv0, + monkeypatch, + ): + monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN) + monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN) + + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + runner.invoke(app, ["self", "upgrade"]) + + assert mock_run.call_count >= 1 + for call in mock_run.call_args_list: + env_kwarg = call.kwargs.get("env") or {} + assert "gh_token" not in env_kwarg + assert "GitHub_Token" not in env_kwarg + for v in env_kwarg.values(): + assert SENTINEL_GH_TOKEN not in v + assert SENTINEL_GITHUB_TOKEN not in v + + def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): + monkeypatch.setenv("GH_PAT", "gh-pat") + monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh") + monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret") + monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key") + monkeypatch.setenv("GITHUB_PAT", "github-pat") + monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github") + monkeypatch.setenv("GITHUB_API_TOKEN", "api-token") + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key") + monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret") + monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token") + monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept") + monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept") + monkeypatch.setenv("UNRELATED_TOKEN", "kept") + + env = specify_cli._version._scrubbed_env() + + assert "GH_PAT" not in env + assert "GH_ENTERPRISE_TOKEN" not in env + assert "GH_ENTERPRISE_SECRET" not in env + assert "GH_ENTERPRISE_PRIVATE_KEY" not in env + assert "GITHUB_PAT" not in env + assert "GITHUB_ENTERPRISE_TOKEN" not in env + assert "GITHUB_API_TOKEN" not in env + assert "GITHUB_APP_PRIVATE_KEY" not in env + assert "GITHUB_OAUTH_CLIENT_SECRET" not in env + assert "HOMEBREW_GITHUB_API_TOKEN" not in env + assert env["GHOST_API_TOKEN"] == "ghost-kept" + assert env["GHIDRA_API_KEY"] == "ghidra-kept" + assert env["UNRELATED_TOKEN"] == "kept" diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 4da392c2c9..54b0376847 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -3,9 +3,9 @@ Network isolation contract (SC-004 / FR-014): every test that exercises `specify self check` or `_fetch_latest_release_tag()` MUST mock `urllib.request.urlopen` so no real outbound call ever reaches -api.github.com. The `self upgrade` stub tests do not need that patch because -the stub is contractually network-free. Run this module under `pytest-socket` -(if installed) with `--disable-socket` as an extra safety net. +api.github.com. Tests for non-network `self upgrade` behavior should keep that +contract explicit with local mocks. Run this module under `pytest-socket` (if +installed) with `--disable-socket` as an extra safety net. """ import json @@ -55,39 +55,6 @@ def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: ) -class TestSelfUpgradeStub: - """Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016).""" - - def test_prints_exactly_three_lines_and_exits_zero(self): - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 0 - lines = strip_ansi(result.output).strip().splitlines() - assert lines == [ - "specify self upgrade is not implemented yet.", - "Run 'specify self check' to see whether a newer release is available.", - "Actual self-upgrade is planned as follow-up work.", - ] - - def test_stub_makes_no_network_call(self): - # The stub must not hit the network via either urllib path: - # unauthenticated requests use urlopen() directly; authenticated ones - # go through build_opener(...).open(). Both are patched so that any - # accidental network call raises immediately. - network_error = AssertionError("stub must not hit the network") - with ( - patch( - "specify_cli.authentication.http.urllib.request.urlopen", - side_effect=network_error, - ), - patch( - "specify_cli.authentication.http.urllib.request.build_opener", - side_effect=network_error, - ), - ): - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 0 - - class TestIsNewer: def test_latest_strictly_greater_returns_true(self): assert _is_newer("0.8.0", "0.7.4") is True @@ -195,6 +162,8 @@ def test_unknown_installed_still_prints_latest_and_reinstall(self): assert "Current version could not be determined" in output assert "0.7.4" in output assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output + assert "specify self upgrade" in output + assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output def test_unparseable_tag_routes_to_indeterminate(self): with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( From 164e5e5dc15c7c49894c9e75838eb5bf183c5bc7 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 11:14:04 +0900 Subject: [PATCH 02/27] fix(cli): normalize self-upgrade prerelease tags --- src/specify_cli/_version.py | 20 +++++--- tests/test_self_upgrade.py | 93 +++++++++++++++++++++---------------- 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 5fe63f477b..9d5fa19181 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -57,13 +57,19 @@ def _get_installed_version() -> str: def _normalize_tag(tag: str) -> str: - """Strip exactly one leading 'v' from a release tag. + """Normalize common git release-tag spellings into PEP 440 text.""" + normalized = tag[1:] if tag.startswith("v") else tag + prerelease_match = re.match( + r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|a|b|rc)[-.]?(\d+)(.*)$", + normalized, + flags=re.IGNORECASE, + ) + if prerelease_match is None: + return normalized - Returns the rest of the string unchanged. This handles the common - 'vX.Y.Z' tag convention in this repo; it MUST NOT strip more - aggressively (e.g., two leading 'v's keeps one). - """ - return tag[1:] if tag.startswith("v") else tag + 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: @@ -250,7 +256,7 @@ def _scrubbed_env() -> dict[str, str]: _TAG_REGEX = re.compile( r"^v\d+\.\d+\.\d+" - r"(?:(?:\.?dev\d+)|(?:[-.]?(?:a|b|rc|alpha|beta)\d+)|" + r"(?:(?:\.?dev\d+)|(?:[-.]?(?:a|b|rc|alpha|beta)[-.]?\d+)|" r"(?:\+[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)*))?$" ) diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index d7be1be0d8..e5d801af7b 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -75,6 +75,18 @@ def _open_url(url, timeout=10, extra_headers=None): monkeypatch.setattr("specify_cli.authentication.http.open_url", _open_url) +def _fake_argv0(monkeypatch, tmp_path, env_name, path_parts): + """Create a fake executable under tmp_path and point sys.argv[0] at it.""" + monkeypatch.setenv(env_name, str(tmp_path)) + fake_dir = tmp_path.joinpath(*path_parts) + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + return fake_specify + + @pytest.fixture def uv_tool_argv0(monkeypatch, tmp_path): """Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME. @@ -84,64 +96,48 @@ def uv_tool_argv0(monkeypatch, tmp_path): `_UV_TOOL_ROOT_OVERRIDE` knob in production code. """ if os.name == "nt": - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin" - else: - monkeypatch.setenv("HOME", str(tmp_path)) - fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin" - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - fake_specify.chmod(0o755) - monkeypatch.setattr("sys.argv", [str(fake_specify)]) - return fake_specify + return _fake_argv0( + monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin") + ) + return _fake_argv0( + monkeypatch, + tmp_path, + "HOME", + (".local", "share", "uv", "tools", "specify-cli", "bin"), + ) @pytest.fixture def pipx_argv0(monkeypatch, tmp_path): """Point sys.argv[0] at a simulated pipx install path under tmp HOME.""" if os.name == "nt": - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - fake_dir = tmp_path / "pipx" / "venvs" / "specify-cli" / "bin" - else: - monkeypatch.setenv("HOME", str(tmp_path)) - fake_dir = tmp_path / ".local" / "pipx" / "venvs" / "specify-cli" / "bin" - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - fake_specify.chmod(0o755) - monkeypatch.setattr("sys.argv", [str(fake_specify)]) - return fake_specify + return _fake_argv0( + monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin") + ) + return _fake_argv0( + monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin") + ) @pytest.fixture def uvx_ephemeral_argv0(monkeypatch, tmp_path): """Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME.""" if os.name == "nt": - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - fake_dir = tmp_path / "uv" / "cache" / "archive-v0" / "abc123" / "bin" - else: - monkeypatch.setenv("HOME", str(tmp_path)) - fake_dir = tmp_path / ".cache" / "uv" / "archive-v0" / "abc123" / "bin" - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - fake_specify.chmod(0o755) - monkeypatch.setattr("sys.argv", [str(fake_specify)]) - return fake_specify + return _fake_argv0( + monkeypatch, + tmp_path, + "LOCALAPPDATA", + ("uv", "cache", "archive-v0", "abc123", "bin"), + ) + return _fake_argv0( + monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin") + ) @pytest.fixture def unsupported_argv0(monkeypatch, tmp_path): """Point sys.argv[0] at a path that does not match any installer prefix.""" - monkeypatch.setenv("HOME", str(tmp_path)) - fake_dir = tmp_path / "random" / "location" / "bin" - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - fake_specify.chmod(0o755) - monkeypatch.setattr("sys.argv", [str(fake_specify)]) - return fake_specify + return _fake_argv0(monkeypatch, tmp_path, "HOME", ("random", "location", "bin")) class TestDetectionUvTool: @@ -1729,6 +1725,21 @@ def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): ) assert result.exit_code == 0 + def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="1.0.0b1" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--tag", "v1.0.0-beta.1"], + ) + assert result.exit_code == 0 + assert "Already on requested release: v1.0.0-beta.1" in strip_ansi( + result.output + ) + def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" From 0182937d5408a59447e12335370243be39cb23e4 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 11:36:00 +0900 Subject: [PATCH 03/27] fix(cli): tighten self-upgrade diagnostics --- src/specify_cli/_version.py | 25 ++++++++++++++++--------- tests/test_self_upgrade.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 9d5fa19181..3d035f83e8 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -228,7 +228,7 @@ class _DetectionSignals: matched_tier: int | None matched_prefix: str | None editable_marker_seen: bool - installer_registries_consulted: list[str] + installer_registries_consulted: tuple[str, ...] resolved_method: _InstallMethod @@ -438,7 +438,7 @@ def _detect_install_method( matched_tier=1, matched_prefix=prefix, editable_marker_seen=False, - installer_registries_consulted=[], + installer_registries_consulted=(), resolved_method=method, ) return method @@ -452,7 +452,7 @@ def _detect_install_method( matched_tier=2, matched_prefix=None, editable_marker_seen=True, - installer_registries_consulted=[], + installer_registries_consulted=(), resolved_method=method, ) return method @@ -512,7 +512,7 @@ def _detect_install_method( matched_tier=3, matched_prefix=None, editable_marker_seen=False, - installer_registries_consulted=consulted, + installer_registries_consulted=tuple(consulted), resolved_method=method, ) return method @@ -525,7 +525,7 @@ def _detect_install_method( matched_tier=None, matched_prefix=None, editable_marker_seen=False, - installer_registries_consulted=consulted, + installer_registries_consulted=tuple(consulted), resolved_method=method, ) return method @@ -911,10 +911,17 @@ def _emit_failure( if category == "installer-invalid": name = installer_name or "(unknown)" - console.print( - f"Installer path {name} is not an executable file; fix the path or reinstall it and retry.", - soft_wrap=True, - ) + if installer_name and os.path.isabs(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 == "target-tag-unparseable": diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index e5d801af7b..1ccba9f134 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -247,7 +247,7 @@ def fake_run(argv, *args, **kwargs): ), patch("specify_cli._version._editable_marker_seen", return_value=False): method, signals = _detect_install_method(include_signals=True) assert method == _InstallMethod.UNSUPPORTED - assert signals.installer_registries_consulted == [] + assert signals.installer_registries_consulted == () def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection( self, monkeypatch, tmp_path @@ -394,7 +394,7 @@ def fake_run(argv, *args, **kwargs): method, signals = _detect_install_method(include_signals=True) assert method == _InstallMethod.UNSUPPORTED assert signals.matched_tier is None - assert signals.installer_registries_consulted == [] + assert signals.installer_registries_consulted == () class TestPrefixExpansion: @@ -1261,6 +1261,34 @@ def test_exec_oserror_is_treated_as_invalid_installer( assert f"Installer path {fake_uv} is not an executable file" in out assert "not found on PATH" not in out + def test_bare_invalid_installer_message_does_not_call_it_a_path( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + "uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ), patch( + "specify_cli._version.subprocess.run", + side_effect=PermissionError("Permission denied"), + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 3 + out = strip_ansi(result.output) + assert "Installer uv is not executable" in out + assert "Installer path uv" not in out + def test_exec_oserror_errno_is_treated_as_invalid_installer( self, uv_tool_argv0, clean_environ, tmp_path ): From c1fddcd01cb884b4e3c7177f5f7aa7db5fd071e5 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 12:12:30 +0900 Subject: [PATCH 04/27] fix(cli): harden self-upgrade verification parsing --- src/specify_cli/_version.py | 21 ++++++++++++--------- tests/test_self_upgrade.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 3d035f83e8..d536e113fa 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -32,6 +32,11 @@ 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 " def _get_installed_version() -> str: @@ -111,12 +116,10 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: except urllib.error.HTTPError as e: # Order matters: HTTPError is a subclass of URLError. if e.code == 403: - return None, ( - "rate limited (configure ~/.specify/auth.json with a GitHub token)" - ) - return None, f"HTTP {e.code}" + return None, _RESOLUTION_FAILURE_RATE_LIMITED + return None, f"{_RESOLUTION_FAILURE_HTTP_PREFIX}{e.code}" except (urllib.error.URLError, OSError): - return None, "offline or timeout" + return None, _RESOLUTION_FAILURE_OFFLINE def _parse_version_text(value: str) -> Version | None: @@ -175,8 +178,8 @@ def _render_argv(argv: list[str]) -> str: _RESOLUTION_FAILURE_CATEGORIES: frozenset[str] = frozenset( { - "offline or timeout", - "rate limited (configure ~/.specify/auth.json with a GitHub token)", + _RESOLUTION_FAILURE_OFFLINE, + _RESOLUTION_FAILURE_RATE_LIMITED, } ) @@ -741,7 +744,7 @@ def _run_installer(plan: _UpgradePlan) -> _InstallerResult: raise -_VERIFY_VERSION_REGEX = re.compile(r"specify (\S+)") +_VERIFY_VERSION_REGEX = re.compile(r"\b(?:specify|specify-cli)\s+(\S+)") def _verify_upgrade(plan: _UpgradePlan) -> str | None: @@ -890,7 +893,7 @@ def _emit_failure( """Render user-facing output for resolver, installer, or verification failures.""" if ( category in _RESOLUTION_FAILURE_CATEGORIES - or category.startswith("HTTP ") + or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX) ): console.print(f"Upgrade aborted: {category}", soft_wrap=True) return diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 1ccba9f134..b20102d95f 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -1543,6 +1543,26 @@ def test_verify_accepts_pep440_equivalent_rc_version( assert result.exit_code == 0 assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output) + def test_verify_accepts_specify_cli_binary_name_in_version_output( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify-cli 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output) + def test_verify_uses_current_entrypoint_when_not_on_path( self, uv_tool_argv0, From 3f032a5c2974941e81ffebcf34737075feb763cd Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 13:32:30 +0900 Subject: [PATCH 05/27] fix(cli): sanitize self-check fallback tags --- docs/upgrade.md | 2 +- src/specify_cli/_version.py | 23 +++++++++++++++++++---- tests/test_upgrade.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index 4ddca19f71..e25c052e0b 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -41,7 +41,7 @@ specify self upgrade --tag vX.Y.Z Bare `specify self upgrade` executes immediately, matching the `pip install -U` / `uv tool upgrade` / `npm update` convention. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; the other paths print path-specific guidance and exit 0 without touching anything. -Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and prints `Upgrade timed out`. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases. +Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases. If your installed CLI is older than the release that introduced `specify self upgrade`, use the manual equivalents below. These commands are also useful when you want explicit control over the installer command. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index d536e113fa..84a9a464a2 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -548,6 +548,16 @@ def _manual_source_spec(target_tag: str | None) -> str: 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: @@ -1037,6 +1047,7 @@ def self_check() -> None: return latest_normalized = _normalize_tag(tag) + manual_tag = _manual_tag_or_placeholder(tag) if installed == "unknown": # FR-020: surface the latest release and the recovery action even @@ -1044,8 +1055,10 @@ def self_check() -> None: console.print("Current version could not be determined.") console.print(f"Latest release: {latest_normalized}") console.print("\nManual fallback:") - console.print(f" uv tool install specify-cli --force --from {_manual_source_spec(tag)}") - console.print(f" pipx install --force {_manual_source_spec(tag)}") + 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 @@ -1055,8 +1068,10 @@ def self_check() -> None: 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(tag)}") - console.print(f" pipx install --force {_manual_source_spec(tag)}") + 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 # Installed is parseable AND is >= latest → "up to date" (FR-006). diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 54b0376847..619263428d 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -165,6 +165,17 @@ def test_unknown_installed_still_prints_latest_and_reinstall(self): assert "specify self upgrade" in output assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output + def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self): + with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.9.0;echo unsafe"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output + assert "git+https://github.com/github/spec-kit.git@v0.9.0;echo unsafe" not in output + def test_unparseable_tag_routes_to_indeterminate(self): with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", From f623484075359806e4c9ace51b75396b0bb8fbb5 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 13:37:37 +0900 Subject: [PATCH 06/27] fix(cli): harden self-check release display --- src/specify_cli/_version.py | 18 ++++++++++-------- tests/test_upgrade.py | 3 ++- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 84a9a464a2..14100ee315 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -157,7 +157,7 @@ def _is_comparable_version_text(value: str) -> bool: def _render_argv(argv: list[str]) -> str: - """Render argv for copy/paste on the current platform.""" + """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) @@ -225,7 +225,7 @@ class _UpgradePlan: @dataclass(frozen=True) class _DetectionSignals: - """Test-only record of which detection tier fired.""" + """Diagnostic record of which detection tier fired.""" sys_argv0: str matched_tier: int | None @@ -262,6 +262,7 @@ def _scrubbed_env() -> dict[str, str]: r"(?:(?:\.?dev\d+)|(?:[-.]?(?:a|b|rc|alpha|beta)[-.]?\d+)|" 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: @@ -273,15 +274,13 @@ def _validate_tag(tag: str) -> str: """ tag = tag.strip() if not tag: - raise typer.BadParameter("Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]") + raise typer.BadParameter(_INVALID_TAG_MESSAGE) if not _TAG_REGEX.match(tag): - raise typer.BadParameter("Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]") + raise typer.BadParameter(_INVALID_TAG_MESSAGE) try: Version(_normalize_tag(tag)) except InvalidVersion as exc: - raise typer.BadParameter( - "Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]" - ) from exc + raise typer.BadParameter(_INVALID_TAG_MESSAGE) from exc return tag @@ -1048,12 +1047,15 @@ def self_check() -> None: latest_normalized = _normalize_tag(tag) manual_tag = _manual_tag_or_placeholder(tag) + latest_display = ( + _normalize_tag(manual_tag) if manual_tag is not None else _MANUAL_TAG_PLACEHOLDER + ) 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_normalized}") + 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)}" diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 619263428d..f2220515f4 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -173,8 +173,9 @@ def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self): result = runner.invoke(app, ["self", "check"]) output = strip_ansi(result.output) assert result.exit_code == 0 + assert "Latest release: vX.Y.Z" in output assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output - assert "git+https://github.com/github/spec-kit.git@v0.9.0;echo unsafe" not in output + assert "v0.9.0;echo unsafe" not in output def test_unparseable_tag_routes_to_indeterminate(self): with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( From 24c06be17ae5e18fb351c28b69d9a62e3abbff1f Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 13:47:51 +0900 Subject: [PATCH 07/27] fix(cli): validate resolved upgrade tags --- src/specify_cli/_version.py | 21 ++++++++++++++++++++- tests/test_self_upgrade.py | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 14100ee315..a6159953b6 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -619,6 +619,7 @@ def _build_upgrade_plan( """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() @@ -628,7 +629,21 @@ def _build_upgrade_plan( tag, failure_reason = _fetch_latest_release_tag() if tag is None: return None, failure_reason # surfaces as exit 1 in the orchestrator - target_tag = tag + 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, + ), + "target-tag-unparseable", + ) else: target_tag = None @@ -1149,6 +1164,10 @@ def self_upgrade( _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. diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index b20102d95f..34357cfd81 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -625,6 +625,25 @@ def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): assert "Target version: v0.8.0" in strip_ansi(result.output) mock_urlopen.assert_not_called() + def test_dry_run_rejects_unparseable_network_tag_before_preview( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = _mock_urlopen_response( + {"tag_name": "v0.9.0;echo unsafe"} + ) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + out = strip_ansi(result.output) + assert result.exit_code == 1 + assert "not a comparable version" in out + assert "Command that would be executed:" not in out + assert mock_run.call_count == 0 + def test_dry_run_with_missing_uv_flags_unresolved_installer( self, uv_tool_argv0, clean_environ ): From 9ebc854f1bf58dc590db0cf5b11d887ed534530b Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 14:04:14 +0900 Subject: [PATCH 08/27] fix(cli): tolerate invalid install metadata --- src/specify_cli/_version.py | 14 ++++++++++++-- tests/test_self_upgrade.py | 21 +++++++++++++++++++++ tests/test_upgrade.py | 28 +++++++++++++++++----------- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index a6159953b6..d2a629705f 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -369,9 +369,14 @@ 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 _md.PackageNotFoundError: + except tuple(metadata_errors): return None payload = dist.read_text("direct_url.json") @@ -823,9 +828,14 @@ def _source_checkout_path() -> Path | None: 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 _md.PackageNotFoundError: + except tuple(metadata_errors): return None files = dist.files or [] for f in files: diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 34357cfd81..7e5910ae72 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -5,6 +5,7 @@ """ import errno +import importlib.metadata import json import os import subprocess @@ -799,6 +800,26 @@ def fake_run(argv, *args, **kwargs): class TestEditableInstallMetadata: + def test_editable_marker_false_when_metadata_is_invalid(self): + invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_metadata_error is None: + class _FakeInvalidMetadataError(Exception): + pass + + invalid_metadata_error = _FakeInvalidMetadataError + + with patch.object( + importlib.metadata, + "InvalidMetadataError", + invalid_metadata_error, + create=True, + ), patch( + "importlib.metadata.distribution", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert specify_cli._version._editable_marker_seen() is False + assert specify_cli._version._source_checkout_path() is None + def test_direct_url_editable_install_marks_source_checkout(self, tmp_path): project_root = tmp_path / "spec-kit" project_root.mkdir() diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index f2220515f4..62d56bab2c 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -9,13 +9,14 @@ """ import json -import urllib.error import importlib.metadata +import urllib.error from unittest.mock import MagicMock, patch import pytest from typer.testing import CliRunner +import specify_cli from specify_cli import app from specify_cli._version import ( _fetch_latest_release_tag, @@ -55,6 +56,17 @@ def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: ) +@pytest.fixture(autouse=True) +def route_open_url_through_urlopen(monkeypatch): + """Keep release-tag tests hermetic even when ~/.specify/auth.json exists.""" + + def _open_url(url, timeout=10, extra_headers=None): + req = specify_cli.authentication.http.build_request(url, extra_headers) + return specify_cli._version.urllib.request.urlopen(req, timeout=timeout) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", _open_url) + + class TestIsNewer: def test_latest_strictly_greater_returns_true(self): assert _is_newer("0.8.0", "0.7.4") is True @@ -287,7 +299,7 @@ def test_failure_output_contains_no_traceback_no_url( def _capture_request_via_urlopen(): captured = {} - def _side_effect(req, timeout=None): + def _side_effect(req, *args, **kwargs): captured["request"] = req return _mock_urlopen_response({"tag_name": "v0.7.4"}) @@ -305,9 +317,7 @@ def test_gh_token_attached_as_bearer_header(self, monkeypatch): monkeypatch.delenv("GITHUB_TOKEN", raising=False) _inject_github_config(monkeypatch, token_env="GH_TOKEN") captured, side_effect = _capture_request_via_urlopen() - mock_opener = MagicMock() - mock_opener.open.side_effect = side_effect - with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}" @@ -317,9 +327,7 @@ def test_github_token_used_when_gh_token_unset(self, monkeypatch): monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) _inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") captured, side_effect = _capture_request_via_urlopen() - mock_opener = MagicMock() - mock_opener.open.side_effect = side_effect - with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" @@ -358,9 +366,7 @@ def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch): monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) _inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") captured, side_effect = _capture_request_via_urlopen() - mock_opener = MagicMock() - mock_opener.open.side_effect = side_effect - with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" From 15c0292e6c44da2bd815e2ef5b6e69c93cd245dc Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 14:09:54 +0900 Subject: [PATCH 09/27] test(cli): align upgrade network mocks --- tests/test_self_upgrade.py | 6 ++---- tests/test_upgrade.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 7e5910ae72..f52428efdc 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -68,10 +68,8 @@ def route_open_url_through_urlopen(monkeypatch): """Keep release-tag tests hermetic even when ~/.specify/auth.json exists.""" def _open_url(url, timeout=10, extra_headers=None): - req = specify_cli._version.urllib.request.Request(url) - for key, value in (extra_headers or {}).items(): - req.add_header(key, value) - return specify_cli._version.urllib.request.urlopen(req, timeout=timeout) + req = specify_cli.authentication.http.build_request(url, extra_headers) + return specify_cli.authentication.http.urllib.request.urlopen(req, timeout=timeout) monkeypatch.setattr("specify_cli.authentication.http.open_url", _open_url) diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 62d56bab2c..2fe88a8c4f 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -62,7 +62,7 @@ def route_open_url_through_urlopen(monkeypatch): def _open_url(url, timeout=10, extra_headers=None): req = specify_cli.authentication.http.build_request(url, extra_headers) - return specify_cli._version.urllib.request.urlopen(req, timeout=timeout) + return specify_cli.authentication.http.urllib.request.urlopen(req, timeout=timeout) monkeypatch.setattr("specify_cli.authentication.http.open_url", _open_url) From eb0deb1e9e85ede0adc118a31094d2a8ea7d9c64 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 14:18:46 +0900 Subject: [PATCH 10/27] fix(cli): respect relative installer paths --- src/specify_cli/_version.py | 15 +++- tests/test_self_upgrade.py | 132 ++++++++++++++++++++++-------------- 2 files changed, 95 insertions(+), 52 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index d2a629705f..184b5382b3 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -607,6 +607,11 @@ def _installer_binary_name(method: _InstallMethod) -> str | None: 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 { @@ -731,13 +736,19 @@ def _run_installer(plan: _UpgradePlan) -> _InstallerResult: # 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_cmd = Path(plan.installer_argv[0]) + installer_name = plan.installer_argv[0] + installer_cmd = Path(installer_name) if installer_cmd.is_absolute(): if installer_cmd.exists() and ( not installer_cmd.is_file() or not os.access(installer_cmd, os.X_OK) ): return _InstallerResult(_InstallerResultKind.INVALID) - elif shutil.which(plan.installer_argv[0]) is None: + 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") diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index f52428efdc..b9f54879c7 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -444,7 +444,7 @@ class TestBareUpgradeUvTool: """uv-tool happy path, bare invocation.""" def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -467,7 +467,7 @@ def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ): # The single `invoke` represents the single user action — no prompt. # If a prompt existed, runner.invoke would hang waiting for input. - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -487,7 +487,7 @@ class TestAlreadyLatestUvTool: def test_already_latest_exits_zero_no_subprocess( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" @@ -502,7 +502,7 @@ def test_already_latest_exits_zero_no_subprocess( def test_dev_build_ahead_of_release_reports_newer_noop( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" @@ -517,7 +517,7 @@ def test_dev_build_ahead_of_release_reports_newer_noop( def test_unparseable_current_version_does_not_false_noop( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" @@ -538,7 +538,7 @@ def test_unparseable_current_version_does_not_false_noop( def test_unparseable_resolved_target_fails_before_literal_noop( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" @@ -592,7 +592,7 @@ def test_dry_run_without_tag_resolves_network_but_no_subprocess( uv_tool_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" @@ -611,7 +611,7 @@ def test_dry_run_without_tag_resolves_network_but_no_subprocess( def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): # --dry-run with --tag must NOT hit the network. - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ), patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -627,7 +627,7 @@ def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): def test_dry_run_rejects_unparseable_network_tag_before_preview( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" @@ -646,7 +646,7 @@ def test_dry_run_rejects_unparseable_network_tag_before_preview( def test_dry_run_with_missing_uv_flags_unresolved_installer( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value=None @@ -866,7 +866,7 @@ def locate_file(self, file): class TestTagValidationWhitespace: def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -906,7 +906,7 @@ class TestBareUpgradePipx: """pipx happy path.""" def test_happy_path(self, pipx_argv0, clean_environ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -942,7 +942,7 @@ def test_pipx_argv0_prefix_short_circuits_before_registry_checks( class TestDryRunPipx: def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -967,7 +967,7 @@ def test_uvx_argv0_prints_exact_one_liner_and_exits_zero( uvx_ephemeral_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run: mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) @@ -986,7 +986,7 @@ def test_offline_still_exits_zero_without_tag_resolution( clean_environ, ): with patch( - "specify_cli._version.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=AssertionError("non-upgradable uvx path must not hit network"), ): result = runner.invoke(app, ["self", "upgrade"]) @@ -1009,7 +1009,7 @@ def test_source_checkout_prints_git_pull_guidance( with patch("specify_cli._version._editable_marker_seen", return_value=True), patch( "specify_cli._version._source_checkout_path", return_value=fake_tree - ), patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run: mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) @@ -1033,7 +1033,7 @@ def test_unsupported_prints_both_reinstall_commands( ): with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( "specify_cli._version.shutil.which", return_value=None - ), patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run: mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) @@ -1060,7 +1060,7 @@ def test_unsupported_offline_degrades_to_placeholder_manual_commands( with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( "specify_cli._version.shutil.which", return_value=None ), patch( - "specify_cli._version.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=AssertionError("unsupported guidance should not require network"), ): result = runner.invoke(app, ["self", "upgrade"]) @@ -1086,7 +1086,7 @@ def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview( uvx_ephemeral_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen: + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen: mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) assert result.exit_code == 0 @@ -1101,7 +1101,7 @@ def test_dry_run_on_unsupported_emits_manual_commands( ): with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( "specify_cli._version.shutil.which", return_value=None - ), patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen: + ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen: mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) assert result.exit_code == 0 @@ -1118,7 +1118,7 @@ class TestInstallerMissing: def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ): which_results = {"specify": "/usr/local/bin/specify"} - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) @@ -1130,7 +1130,7 @@ def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ): def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ): which_results = {} - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) @@ -1145,7 +1145,7 @@ def test_absolute_installer_path_does_not_require_path_lookup( fake_uv.parent.mkdir() fake_uv.write_text("#!/bin/sh\n") fake_uv.chmod(0o755) - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1168,6 +1168,38 @@ def test_absolute_installer_path_does_not_require_path_lookup( result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 + def test_relative_installer_path_does_not_require_path_lookup( + self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "uv" + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o755) + monkeypatch.chdir(tmp_path) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._verify_upgrade", return_value="0.7.6" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + "./uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(0)] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert mock_run.call_args.args[0][0] == "./uv" + def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message( self, uv_tool_argv0, clean_environ, tmp_path ): @@ -1180,7 +1212,7 @@ def fake_run(argv, *args, **kwargs): fake_uv.unlink() raise FileNotFoundError(str(fake_uv)) - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: str(fake_uv) if name == "uv" else None, ), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch( @@ -1202,7 +1234,7 @@ def test_absolute_installer_path_not_executable_gets_specific_message( fake_uv.parent.mkdir() fake_uv.write_text("#!/bin/sh\n") fake_uv.chmod(0o644) - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version.os.access", return_value=False), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1229,7 +1261,7 @@ def test_absolute_installer_path_not_executable_gets_specific_message( def test_real_installer_exit_126_is_not_treated_as_invalid_path( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1246,7 +1278,7 @@ def test_absolute_installer_path_missing_gets_path_specific_message( self, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "missing-installer" / "uv" - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", @@ -1275,7 +1307,7 @@ def test_exec_oserror_is_treated_as_invalid_installer( fake_uv.parent.mkdir() fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") fake_uv.chmod(0o755) - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", @@ -1302,7 +1334,7 @@ def test_exec_oserror_is_treated_as_invalid_installer( def test_bare_invalid_installer_message_does_not_call_it_a_path( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", @@ -1335,7 +1367,7 @@ def test_exec_oserror_errno_is_treated_as_invalid_installer( fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") fake_uv.chmod(0o755) invalid_error = OSError(errno.ENOEXEC, "Exec format error") - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", @@ -1364,7 +1396,7 @@ def test_transient_exec_oserror_is_not_treated_as_invalid_installer( fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") fake_uv.chmod(0o755) transient_error = OSError(errno.EMFILE, "Too many open files") - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", @@ -1388,7 +1420,7 @@ class TestInstallerFailed: """Installer non-zero exit → propagate code, print rollback hint.""" def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1411,7 +1443,7 @@ def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): assert mock_run.call_count == 1 def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1425,7 +1457,7 @@ def test_installer_timeout_prints_timeout_specific_message( self, uv_tool_argv0, clean_environ, monkeypatch ): monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12") - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1444,7 +1476,7 @@ def test_non_finite_timeout_warns_and_runs_without_timeout( self, uv_tool_argv0, clean_environ, monkeypatch ): monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan") - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1465,7 +1497,7 @@ def test_non_finite_timeout_warns_and_runs_without_timeout( def test_real_installer_exit_124_is_not_treated_as_timeout( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1479,7 +1511,7 @@ def test_real_installer_exit_124_is_not_treated_as_timeout( assert "Upgrade timed out while waiting for the installer subprocess." not in out def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1497,7 +1529,7 @@ def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ) def test_prerelease_failure_degrades_rollback_hint_to_releases_page( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="1.0.0rc1" @@ -1521,7 +1553,7 @@ def test_installer_ok_but_verify_returns_old_version( uv_tool_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1544,7 +1576,7 @@ def test_verify_nonzero_exit_is_not_treated_as_success( uv_tool_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1566,7 +1598,7 @@ def test_verify_accepts_pep440_equivalent_rc_version( uv_tool_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.9.0" @@ -1586,7 +1618,7 @@ def test_verify_accepts_specify_cli_binary_name_in_version_output( uv_tool_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1723,7 +1755,7 @@ class TestResolutionFailures: def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ): with patch( - "specify_cli._version.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=urllib.error.URLError("nope"), ): result = runner.invoke(app, ["self", "upgrade"]) @@ -1738,7 +1770,7 @@ def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ): hdrs={}, # type: ignore[arg-type] fp=None, ) - with patch("specify_cli._version.urllib.request.urlopen", side_effect=err): + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 assert ( @@ -1754,7 +1786,7 @@ def test_http_500_exits_1(self, uv_tool_argv0, clean_environ): hdrs={}, # type: ignore[arg-type] fp=None, ) - with patch("specify_cli._version.urllib.request.urlopen", side_effect=err): + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output) @@ -1762,7 +1794,7 @@ def test_http_500_exits_1(self, uv_tool_argv0, clean_environ): def test_unparseable_resolved_release_tag_exits_1_without_traceback( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" @@ -1856,7 +1888,7 @@ def test_unknown_current_renders_literal_in_notice( uv_tool_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" @@ -1878,7 +1910,7 @@ def test_unknown_current_rollback_hint_degrades( uv_tool_argv0, clean_environ, ): - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" @@ -1904,7 +1936,7 @@ def test_env_passed_to_subprocess_has_no_github_tokens( monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -1933,7 +1965,7 @@ def test_env_scrubbing_is_case_insensitive( monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN) monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN) - with patch("specify_cli._version.urllib.request.urlopen") as mock_urlopen, patch( + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" From 1577e5ed62be228a2f926c0a53f6216607478add Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 14:25:13 +0900 Subject: [PATCH 11/27] fix(cli): tighten upgrade failure handling --- src/specify_cli/_version.py | 6 ++++-- tests/conftest.py | 11 +++++++++++ tests/test_self_upgrade.py | 37 ++++++++++++++++++++++++++++++------- tests/test_upgrade.py | 10 ++-------- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 184b5382b3..b6242e7c99 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -852,7 +852,7 @@ def _source_checkout_path() -> Path | None: for f in files: try: abs_path = Path(dist.locate_file(f)).resolve() - except Exception: + except (OSError, RuntimeError, TypeError, ValueError): continue git_root = _git_ancestor(abs_path) if git_root is not None: @@ -944,7 +944,9 @@ def _emit_failure( return if category == "installer-missing": - if installer_name and os.path.isabs(installer_name): + 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, diff --git a/tests/conftest.py b/tests/conftest.py index 0e568a1e2a..5dbda2bde4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,6 +68,17 @@ def strip_ansi(text: str) -> str: return _ANSI_ESCAPE_RE.sub("", text) +def route_auth_open_url_through_urlopen(monkeypatch) -> None: + """Route auth-aware GitHub requests through urlopen for hermetic tests.""" + from specify_cli.authentication import http as _auth_http + + def _open_url(url, timeout=10, extra_headers=None): + req = _auth_http.build_request(url, extra_headers) + return _auth_http.urllib.request.urlopen(req, timeout=timeout) + + monkeypatch.setattr(_auth_http, "open_url", _open_url) + + # --------------------------------------------------------------------------- # Auth config isolation — prevents tests from reading ~/.specify/auth.json # --------------------------------------------------------------------------- diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index b9f54879c7..30252d0753 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -25,7 +25,7 @@ _verify_upgrade, ) -from tests.conftest import strip_ansi +from tests.conftest import route_auth_open_url_through_urlopen, strip_ansi runner = CliRunner() @@ -66,12 +66,7 @@ def clean_environ(monkeypatch): @pytest.fixture(autouse=True) def route_open_url_through_urlopen(monkeypatch): """Keep release-tag tests hermetic even when ~/.specify/auth.json exists.""" - - def _open_url(url, timeout=10, extra_headers=None): - req = specify_cli.authentication.http.build_request(url, extra_headers) - return specify_cli.authentication.http.urllib.request.urlopen(req, timeout=timeout) - - monkeypatch.setattr("specify_cli.authentication.http.open_url", _open_url) + route_auth_open_url_through_urlopen(monkeypatch) def _fake_argv0(monkeypatch, tmp_path, env_name, path_parts): @@ -1200,6 +1195,34 @@ def test_relative_installer_path_does_not_require_path_lookup( assert result.exit_code == 0 assert mock_run.call_args.args[0][0] == "./uv" + def test_relative_installer_path_missing_gets_path_specific_message( + self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path + ): + monkeypatch.chdir(tmp_path) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + "./uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 3 + assert ( + "Installer path ./uv no longer exists; reinstall it and retry." + in strip_ansi(result.output) + ) + assert "not found on PATH" not in strip_ansi(result.output) + def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message( self, uv_tool_argv0, clean_environ, tmp_path ): diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 2fe88a8c4f..9f1e5a2865 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -16,7 +16,6 @@ import pytest from typer.testing import CliRunner -import specify_cli from specify_cli import app from specify_cli._version import ( _fetch_latest_release_tag, @@ -24,7 +23,7 @@ _is_newer, _normalize_tag, ) -from tests.conftest import strip_ansi +from tests.conftest import route_auth_open_url_through_urlopen, strip_ansi runner = CliRunner() @@ -59,12 +58,7 @@ def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: @pytest.fixture(autouse=True) def route_open_url_through_urlopen(monkeypatch): """Keep release-tag tests hermetic even when ~/.specify/auth.json exists.""" - - def _open_url(url, timeout=10, extra_headers=None): - req = specify_cli.authentication.http.build_request(url, extra_headers) - return specify_cli.authentication.http.urllib.request.urlopen(req, timeout=timeout) - - monkeypatch.setattr("specify_cli.authentication.http.open_url", _open_url) + route_auth_open_url_through_urlopen(monkeypatch) class TestIsNewer: From b6a357e1204c37f178f77d315fcd5e40d3e27255 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 14:35:52 +0900 Subject: [PATCH 12/27] fix(cli): align installer path diagnostics --- src/specify_cli/_version.py | 12 ++++++++---- tests/test_self_upgrade.py | 39 ++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index b6242e7c99..76e4533f78 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -739,9 +739,11 @@ def _run_installer(plan: _UpgradePlan) -> _InstallerResult: installer_name = plan.installer_argv[0] installer_cmd = Path(installer_name) if installer_cmd.is_absolute(): - if installer_cmd.exists() and ( - not installer_cmd.is_file() or not os.access(installer_cmd, os.X_OK) - ): + if not installer_cmd.exists(): + binary_name = _installer_binary_name(plan.method) + if binary_name is None or shutil.which(binary_name) != installer_name: + 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(): @@ -961,7 +963,9 @@ def _emit_failure( if category == "installer-invalid": name = installer_name or "(unknown)" - if installer_name and os.path.isabs(installer_name): + 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." diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 30252d0753..06bbbd5613 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -1281,6 +1281,40 @@ def test_absolute_installer_path_not_executable_gets_specific_message( in strip_ansi(result.output) ) + def test_relative_installer_path_not_executable_gets_path_specific_message( + self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "uv" + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o644) + monkeypatch.chdir(tmp_path) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.os.access", return_value=False), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + "./uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + out = strip_ansi(result.output) + assert result.exit_code == 3 + assert ( + "Installer path ./uv is not an executable file; fix the path or reinstall it and retry." + in out + ) + assert "Installer ./uv is not executable" not in out + def test_real_installer_exit_126_is_not_treated_as_invalid_path( self, uv_tool_argv0, clean_environ ): @@ -1303,7 +1337,9 @@ def test_absolute_installer_path_missing_gets_path_specific_message( fake_uv = tmp_path / "missing-installer" / "uv" with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( "specify_cli._version._assemble_installer_argv", return_value=[ str(fake_uv), @@ -1322,6 +1358,7 @@ def test_absolute_installer_path_missing_gets_path_specific_message( f"Installer path {fake_uv} no longer exists; reinstall it and retry." in strip_ansi(result.output) ) + mock_run.assert_not_called() def test_exec_oserror_is_treated_as_invalid_installer( self, uv_tool_argv0, clean_environ, tmp_path From 852cab66eb6565155fcd3aa2f3d2b7fa4a2c81f1 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 15:30:34 +0900 Subject: [PATCH 13/27] fix(cli): validate release and version output --- src/specify_cli/_version.py | 39 +++++++++++++++++++++++++++++-------- tests/conftest.py | 5 +++-- tests/test_self_upgrade.py | 24 ++++++++++++++++++++++- tests/test_upgrade.py | 9 +++++++-- 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 76e4533f78..1762ffa5a7 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -786,7 +786,19 @@ def _run_installer(plan: _UpgradePlan) -> _InstallerResult: raise -_VERIFY_VERSION_REGEX = re.compile(r"\b(?:specify|specify-cli)\s+(\S+)") +_VERIFY_VERSION_LINE_RE = re.compile(r"^\s*(?:specify|specify-cli)\b(?P.*)$") + + +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: @@ -827,8 +839,7 @@ def _verify_upgrade(plan: _UpgradePlan) -> str | None: return None if result.returncode != 0: return None - match = _VERIFY_VERSION_REGEX.search(result.stdout or "") - return match.group(1) if match else None + return _parse_verify_version_output(result.stdout or "") def _source_checkout_path() -> Path | None: @@ -1087,11 +1098,22 @@ def self_check() -> None: console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}") return - latest_normalized = _normalize_tag(tag) manual_tag = _manual_tag_or_placeholder(tag) - latest_display = ( - _normalize_tag(manual_tag) if manual_tag is not None else _MANUAL_TAG_PLACEHOLDER - ) + 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("[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 @@ -1107,8 +1129,9 @@ def self_check() -> None: 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_normalized}") + console.print(f"[green]Update available:[/green] {installed} → {latest_display}") console.print("\nTo upgrade:") console.print(" specify self upgrade") console.print("\nManual fallback:") diff --git a/tests/conftest.py b/tests/conftest.py index 5dbda2bde4..848d806856 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,8 +72,9 @@ def route_auth_open_url_through_urlopen(monkeypatch) -> None: """Route auth-aware GitHub requests through urlopen for hermetic tests.""" from specify_cli.authentication import http as _auth_http - def _open_url(url, timeout=10, extra_headers=None): - req = _auth_http.build_request(url, extra_headers) + def _open_url(url, timeout=10, extra_headers=None, *args, **kwargs): + _ = args, kwargs + req = _auth_http.build_request(url, extra_headers or {}) return _auth_http.urllib.request.urlopen(req, timeout=timeout) monkeypatch.setattr(_auth_http, "open_url", _open_url) diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 06bbbd5613..b2f1d20463 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -1686,13 +1686,35 @@ def test_verify_accepts_specify_cli_binary_name_in_version_output( mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), - _completed_process(0, stdout="specify-cli 0.7.6\n"), + _completed_process(0, stdout="specify-cli version 0.7.6\n"), ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output) + def test_verify_rejects_output_without_parseable_version( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify version unknown\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Verification failed" in out + assert "(unknown) (expected v0.7.6)" in out + def test_verify_uses_current_entrypoint_when_not_on_path( self, uv_tool_argv0, diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 9f1e5a2865..3f0ce4014c 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -166,6 +166,7 @@ def test_unknown_installed_still_prints_latest_and_reinstall(self): output = strip_ansi(result.output) assert result.exit_code == 0 assert "Current version could not be determined" in output + assert "Latest release: v0.7.4" in output assert "0.7.4" in output assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output assert "specify self upgrade" in output @@ -180,10 +181,11 @@ def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self): output = strip_ansi(result.output) assert result.exit_code == 0 assert "Latest release: vX.Y.Z" in output + assert "Could not validate latest release tag from GitHub." in output assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output assert "v0.9.0;echo unsafe" not in output - def test_unparseable_tag_routes_to_indeterminate(self): + def test_unparseable_tag_reports_validation_failure_without_raw_tag(self): with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "not-a-version"}), @@ -192,8 +194,11 @@ def test_unparseable_tag_routes_to_indeterminate(self): output = strip_ansi(result.output) assert result.exit_code == 0 assert "Update available" not in output - assert "Up to date" in output + assert "Up to date" not in output + assert "Could not validate latest release tag from GitHub." in output assert "0.7.4" in output + assert "not-a-version" not in output + assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output class TestFailureCategorization: From 2d7dd8c819e78ded84a2ab57392b15fe65b36ac3 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 16:00:45 +0900 Subject: [PATCH 14/27] fix(cli): clarify source checkout guidance --- src/specify_cli/_version.py | 19 +++++++++++++------ tests/test_self_upgrade.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 1762ffa5a7..0e57b71692 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -885,12 +885,19 @@ def _emit_guidance(method: _InstallMethod, target_tag: str | None) -> None: if method == _InstallMethod.SOURCE_CHECKOUT: tree = _source_checkout_path() - tree_str = str(tree) if tree else "(path unavailable)" - console.print( - f"Running from a source checkout at {tree_str}; " - "upgrade by running the following commands from that directory:", - soft_wrap=True, - ) + 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 diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index b2f1d20463..2b93889d25 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -1017,6 +1017,26 @@ def test_source_checkout_prints_git_pull_guidance( assert "pip install -e ." in out assert mock_run.call_count == 0 + def test_source_checkout_without_path_mentions_checkout_directory( + self, + unsupported_argv0, + clean_environ, + ): + with patch("specify_cli._version._editable_marker_seen", return_value=True), patch( + "specify_cli._version._source_checkout_path", return_value=None + ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + out = strip_ansi(result.output) + assert result.exit_code == 0 + assert "checkout path could not be detected" in out + assert "from your checkout directory" in out + assert "(path unavailable)" not in out + assert mock_run.call_count == 0 + class TestUnsupported: """Unsupported path enumerates manual reinstall commands.""" From a2cf35e742807574efc44e34d8d7c83b08ea9c7f Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 16:13:25 +0900 Subject: [PATCH 15/27] fix(cli): harden upgrade detection helpers --- src/specify_cli/_version.py | 40 +++++++++++++++++----------- tests/conftest.py | 12 --------- tests/test_self_upgrade.py | 14 +++++----- tests/test_upgrade.py | 52 ++++++++++++++++++++++++------------- 4 files changed, 66 insertions(+), 52 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 0e57b71692..8fe3a885f2 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -37,6 +37,12 @@ "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" def _get_installed_version() -> str: @@ -297,7 +303,11 @@ def _expand_prefix(prefix: str) -> Path | None: expanded = os.path.expandvars(expanded) if _UNRESOLVED_ENV_VAR_RE.search(expanded): return None - return Path(expanded).resolve() if Path(expanded).is_absolute() else Path(expanded) + 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: @@ -652,7 +662,7 @@ def _build_upgrade_plan( preview_summary="", pre_upgrade_snapshot=current, ), - "target-tag-unparseable", + _FAILURE_TARGET_TAG_UNPARSEABLE, ) else: target_tag = None @@ -963,7 +973,7 @@ def _emit_failure( console.print(f"Upgrade aborted: {category}", soft_wrap=True) return - if category == "installer-missing": + if category == _FAILURE_INSTALLER_MISSING: if installer_name and ( os.path.isabs(installer_name) or _is_path_like_command(installer_name) ): @@ -979,7 +989,7 @@ def _emit_failure( ) return - if category == "installer-invalid": + 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) @@ -996,7 +1006,7 @@ def _emit_failure( console.print(message, soft_wrap=True) return - if category == "target-tag-unparseable": + if category == _FAILURE_TARGET_TAG_UNPARSEABLE: if plan is None: raise RuntimeError( "internal routing error: target-tag-unparseable requires plan to be set" @@ -1011,7 +1021,7 @@ def _emit_failure( ) return - if category == "installer-timeout": + if category == _FAILURE_INSTALLER_TIMEOUT: if plan is None: raise RuntimeError( "internal routing error: installer-timeout requires plan to be set" @@ -1033,7 +1043,7 @@ def _emit_failure( console.print(_rollback_hint(plan), soft_wrap=True) return - if category == "installer-failed": + if category == _FAILURE_INSTALLER_FAILED: if plan is None or installer_exit is None: raise RuntimeError( "internal routing error: installer-failed requires both " @@ -1051,7 +1061,7 @@ def _emit_failure( console.print(_rollback_hint(plan), soft_wrap=True) return - if category == "verification-mismatch": + if category == _FAILURE_VERIFICATION_MISMATCH: if plan is None: raise RuntimeError( "internal routing error: verification-mismatch requires plan to be set" @@ -1252,7 +1262,7 @@ def self_upgrade( if plan.installer_argv is None: _emit_failure( - "installer-missing", + _FAILURE_INSTALLER_MISSING, plan=plan, installer_name=_installer_binary_name(plan.method), ) @@ -1263,7 +1273,7 @@ def self_upgrade( target_tag = plan.target_tag target_version = _parse_version_text(target_tag) if target_version is None: - _emit_failure("target-tag-unparseable", plan=plan) + _emit_failure(_FAILURE_TARGET_TAG_UNPARSEABLE, plan=plan) raise typer.Exit(1) target_canonical = str(target_version) @@ -1299,15 +1309,15 @@ def self_upgrade( installer_name = plan.installer_argv[0] if plan.installer_argv else None if installer_result.kind == _InstallerResultKind.MISSING: - _emit_failure("installer-missing", plan=plan, installer_name=installer_name) + _emit_failure(_FAILURE_INSTALLER_MISSING, plan=plan, installer_name=installer_name) raise typer.Exit(3) if installer_result.kind == _InstallerResultKind.INVALID: - _emit_failure("installer-invalid", plan=plan, installer_name=installer_name) + _emit_failure(_FAILURE_INSTALLER_INVALID, plan=plan, installer_name=installer_name) raise typer.Exit(3) if installer_result.kind == _InstallerResultKind.TIMEOUT: - _emit_failure("installer-timeout", plan=plan) + _emit_failure(_FAILURE_INSTALLER_TIMEOUT, plan=plan) raise typer.Exit(124) if ( @@ -1318,7 +1328,7 @@ def self_upgrade( if installer_result.returncode != 0: _emit_failure( - "installer-failed", + _FAILURE_INSTALLER_FAILED, plan=plan, installer_exit=installer_result.returncode, ) @@ -1334,7 +1344,7 @@ def self_upgrade( != _canonicalize_version_text(verified) ): _emit_failure( - "verification-mismatch", + _FAILURE_VERIFICATION_MISMATCH, plan=plan, verified_version=verified, ) diff --git a/tests/conftest.py b/tests/conftest.py index 848d806856..0e568a1e2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,18 +68,6 @@ def strip_ansi(text: str) -> str: return _ANSI_ESCAPE_RE.sub("", text) -def route_auth_open_url_through_urlopen(monkeypatch) -> None: - """Route auth-aware GitHub requests through urlopen for hermetic tests.""" - from specify_cli.authentication import http as _auth_http - - def _open_url(url, timeout=10, extra_headers=None, *args, **kwargs): - _ = args, kwargs - req = _auth_http.build_request(url, extra_headers or {}) - return _auth_http.urllib.request.urlopen(req, timeout=timeout) - - monkeypatch.setattr(_auth_http, "open_url", _open_url) - - # --------------------------------------------------------------------------- # Auth config isolation — prevents tests from reading ~/.specify/auth.json # --------------------------------------------------------------------------- diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 2b93889d25..a6d3c1b866 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -25,7 +25,7 @@ _verify_upgrade, ) -from tests.conftest import route_auth_open_url_through_urlopen, strip_ansi +from tests.conftest import strip_ansi runner = CliRunner() @@ -63,12 +63,6 @@ def clean_environ(monkeypatch): monkeypatch.delenv("GITHUB_TOKEN", raising=False) -@pytest.fixture(autouse=True) -def route_open_url_through_urlopen(monkeypatch): - """Keep release-tag tests hermetic even when ~/.specify/auth.json exists.""" - route_auth_open_url_through_urlopen(monkeypatch) - - def _fake_argv0(monkeypatch, tmp_path, env_name, path_parts): """Create a fake executable under tmp_path and point sys.argv[0] at it.""" monkeypatch.setenv(env_name, str(tmp_path)) @@ -405,6 +399,12 @@ def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path): def test_unresolved_posix_variable_is_rejected(self): assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None + def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path): + prefix = str(tmp_path / "specify-cli") + + with patch("pathlib.Path.resolve", side_effect=OSError("bad path")): + assert specify_cli._version._expand_prefix(prefix) is None + class TestArgvAssemblyUvTool: """uv-tool installer argv shape.""" diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 3f0ce4014c..949afe8409 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -1,11 +1,12 @@ """Tests for the `specify self` sub-app (`self check` and `self upgrade`). Network isolation contract (SC-004 / FR-014): every test that exercises -`specify self check` or `_fetch_latest_release_tag()` MUST mock -`urllib.request.urlopen` so no real outbound call ever reaches -api.github.com. Tests for non-network `self upgrade` behavior should keep that -contract explicit with local mocks. Run this module under `pytest-socket` (if -installed) with `--disable-socket` as an extra safety net. +`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound +urllib path it expects (`urlopen` for unauthenticated requests, `build_opener` +for authenticated requests) so no real outbound call ever reaches api.github.com. +Tests for non-network `self upgrade` behavior should keep that contract explicit +with local mocks. Run this module under `pytest-socket` (if installed) with +`--disable-socket` as an extra safety net. """ import json @@ -23,7 +24,7 @@ _is_newer, _normalize_tag, ) -from tests.conftest import route_auth_open_url_through_urlopen, strip_ansi +from tests.conftest import strip_ansi runner = CliRunner() @@ -55,12 +56,6 @@ def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: ) -@pytest.fixture(autouse=True) -def route_open_url_through_urlopen(monkeypatch): - """Keep release-tag tests hermetic even when ~/.specify/auth.json exists.""" - route_auth_open_url_through_urlopen(monkeypatch) - - class TestIsNewer: def test_latest_strictly_greater_returns_true(self): assert _is_newer("0.8.0", "0.7.4") is True @@ -305,6 +300,18 @@ def _side_effect(req, *args, **kwargs): return captured, _side_effect +def _capture_request_via_auth_opener(): + captured = {} + + def _side_effect(req, *args, **kwargs): + captured["request"] = req + return _mock_urlopen_response({"tag_name": "v0.7.4"}) + + opener = MagicMock() + opener.open.side_effect = _side_effect + return captured, opener + + def _inject_github_config(monkeypatch, token_env="GH_TOKEN"): from tests.auth_helpers import inject_github_config inject_github_config(monkeypatch, token_env) @@ -315,8 +322,11 @@ def test_gh_token_attached_as_bearer_header(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) monkeypatch.delenv("GITHUB_TOKEN", raising=False) _inject_github_config(monkeypatch, token_env="GH_TOKEN") - captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + captured, opener = _capture_request_via_auth_opener() + with patch( + "specify_cli.authentication.http.urllib.request.build_opener", + return_value=opener, + ): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}" @@ -325,8 +335,11 @@ def test_github_token_used_when_gh_token_unset(self, monkeypatch): monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) _inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") - captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + captured, opener = _capture_request_via_auth_opener() + with patch( + "specify_cli.authentication.http.urllib.request.build_opener", + return_value=opener, + ): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" @@ -364,8 +377,11 @@ def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", " ") monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) _inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") - captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + captured, opener = _capture_request_via_auth_opener() + with patch( + "specify_cli.authentication.http.urllib.request.build_opener", + return_value=opener, + ): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" From f16ede4279e74237b66a3e0ccc23b49f9ea19ca1 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 19:19:26 +0900 Subject: [PATCH 16/27] fix(cli): avoid echoing invalid release tags --- src/specify_cli/_version.py | 2 +- tests/test_self_upgrade.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 8fe3a885f2..899d30c419 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -1012,7 +1012,7 @@ def _emit_failure( "internal routing error: target-tag-unparseable requires plan to be set" ) console.print( - f"Upgrade aborted: resolved release tag {plan.target_tag!r} is not a comparable version.", + "Upgrade aborted: resolved release tag is not a comparable version.", soft_wrap=True, ) console.print( diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index a6d3c1b866..6c93f5581a 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -544,6 +544,7 @@ def test_unparseable_resolved_target_fails_before_literal_noop( assert result.exit_code == 1 out = strip_ansi(result.output) assert "not a comparable version" in out + assert "release-main" not in out assert "Already on latest release" not in out assert mock_run.call_count == 0 @@ -635,6 +636,7 @@ def test_dry_run_rejects_unparseable_network_tag_before_preview( out = strip_ansi(result.output) assert result.exit_code == 1 assert "not a comparable version" in out + assert "v0.9.0;echo unsafe" not in out assert "Command that would be executed:" not in out assert mock_run.call_count == 0 @@ -1906,7 +1908,8 @@ def test_unparseable_resolved_release_tag_exits_1_without_traceback( assert result.exit_code == 1 out = strip_ansi(result.output) - assert "resolved release tag 'release-main' is not a comparable version" in out + assert "resolved release tag is not a comparable version" in out + assert "release-main" not in out assert "Traceback" not in out assert mock_run.call_count == 0 From 5e5349719cc903401daac04294096ef380bbfb0f Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 23:25:41 +0900 Subject: [PATCH 17/27] fix(cli): tolerate argv path resolve failures --- src/specify_cli/_version.py | 13 ++++++++++--- tests/test_self_upgrade.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 899d30c419..f09eaa3ffe 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -323,14 +323,21 @@ def _path_is_within_prefix(path: Path, prefix: Path) -> bool: 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 candidate.resolve() + return _resolve_path_or_original(candidate) if candidate.exists(): - return candidate.resolve() + return _resolve_path_or_original(candidate) lookup_names = [raw] if len(candidate.parts) > 1: @@ -341,7 +348,7 @@ def _resolved_argv0_path(argv0: str | None = None) -> Path: for lookup_name in lookup_names: resolved = shutil.which(lookup_name) if resolved: - return Path(resolved).resolve() + return _resolve_path_or_original(Path(resolved)) return candidate diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 6c93f5581a..eb6aa5cb43 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -406,6 +406,24 @@ def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path): assert specify_cli._version._expand_prefix(prefix) is None +class TestArgv0Resolution: + """Entrypoint path resolution edge cases.""" + + def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path): + argv0 = tmp_path / "specify" + + with patch("pathlib.Path.resolve", side_effect=OSError("bad path")): + assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0 + + def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self): + with patch( + "specify_cli._version.shutil.which", return_value="/broken/specify" + ), patch("pathlib.Path.resolve", side_effect=OSError("bad path")): + result = specify_cli._version._resolved_argv0_path("specify") + + assert str(result) == "/broken/specify" + + class TestArgvAssemblyUvTool: """uv-tool installer argv shape.""" From b78d857bebbcb4ddc6ac4ff9bb7c095c0775d36c Mon Sep 17 00:00:00 2001 From: pli Date: Tue, 26 May 2026 07:42:54 +0900 Subject: [PATCH 18/27] chore: remove self-upgrade formatting-only diffs --- docs/upgrade.md | 4 ++-- tests/test_upgrade.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index e25c052e0b..facfce3144 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -12,8 +12,8 @@ | **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z` | Upgrade to a specific release tag instead of the latest stable. Replace `vX.Y.Z` with the release tag you want. | | **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. | | **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. | -| **Project Files** | `specify init --here --force --integration ` | Update slash commands, templates, and scripts in your project. | -| **Both** | Run CLI upgrade, then project update | Recommended for major version updates. | +| **Project Files** | `specify init --here --force --integration ` | Update slash commands, templates, and scripts in your project | +| **Both** | Run CLI upgrade, then project update | Recommended for major version updates | --- diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 949afe8409..7c52dfa81b 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -10,8 +10,8 @@ """ import json -import importlib.metadata import urllib.error +import importlib.metadata from unittest.mock import MagicMock, patch import pytest From 943f318c0b1b187bf19cff4b7f73f5f8a1eee6a8 Mon Sep 17 00:00:00 2001 From: pli Date: Tue, 26 May 2026 19:39:09 +0900 Subject: [PATCH 19/27] fix: address self-upgrade review feedback --- README.md | 2 +- docs/upgrade.md | 2 +- src/specify_cli/_version.py | 5 +- tests/http_helpers.py | 15 ++++ tests/test_self_upgrade.py | 132 +++++++++++++++++++----------------- tests/test_upgrade.py | 28 +++----- 6 files changed, 99 insertions(+), 85 deletions(-) create mode 100644 tests/http_helpers.py diff --git a/README.md b/README.md index 96cf1d651a..f79c96f9b5 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ specify self upgrade specify self upgrade --tag vX.Y.Z ``` -Bare `specify self upgrade` executes immediately, matching the `pip install -U` / `uv tool upgrade` / `npm update` convention. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). +Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. For `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). ### 3. Establish project principles diff --git a/docs/upgrade.md b/docs/upgrade.md index facfce3144..42599557ff 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -39,7 +39,7 @@ specify self upgrade specify self upgrade --tag vX.Y.Z ``` -Bare `specify self upgrade` executes immediately, matching the `pip install -U` / `uv tool upgrade` / `npm update` convention. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; the other paths print path-specific guidance and exit 0 without touching anything. +Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index f09eaa3ffe..81afdbb189 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -145,9 +145,8 @@ def _canonicalize_version_text(value: str) -> str: def _stable_release_tag_for_version(version_text: str) -> str | None: """Return `vX.Y.Z` only for exact stable release versions.""" - try: - parsed = Version(version_text) - except InvalidVersion: + 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 diff --git a/tests/http_helpers.py b/tests/http_helpers.py new file mode 100644 index 0000000000..46e26806b4 --- /dev/null +++ b/tests/http_helpers.py @@ -0,0 +1,15 @@ +"""HTTP test helpers shared by version-related CLI tests.""" + +import json +from unittest.mock import MagicMock + + +def mock_urlopen_response(payload: dict) -> MagicMock: + """Build a urlopen context-manager mock whose read returns JSON.""" + body = json.dumps(payload).encode("utf-8") + resp = MagicMock() + resp.read.return_value = body + cm = MagicMock() + cm.__enter__.return_value = resp + cm.__exit__.return_value = False + return cm diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index eb6aa5cb43..5255884caa 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -10,7 +10,7 @@ import os import subprocess import urllib.error -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from typer.testing import CliRunner @@ -26,6 +26,7 @@ ) from tests.conftest import strip_ansi +from tests.http_helpers import mock_urlopen_response runner = CliRunner() @@ -33,17 +34,6 @@ SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" -def _mock_urlopen_response(payload: dict) -> MagicMock: - """Build a urlopen() context-manager mock whose .read() returns the JSON payload.""" - body = json.dumps(payload).encode("utf-8") - resp = MagicMock() - resp.read.return_value = body - cm = MagicMock() - cm.__enter__.return_value = resp - cm.__exit__.return_value = False - return cm - - def _completed_process( returncode: int, stdout: str = "", stderr: str = "" ) -> subprocess.CompletedProcess: @@ -462,7 +452,7 @@ def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), # installer _completed_process(0, stdout="specify 0.7.6\n"), # verify @@ -485,7 +475,7 @@ def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ): ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), @@ -505,7 +495,7 @@ def test_already_latest_exits_zero_no_subprocess( ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.6"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 @@ -520,7 +510,7 @@ def test_dev_build_ahead_of_release_reports_newer_noop( ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 @@ -535,7 +525,7 @@ def test_unparseable_current_version_does_not_false_noop( ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version._get_installed_version", return_value="release-main"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), @@ -556,7 +546,7 @@ def test_unparseable_resolved_target_fails_before_literal_noop( ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version._get_installed_version", return_value="release-main"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "release-main"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 @@ -611,7 +601,7 @@ def test_dry_run_without_tag_resolves_network_but_no_subprocess( ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) assert result.exit_code == 0 @@ -646,7 +636,7 @@ def test_dry_run_rejects_unparseable_network_tag_before_preview( ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = _mock_urlopen_response( + mock_urlopen.return_value = mock_urlopen_response( {"tag_name": "v0.9.0;echo unsafe"} ) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) @@ -666,7 +656,7 @@ def test_dry_run_with_missing_uv_flags_unresolved_installer( ) as mock_run, patch( "specify_cli._version.shutil.which", return_value=None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) assert result.exit_code == 0 @@ -886,7 +876,7 @@ def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_ ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v9.9.9"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.8.0\n"), @@ -926,7 +916,7 @@ def test_happy_path(self, pipx_argv0, clean_environ): ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), @@ -962,7 +952,7 @@ def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) assert result.exit_code == 0 assert "Detected install method: pipx" in strip_ansi(result.output) @@ -985,7 +975,7 @@ def test_uvx_argv0_prints_exact_one_liner_and_exits_zero( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run: - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 expected = ( @@ -1027,7 +1017,7 @@ def test_source_checkout_prints_git_pull_guidance( ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run: - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 @@ -1047,7 +1037,7 @@ def test_source_checkout_without_path_mentions_checkout_directory( ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run: - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) out = strip_ansi(result.output) @@ -1071,7 +1061,7 @@ def test_unsupported_prints_both_reinstall_commands( ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run: - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 @@ -1122,7 +1112,7 @@ def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen: - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) assert result.exit_code == 0 out = strip_ansi(result.output) @@ -1137,7 +1127,7 @@ def test_dry_run_on_unsupported_emits_manual_commands( with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( "specify_cli._version.shutil.which", return_value=None ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen: - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) assert result.exit_code == 0 assert "Could not identify your install method" in strip_ansi(result.output) @@ -1156,7 +1146,7 @@ def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 out = strip_ansi(result.output) @@ -1168,7 +1158,7 @@ def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 assert "Installer pipx not found on PATH" in strip_ansi(result.output) @@ -1198,7 +1188,7 @@ def test_absolute_installer_path_does_not_require_path_lookup( "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(0)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 @@ -1228,7 +1218,7 @@ def test_relative_installer_path_does_not_require_path_lookup( "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(0)] result = runner.invoke(app, ["self", "upgrade"]) @@ -1253,7 +1243,7 @@ def test_relative_installer_path_missing_gets_path_specific_message( "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 @@ -1281,7 +1271,7 @@ def fake_run(argv, *args, **kwargs): ), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 @@ -1313,7 +1303,7 @@ def test_absolute_installer_path_not_executable_gets_specific_message( "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 assert ( @@ -1344,7 +1334,7 @@ def test_relative_installer_path_not_executable_gets_path_specific_message( "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) out = strip_ansi(result.output) @@ -1363,7 +1353,7 @@ def test_real_installer_exit_126_is_not_treated_as_invalid_path( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(126)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 126 @@ -1391,7 +1381,7 @@ def test_absolute_installer_path_missing_gets_path_specific_message( "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 assert ( @@ -1424,7 +1414,7 @@ def test_exec_oserror_is_treated_as_invalid_installer( "specify_cli._version.subprocess.run", side_effect=PermissionError("Permission denied"), ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 out = strip_ansi(result.output) @@ -1451,7 +1441,7 @@ def test_bare_invalid_installer_message_does_not_call_it_a_path( "specify_cli._version.subprocess.run", side_effect=PermissionError("Permission denied"), ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 @@ -1481,7 +1471,7 @@ def test_exec_oserror_errno_is_treated_as_invalid_installer( "git+https://github.com/github/spec-kit.git@v0.7.6", ], ), patch("specify_cli._version.subprocess.run", side_effect=invalid_error): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 out = strip_ansi(result.output) @@ -1510,7 +1500,7 @@ def test_transient_exec_oserror_is_not_treated_as_invalid_installer( "git+https://github.com/github/spec-kit.git@v0.7.6", ], ), patch("specify_cli._version.subprocess.run", side_effect=transient_error): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code != 3 assert isinstance(result.exception, OSError) @@ -1525,7 +1515,7 @@ def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(2)] # installer fails result = runner.invoke(app, ["self", "upgrade"]) @@ -1548,7 +1538,7 @@ def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(127)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 127 @@ -1562,7 +1552,7 @@ def test_installer_timeout_prints_timeout_specific_message( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ subprocess.TimeoutExpired(cmd=["uv"], timeout=12) ] @@ -1581,7 +1571,7 @@ def test_non_finite_timeout_warns_and_runs_without_timeout( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), @@ -1602,7 +1592,7 @@ def test_real_installer_exit_124_is_not_treated_as_timeout( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(124)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 124 @@ -1616,7 +1606,7 @@ def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ) ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(2)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 @@ -1626,6 +1616,26 @@ def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ) "git+https://github.com/github/spec-kit.git@v0.7.5" ) in out + def test_rollback_hint_accepts_normalizable_stable_snapshot( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="v0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(2)] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert ( + "To pin back to the previous version: uv tool install specify-cli --force " + "--from git+https://github.com/github/spec-kit.git@v0.7.5" + ) in out + assert "Previous version was not an exact stable release tag" not in out + def test_prerelease_failure_degrades_rollback_hint_to_releases_page( self, uv_tool_argv0, clean_environ ): @@ -1634,7 +1644,7 @@ def test_prerelease_failure_degrades_rollback_hint_to_releases_page( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="1.0.0rc1" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v1.0.0"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"}) mock_run.side_effect = [_completed_process(2)] result = runner.invoke(app, ["self", "upgrade"]) @@ -1658,7 +1668,7 @@ def test_installer_ok_but_verify_returns_old_version( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), # installer OK _completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD! @@ -1681,7 +1691,7 @@ def test_verify_nonzero_exit_is_not_treated_as_success( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(1, stdout="specify 0.7.6\n"), @@ -1703,7 +1713,7 @@ def test_verify_accepts_pep440_equivalent_rc_version( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.9.0" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v9.9.9"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 1.0.0rc1\n"), @@ -1723,7 +1733,7 @@ def test_verify_accepts_specify_cli_binary_name_in_version_output( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify-cli version 0.7.6\n"), @@ -1743,7 +1753,7 @@ def test_verify_rejects_output_without_parseable_version( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify version unknown\n"), @@ -1921,7 +1931,7 @@ def test_unparseable_resolved_release_tag_exits_1_without_traceback( ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="/usr/bin/uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "release-main"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 @@ -2016,7 +2026,7 @@ def test_unknown_current_renders_literal_in_notice( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), @@ -2038,7 +2048,7 @@ def test_unknown_current_rollback_hint_degrades( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(2)] # installer fails result = runner.invoke(app, ["self", "upgrade"]) @@ -2064,7 +2074,7 @@ def test_env_passed_to_subprocess_has_no_github_tokens( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), @@ -2093,7 +2103,7 @@ def test_env_scrubbing_is_case_insensitive( ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 7c52dfa81b..0023ac7033 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -9,7 +9,6 @@ `--disable-socket` as an extra safety net. """ -import json import urllib.error import importlib.metadata from unittest.mock import MagicMock, patch @@ -25,6 +24,7 @@ _normalize_tag, ) from tests.conftest import strip_ansi +from tests.http_helpers import mock_urlopen_response runner = CliRunner() @@ -36,16 +36,6 @@ ) -def _mock_urlopen_response(payload: dict) -> MagicMock: - body = json.dumps(payload).encode("utf-8") - resp = MagicMock() - resp.read.return_value = body - cm = MagicMock() - cm.__enter__.return_value = resp - cm.__exit__.return_value = False - return cm - - def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: return urllib.error.HTTPError( url="https://api.github.com/repos/github/spec-kit/releases/latest", @@ -119,7 +109,7 @@ class TestUserStory1: def test_newer_available_prints_update_and_install_command(self): with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", - return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), + return_value=mock_urlopen_response({"tag_name": "v0.9.0"}), ): result = runner.invoke(app, ["self", "check"]) output = strip_ansi(result.output) @@ -132,7 +122,7 @@ def test_newer_available_prints_update_and_install_command(self): def test_up_to_date_prints_current_only(self): with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch( "specify_cli.authentication.http.urllib.request.urlopen", - return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), + return_value=mock_urlopen_response({"tag_name": "v0.9.0"}), ): result = runner.invoke(app, ["self", "check"]) output = strip_ansi(result.output) @@ -144,7 +134,7 @@ def test_up_to_date_prints_current_only(self): def test_dev_build_ahead_of_release_is_up_to_date(self): with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch( "specify_cli.authentication.http.urllib.request.urlopen", - return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), + return_value=mock_urlopen_response({"tag_name": "v0.7.4"}), ): result = runner.invoke(app, ["self", "check"]) output = strip_ansi(result.output) @@ -155,7 +145,7 @@ def test_dev_build_ahead_of_release_is_up_to_date(self): def test_unknown_installed_still_prints_latest_and_reinstall(self): with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch( "specify_cli.authentication.http.urllib.request.urlopen", - return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), + return_value=mock_urlopen_response({"tag_name": "v0.7.4"}), ): result = runner.invoke(app, ["self", "check"]) output = strip_ansi(result.output) @@ -170,7 +160,7 @@ def test_unknown_installed_still_prints_latest_and_reinstall(self): def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self): with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch( "specify_cli.authentication.http.urllib.request.urlopen", - return_value=_mock_urlopen_response({"tag_name": "v0.9.0;echo unsafe"}), + return_value=mock_urlopen_response({"tag_name": "v0.9.0;echo unsafe"}), ): result = runner.invoke(app, ["self", "check"]) output = strip_ansi(result.output) @@ -183,7 +173,7 @@ def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self): def test_unparseable_tag_reports_validation_failure_without_raw_tag(self): with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch( "specify_cli.authentication.http.urllib.request.urlopen", - return_value=_mock_urlopen_response({"tag_name": "not-a-version"}), + return_value=mock_urlopen_response({"tag_name": "not-a-version"}), ): result = runner.invoke(app, ["self", "check"]) output = strip_ansi(result.output) @@ -295,7 +285,7 @@ def _capture_request_via_urlopen(): def _side_effect(req, *args, **kwargs): captured["request"] = req - return _mock_urlopen_response({"tag_name": "v0.7.4"}) + return mock_urlopen_response({"tag_name": "v0.7.4"}) return captured, _side_effect @@ -305,7 +295,7 @@ def _capture_request_via_auth_opener(): def _side_effect(req, *args, **kwargs): captured["request"] = req - return _mock_urlopen_response({"tag_name": "v0.7.4"}) + return mock_urlopen_response({"tag_name": "v0.7.4"}) opener = MagicMock() opener.open.side_effect = _side_effect From 4081438ee1590f56fc2a6eb3419ca7ea93f454c4 Mon Sep 17 00:00:00 2001 From: pli Date: Tue, 26 May 2026 20:01:45 +0900 Subject: [PATCH 20/27] fix: address self-upgrade review followups --- docs/upgrade.md | 2 +- src/specify_cli/_version.py | 4 +- tests/self_upgrade_fixtures.py | 77 + tests/self_upgrade_helpers.py | 67 + tests/test_self_upgrade.py | 2151 ----------------------- tests/test_self_upgrade_detection.py | 861 +++++++++ tests/test_self_upgrade_execution.py | 537 ++++++ tests/test_self_upgrade_guidance.py | 184 ++ tests/test_self_upgrade_verification.py | 520 ++++++ 9 files changed, 2250 insertions(+), 2153 deletions(-) create mode 100644 tests/self_upgrade_fixtures.py create mode 100644 tests/self_upgrade_helpers.py delete mode 100644 tests/test_self_upgrade.py create mode 100644 tests/test_self_upgrade_detection.py create mode 100644 tests/test_self_upgrade_execution.py create mode 100644 tests/test_self_upgrade_guidance.py create mode 100644 tests/test_self_upgrade_verification.py diff --git a/docs/upgrade.md b/docs/upgrade.md index 42599557ff..ba9b230341 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -415,7 +415,7 @@ Only Spec Kit infrastructure files: If a command behaves like an older Spec Kit version, first ask the CLI itself: ```bash -# Read-only — prints "Up to date: X.Y.Z" or "Update available: X.Y.Z → Y.Z.W" +# Read-only — prints "Up to date: X.Y.Z" or "Update available: X.Y.Z → vY.Z.W" specify self check # Preview the install method, current version, and target tag the upgrade would use diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 81afdbb189..80331d99e8 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -1356,7 +1356,9 @@ def self_upgrade( ) 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: {plan.pre_upgrade_snapshot} → {verified}", + f"Upgraded specify-cli: {pre_upgrade_display} → {verified_display}", soft_wrap=True, ) diff --git a/tests/self_upgrade_fixtures.py b/tests/self_upgrade_fixtures.py new file mode 100644 index 0000000000..2b2db3dd19 --- /dev/null +++ b/tests/self_upgrade_fixtures.py @@ -0,0 +1,77 @@ +"""Fixtures for `specify self upgrade` tests.""" + +import os + +import pytest + + +@pytest.fixture +def clean_environ(monkeypatch): + """Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment.""" + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + +def _fake_argv0(monkeypatch, tmp_path, env_name, path_parts): + """Create a fake executable under tmp_path and point sys.argv[0] at it.""" + monkeypatch.setenv(env_name, str(tmp_path)) + fake_dir = tmp_path.joinpath(*path_parts) + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + return fake_specify + + +@pytest.fixture +def uv_tool_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME. + + Sets the platform-specific home/tool root env so _expand_prefix() resolves + to a path that actually contains the fake binary. This avoids needing a + `_UV_TOOL_ROOT_OVERRIDE` knob in production code. + """ + if os.name == "nt": + return _fake_argv0( + monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin") + ) + return _fake_argv0( + monkeypatch, + tmp_path, + "HOME", + (".local", "share", "uv", "tools", "specify-cli", "bin"), + ) + + +@pytest.fixture +def pipx_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated pipx install path under tmp HOME.""" + if os.name == "nt": + return _fake_argv0( + monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin") + ) + return _fake_argv0( + monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin") + ) + + +@pytest.fixture +def uvx_ephemeral_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME.""" + if os.name == "nt": + return _fake_argv0( + monkeypatch, + tmp_path, + "LOCALAPPDATA", + ("uv", "cache", "archive-v0", "abc123", "bin"), + ) + return _fake_argv0( + monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin") + ) + + +@pytest.fixture +def unsupported_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a path that does not match any installer prefix.""" + return _fake_argv0(monkeypatch, tmp_path, "HOME", ("random", "location", "bin")) diff --git a/tests/self_upgrade_helpers.py b/tests/self_upgrade_helpers.py new file mode 100644 index 0000000000..2a2cf43f8b --- /dev/null +++ b/tests/self_upgrade_helpers.py @@ -0,0 +1,67 @@ +"""Shared fixtures and helpers for `specify self upgrade` tests. + +These helpers patch subprocess, PATH lookup, and release-tag resolution so +the focused test modules stay isolated from the real environment. +""" + +import errno +import importlib.metadata +import json +import os +import subprocess +import urllib.error +from unittest.mock import patch + +from typer.testing import CliRunner + +import specify_cli +from specify_cli import app +from specify_cli._version import ( + _InstallMethod, + _UpgradePlan, + _assemble_installer_argv, + _detect_install_method, + _verify_upgrade, +) +from tests.conftest import strip_ansi +from tests.http_helpers import mock_urlopen_response + +__all__ = ( + "SENTINEL_GH_TOKEN", + "SENTINEL_GITHUB_TOKEN", + "_InstallMethod", + "_UpgradePlan", + "_assemble_installer_argv", + "_completed_process", + "_detect_install_method", + "_verify_upgrade", + "app", + "errno", + "importlib", + "json", + "mock_urlopen_response", + "os", + "patch", + "runner", + "specify_cli", + "strip_ansi", + "subprocess", + "urllib", +) + +runner = CliRunner() + +SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" +SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" + + +def _completed_process( + returncode: int, stdout: str = "", stderr: str = "" +) -> subprocess.CompletedProcess: + """Build a subprocess.CompletedProcess for installer / verification calls.""" + return subprocess.CompletedProcess( + args=["mocked"], + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py deleted file mode 100644 index 5255884caa..0000000000 --- a/tests/test_self_upgrade.py +++ /dev/null @@ -1,2151 +0,0 @@ -"""Tests for `specify self upgrade`. - -These cases patch subprocess, PATH lookup, and release-tag resolution so the -suite stays isolated from the real environment. -""" - -import errno -import importlib.metadata -import json -import os -import subprocess -import urllib.error -from unittest.mock import patch - -import pytest -from typer.testing import CliRunner - -import specify_cli -from specify_cli import app -from specify_cli._version import ( - _InstallMethod, - _UpgradePlan, - _assemble_installer_argv, - _detect_install_method, - _verify_upgrade, -) - -from tests.conftest import strip_ansi -from tests.http_helpers import mock_urlopen_response - -runner = CliRunner() - -SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" -SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" - - -def _completed_process( - returncode: int, stdout: str = "", stderr: str = "" -) -> subprocess.CompletedProcess: - """Build a subprocess.CompletedProcess for installer / verification calls.""" - return subprocess.CompletedProcess( - args=["mocked"], - returncode=returncode, - stdout=stdout, - stderr=stderr, - ) - - -@pytest.fixture -def clean_environ(monkeypatch): - """Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment.""" - monkeypatch.delenv("GH_TOKEN", raising=False) - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - - -def _fake_argv0(monkeypatch, tmp_path, env_name, path_parts): - """Create a fake executable under tmp_path and point sys.argv[0] at it.""" - monkeypatch.setenv(env_name, str(tmp_path)) - fake_dir = tmp_path.joinpath(*path_parts) - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - fake_specify.chmod(0o755) - monkeypatch.setattr("sys.argv", [str(fake_specify)]) - return fake_specify - - -@pytest.fixture -def uv_tool_argv0(monkeypatch, tmp_path): - """Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME. - - Sets the platform-specific home/tool root env so _expand_prefix() resolves - to a path that actually contains the fake binary. This avoids needing a - `_UV_TOOL_ROOT_OVERRIDE` knob in production code. - """ - if os.name == "nt": - return _fake_argv0( - monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin") - ) - return _fake_argv0( - monkeypatch, - tmp_path, - "HOME", - (".local", "share", "uv", "tools", "specify-cli", "bin"), - ) - - -@pytest.fixture -def pipx_argv0(monkeypatch, tmp_path): - """Point sys.argv[0] at a simulated pipx install path under tmp HOME.""" - if os.name == "nt": - return _fake_argv0( - monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin") - ) - return _fake_argv0( - monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin") - ) - - -@pytest.fixture -def uvx_ephemeral_argv0(monkeypatch, tmp_path): - """Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME.""" - if os.name == "nt": - return _fake_argv0( - monkeypatch, - tmp_path, - "LOCALAPPDATA", - ("uv", "cache", "archive-v0", "abc123", "bin"), - ) - return _fake_argv0( - monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin") - ) - - -@pytest.fixture -def unsupported_argv0(monkeypatch, tmp_path): - """Point sys.argv[0] at a path that does not match any installer prefix.""" - return _fake_argv0(monkeypatch, tmp_path, "HOME", ("random", "location", "bin")) - - -class TestDetectionUvTool: - """Tier-1 path-prefix detection for uv-tool installs.""" - - def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0): - method, signals = _detect_install_method(include_signals=True) - assert method == _InstallMethod.UV_TOOL - assert signals.matched_tier == 1 - assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/") - - def test_detection_is_deterministic(self, uv_tool_argv0): - a = _detect_install_method() - b = _detect_install_method() - assert a == b == _InstallMethod.UV_TOOL - - def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0): - with patch("specify_cli._version.shutil.which", return_value=None), patch( - "specify_cli._version._editable_marker_seen", return_value=False - ): - method = _detect_install_method() - assert method == _InstallMethod.UNSUPPORTED - - def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0): - result = _detect_install_method(include_signals=False) - assert isinstance(result, _InstallMethod) - - def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path): - if os.name == "nt": - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin" - else: - monkeypatch.setenv("HOME", str(tmp_path)) - fake_dir = ( - tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin" - ) - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - monkeypatch.setattr("sys.argv", ["specify"]) - with patch( - "specify_cli._version.shutil.which", - side_effect=lambda name: str(fake_specify) if name == "specify" else None, - ): - method = _detect_install_method() - assert method == _InstallMethod.UV_TOOL - - def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path): - monkeypatch.setenv("HOME", str(tmp_path)) - fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin" - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - monkeypatch.setattr("sys.argv", [str(fake_specify)]) - with patch("specify_cli._version.shutil.which", return_value=None), patch( - "specify_cli._version._editable_marker_seen", return_value=False - ): - method = _detect_install_method() - assert method == _InstallMethod.UNSUPPORTED - - def test_tier3_uv_tool_when_registry_lists_exact_name( - self, - monkeypatch, - tmp_path, - ): - monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) - - def fake_which(name): - return "/usr/bin/uv" if name == "uv" else None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout="specify-cli v0.7.6\nother-tool v1.2.3\n", - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method, signals = _detect_install_method(include_signals=True) - assert method == _InstallMethod.UV_TOOL - assert signals.matched_tier == 3 - assert "uv tool list" in signals.installer_registries_consulted - - def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["specify"]) - - def fake_which(name): - return "/usr/bin/uv" if name == "uv" else None - - def fake_run(argv, *args, **kwargs): - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout="specify-cli v0.7.6\n", - stderr="", - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method, signals = _detect_install_method(include_signals=True) - assert method == _InstallMethod.UNSUPPORTED - assert signals.installer_registries_consulted == () - - def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection( - self, monkeypatch, tmp_path - ): - missing_specify = tmp_path / "missing" / "specify" - monkeypatch.setattr("sys.argv", ["specify"]) - - def fake_which(name): - if name == "specify": - return str(missing_specify) - if name == "uv": - return "/usr/bin/uv" - return None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout="specify-cli v0.7.6\n", - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method, signals = _detect_install_method(include_signals=True) - - assert method == _InstallMethod.UV_TOOL - assert signals.matched_tier == 3 - assert "uv tool list" in signals.installer_registries_consulted - - def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup( - self, monkeypatch, tmp_path - ): - if os.name == "nt": - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin" - else: - monkeypatch.setenv("HOME", str(tmp_path)) - fake_dir = ( - tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin" - ) - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - monkeypatch.setattr("sys.argv", ["./bin/specify"]) - - def fake_which(name): - return str(fake_specify) if name == "specify" else None - - with patch("specify_cli._version.shutil.which", side_effect=fake_which): - method = _detect_install_method() - - assert method == _InstallMethod.UV_TOOL - - def test_tier3_uv_tool_ignores_substring_false_positive( - self, - unsupported_argv0, - ): - def fake_which(name): - return "/usr/bin/uv" if name == "uv" else None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout="my-specify-cli-helper v0.1.0\n", - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method = _detect_install_method() - assert method == _InstallMethod.UNSUPPORTED - - def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint( - self, - unsupported_argv0, - ): - def fake_which(name): - return "/usr/bin/uv" if name == "uv" else None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout="specify-cli v0.7.6\n", - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method = _detect_install_method() - assert method == _InstallMethod.UNSUPPORTED - - def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint( - self, - monkeypatch, - tmp_path, - ): - venv_bin = tmp_path / "venv" / "bin" - venv_bin.mkdir(parents=True) - fake_specify = venv_bin / "specify" - fake_specify.write_text("#!/usr/bin/env python\n") - fake_specify.chmod(0o755) - monkeypatch.setattr("sys.argv", ["specify"]) - - def fake_which(name): - if name == "specify": - return str(fake_specify) - if name == "uv": - return "/usr/bin/uv" - return None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout="specify-cli v0.7.6\n", - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method, signals = _detect_install_method(include_signals=True) - assert method == _InstallMethod.UNSUPPORTED - assert signals.matched_tier is None - assert signals.installer_registries_consulted == () - - -class TestPrefixExpansion: - """Path-prefix expansion edge cases.""" - - def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path): - prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli" - prefix = str(prefix_path) - - expanded = specify_cli._version._expand_prefix(prefix) - - assert expanded == prefix_path.resolve() - - def test_unresolved_posix_variable_is_rejected(self): - assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None - - def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path): - prefix = str(tmp_path / "specify-cli") - - with patch("pathlib.Path.resolve", side_effect=OSError("bad path")): - assert specify_cli._version._expand_prefix(prefix) is None - - -class TestArgv0Resolution: - """Entrypoint path resolution edge cases.""" - - def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path): - argv0 = tmp_path / "specify" - - with patch("pathlib.Path.resolve", side_effect=OSError("bad path")): - assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0 - - def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self): - with patch( - "specify_cli._version.shutil.which", return_value="/broken/specify" - ), patch("pathlib.Path.resolve", side_effect=OSError("bad path")): - result = specify_cli._version._resolved_argv0_path("specify") - - assert str(result) == "/broken/specify" - - -class TestArgvAssemblyUvTool: - """uv-tool installer argv shape.""" - - def test_stable_tag_produces_expected_argv(self): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"): - argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") - assert argv == [ - "/usr/bin/uv", - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ] - - def test_dev_suffix_tag_embedded_literally(self): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"): - argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0") - assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv - assert ( - "upgrade" not in argv - ) # never `uv tool upgrade` — does not accept --tag pinning - - def test_missing_uv_returns_no_installer_argv(self): - with patch("specify_cli._version.shutil.which", return_value=None): - assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None - - -class TestBareUpgradeUvTool: - """uv-tool happy path, bare invocation.""" - - def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), # installer - _completed_process(0, stdout="specify 0.7.6\n"), # verify - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out - assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out - assert mock_run.call_count == 2 - for call in mock_run.call_args_list: - assert call.kwargs.get("shell", False) is False - - def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ): - # The single `invoke` represents the single user action — no prompt. - # If a prompt existed, runner.invoke would hang waiting for input. - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.7.6\n"), - ] - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 0 - - -class TestAlreadyLatestUvTool: - """already on latest, no installer launched.""" - - def test_already_latest_exits_zero_no_subprocess( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version._get_installed_version", return_value="0.7.6"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - assert "Already on latest release: v0.7.6" in strip_ansi(result.output) - assert mock_run.call_count == 0 - - def test_dev_build_ahead_of_release_reports_newer_noop( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output) - assert mock_run.call_count == 0 - - def test_unparseable_current_version_does_not_false_noop( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version._get_installed_version", return_value="release-main"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.7.6\n"), - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Already on latest release" not in out - assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out - assert mock_run.call_count == 2 - - def test_unparseable_resolved_target_fails_before_literal_noop( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version._get_installed_version", return_value="release-main"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 1 - out = strip_ansi(result.output) - assert "not a comparable version" in out - assert "release-main" not in out - assert "Already on latest release" not in out - assert mock_run.call_count == 0 - - def test_pinned_older_tag_still_runs_installer( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.6" - ): - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.7.5\n"), - ] - result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Already on latest release" not in out - assert "Upgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out - assert mock_run.call_count == 2 - - def test_pinned_rc_tag_uses_canonical_version_equality_for_noop( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( - "specify_cli._version._get_installed_version", return_value="1.0.0rc1" - ): - result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) - - assert result.exit_code == 0 - assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output) - - -class TestDryRunUvTool: - """--dry-run preview path + --dry-run combined with --tag.""" - - def test_dry_run_without_tag_resolves_network_but_no_subprocess( - self, - uv_tool_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Dry run — no changes will be made." in out - assert "Detected install method: uv tool" in out - assert "Current version: 0.7.5" in out - assert "Target version: v0.7.6" in out - assert "Command that would be executed:" in out - assert mock_run.call_count == 0 - - def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): - # --dry-run with --tag must NOT hit the network. - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ), patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v0.8.0"], - ) - assert result.exit_code == 0 - assert "Target version: v0.8.0" in strip_ansi(result.output) - mock_urlopen.assert_not_called() - - def test_dry_run_rejects_unparseable_network_tag_before_preview( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = mock_urlopen_response( - {"tag_name": "v0.9.0;echo unsafe"} - ) - result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) - - out = strip_ansi(result.output) - assert result.exit_code == 1 - assert "not a comparable version" in out - assert "v0.9.0;echo unsafe" not in out - assert "Command that would be executed:" not in out - assert mock_run.call_count == 0 - - def test_dry_run_with_missing_uv_flags_unresolved_installer( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value=None - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Command that would be executed: (installer uv not found on PATH)" in out - assert "uv tool install" not in out - assert mock_run.call_count == 0 - - -# =========================================================================== -# Phase 4 — User Story 2: `pipx` immediate upgrade (P2) -# =========================================================================== - - -class TestDetectionPipx: - """Pipx detection — tier 1 (path) and tier 3 (registry).""" - - def test_posix_pipx_prefix_matches(self, pipx_argv0): - method, signals = _detect_install_method(include_signals=True) - assert method == _InstallMethod.PIPX - assert signals.matched_tier == 1 - - def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it( - self, - monkeypatch, - tmp_path, - ): - monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) - - def fake_which(name): - return "/usr/bin/pipx" if name == "pipx" else None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout='{"venvs":{"specify-cli":{}}}', - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method, signals = _detect_install_method(include_signals=True) - assert method == _InstallMethod.PIPX - assert signals.matched_tier == 3 - assert "pipx list --json" in signals.installer_registries_consulted - - def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint( - self, - unsupported_argv0, - ): - def fake_which(name): - return "/usr/bin/pipx" if name == "pipx" else None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout='{"venvs":{"specify-cli":{}}}', - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method = _detect_install_method() - assert method == _InstallMethod.UNSUPPORTED - - def test_tier3_pipx_ignores_malformed_json_output( - self, - unsupported_argv0, - ): - def fake_which(name): - return "/usr/bin/pipx" if name == "pipx" else None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout="not json but mentions specify-cli", - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method = _detect_install_method() - assert method == _InstallMethod.UNSUPPORTED - - def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported( - self, - monkeypatch, - tmp_path, - ): - monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) - - def fake_which(name): - if name == "uv": - return "/usr/bin/uv" - if name == "pipx": - return "/usr/bin/pipx" - return None - - def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout="specify-cli v0.7.6\n", - stderr="", - ) - if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: - return subprocess.CompletedProcess( - args=argv, - returncode=0, - stdout='{"venvs":{"specify-cli":{}}}', - stderr="", - ) - return subprocess.CompletedProcess( - args=argv, returncode=1, stdout="", stderr="" - ) - - with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( - "specify_cli._version.subprocess.run", side_effect=fake_run - ), patch("specify_cli._version._editable_marker_seen", return_value=False): - method, signals = _detect_install_method(include_signals=True) - assert method == _InstallMethod.UNSUPPORTED - assert signals.matched_tier is None - assert "uv tool list" in signals.installer_registries_consulted - assert "pipx list --json" in signals.installer_registries_consulted - - -class TestEditableInstallMetadata: - def test_editable_marker_false_when_metadata_is_invalid(self): - invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) - if invalid_metadata_error is None: - class _FakeInvalidMetadataError(Exception): - pass - - invalid_metadata_error = _FakeInvalidMetadataError - - with patch.object( - importlib.metadata, - "InvalidMetadataError", - invalid_metadata_error, - create=True, - ), patch( - "importlib.metadata.distribution", - side_effect=invalid_metadata_error("bad metadata"), - ): - assert specify_cli._version._editable_marker_seen() is False - assert specify_cli._version._source_checkout_path() is None - - def test_direct_url_editable_install_marks_source_checkout(self, tmp_path): - project_root = tmp_path / "spec-kit" - project_root.mkdir() - (project_root / ".git").mkdir() - - class FakeDist: - files = [] - - def read_text(self, name): - if name == "direct_url.json": - return json.dumps( - { - "dir_info": {"editable": True}, - "url": project_root.as_uri(), - } - ) - return None - - def locate_file(self, file): - return file - - with patch("importlib.metadata.distribution", return_value=FakeDist()): - assert specify_cli._version._editable_marker_seen() is True - assert specify_cli._version._source_checkout_path() == project_root.resolve() - - def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path): - repo_root = tmp_path / "repo" - repo_root.mkdir() - (repo_root / ".git").mkdir() - venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py" - venv_file.parent.mkdir(parents=True) - venv_file.write_text("# installed module\n") - - class FakeDist: - files = ["specify_cli.py"] - - def read_text(self, name): - return None - - def locate_file(self, file): - return venv_file - - with patch("importlib.metadata.distribution", return_value=FakeDist()): - assert specify_cli._version._editable_marker_seen() is False - - -class TestTagValidationWhitespace: - def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.8.0\n"), - ] - result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "]) - - assert result.exit_code == 0 - assert "v0.8.0" in strip_ansi(result.output) - - -class TestArgvAssemblyPipx: - """pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`.""" - - def test_pipx_argv_uses_install_force_positional_not_upgrade(self): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/pipx"): - argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") - assert argv == [ - "/usr/bin/pipx", - "install", - "--force", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ] - assert "upgrade" not in argv # pipx upgrade does not accept arbitrary refs - assert "--spec" not in argv # pipx 1.5+ dropped the --spec flag - - def test_missing_pipx_returns_no_installer_argv(self): - with patch("specify_cli._version.shutil.which", return_value=None): - assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None - - -class TestBareUpgradePipx: - """pipx happy path.""" - - def test_happy_path(self, pipx_argv0, clean_environ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.7.6\n"), - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "via pipx:" in out - assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out - - -class TestDetectionShortCircuit: - """Tier-1 path-prefix matches short-circuit before registry checks.""" - - def test_pipx_argv0_prefix_short_circuits_before_registry_checks( - self, - pipx_argv0, - clean_environ, - ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch( - "specify_cli._version.subprocess.run" - ) as mock_run: - method = _detect_install_method() - assert method == _InstallMethod.PIPX - mock_run.assert_not_called() - - -class TestDryRunPipx: - def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) - assert result.exit_code == 0 - assert "Detected install method: pipx" in strip_ansi(result.output) - assert mock_run.call_count == 0 - - -# =========================================================================== -# Phase 5 — User Story 3: non-upgradable path guidance (P3) -# =========================================================================== - - -class TestUvxEphemeral: - """uvx ephemeral path emits exact one-liner, no installer call.""" - - def test_uvx_argv0_prints_exact_one_liner_and_exits_zero( - self, - uvx_ephemeral_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run: - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 0 - expected = ( - "Running via uvx (ephemeral); the next uvx invocation already " - "resolves to latest — no upgrade action needed." - ) - assert expected in strip_ansi(result.output) - assert mock_run.call_count == 0 - - def test_offline_still_exits_zero_without_tag_resolution( - self, - uvx_ephemeral_argv0, - clean_environ, - ): - with patch( - "specify_cli.authentication.http.urllib.request.urlopen", - side_effect=AssertionError("non-upgradable uvx path must not hit network"), - ): - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 0 - assert "uvx (ephemeral)" in strip_ansi(result.output) - - -class TestSourceCheckout: - """Editable install path emits git pull guidance.""" - - def test_source_checkout_prints_git_pull_guidance( - self, - unsupported_argv0, - tmp_path, - clean_environ, - ): - fake_tree = tmp_path / "worktree" - fake_tree.mkdir() - (fake_tree / ".git").mkdir() - - with patch("specify_cli._version._editable_marker_seen", return_value=True), patch( - "specify_cli._version._source_checkout_path", return_value=fake_tree - ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run: - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert f"Running from a source checkout at {fake_tree}" in out - assert "git pull" in out - assert "pip install -e ." in out - assert mock_run.call_count == 0 - - def test_source_checkout_without_path_mentions_checkout_directory( - self, - unsupported_argv0, - clean_environ, - ): - with patch("specify_cli._version._editable_marker_seen", return_value=True), patch( - "specify_cli._version._source_checkout_path", return_value=None - ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run: - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - out = strip_ansi(result.output) - assert result.exit_code == 0 - assert "checkout path could not be detected" in out - assert "from your checkout directory" in out - assert "(path unavailable)" not in out - assert mock_run.call_count == 0 - - -class TestUnsupported: - """Unsupported path enumerates manual reinstall commands.""" - - def test_unsupported_prints_both_reinstall_commands( - self, - unsupported_argv0, - clean_environ, - ): - with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( - "specify_cli._version.shutil.which", return_value=None - ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run: - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Could not identify your install method automatically" in out - assert ( - "uv tool install specify-cli --force --from " - "git+https://github.com/github/spec-kit.git@vX.Y.Z" - ) in out - assert ( - "pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z" - in out - ) - assert mock_run.call_count == 0 - - def test_unsupported_offline_degrades_to_placeholder_manual_commands( - self, - unsupported_argv0, - clean_environ, - ): - with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( - "specify_cli._version.shutil.which", return_value=None - ), patch( - "specify_cli.authentication.http.urllib.request.urlopen", - side_effect=AssertionError("unsupported guidance should not require network"), - ): - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Could not identify your install method automatically" in out - assert ( - "uv tool install specify-cli --force --from " - "git+https://github.com/github/spec-kit.git@vX.Y.Z" - ) in out - assert ( - "pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z" - in out - ) - - -class TestDryRunNonUpgradablePaths: - """--dry-run on non-upgradable paths emits guidance, not preview.""" - - def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview( - self, - uvx_ephemeral_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen: - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Dry run — no changes will be made." not in out - assert "uvx (ephemeral)" in out - - def test_dry_run_on_unsupported_emits_manual_commands( - self, - unsupported_argv0, - clean_environ, - ): - with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( - "specify_cli._version.shutil.which", return_value=None - ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen: - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) - assert result.exit_code == 0 - assert "Could not identify your install method" in strip_ansi(result.output) - - -# =========================================================================== -# Phase 6 — User Story 4: failure recovery (P2) -# =========================================================================== - - -class TestInstallerMissing: - """Installer disappeared between detection and run → exit 3.""" - - def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ): - which_results = {"specify": "/usr/local/bin/specify"} - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 3 - out = strip_ansi(result.output) - assert "Installer uv not found on PATH; reinstall it and retry." in out - assert "Upgrading specify-cli" not in out - - def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ): - which_results = {} - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 3 - assert "Installer pipx not found on PATH" in strip_ansi(result.output) - - def test_absolute_installer_path_does_not_require_path_lookup( - self, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "installer-bin" / "uv" - fake_uv.parent.mkdir() - fake_uv.write_text("#!/bin/sh\n") - fake_uv.chmod(0o755) - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ), patch( - "specify_cli._version._verify_upgrade", return_value="0.7.6" - ), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - str(fake_uv), - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(0)] - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 0 - - def test_relative_installer_path_does_not_require_path_lookup( - self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "uv" - fake_uv.write_text("#!/bin/sh\n") - fake_uv.chmod(0o755) - monkeypatch.chdir(tmp_path) - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ), patch( - "specify_cli._version._verify_upgrade", return_value="0.7.6" - ), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - "./uv", - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(0)] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - assert mock_run.call_args.args[0][0] == "./uv" - - def test_relative_installer_path_missing_gets_path_specific_message( - self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path - ): - monkeypatch.chdir(tmp_path) - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - "./uv", - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 3 - assert ( - "Installer path ./uv no longer exists; reinstall it and retry." - in strip_ansi(result.output) - ) - assert "not found on PATH" not in strip_ansi(result.output) - - def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message( - self, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "installer-bin" / "uv" - fake_uv.parent.mkdir() - fake_uv.write_text("#!/bin/sh\n") - fake_uv.chmod(0o755) - - def fake_run(argv, *args, **kwargs): - fake_uv.unlink() - raise FileNotFoundError(str(fake_uv)) - - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", - side_effect=lambda name: str(fake_uv) if name == "uv" else None, - ), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 3 - assert ( - f"Installer path {fake_uv} no longer exists; reinstall it and retry." - in strip_ansi(result.output) - ) - - def test_absolute_installer_path_not_executable_gets_specific_message( - self, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "installer-bin" / "uv" - fake_uv.parent.mkdir() - fake_uv.write_text("#!/bin/sh\n") - fake_uv.chmod(0o644) - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version.os.access", return_value=False), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - str(fake_uv), - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 3 - assert ( - f"Installer path {fake_uv} is not an executable file; fix the path or reinstall it and retry." - in strip_ansi(result.output) - ) - - def test_relative_installer_path_not_executable_gets_path_specific_message( - self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "uv" - fake_uv.write_text("#!/bin/sh\n") - fake_uv.chmod(0o644) - monkeypatch.chdir(tmp_path) - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version.os.access", return_value=False), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - "./uv", - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - out = strip_ansi(result.output) - assert result.exit_code == 3 - assert ( - "Installer path ./uv is not an executable file; fix the path or reinstall it and retry." - in out - ) - assert "Installer ./uv is not executable" not in out - - def test_real_installer_exit_126_is_not_treated_as_invalid_path( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(126)] - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 126 - out = strip_ansi(result.output) - assert "Upgrade failed. Installer exit code: 126." in out - assert "not an executable file" not in out - - def test_absolute_installer_path_missing_gets_path_specific_message( - self, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "missing-installer" / "uv" - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - str(fake_uv), - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 3 - assert ( - f"Installer path {fake_uv} no longer exists; reinstall it and retry." - in strip_ansi(result.output) - ) - mock_run.assert_not_called() - - def test_exec_oserror_is_treated_as_invalid_installer( - self, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "installer-bin" / "uv" - fake_uv.parent.mkdir() - fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") - fake_uv.chmod(0o755) - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - str(fake_uv), - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ), patch( - "specify_cli._version.subprocess.run", - side_effect=PermissionError("Permission denied"), - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 3 - out = strip_ansi(result.output) - assert f"Installer path {fake_uv} is not an executable file" in out - assert "not found on PATH" not in out - - def test_bare_invalid_installer_message_does_not_call_it_a_path( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - "uv", - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ), patch( - "specify_cli._version.subprocess.run", - side_effect=PermissionError("Permission denied"), - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 3 - out = strip_ansi(result.output) - assert "Installer uv is not executable" in out - assert "Installer path uv" not in out - - def test_exec_oserror_errno_is_treated_as_invalid_installer( - self, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "installer-bin" / "uv" - fake_uv.parent.mkdir() - fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") - fake_uv.chmod(0o755) - invalid_error = OSError(errno.ENOEXEC, "Exec format error") - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - str(fake_uv), - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ), patch("specify_cli._version.subprocess.run", side_effect=invalid_error): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 3 - out = strip_ansi(result.output) - assert f"Installer path {fake_uv} is not an executable file" in out - assert "not found on PATH" not in out - - def test_transient_exec_oserror_is_not_treated_as_invalid_installer( - self, uv_tool_argv0, clean_environ, tmp_path - ): - fake_uv = tmp_path / "installer-bin" / "uv" - fake_uv.parent.mkdir() - fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") - fake_uv.chmod(0o755) - transient_error = OSError(errno.EMFILE, "Too many open files") - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( - "specify_cli._version._assemble_installer_argv", - return_value=[ - str(fake_uv), - "tool", - "install", - "specify-cli", - "--force", - "--from", - "git+https://github.com/github/spec-kit.git@v0.7.6", - ], - ), patch("specify_cli._version.subprocess.run", side_effect=transient_error): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code != 3 - assert isinstance(result.exception, OSError) - - -class TestInstallerFailed: - """Installer non-zero exit → propagate code, print rollback hint.""" - - def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(2)] # installer fails - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 2 - out = strip_ansi(result.output) - assert "Upgrade failed. Installer exit code: 2." in out - assert "Try again or run the command manually:" in out - assert "git+https://github.com/github/spec-kit.git@v0.7.6" in out - assert ( - "To pin back to the previous version: " - "uv tool install specify-cli --force --from " - "git+https://github.com/github/spec-kit.git@v0.7.5" - ) in out - # No verification attempted after a failed installer run. - assert mock_run.call_count == 1 - - def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(127)] - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 127 - - def test_installer_timeout_prints_timeout_specific_message( - self, uv_tool_argv0, clean_environ, monkeypatch - ): - monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12") - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - subprocess.TimeoutExpired(cmd=["uv"], timeout=12) - ] - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 124 - out = strip_ansi(result.output) - assert "Upgrade timed out while waiting for the installer subprocess." in out - assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out - - def test_non_finite_timeout_warns_and_runs_without_timeout( - self, uv_tool_argv0, clean_environ, monkeypatch - ): - monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan") - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.7.6\n"), - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - assert "Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS='nan'" in strip_ansi( - result.output - ) - assert mock_run.call_args_list[0].kwargs["timeout"] is None - - def test_real_installer_exit_124_is_not_treated_as_timeout( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(124)] - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 124 - out = strip_ansi(result.output) - assert "Upgrade failed. Installer exit code: 124." in out - assert "Upgrade timed out while waiting for the installer subprocess." not in out - - def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(2)] - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 2 - out = strip_ansi(result.output) - assert ( - "To pin back to the previous version: pipx install --force " - "git+https://github.com/github/spec-kit.git@v0.7.5" - ) in out - - def test_rollback_hint_accepts_normalizable_stable_snapshot( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="v0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(2)] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 2 - out = strip_ansi(result.output) - assert ( - "To pin back to the previous version: uv tool install specify-cli --force " - "--from git+https://github.com/github/spec-kit.git@v0.7.5" - ) in out - assert "Previous version was not an exact stable release tag" not in out - - def test_prerelease_failure_degrades_rollback_hint_to_releases_page( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="1.0.0rc1" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"}) - mock_run.side_effect = [_completed_process(2)] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 2 - out = strip_ansi(result.output) - assert "Previous version was not an exact stable release tag" in out - assert "https://github.com/github/spec-kit/releases" in out - assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out - - -class TestVerificationMismatch: - """Installer says 0 but the binary is still the old version → exit 2.""" - - def test_installer_ok_but_verify_returns_old_version( - self, - uv_tool_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), # installer OK - _completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD! - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 2 - out = strip_ansi(result.output) - assert "Verification failed" in out - assert "resolves to 0.7.5 (expected v0.7.6)" in out - assert "The new version may take effect on your next invocation." in out - - def test_verify_nonzero_exit_is_not_treated_as_success( - self, - uv_tool_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(1, stdout="specify 0.7.6\n"), - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 2 - out = strip_ansi(result.output) - assert "Verification failed" in out - assert "(unknown) (expected v0.7.6)" in out - - def test_verify_accepts_pep440_equivalent_rc_version( - self, - uv_tool_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.9.0" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 1.0.0rc1\n"), - ] - result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) - - assert result.exit_code == 0 - assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output) - - def test_verify_accepts_specify_cli_binary_name_in_version_output( - self, - uv_tool_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify-cli version 0.7.6\n"), - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output) - - def test_verify_rejects_output_without_parseable_version( - self, - uv_tool_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify version unknown\n"), - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 2 - out = strip_ansi(result.output) - assert "Verification failed" in out - assert "(unknown) (expected v0.7.6)" in out - - def test_verify_uses_current_entrypoint_when_not_on_path( - self, - uv_tool_argv0, - clean_environ, - ): - assert uv_tool_argv0.exists() - assert uv_tool_argv0.is_file() - - plan = _UpgradePlan( - method=_InstallMethod.UV_TOOL, - current_version="0.7.5", - target_tag="v0.7.6", - installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], - preview_summary="", - pre_upgrade_snapshot="0.7.5", - ) - - with patch( - "specify_cli._version.shutil.which", side_effect=lambda name: None - ), patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.os.access", return_value=True - ): - mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") - verified = _verify_upgrade(plan) - - assert verified == "0.7.6" - assert mock_run.call_args.args[0][0] == str(uv_tool_argv0) - - def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable( - self, - uv_tool_argv0, - clean_environ, - ): - plan = _UpgradePlan( - method=_InstallMethod.UV_TOOL, - current_version="0.7.5", - target_tag="v0.7.6", - installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], - preview_summary="", - pre_upgrade_snapshot="0.7.5", - ) - - with patch( - "specify_cli._version.shutil.which", - side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None, - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version.os.access", return_value=False - ): - mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") - verified = _verify_upgrade(plan) - - assert verified == "0.7.6" - assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify" - - def test_verify_ignores_python_entrypoint_and_falls_back_to_specify( - self, - clean_environ, - tmp_path, - ): - fake_python = tmp_path / "python3" - fake_python.write_text("#!/bin/sh\n") - fake_python.chmod(0o755) - - plan = _UpgradePlan( - method=_InstallMethod.UV_TOOL, - current_version="0.7.5", - target_tag="v0.7.6", - installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], - preview_summary="", - pre_upgrade_snapshot="0.7.5", - ) - - with patch( - "specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version.sys.argv", [str(fake_python)] - ), patch( - "specify_cli._version.os.access", return_value=True - ): - mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") - verified = _verify_upgrade(plan) - - assert verified == "0.7.6" - assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify" - - def test_verify_accepts_specify_cli_named_current_entrypoint( - self, - clean_environ, - tmp_path, - ): - fake_specify_cli = tmp_path / "specify-cli" - fake_specify_cli.write_text("#!/bin/sh\n") - fake_specify_cli.chmod(0o755) - - plan = _UpgradePlan( - method=_InstallMethod.UV_TOOL, - current_version="0.7.5", - target_tag="v0.7.6", - installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], - preview_summary="", - pre_upgrade_snapshot="0.7.5", - ) - - with patch("specify_cli._version.shutil.which", return_value=None), patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch( - "specify_cli._version.os.access", return_value=True - ): - mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") - verified = _verify_upgrade(plan) - - assert verified == "0.7.6" - assert mock_run.call_args.args[0][0] == str(fake_specify_cli) - - -class TestResolutionFailures: - """Pre-installer resolution failure → exit 1, reusing the resolver category strings.""" - - def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ): - with patch( - "specify_cli.authentication.http.urllib.request.urlopen", - side_effect=urllib.error.URLError("nope"), - ): - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 1 - assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output) - - def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ): - err = urllib.error.HTTPError( - url="https://api.github.com", - code=403, - msg="rate limited", - hdrs={}, # type: ignore[arg-type] - fp=None, - ) - with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 1 - assert ( - "Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)" - in strip_ansi(result.output) - ) - - def test_http_500_exits_1(self, uv_tool_argv0, clean_environ): - err = urllib.error.HTTPError( - url="https://api.github.com", - code=500, - msg="srv err", - hdrs={}, # type: ignore[arg-type] - fp=None, - ) - with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): - result = runner.invoke(app, ["self", "upgrade"]) - assert result.exit_code == 1 - assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output) - - def test_unparseable_resolved_release_tag_exits_1_without_traceback( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.subprocess.run" - ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 1 - out = strip_ansi(result.output) - assert "resolved release tag is not a comparable version" in out - assert "release-main" not in out - assert "Traceback" not in out - assert mock_run.call_count == 0 - - -class TestTagValidation: - """--tag regex enforcement.""" - - def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v0.7.6"], - ) - assert result.exit_code == 0 - - def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"], - ) - assert result.exit_code == 0 - assert "Target version: v0.8.0.dev0" in strip_ansi(result.output) - - def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"], - ) - assert result.exit_code == 0 - - def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( - self, uv_tool_argv0, clean_environ - ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( - "specify_cli._version._get_installed_version", return_value="1.0.0b1" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--tag", "v1.0.0-beta.1"], - ) - assert result.exit_code == 0 - assert "Already on requested release: v1.0.0-beta.1" in strip_ansi( - result.output - ) - - def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - result = runner.invoke( - app, - ["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"], - ) - assert result.exit_code == 0 - assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) - - @pytest.mark.parametrize( - "bad_tag", - ["latest", "0.7.5", "main", "v7", "", "v1.2.3abc", "v1.2.3...", "v1.2.3++"], - ) - def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ): - result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag]) - assert result.exit_code == 1 - output = strip_ansi(result.output) - assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output - - -class TestUnknownCurrent: - """'unknown' current version renders literally in notice and success message.""" - - def test_unknown_current_renders_literal_in_notice( - self, - uv_tool_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="unknown" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.7.6\n"), - ] - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 0 - out = strip_ansi(result.output) - assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out - assert "Upgraded specify-cli: unknown → 0.7.6" in out - - def test_unknown_current_rollback_hint_degrades( - self, - uv_tool_argv0, - clean_environ, - ): - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="unknown" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [_completed_process(2)] # installer fails - result = runner.invoke(app, ["self", "upgrade"]) - - assert result.exit_code == 2 - out = strip_ansi(result.output) - assert "Could not determine the previous version" in out - assert "https://github.com/github/spec-kit/releases" in out - - -class TestTokenScrubbing: - """GH_TOKEN / GITHUB_TOKEN are stripped from every child env.""" - - def test_env_passed_to_subprocess_has_no_github_tokens( - self, - uv_tool_argv0, - monkeypatch, - ): - monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) - monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) - - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.7.6\n"), - ] - runner.invoke(app, ["self", "upgrade"]) - - assert mock_run.call_count >= 1 - for call in mock_run.call_args_list: - env_kwarg = call.kwargs.get("env") or {} - assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}" - assert "GITHUB_TOKEN" not in env_kwarg - for v in env_kwarg.values(): - assert SENTINEL_GH_TOKEN not in v - assert SENTINEL_GITHUB_TOKEN not in v - - def test_env_scrubbing_is_case_insensitive( - self, - uv_tool_argv0, - monkeypatch, - ): - monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN) - monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN) - - with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" - ), patch("specify_cli._version.subprocess.run") as mock_run, patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" - ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) - mock_run.side_effect = [ - _completed_process(0), - _completed_process(0, stdout="specify 0.7.6\n"), - ] - runner.invoke(app, ["self", "upgrade"]) - - assert mock_run.call_count >= 1 - for call in mock_run.call_args_list: - env_kwarg = call.kwargs.get("env") or {} - assert "gh_token" not in env_kwarg - assert "GitHub_Token" not in env_kwarg - for v in env_kwarg.values(): - assert SENTINEL_GH_TOKEN not in v - assert SENTINEL_GITHUB_TOKEN not in v - - def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): - monkeypatch.setenv("GH_PAT", "gh-pat") - monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh") - monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret") - monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key") - monkeypatch.setenv("GITHUB_PAT", "github-pat") - monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github") - monkeypatch.setenv("GITHUB_API_TOKEN", "api-token") - monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key") - monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret") - monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token") - monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept") - monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept") - monkeypatch.setenv("UNRELATED_TOKEN", "kept") - - env = specify_cli._version._scrubbed_env() - - assert "GH_PAT" not in env - assert "GH_ENTERPRISE_TOKEN" not in env - assert "GH_ENTERPRISE_SECRET" not in env - assert "GH_ENTERPRISE_PRIVATE_KEY" not in env - assert "GITHUB_PAT" not in env - assert "GITHUB_ENTERPRISE_TOKEN" not in env - assert "GITHUB_API_TOKEN" not in env - assert "GITHUB_APP_PRIVATE_KEY" not in env - assert "GITHUB_OAUTH_CLIENT_SECRET" not in env - assert "HOMEBREW_GITHUB_API_TOKEN" not in env - assert env["GHOST_API_TOKEN"] == "ghost-kept" - assert env["GHIDRA_API_KEY"] == "ghidra-kept" - assert env["UNRELATED_TOKEN"] == "kept" diff --git a/tests/test_self_upgrade_detection.py b/tests/test_self_upgrade_detection.py new file mode 100644 index 0000000000..64cc97829e --- /dev/null +++ b/tests/test_self_upgrade_detection.py @@ -0,0 +1,861 @@ +"""Detection, argv assembly, and dry-run tests for `specify self upgrade`.""" + +from tests.self_upgrade_helpers import ( + _InstallMethod, + _assemble_installer_argv, + _completed_process, + _detect_install_method, + app, + importlib, + json, + mock_urlopen_response, + os, + patch, + runner, + specify_cli, + strip_ansi, + subprocess, +) + +pytest_plugins = ("tests.self_upgrade_fixtures",) + + +class TestDetectionUvTool: + """Tier-1 path-prefix detection for uv-tool installs.""" + + def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UV_TOOL + assert signals.matched_tier == 1 + assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/") + + def test_detection_is_deterministic(self, uv_tool_argv0): + a = _detect_install_method() + b = _detect_install_method() + assert a == b == _InstallMethod.UV_TOOL + + def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0): + with patch("specify_cli._version.shutil.which", return_value=None), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0): + result = _detect_install_method(include_signals=False) + assert isinstance(result, _InstallMethod) + + def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path): + if os.name == "nt": + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin" + else: + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = ( + tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin" + ) + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + monkeypatch.setattr("sys.argv", ["specify"]) + with patch( + "specify_cli._version.shutil.which", + side_effect=lambda name: str(fake_specify) if name == "specify" else None, + ): + method = _detect_install_method() + assert method == _InstallMethod.UV_TOOL + + def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin" + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + with patch("specify_cli._version.shutil.which", return_value=None), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_uv_tool_when_registry_lists_exact_name( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + + def fake_which(name): + return "/usr/bin/uv" if name == "uv" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\nother-tool v1.2.3\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UV_TOOL + assert signals.matched_tier == 3 + assert "uv tool list" in signals.installer_registries_consulted + + def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["specify"]) + + def fake_which(name): + return "/usr/bin/uv" if name == "uv" else None + + def fake_run(argv, *args, **kwargs): + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UNSUPPORTED + assert signals.installer_registries_consulted == () + + def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection( + self, monkeypatch, tmp_path + ): + missing_specify = tmp_path / "missing" / "specify" + monkeypatch.setattr("sys.argv", ["specify"]) + + def fake_which(name): + if name == "specify": + return str(missing_specify) + if name == "uv": + return "/usr/bin/uv" + return None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + + assert method == _InstallMethod.UV_TOOL + assert signals.matched_tier == 3 + assert "uv tool list" in signals.installer_registries_consulted + + def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup( + self, monkeypatch, tmp_path + ): + if os.name == "nt": + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin" + else: + monkeypatch.setenv("HOME", str(tmp_path)) + fake_dir = ( + tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin" + ) + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + monkeypatch.setattr("sys.argv", ["./bin/specify"]) + + def fake_which(name): + return str(fake_specify) if name == "specify" else None + + with patch("specify_cli._version.shutil.which", side_effect=fake_which): + method = _detect_install_method() + + assert method == _InstallMethod.UV_TOOL + + def test_tier3_uv_tool_ignores_substring_false_positive( + self, + unsupported_argv0, + ): + def fake_which(name): + return "/usr/bin/uv" if name == "uv" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="my-specify-cli-helper v0.1.0\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint( + self, + unsupported_argv0, + ): + def fake_which(name): + return "/usr/bin/uv" if name == "uv" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint( + self, + monkeypatch, + tmp_path, + ): + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + fake_specify = venv_bin / "specify" + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", ["specify"]) + + def fake_which(name): + if name == "specify": + return str(fake_specify) + if name == "uv": + return "/usr/bin/uv" + return None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UNSUPPORTED + assert signals.matched_tier is None + assert signals.installer_registries_consulted == () + + +class TestPrefixExpansion: + """Path-prefix expansion edge cases.""" + + def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path): + prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli" + prefix = str(prefix_path) + + expanded = specify_cli._version._expand_prefix(prefix) + + assert expanded == prefix_path.resolve() + + def test_unresolved_posix_variable_is_rejected(self): + assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None + + def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path): + prefix = str(tmp_path / "specify-cli") + + with patch("pathlib.Path.resolve", side_effect=OSError("bad path")): + assert specify_cli._version._expand_prefix(prefix) is None + + +class TestArgv0Resolution: + """Entrypoint path resolution edge cases.""" + + def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path): + argv0 = tmp_path / "specify" + + with patch("pathlib.Path.resolve", side_effect=OSError("bad path")): + assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0 + + def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self): + with patch( + "specify_cli._version.shutil.which", return_value="/broken/specify" + ), patch("pathlib.Path.resolve", side_effect=OSError("bad path")): + result = specify_cli._version._resolved_argv0_path("specify") + + assert str(result) == "/broken/specify" + + +class TestArgvAssemblyUvTool: + """uv-tool installer argv shape.""" + + def test_stable_tag_produces_expected_argv(self): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"): + argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") + assert argv == [ + "/usr/bin/uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ] + + def test_dev_suffix_tag_embedded_literally(self): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"): + argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0") + assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv + assert ( + "upgrade" not in argv + ) # never `uv tool upgrade` — does not accept --tag pinning + + def test_missing_uv_returns_no_installer_argv(self): + with patch("specify_cli._version.shutil.which", return_value=None): + assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None + + +class TestBareUpgradeUvTool: + """uv-tool happy path, bare invocation.""" + + def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), # installer + _completed_process(0, stdout="specify 0.7.6\n"), # verify + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out + assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out + assert mock_run.call_count == 2 + for call in mock_run.call_args_list: + assert call.kwargs.get("shell", False) is False + + def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ): + # The single `invoke` represents the single user action — no prompt. + # If a prompt existed, runner.invoke would hang waiting for input. + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + + +class TestAlreadyLatestUvTool: + """already on latest, no installer launched.""" + + def test_already_latest_exits_zero_no_subprocess( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.6"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Already on latest release: v0.7.6" in strip_ansi(result.output) + assert mock_run.call_count == 0 + + def test_dev_build_ahead_of_release_reports_newer_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output) + assert mock_run.call_count == 0 + + def test_unparseable_current_version_does_not_false_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="release-main"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Already on latest release" not in out + assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out + assert mock_run.call_count == 2 + + def test_unparseable_resolved_target_fails_before_literal_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="release-main"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 1 + out = strip_ansi(result.output) + assert "not a comparable version" in out + assert "release-main" not in out + assert "Already on latest release" not in out + assert mock_run.call_count == 0 + + def test_pinned_older_tag_still_runs_installer( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.6" + ): + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.5\n"), + ] + result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Already on latest release" not in out + assert "Upgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out + assert mock_run.call_count == 2 + + def test_pinned_rc_tag_uses_canonical_version_equality_for_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="1.0.0rc1" + ): + result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) + + assert result.exit_code == 0 + assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output) + + +class TestDryRunUvTool: + """--dry-run preview path + --dry-run combined with --tag.""" + + def test_dry_run_without_tag_resolves_network_but_no_subprocess( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Dry run — no changes will be made." in out + assert "Detected install method: uv tool" in out + assert "Current version: 0.7.5" in out + assert "Target version: v0.7.6" in out + assert "Command that would be executed:" in out + assert mock_run.call_count == 0 + + def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): + # --dry-run with --tag must NOT hit the network. + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ), patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.8.0"], + ) + assert result.exit_code == 0 + assert "Target version: v0.8.0" in strip_ansi(result.output) + mock_urlopen.assert_not_called() + + def test_dry_run_rejects_unparseable_network_tag_before_preview( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = mock_urlopen_response( + {"tag_name": "v0.9.0;echo unsafe"} + ) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + out = strip_ansi(result.output) + assert result.exit_code == 1 + assert "not a comparable version" in out + assert "v0.9.0;echo unsafe" not in out + assert "Command that would be executed:" not in out + assert mock_run.call_count == 0 + + def test_dry_run_with_missing_uv_flags_unresolved_installer( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value=None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Command that would be executed: (installer uv not found on PATH)" in out + assert "uv tool install" not in out + assert mock_run.call_count == 0 + + +# =========================================================================== +# Phase 4 — User Story 2: `pipx` immediate upgrade (P2) +# =========================================================================== + + +class TestDetectionPipx: + """Pipx detection — tier 1 (path) and tier 3 (registry).""" + + def test_posix_pipx_prefix_matches(self, pipx_argv0): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.PIPX + assert signals.matched_tier == 1 + + def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + + def fake_which(name): + return "/usr/bin/pipx" if name == "pipx" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout='{"venvs":{"specify-cli":{}}}', + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.PIPX + assert signals.matched_tier == 3 + assert "pipx list --json" in signals.installer_registries_consulted + + def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint( + self, + unsupported_argv0, + ): + def fake_which(name): + return "/usr/bin/pipx" if name == "pipx" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout='{"venvs":{"specify-cli":{}}}', + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_pipx_ignores_malformed_json_output( + self, + unsupported_argv0, + ): + def fake_which(name): + return "/usr/bin/pipx" if name == "pipx" else None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="not json but mentions specify-cli", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + + def fake_which(name): + if name == "uv": + return "/usr/bin/uv" + if name == "pipx": + return "/usr/bin/pipx" + return None + + def fake_run(argv, *args, **kwargs): + if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli v0.7.6\n", + stderr="", + ) + if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout='{"venvs":{"specify-cli":{}}}', + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch( + "specify_cli._version.subprocess.run", side_effect=fake_run + ), patch("specify_cli._version._editable_marker_seen", return_value=False): + method, signals = _detect_install_method(include_signals=True) + assert method == _InstallMethod.UNSUPPORTED + assert signals.matched_tier is None + assert "uv tool list" in signals.installer_registries_consulted + assert "pipx list --json" in signals.installer_registries_consulted + + +class TestEditableInstallMetadata: + def test_editable_marker_false_when_metadata_is_invalid(self): + invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_metadata_error is None: + class _FakeInvalidMetadataError(Exception): + pass + + invalid_metadata_error = _FakeInvalidMetadataError + + with patch.object( + importlib.metadata, + "InvalidMetadataError", + invalid_metadata_error, + create=True, + ), patch( + "importlib.metadata.distribution", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert specify_cli._version._editable_marker_seen() is False + assert specify_cli._version._source_checkout_path() is None + + def test_direct_url_editable_install_marks_source_checkout(self, tmp_path): + project_root = tmp_path / "spec-kit" + project_root.mkdir() + (project_root / ".git").mkdir() + + class FakeDist: + files = [] + + def read_text(self, name): + if name == "direct_url.json": + return json.dumps( + { + "dir_info": {"editable": True}, + "url": project_root.as_uri(), + } + ) + return None + + def locate_file(self, file): + return file + + with patch("importlib.metadata.distribution", return_value=FakeDist()): + assert specify_cli._version._editable_marker_seen() is True + assert specify_cli._version._source_checkout_path() == project_root.resolve() + + def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path): + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py" + venv_file.parent.mkdir(parents=True) + venv_file.write_text("# installed module\n") + + class FakeDist: + files = ["specify_cli.py"] + + def read_text(self, name): + return None + + def locate_file(self, file): + return venv_file + + with patch("importlib.metadata.distribution", return_value=FakeDist()): + assert specify_cli._version._editable_marker_seen() is False + + +class TestTagValidationWhitespace: + def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.8.0\n"), + ] + result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "]) + + assert result.exit_code == 0 + assert "v0.8.0" in strip_ansi(result.output) + + +class TestArgvAssemblyPipx: + """pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`.""" + + def test_pipx_argv_uses_install_force_positional_not_upgrade(self): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/pipx"): + argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") + assert argv == [ + "/usr/bin/pipx", + "install", + "--force", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ] + assert "upgrade" not in argv # pipx upgrade does not accept arbitrary refs + assert "--spec" not in argv # pipx 1.5+ dropped the --spec flag + + def test_missing_pipx_returns_no_installer_argv(self): + with patch("specify_cli._version.shutil.which", return_value=None): + assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None + + +class TestBareUpgradePipx: + """pipx happy path.""" + + def test_happy_path(self, pipx_argv0, clean_environ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "via pipx:" in out + assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out + + +class TestDetectionShortCircuit: + """Tier-1 path-prefix matches short-circuit before registry checks.""" + + def test_pipx_argv0_prefix_short_circuits_before_registry_checks( + self, + pipx_argv0, + clean_environ, + ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + method = _detect_install_method() + assert method == _InstallMethod.PIPX + mock_run.assert_not_called() + + +class TestDryRunPipx: + def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + assert result.exit_code == 0 + assert "Detected install method: pipx" in strip_ansi(result.output) + assert mock_run.call_count == 0 diff --git a/tests/test_self_upgrade_execution.py b/tests/test_self_upgrade_execution.py new file mode 100644 index 0000000000..0be64f8e06 --- /dev/null +++ b/tests/test_self_upgrade_execution.py @@ -0,0 +1,537 @@ +"""Installer execution, verification, and error-path tests for `specify self upgrade`.""" + +from tests.self_upgrade_helpers import ( + _completed_process, + app, + errno, + mock_urlopen_response, + patch, + runner, + strip_ansi, + subprocess, +) + +pytest_plugins = ("tests.self_upgrade_fixtures",) + +# =========================================================================== +# Phase 6 — User Story 4: failure recovery (P2) +# =========================================================================== + + +class TestInstallerMissing: + """Installer disappeared between detection and run → exit 3.""" + + def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ): + which_results = {"specify": "/usr/local/bin/specify"} + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + out = strip_ansi(result.output) + assert "Installer uv not found on PATH; reinstall it and retry." in out + assert "Upgrading specify-cli" not in out + + def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ): + which_results = {} + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + assert "Installer pipx not found on PATH" in strip_ansi(result.output) + + def test_absolute_installer_path_does_not_require_path_lookup( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o755) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._verify_upgrade", return_value="0.7.6" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(0)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + + def test_relative_installer_path_does_not_require_path_lookup( + self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "uv" + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o755) + monkeypatch.chdir(tmp_path) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._verify_upgrade", return_value="0.7.6" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + "./uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(0)] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert mock_run.call_args.args[0][0] == "./uv" + + def test_relative_installer_path_missing_gets_path_specific_message( + self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path + ): + monkeypatch.chdir(tmp_path) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + "./uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 3 + assert ( + "Installer path ./uv no longer exists; reinstall it and retry." + in strip_ansi(result.output) + ) + assert "not found on PATH" not in strip_ansi(result.output) + + def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o755) + + def fake_run(argv, *args, **kwargs): + fake_uv.unlink() + raise FileNotFoundError(str(fake_uv)) + + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", + side_effect=lambda name: str(fake_uv) if name == "uv" else None, + ), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 3 + assert ( + f"Installer path {fake_uv} no longer exists; reinstall it and retry." + in strip_ansi(result.output) + ) + + def test_absolute_installer_path_not_executable_gets_specific_message( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o644) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.os.access", return_value=False), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + assert ( + f"Installer path {fake_uv} is not an executable file; fix the path or reinstall it and retry." + in strip_ansi(result.output) + ) + + def test_relative_installer_path_not_executable_gets_path_specific_message( + self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "uv" + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o644) + monkeypatch.chdir(tmp_path) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.os.access", return_value=False), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + "./uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + out = strip_ansi(result.output) + assert result.exit_code == 3 + assert ( + "Installer path ./uv is not an executable file; fix the path or reinstall it and retry." + in out + ) + assert "Installer ./uv is not executable" not in out + + def test_real_installer_exit_126_is_not_treated_as_invalid_path( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(126)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 126 + out = strip_ansi(result.output) + assert "Upgrade failed. Installer exit code: 126." in out + assert "not an executable file" not in out + + def test_absolute_installer_path_missing_gets_path_specific_message( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "missing-installer" / "uv" + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + assert ( + f"Installer path {fake_uv} no longer exists; reinstall it and retry." + in strip_ansi(result.output) + ) + mock_run.assert_not_called() + + def test_exec_oserror_is_treated_as_invalid_installer( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + fake_uv.chmod(0o755) + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ), patch( + "specify_cli._version.subprocess.run", + side_effect=PermissionError("Permission denied"), + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + out = strip_ansi(result.output) + assert f"Installer path {fake_uv} is not an executable file" in out + assert "not found on PATH" not in out + + def test_bare_invalid_installer_message_does_not_call_it_a_path( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + "uv", + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ), patch( + "specify_cli._version.subprocess.run", + side_effect=PermissionError("Permission denied"), + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 3 + out = strip_ansi(result.output) + assert "Installer uv is not executable" in out + assert "Installer path uv" not in out + + def test_exec_oserror_errno_is_treated_as_invalid_installer( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + fake_uv.chmod(0o755) + invalid_error = OSError(errno.ENOEXEC, "Exec format error") + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ), patch("specify_cli._version.subprocess.run", side_effect=invalid_error): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 3 + out = strip_ansi(result.output) + assert f"Installer path {fake_uv} is not an executable file" in out + assert "not found on PATH" not in out + + def test_transient_exec_oserror_is_not_treated_as_invalid_installer( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "installer-bin" / "uv" + fake_uv.parent.mkdir() + fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + fake_uv.chmod(0o755) + transient_error = OSError(errno.EMFILE, "Too many open files") + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._version._assemble_installer_argv", + return_value=[ + str(fake_uv), + "tool", + "install", + "specify-cli", + "--force", + "--from", + "git+https://github.com/github/spec-kit.git@v0.7.6", + ], + ), patch("specify_cli._version.subprocess.run", side_effect=transient_error): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code != 3 + assert isinstance(result.exception, OSError) + + +class TestInstallerFailed: + """Installer non-zero exit → propagate code, print rollback hint.""" + + def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(2)] # installer fails + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Upgrade failed. Installer exit code: 2." in out + assert "Try again or run the command manually:" in out + assert "git+https://github.com/github/spec-kit.git@v0.7.6" in out + assert ( + "To pin back to the previous version: " + "uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@v0.7.5" + ) in out + # No verification attempted after a failed installer run. + assert mock_run.call_count == 1 + + def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(127)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 127 + + def test_installer_timeout_prints_timeout_specific_message( + self, uv_tool_argv0, clean_environ, monkeypatch + ): + monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12") + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + subprocess.TimeoutExpired(cmd=["uv"], timeout=12) + ] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 124 + out = strip_ansi(result.output) + assert "Upgrade timed out while waiting for the installer subprocess." in out + assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out + + def test_non_finite_timeout_warns_and_runs_without_timeout( + self, uv_tool_argv0, clean_environ, monkeypatch + ): + monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan") + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS='nan'" in strip_ansi( + result.output + ) + assert mock_run.call_args_list[0].kwargs["timeout"] is None + + def test_real_installer_exit_124_is_not_treated_as_timeout( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(124)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 124 + out = strip_ansi(result.output) + assert "Upgrade failed. Installer exit code: 124." in out + assert "Upgrade timed out while waiting for the installer subprocess." not in out + + def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(2)] + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert ( + "To pin back to the previous version: pipx install --force " + "git+https://github.com/github/spec-kit.git@v0.7.5" + ) in out + + def test_rollback_hint_accepts_normalizable_stable_snapshot( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="v0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(2)] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert ( + "To pin back to the previous version: uv tool install specify-cli --force " + "--from git+https://github.com/github/spec-kit.git@v0.7.5" + ) in out + assert "Previous version was not an exact stable release tag" not in out + + def test_prerelease_failure_degrades_rollback_hint_to_releases_page( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="1.0.0rc1" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"}) + mock_run.side_effect = [_completed_process(2)] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Previous version was not an exact stable release tag" in out + assert "https://github.com/github/spec-kit/releases" in out + assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out + + diff --git a/tests/test_self_upgrade_guidance.py b/tests/test_self_upgrade_guidance.py new file mode 100644 index 0000000000..1ba90c9885 --- /dev/null +++ b/tests/test_self_upgrade_guidance.py @@ -0,0 +1,184 @@ +"""Non-upgradable path guidance tests for `specify self upgrade`.""" + +from tests.self_upgrade_helpers import ( + app, + mock_urlopen_response, + patch, + runner, + strip_ansi, +) + +pytest_plugins = ("tests.self_upgrade_fixtures",) + +# =========================================================================== +# Phase 5 — User Story 3: non-upgradable path guidance (P3) +# =========================================================================== + + +class TestUvxEphemeral: + """uvx ephemeral path emits exact one-liner, no installer call.""" + + def test_uvx_argv0_prints_exact_one_liner_and_exits_zero( + self, + uvx_ephemeral_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + expected = ( + "Running via uvx (ephemeral); the next uvx invocation already " + "resolves to latest — no upgrade action needed." + ) + assert expected in strip_ansi(result.output) + assert mock_run.call_count == 0 + + def test_offline_still_exits_zero_without_tag_resolution( + self, + uvx_ephemeral_argv0, + clean_environ, + ): + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=AssertionError("non-upgradable uvx path must not hit network"), + ): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + assert "uvx (ephemeral)" in strip_ansi(result.output) + + +class TestSourceCheckout: + """Editable install path emits git pull guidance.""" + + def test_source_checkout_prints_git_pull_guidance( + self, + unsupported_argv0, + tmp_path, + clean_environ, + ): + fake_tree = tmp_path / "worktree" + fake_tree.mkdir() + (fake_tree / ".git").mkdir() + + with patch("specify_cli._version._editable_marker_seen", return_value=True), patch( + "specify_cli._version._source_checkout_path", return_value=fake_tree + ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert f"Running from a source checkout at {fake_tree}" in out + assert "git pull" in out + assert "pip install -e ." in out + assert mock_run.call_count == 0 + + def test_source_checkout_without_path_mentions_checkout_directory( + self, + unsupported_argv0, + clean_environ, + ): + with patch("specify_cli._version._editable_marker_seen", return_value=True), patch( + "specify_cli._version._source_checkout_path", return_value=None + ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + out = strip_ansi(result.output) + assert result.exit_code == 0 + assert "checkout path could not be detected" in out + assert "from your checkout directory" in out + assert "(path unavailable)" not in out + assert mock_run.call_count == 0 + + +class TestUnsupported: + """Unsupported path enumerates manual reinstall commands.""" + + def test_unsupported_prints_both_reinstall_commands( + self, + unsupported_argv0, + clean_environ, + ): + with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( + "specify_cli._version.shutil.which", return_value=None + ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run: + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Could not identify your install method automatically" in out + assert ( + "uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@vX.Y.Z" + ) in out + assert ( + "pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z" + in out + ) + assert mock_run.call_count == 0 + + def test_unsupported_offline_degrades_to_placeholder_manual_commands( + self, + unsupported_argv0, + clean_environ, + ): + with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( + "specify_cli._version.shutil.which", return_value=None + ), patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=AssertionError("unsupported guidance should not require network"), + ): + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Could not identify your install method automatically" in out + assert ( + "uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@vX.Y.Z" + ) in out + assert ( + "pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z" + in out + ) + + +class TestDryRunNonUpgradablePaths: + """--dry-run on non-upgradable paths emits guidance, not preview.""" + + def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview( + self, + uvx_ephemeral_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen: + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Dry run — no changes will be made." not in out + assert "uvx (ephemeral)" in out + + def test_dry_run_on_unsupported_emits_manual_commands( + self, + unsupported_argv0, + clean_environ, + ): + with patch("specify_cli._version._editable_marker_seen", return_value=False), patch( + "specify_cli._version.shutil.which", return_value=None + ), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen: + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + assert result.exit_code == 0 + assert "Could not identify your install method" in strip_ansi(result.output) diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py new file mode 100644 index 0000000000..bd3fb51827 --- /dev/null +++ b/tests/test_self_upgrade_verification.py @@ -0,0 +1,520 @@ +"""Verification, resolution, and validation tests for `specify self upgrade`.""" + +from tests.self_upgrade_helpers import ( + SENTINEL_GH_TOKEN, + SENTINEL_GITHUB_TOKEN, + _InstallMethod, + _UpgradePlan, + _completed_process, + _verify_upgrade, + app, + mock_urlopen_response, + patch, + runner, + specify_cli, + strip_ansi, + urllib, +) +import pytest + +pytest_plugins = ("tests.self_upgrade_fixtures",) + +# =========================================================================== +# Phase 6 — User Story 4: failure recovery (P2) +# =========================================================================== + + +class TestVerificationMismatch: + """Installer says 0 but the binary is still the old version → exit 2.""" + + def test_installer_ok_but_verify_returns_old_version( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), # installer OK + _completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD! + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Verification failed" in out + assert "resolves to 0.7.5 (expected v0.7.6)" in out + assert "The new version may take effect on your next invocation." in out + + def test_verify_nonzero_exit_is_not_treated_as_success( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(1, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Verification failed" in out + assert "(unknown) (expected v0.7.6)" in out + + def test_verify_accepts_pep440_equivalent_rc_version( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.9.0" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 1.0.0rc1\n"), + ] + result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) + + assert result.exit_code == 0 + assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output) + + def test_verify_accepts_specify_cli_binary_name_in_version_output( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify-cli version 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output) + + def test_verify_rejects_output_without_parseable_version( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify version unknown\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Verification failed" in out + assert "(unknown) (expected v0.7.6)" in out + + def test_verify_uses_current_entrypoint_when_not_on_path( + self, + uv_tool_argv0, + clean_environ, + ): + assert uv_tool_argv0.exists() + assert uv_tool_argv0.is_file() + + plan = _UpgradePlan( + method=_InstallMethod.UV_TOOL, + current_version="0.7.5", + target_tag="v0.7.6", + installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], + preview_summary="", + pre_upgrade_snapshot="0.7.5", + ) + + with patch( + "specify_cli._version.shutil.which", side_effect=lambda name: None + ), patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.os.access", return_value=True + ): + mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") + verified = _verify_upgrade(plan) + + assert verified == "0.7.6" + assert mock_run.call_args.args[0][0] == str(uv_tool_argv0) + + def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable( + self, + uv_tool_argv0, + clean_environ, + ): + plan = _UpgradePlan( + method=_InstallMethod.UV_TOOL, + current_version="0.7.5", + target_tag="v0.7.6", + installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], + preview_summary="", + pre_upgrade_snapshot="0.7.5", + ) + + with patch( + "specify_cli._version.shutil.which", + side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None, + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version.os.access", return_value=False + ): + mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") + verified = _verify_upgrade(plan) + + assert verified == "0.7.6" + assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify" + + def test_verify_ignores_python_entrypoint_and_falls_back_to_specify( + self, + clean_environ, + tmp_path, + ): + fake_python = tmp_path / "python3" + fake_python.write_text("#!/bin/sh\n") + fake_python.chmod(0o755) + + plan = _UpgradePlan( + method=_InstallMethod.UV_TOOL, + current_version="0.7.5", + target_tag="v0.7.6", + installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], + preview_summary="", + pre_upgrade_snapshot="0.7.5", + ) + + with patch( + "specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version.sys.argv", [str(fake_python)] + ), patch( + "specify_cli._version.os.access", return_value=True + ): + mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") + verified = _verify_upgrade(plan) + + assert verified == "0.7.6" + assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify" + + def test_verify_accepts_specify_cli_named_current_entrypoint( + self, + clean_environ, + tmp_path, + ): + fake_specify_cli = tmp_path / "specify-cli" + fake_specify_cli.write_text("#!/bin/sh\n") + fake_specify_cli.chmod(0o755) + + plan = _UpgradePlan( + method=_InstallMethod.UV_TOOL, + current_version="0.7.5", + target_tag="v0.7.6", + installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], + preview_summary="", + pre_upgrade_snapshot="0.7.5", + ) + + with patch("specify_cli._version.shutil.which", return_value=None), patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch( + "specify_cli._version.os.access", return_value=True + ): + mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") + verified = _verify_upgrade(plan) + + assert verified == "0.7.6" + assert mock_run.call_args.args[0][0] == str(fake_specify_cli) + + +class TestResolutionFailures: + """Pre-installer resolution failure → exit 1, reusing the resolver category strings.""" + + def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ): + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=urllib.error.URLError("nope"), + ): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 1 + assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output) + + def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ): + err = urllib.error.HTTPError( + url="https://api.github.com", + code=403, + msg="rate limited", + hdrs={}, # type: ignore[arg-type] + fp=None, + ) + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 1 + assert ( + "Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)" + in strip_ansi(result.output) + ) + + def test_http_500_exits_1(self, uv_tool_argv0, clean_environ): + err = urllib.error.HTTPError( + url="https://api.github.com", + code=500, + msg="srv err", + hdrs={}, # type: ignore[arg-type] + fp=None, + ) + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 1 + assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output) + + def test_unparseable_resolved_release_tag_exits_1_without_traceback( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 1 + out = strip_ansi(result.output) + assert "resolved release tag is not a comparable version" in out + assert "release-main" not in out + assert "Traceback" not in out + assert mock_run.call_count == 0 + + +class TestTagValidation: + """--tag regex enforcement.""" + + def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.7.6"], + ) + assert result.exit_code == 0 + + def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"], + ) + assert result.exit_code == 0 + assert "Target version: v0.8.0.dev0" in strip_ansi(result.output) + + def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"], + ) + assert result.exit_code == 0 + + def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="1.0.0b1" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--tag", "v1.0.0-beta.1"], + ) + assert result.exit_code == 0 + assert "Already on requested release: v1.0.0-beta.1" in strip_ansi( + result.output + ) + + def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + result = runner.invoke( + app, + ["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"], + ) + assert result.exit_code == 0 + assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) + + @pytest.mark.parametrize( + "bad_tag", + ["latest", "0.7.5", "main", "v7", "", "v1.2.3abc", "v1.2.3...", "v1.2.3++"], + ) + def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ): + result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag]) + assert result.exit_code == 1 + output = strip_ansi(result.output) + assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output + + +class TestUnknownCurrent: + """'unknown' current version renders literally in notice and success message.""" + + def test_unknown_current_renders_literal_in_notice( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="unknown" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out + assert "Upgraded specify-cli: unknown → 0.7.6" in out + + def test_unknown_current_rollback_hint_degrades( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="unknown" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [_completed_process(2)] # installer fails + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 2 + out = strip_ansi(result.output) + assert "Could not determine the previous version" in out + assert "https://github.com/github/spec-kit/releases" in out + + +class TestTokenScrubbing: + """GH_TOKEN / GITHUB_TOKEN are stripped from every child env.""" + + def test_env_passed_to_subprocess_has_no_github_tokens( + self, + uv_tool_argv0, + monkeypatch, + ): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + runner.invoke(app, ["self", "upgrade"]) + + assert mock_run.call_count >= 1 + for call in mock_run.call_args_list: + env_kwarg = call.kwargs.get("env") or {} + assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}" + assert "GITHUB_TOKEN" not in env_kwarg + for v in env_kwarg.values(): + assert SENTINEL_GH_TOKEN not in v + assert SENTINEL_GITHUB_TOKEN not in v + + def test_env_scrubbing_is_case_insensitive( + self, + uv_tool_argv0, + monkeypatch, + ): + monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN) + monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN) + + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="specify 0.7.6\n"), + ] + runner.invoke(app, ["self", "upgrade"]) + + assert mock_run.call_count >= 1 + for call in mock_run.call_args_list: + env_kwarg = call.kwargs.get("env") or {} + assert "gh_token" not in env_kwarg + assert "GitHub_Token" not in env_kwarg + for v in env_kwarg.values(): + assert SENTINEL_GH_TOKEN not in v + assert SENTINEL_GITHUB_TOKEN not in v + + def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): + monkeypatch.setenv("GH_PAT", "gh-pat") + monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh") + monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret") + monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key") + monkeypatch.setenv("GITHUB_PAT", "github-pat") + monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github") + monkeypatch.setenv("GITHUB_API_TOKEN", "api-token") + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key") + monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret") + monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token") + monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept") + monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept") + monkeypatch.setenv("UNRELATED_TOKEN", "kept") + + env = specify_cli._version._scrubbed_env() + + assert "GH_PAT" not in env + assert "GH_ENTERPRISE_TOKEN" not in env + assert "GH_ENTERPRISE_SECRET" not in env + assert "GH_ENTERPRISE_PRIVATE_KEY" not in env + assert "GITHUB_PAT" not in env + assert "GITHUB_ENTERPRISE_TOKEN" not in env + assert "GITHUB_API_TOKEN" not in env + assert "GITHUB_APP_PRIVATE_KEY" not in env + assert "GITHUB_OAUTH_CLIENT_SECRET" not in env + assert "HOMEBREW_GITHUB_API_TOKEN" not in env + assert env["GHOST_API_TOKEN"] == "ghost-kept" + assert env["GHIDRA_API_KEY"] == "ghidra-kept" + assert env["UNRELATED_TOKEN"] == "kept" From b6a7a04c5aac04828852681e1ffe178566e90575 Mon Sep 17 00:00:00 2001 From: pli Date: Tue, 26 May 2026 21:02:58 +0900 Subject: [PATCH 21/27] fix: address self-upgrade review edge cases --- docs/upgrade.md | 8 ++- src/specify_cli/_version.py | 4 +- tests/test_self_upgrade_detection.py | 80 ++++++++++++------------- tests/test_self_upgrade_execution.py | 20 +++---- tests/test_self_upgrade_verification.py | 30 +++++----- 5 files changed, 71 insertions(+), 71 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index ba9b230341..5bdea641d6 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -9,7 +9,7 @@ | What to Upgrade | Command | When to Use | |----------------|---------|-------------| | **CLI Tool (recommended)** | `specify self upgrade` | Latest stable release, in place. Auto-detects whether you installed via `uv tool` or `pipx`. | -| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z` | Upgrade to a specific release tag instead of the latest stable. Replace `vX.Y.Z` with the release tag you want. | +| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Replace `vX.Y.Z[suffix]` with the release tag you want. | | **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. | | **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. | | **Project Files** | `specify init --here --force --integration ` | Update slash commands, templates, and scripts in your project | @@ -35,12 +35,14 @@ specify self upgrade --dry-run # Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install) specify self upgrade -# Or pin a specific release tag (replace vX.Y.Z with the release tag you want) -specify self upgrade --tag vX.Y.Z +# Or pin a specific release tag (replace vX.Y.Z[suffix] with the tag you want) +specify self upgrade --tag vX.Y.Z[suffix] ``` Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything. +Pinned tags accept the same release-tag form that Spec Kit publishes, including prerelease and build suffixes such as `v1.0.0-rc1`, `v0.8.0.dev0`, or `v0.8.0+build.42`. + Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases. If your installed CLI is older than the release that introduced `specify self upgrade`, use the manual equivalents below. These commands are also useful when you want explicit control over the installer command. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 80331d99e8..0222af141e 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -756,9 +756,7 @@ def _run_installer(plan: _UpgradePlan) -> _InstallerResult: installer_cmd = Path(installer_name) if installer_cmd.is_absolute(): if not installer_cmd.exists(): - binary_name = _installer_binary_name(plan.method) - if binary_name is None or shutil.which(binary_name) != installer_name: - return _InstallerResult(_InstallerResultKind.MISSING) + 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): diff --git a/tests/test_self_upgrade_detection.py b/tests/test_self_upgrade_detection.py index 64cc97829e..4be8fd98ea 100644 --- a/tests/test_self_upgrade_detection.py +++ b/tests/test_self_upgrade_detection.py @@ -86,10 +86,10 @@ def test_tier3_uv_tool_when_registry_lists_exact_name( monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) def fake_which(name): - return "/usr/bin/uv" if name == "uv" else None + return "uv" if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + if argv[:3] == ["uv", "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -112,7 +112,7 @@ def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch) monkeypatch.setattr("sys.argv", ["specify"]) def fake_which(name): - return "/usr/bin/uv" if name == "uv" else None + return "uv" if name == "uv" else None def fake_run(argv, *args, **kwargs): return subprocess.CompletedProcess( @@ -139,11 +139,11 @@ def fake_which(name): if name == "specify": return str(missing_specify) if name == "uv": - return "/usr/bin/uv" + return "uv" return None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + if argv[:3] == ["uv", "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -192,10 +192,10 @@ def test_tier3_uv_tool_ignores_substring_false_positive( unsupported_argv0, ): def fake_which(name): - return "/usr/bin/uv" if name == "uv" else None + return "uv" if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + if argv[:3] == ["uv", "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -217,10 +217,10 @@ def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint( unsupported_argv0, ): def fake_which(name): - return "/usr/bin/uv" if name == "uv" else None + return "uv" if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + if argv[:3] == ["uv", "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -253,11 +253,11 @@ def fake_which(name): if name == "specify": return str(fake_specify) if name == "uv": - return "/usr/bin/uv" + return "uv" return None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + if argv[:3] == ["uv", "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -320,10 +320,10 @@ class TestArgvAssemblyUvTool: """uv-tool installer argv shape.""" def test_stable_tag_produces_expected_argv(self): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"): + with patch("specify_cli._version.shutil.which", return_value="uv"): argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") assert argv == [ - "/usr/bin/uv", + "uv", "tool", "install", "specify-cli", @@ -333,7 +333,7 @@ def test_stable_tag_produces_expected_argv(self): ] def test_dev_suffix_tag_embedded_literally(self): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"): + with patch("specify_cli._version.shutil.which", return_value="uv"): argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0") assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv assert ( @@ -350,7 +350,7 @@ class TestBareUpgradeUvTool: def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -373,7 +373,7 @@ def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ): # The single `invoke` represents the single user action — no prompt. # If a prompt existed, runner.invoke would hang waiting for input. with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -395,7 +395,7 @@ def test_already_latest_exits_zero_no_subprocess( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.6"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -410,7 +410,7 @@ def test_dev_build_ahead_of_release_reports_newer_noop( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -425,7 +425,7 @@ def test_unparseable_current_version_does_not_false_noop( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="release-main"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ @@ -446,7 +446,7 @@ def test_unparseable_resolved_target_fails_before_literal_noop( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="release-main"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -461,7 +461,7 @@ def test_unparseable_resolved_target_fails_before_literal_noop( def test_pinned_older_tag_still_runs_installer( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.6" @@ -481,7 +481,7 @@ def test_pinned_older_tag_still_runs_installer( def test_pinned_rc_tag_uses_canonical_version_equality_for_noop( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="1.0.0rc1" ): result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) @@ -501,7 +501,7 @@ def test_dry_run_without_tag_resolves_network_but_no_subprocess( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) @@ -519,7 +519,7 @@ def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): # --dry-run with --tag must NOT hit the network. with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" - ), patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + ), patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -536,7 +536,7 @@ def test_dry_run_rejects_unparseable_network_tag_before_preview( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response( {"tag_name": "v0.9.0;echo unsafe"} @@ -589,10 +589,10 @@ def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it( monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) def fake_which(name): - return "/usr/bin/pipx" if name == "pipx" else None + return "pipx" if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + if argv[:3] == ["pipx", "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -616,10 +616,10 @@ def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint( unsupported_argv0, ): def fake_which(name): - return "/usr/bin/pipx" if name == "pipx" else None + return "pipx" if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + if argv[:3] == ["pipx", "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -641,10 +641,10 @@ def test_tier3_pipx_ignores_malformed_json_output( unsupported_argv0, ): def fake_which(name): - return "/usr/bin/pipx" if name == "pipx" else None + return "pipx" if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + if argv[:3] == ["pipx", "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -670,20 +670,20 @@ def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported( def fake_which(name): if name == "uv": - return "/usr/bin/uv" + return "uv" if name == "pipx": - return "/usr/bin/pipx" + return "pipx" return None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["/usr/bin/uv", "tool", "list"]: + if argv[:3] == ["uv", "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, stdout="specify-cli v0.7.6\n", stderr="", ) - if argv[:3] == ["/usr/bin/pipx", "list", "--json"]: + if argv[:3] == ["pipx", "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -774,7 +774,7 @@ def locate_file(self, file): class TestTagValidationWhitespace: def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -793,10 +793,10 @@ class TestArgvAssemblyPipx: """pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`.""" def test_pipx_argv_uses_install_force_positional_not_upgrade(self): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/pipx"): + with patch("specify_cli._version.shutil.which", return_value="pipx"): argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") assert argv == [ - "/usr/bin/pipx", + "pipx", "install", "--force", "git+https://github.com/github/spec-kit.git@v0.7.6", @@ -814,7 +814,7 @@ class TestBareUpgradePipx: def test_happy_path(self, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + "specify_cli._version.shutil.which", return_value="pipx" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -850,7 +850,7 @@ def test_pipx_argv0_prefix_short_circuits_before_registry_checks( class TestDryRunPipx: def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + "specify_cli._version.shutil.which", return_value="pipx" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): diff --git a/tests/test_self_upgrade_execution.py b/tests/test_self_upgrade_execution.py index 0be64f8e06..b1d2c6dacd 100644 --- a/tests/test_self_upgrade_execution.py +++ b/tests/test_self_upgrade_execution.py @@ -229,7 +229,7 @@ def test_real_installer_exit_126_is_not_treated_as_invalid_path( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -305,7 +305,7 @@ def test_bare_invalid_installer_message_does_not_call_it_a_path( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", return_value=[ @@ -391,7 +391,7 @@ class TestInstallerFailed: def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -414,7 +414,7 @@ def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -428,7 +428,7 @@ def test_installer_timeout_prints_timeout_specific_message( ): monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12") with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -447,7 +447,7 @@ def test_non_finite_timeout_warns_and_runs_without_timeout( ): monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan") with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -468,7 +468,7 @@ def test_real_installer_exit_124_is_not_treated_as_timeout( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -482,7 +482,7 @@ def test_real_installer_exit_124_is_not_treated_as_timeout( def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/pipx" + "specify_cli._version.shutil.which", return_value="pipx" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -500,7 +500,7 @@ def test_rollback_hint_accepts_normalizable_stable_snapshot( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="v0.7.5" ): @@ -520,7 +520,7 @@ def test_prerelease_failure_degrades_rollback_hint_to_releases_page( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="1.0.0rc1" ): diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index bd3fb51827..35bd73ba69 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -33,7 +33,7 @@ def test_installer_ok_but_verify_returns_old_version( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -56,7 +56,7 @@ def test_verify_nonzero_exit_is_not_treated_as_success( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -78,7 +78,7 @@ def test_verify_accepts_pep440_equivalent_rc_version( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.9.0" ): @@ -98,7 +98,7 @@ def test_verify_accepts_specify_cli_binary_name_in_version_output( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -118,7 +118,7 @@ def test_verify_rejects_output_without_parseable_version( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -298,7 +298,7 @@ def test_unparseable_resolved_release_tag_exits_1_without_traceback( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -315,7 +315,7 @@ class TestTagValidation: """--tag regex enforcement.""" def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -325,7 +325,7 @@ def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): assert result.exit_code == 0 def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -336,7 +336,7 @@ def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): assert "Target version: v0.8.0.dev0" in strip_ansi(result.output) def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -348,7 +348,7 @@ def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="1.0.0b1" ): result = runner.invoke( @@ -361,7 +361,7 @@ def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( ) def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="/usr/bin/uv"), patch( + with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -391,7 +391,7 @@ def test_unknown_current_renders_literal_in_notice( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): @@ -413,7 +413,7 @@ def test_unknown_current_rollback_hint_degrades( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): @@ -439,7 +439,7 @@ def test_env_passed_to_subprocess_has_no_github_tokens( monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -468,7 +468,7 @@ def test_env_scrubbing_is_case_insensitive( monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="/usr/bin/uv" + "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): From b02df9cd2522a0cfb3c58fb99334d3b17dcb33bb Mon Sep 17 00:00:00 2001 From: pli Date: Wed, 27 May 2026 22:03:12 +0900 Subject: [PATCH 22/27] fix: address self-upgrade review docs --- README.md | 2 +- docs/upgrade.md | 4 ++-- src/specify_cli/_version.py | 21 ++++++++------------- tests/test_self_upgrade_execution.py | 2 -- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f79c96f9b5..9426287dec 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Run `specify integration list` to see all available integrations in your install After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration --integration-options="--skills"` installs agent skills instead of slash-command prompt files. -#### Core Commands +### Core Commands Essential commands for the Spec-Driven Development workflow: diff --git a/docs/upgrade.md b/docs/upgrade.md index 5bdea641d6..7f8a4c8ae9 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -9,7 +9,7 @@ | What to Upgrade | Command | When to Use | |----------------|---------|-------------| | **CLI Tool (recommended)** | `specify self upgrade` | Latest stable release, in place. Auto-detects whether you installed via `uv tool` or `pipx`. | -| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Replace `vX.Y.Z[suffix]` with the release tag you want. | +| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Suffixes are limited to dev, alpha/beta/rc, or build metadata forms. | | **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. | | **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. | | **Project Files** | `specify init --here --force --integration ` | Update slash commands, templates, and scripts in your project | @@ -41,7 +41,7 @@ specify self upgrade --tag vX.Y.Z[suffix] Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything. -Pinned tags accept the same release-tag form that Spec Kit publishes, including prerelease and build suffixes such as `v1.0.0-rc1`, `v0.8.0.dev0`, or `v0.8.0+build.42`. +Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, or `v0.8.0+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 0222af141e..25db15bdc6 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -156,11 +156,6 @@ def _stable_release_tag_for_version(version_text: str) -> str | None: return f"v{release[0]}.{release[1]}.{release[2]}" -def _is_comparable_version_text(value: str) -> bool: - """Return whether version-like text parses under PEP 440 after tag normalization.""" - return _parse_version_text(value) is not None - - 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) @@ -273,9 +268,10 @@ def _scrubbed_env() -> dict[str, str]: def _validate_tag(tag: str) -> str: """Validate a user-supplied --tag value. - Accepts vX.Y.Z plus optional PEP-440-ish suffix (dev0, rc1, beta.1, - +build.42). Rejects everything else (including bare 'latest', hash refs, - branch names, or a numeric version without the 'v' prefix). + Accepts vX.Y.Z plus optional dev, alpha/beta/rc, or build-metadata suffixes + (for example: v1.0.0-rc1, v0.8.0.dev0, v0.8.0+build.42). Rejects + everything else, including bare 'latest', hash refs, branch names, and + numeric versions without the 'v' prefix. """ tag = tag.strip() if not tag: @@ -1163,9 +1159,6 @@ def self_check() -> None: return # Installed is parseable AND is >= latest → "up to date" (FR-006). - # Also reached when the tag is unparseable (InvalidVersion) → _is_newer - # returns False, and the up-to-date branch is the safer default per - # FR-004 / test T016. console.print(f"[green]Up to date:[/green] {installed}") @@ -1200,8 +1193,10 @@ def self_upgrade( 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 (installer exited 0 but `specify --version` - does not resolve to the target tag) + 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, diff --git a/tests/test_self_upgrade_execution.py b/tests/test_self_upgrade_execution.py index b1d2c6dacd..7c16bf1b4a 100644 --- a/tests/test_self_upgrade_execution.py +++ b/tests/test_self_upgrade_execution.py @@ -533,5 +533,3 @@ def test_prerelease_failure_degrades_rollback_hint_to_releases_page( assert "Previous version was not an exact stable release tag" in out assert "https://github.com/github/spec-kit/releases" in out assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out - - From 839d029249e58be883b98c06a94625a7c20ca639 Mon Sep 17 00:00:00 2001 From: pli Date: Wed, 27 May 2026 22:32:50 +0900 Subject: [PATCH 23/27] fix: refine self-upgrade review followups --- README.md | 6 +++--- src/specify_cli/_version.py | 4 +++- tests/test_self_upgrade_verification.py | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9426287dec..79668d1654 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,11 @@ specify self upgrade --dry-run # Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install) specify self upgrade -# Or pin a specific release tag (replace vX.Y.Z with your desired release tag) -specify self upgrade --tag vX.Y.Z +# Or pin a specific release tag (replace vX.Y.Z[suffix] with your desired release tag) +specify self upgrade --tag vX.Y.Z[suffix] ``` -Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. For `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). +Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. For `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work, including dev, alpha/beta/rc, or build metadata suffixes. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). ### 3. Establish project principles diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 25db15bdc6..cd461a66d8 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -243,7 +243,9 @@ def _is_github_credential_env_key(key: str) -> bool: """Return whether an env key looks like a GitHub credential.""" upper = key.upper() return ( - upper.startswith("GH_") or "GITHUB" in upper + upper.startswith("GH_") + or upper.startswith("GITHUB_") + or "_GITHUB_" in upper ) and upper.endswith(_GITHUB_CREDENTIAL_SUFFIXES) diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index 35bd73ba69..29955608db 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -499,6 +499,7 @@ def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key") monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret") monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token") + monkeypatch.setenv("NOTGITHUB_TOKEN", "not-github-kept") monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept") monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept") monkeypatch.setenv("UNRELATED_TOKEN", "kept") @@ -515,6 +516,7 @@ def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): assert "GITHUB_APP_PRIVATE_KEY" not in env assert "GITHUB_OAUTH_CLIENT_SECRET" not in env assert "HOMEBREW_GITHUB_API_TOKEN" not in env + assert env["NOTGITHUB_TOKEN"] == "not-github-kept" assert env["GHOST_API_TOKEN"] == "ghost-kept" assert env["GHIDRA_API_KEY"] == "ghidra-kept" assert env["UNRELATED_TOKEN"] == "kept" From 46ddfa85a632bf5c7ea8b07ae6e098e4a69d00fa Mon Sep 17 00:00:00 2001 From: pli Date: Wed, 27 May 2026 22:57:16 +0900 Subject: [PATCH 24/27] fix: address self-upgrade review cleanup --- src/specify_cli/_version.py | 15 ++++++++------- tests/test_self_upgrade_verification.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index cd461a66d8..b039d68d1b 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -43,6 +43,11 @@ _FAILURE_INSTALLER_TIMEOUT = "installer-timeout" _FAILURE_INSTALLER_FAILED = "installer-failed" _FAILURE_VERIFICATION_MISMATCH = "verification-mismatch" +_PRERELEASE_TAG_PATTERN = re.compile( + r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|a|b|rc)[-.]?(\d+)(.*)$", + flags=re.IGNORECASE, +) +_TIER3_REGISTRY_TIMEOUT_SECS = 5 def _get_installed_version() -> str: @@ -70,11 +75,7 @@ def _get_installed_version() -> str: def _normalize_tag(tag: str) -> str: """Normalize common git release-tag spellings into PEP 440 text.""" normalized = tag[1:] if tag.startswith("v") else tag - prerelease_match = re.match( - r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|a|b|rc)[-.]?(\d+)(.*)$", - normalized, - flags=re.IGNORECASE, - ) + prerelease_match = _PRERELEASE_TAG_PATTERN.match(normalized) if prerelease_match is None: return normalized @@ -490,7 +491,7 @@ def _detect_install_method( [uv_bin, "tool", "list"], capture_output=True, text=True, - timeout=5, + timeout=_TIER3_REGISTRY_TIMEOUT_SECS, env=_scrubbed_env(), check=False, ) @@ -510,7 +511,7 @@ def _detect_install_method( [pipx_bin, "list", "--json"], capture_output=True, text=True, - timeout=5, + timeout=_TIER3_REGISTRY_TIMEOUT_SECS, env=_scrubbed_env(), check=False, ) diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index 29955608db..58bf6809bf 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -437,13 +437,17 @@ def test_env_passed_to_subprocess_has_no_github_tokens( ): monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + response = mock_urlopen_response({"tag_name": "v0.7.6"}) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.authentication.http.urllib.request.build_opener" + ) as mock_build_opener, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = response + mock_build_opener.return_value.open.return_value = response mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), @@ -466,13 +470,17 @@ def test_env_scrubbing_is_case_insensitive( ): monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN) monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN) + response = mock_urlopen_response({"tag_name": "v0.7.6"}) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.authentication.http.urllib.request.build_opener" + ) as mock_build_opener, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): - mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_urlopen.return_value = response + mock_build_opener.return_value.open.return_value = response mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), From e3ecd2c44c54f032d6ddee637bcd247ea99ab84a Mon Sep 17 00:00:00 2001 From: pli Date: Wed, 27 May 2026 23:09:41 +0900 Subject: [PATCH 25/27] fix: handle self-upgrade review edge cases --- src/specify_cli/_version.py | 6 +++++- tests/test_self_upgrade_verification.py | 20 ++++++++++++++++++++ tests/test_upgrade.py | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index b039d68d1b..c350e36eef 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -799,7 +799,10 @@ def _run_installer(plan: _UpgradePlan) -> _InstallerResult: raise -_VERIFY_VERSION_LINE_RE = re.compile(r"^\s*(?:specify|specify-cli)\b(?P.*)$") +_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: @@ -1127,6 +1130,7 @@ def self_check() -> None: 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( diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index 58bf6809bf..6ddabdb82d 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -112,6 +112,26 @@ def test_verify_accepts_specify_cli_binary_name_in_version_output( assert result.exit_code == 0 assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output) + def test_verify_accepts_capitalized_binary_name_in_version_output( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) + mock_run.side_effect = [ + _completed_process(0), + _completed_process(0, stdout="Specify, version 0.7.6\n"), + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output) + def test_verify_rejects_output_without_parseable_version( self, uv_tool_argv0, diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 0023ac7033..3ad8c84f62 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -181,6 +181,7 @@ def test_unparseable_tag_reports_validation_failure_without_raw_tag(self): assert "Update available" not in output assert "Up to date" not in output assert "Could not validate latest release tag from GitHub." in output + assert "Latest release: vX.Y.Z" in output assert "0.7.4" in output assert "not-a-version" not in output assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output From 6609ebe4604e0b246acfc38b5882cf21ea559a0a Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 28 May 2026 07:18:54 +0900 Subject: [PATCH 26/27] fix: address self-upgrade review nits --- src/specify_cli/_version.py | 9 +++++---- tests/self_upgrade_fixtures.py | 2 +- tests/test_self_upgrade_verification.py | 14 +++++++++++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index c350e36eef..997a0a575a 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -44,10 +44,11 @@ _FAILURE_INSTALLER_FAILED = "installer-failed" _FAILURE_VERIFICATION_MISMATCH = "verification-mismatch" _PRERELEASE_TAG_PATTERN = re.compile( - r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|a|b|rc)[-.]?(\d+)(.*)$", + 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: @@ -261,8 +262,8 @@ def _scrubbed_env() -> dict[str, str]: _TAG_REGEX = re.compile( - r"^v\d+\.\d+\.\d+" - r"(?:(?:\.?dev\d+)|(?:[-.]?(?:a|b|rc|alpha|beta)[-.]?\d+)|" + 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]" @@ -848,7 +849,7 @@ def _verify_upgrade(plan: _UpgradePlan) -> str | None: check=False, capture_output=True, text=True, - timeout=10, + timeout=_VERIFY_TIMEOUT_SECS, env=_scrubbed_env(), ) except (subprocess.TimeoutExpired, OSError): diff --git a/tests/self_upgrade_fixtures.py b/tests/self_upgrade_fixtures.py index 2b2db3dd19..3092100a8d 100644 --- a/tests/self_upgrade_fixtures.py +++ b/tests/self_upgrade_fixtures.py @@ -17,7 +17,7 @@ def _fake_argv0(monkeypatch, tmp_path, env_name, path_parts): monkeypatch.setenv(env_name, str(tmp_path)) fake_dir = tmp_path.joinpath(*path_parts) fake_dir.mkdir(parents=True) - fake_specify = fake_dir / "specify" + fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify") fake_specify.write_text("#!/usr/bin/env python\n") fake_specify.chmod(0o755) monkeypatch.setattr("sys.argv", [str(fake_specify)]) diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index 6ddabdb82d..7bee64779a 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -183,6 +183,7 @@ def test_verify_uses_current_entrypoint_when_not_on_path( assert verified == "0.7.6" assert mock_run.call_args.args[0][0] == str(uv_tool_argv0) + assert mock_run.call_args.kwargs["timeout"] == specify_cli._version._VERIFY_TIMEOUT_SECS def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable( self, @@ -393,7 +394,18 @@ def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): @pytest.mark.parametrize( "bad_tag", - ["latest", "0.7.5", "main", "v7", "", "v1.2.3abc", "v1.2.3...", "v1.2.3++"], + [ + "latest", + "0.7.5", + "main", + "v7", + "", + "v1.2.3abc", + "v1.2.3...", + "v1.2.3++", + "v\uff11.2.3", + "v1.\u0662.3", + ], ) def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ): result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag]) From 3ff536967eec712fd4cdb912de3b0790f5c393c2 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 28 May 2026 07:34:12 +0900 Subject: [PATCH 27/27] fix: address follow-up self-upgrade review --- src/specify_cli/_version.py | 18 ++++-- tests/conftest.py | 69 ++++++++++++++++++++++ tests/self_upgrade_fixtures.py | 77 ------------------------- tests/self_upgrade_helpers.py | 17 ------ tests/test_self_upgrade_detection.py | 18 +++--- tests/test_self_upgrade_execution.py | 12 ++-- tests/test_self_upgrade_guidance.py | 8 +-- tests/test_self_upgrade_verification.py | 18 +++--- 8 files changed, 111 insertions(+), 126 deletions(-) delete mode 100644 tests/self_upgrade_fixtures.py diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 997a0a575a..ed7eb8f599 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -74,7 +74,11 @@ def _get_installed_version() -> str: def _normalize_tag(tag: str) -> str: - """Normalize common git release-tag spellings into PEP 440 text.""" + """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: @@ -244,11 +248,9 @@ class _DetectionSignals: def _is_github_credential_env_key(key: str) -> bool: """Return whether an env key looks like a GitHub credential.""" upper = key.upper() - return ( - upper.startswith("GH_") - or upper.startswith("GITHUB_") - or "_GITHUB_" in upper - ) and upper.endswith(_GITHUB_CREDENTIAL_SUFFIXES) + if upper.startswith(("GH_", "GITHUB_")): + return True + return "_GITHUB_" in upper and upper.endswith(_GITHUB_CREDENTIAL_SUFFIXES) def _scrubbed_env() -> dict[str, str]: @@ -1280,6 +1282,8 @@ def self_upgrade( 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) target_canonical = str(target_version) @@ -1297,6 +1301,8 @@ def self_upgrade( 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; an + # unparseable current version deliberately proceeds to installation. if tag is not None and current_canonical == target_canonical: console.print(f"Already on requested release: {target_tag}") raise typer.Exit(0) diff --git a/tests/conftest.py b/tests/conftest.py index 0e568a1e2a..4ef643e121 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -81,3 +81,72 @@ def _isolate_auth_config(monkeypatch): # Also clear the per-process cache so tests that unset _config_override # won't see a previously cached real-file result. monkeypatch.setattr(_auth_http, "_config_cache", None) + + +@pytest.fixture +def clean_environ(monkeypatch): + """Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment.""" + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + +def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts): + """Create a fake executable under tmp_path and point sys.argv[0] at it.""" + monkeypatch.setenv(env_name, str(tmp_path)) + fake_dir = tmp_path.joinpath(*path_parts) + fake_dir.mkdir(parents=True) + fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify") + fake_specify.write_text("#!/usr/bin/env python\n") + fake_specify.chmod(0o755) + monkeypatch.setattr("sys.argv", [str(fake_specify)]) + return fake_specify + + +@pytest.fixture +def uv_tool_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME.""" + if os.name == "nt": + return _fake_self_upgrade_argv0( + monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin") + ) + return _fake_self_upgrade_argv0( + monkeypatch, + tmp_path, + "HOME", + (".local", "share", "uv", "tools", "specify-cli", "bin"), + ) + + +@pytest.fixture +def pipx_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated pipx install path under tmp HOME.""" + if os.name == "nt": + return _fake_self_upgrade_argv0( + monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin") + ) + return _fake_self_upgrade_argv0( + monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin") + ) + + +@pytest.fixture +def uvx_ephemeral_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME.""" + if os.name == "nt": + return _fake_self_upgrade_argv0( + monkeypatch, + tmp_path, + "LOCALAPPDATA", + ("uv", "cache", "archive-v0", "abc123", "bin"), + ) + return _fake_self_upgrade_argv0( + monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin") + ) + + +@pytest.fixture +def unsupported_argv0(monkeypatch, tmp_path): + """Point sys.argv[0] at a path that does not match any installer prefix.""" + return _fake_self_upgrade_argv0( + monkeypatch, tmp_path, "HOME", ("random", "location", "bin") + ) diff --git a/tests/self_upgrade_fixtures.py b/tests/self_upgrade_fixtures.py deleted file mode 100644 index 3092100a8d..0000000000 --- a/tests/self_upgrade_fixtures.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Fixtures for `specify self upgrade` tests.""" - -import os - -import pytest - - -@pytest.fixture -def clean_environ(monkeypatch): - """Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment.""" - monkeypatch.delenv("GH_TOKEN", raising=False) - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - - -def _fake_argv0(monkeypatch, tmp_path, env_name, path_parts): - """Create a fake executable under tmp_path and point sys.argv[0] at it.""" - monkeypatch.setenv(env_name, str(tmp_path)) - fake_dir = tmp_path.joinpath(*path_parts) - fake_dir.mkdir(parents=True) - fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify") - fake_specify.write_text("#!/usr/bin/env python\n") - fake_specify.chmod(0o755) - monkeypatch.setattr("sys.argv", [str(fake_specify)]) - return fake_specify - - -@pytest.fixture -def uv_tool_argv0(monkeypatch, tmp_path): - """Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME. - - Sets the platform-specific home/tool root env so _expand_prefix() resolves - to a path that actually contains the fake binary. This avoids needing a - `_UV_TOOL_ROOT_OVERRIDE` knob in production code. - """ - if os.name == "nt": - return _fake_argv0( - monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin") - ) - return _fake_argv0( - monkeypatch, - tmp_path, - "HOME", - (".local", "share", "uv", "tools", "specify-cli", "bin"), - ) - - -@pytest.fixture -def pipx_argv0(monkeypatch, tmp_path): - """Point sys.argv[0] at a simulated pipx install path under tmp HOME.""" - if os.name == "nt": - return _fake_argv0( - monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin") - ) - return _fake_argv0( - monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin") - ) - - -@pytest.fixture -def uvx_ephemeral_argv0(monkeypatch, tmp_path): - """Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME.""" - if os.name == "nt": - return _fake_argv0( - monkeypatch, - tmp_path, - "LOCALAPPDATA", - ("uv", "cache", "archive-v0", "abc123", "bin"), - ) - return _fake_argv0( - monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin") - ) - - -@pytest.fixture -def unsupported_argv0(monkeypatch, tmp_path): - """Point sys.argv[0] at a path that does not match any installer prefix.""" - return _fake_argv0(monkeypatch, tmp_path, "HOME", ("random", "location", "bin")) diff --git a/tests/self_upgrade_helpers.py b/tests/self_upgrade_helpers.py index 2a2cf43f8b..15c795ea11 100644 --- a/tests/self_upgrade_helpers.py +++ b/tests/self_upgrade_helpers.py @@ -4,18 +4,10 @@ the focused test modules stay isolated from the real environment. """ -import errno -import importlib.metadata -import json -import os import subprocess -import urllib.error -from unittest.mock import patch from typer.testing import CliRunner -import specify_cli -from specify_cli import app from specify_cli._version import ( _InstallMethod, _UpgradePlan, @@ -35,18 +27,9 @@ "_completed_process", "_detect_install_method", "_verify_upgrade", - "app", - "errno", - "importlib", - "json", "mock_urlopen_response", - "os", - "patch", "runner", - "specify_cli", "strip_ansi", - "subprocess", - "urllib", ) runner = CliRunner() diff --git a/tests/test_self_upgrade_detection.py b/tests/test_self_upgrade_detection.py index 4be8fd98ea..80843f3fed 100644 --- a/tests/test_self_upgrade_detection.py +++ b/tests/test_self_upgrade_detection.py @@ -1,24 +1,24 @@ """Detection, argv assembly, and dry-run tests for `specify self upgrade`.""" +import importlib.metadata +import json +import os +import subprocess +from unittest.mock import patch + +import specify_cli +from specify_cli import app + from tests.self_upgrade_helpers import ( _InstallMethod, _assemble_installer_argv, _completed_process, _detect_install_method, - app, - importlib, - json, mock_urlopen_response, - os, - patch, runner, - specify_cli, strip_ansi, - subprocess, ) -pytest_plugins = ("tests.self_upgrade_fixtures",) - class TestDetectionUvTool: """Tier-1 path-prefix detection for uv-tool installs.""" diff --git a/tests/test_self_upgrade_execution.py b/tests/test_self_upgrade_execution.py index 7c16bf1b4a..11285a6d94 100644 --- a/tests/test_self_upgrade_execution.py +++ b/tests/test_self_upgrade_execution.py @@ -1,18 +1,18 @@ """Installer execution, verification, and error-path tests for `specify self upgrade`.""" +import errno +import subprocess +from unittest.mock import patch + +from specify_cli import app + from tests.self_upgrade_helpers import ( _completed_process, - app, - errno, mock_urlopen_response, - patch, runner, strip_ansi, - subprocess, ) -pytest_plugins = ("tests.self_upgrade_fixtures",) - # =========================================================================== # Phase 6 — User Story 4: failure recovery (P2) # =========================================================================== diff --git a/tests/test_self_upgrade_guidance.py b/tests/test_self_upgrade_guidance.py index 1ba90c9885..55d6c2bf7b 100644 --- a/tests/test_self_upgrade_guidance.py +++ b/tests/test_self_upgrade_guidance.py @@ -1,15 +1,15 @@ """Non-upgradable path guidance tests for `specify self upgrade`.""" +from unittest.mock import patch + +from specify_cli import app + from tests.self_upgrade_helpers import ( - app, mock_urlopen_response, - patch, runner, strip_ansi, ) -pytest_plugins = ("tests.self_upgrade_fixtures",) - # =========================================================================== # Phase 5 — User Story 3: non-upgradable path guidance (P3) # =========================================================================== diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index 7bee64779a..7a36b030b3 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -1,5 +1,12 @@ """Verification, resolution, and validation tests for `specify self upgrade`.""" +import urllib.error +from unittest.mock import patch + +import pytest +import specify_cli +from specify_cli import app + from tests.self_upgrade_helpers import ( SENTINEL_GH_TOKEN, SENTINEL_GITHUB_TOKEN, @@ -7,17 +14,10 @@ _UpgradePlan, _completed_process, _verify_upgrade, - app, mock_urlopen_response, - patch, runner, - specify_cli, strip_ansi, - urllib, ) -import pytest - -pytest_plugins = ("tests.self_upgrade_fixtures",) # =========================================================================== # Phase 6 — User Story 4: failure recovery (P2) @@ -530,10 +530,12 @@ def test_env_scrubbing_is_case_insensitive( def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): monkeypatch.setenv("GH_PAT", "gh-pat") + monkeypatch.setenv("GH_TOKEN_FILE", "gh-token-file") monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh") monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret") monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key") monkeypatch.setenv("GITHUB_PAT", "github-pat") + monkeypatch.setenv("GITHUB_TOKEN_PATH", "github-token-path") monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github") monkeypatch.setenv("GITHUB_API_TOKEN", "api-token") monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key") @@ -547,10 +549,12 @@ def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): env = specify_cli._version._scrubbed_env() assert "GH_PAT" not in env + assert "GH_TOKEN_FILE" not in env assert "GH_ENTERPRISE_TOKEN" not in env assert "GH_ENTERPRISE_SECRET" not in env assert "GH_ENTERPRISE_PRIVATE_KEY" not in env assert "GITHUB_PAT" not in env + assert "GITHUB_TOKEN_PATH" not in env assert "GITHUB_ENTERPRISE_TOKEN" not in env assert "GITHUB_API_TOKEN" not in env assert "GITHUB_APP_PRIVATE_KEY" not in env