FromGitHub.psm1

#Region '.\private\GetAsset.ps1' -1

function GetAsset {
    <#
        .SYNOPSIS
            Download and extract a GitHub release asset.
        .DESCRIPTION
            Downloads the asset from a GitHub release and extracts it if it's an archive.
            Returns the path to the directory where the file was downloaded, or the subfolder where it was extracted.
    #>

    [CmdletBinding()]
    param(
        # The Asset (from `SelectAssetByPlatform`) to download and extract
        [Parameter(Mandatory)]
        $Asset,

        # The name of a folder to extract the asset to (in case it's a zip)
        [string]$Repo = $(@($Asset.Name -split "[-_. /\\]+")[0])
    )

    # Download into our PresentWorkingDirectory
    $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 PresentWorkingDirectory)
    # 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 the path to the extracted asset
        $PackagePath
    } else {
        # Return the path to the downloaded asset
        $Pwd.Path
    }
}
#EndRegion '.\private\GetAsset.ps1' 57
#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'"

    $headers = @{
        Accept     = 'application/vnd.github.v3+json'
    }
    if( $env:GITHUB_TOKEN ) {
        $headers.Authorization = "bearer $($Env:GITHUB_TOKEN)"
    }

    $Result = if ($Tag -eq 'latest') {
        Invoke-RestMethod "https://api.github.com/repos/$Org/$Repo/releases/$Tag" -Headers $headers -Verbose:$false
    } else {
        Invoke-RestMethod "https://api.github.com/repos/$Org/$Repo/releases/tags/$Tag" -Headers $headers -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' 70
#Region '.\private\GetOSArchitecture.ps1' -1

function GetOSArchitecture {
    [CmdletBinding()]
    param(
        # If set, returns a regex pattern (based on the OS) that usually matches the architecture in asset names
        [switch]$Pattern,
        # A mock override exposed for testing only -- API only available in PS6+
        [string]$OSArchitecture = ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture),
        # A mock override exposed for testing only
        [bool]$Is64Bit = ([Environment]::Is64BitOperatingSystem)
    )

    # PowerShell Core
    $Architecture = if ($OSArchitecture) {
        $OSArchitecture
        # Legacy Windows PowerShell
    } elseif ($Is64Bit) {
        "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" }
            "X64" { "amd64|x64|x86_64" }
        }
    } else {
        $Architecture
    }
}
#EndRegion '.\private\GetOSArchitecture.ps1' 35
#Region '.\private\GetOSPlatform.ps1' -1

