Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ specify self upgrade
specify self upgrade --tag vX.Y.Z[suffix]
```

Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything.
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work. On Windows, `uv` cannot replace the tool environment `specify` is running from, so for `uv tool` installs `specify self upgrade` prints that command for you to run once it exits, and exits 0 without touching anything. Stop any other process running from that environment first, such as a `specify mcp` server, or uv can still fail partway through. The other paths print path-specific guidance and exit 0 without touching anything.

Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected.

Expand Down
26 changes: 26 additions & 0 deletions src/specify_cli/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,26 @@ def _installer_binary_name(method: _InstallMethod) -> str | None:
return None


def _runs_from_uv_tool_env_on_windows() -> bool:
"""Return whether this process runs from a uv tool environment on Windows."""
return os.name == "nt" and os.path.isfile(
os.path.join(sys.prefix, "uv-receipt.toml")
)


# Another process from the same tool environment, such as an editor's
# `specify mcp` server, keeps python.exe locked after this command exits.
_UV_TOOL_MANUAL_INTRO = (
"Stop any other specify process (such as `specify mcp`), then run this "
"once this command exits:"
)


def _uv_tool_manual_command(target_tag: str | None) -> str:
"""Return the uv command a Windows uv-tool user runs after specify exits."""
return f"uv tool install specify-cli --force --from {_source_spec(target_tag)}"


def _is_path_like_command(value: str) -> bool:
"""Return whether an argv[0] names a path rather than a bare command."""
return Path(value).parent != Path(".") or "/" in value or "\\" in value
Expand Down Expand Up @@ -729,6 +749,12 @@ def _build_upgrade_plan(
command_preview = (
f"(installer {_installer_binary_name(method)} not found on PATH)"
)
elif method == _InstallMethod.UV_TOOL and _runs_from_uv_tool_env_on_windows():
# self_upgrade prints the uv command instead of running it.
command_preview = (
"(none — on Windows, uv cannot replace the tool environment specify "
"is running from)"
)
else:
command_preview = (
_render_argv(argv) if argv is not None else "(none — non-upgradable path)"
Expand Down
29 changes: 28 additions & 1 deletion src/specify_cli/selfs/command_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ def self_upgrade(
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 0. On Windows, uv cannot replace the uv-tool environment specify is
running from, so uv-tool installs print the uv command to run once
specify exits, and exit 0 without changes.

Exit codes:
0 success or no-op-success (already on latest, --dry-run, or
Expand Down Expand Up @@ -73,6 +75,9 @@ def self_upgrade(
_parse_version_text,
_render_argv,
_run_installer,
_runs_from_uv_tool_env_on_windows,
_UV_TOOL_MANUAL_INTRO,
_uv_tool_manual_command,
_validate_tag,
_verify_upgrade,
console,
Expand Down Expand Up @@ -110,6 +115,13 @@ def self_upgrade(
console.print("Dry run — no changes will be made.")
for line in plan.preview_summary.splitlines():
console.print(line)
if (
plan.method == _InstallMethod.UV_TOOL
and plan.installer_argv is not None
and _runs_from_uv_tool_env_on_windows()
):
console.print(_UV_TOOL_MANUAL_INTRO, soft_wrap=True)
console.print(f" {_uv_tool_manual_command(plan.target_tag)}", soft_wrap=True)
raise typer.Exit(0)

if plan.method in (
Expand Down Expand Up @@ -153,6 +165,21 @@ def self_upgrade(
console.print(f"Already on requested release: {target_tag}")
raise typer.Exit(0)

if plan.method == _InstallMethod.UV_TOOL and _runs_from_uv_tool_env_on_windows():
Comment thread
Copilot marked this conversation as resolved.
# uv deletes the tool environment before reinstalling it, and Windows
# will not delete the python.exe this process is running from, so uv
# would leave the environment without its packages. Leave the
# reinstall to the user's shell instead.
console.print(
f"Not upgrading specify-cli {plan.current_version} → {target_tag}: "
"on Windows, uv cannot replace the tool environment specify is "
"running from. No changes were made.",
soft_wrap=True,
)
console.print(_UV_TOOL_MANUAL_INTRO, soft_wrap=True)
console.print(f" {_uv_tool_manual_command(target_tag)}", soft_wrap=True)
raise typer.Exit(0)
Comment thread
Copilot marked this conversation as resolved.

installed_version = _parse_version_text(plan.current_version)
verb = (
"Downgrading"
Expand Down
192 changes: 192 additions & 0 deletions tests/specify_cli/test_version_execution.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Installer execution, verification, and error-path tests for `specify self upgrade`."""

import errno
import os
import subprocess
from unittest.mock import patch

from specify_cli import app
from specify_cli._version import _runs_from_uv_tool_env_on_windows

from tests.specify_cli.self_upgrade_helpers import (
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
Expand Down Expand Up @@ -541,3 +543,193 @@ def test_prerelease_failure_degrades_rollback_hint_to_releases_page(
assert "Previous version was not an exact stable release tag" in out
assert "https://github.com/github/spec-kit/releases" in out
assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out


class TestWindowsUvToolEnvironment:
"""Windows uv-tool installs cannot reinstall the environment they run from."""

def test_windows_uv_tool_prints_command_without_running_installer(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="1.2.2"
), patch(
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
result = runner.invoke(app, ["self", "upgrade"])

assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Not upgrading specify-cli 1.2.2 → v1.2.4" in out
assert (
" uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@v1.2.4"
) in out
assert "Upgrade failed" not in out
assert mock_run.call_count == 0

def test_windows_pipx_still_runs_installer(self, pipx_argv0, clean_environ):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="pipx"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="1.2.2"
), patch(
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
mock_run.side_effect = [
_completed_process(0), # installer
_completed_process(0, stdout="specify 1.2.4\n"), # verify
]
result = runner.invoke(app, ["self", "upgrade"])

assert result.exit_code == 0
assert "Upgraded specify-cli: 1.2.2 → 1.2.4" in strip_ansi(result.output)
assert mock_run.call_count == 2

def test_windows_uv_tool_dry_run_previews_manual_command(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="1.2.2"
), patch(
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
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 "Target version: v1.2.4" in out
assert (
"Command that would be executed: (none — on Windows, uv cannot "
"replace the tool environment specify is running from)"
) in " ".join(out.split())
assert (
"Stop any other specify process (such as `specify mcp`), then run "
"this once this command exits:\n"
" uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@v1.2.4\n"
) in out
assert mock_run.call_count == 0

def test_windows_uv_tool_dry_run_with_tag_previews_pinned_command(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="1.2.2"
), patch(
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
):
result = runner.invoke(
app, ["self", "upgrade", "--dry-run", "--tag", "v1.2.0"]
)

assert result.exit_code == 0
assert (
"\n uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@v1.2.0\n"
) in strip_ansi(result.output)
mock_urlopen.assert_not_called()
assert mock_run.call_count == 0

def test_windows_uv_tool_dry_run_matches_real_run_command(
self, uv_tool_argv0, clean_environ
):
outputs = {}
for args in (["self", "upgrade", "--dry-run"], ["self", "upgrade"]):
with patch(
"specify_cli.authentication.http.urllib.request.urlopen"
) as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="1.2.2"
), patch(
"specify_cli._version._runs_from_uv_tool_env_on_windows",
return_value=True,
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
result = runner.invoke(app, args)
assert result.exit_code == 0
assert mock_run.call_count == 0
outputs[len(args)] = strip_ansi(result.output)

manual_steps = (
"Stop any other specify process (such as `specify mcp`), then run "
"this once this command exits:\n"
" uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@v1.2.4\n"
)
assert manual_steps in outputs[3]
assert manual_steps in outputs[2]

def test_non_windows_uv_tool_dry_run_still_previews_installer(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run"), patch(
"specify_cli._version._get_installed_version", return_value="1.2.2"
), patch(
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=False
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])

assert result.exit_code == 0
out = strip_ansi(result.output)
assert (
"Command that would be executed: uv tool install specify-cli --force "
"--from git+https://github.com/github/spec-kit.git@v1.2.4"
) in " ".join(out.split())
assert "then run this once this command exits" not in out

def test_windows_uv_tool_dry_run_without_uv_reports_missing_installer(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value=None
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="1.2.2"
), patch(
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])

assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Command that would be executed: (installer uv not found on PATH)" in out
assert "then run this once this command exits" not in out
assert mock_run.call_count == 0

def test_help_mentions_windows_uv_tool_exception(self):
result = runner.invoke(app, ["self", "upgrade", "--help"])
assert result.exit_code == 0
out = " ".join(strip_ansi(result.output).split())
assert "On Windows, uv cannot replace the uv-tool environment" in out

def test_detects_uv_tool_environment_on_windows(self, tmp_path, monkeypatch):
(tmp_path / "uv-receipt.toml").write_text("[tool]\n")
monkeypatch.setattr("sys.prefix", str(tmp_path))
monkeypatch.setattr(os, "name", "nt")
assert _runs_from_uv_tool_env_on_windows() is True

def test_ignores_environment_without_uv_receipt(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.prefix", str(tmp_path))
monkeypatch.setattr(os, "name", "nt")
assert _runs_from_uv_tool_env_on_windows() is False

def test_ignores_uv_tool_environment_off_windows(self, tmp_path, monkeypatch):
(tmp_path / "uv-receipt.toml").write_text("[tool]\n")
monkeypatch.setattr("sys.prefix", str(tmp_path))
monkeypatch.setattr(os, "name", "posix")
assert _runs_from_uv_tool_env_on_windows() is False
Loading