diff --git a/eng/scripts/README.md b/eng/scripts/README.md new file mode 100644 index 00000000000..e5c66d7105d --- /dev/null +++ b/eng/scripts/README.md @@ -0,0 +1,162 @@ +# Aspire CLI Download Scripts + +This directory contains scripts to download and install the Aspire CLI for different platforms. + +## Scripts + +- **`get-aspire-cli.sh`** - Bash script for Unix-like systems (Linux, macOS) +- **`get-aspire-cli.ps1`** - PowerShell script for cross-platform use (Windows, Linux, macOS) + +## Current Limitations + +⚠️ **Important**: Currently, only the following combination works: +- **Version**: `9.0` +- **Quality**: `daily` + +Other version/quality combinations are not yet available through the download URLs. + +## Parameters + +### Bash Script (`get-aspire-cli.sh`) + +| Parameter | Short | Description | Default | +|------------------|-------|---------------------------------------------------|-----------------------| +| `--install-path` | `-i` | Directory to install the CLI | `$HOME/.aspire/bin` | +| `--version` | | Version of the Aspire CLI to download | `9.0` | +| `--quality` | `-q` | Quality to download | `daily` | +| `--os` | | Operating system (auto-detected if not specified) | auto-detect | +| `--arch` | | Architecture (auto-detected if not specified) | auto-detect | +| `--keep-archive` | `-k` | Keep downloaded archive files after installation | `false` | +| `--verbose` | `-v` | Enable verbose output | `false` | +| `--help` | `-h` | Show help message | | + +### PowerShell Script (`get-aspire-cli.ps1`) + +| Parameter | Description | Default | +|-----------------|---------------------------------------------------|----------------------------------| +| `-InstallPath` | Directory to install the CLI | `$HOME/.aspire/bin` (Unix) / `%USERPROFILE%\.aspire\bin` (Windows) | +| `-Version` | Version of the Aspire CLI to download | `9.0` | +| `-Quality` | Quality to download | `daily` | +| `-OS` | Operating system (auto-detected if not specified) | auto-detect | +| `-Architecture` | Architecture (auto-detected if not specified) | auto-detect | +| `-KeepArchive` | Keep downloaded archive files after installation | `false` | +| `-Help` | Show help message | | + +## Install Path Parameter + +The `--install-path` (bash) or `-InstallPath` (PowerShell) parameter specifies where the Aspire CLI will be installed: + +- **Default behavior**: + - **Unix systems**: `$HOME/.aspire/bin` + - **Windows**: `%USERPROFILE%\.aspire\bin` +- **Custom path**: You can specify any directory path where you want the CLI installed +- **Directory creation**: The scripts will automatically create the directory if it doesn't exist +- **PATH integration**: The scripts automatically update the current session's PATH and add to shell profiles for persistent access +- **Final location**: The CLI executable will be placed directly in the specified directory as: + - `aspire` (on Linux/macOS) + - `aspire.exe` (on Windows) + +### Example Install Paths + +```bash +# Default - installs to $HOME/.aspire/bin/aspire +./get-aspire-cli.sh + +# Custom path - installs to /usr/local/bin/aspire +./get-aspire-cli.sh --install-path "/usr/local/bin" + +# Relative path - installs to ../tools/aspire-cli/aspire +./get-aspire-cli.sh --install-path "../tools/aspire-cli" +``` + +## Usage Examples + +### Bash Script Examples + +```bash +# Basic usage - download to default location ($HOME/.aspire/bin) +./get-aspire-cli.sh + +# Specify custom install directory +./get-aspire-cli.sh --install-path "/usr/local/bin" + +# Download with verbose output +./get-aspire-cli.sh --verbose + +# Keep the downloaded archive files for inspection +./get-aspire-cli.sh --keep-archive + +# Force specific OS and architecture (useful for cross-compilation scenarios) +./get-aspire-cli.sh --os "linux" --arch "x64" + +# Combine multiple options +./get-aspire-cli.sh --install-path "/tmp/aspire" --verbose --keep-archive +``` + +### PowerShell Script Examples + +```powershell +# Basic usage - download to default location (%USERPROFILE%\.aspire\bin or $HOME/.aspire/bin) +.\get-aspire-cli.ps1 + +# Specify custom install directory +.\get-aspire-cli.ps1 -InstallPath "C:\Tools\Aspire" + +# Download with verbose output +.\get-aspire-cli.ps1 -Verbose + +# Keep the downloaded archive files for inspection +.\get-aspire-cli.ps1 -KeepArchive + +# Force specific OS and architecture +.\get-aspire-cli.ps1 -OS "win" -Architecture "x64" + +# Combine multiple options +.\get-aspire-cli.ps1 -InstallPath "C:\temp\aspire" -Verbose -KeepArchive +``` + +## Supported Runtime Identifiers + +The following runtime identifier (RID) combinations are available: + +| Runtime Identifier | AOTed | +|-------------------|-------------| +| `win-x64` | ✅ | +| `win-arm64` | ✅ | +| `win-x86` | ❌ | +| `linux-x64` | ✅ | +| `linux-arm64` | ❌ | +| `linux-musl-x64` | ❌ | +| `osx-x64` | ✅ | +| `osx-arm64` | ✅ | + +The non-aot binaries are self-contained executables. + +## Troubleshooting + +### Common Issues + +1. **"Unsupported platform" error**: Your OS/architecture combination may not be supported +2. **"Failed to download" error**: Check your internet connection and firewall settings +3. **"Checksum validation failed" error**: The download may have been corrupted; try again +4. **"HTML error page" error**: The requested version/platform combination may not be available + +### Getting Help + +Run the scripts with the help flag to see detailed usage information: + +```bash +./get-aspire-cli.sh --help +``` + +```powershell +.\get-aspire-cli.ps1 -Help +``` + +## Contributing + +When modifying these scripts, ensure: +- Both scripts maintain feature parity where possible +- Error handling is comprehensive and user-friendly +- Platform detection logic is robust +- Security best practices are followed for downloads and file handling diff --git a/eng/scripts/get-aspire-cli.ps1 b/eng/scripts/get-aspire-cli.ps1 new file mode 100755 index 00000000000..7d41470625d --- /dev/null +++ b/eng/scripts/get-aspire-cli.ps1 @@ -0,0 +1,639 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$InstallPath = "", + [string]$Version = "9.0", + [string]$Quality = "daily", + [string]$OS = "", + [string]$Architecture = "", + [switch]$KeepArchive, + [switch]$Help +) + +# Global constants +$Script:UserAgent = "get-aspire-cli.ps1/1.0" +$Script:IsModernPowerShell = $PSVersionTable.PSVersion.Major -ge 6 +$Script:ArchiveDownloadTimeoutSec = 600 +$Script:ChecksumDownloadTimeoutSec = 120 + +# True if the script is executed from a file (pwsh -File … or .\get-aspire-cli.ps1) +# 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 4) { + Write-Host "Error: This script requires PowerShell 4.0 or later. Current version: $($PSVersionTable.PSVersion)" -ForegroundColor Red + if ($InvokedFromFile) { exit 1 } else { return 1 } +} + +if ($Help) { + Write-Host @" +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. + +PARAMETERS: + -InstallPath Directory to install the CLI (default: %USERPROFILE%\.aspire\bin on Windows, $HOME/.aspire/bin on Unix) + -Version Version of the Aspire CLI to download (default: 9.0) + -Quality Quality to download (default: daily) + -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 -Version "9.0" -Quality "release" + .\get-aspire-cli.ps1 -OS "linux" -Architecture "x64" + .\get-aspire-cli.ps1 -KeepArchive + .\get-aspire-cli.ps1 -Help + +"@ + if ($InvokedFromFile) { exit 0 } else { return 0 } +} + +# Consolidated output function with fallback for platforms that don't support Write-Host +function Write-Message { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + [ValidateSet('Verbose', 'Info', 'Success', 'Warning', 'Error')] + [string]$Level = 'Info' + ) + + try { + switch ($Level) { + 'Verbose' { Write-Verbose $Message } + 'Info' { Write-Host $Message -ForegroundColor White } + 'Success' { Write-Host $Message -ForegroundColor Green } + 'Warning' { Write-Host "Warning: $Message" -ForegroundColor Yellow } + 'Error' { Write-Host "Error: $Message" -ForegroundColor Red } + } + } + catch { + # Fallback for platforms that don't support Write-Host (e.g., Azure Functions) + $prefix = if ($Level -in @('Warning', 'Error')) { "$Level`: " } else { "" } + Write-Output "$prefix$Message" + } +} + +# Helper function for PowerShell version-specific operations +function Invoke-WithPowerShellVersion { + param( + [scriptblock]$ModernAction, + [scriptblock]$LegacyAction + ) + + if ($Script:IsModernPowerShell) { + & $ModernAction + } else { + & $LegacyAction + } +} + +# Function to detect OS +function Get-OperatingSystem { + Invoke-WithPowerShellVersion -ModernAction { + if ($IsWindows) { + return "win" + } + elseif ($IsLinux) { + try { + $lddOutput = & ldd --version 2>&1 | Out-String + return if ($lddOutput -match "musl") { "linux-musl" } else { "linux" } + } + catch { return "linux" } + } + elseif ($IsMacOS) { + return "osx" + } + else { + return "unsupported" + } + } -LegacyAction { + # PowerShell 5.1 and earlier - more reliable Windows detection + if ($env:OS -eq "Windows_NT" -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { + return "win" + } + + $platform = [System.Environment]::OSVersion.Platform + switch ($platform) { + { $_ -in @([System.PlatformID]::Unix, 4, 6) } { return "linux" } + { $_ -in @([System.PlatformID]::MacOSX, 128) } { return "osx" } + default { return "unsupported" } + } + } +} + +# Taken from dotnet-install.ps1 and enhanced for cross-platform support +function Get-MachineArchitecture() { + Write-Message "Get-MachineArchitecture called" -Level Verbose + + # On Windows PowerShell, use environment variables + if (-not $Script:IsModernPowerShell -or $IsWindows) { + # On PS x86, PROCESSOR_ARCHITECTURE reports x86 even on x64 systems. + # To get the correct architecture, we need to use PROCESSOR_ARCHITEW6432. + # PS x64 doesn't define this, so we fall back to PROCESSOR_ARCHITECTURE. + # Possible values: amd64, x64, x86, arm64, arm + if ( $null -ne $ENV:PROCESSOR_ARCHITEW6432 ) { + return $ENV:PROCESSOR_ARCHITEW6432 + } + + try { + if ( ((Get-CimInstance -ClassName CIM_OperatingSystem).OSArchitecture) -like "ARM*") { + if ( [Environment]::Is64BitOperatingSystem ) { + return "arm64" + } + return "arm" + } + } + catch { + # Machine doesn't support Get-CimInstance + } + + if ($null -ne $ENV:PROCESSOR_ARCHITECTURE) { + return $ENV:PROCESSOR_ARCHITECTURE + } + } + + # For PowerShell 6+ on Unix systems, use .NET runtime information + if ($Script:IsModernPowerShell) { + try { + $runtimeArch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture + switch ($runtimeArch) { + 'X64' { return "x64" } + 'X86' { return "x86" } + 'Arm64' { return "arm64" } + default { + Write-Message "Unknown runtime architecture: $runtimeArch" -Level Verbose + # Fall back to uname if available + if (Get-Command uname -ErrorAction SilentlyContinue) { + $unameArch = & uname -m + switch ($unameArch) { + { @('x86_64', 'amd64') -contains $_ } { return "x64" } + { @('aarch64', 'arm64') -contains $_ } { return "arm64" } + { @('i386', 'i686') -contains $_ } { return "x86" } + default { + Write-Message "Unknown uname architecture: $unameArch" -Level Verbose + return "x64" # Default fallback + } + } + } + return "x64" # Default fallback + } + } + } + catch { + Write-Message "Failed to get runtime architecture: $($_.Exception.Message)" -Level Warning + # Final fallback - assume x64 + return "x64" + } + } + + # Final fallback for older PowerShell versions + return "x64" +} + +# taken from dotnet-install.ps1 +function Get-CLIArchitectureFromArchitecture([string]$Architecture) { + Write-Message "Get-CLIArchitectureFromArchitecture called with Architecture: $Architecture" -Level Verbose + + if ($Architecture -eq "") { + $Architecture = Get-MachineArchitecture + } + + switch ($Architecture.ToLowerInvariant()) { + { ($_ -eq "amd64") -or ($_ -eq "x64") } { return "x64" } + { $_ -eq "x86" } { return "x86" } + { $_ -eq "arm64" } { return "arm64" } + default { throw "Architecture '$Architecture' not supported. If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" } + } +} + +function Get-ContentTypeFromUri { + param( + [Parameter(Mandatory = $true)] + [string]$Uri, + [int]$TimeoutSec = 60, + [int]$OperationTimeoutSec = 30, + [int]$MaxRetries = 5 + ) + + try { + Write-Message "Making HEAD request to get content type for: $Uri" -Level Verbose + $headResponse = Invoke-SecureWebRequest -Uri $Uri -Method 'Head' -TimeoutSec $TimeoutSec -OperationTimeoutSec $OperationTimeoutSec -MaxRetries $MaxRetries + + # Extract Content-Type from response headers + $headers = $headResponse.Headers + if ($headers) { + # Try common case variations and use case-insensitive lookup + $contentTypeKey = $headers.Keys | Where-Object { $_ -ieq 'Content-Type' } | Select-Object -First 1 + if ($contentTypeKey) { + $value = $headers[$contentTypeKey] + if ($value -is [array]) { + return $value -join ', ' + } else { + return $value + } + } + } + return "" + } + catch { + Write-Message "Failed to get content type from URI: $($_.Exception.Message)" -Level Verbose + return "Unable to determine ($($_.Exception.Message))" + } +} + +# Common function for web requests with centralized configuration +function Invoke-SecureWebRequest { + param( + [string]$Uri, + [string]$OutFile, + [string]$Method = 'Get', + [int]$TimeoutSec = 60, + [int]$OperationTimeoutSec = 30, + [int]$MaxRetries = 5 + ) + + # Configure TLS for PowerShell 5 + if (-not $Script:IsModernPowerShell) { + try { + # Set TLS 1.2 and attempt TLS 1.3 if available + $protocols = [Net.SecurityProtocolType]::Tls12 + try { + $protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13 + } + catch { + Write-Message "TLS 1.3 not available, using TLS 1.2 only" -Level Verbose + } + [Net.ServicePointManager]::SecurityProtocol = $protocols + } + catch { + Write-Message "Failed to configure TLS settings: $($_.Exception.Message)" -Level Warning + } + } + + # Build base request parameters + $requestParams = @{ + Uri = $Uri + Method = $Method + MaximumRedirection = 10 + TimeoutSec = $TimeoutSec + UserAgent = $Script:UserAgent + } + + if ($Method -eq 'Get' -and $OutFile) { + $requestParams.OutFile = $OutFile + } + + # Add modern PowerShell parameters with graceful fallback + if ($Script:IsModernPowerShell) { + @('SslProtocol', 'OperationTimeoutSeconds', 'MaximumRetryCount') | ForEach-Object { + $paramName = $_ + $paramValue = switch ($paramName) { + 'SslProtocol' { @('Tls12', 'Tls13') } + 'OperationTimeoutSeconds' { $OperationTimeoutSec } + 'MaximumRetryCount' { $MaxRetries } + } + + try { + $requestParams[$paramName] = $paramValue + } + catch { + Write-Message "$paramName parameter not available: $($_.Exception.Message)" -Level Verbose + } + } + } + + try { + return Invoke-WebRequest @requestParams + } + catch { + throw $_.Exception + } +} + +# Simplified file download wrapper +function Invoke-FileDownload { + param( + [Parameter(Mandatory = $true)] + [string]$Uri, + [Parameter(Mandatory = $true)] + [string]$OutputPath, + [int]$TimeoutSec = 60, + [int]$OperationTimeoutSec = 30, + [int]$MaxRetries = 5 + ) + + # Validate content type via HEAD request + Write-Message "Validating content type for $Uri" -Level Verbose + $contentType = Get-ContentTypeFromUri -Uri $Uri -TimeoutSec 60 -OperationTimeoutSec $OperationTimeoutSec -MaxRetries $MaxRetries + Write-Message "Detected content type: '$contentType'" -Level Verbose + + if ($contentType -and $contentType.ToLowerInvariant().StartsWith("text/html")) { + throw "Server returned HTML content instead of expected file. Make sure the URL is correct: $Uri" + } + + try { + Write-Message "Downloading $Uri to $OutputPath" -Level Verbose + Invoke-SecureWebRequest -Uri $Uri -OutFile $OutputPath -TimeoutSec $TimeoutSec -OperationTimeoutSec $OperationTimeoutSec -MaxRetries $MaxRetries + Write-Message "Successfully downloaded file to: $OutputPath" -Level Verbose + } + catch { + throw "Failed to download $Uri to $OutputPath - $($_.Exception.Message)" + } +} + +# Validate the checksum of the downloaded file +function Test-FileChecksum { + param( + [string]$ArchiveFile, + [string]$ChecksumFile + ) + + # Check if Get-FileHash cmdlet is available + if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) { + throw "Get-FileHash cmdlet not found. Please use PowerShell 4.0 or later to validate checksums." + } + + $expectedChecksum = (Get-Content $ChecksumFile -Raw).Trim().ToLower() + $actualChecksum = (Get-FileHash -Path $ArchiveFile -Algorithm SHA512).Hash.ToLower() + + # Compare checksums + if ($expectedChecksum -ne $actualChecksum) { + $displayChecksum = if ($expectedChecksum.Length -gt 128) { $expectedChecksum.Substring(0, 128) + "..." } else { $expectedChecksum } + throw "Checksum validation failed for $ArchiveFile with checksum from $ChecksumFile !`nExpected: $displayChecksum`nActual: $actualChecksum" + } +} + +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)) { + 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 { + param([string]$InstallPath) + + if (-not [string]::IsNullOrWhiteSpace($InstallPath)) { + return $InstallPath + } + + # 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." + } + + return Join-Path (Join-Path $homeDirectory ".aspire") "bin" +} + +# Simplified PATH environment update +function Update-PathEnvironment { + param( + [Parameter(Mandatory = $true)] + [string]$InstallPath, + [Parameter(Mandatory = $true)] + [string]$TargetOS + ) + + $pathSeparator = [System.IO.Path]::PathSeparator + + # Update current session PATH + $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) + if ($currentPathArray -notcontains $InstallPath) { + $env:PATH = ($currentPathArray + @($InstallPath)) -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) { + $newUserPath = ($userPathArray + @($InstallPath)) -join $pathSeparator + [Environment]::SetEnvironmentVariable("PATH", $newUserPath, [EnvironmentVariableTarget]::User) + Write-Message "Added $InstallPath to user PATH environment variable" -Level Info + } + + Write-Host "" + Write-Host "The aspire cli is now available for use in this and new sessions." -ForegroundColor Green + } + 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 { + 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 + } + } +} + +# Function to download and install the Aspire CLI +function Install-AspireCli { + param( + [Parameter(Mandatory = $true)] + [string]$InstallPath, + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [string]$Quality, + [string]$OS, + [string]$Architecture, + [switch]$KeepArchive + ) + + # Create a temporary directory for downloads + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-cli-download-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))" + + if (-not (Test-Path $tempDir)) { + 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)" + } + } + + 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" } + $url = "https://aka.ms/dotnet/$Version/$Quality/aspire-cli-$runtimeIdentifier.$extension" + $checksumUrl = "$url.sha512" + + $filename = Join-Path $tempDir "aspire-cli-$runtimeIdentifier.$extension" + $checksumFilename = Join-Path $tempDir "aspire-cli-$runtimeIdentifier.$extension.sha512" + + # Download the Aspire CLI archive + Write-Message "Downloading from: $url" -Level Info + Invoke-FileDownload -Uri $url -TimeoutSec $Script:ArchiveDownloadTimeoutSec -OutputPath $filename + + # Download and test the checksum + Invoke-FileDownload -Uri $checksumUrl -TimeoutSec $Script:ChecksumDownloadTimeoutSec -OutputPath $checksumFilename + Test-FileChecksum -ArchiveFile $filename -ChecksumFile $checksumFilename + + Write-Message "Successfully downloaded and validated: $filename" -Level Verbose + + # Unpack the archive + Expand-AspireCliArchive -ArchiveFile $filename -DestinationPath $InstallPath -OS $targetOS + + $cliExe = if ($targetOS -eq "win") { "aspire.exe" } else { "aspire" } + $cliPath = Join-Path $InstallPath $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 + } + } + } +} + +# Main function +function Main { + try { + # Determine the installation path + $InstallPath = Get-InstallPath -InstallPath $InstallPath + + # Download and install the Aspire CLI + $targetOS = Install-AspireCli -InstallPath $InstallPath -Version $Version -Quality $Quality -OS $OS -Architecture $Architecture -KeepArchive:$KeepArchive + + # Update PATH environment variables + Update-PathEnvironment -InstallPath $InstallPath -TargetOS $targetOS + } + catch { + Write-Message $_.Exception.Message -Level Error + throw + } +} + +# Run main function and handle exit code +try { + # Ensure we're not in strict mode which can cause issues in PowerShell 5.1 + if (-not $Script:IsModernPowerShell) { + Set-StrictMode -Off + } + + Main + $exitCode = 0 +} +catch { + Write-Error $_ + $exitCode = 1 +} + +if ($InvokedFromFile) { exit $exitCode } else { return $exitCode } diff --git a/eng/scripts/get-aspire-cli.sh b/eng/scripts/get-aspire-cli.sh new file mode 100755 index 00000000000..26a4ebbfb06 --- /dev/null +++ b/eng/scripts/get-aspire-cli.sh @@ -0,0 +1,653 @@ +#!/usr/bin/env bash + +# get-aspire-cli.sh - Download and unpack the Aspire CLI for the current platform +# Usage: ./get-aspire-cli.sh [OPTIONS] +# curl -sSL /get-aspire-cli.sh | bash -s -- [OPTIONS] + +set -euo pipefail + +# Global constants +readonly USER_AGENT="get-aspire-cli.sh/1.0" +readonly ARCHIVE_DOWNLOAD_TIMEOUT_SEC=600 +readonly CHECKSUM_DOWNLOAD_TIMEOUT_SEC=120 +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly RESET='\033[0m' + +# Default values +INSTALL_PATH="" +VERSION="9.0" +QUALITY="daily" +OS="" +ARCH="" +SHOW_HELP=false +VERBOSE=false +KEEP_ARCHIVE=false + +# Function to show help +show_help() { + cat << 'EOF' +Aspire CLI Download Script + +DESCRIPTION: + Downloads and unpacks the Aspire CLI for the current platform from the specified version and quality. + +USAGE: + ./get-aspire-cli.sh [OPTIONS] + + -i, --install-path PATH Directory to install the CLI (default: $HOME/.aspire/bin) + --version VERSION Version of the Aspire CLI to download (default: 9.0) + -q, --quality QUALITY Quality to download (default: daily) + --os OS Operating system (default: auto-detect) + --arch ARCH Architecture (default: auto-detect) + -k, --keep-archive Keep downloaded archive files and temporary directory after installation + -v, --verbose Enable verbose output + -h, --help Show this help message + +EXAMPLES: + ./get-aspire-cli.sh + ./get-aspire-cli.sh --install-path "/usr/local/bin" + ./get-aspire-cli.sh --version "9.0" --quality "release" + ./get-aspire-cli.sh --os "linux" --arch "x64" + ./get-aspire-cli.sh --keep-archive + ./get-aspire-cli.sh --help + + # Piped execution (like wget | bash or curl | bash): + curl -sSL /get-aspire-cli.sh | bash + curl -sSL /get-aspire-cli.sh | bash -s -- --install-path "/usr/local/bin" + +EOF +} + +# Function to parse command line arguments +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + -i|--install-path) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + INSTALL_PATH="$2" + shift 2 + ;; + --version) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + VERSION="$2" + shift 2 + ;; + -q|--quality) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + QUALITY="$2" + shift 2 + ;; + --os) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + OS="$2" + shift 2 + ;; + --arch) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + ARCH="$2" + shift 2 + ;; + -k|--keep-archive) + KEEP_ARCHIVE=true + shift + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + -h|--help) + SHOW_HELP=true + shift + ;; + *) + say_error "Unknown option '$1'" + say_info "Use --help for usage information." + exit 1 + ;; + esac + done +} + +# Function for verbose logging +say_verbose() { + if [[ "$VERBOSE" == true ]]; then + echo -e "${YELLOW}$1${RESET}" >&2 + fi +} + +say_error() { + echo -e "${RED}Error: $1${RESET}\n" >&2 +} + +say_warn() { + echo -e "${YELLOW}Warning: $1${RESET}\n" >&2 +} + +say_info() { + echo -e "$1" >&2 +} + +# Function to detect OS +detect_os() { + local uname_s + uname_s=$(uname -s) + + case "$uname_s" in + Darwin*) + printf "osx" + ;; + Linux*) + # Check if it's musl-based (Alpine, etc.) + if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -q musl; then + printf "linux-musl" + else + printf "linux" + fi + ;; + CYGWIN*|MINGW*|MSYS*) + printf "win" + ;; + *) + printf "unsupported" + return 1 + ;; + esac +} + +# Function to validate and normalize architecture +get_cli_architecture_from_architecture() { + local architecture="$1" + + if [[ "$architecture" == "" ]]; then + architecture=$(detect_architecture) + fi + + case "$(echo "$architecture" | tr '[:upper:]' '[:lower:]')" in + amd64|x64) + printf "x64" + ;; + x86) + printf "x86" + ;; + arm64) + printf "arm64" + ;; + *) + say_error "Architecture $architecture not supported. If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" + return 1 + ;; + esac +} + +# Function to detect architecture +detect_architecture() { + local uname_m + uname_m=$(uname -m) + + case "$uname_m" in + x86_64|amd64) + printf "x64" + ;; + aarch64|arm64) + printf "arm64" + ;; + i386|i686) + printf "x86" + ;; + *) + say_error "Architecture $uname_m not supported. If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" + return 1 + ;; + 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}" + + 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}" + + 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 to $output_path" + return 1 + fi +} + +# Validate the checksum of the downloaded file +validate_checksum() { + local archive_file="$1" + local checksum_file="$2" + + # 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 install/unpack archive files +install_archive() { + local archive_file="$1" + local destination_path="$2" + local os="$3" + + say_verbose "Installing archive to: $destination_path" + + # Create install directory if it doesn't exist + if [[ ! -d "$destination_path" ]]; then + say_verbose "Creating install directory: $destination_path" + mkdir -p "$destination_path" + fi + + if [[ "$os" == "win" ]]; then + # Use unzip for ZIP files + 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 + 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 + fi + + say_verbose "Successfully installed archive" +} + +# Function to add PATH to shell configuration file +# Parameters: +# $1 - config_file: Path to the shell configuration file +# $2 - bin_path: The binary path to add to PATH +# $3 - command: The command to add to the configuration file +add_to_path() +{ + local config_file="$1" + local bin_path="$2" + local command="$3" + + if [[ ":$PATH:" == *":$bin_path:"* ]]; then + say_info "Path $bin_path already exists in \$PATH, skipping addition" + 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 "$command" >> "$config_file" + say_info "Successfully added aspire to \$PATH in $config_file" + else + say_info "Manually add the following to $config_file (or similar):" + say_info " $command" + fi +} + +# Function to add PATH to shell profile +add_to_shell_profile() { + local bin_path="$1" + local bin_path_unexpanded="$2" + local xdg_config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + + # Detect the current shell + local shell_name + + # Try to get shell from SHELL environment variable + if [[ -n "${SHELL:-}" ]]; then + shell_name=$(basename "$SHELL") + else + # Fallback to detecting from process + shell_name=$(ps -p $$ -o comm= 2>/dev/null || echo "sh") + fi + + # Normalize shell name + case "$shell_name" in + bash|zsh|fish) + ;; + sh|dash|ash) + shell_name="sh" + ;; + *) + # Default to bash for unknown shells + shell_name="bash" + ;; + esac + + say_verbose "Detected shell: $shell_name" + + local config_files + case "$shell_name" in + bash) + config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $xdg_config_home/bash/.bashrc $xdg_config_home/bash/.bash_profile" + ;; + zsh) + config_files="$HOME/.zshrc $HOME/.zshenv $xdg_config_home/zsh/.zshrc $xdg_config_home/zsh/.zshenv" + ;; + fish) + config_files="$HOME/.config/fish/config.fish" + ;; + sh) + config_files="$HOME/.profile /etc/profile" + ;; + *) + # Default to bash files for unknown shells + config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" + ;; + esac + + # Get the appropriate shell config file + local config_file + + # Find the first existing config file + for file in $config_files; do + if [[ -f "$file" ]]; then + config_file="$file" + break + fi + done + + if [[ -z $config_file ]]; then + say_error "No config file found for $shell_name. Checked files: $config_files" + exit 1 + fi + + case "$shell_name" in + bash|zsh|sh) + add_to_path "$config_file" "$bin_path" "export PATH=\"$bin_path_unexpanded:\$PATH\"" + ;; + fish) + add_to_path "$config_file" "$bin_path" "fish_add_path $bin_path_unexpanded" + ;; + *) + say_error "Unsupported shell type $shell_name. Please add the path $bin_path_unexpanded manually to \$PATH in your profile." + return 1 + ;; + esac + + printf "\nTo use the Aspire CLI in new terminal sessions, restart your terminal or run:\n" + say_info " source $config_file" + + return 0 +} + +# 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 + + 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 + + # Construct the runtime identifier + runtimeIdentifier="${os}-${arch}" + + # Determine file extension based on OS + if [[ "$os" == "win" ]]; then + extension="zip" + else + extension="tar.gz" + fi + + # Construct the URLs + url="https://aka.ms/dotnet/${VERSION}/${QUALITY}/aspire-cli-${runtimeIdentifier}.${extension}" + checksum_url="${url}.sha512" + + filename="${temp_dir}/aspire-cli-${runtimeIdentifier}.${extension}" + checksum_filename="${temp_dir}/aspire-cli-${runtimeIdentifier}.${extension}.sha512" + + # Download the Aspire CLI archive + if ! download_file "$url" "$filename" $ARCHIVE_DOWNLOAD_TIMEOUT_SEC; then + return 1 + fi + + # Download and test the checksum + if ! download_file "$checksum_url" "$checksum_filename" $CHECKSUM_DOWNLOAD_TIMEOUT_SEC; then + return 1 + fi + + if ! validate_checksum "$filename" "$checksum_filename"; then + return 1 + fi + + say_verbose "Successfully downloaded and validated: $filename" + + # Install the archive + if ! install_archive "$filename" "$INSTALL_PATH" "$os"; then + return 1 + fi + + if [[ "$os" == "win" ]]; then + cli_exe="aspire.exe" + else + cli_exe="aspire" + fi + cli_path="${INSTALL_PATH}/${cli_exe}" + + say_info "Aspire CLI successfully installed to: ${GREEN}$cli_path${RESET}" +} + +# Parse command line arguments +parse_args "$@" + +# Show help if requested +if [[ "$SHOW_HELP" == true ]]; then + show_help + exit 0 +fi + +# Set default install path if not provided +if [[ -z "$INSTALL_PATH" ]]; then + INSTALL_PATH="$HOME/.aspire/bin" + INSTALL_PATH_UNEXPANDED="\$HOME/.aspire/bin" +else + INSTALL_PATH_UNEXPANDED="$INSTALL_PATH" +fi + +# Create a temporary directory for downloads +temp_dir=$(mktemp -d -t aspire-cli-download-XXXXXXXX) +say_verbose "Creating temporary directory: $temp_dir" + +# Cleanup function for temporary directory +cleanup() { + # shellcheck disable=SC2317 # Function is called via trap + 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 + +# Download and install the archive +if ! download_and_install_archive "$temp_dir"; then + exit 1 +fi + +# Handle GitHub Actions environment +if [[ -n "${GITHUB_ACTIONS:-}" ]] && [[ "${GITHUB_ACTIONS}" == "true" ]]; then + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$INSTALL_PATH" >> "$GITHUB_PATH" + say_verbose "Added $INSTALL_PATH to \$GITHUB_PATH" + fi +fi + +# Add to shell profile for persistent PATH +add_to_shell_profile "$INSTALL_PATH" "$INSTALL_PATH_UNEXPANDED" + +# Add to current session PATH, if the path is not already in PATH +if [[ ":$PATH:" != *":$INSTALL_PATH:"* ]]; then + export PATH="$INSTALL_PATH:$PATH" +fi