diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f5670ef..be69bee 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -14,6 +14,7 @@ $ErrorActionPreference = "Stop" $RepoGitUrl = "git+https://github.com/Alishahryar1/free-claude-code.git" $PythonVersion = "3.14.0" +$MinUvVersion = "0.11.0" $UvInstallUrl = "https://astral.sh/uv/install.ps1" function Show-Usage { @@ -103,6 +104,184 @@ function Assert-CommandAvailable { } } +function Invoke-ProbeCommand { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + + try { + $output = & $FilePath @Arguments 2>$null + return [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = ($output | Out-String) + } + } + catch { + return [pscustomobject] @{ + ExitCode = 1 + Output = "" + } + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + +function Get-InstalledUvVersion { + $version = "" + + $selfVersionProbe = Invoke-ProbeCommand -FilePath "uv" -Arguments @("self", "version", "--short") + if ($selfVersionProbe.ExitCode -eq 0) { + $version = $selfVersionProbe.Output.Trim() + } + + if ([string]::IsNullOrWhiteSpace($version)) { + $versionProbe = Invoke-ProbeCommand -FilePath "uv" -Arguments @("--version") + if (($versionProbe.ExitCode -eq 0) -and ($versionProbe.Output -match '^uv\s+([^\s]+)')) { + $version = $Matches[1] + } + } + + if ([string]::IsNullOrWhiteSpace($version)) { + throw "Unable to determine uv version." + } + + return $version +} + +function Test-UvVersionAtLeast { + param( + [string] $Version, + [string] $Minimum + ) + + $normalizedVersion = $Version -replace '[-+].*$', '' + $normalizedMinimum = $Minimum -replace '[-+].*$', '' + return ([version] $normalizedVersion) -ge ([version] $normalizedMinimum) +} + +function Test-UvVersionSatisfiesMinimum { + $version = Get-InstalledUvVersion + return Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion +} + +function Assert-MinUvVersion { + if ($DryRun) { + return + } + + $version = Get-InstalledUvVersion + if (-not (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion)) { + throw "uv $MinUvVersion or newer is required; found uv $version. Upgrade uv with its installer or package manager, then rerun this installer." + } +} + +function Test-UvSelfUpdateSupported { + $probe = Invoke-ProbeCommand -FilePath "uv" -Arguments @("self", "update", "--dry-run") + return $probe.ExitCode -eq 0 +} + +function Test-UvInstalledByScoop { + if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) { + return $false + } + + $probe = Invoke-ProbeCommand -FilePath "scoop" -Arguments @("list", "uv") + return ($probe.ExitCode -eq 0) -and ($probe.Output -match '(^|\s)uv(\s|$)') +} + +function Test-UvInstalledByWinget { + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + return $false + } + + $probe = Invoke-ProbeCommand -FilePath "winget" -Arguments @("list", "--id", "astral-sh.uv", "-e") + return ($probe.ExitCode -eq 0) -and ($probe.Output -match 'astral-sh\.uv') +} + +function Test-UvInstalledByPipx { + if (-not (Get-Command pipx -ErrorAction SilentlyContinue)) { + return $false + } + + $probe = Invoke-ProbeCommand -FilePath "pipx" -Arguments @("list") + return ($probe.ExitCode -eq 0) -and ($probe.Output -match '(?m)\bpackage uv\b') +} + +function Test-UvInstalledInActiveVirtualenv { + if ([string]::IsNullOrWhiteSpace($env:VIRTUAL_ENV)) { + return $false + } + + $uvCommand = Get-Command uv -ErrorAction SilentlyContinue + if (-not $uvCommand) { + return $false + } + + $uvPath = [IO.Path]::GetFullPath($uvCommand.Source) + $venvPath = ([IO.Path]::GetFullPath($env:VIRTUAL_ENV)).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + $nativePrefix = "$venvPath$([IO.Path]::DirectorySeparatorChar)" + $alternatePrefix = "$venvPath$([IO.Path]::AltDirectorySeparatorChar)" + + return $uvPath.StartsWith($nativePrefix, [StringComparison]::OrdinalIgnoreCase) -or + $uvPath.StartsWith($alternatePrefix, [StringComparison]::OrdinalIgnoreCase) +} + +function Update-ExistingUv { + if (Test-UvSelfUpdateSupported) { + Invoke-InstallCommand -FilePath "uv" -Arguments @("self", "update") + return + } + + if (Test-UvInstalledByScoop) { + Invoke-InstallCommand -FilePath "scoop" -Arguments @("update", "uv") + return + } + + if (Test-UvInstalledByWinget) { + Invoke-InstallCommand -FilePath "winget" -Arguments @( + "upgrade", + "--id", + "astral-sh.uv", + "-e", + "--accept-package-agreements", + "--accept-source-agreements" + ) + return + } + + if (Test-UvInstalledByPipx) { + Invoke-InstallCommand -FilePath "pipx" -Arguments @("upgrade", "uv") + return + } + + if (Test-UvInstalledInActiveVirtualenv) { + Invoke-InstallCommand -FilePath "python" -Arguments @("-m", "pip", "install", "--upgrade", "uv") + return + } + + if (Test-UvVersionSatisfiesMinimum) { + Write-Host "uv is already installed and satisfies >=$MinUvVersion; skipping automatic uv update because the install source was not detected." + return + } + + $version = "unknown" + try { + $version = Get-InstalledUvVersion + } + catch { + $version = "unknown" + } + throw "uv $MinUvVersion or newer is required; found uv $version. The existing uv install source was not detected. Upgrade uv manually with the package manager that installed it, then rerun this installer." +} + function Install-ClaudeIfMissing { if (Get-Command claude -ErrorAction SilentlyContinue) { Write-Host "Claude Code already found on PATH; skipping install." @@ -117,7 +296,8 @@ function Install-OrUpdateUv { Add-UvToPath if (Get-Command uv -ErrorAction SilentlyContinue) { - Invoke-InstallCommand -FilePath "uv" -Arguments @("self", "update") + Update-ExistingUv + Assert-MinUvVersion return } @@ -127,6 +307,8 @@ function Install-OrUpdateUv { if ((-not $DryRun) -and (-not (Get-Command uv -ErrorAction SilentlyContinue))) { throw "uv was installed, but it is not available on PATH. Open a new terminal or add uv's bin directory to PATH." } + + Assert-MinUvVersion } function Get-PackageSpec { diff --git a/scripts/install.sh b/scripts/install.sh index d5aa437..ede2dcf 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,6 +3,7 @@ set -eu REPO_GIT_URL="git+https://github.com/Alishahryar1/free-claude-code.git" PYTHON_VERSION="3.14.0" +MIN_UV_VERSION="0.11.0" UV_INSTALL_URL="https://astral.sh/uv/install.sh" dry_run=0 @@ -99,6 +100,105 @@ require_command() { fi } +current_uv_version() { + version=$(uv self version --short 2>/dev/null || true) + if [ -z "$version" ]; then + version=$(uv --version 2>/dev/null | sed 's/^uv //; s/ .*//' || true) + fi + + [ -n "$version" ] || return 1 + printf '%s\n' "$version" +} + +version_ge() { + current=${1%%[-+]*} + minimum=${2%%[-+]*} + + old_ifs=$IFS + IFS=. + set -- $current + current_major=${1:-0} + current_minor=${2:-0} + current_patch=${3:-0} + set -- $minimum + minimum_major=${1:-0} + minimum_minor=${2:-0} + minimum_patch=${3:-0} + IFS=$old_ifs + + [ "$current_major" -gt "$minimum_major" ] && return 0 + [ "$current_major" -lt "$minimum_major" ] && return 1 + [ "$current_minor" -gt "$minimum_minor" ] && return 0 + [ "$current_minor" -lt "$minimum_minor" ] && return 1 + [ "$current_patch" -ge "$minimum_patch" ] +} + +uv_version_satisfies_minimum() { + version=$(current_uv_version) || return 1 + version_ge "$version" "$MIN_UV_VERSION" +} + +validate_uv_version() { + [ "$dry_run" -eq 1 ] && return 0 + + version=$(current_uv_version) || fail "Unable to determine uv version." + if ! version_ge "$version" "$MIN_UV_VERSION"; then + fail "uv $MIN_UV_VERSION or newer is required; found uv $version. Upgrade uv with its installer or package manager, then rerun this installer." + fi +} + +uv_self_update_supported() { + uv self update --dry-run >/dev/null 2>&1 +} + +uv_installed_by_homebrew() { + command -v brew >/dev/null 2>&1 && brew list --versions uv >/dev/null 2>&1 +} + +uv_installed_by_pipx() { + command -v pipx >/dev/null 2>&1 && pipx list 2>/dev/null | grep -Eq '(^|[[:space:]])package uv([[:space:]]|$)' +} + +uv_installed_in_active_virtualenv() { + [ -n "${VIRTUAL_ENV:-}" ] || return 1 + + uv_path=$(command -v uv) + case "$uv_path" in + "$VIRTUAL_ENV"/*) return 0 ;; + *) return 1 ;; + esac +} + +update_existing_uv() { + if uv_self_update_supported; then + run uv self update + return 0 + fi + + if uv_installed_by_homebrew; then + run brew upgrade uv + return 0 + fi + + if uv_installed_by_pipx; then + run pipx upgrade uv + return 0 + fi + + if uv_installed_in_active_virtualenv; then + run python -m pip install --upgrade uv + return 0 + fi + + if uv_version_satisfies_minimum; then + printf 'uv is already installed and satisfies >=%s; skipping automatic uv update because the install source was not detected.\n' "$MIN_UV_VERSION" + return 0 + fi + + version=$(current_uv_version 2>/dev/null || printf 'unknown') + fail "uv $MIN_UV_VERSION or newer is required; found uv $version. The existing uv install source was not detected. Upgrade uv manually with the package manager that installed it, then rerun this installer." +} + install_claude_if_missing() { if command -v claude >/dev/null 2>&1; then printf 'Claude Code already found on PATH; skipping install.\n' @@ -113,7 +213,8 @@ install_or_update_uv() { add_uv_to_path if command -v uv >/dev/null 2>&1; then - run uv self update + update_existing_uv + validate_uv_version return 0 fi @@ -123,6 +224,8 @@ install_or_update_uv() { if [ "$dry_run" -eq 0 ] && ! command -v uv >/dev/null 2>&1; then fail "uv was installed, but it is not available on PATH. Open a new terminal or add uv's bin directory to PATH." fi + + validate_uv_version } parse_args() { diff --git a/tests/scripts/test_installers.py b/tests/scripts/test_installers.py index 7709c1f..d59a0a6 100644 --- a/tests/scripts/test_installers.py +++ b/tests/scripts/test_installers.py @@ -42,17 +42,51 @@ def test_install_sh_installs_claude_only_when_missing() -> None: def test_install_sh_installs_missing_uv_without_self_update() -> None: - body = _braced_body(_script_text("install.sh"), "install_or_update_uv()") + text = _script_text("install.sh") + body = _braced_body(text, "install_or_update_uv()") assert "if command -v uv >/dev/null 2>&1; then" in body - assert body.count("run uv self update") == 1 + assert "update_existing_uv" in body + assert "run uv self update" not in body - update_index = body.index("run uv self update") - return_index = body.index("return 0", update_index) + update_index = body.index("update_existing_uv") + validate_existing_index = body.index("validate_uv_version", update_index) installer_index = body.index("run_uv_installer") + validate_installed_index = body.index("validate_uv_version", installer_index) verification_index = body.index('if [ "$dry_run" -eq 0 ] && ! command -v uv') - assert update_index < return_index < installer_index < verification_index + assert update_index < validate_existing_index < installer_index + assert installer_index < verification_index < validate_installed_index + + +def test_install_sh_updates_uv_with_detected_source() -> None: + text = _script_text("install.sh") + update_body = _braced_body(text, "update_existing_uv()") + + assert "uv self update --dry-run" in text + assert update_body.count("run uv self update") == 1 + assert update_body.index("uv_self_update_supported") < update_body.index( + "run uv self update" + ) + + assert "brew list --versions uv" in text + assert "run brew upgrade uv" in update_body + assert "pipx list" in text + assert "run pipx upgrade uv" in update_body + assert "VIRTUAL_ENV" in text + assert "run python -m pip install --upgrade uv" in update_body + assert "uv_version_satisfies_minimum" in update_body + assert "install source was not detected" in update_body + + +def test_install_sh_validates_minimum_uv_version() -> None: + text = _script_text("install.sh") + validate_body = _braced_body(text, "validate_uv_version()") + + assert 'MIN_UV_VERSION="0.11.0"' in text + assert "uv self version --short" in text + assert "version_ge" in validate_body + assert "uv $MIN_UV_VERSION or newer is required" in validate_body def test_install_ps1_installs_claude_only_when_missing() -> None: @@ -76,15 +110,59 @@ def test_install_ps1_installs_claude_only_when_missing() -> None: def test_install_ps1_installs_missing_uv_without_self_update() -> None: - body = _braced_body(_script_text("install.ps1"), "function Install-OrUpdateUv") + text = _script_text("install.ps1") + body = _braced_body(text, "function Install-OrUpdateUv") self_update = 'Invoke-InstallCommand -FilePath "uv" -Arguments @("self", "update")' assert "if (Get-Command uv -ErrorAction SilentlyContinue)" in body - assert body.count(self_update) == 1 + assert "Update-ExistingUv" in body + assert self_update not in body - update_index = body.index(self_update) - return_index = body.index("return", update_index) + update_index = body.index("Update-ExistingUv") + validate_existing_index = body.index("Assert-MinUvVersion", update_index) installer_index = body.index("Invoke-UvInstaller") verification_index = body.index("if ((-not $DryRun)") + validate_installed_index = body.index("Assert-MinUvVersion", installer_index) - assert update_index < return_index < installer_index < verification_index + assert update_index < validate_existing_index < installer_index + assert installer_index < verification_index < validate_installed_index + + +def test_install_ps1_updates_uv_with_detected_source() -> None: + text = _script_text("install.ps1") + update_body = _braced_body(text, "function Update-ExistingUv") + self_update = 'Invoke-InstallCommand -FilePath "uv" -Arguments @("self", "update")' + + assert '"self", "update", "--dry-run"' in text + assert update_body.count(self_update) == 1 + assert update_body.index("Test-UvSelfUpdateSupported") < update_body.index( + self_update + ) + + assert ( + 'Invoke-InstallCommand -FilePath "scoop" -Arguments @("update", "uv")' + in update_body + ) + assert '"winget"' in update_body + assert '"astral-sh.uv"' in update_body + assert '"--accept-package-agreements"' in update_body + assert ( + 'Invoke-InstallCommand -FilePath "pipx" -Arguments @("upgrade", "uv")' + in update_body + ) + assert ( + 'Invoke-InstallCommand -FilePath "python" -Arguments @("-m", "pip", "install", "--upgrade", "uv")' + in update_body + ) + assert "Test-UvVersionSatisfiesMinimum" in update_body + assert "install source was not detected" in update_body + + +def test_install_ps1_validates_minimum_uv_version() -> None: + text = _script_text("install.ps1") + validate_body = _braced_body(text, "function Assert-MinUvVersion") + + assert '$MinUvVersion = "0.11.0"' in text + assert '"self", "version", "--short"' in text + assert "[version]" in text + assert "uv $MinUvVersion or newer is required" in validate_body