diff --git a/docs/upgrade.md b/docs/upgrade.md index 943d9f41a1..8de9a81e9f 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -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 ` 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 ` 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. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index c8fcad7c7e..4282d976f0 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -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 @@ -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)" diff --git a/src/specify_cli/selfs/command_upgrade.py b/src/specify_cli/selfs/command_upgrade.py index 1f645c0e45..c950dc2852 100644 --- a/src/specify_cli/selfs/command_upgrade.py +++ b/src/specify_cli/selfs/command_upgrade.py @@ -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 @@ -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, @@ -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 ( @@ -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(): + # 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) + installed_version = _parse_version_text(plan.current_version) verb = ( "Downgrading" diff --git a/tests/specify_cli/test_version_execution.py b/tests/specify_cli/test_version_execution.py index 9e3f2514fe..eba34884b9 100644 --- a/tests/specify_cli/test_version_execution.py +++ b/tests/specify_cli/test_version_execution.py @@ -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) @@ -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