function GetOSPlatform {
    [CmdletBinding()]
    param(
        # If set, returns a regex pattern (based on the OS) that usually matches the OS in asset names
        [switch]$Pattern,
        # A mock override exposed for testing only
        [scriptblock]$IsOsPlatform = {
            [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( $args[0] )
        }
    )
    $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 (& $IsOsPlatform $platform::Windows) {
        "windows"
    } elseif (& $IsOsPlatform $platform::Linux) {
        "linux"
    } elseif (& $IsOsPlatform $platform::OSX) {
        "darwin"
    } elseif (& $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' 36
#Region '.\private\InitializeBinDir.ps1' -1

function InitializeBinDir {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The location to install to.
        # Defaults to $Env:LocalAppData\Programs\Tools on Windows, /usr/local/bin on Linux/MacOS
        # Passthrough from Install-FromGitHub
        [string]$BinDir,
        # Skip ShouldProcess confirmation
        [switch]$Force,
        # A mock override exposed for testing only
        [switch]$IsPosix = $IsLinux -or $IsMacOS
    )

    if (!$BinDir) {
        $BinDir = $(if ($IsPosix) {
                '/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.ShouldProcess($BinDir, "create directory and add to PATH")) {
            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' 47
#Region '.\private\MoveExecutable.ps1' -1

function MoveExecutable {
    # Some teams (e.g. earthly/earthly), 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...
    # DO NOT USE `[CmdletBinding()]` or [Parameter()]
    # We splat the parameters from Install-GitHubRelease and we need to ignore the extras
    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The directory where the asset was extracted to (i.e. the output of GetAsset)
        [string]$AssetDir,

        # The directory to move the executable(s) to (i.e. the output of InitializeBinDir)
        [Alias("TargetDirectory")]
        [string]$BinDir,

        # A regex pattern to select the right asset for this OS
        [string]$OS,

        # A regex pattern to select the right asset for this architecture
        [string]$Architecture,

        # An explicit user-supplied name for the executable
        [string]$ExecutableName,

        # The name of the repository, as a fallback for the executable name
        [string]$Repo,

        # The tag of the release we downloaded, in case we need to trim it from the name
        [string]$Tag,

        # For testing purposes, override OS detection
        [switch]$IsPosix = $IsLinux -or $IsMacOS,

        # Skip ShouldProcess confirmation when moving files
        [switch]$Force
    )
    $AllFiles = Get-ChildItem $AssetDir -File -Recurse
    if ($AllFiles.Count -eq 0) {
        Write-Warning "No executables found in $AssetDir"
        return
    }
    foreach ($File in $AllFiles) {
        $null = $PSBoundParameters.Remove("BinDir")
        $null = $PSBoundParameters.Remove("AssetDir")
        # We should avoid using -Force because it renames the file even if it doesn't need to be renamed
        # E.g. watch out for repos like `Flux2` producing flux.exe which should not be renamed (if we -Force it will be flux2.exe)
        # For now, all my regression tests pass if I only force when there's only one file, and it has separators in the name:
        # -Force:($AllFiles.Count -eq 1 -and $File -match "[-_ ]")
        # But they also pass if I just never Force, so I am leaving it like that for now
        $NewName = SelectExecutableName -File $File @PSBoundParameters

        if ($NewName -ne $File.Name) {
            Write-Warning "Renaming $File to $NewName"
            $File = Rename-Item $File.FullName -NewName $NewName -PassThru
        }

        # Some few projects include the docs with their package (e.g. opentofu)
        # And I want the user to know these files were available, but not move them
        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 $BinDir"

        # On non-Windows systems, we might need sudo to copy (if the folder is write protected)
        if ($IsPosix -and (Get-Item $BinDir -Force).Attributes -eq "ReadOnly,Directory") {
            if ($Force -or $PSCmdlet.ShouldProcess("Moving $File requires elevated permissions. Do you want to continue?", "$BinDir")) {
                sudo mv -f $File.FullName $BinDir
                sudo chmod +x "$BinDir/$($File.Name)"
            }
        } else {
            if (Test-Path $BinDir/$($File.Name)) {
                Remove-Item $BinDir/$($File.Name) -Recurse -Force
            }
            $Executable = Move-Item $File.FullName -Destination $BinDir -Force -ErrorAction Stop -PassThru
            if ($IsPosix -and ($Force -or $PSCmdlet.ShouldProcess("Setting eXecute bit", $Executable.FullName))) {
                chmod +x $Executable.FullName
            }
        }
        # Output the moved item, because sometimes our "using someearthly_version_win64.zip" message is confusing
        Get-Item (Join-Path $BinDir $File.Name) -ErrorAction Ignore
    }
}
#EndRegion '.\private\MoveExecutable.ps1' 84
#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 select the right asset for this OS
        [string]$OS,

        # A regex pattern to select the right asset for this architecture
        [string]$Architecture
    )
    # On Linux, we should prefer musl if it's available because the system is probably musl based
    $IsMusl = if ($IsLinux) {
        [bool](Get-ChildItem /usr/lib/ -Filter '*musl*.so*' -Recurse -ErrorAction Ignore | Select-Object -First 1)
    } else {
        $false
    }

    # Higher is better.
    # Sort the available assets in order of preference to choose an archive over an installer
    # If the extension is not in this list, we don't know how to handle it (for now)
    # TODO: Support for linux packages (deb, rpm, apk, etc)
    # TODO: Support for better archives (7z, etc)
    $assetExtension = ".zip", ".tgz", ".tar.gz", ".exe"
    $checksumExtension = ".sha", ".sha256", ".sha256sum", ".sha256sums", ".checksum", ".checksums", ".txt"
    $extension = $assetExtension + $checksumExtension
    $AllAssets = $assets |
        # 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 (!$_.Extension -and $OS -notmatch "windows" ) {
                    if ($IsMusl) {
                        if ($name -match "musl") {
                            10
                        } else {
                            99
                        }
                    } else {
                        if ($name -match "musl") {
                            99
                        } else {
                            10
                        }
                    }
                } else {
                    $index = [array]::IndexOf($extension, $_.Extension)
                    if ($IsMusl) {
                        if ($name -match "musl") {
                            $index
                        } else {
                            $index + 10
                        }
                    } else {
                        if ($name -match "musl") {
                            $index + 10
                        } else {
                            $index
                        }
                    }
                }
            }
        } |
        Where-Object { $_.Priority -ge 0 } |
        Sort-Object Priority, { $_.Name.Length }, Name

    Write-Verbose "Found $($AllAssets.Count) (of $($assets.Count)) assets. Testing for $OS/$Architecture`n $($AllAssets| Format-Table name, b*url | Out-String)"

    $MatchedAssets = $AllAssets.where{ $_.name -match $OS -and $_.name -match $Architecture }
    Write-Verbose "Assets for $OS/$Architecture`n $($MatchedAssets| Format-Table name, Extension, b*url | Out-String)"

    if ($MatchedAssets.Count -eq 1) {
        $asset = $MatchedAssets[0]
    } elseif ($MatchedAssets.Count -gt 1) {
        # The patterns are expected to be | separated and in order of preference
        :top foreach ($o in $OS -split '\|') {
            foreach ($a in $Architecture -split '\|') {
                # Now that we're looking in order of preference, we can just stop when we find a match
                if ($asset = $AllAssets.Where({ $_.name -match $o -and $_.name -match $a -and ((-not $_.Extension -and $OS -notmatch "windows") -or $_.Extension -in $assetExtension) }, "First", 1)) {
                    Write-Verbose "Selected $($asset.name) for $o|$a"

                    # Check for a match-specific checksum file
                    if ($checksum = $AllAssets.Where({ $_.name -match $o -and $_.name -match $a -and ((-not $_.Extension -and $OS -notmatch "windows") -or $_.Extension -in $checksumExtension) }, "First", 1)) {
                        $asset | Add-Member -NotePropertyMember @{ ChecksumUrl = $checksum.browser_download_url }
                    }
                    break top
                } else {
                    Write-Verbose "No match for $o|$a"
                }
            }
        }

    } else {
        throw "No asset found for $OS/$Architecture`n $($AllAssets.name -join "`n")"
    }

    # Check for a single checksum file for all assets
    if (!$checksum) {
        $checksum = $assets.Where({ $_.name -match "checksum|sha256sums|sha" }, "First")
        Write-Verbose "Found checksum file $($checksum.browser_download_url) for $($asset.name)"
        # Add that url to the asset object
        $asset | Add-Member -NotePropertyMember @{ ChecksumUrl = $checksum.browser_download_url } -Force
    }
    $asset
}
#EndRegion '.\private\SelectAssetByPlatform.ps1' 108
#Region '.\private\SelectExecutableName.ps1' -1

function SelectExecutableName {
    <#
        .SYNOPSIS
            Selects a name for the executable that doesn't include the OS/Architecture/Version in the name
    #>

    [CmdletBinding()]
    param(
        # A regex pattern to select the right asset for this OS
        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory)]
        [string]$OS,

        # A regex pattern to select the right asset for this architecture
        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory)]
        [string]$Architecture,

        # An explicit user-supplied name for the executable
        [string]$ExecutableName,

        # The name of the repository, as a fallback for the executable name
        [string]$Repo,

        # The tag of the release we downloaded, in case we need to trim it from the name
        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory)]
        [string]$Tag,

        # For testing purposes, override OS detection
        [switch]$IsPosix = $IsLinux -or $IsMacOS,

        # The file to pick a name for
        [System.IO.FileInfo]$File,

        # If set, forces picking a new file name
        [switch]$Force,

        # A list of file extensions to consider executable
        # By default, empty on Linux/MacOS, and $Env:PATHEXT on Windows
        $FilterExtensions = @(
            # On Windows, only rename when it has an executable extension
            if (-not $IsPosix) {
                @($ENV:PATHEXT -split ';') + '.EXE'
            }
        )
    )
    $Tag = [Regex]::Escape($Tag)

    # If the file has a token we want to replace in the name (and an executable extension if we're on Windows)
    $NeedsCleaning = $File.BaseName -match $OS -or $File.BaseName -match $Architecture -or $File.BaseName -match $Tag
    if ($Force -or ($NeedsCleaning -and ($FilterExtensions.Count -eq 0 -or $File.Extension -in $FilterExtensions))) {
        # When there is a manually specified executable name, we use that
        if ($ExecutableName) {
            # Make sure the executable name has the right extension
            if ($File.Extension) {
                [IO.Path]::ChangeExtension($ExecutableName, $File.Extension)
            } else {
                $ExecutableName
            }
            # Try just removing the OS, Architecture, and Tag from the name
        } elseif ($NeedsCleaning -and ($NewName = ($File.BaseName -replace "[-_\. ]*(?:$Tag)[-_\. ]*" -replace "[-_\. ]*(?:$OS)[-_\. ]*" -replace "[-_\. ]*(?:$Architecture)[-_\. ]*"))) {
            $NewName.Trim("-_. ") + $File.Extension
        } else {
            # Otherwise, fall back to the repo name
            $Repo + $File.Extension
        }
    } else {
        $File.Name
    }
}
#EndRegion '.\private\SelectExecutableName.ps1' 71
#Region '.\public\Install-FromGitHub.ps1' -1

function Install-FromGitHub {
    <#
    .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-FromGitHub FluxCD Flux2
 
        Install `Flux` from the https://github.com/FluxCD/Flux2 repository
    .EXAMPLE
        Install-FromGitHub earthly earthly
 
        Install `earthly` from the https://github.com/earthly/earthly repository
    .EXAMPLE
        Install-FromGitHub junegunn fzf
 
        Install `fzf` from the https://github.com/junegunn/fzf repository
    .EXAMPLE
        Install-FromGitHub BurntSushi ripgrep
 
        Install `rg` from the https://github.com/BurntSushi/ripgrep repository
    .EXAMPLE
        Install-FromGitHub opentofu opentofu
 
        Install `opentofu` from the https://github.com/opentofu/opentofu repository
    .EXAMPLE
        Install-FromGitHub twpayne chezmoi
 
        Install `chezmoi` from the https://github.com/twpayne/chezmoi repository
    .EXAMPLE
        Install-FromGitHub 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-FromGitHub sharkdp/bat
        Install-FromGitHub sharkdp/fd
 
        Install `bat` and `fd` from their repositories
    .NOTES
        All these examples are (only) tested on Windows and WSL Ubuntu
    #>

    [Alias("Install-GitHubRelease")]
    [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
        [string]$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
        [string]$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,

        # Optionally, the file name for the executable (it will be renamed to this)
        [string]$ExecutableName
    )
    begin {
        $ErrorActionPreference = "Stop"

        # 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
        }

        # Make sure there's a place to put the binary on the PATH
        $BinDir = InitializeBinDir $BinDir -Force:$Force
    }
    process {

        $release = GetGitHubRelease @PSBoundParameters
        # Update the $Repo (because we use it as a fallback name) after parsing argument handling
        $PSBoundParameters["Repo"] = $Repo = $release.Repo
        $PSBoundParameters["Tag"] = $release.tag_name
        $null = $PSBoundParameters.Remove("Org")

        $asset = SelectAssetByPlatform -assets $release.assets -OS $OS -Architecture $Architecture

        # 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
        $AssetDir = GetAsset $asset -Repo $Repo
        Pop-Location

        Write-Verbose "Moving the executable(s) from $AssetDir to $BinDir"
        MoveExecutable -AssetDir $AssetDir -BinDir $BinDir @PSBoundParameters -Force:$Force

        Remove-Item $workInTemp -Recurse
    }
}
#EndRegion '.\public\Install-FromGitHub.ps1' 128
#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"
                if($Env:GITHUB_TOKEN) {
                    Invoke-RestMethod $Check -Headers @{ Authorization = "Bearer $($Env:GITHUB_TOKEN)" }
                } else {
                    Invoke-RestMethod $Check
                }
            } elseif (Test-Path $Check) {
                Write-Debug "Checksum is a file: $Check"
                Get-Content $Check
            } else {
                Write-Debug "Checksum is a string: $Check"
                "$([IO.Path]::GetFileName($Target)) = $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' 54