Install-GithubRelease.ps1

<#
    .DESCRIPTION
        A cross-platform script to download, check the file hash, and make sure the binary is on your PATH.
    .SYNOPSIS
        Install a binary from a github release.
    .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 mikefarah yq
 
        Install `yq` from the https://github.com/mikefarah/yq repository
    .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
#>


<#PSScriptInfo
    .VERSION 1.3.1
 
    .GUID 802367c6-654a-450b-94db-87e1d52e020a
 
    .AUTHOR Joel Bennett
 
    .COMPANYNAME HuddledMasses.org
 
    .COPYRIGHT Copyright (c) 2019-2023, Joel Bennett
 
    .TAGS Install Github Releases Binaries Linux Windows
 
    .LICENSEURI https://github.com/Jaykul/BoxStarter-Boxes/blob/master/LICENSE
 
    .PROJECTURI https://github.com/Jaykul/BoxStarter-Boxes
 
    .RELEASENOTES
 
    - **1.3.1** Fixed the -BinDir parameter (and installing to hidden folders)
    - **1.3.0** Added support for mikefarah/yq, by supporting checksum files with multiple hashes (for different hash algorithms)
    - **1.2.0** Added support for .zip files on Linux
                Also for checksum files based on the name "SHA256SUMS" instead of "checksums"
    - **1.1.0** Added support for directly downloading binaries (.exe on Windows, or no extension) to support earthly/earthly
    - **1.0.0** Broke this out from my BoxStarter Boxes, so I could share it more easily.
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    # The user or organization that owns the repository
    [Parameter(Mandatory)]
    [Alias("User")]
    [string]$Org,

    # The name of the repository or project to download from
    [Parameter(Mandatory)]
    [string]$Repo,

    # The version (tag) of the release to download. Defaults to 'latest' which is always the latest release.
    [string]$Version = 'latest',

    # The location to install to. Defaults to $Env:LocalAppData\Programs on Windows, /usr/local/bin on Linux/MacOS
    [string]$BinDir
)

function Get-OSPlatform {
    [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
    }
}

function Get-OSArchitecture {
    [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 $arch
        switch ($arch) {
            "Arm" { "arm(?!64)" }
            "Arm64" { "arm64" }
            "X86" { "x86|386" }
            "X64" { "amd64|x64|x86_64" }
        }
    } else {
        $arch
    }
}

function Get-GitHubRelease {
    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [Alias("User")]
        [string]$Org,

        [Parameter(Position = 1)]
        [string]$Repo,

        [Parameter(Position = 2)]
        [Alias("Version")]
        [string]$Tag = 'latest'
    )

    Write-Debug "Checking GitHub for tag '$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-Debug "Found tag '$($result.tag_name)' for $tag"
    $result
}

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 files, or one or more hash strings)
        [string[]]$Checksum
    )

    # If Checksum is a file, get the checksum from the file
    if (Test-Path $Checksum) {
        $basename = [Regex]::Escape([IO.Path]::GetFileName($Target))
        Write-Debug "Checksum is a file, getting checksum for $basename from $checksum"
        $Checksum = (Select-String -LiteralPath $Checksum -Pattern $basename).Line -split "\s+|=" -notmatch $basename
    }

    $Actual = (Get-FileHash -LiteralPath $Target -Algorithm SHA256).Hash
    # Supports checksum files with an ARRAY of valid checksums (for different hash algorithms)
    # ... 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"
    }
}

