Eigenverft.Manifested.Drydock.GitHub.ps1

function Get-GitHubLatestRelease {
<#
.SYNOPSIS
Gets latest GitHub release assets (Name, Version, DownloadUrl, Path) and downloads them into structured subfolders, with optional extraction for ZIP files.
 
.DESCRIPTION
Parses the provided GitHub repository URL (https://github.com/owner/repo), queries the GitHub API for the latest release, filters assets by allowlist (Whitelist, all patterns must match) and denylist (BlackList, any substring match), and downloads results into a target folder layout:
 
- By default, downloads to the user's Downloads folder.
- Per-asset subfolders are used by default; use -NoSubfolder to place the file directly under the chosen folder (or version folder).
- Use -IncludeVersionFolder to insert a version folder (release tag) beneath the DownloadFolder.
- With -Extract, .zip assets are downloaded to a temp location, extracted (overwrite on) into the target directory, and temp data is cleaned up. Non-zip assets are merely downloaded.
 
Notes:
- Uses WebClient for compatibility with Windows PowerShell 5/5.1. On PowerShell 7+, WebClient is also available.
- Sets TLS 1.2 on older stacks when possible.
- Minimal console logging via _Write-StandardMessage (INF/WRN/ERR/FTL gating).
- Idempotent: re-running converges to the same on-disk state (files are overwritten, extracts overwrite).
 
.PARAMETER RepoUrl
Full URL to a GitHub repository, for example: https://github.com/owner/repo
 
.PARAMETER Whitelist
Wildcard patterns; only assets whose names match every provided pattern are included. If omitted, all assets are considered before BlackList filtering.
 
.PARAMETER BlackList
Substring patterns; assets whose names contain any provided substring (case-insensitive) are excluded.
 
.PARAMETER DownloadFolder
Root folder where assets will be placed. Defaults to the current user's Downloads folder if omitted.
 
.PARAMETER NoSubfolder
When present, disables the default per-asset subfolder creation.
 
.PARAMETER IncludeVersionFolder
When present, prepends the release tag as a version folder under DownloadFolder.
 
.PARAMETER Extract
When present, ZIP assets are extracted (overwrite) into the target directory after download; non-zip assets are downloaded as files.
 
.OUTPUTS
System.Object
Each asset is emitted as a PSCustomObject with properties:
- Name
- Version
- DownloadUrl
- Path # file path for non-extracted items; target directory when extracted
 
.EXAMPLE
# Download all latest assets into per-asset folders under a version folder
Get-GitHubLatestRelease -RepoUrl 'https://github.com/ggml-org/llama.cpp' -IncludeVersionFolder
 
.EXAMPLE
# Allowlist AVX2 builds only, exclude debug artifacts, extract ZIPs, do not create per-asset subfolders
Get-GitHubLatestRelease -RepoUrl 'https://github.com/ggml-org/llama.cpp' -Whitelist '*avx2*' -BlackList 'debug' -NoSubfolder -Extract
 
.EXAMPLE
# Only x64 artifacts, exclude any 'beta' labeled assets, default layout (per-asset subfolders)
Get-GitHubLatestRelease -RepoUrl 'https://github.com/owner/repo' -Whitelist '*x64*' -BlackList 'beta'
 
.NOTES
Requires internet access. Uses GitHub's public API and a default User-Agent. Handles TLS 1.2 enablement when possible. No external executables required.
#>

    [CmdletBinding()]
    [Alias('gglr')]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [ValidateNotNullOrEmpty()]
        [string]$RepoUrl,

        [Parameter()]
        [string[]]$Whitelist,

        [Parameter()]
        [string[]]$BlackList,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$DownloadFolder,

        [Parameter()]
        [switch]$NoSubfolder,

        [Parameter()]
        [switch]$IncludeVersionFolder,

        [Parameter()]
        [switch]$Extract
    )

    # ---- Inline helpers (local scope only) ---------------------------------

    function _Write-StandardMessage {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        [CmdletBinding()]
        param(
            [Parameter(Mandatory=$true)]
            [ValidateNotNullOrEmpty()]
            [string]$Message,
            [Parameter(Mandatory=$false)]
            [ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')]
            [string]$Level = 'INF',
            [Parameter(Mandatory=$false)]
            [ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')]
            [string]$MinLevel
        )
        if (-not $PSBoundParameters.ContainsKey('MinLevel')) { $MinLevel = if ($Global:ConsoleLogMinLevel) { $Global:ConsoleLogMinLevel } else { 'INF' } }
        $sevMap = @{ TRC=0; DBG=1; INF=2; WRN=3; ERR=4; FTL=5 }
        $lvl = $Level.ToUpperInvariant()
        $min = $MinLevel.ToUpperInvariant()
        $sev = $sevMap[$lvl]
        $gate= $sevMap[$min]
        if ($sev -ge 4 -and $sev -lt $gate -and $gate -ge 4) { $lvl = $min ; $sev = $gate }
        if ($sev -lt $gate) { return }
        $ts = ([DateTime]::UtcNow).ToString('yyyy-MM-dd HH:mm:ss:fff')
        $stack      = Get-PSCallStack
        $helperName = $MyInvocation.MyCommand.Name
        $orgFunc    = $null
        $caller     = $null
        if ($stack) {
            $orgIdx = -1
            for ($i = 0; $i -lt $stack.Count; $i++) { if ($stack[$i].FunctionName -ne $helperName) { $orgFunc = $stack[$i]; $orgIdx = $i; break } }
            if ($orgIdx -ge 0) { $callerIdx = $orgIdx + 1; if ($stack.Count -gt $callerIdx) { $caller = $stack[$callerIdx] } else { $caller = $orgFunc } }
        }
        if (-not $caller) { $caller = [pscustomobject]@{ ScriptName = $PSCommandPath; FunctionName = '<scriptblock>' } }
        $file = if ($caller.ScriptName) { Split-Path -Leaf $caller.ScriptName } else { 'console' }
        $func = if ($caller.FunctionName) { $caller.FunctionName } else { '<scriptblock>' }
        $line = "[{0} {1}] [{2}] [{3}] {4}" -f $ts, $lvl, $file, $func, $Message
        if ($sev -ge 4) {
            if ($ErrorActionPreference -eq 'Stop') {
                Write-Error -Message $line -ErrorId ("ConsoleLog.{0}" -f $lvl) -Category NotSpecified -ErrorAction Stop
            } else {
                Write-Error -Message $line -ErrorId ("ConsoleLog.{0}" -f $lvl) -Category NotSpecified
            }
        } else {
            Write-Information -MessageData $line -InformationAction Continue
        }
    }

    function _Download-File {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        param(
            [Parameter(Mandatory=$true)][string]$Uri,
            [Parameter(Mandatory=$true)][string]$Destination
        )
        # Ensure TLS 1.2 on older stacks (best-effort).
        try {
            $spm = [type]::GetType('System.Net.ServicePointManager')
            if ($spm) {
                $protoProp = $spm.GetProperty('SecurityProtocol')
                if ($protoProp) {
                    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
                }
            }
        } catch { }

        # Use WebClient for PS5 compatibility and cross-platform Core availability.
        $wc = New-Object System.Net.WebClient
        try {
            $wc.Headers['User-Agent'] = 'PowerShell-GetGitHubLatestRelease/1.0'
            $wc.Headers['Accept']     = 'application/octet-stream'
            $dir = Split-Path -Parent $Destination
            if ($dir -and -not (Test-Path -LiteralPath $dir)) {
                New-Item -ItemType Directory -Path $dir -Force | Out-Null
            }
            $wc.DownloadFile($Uri, $Destination)
        } finally {
            $wc.Dispose()
        }
    }

    function _AllPatternsMatch {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        param(
            [Parameter(Mandatory=$true)][string]$Name,
            [Parameter()][string[]]$Patterns
        )
        if ($null -eq $Patterns -or $Patterns.Count -eq 0) { return $true }
        for ($i = 0; $i -lt $Patterns.Count; $i++) {
            $p = $Patterns[$i]
            if ($null -eq $p -or $p.Length -eq 0) { return $false }
            if (-not ($Name -like $p)) { return $false }
        }
        return $true
    }

    function _ContainsAnySubstringCI {
        [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")]
        param(
            [Parameter(Mandatory=$true)][string]$Name,
            [Parameter()][string[]]$Needles
        )
        if ($null -eq $Needles -or $Needles.Count -eq 0) { return $false }
        for ($i = 0; $i -lt $Needles.Count; $i++) {
            $n = $Needles[$i]
            if ($null -ne $n -and $n.Length -gt 0) {
                if ($Name.IndexOf($n, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
            }
        }
        return $false
    }

    # ---- Resolve DownloadFolder default ------------------------------------
    if (-not $PSBoundParameters.ContainsKey('DownloadFolder')) {
        $userHome = [Environment]::GetFolderPath('UserProfile')
        if ($null -eq $userHome -or $userHome.Length -eq 0) {
            # Fallback to env var if needed
            $userHome = $env:USERPROFILE
        }
        if ($null -eq $userHome -or $userHome.Length -eq 0) {
            _Write-StandardMessage -Message "Cannot determine home directory to compute default Downloads path." -Level ERR
            return
        }
        $DownloadFolder = Join-Path -Path $userHome -ChildPath 'Downloads'
    }

    # ---- Parse GitHub URL ---------------------------------------------------
    $owner = $null
    $repo  = $null
    try {
        $u = [Uri]$RepoUrl
        $path = $u.AbsolutePath.Trim('/')
        # Remove trailing ".git" if present.
        if ($path.EndsWith('.git', [System.StringComparison]::OrdinalIgnoreCase)) {
            $path = $path.Substring(0, $path.Length - 4)
        }
        $parts = $path.Split('/')
        if ($parts.Length -lt 2) { throw [System.ArgumentException]::new('Expected URL like https://github.com/owner/repo') }
        $owner = $parts[0]
        $repo  = $parts[1]
    } catch {
        _Write-StandardMessage -Message ("RepoUrl parse failed: {0}" -f $_.Exception.Message) -Level ERR
        return
    }

    # ---- Query GitHub API ---------------------------------------------------
    $release = $null
    try {
        $apiUri  = 'https://api.github.com/repos/{0}/{1}/releases/latest' -f $owner, $repo
        $headers = @{
            'User-Agent' = 'PowerShell-GetGitHubLatestRelease/1.0'
            'Accept'     = 'application/vnd.github+json'
        }
        # Best-effort TLS12 for older PS5; harmless on PS7.
        try {
            $spm = [type]::GetType('System.Net.ServicePointManager')
            if ($spm) {
                $protoProp = $spm.GetProperty('SecurityProtocol')
                if ($protoProp) {
                    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
                }
            }
        } catch { }
        $release = Invoke-RestMethod -Uri $apiUri -Headers $headers -Method Get
    } catch {
        _Write-StandardMessage -Message ("GitHub API call failed for {0}/{1}: {2}" -f $owner, $repo, $_.Exception.Message) -Level ERR
        return
    }

    if ($null -eq $release) {
        _Write-StandardMessage -Message "GitHub API returned no data." -Level ERR
        return
    }

    $tag = $null
    if ($null -ne $release.tag_name -and $release.tag_name.ToString().Length -gt 0) {
        $tag = $release.tag_name.ToString()
    } elseif ($null -ne $release.name -and $release.name.ToString().Length -gt 0) {
        $tag = $release.name.ToString()
    } else {
        $tag = 'unknown'
    }

    $assets = @()
    if ($null -ne $release.assets) {
        # Copy to a PowerShell array to avoid relying on pipeline variables.
        for ($i = 0; $i -lt $release.assets.Count; $i++) { $assets += ,$release.assets[$i] }
    }

    if ($assets.Count -eq 0) {
        _Write-StandardMessage -Message ("No assets found for latest release {0}/{1} tag {2}." -f $owner, $repo, $tag) -Level WRN
        return @()
    }

    # ---- Filter by Whitelist/BlackList -------------------------------------
    $selected = @()
    for ($i = 0; $i -lt $assets.Count; $i++) {
        $a = $assets[$i]
        $n = [string]$a.name
        if (-not (_AllPatternsMatch -Name $n -Patterns $Whitelist)) { continue }
        if (_ContainsAnySubstringCI -Name $n -Needles $BlackList) { continue }
        $selected += ,$a
    }

    if ($selected.Count -eq 0) {
        _Write-StandardMessage -Message "No assets matched allow/deny filters." -Level WRN
        return @()
    }

    # ---- Prepare extraction support if needed -------------------------------
    $zipTypeReady = $false
    if ($Extract.IsPresent) {
        try {
            $null = [System.IO.Compression.ZipFile]
            $zipTypeReady = $true
        } catch {
            try {
                [void][System.Reflection.Assembly]::Load('System.IO.Compression.FileSystem')
                $null = [System.IO.Compression.ZipFile]
                $zipTypeReady = $true
            } catch {
                _Write-StandardMessage -Message "ZIP extraction requires System.IO.Compression.ZipFile which is unavailable." -Level ERR
                return
            }
        }
    }

    # ---- Ensure base folders exist -----------------------------------------
    if (-not (Test-Path -LiteralPath $DownloadFolder)) {
        New-Item -ItemType Directory -Path $DownloadFolder -Force | Out-Null
        _Write-StandardMessage -Message ("Created directory: {0}" -f $DownloadFolder)
    }

    $rootTarget = $DownloadFolder
    if ($IncludeVersionFolder.IsPresent) {
        $rootTarget = Join-Path -Path $rootTarget -ChildPath $tag
        if (-not (Test-Path -LiteralPath $rootTarget)) {
            New-Item -ItemType Directory -Path $rootTarget -Force | Out-Null
            _Write-StandardMessage -Message ("Created version directory: {0}" -f $rootTarget)
        }
    }

    # One temp root for this command invocation (used only if Extract).
    $tempRoot = $null
    if ($Extract.IsPresent) {
        $tempRoot = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ([Guid]::NewGuid().ToString())
        New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
    }

    # ---- Process assets -----------------------------------------------------
    $results = @()
    for ($i = 0; $i -lt $selected.Count; $i++) {
        $asset = $selected[$i]
        $name  = [string]$asset.name
        $url   = [string]$asset.browser_download_url

        $targetDir = $rootTarget
        if (-not $NoSubfolder.IsPresent) {
            $base = [IO.Path]::GetFileNameWithoutExtension($name)
            if ($base.Length -eq 0) { $base = 'artifact' }
            $targetDir = Join-Path -Path $targetDir -ChildPath $base
        }

        if (-not (Test-Path -LiteralPath $targetDir)) {
            New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
            _Write-StandardMessage -Message ("Created directory: {0}" -f $targetDir)
        }

        $pathOut = $null
        $ext = [IO.Path]::GetExtension($name)
        if ($Extract.IsPresent -and $zipTypeReady -and ($null -ne $ext) -and ($ext.Equals('.zip', [System.StringComparison]::OrdinalIgnoreCase))) {
            # Download ZIP to temp file, then extract overwrite.
            $tempZip = Join-Path -Path $tempRoot -ChildPath $name
            _Download-File -Uri $url -Destination $tempZip
            _Write-StandardMessage -Message ("Downloaded: {0}" -f $name)
            try {
                $zip = [System.IO.Compression.ZipFile]::OpenRead($tempZip)
                try {
                    for ($e = 0; $e -lt $zip.Entries.Count; $e++) {
                        $entry = $zip.Entries[$e]
                        if ($null -eq $entry.Name -or $entry.Name.Length -eq 0) {
                            # Directory entry in archive; ensure directory exists.
                            $dirPath = Join-Path -Path $targetDir -ChildPath $entry.FullName
                            if (-not (Test-Path -LiteralPath $dirPath)) {
                                New-Item -ItemType Directory -Path $dirPath -Force | Out-Null
                            }
                            continue
                        }
                        $destPath = Join-Path -Path $targetDir -ChildPath $entry.FullName
                        $destDir  = Split-Path -Parent $destPath
                        if ($destDir -and -not (Test-Path -LiteralPath $destDir)) {
                            New-Item -ItemType Directory -Path $destDir -Force | Out-Null
                        }
                        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destPath, $true)
                    }
                } finally {
                    $zip.Dispose()
                }
            } catch {
                _Write-StandardMessage -Message ("Extraction failed for {0}: {1}" -f $name, $_.Exception.Message) -Level ERR
                return
            } finally {
                if (Test-Path -LiteralPath $tempZip) { Remove-Item -LiteralPath $tempZip -Force }
            }
            _Write-StandardMessage -Message ("Extracted: {0} -> {1}" -f $name, $targetDir)
            $pathOut = $targetDir
        } else {
            $destFile = Join-Path -Path $targetDir -ChildPath $name
            _Download-File -Uri $url -Destination $destFile
            _Write-StandardMessage -Message ("Downloaded: {0} -> {1}" -f $name, $destFile)
            $pathOut = $destFile
        }

        $results += [PSCustomObject]@{
            Name        = $name
            Version     = $tag
            DownloadUrl = $url
            Path        = $pathOut
        }
    }

    if ($tempRoot -and (Test-Path -LiteralPath $tempRoot)) {
        try { Remove-Item -LiteralPath $tempRoot -Recurse -Force } catch { }
    }

    return $results
}