FromGitHub.psm1
#Region '.\private\GetGitHubRelease.ps1' -1 function GetGitHubRelease { # DO NOT USE `[CmdletBinding()]` or [Parameter()] # We splat the parameters from Install-GitHubRelease and we need to ignore the extras param( [string]$Org, [string]$Repo, [string]$Tag = 'latest' ) Write-Debug "Org: $Org, Repo: $Repo, Tag: $Tag" # Handle the Org parameter being a org/repo/version or the full URL to a project or release if ($Org -match "github.com") { Write-Debug "Org is a github.com url: $Org" if ($Org -match "releases/tag/.*") { $Org, $Repo, $Tag = $Org.split("/").where({ "github.com" -eq $_ }, "SkipUntil")[1, 2, -1] if ($PSBoundParameters.ContainsKey('Repo')) { Write-Warning "Repo is ignored when passing a full URL to a release/tag" } if ($PSBoundParameters.ContainsKey('Tag')) { Write-Warning "Tag is ignored when passing a full URL to a release/tag" } } else { if ($PSBoundParameters.ContainsKey('Repo')) { Write-Warning "Repo is ignored when passing a project URL" if (!$PSBoundParameters.ContainsKey('Tag')) { Write-Debug " and repo specified without Tag: $Repo" $Tag = $Repo } } $Org, $Repo = $Org.Split('/').where({ "github.com" -eq $_ }, "SkipUntil")[1, 2] } } elseif ($Org -match "/") { Write-Debug "Org is a / separated string: $Org" if ($PSBoundParameters.ContainsKey('Repo')) { Write-Warning "Repo is ignored when passing a / separated string for Org" if (!$PSBoundParameters.ContainsKey('Tag')) { Write-Debug " and repo specified without Tag: $Repo" $Tag = $Repo } } $Org, $Repo, $Version = $Org.Split('/') if ($Version -and -not $PSBoundParameters.ContainsKey('Repo') -and -not $PSBoundParameters.ContainsKey('Tag')) { $Tag = @($Version)[0] } } Write-Verbose "Checking GitHub $Org/$Repo for '$Tag'" $Result = if ($Tag -eq 'latest') { Invoke-RestMethod "https://api.github.com/repos/$Org/$Repo/releases/$Tag" -Headers @{Accept = 'application/json' } -Verbose:$false } else { Invoke-RestMethod "https://api.github.com/repos/$Org/$Repo/releases/tags/$Tag" -Headers @{Accept = 'application/json' } -Verbose:$false } Write-Verbose "found release $($Result.tag_name) for $Org/$Repo" $result | Add-Member -NotePropertyMembers @{ Org = $Org Repo = $Repo } -PassThru } #EndRegion '.\private\GetGitHubRelease.ps1' 64 #Region '.\private\GetOSArchitecture.ps1' -1 function GetOSArchitecture { [CmdletBinding()] param( [switch]$Pattern ) # PowerShell Core $Architecture = if (($arch = "$([Runtime.InteropServices.RuntimeInformation]::OSArchitecture)")) { $arch # Legacy Windows PowerShell } elseif ([Environment]::Is64BitOperatingSystem) { "X64"; } else { "X86"; } # Optionally, turn this into a regex pattern that usually works if ($Pattern) { Write-Information $Architecture switch ($Architecture) { "Arm" { "arm(?!64)" } "Arm64" { "arm64" } "X86" { "x86|386|32" } "X64" { "amd64|x64|x86_64|64" } } } else { $Architecture } } #EndRegion '.\private\GetOSArchitecture.ps1' 29 #Region '.\private\GetOSPlatform.ps1' -1 function GetOSPlatform { [CmdletBinding()] param( [switch]$Pattern ) $ri = [System.Runtime.InteropServices.RuntimeInformation] $platform = [System.Runtime.InteropServices.OSPlatform] # if $ri isn't defined, then we must be running in Powershell 5.1, which only works on Windows. $OS = if (-not $ri -or $ri::IsOSPlatform($platform::Windows)) { "windows" } elseif ($ri::IsOSPlatform($platform::Linux)) { "linux" } elseif ($ri::IsOSPlatform($platform::OSX)) { "darwin" } elseif ($ri::IsOSPlatform($platform::FreeBSD)) { "freebsd" } else { throw "unsupported platform" } if ($Pattern) { Write-Information $OS switch ($OS) { "windows" { "windows|(?<!dar)win" } "linux" { "linux|unix" } "darwin" { "darwin|osx" } "freebsd" { "freebsd" } } } else { $OS } } #EndRegion '.\private\GetOSPlatform.ps1' 32 #Region '.\private\InitializeBinDir.ps1' -1 function InitializeBinDir { [CmdletBinding(SupportsShouldProcess)] param( [string]$BinDir, [switch]$Force ) if (!$BinDir) { $BinDir = $(if ($IsLinux -or $IsMacOS) { '/usr/local/bin' } elseif ($Env:LocalAppData) { "$Env:LocalAppData\Programs\Tools" } else { "$HOME/.tools" }) } if (!(Test-Path $BinDir)) { # First time use of $BinDir if ($Force -or $PSCmdlet.ShouldContinue("Create $BinDir and add to Path?", "$BinDir does not exist")) { New-Item -Type Directory -Path $BinDir | Out-Null if ($Env:PATH -split [IO.Path]::PathSeparator -notcontains $BinDir) { $Env:PATH += [IO.Path]::PathSeparator + $BinDir # If it's *not* Windows, $BinDir would be /usr/local/bin or something already in your PATH if (!$IsLinux -and !$IsMacOS) { # But if it is Windows, we need to make the PATH change permanent $PATH = [Environment]::GetEnvironmentVariable("PATH", [EnvironmentVariableTarget]::User) $PATH += [IO.Path]::PathSeparator + $BinDir [Environment]::SetEnvironmentVariable("PATH", $PATH, [EnvironmentVariableTarget]::User) } } } else { throw "Cannot install $Repo to $BinDir" } } $BinDir } #EndRegion '.\private\InitializeBinDir.ps1' 35 #Region '.\private\MoveExecutable.ps1' -1 function MoveExecutable { # DO NOT USE `[CmdletBinding()]` or [Parameter()] # We splat the parameters from Install-GitHubRelease and we need to ignore the extras param( [string]$FromDir, [Alias("TargetDirectory")] [string]$ToDir, # A regex pattern to selecting the right asset for this OS [string]$OS, # A regex patter to select the right asset for this architecture [string]$Architecture ) $Filter = @{ } if (!$IsLinux -and !$IsMacOS) { # On Windows, it must have an executable extension # PATHEXT are all the executable extensions, but we redundantly add EXE just in case $Filter.Include = @($ENV:PATHEXT -replace '\.', '*.' -split ';') + '*.exe' } foreach ($File in Get-ChildItem $FromDir -File -Recurse @Filter) { # Some teams (e.g. earthly/earthly), don't use a zip, so they name the actual binary with the platform name # We do not want to type earthly_win64.exe every time, so rename to the base name... if ($File.BaseName -match $OS -or $File.BaseName -match $Architecture ) { # $File = Rename-Item $File.FullName -NewName "$Repo$($_.Extension)" -PassThru if (!($NewName = ($File.BaseName -replace "[-_\.]*(?:$OS)" -replace "[-_\.]*(?:$Architecture)"))) { $NewName = $Repo } $NewName += $File.Extension Write-Warning "Renaming $File to $NewName" $File = Rename-Item $File.FullName -NewName $NewName -PassThru } # Some few teams include the docs with their package (e.g. opentofu) if ($File.BaseName -match "README|LICENSE|CHANGELOG" -or $File.Extension -in ".md", ".rst", ".txt", ".asc", ".doc" ) { Write-Verbose "Skipping doc $File" continue } Write-Verbose "Moving $File to $ToDir" # On non-Windows systems, we might need sudo to copy (if the folder is write protected) if ($OS -notmatch "windows" -and (Get-Item $ToDir -Force).Attributes -eq "ReadOnly,Directory") { sudo mv -f $File.FullName $ToDir sudo chmod +x "$ToDir/$($File.Name)" } else { if (Test-Path $ToDir/$($File.Name)) { Remove-Item $ToDir/$($File.Name) -Recurse -Force } $Executable = Move-Item $File.FullName -Destination $ToDir -Force -ErrorAction Stop -PassThru if ($OS -notmatch "windows") { chmod +x $Executable.FullName } } # Output the moved item, because sometimes our "using someearthl_version_win64.zip" message is confusing Get-Item (Join-Path $ToDir $File.Name) -ErrorAction Ignore } } #EndRegion '.\private\MoveExecutable.ps1' 58 #Region '.\private\SelectAssetByPlatform.ps1' -1 function SelectAssetByPlatform { # DO NOT USE `[CmdletBinding()]` or [Parameter()] # We splat the parameters from Install-GitHubRelease and we need to ignore the extras param( $Assets, # A regex pattern to selecting the right asset for this OS [string]$OS, # A regex patter to select the right asset for this architecture [string]$Architecture ) # Order the list of available asses in this order of preference (basically, choose an archive over an installer) $extension = ".zip", ".tgz", ".tar.gz", ".exe" $MatchedAssets = $assets.where{ $_.name -match $OS -and $_.name -match $Architecture } | # I need both the Extension and the Priority on the final object for the logic below # I'll put the extension on, and then use that to calculate the priority # It would be faster (but ugly) to use a single Select-Object, but compared to downloading and unzipping, that's irrelevant Select-Object *, @{ Name = "Extension"; Expr = { $_.name -replace '^[^.]+$', '' -replace ".*?((?:\.tar)?\.[^.]+$)", '$1' } } | Select-Object *, @{ Name = "Priority"; Expr = { if (($index = [array]::IndexOf($extension, $_.Extension)) -lt 0) { $index * -10 } else { $index } } } | Sort-Object Priority, Name if ($MatchedAssets.Count -gt 1) { if ($asset = $MatchedAssets.where({ $_.Extension -in $extension }, "First")) { Write-Warning "Found multiple available downloads for $OS/$Architecture`n $($MatchedAssets| Format-Table name, Extension, b*url | Out-String)`nUsing $($asset.name)" # If it's not windows, executables don't need an extesion } elseif ($os -notmatch "windows" -and ($asset = $MatchedAssets.Where({ !$_.Extension }, "First", 0))) { Write-Warning "Found multiple available downloads for $OS/$Architecture`n $($MatchedAssets| Format-Table name, Extension, b*url | Out-String)`nUsing $($asset.name)" } else { throw "Found multiple available downloads for $OS/$Architecture`n $($MatchedAssets| Format-Table name, Extension, b*url | Out-String)`nUnable to detect usable release." } } elseif ($MatchedAssets.Count -eq 0) { throw "No asset found for $OS/$Architecture`n $($Assets.name -join "`n")" } else { $asset = $MatchedAssets[0] } # Check for a match-specific checksum file if( ($sha = $MatchedAssets.Where({$_.name -match "checksum|sha256sums|sha"}, "First")) -or # or a single checksum file for all assets ($sha = $assets.Where({$_.name -match "checksum|sha256sums|sha"}, "First"))) { Write-Verbose "Found checksum file $($sha.browser_download_url) for $($asset.name)" # Add that url to the asset object $asset | Add-Member -NotePropertyMember @{ ChecksumUrl = $sha.browser_download_url } } $asset } #EndRegion '.\private\SelectAssetByPlatform.ps1' 47 #Region '.\public\Install-GitHubRelease.ps1' -1 function Install-GitHubRelease { <# .SYNOPSIS Install a binary from a github release. .DESCRIPTION An installer for single-binary tools released on GitHub. This cross-platform script will download, check the file hash, unpack and and make sure the binary is on your PATH. It uses the github API to get the details of the release and find the list of downloadable assets, and relies on the common naming convention to detect the right binary for your OS (and architecture). .EXAMPLE Install-GithubRelease FluxCD Flux2 Install `Flux` from the https://github.com/FluxCD/Flux2 repository .EXAMPLE Install-GithubRelease earthly earthly Install `earthly` from the https://github.com/earthly/earthly repository .EXAMPLE Install-GithubRelease junegunn fzf Install `fzf` from the https://github.com/junegunn/fzf repository .EXAMPLE Install-GithubRelease BurntSushi ripgrep Install `rg` from the https://github.com/BurntSushi/ripgrep repository .EXAMPLE Install-GithubRelease opentofu opentofu Install `opentofu` from the https://github.com/opentofu/opentofu repository .EXAMPLE Install-GithubRelease twpayne chezmoi Install `chezmoi` from the https://github.com/twpayne/chezmoi repository .EXAMPLE Install-GitHubRelease https://github.com/mikefarah/yq/releases/tag/v4.44.6 Install `yq` version v4.44.6 from it's release on github.com .EXAMPLE Install-GithubRelease sharkdp/bat Install-GithubRelease sharkdp/fd Install `bat` and `fd` from their repositories .NOTES All these examples are (only) tested on Windows and WSL Ubuntu #> [CmdletBinding(SupportsShouldProcess)] param( # The user or organization that owns the repository # Also supports pasting the org and repo as a single string: fluxcd/flux2 # Or passing the full URL to the project: https://github.com/fluxcd/flux2 # Or a specific release: https://github.com/fluxcd/flux2/releases/tag/v2.5.0 [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias("User")] [string]$Org, # The name of the repository or project to download from [Parameter(Position = 1, ValueFromPipelineByPropertyName)] [string]$Repo, # The tag of the release to download. Defaults to 'latest' [Parameter(Position = 2, ValueFromPipelineByPropertyName)] [Alias("Version")] [string]$Tag = 'latest', # Skip prompting to create the "BinDir" tool directory (on Windows) [switch]$Force, # A regex pattern to override selecting the right option from the assets on the release # The operating system is automatically detected, you do not need to pass this parameter $OS, # A regex pattern to override selecting the right option from the assets on the release # The architecture is automatically detected, you do not need to pass this parameter $Architecture, # The location to install to. # Defaults to $Env:LocalAppData\Programs\Tools on Windows, /usr/local/bin on Linux/MacOS # There's normally no reason to pass this parameter [string]$BinDir ) process { # Really this should just be a default value, but GetOSPlatform is private because it's weird, ok? if (!$OS) { $OS = GetOSPlatform -Pattern $PSBoundParameters["OS"] = $OS } if (!$Architecture) { $Architecture = GetOSArchitecture -Pattern $PSBoundParameters["Architecture"] = $Architecture } $release = GetGitHubRelease @PSBoundParameters # Update the $Repo (because we use it as a fallback name) after parsing argument handling $Repo = $release.Repo $asset = SelectAssetByPlatform -assets $release.assets @PSBoundParameters # Make a random folder to unpack in $workInTemp = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName()) New-Item -Type Directory -Path $workInTemp | Out-Null Push-Location $workInTemp # Download into our workInTemp folder $ProgressPreference = "SilentlyContinue" Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $asset.name -Verbose:$false # There might be a checksum file if ($asset.ChecksumUrl) { if (!(Test-FileHash -Target $asset.name -Checksum $asset.ChecksumUrl)) { throw "Checksum mismatch for $($asset.name)" } } else { Write-Warning "No checksum file found, skipping checksum validation for $($asset.name)" } # If it's an archive, expand it (inside our workInTemp folder) # We'll keep the folder the executable is in as $PackagePath either way. if ($asset.Extension -and $asset.Extension -ne ".exe") { $File = Get-Item $asset.name New-Item -Type Directory -Path $Repo | Convert-Path -OutVariable PackagePath | Set-Location Write-Verbose "Extracting $File to $PackagePath" if ($asset.Extension -eq ".zip") { Microsoft.PowerShell.Archive\Expand-Archive $File.FullName } else { if ($VerbosePreference -eq "Continue") { tar -xzvf $File.FullName } else { tar -xzf $File.FullName } } # Return to the workInTemp folder Set-Location $workInTemp } else { $PackagePath = $workInTemp } # Make sure there's a place to put the binary on the PATH $BinDir = InitializeBinDir $BinDir -Force:$Force Write-Verbose "Moving the exectuable(s) from $PackagePath to $BinDir" MoveExecutable -FromDir $PackagePath -ToDir $BinDir @PSBoundParameters Pop-Location Remove-Item $workInTemp -Recurse } } #EndRegion '.\public\Install-GitHubRelease.ps1' 153 #Region '.\public\Test-FileHash.ps1' -1 function Test-FileHash { <# .SYNOPSIS Test the hash of a file against one or more checksum files or strings .DESCRIPTION Checksum files are assumed to have one line per file name, with the hash (or multiple hashes) on the line following the file name. In order to support installing yq (which has a checksum file with multiple hashes), this function handles checksum files with an ARRAY of valid checksums for each file name by searching the array for any matching hash. This isn't great, but an accidental pass is almost inconceivable, and determining the hash order is too complicated (given only one weird project does this so far). #> [OutputType([bool])] [CmdletBinding()] param( # The path to the file to check the hash of [string]$Target, # The hash(es) or checksum(s) to compare to (can be one or more urls, files, or hash strings) [string[]]$Checksum ) $basename = [Regex]::Escape([IO.Path]::GetFileName($Target)) # Supports checksum files with an ARRAY of valid checksums (for different hash algorithms) $Checksum = @( foreach($check in $Checksum) { # If Checksum is a URL, fetch the checksum(s) from the URL if ($Check -match "https?://") { Write-Debug "Checksum is a URL: $Check" Invoke-RestMethod $Check } elseif (Test-Path $Check) { Write-Debug "Checksum is a file: $Check" Get-Content $Check } } ) -match $basename -split "\s+|=" -notmatch $basename $Actual = (Get-FileHash -LiteralPath $Target -Algorithm SHA256).Hash # ... by searching the array for any matching hash (an accidental pass is almost inconceivable). [bool]($Checksum -eq $Actual) if ($Checksum -eq $Actual) { Write-Verbose "Checksum matches $Actual" } else { Write-Error "Checksum mismatch!`nValid: $Checksum`nActual: $Actual" } } #EndRegion '.\public\Test-FileHash.ps1' 47 |