⚡ Sangsakala Proxy 🏠 Home
Skip to content

Commit f22ae68

Browse files
authored
fix(cli): don't self-upgrade uv tool installs on Windows (#5149)
* fix(cli): don't self-upgrade uv tool installs on Windows `uv tool install --force` deletes the tool environment before reinstalling it. When `specify self upgrade` runs it from that environment on Windows, uv removes Lib\site-packages and then fails on Scripts\ because the running python.exe is locked, leaving `specify` without its package. When the running interpreter is a uv tool environment on Windows, print the uv command to run once specify exits and make no changes. pipx installs and other platforms are unchanged. Fixes #5141 Assisted-by: Claude Code (model: Claude Opus 5.5, supervised) * fix(cli): preview the Windows uv tool outcome in self upgrade --dry-run On Windows, a real `specify self upgrade` from a uv tool environment prints the uv command instead of running it, but `--dry-run` still listed that command as the one that would be executed. Preview the same outcome, printing the command to run once specify exits exactly as the real run does, and add the Windows exception to the `--help` text. Refs #5141 Assisted-by: Claude Code (model: Claude Opus 5.5, supervised) * fix(cli): tell Windows uv tool users to stop other specify processes Exiting `specify self upgrade` does not release the Windows lock on the tool environment's python.exe if another process from it is still running, such as an editor's `specify mcp` server. Running the printed uv command then fails partway through the same way. Say to stop those processes first, in both the real run and --dry-run output and in docs/upgrade.md. Refs #5141 Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) * test(cli): pin the Windows uv tool no-op when already current On a Windows uv tool install that is already on the latest or requested release, self upgrade reports that and exits before the manual uv command guidance. Cover both cases so the guard can't move ahead of the version check unnoticed. * test(cli): use an absolute installer path in the Windows uv tool tests Since #5172, self upgrade rejects an installer lookup that returns a relative path, so mocking shutil.which as "uv" or "pipx" now stops the upgrade with "installer rejected". Return sys.executable instead, as the other self upgrade tests on main do, and render the expected non-Windows dry-run command from that path.
1 parent e4103be commit f22ae68

4 files changed

Lines changed: 296 additions & 2 deletions

File tree

