diff --git a/eng/scripts/get-aspire-cli.ps1 b/eng/scripts/get-aspire-cli.ps1 index 9846bbebee7..62ba29682a5 100755 --- a/eng/scripts/get-aspire-cli.ps1 +++ b/eng/scripts/get-aspire-cli.ps1 @@ -1,5 +1,80 @@ #!/usr/bin/env pwsh +<# +.SYNOPSIS + Download and install the Aspire CLI + +.DESCRIPTION + Downloads and installs the Aspire CLI for the current platform from the specified version and quality. + Automatically updates the current session's PATH environment variable and supports GitHub Actions. + + Running with `-Quality release` downloads the latest release version of the Aspire CLI for your platform and architecture. + Running with `-Quality staging` downloads the latest staging version, or the release version if no staging is available. + Running with `-Quality dev` downloads the latest dev build from `main`. + + The default quality is 'release'. + + Pass a specific version to get CLI for that version. + +.PARAMETER InstallPath + Directory to install the CLI (default: %USERPROFILE%\.aspire\bin on Windows, `$HOME/.aspire/bin on Unix) + +.PARAMETER Version + Version of the Aspire CLI to download (default: unset) + +.PARAMETER Quality + Quality to download (default: release) + +.PARAMETER OS + Operating system (default: auto-detect) + +.PARAMETER Architecture + Architecture (default: auto-detect) + +.PARAMETER KeepArchive + Keep downloaded archive files and temporary directory after installation + +.EXAMPLE + .\get-aspire-cli.ps1 + +.EXAMPLE + .\get-aspire-cli.ps1 -InstallPath "C:\tools\aspire" + +.EXAMPLE + .\get-aspire-cli.ps1 -Quality "staging" + +.EXAMPLE + .\get-aspire-cli.ps1 -Version "9.5.0-preview.1.25366.3" + +.EXAMPLE + .\get-aspire-cli.ps1 -OS "linux" -Architecture "x64" + +.EXAMPLE + .\get-aspire-cli.ps1 -KeepArchive + +.EXAMPLE + .\get-aspire-cli.ps1 -WhatIf + +.EXAMPLE + # Piped execution + iex "& { $(irm https://aka.ms/aspire/get/install.ps1) }" + +.EXAMPLE + # Piped execution + iex "& { $(irm https://aka.ms/aspire/get/install.ps1) } -Quality staging" + +.NOTES + The script automatically updates the PATH environment variable for the current session. + + Windows: The script will also add the installation path to the user's persistent PATH + environment variable and to the session PATH, making the aspire CLI available in the existing and new terminal sessions. + + GitHub Actions Support: + When running in GitHub Actions (GITHUB_ACTIONS=true), the script will automatically + append the installation path to the GITHUB_PATH file to make the CLI available in + subsequent workflow steps. +#> + [CmdletBinding(SupportsShouldProcess)] param( [Parameter(HelpMessage = "Directory to install the CLI")] @@ -21,10 +96,7 @@ param( [string]$Architecture = "", [Parameter(HelpMessage = "Keep downloaded archive files and temporary directory after installation")] - [switch]$KeepArchive, - - [Parameter(HelpMessage = "Show help message")] - [switch]$Help + [switch]$KeepArchive ) # Global constants @@ -32,6 +104,7 @@ $Script:UserAgent = "get-aspire-cli.ps1/1.0" $Script:IsModernPowerShell = $PSVersionTable.PSVersion.Major -ge 6 -and $PSVersionTable.PSEdition -eq "Core" $Script:ArchiveDownloadTimeoutSec = 600 $Script:ChecksumDownloadTimeoutSec = 120 +$Script:HostOS = "unset" # Configuration constants $Script:Config = @{ @@ -53,16 +126,9 @@ $Script:Config = @{ # False if the body is piped / dot‑sourced / iex’d into the current session. $InvokedFromFile = -not [string]::IsNullOrEmpty($PSCommandPath) -# Ensure minimum PowerShell version -if ($PSVersionTable.PSVersion.Major -lt $Script:Config.MinimumPowerShellVersion) { - Write-Message "Error: This script requires PowerShell $($Script:Config.MinimumPowerShellVersion).0 or later. Current version: $($PSVersionTable.PSVersion)" -Level Error - if ($InvokedFromFile) { - exit 1 - } - else { - return 1 - } -} +# ============================================================================= +# START: Shared code +# ============================================================================= # Consolidated output function with fallback for platforms that don't support Write-Host function Write-Message { @@ -108,59 +174,6 @@ function Write-Message { } } -if ($Help) { - Write-Message @" -Aspire CLI Download Script - -DESCRIPTION: - Downloads and installs the Aspire CLI for the current platform from the specified version and quality. - Automatically updates the current session's PATH environment variable and supports GitHub Actions. - - Running with `-Quality release` download the latest release version of the Aspire CLI for your platform and architecture. - Running with `-Quality staging` will download the latest staging version, or the release version if no staging is available. - Running with `-Quality dev` will download the latest dev build from `main`. - - The default quality is '$($Script:Config.DefaultQuality)'. - - Pass a specific version to get CLI for that version. - -PARAMETERS: - -InstallPath Directory to install the CLI (default: %USERPROFILE%\.aspire\bin on Windows, `$HOME/.aspire/bin on Unix) - -Quality Quality to download (default: $($Script:Config.DefaultQuality)) - -Version Version of the Aspire CLI to download (default: unset) - -OS Operating system (default: auto-detect) - -Architecture Architecture (default: auto-detect) - -KeepArchive Keep downloaded archive files and temporary directory after installation - -Help Show this help message - -ENVIRONMENT: - The script automatically updates the PATH environment variable for the current session. - - Windows: The script will also add the installation path to the user's persistent PATH - environment variable and to the session PATH, making the aspire CLI available in the existing and new terminal sessions. - - GitHub Actions Support: - When running in GitHub Actions (GITHUB_ACTIONS=true), the script will automatically - append the installation path to the GITHUB_PATH file to make the CLI available in - subsequent workflow steps. - -EXAMPLES: - .\get-aspire-cli.ps1 - .\get-aspire-cli.ps1 -InstallPath "C:\tools\aspire" - .\get-aspire-cli.ps1 -Quality "staging" - .\get-aspire-cli.ps1 -Version "9.5.0-preview.1.25366.3" - .\get-aspire-cli.ps1 -OS "linux" -Architecture "x64" - .\get-aspire-cli.ps1 -KeepArchive - .\get-aspire-cli.ps1 -WhatIf - .\get-aspire-cli.ps1 -Help - - # Piped execution - iex "& { `$(irm https://aka.ms/aspire/get/install.ps1) }" - iex "& { `$(irm https://aka.ms/aspire/get/install.ps1) } -Quality staging" -"@ - if ($InvokedFromFile) { exit 0 } else { return } -} - # Helper function for PowerShell version-specific operations function Invoke-WithPowerShellVersion { [CmdletBinding()] @@ -185,6 +198,7 @@ function Get-OperatingSystem { [OutputType([string])] param() + Write-Message "Detecting OS" -Level Verbose try { return Invoke-WithPowerShellVersion -ModernAction { if ($IsWindows) { @@ -312,8 +326,6 @@ function Get-CLIArchitectureFromArchitecture { [string]$Architecture ) - Write-Message "Converting architecture: $Architecture" -Level Verbose - if ($Architecture -eq "") { $Architecture = Get-MachineArchitecture } @@ -335,6 +347,266 @@ function Get-CLIArchitectureFromArchitecture { } } +function Get-RuntimeIdentifier { + [CmdletBinding()] + [OutputType([object])] + param( + [string]$_OS, + [string]$_Architecture + ) + + # Determine OS and architecture (either detected or user-specified) + $computedTargetOS = if ([string]::IsNullOrWhiteSpace($_OS)) { $Script:HostOS } else { $_OS } + + # Check for unsupported OS + if ($computedTargetOS -eq "unsupported") { + throw "Unsupported operating system. Current platform: $([System.Environment]::OSVersion.Platform)" + } + + $computedTargetArch = if ([string]::IsNullOrWhiteSpace($_Architecture)) { Get-CLIArchitectureFromArchitecture "" } else { Get-CLIArchitectureFromArchitecture $_Architecture } + + return "${computedTargetOS}-${computedTargetArch}" +} + +function Expand-AspireCliArchive { + param( + [string]$ArchiveFile, + [string]$DestinationPath + ) + + Write-Message "Unpacking archive to: $DestinationPath" -Level Verbose + + # Create destination directory if it doesn't exist + if (-not (Test-Path $DestinationPath)) { + Write-Message "Creating destination directory: $DestinationPath" -Level Verbose + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + } + + # Check archive format based on file extension and extract accordingly + if ($ArchiveFile -match "\.zip$") { + # Use Expand-Archive for ZIP files + if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) { + throw "Expand-Archive cmdlet not found. Please use PowerShell 5.0 or later to extract ZIP files." + } + + try { + Expand-Archive -Path $ArchiveFile -DestinationPath $DestinationPath -Force + } + catch { + throw "Failed to unpack archive: $($_.Exception.Message)" + } + } + elseif ($ArchiveFile -match "\.tar\.gz$") { + # Use tar for tar.gz files + if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { + throw "tar command not found. Please install tar to extract tar.gz files." + } + + $currentLocation = Get-Location + try { + Set-Location $DestinationPath + & tar -xzf $ArchiveFile + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract tar.gz archive: $ArchiveFile. tar command returned exit code $LASTEXITCODE" + } + } + finally { + Set-Location $currentLocation + } + } + else { + throw "Unsupported archive format: $ArchiveFile. Only .zip and .tar.gz files are supported." + } + + Write-Message "Successfully unpacked archive" -Level Verbose +} + +# Simplified installation path determination +function Get-DefaultInstallPrefix { + [CmdletBinding()] + [OutputType([string])] + param() + + # Get home directory cross-platform + $homeDirectory = Invoke-WithPowerShellVersion -ModernAction { + if ($env:HOME) { + $env:HOME + } elseif ($IsWindows -and $env:USERPROFILE) { + $env:USERPROFILE + } elseif ($env:USERPROFILE) { + $env:USERPROFILE + } else { + $null + } + } -LegacyAction { + if ($env:USERPROFILE) { + $env:USERPROFILE + } elseif ($env:HOME) { + $env:HOME + } else { + $null + } + } + + if ([string]::IsNullOrWhiteSpace($homeDirectory)) { + throw "Unable to determine user home directory. Please specify -InstallPath parameter." + } + + $defaultPath = Join-Path $homeDirectory ".aspire" + return [System.IO.Path]::GetFullPath($defaultPath) +} + +# Simplified PATH environment update +function Update-PathEnvironment { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$CliBinDir + ) + + $pathSeparator = [System.IO.Path]::PathSeparator + + # Update current session PATH + $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) + if ($currentPathArray -notcontains $CliBinDir) { + if ($PSCmdlet.ShouldProcess("PATH environment variable", "Add $CliBinDir to current session")) { + $env:PATH = (@($CliBinDir) + $currentPathArray) -join $pathSeparator + Write-Message "Added $CliBinDir to PATH for current session" -Level Info + } + } + + # Update persistent PATH for Windows + if ($Script:HostOS -eq "win") { + try { + $userPath = [Environment]::GetEnvironmentVariable("PATH", [EnvironmentVariableTarget]::User) + if (-not $userPath) { $userPath = "" } + $userPathArray = if ($userPath) { $userPath.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) } else { @() } + if ($userPathArray -notcontains $CliBinDir) { + if ($PSCmdlet.ShouldProcess("User PATH environment variable", "Add $CliBinDir")) { + $newUserPath = (@($CliBinDir) + $userPathArray) -join $pathSeparator + [Environment]::SetEnvironmentVariable("PATH", $newUserPath, [EnvironmentVariableTarget]::User) + Write-Message "Added $CliBinDir to user PATH environment variable" -Level Info + } + } + + Write-Message "" -Level Info + Write-Message "The aspire cli is now available for use in this and new sessions." -Level Success + } + catch { + Write-Message "Failed to update persistent PATH environment variable: $($_.Exception.Message)" -Level Warning + Write-Message "You may need to manually add $CliBinDir to your PATH environment variable" -Level Info + } + } + + # GitHub Actions support + if ($env:GITHUB_ACTIONS -eq "true" -and $env:GITHUB_PATH) { + try { + if ($PSCmdlet.ShouldProcess("GITHUB_PATH environment variable", "Add $CliBinDir to GITHUB_PATH")) { + Add-Content -Path $env:GITHUB_PATH -Value $CliBinDir + Write-Message "Added $CliBinDir to GITHUB_PATH for GitHub Actions" -Level Success + } + } + catch { + Write-Message "Failed to update GITHUB_PATH: $($_.Exception.Message)" -Level Warning + } + } +} + +# Function to create a temporary directory with conflict resolution +function New-TempDirectory { + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Prefix + ) + + if ($PSCmdlet.ShouldProcess("temporary directory", "Create temporary directory with prefix '$Prefix'")) { + # Create a temporary directory for downloads with conflict resolution + $tempBaseName = "$Prefix-$([System.Guid]::NewGuid().ToString("N").Substring(0, 8))" + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) $tempBaseName + + # Handle potential conflicts + $attempt = 1 + while (Test-Path $tempDir) { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "$tempBaseName-$attempt" + $attempt++ + if ($attempt -gt 10) { + throw "Unable to create temporary directory after 10 attempts" + } + } + + Write-Message "Creating temporary directory: $tempDir" -Level Verbose + try { + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + return $tempDir + } + catch { + throw "Failed to create temporary directory: $tempDir - $($_.Exception.Message)" + } + } + else { + # Return a WhatIf path when -WhatIf is used + return Join-Path ([System.IO.Path]::GetTempPath()) "$Prefix-whatif" + } +} + +# Cleanup function for temporary directory +function Remove-TempDirectory { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter()] + [string]$TempDir + ) + + if (-not [string]::IsNullOrWhiteSpace($TempDir) -and (Test-Path $TempDir)) { + if (-not $KeepArchive) { + Write-Message "Cleaning up temporary files..." -Level Verbose + try { + if ($PSCmdlet.ShouldProcess($TempDir, "Remove temporary directory")) { + Remove-Item $TempDir -Recurse -Force + } + } + catch { + Write-Message "Failed to clean up temporary directory: $TempDir - $($_.Exception.Message)" -Level Warning + } + } + else { + Write-Message "Archive files kept in: $TempDir" -Level Info + } + } +} + +# ============================================================================= +# END: Shared code +# ============================================================================= + +# Simplified installation path determination +function Get-InstallPath { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [string]$InstallPath + ) + + if (-not [string]::IsNullOrWhiteSpace($InstallPath)) { + # Validate that the path is not just whitespace and can be created + try { + $resolvedPath = [System.IO.Path]::GetFullPath($InstallPath) + return $resolvedPath + } + catch { + throw "Invalid installation path: $InstallPath - $($_.Exception.Message)" + } + } + + $defaultPath = Join-Path (Get-DefaultInstallPrefix) "bin" + return [System.IO.Path]::GetFullPath($defaultPath) +} + function Get-ContentTypeFromUri { [CmdletBinding()] [OutputType([string])] @@ -524,163 +796,6 @@ function Test-FileChecksum { } } -function Expand-AspireCliArchive { - param( - [string]$ArchiveFile, - [string]$DestinationPath, - [string]$OS - ) - - Write-Message "Unpacking archive to: $DestinationPath" -Level Verbose - - try { - # Create destination directory if it doesn't exist - if (-not (Test-Path $DestinationPath)) { - Write-Message "Creating destination directory: $DestinationPath" -Level Verbose - New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null - } - - if ($OS -eq "win") { - # Use Expand-Archive for ZIP files on Windows - if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) { - throw "Expand-Archive cmdlet not found. Please use PowerShell 5.0 or later to extract ZIP files." - } - - Expand-Archive -Path $ArchiveFile -DestinationPath $DestinationPath -Force - } - else { - # Use tar for tar.gz files on Unix systems - if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { - throw "tar command not found. Please install tar to extract tar.gz files." - } - - $currentLocation = Get-Location - try { - Set-Location $DestinationPath - & tar -xzf $ArchiveFile - } - finally { - Set-Location $currentLocation - } - } - - Write-Message "Successfully unpacked archive" -Level Verbose - } - catch { - throw "Failed to unpack archive: $($_.Exception.Message)" - } -} - -# Simplified installation path determination -function Get-InstallPath { - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter()] - [string]$InstallPath - ) - - if (-not [string]::IsNullOrWhiteSpace($InstallPath)) { - # Validate that the path is not just whitespace and can be created - try { - $resolvedPath = [System.IO.Path]::GetFullPath($InstallPath) - return $resolvedPath - } - catch { - throw "Invalid installation path: $InstallPath - $($_.Exception.Message)" - } - } - - # Get home directory cross-platform - $homeDirectory = Invoke-WithPowerShellVersion -ModernAction { - if ($env:HOME) { - $env:HOME - } elseif ($IsWindows -and $env:USERPROFILE) { - $env:USERPROFILE - } elseif ($env:USERPROFILE) { - $env:USERPROFILE - } else { - $null - } - } -LegacyAction { - if ($env:USERPROFILE) { - $env:USERPROFILE - } elseif ($env:HOME) { - $env:HOME - } else { - $null - } - } - - if ([string]::IsNullOrWhiteSpace($homeDirectory)) { - throw "Unable to determine user home directory. Please specify -InstallPath parameter." - } - - $defaultPath = Join-Path (Join-Path $homeDirectory ".aspire") "bin" - return [System.IO.Path]::GetFullPath($defaultPath) -} - -# Simplified PATH environment update -function Update-PathEnvironment { - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string]$InstallPath, - - [Parameter(Mandatory = $true)] - [ValidateSet("win", "linux", "linux-musl", "osx")] - [string]$TargetOS - ) - - $pathSeparator = [System.IO.Path]::PathSeparator - - # Update current session PATH - $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) - if ($currentPathArray -notcontains $InstallPath) { - if ($PSCmdlet.ShouldProcess("PATH environment variable", "Add $InstallPath to current session")) { - $env:PATH = (@($InstallPath) + $currentPathArray) -join $pathSeparator - Write-Message "Added $InstallPath to PATH for current session" -Level Info - } - } - - # Update persistent PATH for Windows - if ($TargetOS -eq "win") { - try { - $userPath = [Environment]::GetEnvironmentVariable("PATH", [EnvironmentVariableTarget]::User) - if (-not $userPath) { $userPath = "" } - $userPathArray = if ($userPath) { $userPath.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) } else { @() } - if ($userPathArray -notcontains $InstallPath) { - if ($PSCmdlet.ShouldProcess("User PATH environment variable", "Add $InstallPath")) { - $newUserPath = (@($InstallPath) + $userPathArray) -join $pathSeparator - [Environment]::SetEnvironmentVariable("PATH", $newUserPath, [EnvironmentVariableTarget]::User) - Write-Message "Added $InstallPath to user PATH environment variable" -Level Info - } - } - - Write-Message "" -Level Info - Write-Message "The aspire cli is now available for use in this and new sessions." -Level Success - } - catch { - Write-Message "Failed to update persistent PATH environment variable: $($_.Exception.Message)" -Level Warning - Write-Message "You may need to manually add $InstallPath to your PATH environment variable" -Level Info - } - } - - # GitHub Actions support - if ($env:GITHUB_ACTIONS -eq "true" -and $env:GITHUB_PATH) { - try { - if ($PSCmdlet.ShouldProcess("GITHUB_PATH environment variable", "Add $InstallPath to GITHUB_PATH")) { - Add-Content -Path $env:GITHUB_PATH -Value $InstallPath - Write-Message "Added $InstallPath to GITHUB_PATH for GitHub Actions" -Level Success - } - } - catch { - Write-Message "Failed to update GITHUB_PATH: $($_.Exception.Message)" -Level Warning - } - } -} - # Enhanced URL construction function with configuration-based URLs function Get-AspireCliUrl { [CmdletBinding()] @@ -741,52 +856,18 @@ function Install-AspireCli { [OutputType([string])] param( [Parameter(Mandatory = $true)] - [string]$InstallPath, + [string]$CliBinDir, [string]$Version, [string]$Quality, - [string]$OS, - [string]$Architecture + [string]$TargetRID ) - # Create a temporary directory for downloads with conflict resolution - $tempBaseName = "aspire-cli-download-$([System.Guid]::NewGuid().ToString("N").Substring(0, 8))" - $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) $tempBaseName - - # Handle potential conflicts - $attempt = 1 - while (Test-Path $tempDir) { - $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "$tempBaseName-$attempt" - $attempt++ - if ($attempt -gt 10) { - throw "Unable to create temporary directory after 10 attempts" - } - } - - if ($PSCmdlet.ShouldProcess($InstallPath, "Create temporary directory")) { - Write-Message "Creating temporary directory: $tempDir" -Level Verbose - try { - New-Item -ItemType Directory -Path $tempDir -Force | Out-Null - } - catch { - throw "Failed to create temporary directory: $tempDir - $($_.Exception.Message)" - } - } + $tempDir = $null + $tempDir = New-TempDirectory -Prefix "aspire-cli-download" try { - # Determine OS and architecture (either detected or user-specified) - $targetOS = if ([string]::IsNullOrWhiteSpace($OS)) { Get-OperatingSystem } else { $OS } - - # Check for unsupported OS - if ($targetOS -eq "unsupported") { - throw "Unsupported operating system. Current platform: $([System.Environment]::OSVersion.Platform)" - } - - $targetArch = if ([string]::IsNullOrWhiteSpace($Architecture)) { Get-CLIArchitectureFromArchitecture "" } else { Get-CLIArchitectureFromArchitecture $Architecture } - - # Construct the runtime identifier and URLs - $runtimeIdentifier = "$targetOS-$targetArch" - $extension = if ($targetOS -eq "win") { "zip" } else { "tar.gz" } - $urls = Get-AspireCliUrl -Version $Version -Quality $Quality -RuntimeIdentifier $runtimeIdentifier -Extension $extension + $extension = if ($TargetRID.StartsWith("win-")) { "zip" } else { "tar.gz" } + $urls = Get-AspireCliUrl -Version $Version -Quality $Quality -RuntimeIdentifier $TargetRID -Extension $extension $archivePath = Join-Path $tempDir $urls.ArchiveFilename $checksumPath = Join-Path $tempDir $urls.ChecksumFilename @@ -805,35 +886,18 @@ function Install-AspireCli { Write-Message "Successfully downloaded and validated: $($urls.ArchiveFilename)" -Level Verbose } - if ($PSCmdlet.ShouldProcess($InstallPath, "Install CLI")) { + if ($PSCmdlet.ShouldProcess($CliBinDir, "Install CLI")) { # Unpack the archive - Expand-AspireCliArchive -ArchiveFile $archivePath -DestinationPath $InstallPath -OS $targetOS + Expand-AspireCliArchive -ArchiveFile $archivePath -DestinationPath $CliBinDir - $cliExe = if ($targetOS -eq "win") { "aspire.exe" } else { "aspire" } - $cliPath = Join-Path $InstallPath $cliExe + $cliExe = if ($TargetRID.StartsWith("win-")) { "aspire.exe" } else { "aspire" } + $cliPath = Join-Path $CliBinDir $cliExe Write-Message "Aspire CLI successfully installed to: $cliPath" -Level Success } - - # Return the target OS for the caller to use - return $targetOS } finally { - # Clean up temporary directory and downloaded files - if (Test-Path $tempDir -ErrorAction SilentlyContinue) { - if (-not $KeepArchive) { - try { - Write-Message "Cleaning up temporary files..." -Level Verbose - Remove-Item $tempDir -Recurse -Force -ErrorAction Stop - } - catch { - Write-Message "Failed to clean up temporary directory: $tempDir - $($_.Exception.Message)" -Level Warning - } - } - else { - Write-Message "Archive files kept in: $tempDir" -Level Info - } - } + Remove-TempDirectory -TempDir $tempDir } } @@ -879,15 +943,27 @@ function Start-AspireCliInstallation { } } + $rid = Get-RuntimeIdentifier -_OS $OS -_Architecture $Architecture + # Download and install the Aspire CLI - $targetOS = Install-AspireCli -InstallPath $resolvedInstallPath -Version $Version -Quality $Quality -OS $OS -Architecture $Architecture + Install-AspireCli -CliBinDir $resolvedInstallPath -Version $Version -Quality $Quality -TargetRID $rid # Update PATH environment variables - Update-PathEnvironment -InstallPath $resolvedInstallPath -TargetOS $targetOS + Update-PathEnvironment -CliBinDir $resolvedInstallPath } catch { - # Display clean error message without stack trace - Write-Message "Error: $($_.Exception.Message)" -Level Error + # Log the full exception details if verbose + Write-Verbose "Full exception details: $($_.Exception | Out-String)" + + # Show clean message to user + $cleanMessage = switch ($_.Exception.GetType().Name) { + 'ArgumentException' { "Invalid argument: $($_.Exception.Message)" } + 'UnauthorizedAccessException' { "Access denied: $($_.Exception.Message)" } + 'ParameterBindingValidationException' { "Parameter validation failed: $($_.Exception.Message)" } + default { $_.Exception.Message } + } + + Write-Message "Error: $cleanMessage" -Level Error if ($InvokedFromFile) { exit 1 } else { @@ -896,6 +972,17 @@ function Start-AspireCliInstallation { } } +# Ensure minimum PowerShell version +if ($PSVersionTable.PSVersion.Major -lt $Script:Config.MinimumPowerShellVersion) { + Write-Message "Error: This script requires PowerShell $($Script:Config.MinimumPowerShellVersion).0 or later. Current version: $($PSVersionTable.PSVersion)" -Level Error + if ($InvokedFromFile) { + exit 1 + } + else { + return 1 + } +} + # Run main function and handle exit code try { # Ensure we're not in strict mode which can cause issues in PowerShell 5.1 @@ -903,6 +990,7 @@ try { Set-StrictMode -Off } + $script:HostOS = Get-OperatingSystem Start-AspireCliInstallation $exitCode = 0 } diff --git a/eng/scripts/get-aspire-cli.sh b/eng/scripts/get-aspire-cli.sh index 5fe5730703e..17fd3319ddb 100755 --- a/eng/scripts/get-aspire-cli.sh +++ b/eng/scripts/get-aspire-cli.sh @@ -19,17 +19,18 @@ readonly RESET='\033[0m' INSTALL_PATH="" VERSION="" QUALITY="" -OS="" -ARCH="" +OS_ARG="" +ARCH_ARG="" SHOW_HELP=false VERBOSE=false KEEP_ARCHIVE=false DRY_RUN=false +HOST_OS="unset" DEFAULT_QUALITY="release" # Function to show help show_help() { - cat << 'EOF' + cat << EOF Aspire CLI Download Script DESCRIPTION: @@ -111,7 +112,7 @@ parse_args() { say_info "Use --help for usage information." exit 1 fi - OS="$2" + OS_ARG="$2" shift 2 ;; --arch) @@ -120,7 +121,7 @@ parse_args() { say_info "Use --help for usage information." exit 1 fi - ARCH="$2" + ARCH_ARG="$2" shift 2 ;; -k|--keep-archive) @@ -148,6 +149,10 @@ parse_args() { done } +# ============================================================================= +# START: Shared code +# ============================================================================= + # Function for verbose logging say_verbose() { if [[ "$VERBOSE" == true ]]; then @@ -156,11 +161,11 @@ say_verbose() { } say_error() { - echo -e "${RED}Error: $1${RESET}\n" >&2 + echo -e "${RED}Error: $1${RESET}" >&2 } say_warn() { - echo -e "${YELLOW}Warning: $1${RESET}\n" >&2 + echo -e "${YELLOW}Warning: $1${RESET}" >&2 } say_info() { @@ -239,157 +244,59 @@ detect_architecture() { esac } -# Common function for HTTP requests with centralized configuration -secure_curl() { - local url="$1" - local output_file="$2" - local timeout="${3:-300}" - local user_agent="${4:-$USER_AGENT}" - local max_retries="${5:-5}" - local method="${6:-GET}" +# Function to compute the Runtime Identifier (RID) +get_runtime_identifier() { + # set target_os to $1 and default to HOST_OS + local target_os="$1" + local target_arch="$2" - local curl_args=( - --fail - --show-error - --location - --tlsv1.2 - --tls-max 1.3 - --max-time "$timeout" - --user-agent "$user_agent" - --max-redirs 10 - --retry "$max_retries" - --retry-delay 1 - --retry-max-time 60 - --request "$method" - ) - - # Add extra args based on method - if [[ "$method" == "HEAD" ]]; then - curl_args+=(--silent --head) - else - curl_args+=(--progress-bar) - fi - - # Add output file only for GET requests - if [[ "$method" == "GET" ]]; then - curl_args+=(--output "$output_file") + if [[ -z "$target_os" ]]; then + target_os=$HOST_OS fi - say_verbose "curl ${curl_args[*]} $url" - curl "${curl_args[@]}" "$url" -} - -# Validate content type via HEAD request -validate_content_type() { - local url="$1" - - say_verbose "Validating content type for $url" - - # Get headers via HEAD request - local headers - if headers=$(secure_curl "$url" /dev/null 60 "$USER_AGENT" 3 "HEAD" 2>&1); then - # Check if response suggests HTML content (error page) - if echo "$headers" | grep -qi "content-type:.*text/html"; then - say_error "Server returned HTML content instead of expected file. Make sure the URL is correct: $url" + if [[ -z "$target_arch" ]]; then + if ! target_arch=$(get_cli_architecture_from_architecture ""); then return 1 fi else - # If HEAD request fails, continue anyway as some servers don't support it - say_verbose "HEAD request failed, proceeding with download." + if ! target_arch=$(get_cli_architecture_from_architecture "$target_arch"); then + return 1 + fi fi - return 0 + printf "%s" "${target_os}-${target_arch}" } -# General-purpose file download wrapper -download_file() { - local url="$1" - local output_path="$2" - local timeout="${3:-300}" - local max_retries="${4:-5}" - local validate_content_type="${5:-true}" - local use_temp_file="${6:-true}" - +# Create a temporary directory with a prefix. Honors DRY_RUN +new_temp_dir() { + local prefix="$1" if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would download $url" + printf "/tmp/%s-whatif" "$prefix" return 0 fi - - local target_file="$output_path" - if [[ "$use_temp_file" == true ]]; then - target_file="${output_path}.tmp" - fi - - # Validate content type via HEAD request if requested - if [[ "$validate_content_type" == true ]]; then - if ! validate_content_type "$url"; then - return 1 - fi - fi - - say_verbose "Downloading $url to $target_file" - say_info "Downloading from: $url" - - # Download the file - if secure_curl "$url" "$target_file" "$timeout" "$USER_AGENT" "$max_retries"; then - # Move temp file to final location if using temp file - if [[ "$use_temp_file" == true ]]; then - mv "$target_file" "$output_path" - fi - - say_verbose "Successfully downloaded file to: $output_path" - return 0 - else - say_error "Failed to download $url" + local dir + if ! dir=$(mktemp -d -t "${prefix}-XXXXXXXX"); then + say_error "Unable to create temporary directory" return 1 fi + say_verbose "Creating temporary directory: $dir" + printf "%s" "$dir" } -# Validate the checksum of the downloaded file -validate_checksum() { - local archive_file="$1" - local checksum_file="$2" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would validate checksum of $archive_file using $checksum_file" +# Remove a temporary directory unless KEEP_ARCHIVE is set. Honors DRY_RUN +remove_temp_dir() { + local dir="$1" + if [[ -z "$dir" || ! -d "$dir" ]]; then return 0 fi - - # Determine the checksum command to use - local checksum_cmd="" - if command -v sha512sum >/dev/null 2>&1; then - checksum_cmd="sha512sum" - elif command -v shasum >/dev/null 2>&1; then - checksum_cmd="shasum -a 512" - else - say_error "Neither sha512sum nor shasum is available. Please install one of them to validate checksums." - return 1 - fi - - # Read the expected checksum from the file - local expected_checksum - expected_checksum=$(tr -d '\n\r' < "$checksum_file" | tr '[:upper:]' '[:lower:]') - - # Calculate the actual checksum - local actual_checksum - actual_checksum=$(${checksum_cmd} "$archive_file" | cut -d' ' -f1) - - # Compare checksums - if [[ "$expected_checksum" == "$actual_checksum" ]]; then + if [[ "$DRY_RUN" == true ]]; then return 0 + fi + if [[ "$KEEP_ARCHIVE" != true ]]; then + say_verbose "Cleaning up temporary files..." + rm -rf "$dir" || say_warn "Failed to clean up temporary directory: $dir" else - # Limit expected checksum display to 128 characters for output - local expected_checksum_display - if [[ ${#expected_checksum} -gt 128 ]]; then - expected_checksum_display="${expected_checksum:0:128}" - else - expected_checksum_display="$expected_checksum" - fi - - say_error "Checksum validation failed for $archive_file with checksum from $checksum_file !" - say_info "Expected: $expected_checksum_display" - say_info "Actual: $actual_checksum" - return 1 + printf "Archive files kept in: %s\n" "$dir" fi } @@ -397,7 +304,6 @@ validate_checksum() { install_archive() { local archive_file="$1" local destination_path="$2" - local os="$3" if [[ "$DRY_RUN" == true ]]; then say_info "[DRY RUN] Would install archive $archive_file to $destination_path" @@ -412,28 +318,28 @@ install_archive() { mkdir -p "$destination_path" fi - if [[ "$os" == "win" ]]; then - # Use unzip for ZIP files + # Check archive format and extract accordingly + if [[ "$archive_file" =~ \.zip$ ]]; then if ! command -v unzip >/dev/null 2>&1; then say_error "unzip command not found. Please install unzip to extract ZIP files." return 1 fi - if ! unzip -o "$archive_file" -d "$destination_path"; then say_error "Failed to extract ZIP archive: $archive_file" return 1 fi - else - # Use tar for tar.gz files on Unix systems + elif [[ "$archive_file" =~ \.tar\.gz$ ]]; then if ! command -v tar >/dev/null 2>&1; then say_error "tar command not found. Please install tar to extract tar.gz files." return 1 fi - if ! tar -xzf "$archive_file" -C "$destination_path"; then say_error "Failed to extract tar.gz archive: $archive_file" return 1 fi + else + say_error "Unsupported archive format: $archive_file. Only .zip and .tar.gz files are supported." + return 1 fi say_verbose "Successfully installed archive" @@ -461,7 +367,7 @@ add_to_path() elif [[ -f "$config_file" ]] && grep -Fxq "$command" "$config_file"; then say_info "Command already exists in $config_file, skipping addition" elif [[ -w $config_file ]]; then - echo -e "\n# Added by get-aspire-cli.sh" >> "$config_file" + echo -e "\n# Added by Aspire CLI installation script" >> "$config_file" echo "$command" >> "$config_file" say_info "Successfully added aspire to \$PATH in $config_file" else @@ -551,12 +457,172 @@ add_to_shell_profile() { ;; esac - printf "\nTo use the Aspire CLI in new terminal sessions, restart your terminal or run:\n" - say_info " source $config_file" + if [[ "$DRY_RUN" != true ]]; then + printf "\nTo use the Aspire CLI in new terminal sessions, restart your terminal or run:\n" + say_info " source $config_file" + fi return 0 } +# ============================================================================= +# END: Shared code +# ============================================================================= + +# Common function for HTTP requests with centralized configuration +secure_curl() { + local url="$1" + local output_file="$2" + local timeout="${3:-300}" + local user_agent="${4:-$USER_AGENT}" + local max_retries="${5:-5}" + local method="${6:-GET}" + + local curl_args=( + --fail + --show-error + --location + --tlsv1.2 + --tls-max 1.3 + --max-time "$timeout" + --user-agent "$user_agent" + --max-redirs 10 + --retry "$max_retries" + --retry-delay 1 + --retry-max-time 60 + --request "$method" + ) + + # Add extra args based on method + if [[ "$method" == "HEAD" ]]; then + curl_args+=(--silent --head) + else + curl_args+=(--progress-bar) + fi + + # Add output file only for GET requests + if [[ "$method" == "GET" ]]; then + curl_args+=(--output "$output_file") + fi + + say_verbose "curl ${curl_args[*]} $url" + curl "${curl_args[@]}" "$url" +} + +# Validate content type via HEAD request +validate_content_type() { + local url="$1" + + say_verbose "Validating content type for $url" + + # Get headers via HEAD request + local headers + if headers=$(secure_curl "$url" /dev/null 60 "$USER_AGENT" 3 "HEAD" 2>&1); then + # Check if response suggests HTML content (error page) + if echo "$headers" | grep -qi "content-type:.*text/html"; then + say_error "Server returned HTML content instead of expected file. Make sure the URL is correct: $url" + return 1 + fi + else + # If HEAD request fails, continue anyway as some servers don't support it + say_verbose "HEAD request failed, proceeding with download." + fi + + return 0 +} + +# General-purpose file download wrapper +download_file() { + local url="$1" + local output_path="$2" + local timeout="${3:-300}" + local max_retries="${4:-5}" + local validate_content_type="${5:-true}" + local use_temp_file="${6:-true}" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would download $url" + return 0 + fi + + local target_file="$output_path" + if [[ "$use_temp_file" == true ]]; then + target_file="${output_path}.tmp" + fi + + # Validate content type via HEAD request if requested + if [[ "$validate_content_type" == true ]]; then + if ! validate_content_type "$url"; then + return 1 + fi + fi + + say_verbose "Downloading $url to $target_file" + say_info "Downloading from: $url" + + # Download the file + if secure_curl "$url" "$target_file" "$timeout" "$USER_AGENT" "$max_retries"; then + # Move temp file to final location if using temp file + if [[ "$use_temp_file" == true ]]; then + mv "$target_file" "$output_path" + fi + + say_verbose "Successfully downloaded file to: $output_path" + return 0 + else + say_error "Failed to download $url" + return 1 + fi +} + +# Validate the checksum of the downloaded file +validate_checksum() { + local archive_file="$1" + local checksum_file="$2" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would validate checksum of $archive_file using $checksum_file" + return 0 + fi + + # Determine the checksum command to use + local checksum_cmd="" + if command -v sha512sum >/dev/null 2>&1; then + checksum_cmd="sha512sum" + elif command -v shasum >/dev/null 2>&1; then + checksum_cmd="shasum -a 512" + else + say_error "Neither sha512sum nor shasum is available. Please install one of them to validate checksums." + return 1 + fi + + # Read the expected checksum from the file + local expected_checksum + expected_checksum=$(tr -d '\n\r' < "$checksum_file" | tr '[:upper:]' '[:lower:]') + + # Calculate the actual checksum + local actual_checksum + actual_checksum=$(${checksum_cmd} "$archive_file" | cut -d' ' -f1) + + # Compare checksums + if [[ "$expected_checksum" == "$actual_checksum" ]]; then + return 0 + else + # Limit expected checksum display to 128 characters for output + local expected_checksum_display + if [[ ${#expected_checksum} -gt 128 ]]; then + expected_checksum_display="${expected_checksum:0:128}" + else + expected_checksum_display="$expected_checksum" + fi + + say_error "Checksum validation failed for $archive_file with checksum from $checksum_file !" + say_info "Expected: $expected_checksum_display" + say_info "Actual: $actual_checksum" + return 1 + fi +} + # Function to construct the base URL for the Aspire CLI download construct_aspire_cli_url() { local version="$1" @@ -608,34 +674,17 @@ construct_aspire_cli_url() { # Function to download and install archive download_and_install_archive() { local temp_dir="$1" - local os arch runtimeIdentifier url filename checksum_url checksum_filename extension - local cli_exe cli_path - - # Detect OS and architecture if not provided - if [[ -z "$OS" ]]; then - if ! os=$(detect_os); then - say_error "Unsupported operating system. Current platform: $(uname -s)" - return 1 - fi - else - os="$OS" - fi + local target_os="$2" + local target_arch="$3" - if [[ -z "$ARCH" ]]; then - if ! arch=$(get_cli_architecture_from_architecture ""); then - return 1 - fi - else - if ! arch=$(get_cli_architecture_from_architecture "$ARCH"); then - return 1 - fi - fi + local runtimeIdentifier url filename checksum_url checksum_filename extension + local cli_exe cli_path - # Construct the runtime identifier - runtimeIdentifier="${os}-${arch}" + # Construct the runtime identifier using the function + runtimeIdentifier=$(get_runtime_identifier "$target_os" "$target_arch") # Determine file extension based on OS - if [[ "$os" == "win" ]]; then + if [[ "$target_os" == "win" ]]; then extension="zip" else extension="tar.gz" @@ -671,11 +720,11 @@ download_and_install_archive() { fi # Install the archive - if ! install_archive "$filename" "$INSTALL_PATH" "$os"; then + if ! install_archive "$filename" "$INSTALL_PATH"; then return 1 fi - if [[ "$os" == "win" ]]; then + if [[ "$target_os" == "win" ]]; then cli_exe="aspire.exe" else cli_exe="aspire" @@ -693,6 +742,8 @@ if [[ "$SHOW_HELP" == true ]]; then exit 0 fi +HOST_OS=$(detect_os) + # Validate that both --version and --quality are not provided together if [[ -n "$VERSION" && -n "$QUALITY" ]]; then say_error "Cannot specify both --version and --quality. Use --version for a specific version or --quality for a quality level." @@ -714,37 +765,12 @@ else INSTALL_PATH_UNEXPANDED="$INSTALL_PATH" fi -# Create a temporary directory for downloads -if [[ "$DRY_RUN" == true ]]; then - temp_dir="/tmp/aspire-cli-dry-run" -else - temp_dir=$(mktemp -d -t aspire-cli-download-XXXXXXXX) - say_verbose "Creating temporary directory: $temp_dir" -fi - -# Cleanup function for temporary directory -cleanup() { - # shellcheck disable=SC2317 # Function is called via trap - if [[ "$DRY_RUN" == true ]]; then - # No cleanup needed in dry-run mode - return 0 - fi - - if [[ -n "${temp_dir:-}" ]] && [[ -d "$temp_dir" ]]; then - if [[ "$KEEP_ARCHIVE" != true ]]; then - say_verbose "Cleaning up temporary files..." - rm -rf "$temp_dir" || say_warn "Failed to clean up temporary directory: $temp_dir" - else - printf "Archive files kept in: %s\n" "$temp_dir" - fi - fi -} - -# Set trap for cleanup on exit -trap cleanup EXIT +# Create a temporary directory for downloads and set cleanup trap +temp_dir=$(new_temp_dir "aspire-cli-download") +trap 'remove_temp_dir "$temp_dir"' EXIT # Download and install the archive -if ! download_and_install_archive "$temp_dir"; then +if ! download_and_install_archive "$temp_dir" "$OS_ARG" "$ARCH_ARG"; then exit 1 fi