From 71435b5ff5b0d3a68adb8bcd2ff921c130e36048 Mon Sep 17 00:00:00 2001 From: pli Date: Thu, 21 May 2026 10:21:53 +0900 Subject: [PATCH 01/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 From 3b00dfeee89f3954fadf6bc9e9219fd2cf72ec0c Mon Sep 17 00:00:00 2001 From: pli Date: Mon, 1 Jun 2026 10:24:01 +0900 Subject: [PATCH 28/32] fix: resolve self-upgrade review and Windows CI failures - README: promote "Optional Commands" to ### so it is a sibling of "Core Commands" under "Available Slash Commands" (consistent heading levels; avoids the h2->h4 jump a revert would create). - _version: allow --tag prerelease/dev and build-metadata suffixes to compose (e.g. v1.0.0-rc1+build.42), matching PEP 440 / semver; the Version() check still enforces canonical validity. - tests: compare resolved argv0 as Path objects instead of POSIX strings so the assertion holds on Windows; skip the relative-installer-path executable-bit tests on Windows via a new requires_posix marker (they rely on chmod/X_OK semantics and chdir-into-tmp teardown that do not hold there). Add a combined prerelease+build-metadata tag test. --- README.md | 2 +- docs/upgrade.md | 4 ++-- src/specify_cli/_version.py | 13 +++++++++---- tests/self_upgrade_helpers.py | 14 ++++++++++++++ tests/test_self_upgrade_detection.py | 6 +++++- tests/test_self_upgrade_execution.py | 4 ++++ tests/test_self_upgrade_verification.py | 14 ++++++++++++++ 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 79668d1654..d7f1486676 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ Essential commands for the Spec-Driven Development workflow: | `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution | | `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan | -#### Optional Commands +### Optional Commands Additional commands for enhanced quality and validation: diff --git a/docs/upgrade.md b/docs/upgrade.md index 7f8a4c8ae9..820cc9eabf 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. Suffixes are limited to dev, alpha/beta/rc, or build metadata forms. | +| **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, and/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 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. +Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+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 ed7eb8f599..0635693c9e 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -263,10 +263,14 @@ def _scrubbed_env() -> dict[str, str]: } +# vMAJOR.MINOR.PATCH, then an optional dev/prerelease segment, then an +# optional build-metadata segment. The two trailing segments are independent +# so they can compose (e.g. v1.0.0-rc1+build.42) — matching PEP 440 /semver, +# which the Version() check below then enforces canonically. _TAG_REGEX = re.compile( r"^v[0-9]+\.[0-9]+\.[0-9]+" - r"(?:(?:\.?dev[0-9]+)|(?:[-.]?(?:a|b|rc|alpha|beta)[-.]?[0-9]+)|" - r"(?:\+[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)*))?$" + 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]" @@ -274,8 +278,9 @@ def _scrubbed_env() -> dict[str, str]: def _validate_tag(tag: str) -> str: """Validate a user-supplied --tag value. - 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 + Accepts vX.Y.Z plus an optional dev or alpha/beta/rc suffix and/or an + optional build-metadata suffix, which may combine (for example: + v1.0.0-rc1, v0.8.0.dev0, v0.8.0+build.42, v1.0.0-rc1+build.42). Rejects everything else, including bare 'latest', hash refs, branch names, and numeric versions without the 'v' prefix. """ diff --git a/tests/self_upgrade_helpers.py b/tests/self_upgrade_helpers.py index 15c795ea11..c363f57b13 100644 --- a/tests/self_upgrade_helpers.py +++ b/tests/self_upgrade_helpers.py @@ -4,8 +4,10 @@ the focused test modules stay isolated from the real environment. """ +import os import subprocess +import pytest from typer.testing import CliRunner from specify_cli._version import ( @@ -28,12 +30,24 @@ "_detect_install_method", "_verify_upgrade", "mock_urlopen_response", + "requires_posix", "runner", "strip_ansi", ) runner = CliRunner() +# Some installer error-path tests create a relative `./uv` fixture, `chdir` +# into the tmp dir, and assert POSIX executable-bit semantics (chmod / X_OK). +# None of that maps cleanly onto Windows: `os.access(path, X_OK)` ignores the +# mode bits, and pytest cannot rmtree a tmp dir that is still the cwd, so the +# fixtures raise PermissionError during teardown. Skip these on Windows — the +# realistic absolute-path and bare-PATH-command branches stay covered there. +requires_posix = pytest.mark.skipif( + os.name == "nt", + reason="relative-path / executable-bit semantics are POSIX-only", +) + SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" diff --git a/tests/test_self_upgrade_detection.py b/tests/test_self_upgrade_detection.py index 80843f3fed..7c074a39e0 100644 --- a/tests/test_self_upgrade_detection.py +++ b/tests/test_self_upgrade_detection.py @@ -4,6 +4,7 @@ import json import os import subprocess +from pathlib import Path from unittest.mock import patch import specify_cli @@ -313,7 +314,10 @@ def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self): ), patch("pathlib.Path.resolve", side_effect=OSError("bad path")): result = specify_cli._version._resolved_argv0_path("specify") - assert str(result) == "/broken/specify" + # Compare as Path objects: on Windows the same logical path renders + # with backslashes, so a raw string compare against the POSIX form + # would spuriously fail. + assert result == Path("/broken/specify") class TestArgvAssemblyUvTool: diff --git a/tests/test_self_upgrade_execution.py b/tests/test_self_upgrade_execution.py index 11285a6d94..84862008d2 100644 --- a/tests/test_self_upgrade_execution.py +++ b/tests/test_self_upgrade_execution.py @@ -9,6 +9,7 @@ from tests.self_upgrade_helpers import ( _completed_process, mock_urlopen_response, + requires_posix, runner, strip_ansi, ) @@ -73,6 +74,7 @@ def test_absolute_installer_path_does_not_require_path_lookup( result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 + @requires_posix def test_relative_installer_path_does_not_require_path_lookup( self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path ): @@ -105,6 +107,7 @@ def test_relative_installer_path_does_not_require_path_lookup( assert result.exit_code == 0 assert mock_run.call_args.args[0][0] == "./uv" + @requires_posix def test_relative_installer_path_missing_gets_path_specific_message( self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path ): @@ -191,6 +194,7 @@ def test_absolute_installer_path_not_executable_gets_specific_message( in strip_ansi(result.output) ) + @requires_posix def test_relative_installer_path_not_executable_gets_path_specific_message( self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path ): diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index 7a36b030b3..fe2cf31869 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -392,6 +392,20 @@ def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): assert result.exit_code == 0 assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) + def test_valid_prerelease_with_build_metadata_tag( + self, uv_tool_argv0, clean_environ + ): + # Prerelease and build-metadata suffixes compose (PEP 440 / semver). + 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( + app, + ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"], + ) + assert result.exit_code == 0 + assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output) + @pytest.mark.parametrize( "bad_tag", [ From 47c37be9f26513a3fcb3a7a39f9f83fcc2f811ec Mon Sep 17 00:00:00 2001 From: pli Date: Mon, 1 Jun 2026 10:45:19 +0900 Subject: [PATCH 29/32] fix: address second self-upgrade review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - self_check: clarify that the "up to date" branch is reached only for parseable latest tags (the unparseable case returns earlier), so the InvalidVersion fallback assumption is not reintroduced. - self_upgrade: compare target/current as Version instances directly instead of re-parsing the canonical strings through _is_newer; the empty-current case stays explicit via the not-None guard. - tests: document the intentional broad GH_/GITHUB_ env scrub with a test asserting non-credential context vars (GH_HOST, GITHUB_REPOSITORY, …) are stripped from the installer subprocess env — a deliberate fail-safe that also catches credential-adjacent names without a recognized suffix. --- src/specify_cli/_version.py | 16 +++++++++++----- tests/test_self_upgrade_verification.py | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 0635693c9e..6a1b244c41 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -1173,7 +1173,10 @@ def self_check() -> None: console.print(f" pipx install --force {_manual_source_spec(manual_tag)}") return - # Installed is parseable AND is >= latest → "up to date" (FR-006). + # Reached only when manual_tag parsed cleanly — the unparseable-latest case + # already returned at the `manual_tag is None` branch above — and installed + # is parseable AND >= latest → "up to date" (FR-006). Do not reintroduce an + # InvalidVersion-fallback assumption here. console.print(f"[green]Up to date:[/green] {installed}") @@ -1296,10 +1299,13 @@ def self_upgrade( 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 + # target_version and current_version are already Version instances here, + # so compare them directly instead of re-parsing the canonical strings + # through _is_newer; the empty-current case stays explicit via the + # `current_version is not None` guard rather than being swallowed by an + # InvalidVersion catch. + if tag is None and current_version is not None and not ( + target_version > current_version ): if current_canonical == target_canonical: console.print(f"Already on latest release: {target_tag}") diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index fe2cf31869..745aa39124 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -578,3 +578,28 @@ def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): assert env["GHOST_API_TOKEN"] == "ghost-kept" assert env["GHIDRA_API_KEY"] == "ghidra-kept" assert env["UNRELATED_TOKEN"] == "kept" + + def test_env_scrubbing_strips_noncredential_github_vars_by_design( + self, monkeypatch + ): + # The scrub is intentionally broad: every GH_/GITHUB_-prefixed name is + # removed from the installer subprocess env, including non-credential + # context vars. This is a deliberate fail-safe so credential-adjacent + # names that lack a recognized suffix (e.g. GH_TOKEN_FILE, + # GITHUB_TOKEN_PATH, asserted above) can never leak. The installer + # (`uv tool install` / `pipx install` of a public package) does not + # consume routing/context vars like GITHUB_REPOSITORY, so nothing the + # subprocess needs is lost by stripping them. + monkeypatch.setenv("GH_HOST", "github.example.com") + monkeypatch.setenv("GH_CONFIG_DIR", "/home/u/.config/gh") + monkeypatch.setenv("GITHUB_REPOSITORY", "github/spec-kit") + monkeypatch.setenv("GITHUB_WORKSPACE", "/home/runner/work") + monkeypatch.setenv("GITHUB_USER", "octocat") + + env = specify_cli._version._scrubbed_env() + + assert "GH_HOST" not in env + assert "GH_CONFIG_DIR" not in env + assert "GITHUB_REPOSITORY" not in env + assert "GITHUB_WORKSPACE" not in env + assert "GITHUB_USER" not in env From b5707928b798faab5ec17eb979f0409764c97dd9 Mon Sep 17 00:00:00 2001 From: pli Date: Mon, 1 Jun 2026 12:08:25 +0900 Subject: [PATCH 30/32] fix: address third self-upgrade review round - self_upgrade: unify the no-op short-circuits on packaging Version equality instead of canonical-string equality. Version("1.0") equals Version("1.0.0") but their str() forms differ, so the old check could misreport an equal install as "already on latest release or newer". Both the unpinned and pinned branches now use Version comparison. - self_upgrade: compare the verified version as a parsed Version against the target so a non-version verifier result is a mismatch (exit 2) rather than a coincidental canonical-string match. - resolver: map HTTP 429 (Too Many Requests / secondary rate limit) to the rate-limited category so users get the same actionable token hint as 403. - _is_github_credential_env_key: document the precise (intentionally broad) scrub matching contract in the docstring. - tests: add a trailing-zero Version-equality regression test and a parametrized HTTP-status categorization test (429 -> rate limited; 404/502 -> verbatim). --- src/specify_cli/_version.py | 60 +++++++++++++++++-------- tests/test_self_upgrade_detection.py | 20 +++++++++ tests/test_self_upgrade_verification.py | 29 ++++++++++++ 3 files changed, 90 insertions(+), 19 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 6a1b244c41..41c40996b7 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -127,7 +127,10 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: return tag, None except urllib.error.HTTPError as e: # Order matters: HTTPError is a subclass of URLError. - if e.code == 403: + # 403 (primary rate limit / abuse detection) and 429 (Too Many Requests / + # secondary rate limit) both get the actionable "configure a token" hint; + # every other status is surfaced verbatim as "HTTP {code}". + if e.code in (403, 429): return None, _RESOLUTION_FAILURE_RATE_LIMITED return None, f"{_RESOLUTION_FAILURE_HTTP_PREFIX}{e.code}" except (urllib.error.URLError, OSError): @@ -246,7 +249,21 @@ class _DetectionSignals: def _is_github_credential_env_key(key: str) -> bool: - """Return whether an env key looks like a GitHub credential.""" + """Return whether an env key should be scrubbed as a GitHub credential. + + Matching contract (case-insensitive): + + - Any key with a ``GH_`` or ``GITHUB_`` prefix is scrubbed unconditionally. + This is deliberately broad: it catches credential-adjacent names that lack + a recognized suffix (e.g. ``GH_TOKEN_FILE``, ``GITHUB_TOKEN_PATH``) at the + cost of also dropping benign context vars (``GH_HOST``, + ``GITHUB_REPOSITORY``) the installer subprocess does not consume. + - Otherwise the key is scrubbed only when it contains an underscore-delimited + ``_GITHUB_`` segment *and* ends with a credential suffix + (``_TOKEN``/``_SECRET``/``_KEY``/``_PAT``) — e.g. ``HOMEBREW_GITHUB_API_TOKEN``. + Un-delimited variants such as a hypothetical ``GITHUBTOKEN`` are not matched + by this branch; no real tool sets such a name. + """ upper = key.upper() if upper.startswith(("GH_", "GITHUB_")): return True @@ -1294,27 +1311,30 @@ def self_upgrade( # 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) - 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 "" - # target_version and current_version are already Version instances here, - # so compare them directly instead of re-parsing the canonical strings - # through _is_newer; the empty-current case stays explicit via the - # `current_version is not None` guard rather than being swallowed by an - # InvalidVersion catch. + # target_version and current_version are Version instances here, so use + # packaging's ordering/equality directly rather than comparing canonical + # strings: Version("1.0") == Version("1.0.0") yet their str() forms + # differ, so canonical-string equality would misreport equal versions as + # "or newer". The unparseable-current case stays explicit via the + # `current_version is not None` guard. if tag is None and current_version is not None and not ( target_version > current_version ): - if current_canonical == target_canonical: + if target_version == current_version: console.print(f"Already on latest release: {target_tag}") else: console.print(f"Already on latest release or newer: {plan.current_version}") raise typer.Exit(0) - # Pinned upgrades are no-ops only on an exact parseable match; an - # unparseable current version deliberately proceeds to installation. - if tag is not None and current_canonical == target_canonical: + # Pinned upgrades are no-ops only on an exact parseable match — the same + # Version equality used by the unpinned branch above; an unparseable + # current version deliberately proceeds to installation. + if ( + tag is not None + and current_version is not None + and target_version == current_version + ): console.print(f"Already on requested release: {target_tag}") raise typer.Exit(0) @@ -1362,11 +1382,13 @@ def self_upgrade( # 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) - ): + # Compare as Version instances, not canonical strings: _canonicalize_version_text + # falls back to _normalize_tag() on unparseable input, so two raw strings could + # coincidentally match. Requiring a parseable verified version that equals the + # (already-parsed) target makes a non-version verifier result a mismatch (exit 2) + # rather than a silently-masked "success". + verified_version = _parse_version_text(verified) if verified is not None else None + if verified_version is None or verified_version != target_version: _emit_failure( _FAILURE_VERIFICATION_MISMATCH, plan=plan, diff --git a/tests/test_self_upgrade_detection.py b/tests/test_self_upgrade_detection.py index 7c074a39e0..fdd914a539 100644 --- a/tests/test_self_upgrade_detection.py +++ b/tests/test_self_upgrade_detection.py @@ -408,6 +408,26 @@ def test_already_latest_exits_zero_no_subprocess( assert "Already on latest release: v0.7.6" in strip_ansi(result.output) assert mock_run.call_count == 0 + def test_trailing_zero_equivalent_version_reports_latest_not_newer( + self, uv_tool_argv0, clean_environ + ): + # Version("1.0") == Version("1.0.0") under packaging even though their + # canonical strings differ. The no-op message must use Version equality + # so this prints "Already on latest release", not "... or newer". + 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="uv" + ), patch("specify_cli._version._get_installed_version", return_value="1.0"): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Already on latest release: v1.0.0" in out + assert "or newer" not in out + assert mock_run.call_count == 0 + def test_dev_build_ahead_of_release_reports_newer_noop( self, uv_tool_argv0, clean_environ ): diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index 745aa39124..b84615076f 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -313,6 +313,35 @@ def test_http_500_exits_1(self, uv_tool_argv0, clean_environ): assert result.exit_code == 1 assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output) + @pytest.mark.parametrize( + "code, expected", + [ + # 429 (Too Many Requests / secondary rate limit) gets the same + # actionable token hint as 403; other statuses surface verbatim. + ( + 429, + "Upgrade aborted: rate limited (configure ~/.specify/auth.json " + "with a GitHub token)", + ), + (404, "Upgrade aborted: HTTP 404"), + (502, "Upgrade aborted: HTTP 502"), + ], + ) + def test_http_error_categorization( + self, code, expected, uv_tool_argv0, clean_environ + ): + err = urllib.error.HTTPError( + url="https://api.github.com", + code=code, + msg="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 expected in strip_ansi(result.output) + def test_unparseable_resolved_release_tag_exits_1_without_traceback( self, uv_tool_argv0, clean_environ ): From 05d00ea3fe1f55591e190cd143d8bd8b837aeacb Mon Sep 17 00:00:00 2001 From: pli Date: Mon, 1 Jun 2026 13:33:37 +0900 Subject: [PATCH 31/32] fix: address fourth self-upgrade review round - self_upgrade: label a pinned target older than the installed version as "Downgrading" rather than "Upgrading" so `--tag ` is not mistaken for a forward upgrade. - resolver: drop the unused `typing.Optional` import and annotate the `--tag` option as `str | None`, consistent with the rest of the module (verified Typer resolves it on the supported Python versions). - _is_github_credential_env_key: add `_PASSWORD` and `_CREDENTIALS` to the recognized credential suffixes and document that only these shapes are scrubbed (not blanket coverage). - tests: assert the precise exit code (1) for the re-raised transient OSError path; skip the InvalidMetadataError test on Pythons where the real exception is absent instead of fabricating it; update the pinned downgrade test to expect the "Downgrading" label. --- src/specify_cli/_version.py | 34 +++++++++++++++++++++------- tests/test_self_upgrade_detection.py | 30 ++++++++++++------------ tests/test_self_upgrade_execution.py | 5 +++- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 41c40996b7..b3fbc78dc7 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -24,7 +24,6 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Optional import typer from packaging.version import InvalidVersion, Version @@ -244,7 +243,14 @@ class _DetectionSignals: resolved_method: _InstallMethod -_GITHUB_CREDENTIAL_SUFFIXES = ("_TOKEN", "_SECRET", "_KEY", "_PAT") +_GITHUB_CREDENTIAL_SUFFIXES = ( + "_TOKEN", + "_SECRET", + "_KEY", + "_PAT", + "_PASSWORD", + "_CREDENTIALS", +) _UNRESOLVED_ENV_VAR_RE = re.compile(r"\$\w+|\$\{\w+\}|%[^%]+%") @@ -260,9 +266,11 @@ def _is_github_credential_env_key(key: str) -> bool: ``GITHUB_REPOSITORY``) the installer subprocess does not consume. - Otherwise the key is scrubbed only when it contains an underscore-delimited ``_GITHUB_`` segment *and* ends with a credential suffix - (``_TOKEN``/``_SECRET``/``_KEY``/``_PAT``) — e.g. ``HOMEBREW_GITHUB_API_TOKEN``. - Un-delimited variants such as a hypothetical ``GITHUBTOKEN`` are not matched - by this branch; no real tool sets such a name. + (``_TOKEN``/``_SECRET``/``_KEY``/``_PAT``/``_PASSWORD``/``_CREDENTIALS``) — + e.g. ``HOMEBREW_GITHUB_API_TOKEN``. Un-delimited variants such as a + hypothetical ``GITHUBTOKEN`` are not matched by this branch; no real tool + sets such a name. Only these recognized shapes are scrubbed — this is not + blanket coverage of every conceivable secret name. """ upper = key.upper() if upper.startswith(("GH_", "GITHUB_")): @@ -1205,7 +1213,7 @@ def self_upgrade( help="Print the preview (method, current, target, installer argv) and " "exit 0 without launching the installer subprocess.", ), - tag: Optional[str] = typer.Option( + tag: str | None = typer.Option( None, "--tag", help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the " @@ -1339,10 +1347,20 @@ def self_upgrade( 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. + # before the installer's own output starts streaming. A pinned target older + # than the installed version is a downgrade — say so explicitly so + # `--tag ` does not masquerade as a forward upgrade. + installed_version = _parse_version_text(plan.current_version) + verb = ( + "Downgrading" + if tag is not None + and installed_version is not None + and target_version < installed_version + else "Upgrading" + ) argv_str = _render_argv(plan.installer_argv) if plan.installer_argv else "" console.print( - f"Upgrading specify-cli {plan.current_version} → {plan.target_tag} " + f"{verb} specify-cli {plan.current_version} → {plan.target_tag} " f"via {_method_label(plan.method)}: {argv_str}", soft_wrap=True, ) diff --git a/tests/test_self_upgrade_detection.py b/tests/test_self_upgrade_detection.py index fdd914a539..ab575e7435 100644 --- a/tests/test_self_upgrade_detection.py +++ b/tests/test_self_upgrade_detection.py @@ -7,6 +7,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + import specify_cli from specify_cli import app @@ -499,7 +501,9 @@ def test_pinned_older_tag_still_runs_installer( 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 + # A pinned older tag is a downgrade and must be labelled as such. + assert "Downgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out + assert "Upgrading specify-cli" not in out assert mock_run.call_count == 2 def test_pinned_rc_tag_uses_canonical_version_equality_for_noop( @@ -729,20 +733,18 @@ def fake_run(argv, *args, **kwargs): class TestEditableInstallMetadata: + @pytest.mark.skipif( + not hasattr(importlib.metadata, "InvalidMetadataError"), + reason=( + "importlib.metadata.InvalidMetadataError does not exist on this " + "Python; _editable_direct_url_path only catches it when present, so " + "fabricating it would exercise a path that cannot fire in production" + ), + ) 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( + invalid_metadata_error = importlib.metadata.InvalidMetadataError + + with patch( "importlib.metadata.distribution", side_effect=invalid_metadata_error("bad metadata"), ): diff --git a/tests/test_self_upgrade_execution.py b/tests/test_self_upgrade_execution.py index 84862008d2..6696b4fc79 100644 --- a/tests/test_self_upgrade_execution.py +++ b/tests/test_self_upgrade_execution.py @@ -386,7 +386,10 @@ def test_transient_exec_oserror_is_not_treated_as_invalid_installer( ), 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 + # Transient/unknown OSErrors are re-raised rather than mapped to the + # invalid-installer exit 3, so the CLI surfaces them as an uncaught + # error: exit code 1 with the original OSError preserved. + assert result.exit_code == 1 assert isinstance(result.exception, OSError) From 008c616febd0d22615f61933a8dd569407e0fb01 Mon Sep 17 00:00:00 2001 From: pli Date: Mon, 1 Jun 2026 14:10:19 +0900 Subject: [PATCH 32/32] fix: accept uppercase V prefix in --tag Fold a leading uppercase `V` (a common paste) to the canonical lowercase `v` before validating `--tag`. The remainder of the tag stays case-sensitive on purpose: the validated value is used verbatim as a git ref, which is case-sensitive on GitHub, so rewriting label/build-metadata casing could point at a tag that does not exist. Adds a normalization test. --- src/specify_cli/_version.py | 13 ++++++++++--- tests/test_self_upgrade_verification.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index b3fbc78dc7..e634a4f286 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -305,13 +305,20 @@ def _validate_tag(tag: str) -> str: Accepts vX.Y.Z plus an optional dev or alpha/beta/rc suffix and/or an optional build-metadata suffix, which may combine (for example: - v1.0.0-rc1, v0.8.0.dev0, v0.8.0+build.42, v1.0.0-rc1+build.42). Rejects - everything else, including bare 'latest', hash refs, branch names, and - numeric versions without the 'v' prefix. + v1.0.0-rc1, v0.8.0.dev0, v0.8.0+build.42, v1.0.0-rc1+build.42). An + uppercase ``V`` prefix is accepted and folded to the canonical lowercase + ``v``. Rejects everything else, including bare 'latest', hash refs, branch + names, and numeric versions without the 'v' prefix. """ tag = tag.strip() if not tag: raise typer.BadParameter(_INVALID_TAG_MESSAGE) + # Fold a leading uppercase `V` (a common paste) to the canonical lowercase + # `v`. The remainder stays case-sensitive on purpose: the validated tag is + # used verbatim as a git ref, which is case-sensitive on GitHub, so we must + # not rewrite label/build-metadata casing into a ref that may not exist. + if tag[:1] == "V": + tag = "v" + tag[1:] if not _TAG_REGEX.match(tag): raise typer.BadParameter(_INVALID_TAG_MESSAGE) try: diff --git a/tests/test_self_upgrade_verification.py b/tests/test_self_upgrade_verification.py index b84615076f..f1a018f06c 100644 --- a/tests/test_self_upgrade_verification.py +++ b/tests/test_self_upgrade_verification.py @@ -421,6 +421,21 @@ def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): assert result.exit_code == 0 assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) + def test_uppercase_v_prefix_is_folded_to_lowercase( + self, uv_tool_argv0, clean_environ + ): + # A pasted uppercase `V` prefix is accepted and normalized to `v` so + # the git ref matches the canonical lowercase release tag. + 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( + app, + ["self", "upgrade", "--dry-run", "--tag", "V0.7.6"], + ) + assert result.exit_code == 0 + assert "Target version: v0.7.6" in strip_ansi(result.output) + def test_valid_prerelease_with_build_metadata_tag( self, uv_tool_argv0, clean_environ ):