diff --git a/README.md b/README.md index 79940074e4..941aa2bd49 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,25 @@ specify init --here --integration copilot specify check ``` -To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade: +To upgrade Specify, the CLI ships with two self-management commands that handle the common case for you. 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 with your desired release tag) +specify self upgrade --tag v0.8.6 +``` + +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). + +If you prefer to drive the installer yourself, the manual equivalents still work: ```bash uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z diff --git a/docs/installation.md b/docs/installation.md index 86ad35559f..ebbc3dc93c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -91,6 +91,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 ec87662cbc..16462114d0 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 v0.8.6` | Upgrade to a specific release tag instead of the latest stable. Replace `v0.8.6` 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,6 +21,30 @@ The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes. +### 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 with the release tag you want) +specify self upgrade --tag v0.8.6 +``` + +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 `specify self upgrade` isn't available on your install (it shipped in a recent release) or you want explicit control over the installer command, use the manual equivalents below. + ### 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): @@ -46,10 +72,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. +`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. --- @@ -178,8 +208,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 @@ -196,7 +226,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 @@ -380,7 +410,17 @@ Only Spec Kit infrastructure files: ### "CLI upgrade doesn't seem to work" -Verify the installation: +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 +``` + +If `self check` shows the wrong version, verify the installation: ```bash # Check installed tools diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 325692900e..49bbb8fa2a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -27,6 +27,7 @@ """ import os +import re import subprocess import sys import zipfile @@ -37,8 +38,11 @@ import stat import shlex import urllib.error +import urllib.parse import urllib.request import yaml +from dataclasses import dataclass +from enum import Enum from pathlib import Path from packaging.version import InvalidVersion, Version @@ -1739,6 +1743,44 @@ def _normalize_tag(tag: str) -> str: """ return tag[1:] if tag.startswith("v") else tag + +def _canonicalize_version_text(value: str) -> str: + """Normalize version-like text for equality checks when parseable.""" + normalized = _normalize_tag(value) + try: + return str(Version(normalized)) + except InvalidVersion: + return normalized + + +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.""" + try: + Version(_normalize_tag(value)) + return True + except InvalidVersion: + return False + + +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) + + def _is_newer(latest: str, current: str) -> bool: """Return True iff `latest` is strictly greater than `current` under PEP 440. @@ -1786,10 +1828,752 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: return None, "offline or timeout" +_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)", + } +) + +_INSTALLER_INVALID_SENTINEL = 900126 +_INSTALLER_TIMEOUT_SENTINEL = 900124 + + +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" + + +@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 + + +def _scrubbed_env() -> dict[str, str]: + """Return a copy of `os.environ` without GitHub token variables.""" + + return { + k: v + for k, v in os.environ.items() + if k.upper() not in {"GH_TOKEN", "GITHUB_TOKEN"} + } + + +_TAG_REGEX = re.compile(r"^v\d+\.\d+\.\d+(?:[a-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]") + + return tag + + +def _expand_prefix(prefix: str) -> Path: + """Expand `~` or `%LOCALAPPDATA%`-style tokens in a path prefix.""" + + expanded = os.path.expandvars(os.path.expanduser(prefix)) + return Path(expanded).resolve() if Path(expanded).is_absolute() else Path(expanded) + + +def _path_is_within_prefix(path: Path, prefix: Path) -> bool: + """Return whether `path` is under `prefix` using path-aware matching.""" + if 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 not candidate.is_absolute() and len(candidate.parts) == 1: + resolved = shutil.which(raw) or shutil.which("specify") + if resolved: + return Path(resolved).resolve() + return candidate.resolve() + + +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"} + + +def _tier3_registry_lookup_allowed(argv0: str | None, argv0_path: Path) -> bool: + """Return whether tier-3 registry reconciliation is safe for this entrypoint.""" + raw = argv0 or sys.argv[0] + candidate = Path(raw) + return ( + (not candidate.is_absolute() and len(candidate.parts) == 1) + or 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() + if editable_root is not None and _git_ancestor(editable_root) is not None: + return True + return False + + +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 _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, 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 uv_tool_match ^ pipx_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") or "uv" # resolve at invocation time too + 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") or "pipx" + return [ + pipx_bin, + "install", + "--force", + source_spec, + ] + + 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) + + 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: " + f"{_render_argv(argv) if argv is not None else '(none — non-upgradable path)'}" + ) + + 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 _run_installer(plan: _UpgradePlan) -> subprocess.CompletedProcess | None: + """Invoke the installer subprocess. + + Returns None if the installer binary is not found on PATH. Returns a + synthetic CompletedProcess with returncode=`_INSTALLER_INVALID_SENTINEL` + when an absolute installer path exists but is not executable or not a + regular file. Returns a CompletedProcess for every other case including + non-zero exit — caller inspects returncode. + + stdout/stderr are inherited (not captured) so the user sees installer + progress in real time. The child environment has GH_TOKEN / + GITHUB_TOKEN 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 we return a synthetic + CompletedProcess with returncode=`_INSTALLER_TIMEOUT_SENTINEL`, which the + orchestrator maps to user-facing exit code `124`. An unparseable or + non-positive value is silently ignored (no 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 subprocess.CompletedProcess( + args=plan.installer_argv, + returncode=_INSTALLER_INVALID_SENTINEL, + stdout=None, + stderr=None, + ) + elif shutil.which(plan.installer_argv[0]) is None: + return None # signals installer-missing to caller + + 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: + timeout = None + except ValueError: + timeout = None + + try: + return subprocess.run( + plan.installer_argv, + shell=False, + check=False, + env=_scrubbed_env(), + timeout=timeout, + ) + except subprocess.TimeoutExpired: + # Surface as installer-failed via a synthetic CompletedProcess with a + # non-zero exit code so the orchestrator's installer-failed path emits + # the manual-retry argv + rollback hint. + return subprocess.CompletedProcess( + args=plan.installer_argv, + returncode=_INSTALLER_TIMEOUT_SENTINEL, + stdout=None, + stderr=None, + ) + except FileNotFoundError: + return None # disappeared between pre-flight and exec + except OSError: + return subprocess.CompletedProcess( + args=plan.installer_argv, + returncode=_INSTALLER_INVALID_SENTINEL, + stdout=None, + stderr=None, + ) + + +_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}; " + f"upgrade by running: git pull && pip install -e .", + soft_wrap=True, + ) + 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": + if installer_name and os.path.isabs(installer_name): + console.print( + f"Installer path {installer_name} is not an executable file; fix the path or reinstall it and retry.", + soft_wrap=True, + ) + else: + name = installer_name or "(unknown)" + console.print( + f"Installer {name} is not an executable file; fix the path or reinstall it and retry.", + 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 "" + if installer_exit == _INSTALLER_TIMEOUT_SENTINEL: + 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 + 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 (read-only) and upgrade in place.", add_completion=False, ) app.add_typer(self_app, name="self") @@ -1799,10 +2583,9 @@ 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() @@ -1824,16 +2607,22 @@ 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("\nTo upgrade:") + console.print(" specify self upgrade") + console.print("\nManual fallback:") console.print(" uv tool install specify-cli --force \\") console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + console.print(f" pipx install --force git+https://github.com/github/spec-kit.git@{tag}") return if _is_newer(latest_normalized, installed): console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}") console.print("\nTo upgrade:") + console.print(" specify self upgrade") + console.print("\nManual fallback:") console.print(" uv tool install specify-cli --force \\") console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + console.print(f" pipx install --force git+https://github.com/github/spec-kit.git@{tag}") return # Installed is parseable AND is >= latest → "up to date" (FR-006). @@ -1844,20 +2633,176 @@ 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. + + Detection classifies the runtime into uv-tool / pipx / uvx (ephemeral) / + source-checkout / unsupported. Only uv-tool and pipx are upgraded + automatically; the other three paths print path-specific guidance and + exit 0. + + Exit codes: + 0 success or no-op-success (already on latest, --dry-run, or + non-upgradable path with guidance shown) + 1 target-tag resolution failure or --tag regex validation failure + 2 verification mismatch (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 + other installer exit code propagated verbatim + + Environment variables: + SPECIFY_UPGRADE_TIMEOUT_SECS Optional integer/float seconds. Caps how + long the installer subprocess may run. Unset (default) means no + timeout — interrupt with Ctrl+C if the installer hangs. + """ + if tag is not None: + try: + tag = _validate_tag(tag) + except typer.BadParameter as exc: + console.print(str(exc), soft_wrap=True) + raise typer.Exit(1) from exc + + plan, failure_reason = _build_upgrade_plan(target_tag_override=tag) + + # Resolver could not produce a tag → surface the categorized failure + # and exit non-zero so scripts notice (action-oriented unlike `self check`). + if plan is None: + if failure_reason is None: + # _build_upgrade_plan's contract: if plan is None, failure_reason + # is set. Defend explicitly so the guard survives `python -O`. + raise RuntimeError( + "internal contract violation: _build_upgrade_plan returned (None, None)" + ) + _emit_failure(failure_reason) + raise typer.Exit(1) + + # --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) + + # No-op success when the user is already on the latest tag. + target_canonical = _canonicalize_version_text(plan.target_tag) + current_canonical = ( + _canonicalize_version_text(plan.current_version) + if plan.current_version != "unknown" + else "unknown" + ) + if plan.current_version != "unknown": + versions_comparable = _is_comparable_version_text( + plan.current_version + ) and _is_comparable_version_text(target_canonical) + if tag is None and versions_comparable and not _is_newer( + target_canonical, plan.current_version + ): + if current_canonical == target_canonical: + console.print(f"Already on latest release: {plan.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: {plan.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. + completed = _run_installer(plan) + + if completed is None: + installer_name = plan.installer_argv[0] if plan.installer_argv else None + _emit_failure("installer-missing", plan=plan, installer_name=installer_name) + raise typer.Exit(3) + + if completed.returncode == _INSTALLER_INVALID_SENTINEL: + installer_name = plan.installer_argv[0] if plan.installer_argv else None + _emit_failure("installer-invalid", plan=plan, installer_name=installer_name) + raise typer.Exit(3) + + if completed.returncode != 0: + _emit_failure( + "installer-failed", + plan=plan, + installer_exit=completed.returncode, + ) + raise typer.Exit( + 124 + if completed.returncode == _INSTALLER_TIMEOUT_SENTINEL + else completed.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, + ) + - 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. - Use `specify self check` today to see whether a newer release is available - and to get a copy-pasteable reinstall command. - """ - 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.") # ===== Extension Commands ===== diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index c4f986d177..387ce49c5d 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -371,7 +371,9 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): ) assert result.returncode == 0, result.stderr # pwsh may prefix warnings to stdout; find the JSON line - json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")] + json_line = [ + line for line in result.stdout.splitlines() if line.strip().startswith("{") + ] assert json_line, f"No JSON in output: {result.stdout}" data = json.loads(json_line[-1]) assert "BRANCH_NAME" in data diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 0b74a6f1a9..3e3257da65 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -267,10 +267,10 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(f"{cmd_dir}/speckit.{stem}.md") # Framework files - files.append(f".specify/integration.json") - files.append(f".specify/init-options.json") + files.append(".specify/integration.json") + files.append(".specify/init-options.json") files.append(f".specify/integrations/{self.KEY}.manifest.json") - files.append(f".specify/integrations/speckit.manifest.json") + files.append(".specify/integrations/speckit.manifest.json") if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 956c7a796f..a90fc48572 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -152,7 +152,7 @@ def test_yaml_is_valid(self, tmp_path): content = f.read_text(encoding="utf-8") # Strip trailing source comment before parsing lines = content.split("\n") - yaml_lines = [l for l in lines if not l.startswith("# Source:")] + yaml_lines = [line for line in lines if not line.startswith("# Source:")] try: parsed = yaml.safe_load("\n".join(yaml_lines)) except Exception as exc: @@ -183,7 +183,7 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): content = cmd_files[0].read_text(encoding="utf-8") # Strip source comment for parsing lines = content.split("\n") - yaml_lines = [l for l in lines if not l.startswith("# Source:")] + yaml_lines = [line for line in lines if not line.startswith("# Source:")] parsed = yaml.safe_load("\n".join(yaml_lines)) assert "description:" not in parsed["prompt"] diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 142db0dd92..6b8731a69a 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -196,9 +196,10 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): try: os.chdir(project) runner = CliRunner() - with ( - patch("specify_cli._stdin_is_interactive", return_value=True), - patch("specify_cli.select_with_arrows", return_value="claude"), + with patch( + "specify_cli._stdin_is_interactive", return_value=True + ), patch( + "specify_cli.select_with_arrows", return_value="claude" ): result = runner.invoke( app, @@ -503,7 +504,7 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): """Skills that have hook sections should get the normalization note.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") + i.setup(tmp_path, m, script_type="sh") specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") @@ -542,7 +543,7 @@ def test_hook_note_preserves_indentation(self, tmp_path): ) result = ClaudeIntegration._inject_hook_command_note(content) lines = result.splitlines() - note_line = [l for l in lines if "replace dots" in l][0] + note_line = [line for line in lines if "replace dots" in line][0] assert note_line.startswith(" "), "Note should preserve indentation" def test_post_process_injects_all_claude_flags(self): diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 938cb87650..ff0343c16c 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -571,12 +571,19 @@ def test_open_url_attaches_auth_for_matching_host(self, monkeypatch): self._set_config(monkeypatch, [_github_entry()]) captured = {} mock_opener = MagicMock() + def fake_open(req, timeout=None): captured["req"] = req - resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) return resp + mock_opener.open.side_effect = fake_open - with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + with patch( + "specify_cli.authentication.http.urllib.request.build_opener", + return_value=mock_opener, + ): open_url("https://github.com/org/repo/catalog.json") assert captured["req"].get_header("Authorization") == "Bearer my-token" @@ -586,11 +593,18 @@ def test_open_url_no_auth_for_non_matching_host(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", "my-token") self._set_config(monkeypatch, [_github_entry()]) captured = {} + def fake_urlopen(req, timeout=None): captured["req"] = req - resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) return resp - with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen): + + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=fake_urlopen, + ): open_url("https://example.com/file.json") assert captured["req"].get_header("Authorization") is None @@ -599,11 +613,18 @@ def test_open_url_no_auth_when_no_config(self, monkeypatch): from specify_cli.authentication.http import open_url self._set_config(monkeypatch, []) captured = {} + def fake_urlopen(req, timeout=None): captured["req"] = req - resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) return resp - with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen): + + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=fake_urlopen, + ): open_url("https://github.com/org/repo") assert captured["req"].get_header("Authorization") is None @@ -614,15 +635,26 @@ def test_open_url_falls_through_on_401(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", "bad-token") self._set_config(monkeypatch, [_github_entry()]) call_count = 0 + def fake_side_effect(req, timeout=None): - nonlocal call_count; call_count += 1 + nonlocal call_count + call_count += 1 if call_count == 1: raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None) - resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) return resp - mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect - with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \ - patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect): + + mock_opener = MagicMock() + mock_opener.open.side_effect = fake_side_effect + with patch( + "specify_cli.authentication.http.urllib.request.build_opener", + return_value=mock_opener, + ), patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=fake_side_effect, + ): open_url("https://github.com/org/repo") assert call_count == 2 @@ -692,7 +724,6 @@ def test_config_cached_after_first_load(self, monkeypatch): """_load_config() should call load_auth_config only once per process.""" from unittest.mock import patch from specify_cli.authentication import http as _mod - from specify_cli.authentication.config import AuthConfigEntry # Allow the real load path (no override) monkeypatch.setattr(_mod, "_config_override", None) monkeypatch.setattr(_mod, "_config_cache", None) @@ -821,13 +852,19 @@ def _set_config(self, monkeypatch, entries): def _capture_request(self): import json as _json from unittest.mock import MagicMock + captured: dict = {} + def side_effect(req, timeout=None): captured["request"] = req body = _json.dumps({"tag_name": "v9.9.9"}).encode() - resp = MagicMock(); resp.read.return_value = body - cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False + resp = MagicMock() + resp.read.return_value = body + cm = MagicMock() + cm.__enter__.return_value = resp + cm.__exit__.return_value = False return cm + return captured, side_effect def test_gh_token_forwarded_when_configured(self, monkeypatch): @@ -836,8 +873,12 @@ def test_gh_token_forwarded_when_configured(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel") self._set_config(monkeypatch, [_github_entry()]) captured, side_effect = self._capture_request() - mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect - with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + mock_opener = MagicMock() + mock_opener.open.side_effect = side_effect + with patch( + "specify_cli.authentication.http.urllib.request.build_opener", + return_value=mock_opener, + ): _fetch_latest_release_tag() assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel" diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 89e8b4a8b8..ea3e149fb9 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -18,9 +18,7 @@ from pathlib import Path from specify_cli.extensions import ( - ExtensionManifest, ExtensionManager, - ExtensionError, ) @@ -220,9 +218,7 @@ def test_skills_created_when_ai_skills_active(self, skills_project, extension_di """Skills should be created when ai_skills is enabled.""" project_dir, skills_dir = skills_project manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( - extension_dir, "0.1.0", register_commands=False - ) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) # Check that skill directories were created skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) @@ -334,14 +330,13 @@ def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir): """Kimi agent should use the same hyphenated skill names as hooks.""" _create_init_options(project_dir, ai="kimi", ai_skills=True) _create_skills_dir(project_dir, ai="kimi") - ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + ext_id = "test-ext" + ext_dir = _create_extension_dir(temp_dir, ext_id=ext_id) manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( - ext_dir, "0.1.0", register_commands=False - ) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - metadata = manager.registry.get(manifest.id) + metadata = manager.registry.get(ext_id) assert "speckit-test-ext-hello" in metadata["registered_skills"] assert "speckit-test-ext-world" in metadata["registered_skills"] @@ -352,11 +347,9 @@ def test_kimi_creates_skills_when_ai_skills_disabled(self, project_dir, temp_dir ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( - ext_dir, "0.1.0", register_commands=False - ) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - metadata = manager.registry.get(manifest.id) + metadata = manager.registry.get("test-ext") assert "speckit-test-ext-hello" in metadata["registered_skills"] assert "speckit-test-ext-world" in metadata["registered_skills"] assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() @@ -451,12 +444,11 @@ def test_missing_command_file_skipped(self, skills_project, temp_dir): ) # Intentionally do NOT create ghost.md + ext_id = "missing-cmd-ext" manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( - ext_dir, "0.1.0", register_commands=False - ) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - metadata = manager.registry.get(manifest.id) + metadata = manager.registry.get(ext_id) assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"] assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"] @@ -588,9 +580,7 @@ def test_command_without_frontmatter(self, skills_project, temp_dir): ) manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( - ext_dir, "0.1.0", register_commands=False - ) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md" assert skill_file.exists() @@ -607,9 +597,7 @@ def test_gemini_agent_skills(self, project_dir, temp_dir): ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( - ext_dir, "0.1.0", register_commands=False - ) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) skills_dir = project_dir / ".gemini" / "skills" assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() @@ -623,12 +611,8 @@ def test_multiple_extensions_independent_skills(self, skills_project, temp_dir): ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b") manager = ExtensionManager(project_dir) - manifest_a = manager.install_from_directory( - ext_dir_a, "0.1.0", register_commands=False - ) - manifest_b = manager.install_from_directory( - ext_dir_b, "0.1.0", register_commands=False - ) + manager.install_from_directory(ext_dir_a, "0.1.0", register_commands=False) + manager.install_from_directory(ext_dir_b, "0.1.0", register_commands=False) # Both should have skills assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists() @@ -684,9 +668,7 @@ def test_malformed_frontmatter_handled(self, skills_project, temp_dir): manager = ExtensionManager(project_dir) # Should not raise - manifest = manager.install_from_directory( - ext_dir, "0.1.0", register_commands=False - ) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md" assert skill_file.exists() diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 1434ba309d..65ef5eb234 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1846,7 +1846,7 @@ def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir): registrar = CommandRegistrar() from specify_cli.extensions import ExtensionManifest manifest = ExtensionManifest(ext_dir / "extension.yml") - registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) skill_subdir = skills_dir / "speckit-cleanup-ext-run" assert skill_subdir.exists(), "Skill subdirectory should exist after registration" @@ -2579,8 +2579,9 @@ def fake_open(req, timeout=None): def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch): """download_extension passes Authorization header when a provider is configured.""" - from unittest.mock import patch, MagicMock - import zipfile, io + import io + import zipfile + from unittest.mock import MagicMock, patch monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py new file mode 100644 index 0000000000..4dc9b8aa5b --- /dev/null +++ b/tests/test_self_upgrade.py @@ -0,0 +1,1508 @@ +"""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 json +import specify_cli +import subprocess +import urllib.error +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import ( + _InstallMethod, + _INSTALLER_TIMEOUT_SENTINEL, + _editable_marker_seen, + _source_checkout_path, + _detect_install_method, + _assemble_installer_argv, + app, +) + +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.urllib.request.Request(url) + for key, value in (extra_headers or {}).items(): + req.add_header(key, value) + return specify_cli.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 HOME=tmp_path so _expand_prefix("~/.local/share/uv/tools/specify-cli/") + expands to a path that actually contains the fake binary. This avoids + needing a `_UV_TOOL_ROOT_OVERRIDE` knob in production code. + """ + 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.""" + 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.""" + 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 TestDetection_UvTool: + """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.shutil.which", return_value=None), patch( + "specify_cli._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): + 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.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.shutil.which", return_value=None), patch( + "specify_cli._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, + ): + monkeypatch.setattr("sys.argv", ["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.shutil.which", side_effect=fake_which), patch( + "specify_cli.subprocess.run", side_effect=fake_run + ), patch("specify_cli._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_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.shutil.which", side_effect=fake_which), patch( + "specify_cli.subprocess.run", side_effect=fake_run + ), patch("specify_cli._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.shutil.which", side_effect=fake_which), patch( + "specify_cli.subprocess.run", side_effect=fake_run + ), patch("specify_cli._editable_marker_seen", return_value=False): + method = _detect_install_method() + assert method == _InstallMethod.UNSUPPORTED + + +class TestArgvAssembly_UvTool: + """uv-tool installer argv shape.""" + + def test_stable_tag_produces_expected_argv(self): + with patch("specify_cli.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.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 + + +class TestBareUpgrade_UvTool: + """uv-tool happy path, bare invocation.""" + + def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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 TestAlreadyLatest_UvTool: + """already on latest, no installer launched.""" + + def test_already_latest_exits_zero_no_subprocess( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.subprocess.run" + ) as mock_run, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.subprocess.run" + ) as mock_run, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.subprocess.run" + ) as mock_run, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._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_pinned_older_tag_still_runs_installer( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli.subprocess.run" + ) as mock_run, patch( + "specify_cli._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.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._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 TestDryRun_UvTool: + """--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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.subprocess.run" + ) as mock_run, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.subprocess.run" + ), patch("specify_cli.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._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() + + +# =========================================================================== +# Phase 4 — User Story 2: `pipx` immediate upgrade (P2) +# =========================================================================== + + +class TestDetection_Pipx: + """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, + ): + monkeypatch.setattr("sys.argv", ["specify"]) + + def fake_which(name): + return "/usr/bin/pipx" if name == "pipx" else None + + def fake_run(argv, *args, **kwargs): + if argv[0] == "/usr/bin/pipx" and argv[1] == "list": + 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.shutil.which", side_effect=fake_which), patch( + "specify_cli.subprocess.run", side_effect=fake_run + ), patch("specify_cli._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[0] == "/usr/bin/pipx" and argv[1] == "list": + 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.shutil.which", side_effect=fake_which), patch( + "specify_cli.subprocess.run", side_effect=fake_run + ), patch("specify_cli._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[0] == "/usr/bin/pipx" and argv[1] == "list": + 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.shutil.which", side_effect=fake_which), patch( + "specify_cli.subprocess.run", side_effect=fake_run + ), patch("specify_cli._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, + ): + monkeypatch.setattr("sys.argv", ["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.shutil.which", side_effect=fake_which), patch( + "specify_cli.subprocess.run", side_effect=fake_run + ), patch("specify_cli._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 _editable_marker_seen() is True + assert _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 _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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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 TestArgvAssembly_Pipx: + """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.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 + + +class TestBareUpgrade_Pipx: + """pipx happy path.""" + + def test_happy_path(self, pipx_argv0, clean_environ): + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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 TestTiebreak_UvVsPipx: + """argv0 prefix wins over registry tiebreak.""" + + def test_pipx_argv0_wins_over_uv_registry_when_both_listed( + self, + pipx_argv0, + clean_environ, + ): + # pipx_argv0 makes tier-1 fire for pipx; uv-registry tier-3 never + # runs because tier-1 already short-circuited. + def fake_run(argv, *args, **kwargs): + if argv[1:3] == ["tool", "list"]: + return subprocess.CompletedProcess( + args=argv, + returncode=0, + stdout="specify-cli 0.7.5", + stderr="", + ) + return subprocess.CompletedProcess( + args=argv, returncode=1, stdout="", stderr="" + ) + + with patch("specify_cli.shutil.which", return_value="/usr/bin/X"), patch( + "specify_cli.subprocess.run", side_effect=fake_run + ): + method = _detect_install_method() + assert method == _InstallMethod.PIPX + + +class TestDryRun_Pipx: + def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.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.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._editable_marker_seen", return_value=True), patch( + "specify_cli._source_checkout_path", return_value=fake_tree + ), patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.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 && 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._editable_marker_seen", return_value=False), patch( + "specify_cli.shutil.which", return_value=None + ), patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.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._editable_marker_seen", return_value=False), patch( + "specify_cli.shutil.which", return_value=None + ), patch( + "specify_cli.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 TestDryRun_NonUpgradablePaths: + """--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.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._editable_marker_seen", return_value=False), patch( + "specify_cli.shutil.which", return_value=None + ), patch("specify_cli.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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", side_effect=lambda n: which_results.get(n) + ), patch("specify_cli._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 uv not found on PATH; reinstall it and retry." in strip_ansi( + result.output + ) + + def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ): + which_results: dict[str, str] = {} + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", side_effect=lambda n: which_results.get(n) + ), patch("specify_cli._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 / "uv" + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o755) + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", side_effect=lambda name: None + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._get_installed_version", return_value="0.7.5" + ), patch( + "specify_cli._verify_upgrade", return_value="0.7.6" + ), patch( + "specify_cli._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_absolute_installer_path_not_executable_gets_specific_message( + self, uv_tool_argv0, clean_environ, tmp_path + ): + fake_uv = tmp_path / "uv" + fake_uv.write_text("#!/bin/sh\n") + fake_uv.chmod(0o644) + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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 / "uv" + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._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 / "uv" + fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + fake_uv.chmod(0o755) + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", side_effect=lambda name: None + ), patch("specify_cli._get_installed_version", return_value="0.7.5"), patch( + "specify_cli._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.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 + + +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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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(_INSTALLER_TIMEOUT_SENTINEL)] + 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_real_installer_exit_124_is_not_treated_as_timeout( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/pipx" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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, + ): + from specify_cli import _UpgradePlan, _verify_upgrade + + def fake_which(name): + if name == "specify": + return None + return None + + 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.shutil.which", side_effect=fake_which + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli.sys.argv", [str(uv_tool_argv0)] + ), patch( + "specify_cli.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_ignores_python_entrypoint_and_falls_back_to_specify( + self, + clean_environ, + tmp_path, + ): + from specify_cli import _UpgradePlan, _verify_upgrade + + 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.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli.sys.argv", [str(fake_python)] + ), patch( + "specify_cli.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" + + +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.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.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.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) + + +class TestTagValidation: + """--tag regex enforcement.""" + + def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): + with patch("specify_cli.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._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.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._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.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._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.shutil.which", return_value="/usr/bin/uv"), patch( + "specify_cli._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", ""]) + def test_invalid_tags_rejected(self, bad_tag, clean_environ): + result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag]) + assert result.exit_code == 1 + combined = strip_ansi(result.output) + strip_ansi(result.stderr or "") + assert "Invalid --tag" in combined or "expected vMAJOR.MINOR.PATCH" in combined + + +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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli.subprocess.run") as mock_run, patch( + "specify_cli._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 + + +class TestDryRunWithTag_SkipsNetwork: + """--dry-run with --tag never calls urlopen.""" + + def test_urlopen_not_called_when_tag_supplied_with_dry_run( + self, + uv_tool_argv0, + clean_environ, + ): + with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli.shutil.which", return_value="/usr/bin/uv" + ), patch("specify_cli._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 + mock_urlopen.assert_not_called() diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index f2e10d8b0f..315221d8c1 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -123,7 +123,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None: setup-tasks.sh --json should exit 0 and return an absolute, existing TASKS_TEMPLATE path pointing to the core template. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" result = subprocess.run( @@ -150,7 +150,7 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None: When an override exists at .specify/templates/overrides/tasks-template.md, setup-tasks.sh --json must return the override path, not the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # Create the override overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" @@ -187,7 +187,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None: When an extension template exists, setup-tasks.sh --json must resolve tasks-template.md from the extension before falling back to the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # FIX: real extension layout is .specify/extensions//templates/.md extension_dir = ( @@ -225,7 +225,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None: When both preset and extension templates exist, setup-tasks.sh --json must resolve the preset path because presets outrank extensions. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # FIX: real extension layout is .specify/extensions//templates/.md extension_dir = ( @@ -269,7 +269,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None: When two presets both provide tasks-template.md, the one listed first in .specify/presets/.registry wins. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # resolve_template reads .specify/presets/.registry as a JSON object with a # "presets" map where each entry has a numeric "priority" (lower = higher @@ -329,7 +329,7 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None: When tasks-template.md is absent from all locations, setup-tasks.sh must exit non-zero and print a helpful ERROR message to stderr. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # Remove the core template so no template exists anywhere core = tasks_repo / ".specify" / "templates" / "tasks-template.md" @@ -429,7 +429,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None: setup-tasks.ps1 -Json should exit 0 and return an absolute, existing TASKS_TEMPLATE path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" exe = "pwsh" if HAS_PWSH else _POWERSHELL @@ -457,7 +457,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None: When an override exists at .specify/templates/overrides/tasks-template.md, setup-tasks.ps1 -Json must return the override path, not the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True, exist_ok=True) @@ -493,7 +493,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None: When tasks-template.md is absent from all locations, setup-tasks.ps1 must exit non-zero and write a helpful error to stderr. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) core = tasks_repo / ".specify" / "templates" / "tasks-template.md" core.unlink() @@ -581,4 +581,4 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json( assert result.returncode != 0 assert "Not on a feature branch" in result.stderr - \ No newline at end of file + diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index c99f675081..695d87ee48 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -853,7 +853,7 @@ def test_dry_run_with_timestamp(self, git_repo: Path): assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}" # Verify no side effects branches = subprocess.run( - ["git", "branch", "--list", f"*ts-feat*"], + ["git", "branch", "--list", "*ts-feat*"], cwd=git_repo, capture_output=True, text=True, diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 7169c44df0..25589f19cd 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -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._get_installed_version", return_value="0.7.4"), patch(