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","")]
        # This function is globally exempt from the GENERAL POWERSHELL REQUIREMENTS unless explicitly stated otherwise.
        [CmdletBinding()]
        param(
            [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Message,
            [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$Level='INF',
            [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$MinLevel
        )

        if ($null -eq $Message) {
            $Message = [string]::Empty
        }

        $sevMap=@{TRC=0;DBG=1;INF=2;WRN=3;ERR=4;FTL=5}
        if(-not $PSBoundParameters.ContainsKey('MinLevel')){
            $gv=Get-Variable ConsoleLogMinLevel -Scope Global -ErrorAction SilentlyContinue
            $MinLevel=if($gv -and $gv.Value -and -not [string]::IsNullOrEmpty([string]$gv.Value)){[string]$gv.Value}else{'INF'}
        }
        $lvl=$Level.ToUpperInvariant()
        $min=$MinLevel.ToUpperInvariant()
        $sev=$sevMap[$lvl];if($null -eq $sev){$lvl='INF';$sev=$sevMap['INF']}
        $gate=$sevMap[$min];if($null -eq $gate){$min='INF';$gate=$sevMap['INF']}
        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('yy-MM-dd HH:mm:ss.ff')
        $stack=Get-PSCallStack ; $helperName=$MyInvocation.MyCommand.Name ; $helperScript=$MyInvocation.MyCommand.ScriptBlock.File ; $caller=$null
        if($stack){
            # 1: prefer first non-underscore function not defined in the helper's own file
            for($i=0;$i -lt $stack.Count;$i++){
                $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName
                if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_') -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break}
            }
            # 2: fallback to first non-underscore function (any file)
            if(-not $caller){
                for($i=0;$i -lt $stack.Count;$i++){
                    $f=$stack[$i];$fn=$f.FunctionName
                    if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_')){$caller=$f;break}
                }
            }
            # 3: fallback to first non-helper frame not from helper's own file
            if(-not $caller){
                for($i=0;$i -lt $stack.Count;$i++){
                    $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName
                    if($fn -and $fn -ne $helperName -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break}
                }
            }
            # 4: final fallback to first non-helper frame
            if(-not $caller){
                for($i=0;$i -lt $stack.Count;$i++){
                    $f=$stack[$i];$fn=$f.FunctionName
                    if($fn -and $fn -ne $helperName){$caller=$f;break}
                }
            }
        }
        if(-not $caller){$caller=[pscustomobject]@{ScriptName=$PSCommandPath;FunctionName=$null}}
        $lineNumber=$null ; 
        $p=$caller.PSObject.Properties['ScriptLineNumber'];if($p -and $p.Value){$lineNumber=[string]$p.Value}
        if(-not $lineNumber){
            $p=$caller.PSObject.Properties['Position']
            if($p -and $p.Value){
                $sp=$p.Value.PSObject.Properties['StartLineNumber'];if($sp -and $sp.Value){$lineNumber=[string]$sp.Value}
            }
        }
        if(-not $lineNumber){
            $p=$caller.PSObject.Properties['Location']
            if($p -and $p.Value){
                $m=[regex]::Match([string]$p.Value,':(\d+)\s+char:','IgnoreCase');if($m.Success -and $m.Groups.Count -gt 1){$lineNumber=$m.Groups[1].Value}
            }
        }
        $file=if($caller.ScriptName){Split-Path -Leaf $caller.ScriptName}else{'cmd'}
        if($file -ne 'console' -and $lineNumber){$file="{0}:{1}" -f $file,$lineNumber}
        $prefix="[$ts "
        $suffix="] [$file] $Message"
        $cfg=@{TRC=@{Fore='DarkGray';Back=$null};DBG=@{Fore='Cyan';Back=$null};INF=@{Fore='Green';Back=$null};WRN=@{Fore='Yellow';Back=$null};ERR=@{Fore='Red';Back=$null};FTL=@{Fore='Red';Back='DarkRed'}}[$lvl]
        $fore=$cfg.Fore
        $back=$cfg.Back
        $isInteractive = [System.Environment]::UserInteractive

        if($isInteractive -and ($fore -or $back)){
            Write-Host -NoNewline $prefix
            if($fore -and $back){Write-Host -NoNewline $lvl -ForegroundColor $fore -BackgroundColor $back}
            elseif($fore){Write-Host -NoNewline $lvl -ForegroundColor $fore}
            elseif($back){Write-Host -NoNewline $lvl -BackgroundColor $back}
            Write-Host $suffix
        } else {
            Write-Host "$prefix$lvl$suffix"
        }

        if($sev -ge 4 -and $ErrorActionPreference -eq 'Stop'){throw ("ConsoleLog.{0}: {1}" -f $lvl,$Message)}
    }

    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
}