function Install-GitHubRelease {
    <#
    .SYNOPSIS
        Install a binary from a github release.
    .DESCRIPTION
        Cross-platform script to download, check file hash, and make sure the binary is on your PATH.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The user or organization that owns the repository
        [Parameter(Mandatory)]
        [Alias("User")]
        [string]$Org,

        # The name of the repository or project to download from
        [Parameter(Mandatory)]
        [string]$Repo,

        # The version of the release to download. Defaults to 'latest'
        [string]$Version = 'latest',

        # The operating system (will be detected, if not specified)
        $OS = (Get-OSPlatform -Pattern),

        # The architecture (will be detected, if not specified)
        $Architecture = (Get-OSArchitecture -Pattern),

        # The location to install to. Defaults to $Env:LocalAppData\Programs on Windows, /usr/local/bin on Linux/MacOS
        [string]$BinDir = $(if ($OS -notmatch "windows") { '/usr/local/bin' } elseif ($Env:LocalAppData) { "$Env:LocalAppData\Programs\Tools" } else { "$HOME/.tools" })
    )
    # A list of extensions in order of preference
    $extension = ".zip", ".tgz", ".tar.gz", ".exe"

    $null = $PSBoundParameters.Remove("OS")
    $null = $PSBoundParameters.Remove("Architecture")
    $null = $PSBoundParameters.Remove("BinDir")

    $release = Get-GitHubRelease @PSBoundParameters
    Write-Verbose "found release $($release.tag_name) for $org/$repo"

    $assets = $release.assets.where{ $_.name -match $OS -and $_.name -match $Architecture } |
        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 ($assets.Count -gt 1) {
        if ($asset = $assets.where({ $_.Extension -in $extension }, "First")) {
            Write-Warning "Found multiple available downloands for $OS/$Architecture`n $($assets| Format-Table name, Extension, b*url | Out-String)`nUsing $($asset.name)"
            # If it's not on windows, executables don't need an extesion
        } elseif ($os -notmatch "windows" -and ($asset = $assets.Where({ !$_.Extension }, "First", 0))) {
            Write-Warning "Found multiple available downloands for $OS/$Architecture`n $($assets| Format-Table name, Extension, b*url | Out-String)`nUsing $($asset.name)"
        } else {
            throw "Found multiple available downloands for $OS/$Architecture`n $($assets| Format-Table name, Extension, b*url | Out-String)`nUnable to detect usable release."
        }
    } elseif ($assets.Count -eq 0) {
        throw "No asset found for $OS/$Architecture`n $($release.assets.name -join "`n")"
    } else {
        $asset = $assets[0]
    }

    # Make a folder to unpack in
    $tempdir = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
    New-Item -Type Directory -Path $tempdir | Out-Null
    Push-Location $tempdir

    $ProgressPreference = "SilentlyContinue"
    Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $asset.name -Verbose:$false

    # There might be a checksum file
    $checksum = $release.assets.where{ $_.name -match "checksum|sha256sums" }[0]
    if ($checksum.Count -gt 0) {
        Write-Verbose "Found checksum file $($checksum.name)"
        Invoke-WebRequest -Uri $checksum.browser_download_url -OutFile $checksum.name -Verbose:$false

        if (!(Test-FileHash -Target $asset.name -Checksum $checksum.name)) {
            throw "Checksum mismatch for $($asset.name)"
        }
    } else {
        Write-Warning "No checksum file found for $($asset.name)"
    }

    # If it's an archive, expand it
    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
            }
        }

        Set-Location $tempdir
    } else {
        Remove-Item $checksum.name
        $PackagePath = $tempdir
    }

    $Filter = @{ }
    if ($OS -match "windows") {
        $Filter.Include = @($ENV:PATHEXT -replace '\.', '*.' -split ';') + '*.exe'
    }

    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 should be /usr/local/bin or something already in your PATH
                # Make the change permanent
                if ($OS -match "windows") {
                    $PATH = [Environment]::GetEnvironmentVariable("PATH", [EnvironmentVariableTarget]::User)
                    $PATH += [IO.Path]::PathSeparator + $BinDir
                    [Environment]::SetEnvironmentVariable("PATH", $PATH, [EnvironmentVariableTarget]::User)
                }
            }
        } else {
            throw "Cannot install $Repo to $BinDir"
        }
    }

    Write-Verbose "Moving files from $PackagePath"
    foreach ($File in Get-ChildItem $PackagePath -File -Recurse @Filter) {
        # Some teams (e.g. earthly/earthly), name the actual binary with the platform name, which is annoying
        if ($File.BaseName -match $OS -and $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 $BinDir"

        if ($OS -notmatch "windows" -and (Get-Item $BinDir -Force).Attributes -eq "ReadOnly,Directory") {
            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 ($OS -notmatch "windows") {
                chmod +x $Executable.FullName
            }
        }
    }

    Pop-Location

    Remove-Item $tempdir -Recurse
}

Install-GitHubRelease @PSBoundParameters