‎docs/upgrade.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ specify self upgrade
4343
specify self upgrade --tag vX.Y.Z[suffix]
4444
```
4545

46-
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.
46+
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.
4747

4848
Tool detection recognizes current and legacy default installation directories, as well as directories configured with `UV_TOOL_DIR`, `PIPX_HOME`, or `XDG_DATA_HOME`. Keep the same directory environment variables set when upgrading a relocated installation so the installer also targets that location.
4949

‎src/specify_cli/_version.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,26 @@ def _installer_binary_name(method: _InstallMethod) -> str | None:
769769
return None
770770

771771

772+
def _runs_from_uv_tool_env_on_windows() -> bool:
773+
"""Return whether this process runs from a uv tool environment on Windows."""
774+
return os.name == "nt" and os.path.isfile(
775+
os.path.join(sys.prefix, "uv-receipt.toml")
776+
)
777+
778+
779+
# Another process from the same tool environment, such as an editor's
780+
# `specify mcp` server, keeps python.exe locked after this command exits.
781+
_UV_TOOL_MANUAL_INTRO = (
782+
"Stop any other specify process (such as `specify mcp`), then run this "
783+
"once this command exits:"
784+
)
785+
786+
787+
def _uv_tool_manual_command(target_tag: str | None) -> str:
788+
"""Return the uv command a Windows uv-tool user runs after specify exits."""
789+
return f"uv tool install specify-cli --force --from {_source_spec(target_tag)}"
790+
791+
772792
def _is_path_like_command(value: str) -> bool:
773793
"""Return whether an argv[0] names a path rather than a bare command."""
774794
return Path(value).parent != Path(".") or "/" in value or "\\" in value
@@ -834,6 +854,12 @@ def _build_upgrade_plan(
834854
if installer_rejected
835855
else f"(installer {_installer_binary_name(method)} not found on PATH)"
836856
)
857+
elif method == _InstallMethod.UV_TOOL and _runs_from_uv_tool_env_on_windows():
858+
# self_upgrade prints the uv command instead of running it.
859+
command_preview = (
860+
"(none — on Windows, uv cannot replace the tool environment specify "
861+
"is running from)"
862+
)
837863
else:
838864
command_preview = (
839865
_render_argv(argv) if argv is not None else "(none — non-upgradable path)"

‎src/specify_cli/selfs/command_upgrade.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ def self_upgrade(
3333
Detection classifies the runtime into uv-tool / pipx / uvx (ephemeral) /
3434
source-checkout / unsupported. Only uv-tool and pipx are upgraded
3535
automatically; the other three paths print path-specific guidance and
36-
exit 0.
36+
exit 0. On Windows, uv cannot replace the uv-tool environment specify is
37+
running from, so uv-tool installs print the uv command to run once
38+
specify exits, and exit 0 without changes.
3739
3840
Exit codes:
3941
0 success or no-op-success (already on latest, --dry-run, or
@@ -73,6 +75,9 @@ def self_upgrade(
7375
_parse_version_text,
7476
_render_argv,
7577
_run_installer,
78+
_runs_from_uv_tool_env_on_windows,
79+
_UV_TOOL_MANUAL_INTRO,
80+
_uv_tool_manual_command,
7681
_validate_tag,
7782
_verify_upgrade,
7883
console,
@@ -113,6 +118,13 @@ def self_upgrade(
113118
console.print("Dry run — no changes will be made.")
114119
for line in plan.preview_summary.splitlines():
115120
console.print(line)
121+
if (
122+
plan.method == _InstallMethod.UV_TOOL
123+
and plan.installer_argv is not None
124+
and _runs_from_uv_tool_env_on_windows()
125+
):
126+
console.print(_UV_TOOL_MANUAL_INTRO, soft_wrap=True)
127+
console.print(f" {_uv_tool_manual_command(plan.target_tag)}", soft_wrap=True)
116128
raise typer.Exit(0)
117129

118130
if plan.method in (
@@ -157,6 +169,21 @@ def self_upgrade(
157169
console.print(f"Already on requested release: {target_tag}")
158170
raise typer.Exit(0)
159171

172+
if plan.method == _InstallMethod.UV_TOOL and _runs_from_uv_tool_env_on_windows():
173+
# uv deletes the tool environment before reinstalling it, and Windows
174+
# will not delete the python.exe this process is running from, so uv
175+
# would leave the environment without its packages. Leave the
176+
# reinstall to the user's shell instead.
177+
console.print(
178+
f"Not upgrading specify-cli {plan.current_version} → {target_tag}: "
179+
"on Windows, uv cannot replace the tool environment specify is "
180+
"running from. No changes were made.",
181+
soft_wrap=True,
182+
)
183+
console.print(_UV_TOOL_MANUAL_INTRO, soft_wrap=True)
184+
console.print(f" {_uv_tool_manual_command(target_tag)}", soft_wrap=True)
185+
raise typer.Exit(0)
186+
160187
installed_version = _parse_version_text(plan.current_version)
161188
verb = (
162189
"Downgrading"

‎tests/specify_cli/test_version_execution.py‎

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Installer execution, verification, and error-path tests for `specify self upgrade`."""
22

33
import errno
4+
import os
45
import subprocess
56
import sys
67
from unittest.mock import patch
78

89
from specify_cli import app
10+
from specify_cli._version import _render_argv, _runs_from_uv_tool_env_on_windows
911

1012
from tests.specify_cli.self_upgrade_helpers import (
1113
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
@@ -542,3 +544,242 @@ def test_prerelease_failure_degrades_rollback_hint_to_releases_page(
542544
assert "Previous version was not an exact stable release tag" in out
543545
assert "https://github.com/github/spec-kit/releases" in out
544546
assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out
547+
548+
549+
class TestWindowsUvToolEnvironment:
550+
"""Windows uv-tool installs cannot reinstall the environment they run from."""
551+
552+
def test_windows_uv_tool_prints_command_without_running_installer(
553+
self, uv_tool_argv0, clean_environ
554+
):
555+
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
556+
"specify_cli._version.shutil.which", return_value=sys.executable
557+
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
558+
"specify_cli._version._get_installed_version", return_value="1.2.2"
559+
), patch(
560+
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
561+
):
562+
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
563+
result = runner.invoke(app, ["self", "upgrade"])
564+
565+
assert result.exit_code == 0
566+
out = strip_ansi(result.output)
567+
assert "Not upgrading specify-cli 1.2.2 → v1.2.4" in out
568+
assert (
569+
" uv tool install specify-cli --force --from "
570+
"git+https://github.com/github/spec-kit.git@v1.2.4"
571+
) in out
572+
assert "Upgrade failed" not in out
573+
assert mock_run.call_count == 0
574+
575+
def test_windows_uv_tool_already_on_latest_is_noop(
576+
self, uv_tool_argv0, clean_environ
577+
):
578+
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
579+
"specify_cli._version.shutil.which", return_value=sys.executable
580+
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
581+
"specify_cli._version._get_installed_version", return_value="1.2.4"
582+
), patch(
583+
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
584+
):
585+
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
586+
result = runner.invoke(app, ["self", "upgrade"])
587+
588+
assert result.exit_code == 0
589+
out = strip_ansi(result.output)
590+
assert "Already on latest release: v1.2.4" in out
591+
assert "Not upgrading" not in out
592+
assert "uv tool install" not in out
593+
assert mock_run.call_count == 0
594+
595+
def test_windows_uv_tool_already_on_requested_tag_is_noop(
596+
self, uv_tool_argv0, clean_environ
597+
):
598+
with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch(
599+
"specify_cli._version.subprocess.run"
600+
) as mock_run, patch(
601+
"specify_cli._version._get_installed_version", return_value="1.2.4"
602+
), patch(
603+
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
604+
):
605+
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.2.4"])
606+
607+
assert result.exit_code == 0
608+
out = strip_ansi(result.output)
609+
assert "Already on requested release: v1.2.4" in out
610+
assert "Not upgrading" not in out
611+
assert "uv tool install" not in out
612+
assert mock_run.call_count == 0
613+
614+
def test_windows_pipx_still_runs_installer(self, pipx_argv0, clean_environ):
615+
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
616+
"specify_cli._version.shutil.which", return_value=sys.executable
617+
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
618+
"specify_cli._version._get_installed_version", return_value="1.2.2"
619+
), patch(
620+
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
621+
):
622+
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
623+
mock_run.side_effect = [
624+
_completed_process(0), # installer
625+
_completed_process(0, stdout="specify 1.2.4\n"), # verify
626+
]
627+
result = runner.invoke(app, ["self", "upgrade"])
628+
629+
assert result.exit_code == 0
630+
assert "Upgraded specify-cli: 1.2.2 → 1.2.4" in strip_ansi(result.output)
631+
assert mock_run.call_count == 2
632+
633+
def test_windows_uv_tool_dry_run_previews_manual_command(
634+
self, uv_tool_argv0, clean_environ
635+
):
636+
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
637+
"specify_cli._version.shutil.which", return_value=sys.executable
638+
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
639+
"specify_cli._version._get_installed_version", return_value="1.2.2"
640+
), patch(
641+
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
642+
):
643+
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
644+
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
645+
646+
assert result.exit_code == 0
647+
out = strip_ansi(result.output)
648+
assert "Dry run — no changes will be made." in out
649+
assert "Target version: v1.2.4" in out
650+
assert (
651+
"Command that would be executed: (none — on Windows, uv cannot "
652+
"replace the tool environment specify is running from)"
653+
) in " ".join(out.split())
654+
assert (
655+
"Stop any other specify process (such as `specify mcp`), then run "
656+
"this once this command exits:\n"
657+
" uv tool install specify-cli --force --from "
658+
"git+https://github.com/github/spec-kit.git@v1.2.4\n"
659+
) in out
660+
assert mock_run.call_count == 0
661+
662+
def test_windows_uv_tool_dry_run_with_tag_previews_pinned_command(
663+
self, uv_tool_argv0, clean_environ
664+
):
665+
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
666+
"specify_cli._version.shutil.which", return_value=sys.executable
667+
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
668+
"specify_cli._version._get_installed_version", return_value="1.2.2"
669+
), patch(
670+
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
671+
):
672+
result = runner.invoke(
673+
app, ["self", "upgrade", "--dry-run", "--tag", "v1.2.0"]
674+
)
675+
676+
assert result.exit_code == 0
677+
assert (
678+
"\n uv tool install specify-cli --force --from "
679+
"git+https://github.com/github/spec-kit.git@v1.2.0\n"
680+
) in strip_ansi(result.output)
681+
mock_urlopen.assert_not_called()
682+
assert mock_run.call_count == 0
683+
684+
def test_windows_uv_tool_dry_run_matches_real_run_command(
685+
self, uv_tool_argv0, clean_environ
686+
):
687+
outputs = {}
688+
for args in (["self", "upgrade", "--dry-run"], ["self", "upgrade"]):
689+
with patch(
690+
"specify_cli.authentication.http.urllib.request.urlopen"
691+
) as mock_urlopen, patch(
692+
"specify_cli._version.shutil.which", return_value=sys.executable
693+
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
694+
"specify_cli._version._get_installed_version", return_value="1.2.2"
695+
), patch(
696+
"specify_cli._version._runs_from_uv_tool_env_on_windows",
697+
return_value=True,
698+
):
699+
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
700+
result = runner.invoke(app, args)
701+
assert result.exit_code == 0
702+
assert mock_run.call_count == 0
703+
outputs[len(args)] = strip_ansi(result.output)
704+
705+
manual_steps = (
706+
"Stop any other specify process (such as `specify mcp`), then run "
707+
"this once this command exits:\n"
708+
" uv tool install specify-cli --force --from "
709+
"git+https://github.com/github/spec-kit.git@v1.2.4\n"
710+
)
711+
assert manual_steps in outputs[3]
712+
assert manual_steps in outputs[2]
713+
714+
def test_non_windows_uv_tool_dry_run_still_previews_installer(
715+
self, uv_tool_argv0, clean_environ
716+
):
717+
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
718+
"specify_cli._version.shutil.which", return_value=sys.executable
719+
), patch("specify_cli._version.subprocess.run"), patch(
720+
"specify_cli._version._get_installed_version", return_value="1.2.2"
721+
), patch(
722+
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=False
723+
):
724+
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
725+
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
726+
727+
assert result.exit_code == 0
728+
out = strip_ansi(result.output)
729+
expected_argv = _render_argv(
730+
[
731+
sys.executable,
732+
"tool",
733+
"install",
734+
"specify-cli",
735+
"--force",
736+
"--from",
737+
"git+https://github.com/github/spec-kit.git@v1.2.4",
738+
]
739+
)
740+
assert f"Command that would be executed: {expected_argv}" in " ".join(
741+
out.split()
742+
)
743+
assert "then run this once this command exits" not in out
744+
745+
def test_windows_uv_tool_dry_run_without_uv_reports_missing_installer(
746+
self, uv_tool_argv0, clean_environ
747+
):
748+
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
749+
"specify_cli._version.shutil.which", return_value=None
750+
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
751+
"specify_cli._version._get_installed_version", return_value="1.2.2"
752+
), patch(
753+
"specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True
754+
):
755+
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"})
756+
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
757+
758+
assert result.exit_code == 0
759+
out = strip_ansi(result.output)
760+
assert "Command that would be executed: (installer uv not found on PATH)" in out
761+
assert "then run this once this command exits" not in out
762+
assert mock_run.call_count == 0
763+
764+
def test_help_mentions_windows_uv_tool_exception(self):
765+
result = runner.invoke(app, ["self", "upgrade", "--help"])
766+
assert result.exit_code == 0
767+
out = " ".join(strip_ansi(result.output).split())
768+
assert "On Windows, uv cannot replace the uv-tool environment" in out
769+
770+
def test_detects_uv_tool_environment_on_windows(self, tmp_path, monkeypatch):
771+
(tmp_path / "uv-receipt.toml").write_text("[tool]\n")
772+
monkeypatch.setattr("sys.prefix", str(tmp_path))
773+
monkeypatch.setattr(os, "name", "nt")
774+
assert _runs_from_uv_tool_env_on_windows() is True
775+
776+
def test_ignores_environment_without_uv_receipt(self, tmp_path, monkeypatch):
777+
monkeypatch.setattr("sys.prefix", str(tmp_path))
778+
monkeypatch.setattr(os, "name", "nt")
779+
assert _runs_from_uv_tool_env_on_windows() is False
780+
781+
def test_ignores_uv_tool_environment_off_windows(self, tmp_path, monkeypatch):
782+
(tmp_path / "uv-receipt.toml").write_text("[tool]\n")
783+
monkeypatch.setattr("sys.prefix", str(tmp_path))
784+
monkeypatch.setattr(os, "name", "posix")
785+
assert _runs_from_uv_tool_env_on_windows() is False

0 commit comments

Comments
 (0)