Viscalyx.GitHub.psm1
#Region '.\prefix.ps1' -1 $script:dscResourceCommonModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/DscResource.Common' Import-Module -Name $script:dscResourceCommonModulePath $script:viscalyxCommonModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/Viscalyx.Common' Import-Module -Name $script:viscalyxCommonModulePath $script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US' #EndRegion '.\prefix.ps1' 8 #Region '.\Private\Convert-SecureStringAsPlainText.ps1' -1 <# .SYNOPSIS Converts a secure string to a plain text string. .DESCRIPTION This function converts a secure string to a plain text string by using the SecureStringToBSTR method and then converts the resulting BSTR to a string using PtrToStringBSTR. The function ensures that the memory allocated for the BSTR is properly freed to prevent memory leaks. .PARAMETER SecureString The secure string that should be converted to a plain text string. .EXAMPLE $securePassword = ConvertTo-SecureString -String 'P@ssw0rd' -AsPlainText -Force $plainTextPassword = Convert-SecureStringAsPlainText -SecureString $securePassword This example converts a secure string to a plain text string. .EXAMPLE $credential = Get-Credential $plainTextPassword = Convert-SecureStringAsPlainText -SecureString $credential.Password This example gets credentials from the user and converts the password secure string to a plain text string. .NOTES This function should be used with caution as it exposes secure strings as plain text which can be a security risk. Only use this function when absolutely necessary and in a secure context. #> function Convert-SecureStringAsPlainText { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Security.SecureString] $SecureString ) Write-Verbose -Message $script:localizedData.Convert_SecureStringAsPlainText_Converting # cSpell: ignore BSTR $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) try { $plainText = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { if ($bstr -ne [IntPtr]::Zero) { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } return $plainText } #EndRegion '.\Private\Convert-SecureStringAsPlainText.ps1' 62 #Region '.\Private\Invoke-UrlDownload.ps1' -1 <# .SYNOPSIS Downloads a file from a URL with progress indication. .DESCRIPTION The Invoke-UrlDownload function downloads a file from a specified URL to a local destination path. It uses PowerShell's Invoke-WebRequest to perform the download and relies on Invoke-WebRequest's built-in progress reporting rather than implementing its own Write-Progress callbacks. The function supports specifying a User-Agent header and includes robust error handling for HTTP errors (for example 401 and 404), network issues, and file system permission errors. The function will create the output directory if needed and honors the -Force switch to overwrite existing files. .PARAMETER Uri Specifies the URL from which to download the file. .PARAMETER OutputPath Specifies the local file path where the downloaded file will be saved. .PARAMETER UserAgent Specifies the User-Agent header to be used in the HTTP request. Defaults to 'Viscalyx.GitHub'. .PARAMETER Force Forces the download even if the file already exists at the specified output path. Without this parameter, the function will skip the download if the file exists. .EXAMPLE Invoke-UrlDownload -Uri 'https://example.com/file.zip' -OutputPath 'C:\Downloads\file.zip' Downloads a file from example.com and saves it to the specified path. .EXAMPLE Invoke-UrlDownload -Uri 'https://example.com/file.zip' -OutputPath 'C:\Downloads\file.zip' -Force Downloads a file from example.com and overwrites any existing file at the specified path. .INPUTS None. This function does not accept pipeline input. .OUTPUTS System.Boolean Returns $true when the download succeeds or when the download is skipped because the file already exists (when -Force is not specified). Returns $false when a non-terminating error occurs. A terminating error will throw and produce no return value. .NOTES This function is designed to be used internally by other commands within the module. #> function Invoke-UrlDownload { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.Uri] $Uri, [Parameter(Mandatory = $true)] [System.String] $OutputPath, [Parameter()] [System.String] $UserAgent = 'Viscalyx.GitHub', [Parameter()] [System.Management.Automation.SwitchParameter] $Force ) # Validate and create output directory if needed $outputDirectory = Split-Path -Path $OutputPath -Parent if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { if (-not (Test-Path -Path $outputDirectory)) { try { Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_CreatingDirectory -f $outputDirectory) $null = New-Item -Path $outputDirectory -ItemType Directory -Force -ErrorAction Stop } catch { $errorMessage = $script:localizedData.Invoke_UrlDownload_DirectoryCreationError -f $outputDirectory, $_.Exception.Message $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( ($errorMessage), 'IUD0001', [System.Management.Automation.ErrorCategory]::InvalidOperation, $outputDirectory ) ) } } } # Check if file already exists if (Test-Path -Path $OutputPath) { if ($Force) { Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_DownloadingFile -f $Uri, $OutputPath) } else { Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_SkippingDownload -f $OutputPath) return $true } } else { Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_DownloadingFile -f $Uri, $OutputPath) } try { $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = 'Stop' # Create WebRequest parameters $webRequestParams = @{ Uri = $Uri OutFile = $OutputPath UserAgent = $UserAgent ErrorAction = 'Stop' } <# Add UseBasicParsing parameter only for Windows PowerShell 5.1. In PowerShell Core/7+, basic parsing is the default and the parameter is deprecated/removed #> if ($PSVersionTable.PSEdition -eq 'Desktop') { $webRequestParams.UseBasicParsing = $true } # Download the file using Invoke-WebRequest # This will handle the download and progress reporting automatically Invoke-WebRequest @webRequestParams $ErrorActionPreference = $previousErrorActionPreference Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_DownloadCompleted -f $OutputPath) return $true } catch { $ErrorActionPreference = $previousErrorActionPreference # Save the error record before entering any switch or other context-changing statements $errorRecord = $_ # Determine the type of error and provide specific error message $errorMessage = $errorRecord.Exception.Message if ($errorRecord.Exception -is [System.Net.WebException]) { $webException = $errorRecord.Exception -as [System.Net.WebException] # Check if there's an HTTP response if ($webException.Response) { $response = $webException.Response # Check if we can access StatusCode if ($response.StatusCode) { $statusCode = $response.StatusCode.value__ switch ($statusCode) { 401 { $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_UnauthorizedError -f $Uri, $errorMessage) -ErrorRecord $errorRecord Write-Error -Message ($script:localizedData.Invoke_UrlDownload_UnauthorizedError -f $Uri, $errorMessage) -Category SecurityError -ErrorId 'Invoke_UrlDownload_Unauthorized' -TargetObject $Uri -Exception $exception } 404 { $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_NotFoundError -f $Uri, $errorMessage) -ErrorRecord $errorRecord Write-Error -Message ($script:localizedData.Invoke_UrlDownload_NotFoundError -f $Uri, $errorMessage) -Category ResourceUnavailable -ErrorId 'Invoke_UrlDownload_NotFound' -TargetObject $Uri -Exception $exception } default { $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -ErrorRecord $errorRecord Write-Error -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -Category NotSpecified -ErrorId 'Invoke_UrlDownload_NetworkError' -TargetObject $Uri -Exception $exception } } } else { $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -ErrorRecord $errorRecord Write-Error -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -Category NotSpecified -ErrorId 'Invoke_UrlDownload_NetworkError' -TargetObject $Uri -Exception $exception } } else { $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -ErrorRecord $errorRecord Write-Error -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -Category NotSpecified -ErrorId 'Invoke_UrlDownload_NetworkError' -TargetObject $Uri -Exception $exception } } elseif ($errorRecord.Exception -is [System.UnauthorizedAccessException] -or ($errorRecord.Exception -is [System.IO.IOException] -and $errorMessage -match 'denied|access')) { $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_PermissionError -f $OutputPath, $errorMessage) -ErrorRecord $errorRecord Write-Error -Message ($script:localizedData.Invoke_UrlDownload_PermissionError -f $OutputPath, $errorMessage) -Category PermissionDenied -ErrorId 'Invoke_UrlDownload_PermissionError' -TargetObject $OutputPath -Exception $exception } else { $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_UnknownError -f $Uri, $errorMessage) -ErrorRecord $errorRecord Write-Error -Message ($script:localizedData.Invoke_UrlDownload_UnknownError -f $Uri, $errorMessage) -Category NotSpecified -ErrorId 'Invoke_UrlDownload_UnknownError' -TargetObject $Uri -Exception $exception } return $false } } #EndRegion '.\Private\Invoke-UrlDownload.ps1' 235 #Region '.\Public\Get-GitHubRelease.ps1' -1 <# .SYNOPSIS Gets releases from a GitHub repository. .DESCRIPTION Gets releases from a GitHub repository. By default, it returns all non-prerelease and non-draft releases. Use the Latest parameter to return only the most recent release. Use the IncludePrerelease parameter to include prerelease versions, and the IncludeDraft parameter to include draft releases in the results. .PARAMETER OwnerName The name of the repository owner. .PARAMETER RepositoryName The name of the repository. .PARAMETER Latest If specified, only returns the most recent release that matches other filter criteria. .PARAMETER IncludePrerelease If specified, prerelease versions will be included in the results. .PARAMETER IncludeDraft If specified, draft releases will be included in the results. .PARAMETER Token The GitHub personal access token to use for authentication. If not specified, the function will use anonymous access which has rate limits. .EXAMPLE Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' Gets all non-prerelease, non-draft releases from the PowerShell/PowerShell repository. .EXAMPLE Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -Latest Gets the latest non-prerelease, non-draft release from the PowerShell/PowerShell repository. .EXAMPLE Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -IncludePrerelease Gets all releases including prereleases (but excluding drafts) from the PowerShell/PowerShell repository. .EXAMPLE Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -IncludePrerelease -IncludeDraft Gets all releases including prereleases and drafts from the PowerShell/PowerShell repository. .NOTES For more information about GitHub releases, see the GitHub REST API documentation: https://docs.github.com/en/rest/releases/releases #> function Get-GitHubRelease { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $OwnerName, [Parameter(Mandatory = $true)] [System.String] $RepositoryName, [Parameter()] [System.Management.Automation.SwitchParameter] $Latest, [Parameter()] [System.Management.Automation.SwitchParameter] $IncludePrerelease, [Parameter()] [System.Management.Automation.SwitchParameter] $IncludeDraft, [Parameter()] [System.Security.SecureString] $Token ) Write-Verbose -Message ($script:localizedData.Get_GitHubRelease_ProcessingRepository -f $OwnerName, $RepositoryName) $apiBaseUrl = 'https://api.github.com/repos/{0}/{1}/releases' -f $OwnerName, $RepositoryName $headers = @{ Accept = 'application/vnd.github.v3+json' } if ($Token) { # Convert SecureString to plain text for the authorization header $plainTextToken = Convert-SecureStringAsPlainText -SecureString $Token $headers.Authorization = "Bearer $plainTextToken" } try { $releases = Invoke-RestMethod -Uri $apiBaseUrl -Headers $headers -Method 'Get' -ErrorAction 'Stop' } catch { $writeErrorParameters = @{ Message = $script:localizedData.Get_GitHubRelease_Error_ApiRequest -f $_.Exception.Message Category = 'ObjectNotFound' ErrorId = 'GGHR0001' # cSpell: disable-line TargetObject = '{0}/{1}' -f $OwnerName, $RepositoryName } Write-Error @writeErrorParameters return $null } if (-not $releases -or $releases.Count -eq 0) { Write-Verbose -Message ($script:localizedData.Get_GitHubRelease_NoReleasesFound -f $OwnerName, $RepositoryName) return $null } if (-not $IncludePrerelease) { Write-Verbose -Message $script:localizedData.Get_GitHubRelease_FilteringPrerelease $releases = $releases | Where-Object -FilterScript { -not $_.prerelease } } if (-not $IncludeDraft) { Write-Verbose -Message $script:localizedData.Get_GitHubRelease_FilteringDraft $releases = $releases | Where-Object -FilterScript { -not $_.draft } } if ($Latest) { <# Sort by created_at descending to get the most recent release. The created_at attribute is the date of the commit used for the release, and not the date when the release was drafted or published. #> $latestRelease = $releases | Sort-Object -Property 'created_at' -Descending | Select-Object -First 1 return $latestRelease } return $releases } #EndRegion '.\Public\Get-GitHubRelease.ps1' 159 #Region '.\Public\Get-GitHubReleaseAsset.ps1' -1 <# .SYNOPSIS Gets metadata for a specific asset from a GitHub repository release. .DESCRIPTION This command retrieves metadata information about assets from releases of a GitHub repository. You can use the Latest parameter to specifically target the latest release, and the IncludePrerelease parameter to include prerelease versions when determining the latest release. The IncludeDraft parameter allows including draft releases in the results. The command returns metadata including the asset name, size, download URL, release version, and other relevant information for matching assets. This command can either fetch releases directly from GitHub or accept pre-fetched release objects through the InputObject parameter. .PARAMETER InputObject One or more release objects from GitHub. These objects should be the output from Get-GitHubRelease. This parameter enables piping releases directly into this command. .PARAMETER OwnerName The name of the repository owner. .PARAMETER RepositoryName The name of the repository. .PARAMETER Latest If specified, only returns assets from the latest release based on semantic versioning. .PARAMETER IncludePrerelease If specified, prerelease versions will be included in the release results. .PARAMETER IncludeDraft If specified, draft releases will be included in the release results. .PARAMETER AssetName The name of the asset to retrieve metadata for. You can use wildcards to match the asset name. If not specified, all assets from the matching release will be returned. .PARAMETER Token The GitHub personal access token to use for authentication. If not specified, the command will use anonymous access which has rate limits. .EXAMPLE Get-GitHubReleaseAsset -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -AssetName 'PowerShell-*-win-x64.msi' This example retrieves metadata for the Windows x64 MSI installer asset from the latest full release of the PowerShell/PowerShell repository. .EXAMPLE Get-GitHubReleaseAsset -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -AssetName 'PowerShell-*-win-x64.msi' -IncludePrerelease This example retrieves metadata for the Windows x64 MSI installer asset from the latest release (including prereleases) of the PowerShell/PowerShell repository. .EXAMPLE Get-GitHubReleaseAsset -OwnerName 'Microsoft' -RepositoryName 'WSL' -AssetName '*.x64' -Token 'ghp_1234567890abcdef' This example retrieves metadata for the AppX bundle asset from the latest release of the Microsoft/WSL repository using a GitHub personal access token for authentication. .EXAMPLE Get-GitHubReleaseAsset -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -IncludePrerelease -IncludeDraft This example retrieves metadata for all assets from all releases, including prereleases and drafts, of the PowerShell/PowerShell repository. .EXAMPLE Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' | Get-GitHubReleaseAsset -AssetName 'PowerShell-*-win-x64.msi' This example pipes releases from Get-GitHubRelease and retrieves metadata for the Windows x64 MSI installer assets. .NOTES This command requires internet connectivity to access the GitHub API when using the ByRepository parameter set. GitHub API rate limits may apply for unauthenticated requests. #> function Get-GitHubReleaseAsset { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the examples are syntactically correct. The rule does not seem to understand that there is pipeline input.')] [CmdletBinding(DefaultParameterSetName = 'ByRepository')] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $true, ParameterSetName = 'ByInputObject', ValueFromPipeline = $true)] [PSCustomObject[]] $InputObject, [Parameter(Mandatory = $true, ParameterSetName = 'ByRepository')] [System.String] $OwnerName, [Parameter(Mandatory = $true, ParameterSetName = 'ByRepository')] [System.String] $RepositoryName, [Parameter(ParameterSetName = 'ByRepository')] [System.Management.Automation.SwitchParameter] $Latest, [Parameter(ParameterSetName = 'ByRepository')] [System.Management.Automation.SwitchParameter] $IncludePrerelease, [Parameter(ParameterSetName = 'ByRepository')] [System.Management.Automation.SwitchParameter] $IncludeDraft, [Parameter(ParameterSetName = 'ByRepository')] [System.Security.SecureString] $Token, [Parameter()] [System.String] $AssetName ) begin { $releases = @() } process { if ($PSCmdlet.ParameterSetName -eq 'ByRepository') { $getGitHubReleaseParameters = @{} + $PSBoundParameters $getGitHubReleaseParameters.Remove('AssetName') $releaseFromRepo = Get-GitHubRelease @getGitHubReleaseParameters if (-not $releaseFromRepo) { return } $releases += $releaseFromRepo } else { $releases += $InputObject } } end { Write-Verbose -Message ($script:localizedData.Get_GitHubReleaseAsset_FoundRelease -f ($releases.name -join ', ')) foreach ($release in $releases) { if ($AssetName) { # Find the requested asset using wildcard matching $matchingAssets = $release.assets | Where-Object -FilterScript { $_.name -like $AssetName } if (-not $matchingAssets -or $matchingAssets.Count -eq 0) { $writeErrorParameters = @{ Message = $script:localizedData.Get_GitHubReleaseAsset_MissingAssetName Category = 'ObjectNotFound' ErrorId = 'GGHRAM0001' # cSpell: disable-line TargetObject = $AssetName } Write-Error @writeErrorParameters continue } } else { $matchingAssets = $release.assets } Write-Verbose -Message ( $script:localizedData.Get_GitHubReleaseAsset_FoundAsset -f ($matchingAssets.name -join ', ') ) $matchingAssets } } } #EndRegion '.\Public\Get-GitHubReleaseAsset.ps1' 191 #Region '.\Public\Save-GitHubReleaseAsset.ps1' -1 <# .SYNOPSIS Downloads GitHub release assets to a specified path. .DESCRIPTION The Save-GitHubReleaseAsset command downloads GitHub release assets to a specified local path. It can process multiple assets passed through the pipeline from Get-GitHubReleaseAsset and supports filtering by asset name. The command displays a progress bar during download to provide visual feedback to the user about the download status. The command can be used in two ways: 1. By piping GitHub release asset objects from Get-GitHubReleaseAsset 2. By directly specifying the download URI for an asset .PARAMETER InputObject Specifies the GitHub release asset objects to download. These objects are typically passed through the pipeline from Get-GitHubReleaseAsset. .PARAMETER Path Specifies the local path where the assets will be downloaded. If the path doesn't exist, it will be created. .PARAMETER AssetName Specifies a filter to download only assets that match the given name pattern. Wildcard characters are supported. .PARAMETER Uri Specifies the direct URI to a GitHub release asset to download. This parameter cannot be used together with InputObject. .PARAMETER MaxRetries Specifies the maximum number of retry attempts for failed downloads due to transient network issues. The default value is 3. Each retry uses exponential backoff (2^attempt seconds) before attempting the download again. .PARAMETER FileHash Specifies the expected SHA256 file hash for verification. Can be either: - A hashtable containing expected SHA256 file hashes keyed by asset names (for multiple files) - A string containing the expected SHA256 hash (for single file downloads) After each successful download, the actual file hash is computed and compared to the expected hash. If they differ, the downloaded file is deleted and an error is written. This parameter enables file integrity validation during the download process. .PARAMETER Overwrite Forces the download even if the file already exists at the specified output path. Without this parameter, the command will skip downloading files that already exist. .PARAMETER Force Suppresses all confirmation prompts and performs the operation without asking for user confirmation. This is useful for automation scenarios where user interaction is not possible or desired. .EXAMPLE $inputObject = Get-GitHubReleaseAsset -Owner 'PowerShell' -Repository 'PowerShell' -Tag 'v7.3.0' ; Save-GitHubReleaseAsset -InputObject $inputObject -Path 'C:\Downloads' Downloads all assets from PowerShell v7.3.0 release to the C:\Downloads directory. .EXAMPLE $inputObject = Get-GitHubReleaseAsset -Owner 'PowerShell' -Repository 'PowerShell' -Tag 'v7.3.0' ; Save-GitHubReleaseAsset -InputObject $inputObject -Path 'C:\Downloads' -AssetName '*win-x64*' Downloads only the Windows x64 assets from PowerShell v7.3.0 release to the C:\Downloads directory. .EXAMPLE Save-GitHubReleaseAsset -Uri 'https://github.com/PowerShell/PowerShell/releases/download/v7.3.0/PowerShell-7.3.0-win-x64.msi' -Path 'C:\Downloads' Downloads a specific PowerShell 7.3.0 MSI directly using its URI to the C:\Downloads directory. .EXAMPLE Save-GitHubReleaseAsset -Uri 'https://github.com/PowerShell/PowerShell/releases/download/v7.3.0/PowerShell-7.3.0-win-x64.msi' -Path 'C:\Downloads' -AssetName 'custom-name.msi' Downloads a specific PowerShell MSI and saves it as 'custom-name.msi' in the C:\Downloads directory. .EXAMPLE Save-GitHubReleaseAsset -Uri 'https://github.com/PowerShell/PowerShell/releases/download/v7.3.0/PowerShell-7.3.0-win-x64.msi' -Path 'C:\Downloads' -FileHash 'A1B2C3D4E5F6...' Downloads a PowerShell MSI and verifies its SHA256 hash matches the expected value. If the hash doesn't match, the downloaded file is deleted and an error is written. .EXAMPLE $fileHashes = @{ 'PowerShell-7.3.0-win-x64.msi' = 'A1B2C3D4E5F6...'; 'PowerShell-7.3.0-win-x86.msi' = 'F6E5D4C3B2A1...' } $inputObject = Get-GitHubReleaseAsset -Owner 'PowerShell' -Repository 'PowerShell' -Tag 'v7.3.0' ; Save-GitHubReleaseAsset -InputObject $inputObject -Path 'C:\Downloads' -AssetName '*win*.msi' -FileHash $fileHashes Downloads multiple PowerShell MSI files and verifies each file's SHA256 hash against the corresponding value in the hashtable. Files with mismatched hashes are deleted and errors are written. .EXAMPLE $inputObject = Get-GitHubReleaseAsset -Owner 'PowerShell' -Repository 'PowerShell' -Tag 'v7.3.0' ; Save-GitHubReleaseAsset -InputObject $inputObject -Path 'C:\Downloads' -Overwrite Downloads all assets from PowerShell v7.3.0 release to the C:\Downloads directory, overwriting any existing files. .INPUTS System.Management.Automation.PSObject Accepts GitHub release asset objects with 'name' and 'browser_download_url' properties. .OUTPUTS None This command does not generate output. #> function Save-GitHubReleaseAsset { [CmdletBinding(DefaultParameterSetName = 'ByInputObject', SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ByInputObject')] [AllowEmptyCollection()] [AllowNull()] [PSObject[]] $InputObject, [Parameter(Mandatory = $true)] [ValidateScript({ if ((Test-Path -Path $_) -and -not (Test-Path -Path $_ -PathType Container)) { throw ($script:localizedData.Save_GitHubReleaseAsset_PathIsFile -f $_) } return $true })] [System.String] $Path, [Parameter(ParameterSetName = 'ByInputObject')] [Parameter(ParameterSetName = 'ByUri')] [System.String] $AssetName, [Parameter(Mandatory = $true, ParameterSetName = 'ByUri')] [System.Uri] $Uri, [Parameter()] [ValidateRange(0, 10)] [System.Int32] $MaxRetries = 3, [Parameter()] [ValidateScript({ $_ -is [System.Collections.Hashtable] -or $_ -is [System.String] })] [System.Object] $FileHash, [Parameter()] [System.Management.Automation.SwitchParameter] $Overwrite, [Parameter()] [System.Management.Automation.SwitchParameter] $Force ) begin { if ($Force.IsPresent -and -not $Confirm) { $ConfirmPreference = 'None' } # Create a collection to store assets for processing $assetsToDownload = New-Object -TypeName 'System.Collections.Generic.List[PSObject]' # Flag to track if we should skip processing $skipProcessing = $false # Ensure the output directory exists if (-not (Test-Path -Path $Path)) { $shouldProcessDescriptionMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessDescription -f $Path $shouldProcessConfirmationMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessConfirmation $shouldProcessCaptionMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessCaption if ($PSCmdlet.ShouldProcess($shouldProcessDescriptionMessage, $shouldProcessConfirmationMessage, $shouldProcessCaptionMessage)) { Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_CreatingDirectory -f $Path) $null = New-Item -Path $Path -ItemType Directory -Force } else { # User declined to create the directory, set flag to skip processing $skipProcessing = $true return } } # If Uri parameter is used, create a custom object to represent the asset if ($PSCmdlet.ParameterSetName -eq 'ByUri') { $uriFileName = [System.IO.Path]::GetFileName($Uri.AbsolutePath) # If AssetName is specified, use that as the filename instead $fileName = if ($PSBoundParameters.ContainsKey('AssetName')) { $AssetName } else { $uriFileName } $assetObject = [PSCustomObject]@{ name = $fileName browser_download_url = $Uri.AbsoluteUri } $assetsToDownload.Add($assetObject) Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_UsingDirectUri -f $Uri, $fileName) } } process { # Skip processing if user declined directory creation if ($skipProcessing) { return } # Only process pipeline input for ByInputObject parameter set if ($PSCmdlet.ParameterSetName -eq 'ByInputObject') { foreach ($asset in $InputObject) { # Skip assets that don't match the asset name filter if one is provided if ($PSBoundParameters.ContainsKey('AssetName')) { if (-not ($asset.name -like $AssetName)) { Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_AssetFiltered -f $asset.name, $AssetName) continue } } # Add asset to the collection $assetsToDownload.Add($asset) } } } end { # Skip processing if user declined directory creation if ($skipProcessing) { return } if ($assetsToDownload.Count -eq 0) { Write-Error -Message $script:localizedData.Save_GitHubReleaseAsset_NoAssetsToDownload -Category ObjectNotFound -ErrorId 'SGRA0003' -TargetObject $AssetName return } Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_DownloadingAssets -f $assetsToDownload.Count) # Download assets one by one with progress indicator for ($i = 0; $i -lt $assetsToDownload.Count; $i++) { $asset = $assetsToDownload[$i] $destination = Join-Path -Path $Path -ChildPath $asset.name $activityMessage = $script:localizedData.Save_GitHubReleaseAsset_Progress_DownloadingAssets_Status -f $asset.name, ($i + 1), $assetsToDownload.Count $percentComplete = [System.Math]::Round((($i + 1) / $assetsToDownload.Count) * 100) # Show progress Write-Progress -Activity $script:localizedData.Save_GitHubReleaseAsset_Progress_DownloadingAssets_Activity -Status $activityMessage -PercentComplete $percentComplete Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_DownloadingAsset -f $asset.name, $destination) # Check if user approves downloading this asset $shouldProcessDescriptionMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessDownloadDescription -f $asset.name, $destination $shouldProcessConfirmationMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessDownloadConfirmation $shouldProcessCaptionMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessDownloadCaption if (-not $PSCmdlet.ShouldProcess($shouldProcessDescriptionMessage, $shouldProcessConfirmationMessage, $shouldProcessCaptionMessage)) { Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_SkippedByUser -f $asset.name) continue } # Use the private function to download the file with retry logic $attempt = 0 $downloadSuccessful = $false $lastException = $null while ($attempt -le $MaxRetries -and -not $downloadSuccessful) { $attempt++ try { $downloadResult = Invoke-UrlDownload -Uri $asset.browser_download_url -OutputPath $destination -Force:$Overwrite -ErrorAction Stop $downloadSuccessful = $downloadResult } catch { $lastException = $_ if ($attempt -le $MaxRetries) { # Calculate exponential backoff: 2^attempt seconds $waitTime = [System.Math]::Pow(2, $attempt) Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_RetryingDownload -f $asset.name, $attempt, $MaxRetries, $waitTime) Start-Sleep -Seconds $waitTime } else { # Max retries exceeded if ($ErrorActionPreference -eq 'Stop') { throw } } } } if (-not $downloadSuccessful) { # Download failed after all retries if ($ErrorActionPreference -eq 'Stop') { throw ($script:localizedData.Save_GitHubReleaseAsset_DownloadFailed -f $asset.name) } else { $errorMessage = $script:localizedData.Save_GitHubReleaseAsset_DownloadFailed -f $asset.name if ($lastException) { # Create an ErrorRecord with full metadata using the captured exception $errorRecord = [System.Management.Automation.ErrorRecord]::new( $lastException.Exception, 'SGRA0006', [System.Management.Automation.ErrorCategory]::OperationStopped, $asset.name ) $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($errorMessage) Write-Error -ErrorRecord $errorRecord } else { # Fallback if no exception was captured Write-Error -Message $errorMessage -Category OperationStopped -ErrorId 'SGRA0006' -TargetObject $asset.name } } } # Verify file hash if FileHash parameter is provided and download was successful if ($downloadSuccessful -and $PSBoundParameters.ContainsKey('FileHash')) { # Determine the expected hash based on the type of FileHash parameter $expectedHash = if ($FileHash -is [System.String]) { # String type: use the hash for the current asset $FileHash } elseif ($FileHash -is [System.Collections.Hashtable] -and $FileHash.ContainsKey($asset.name)) { # Hashtable type: look up the hash for this asset $FileHash[$asset.name] } else { # No hash available for this asset, skip validation $null } if ($expectedHash) { Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_VerifyingHash -f $asset.name) $hashMatches = Test-FileHash -Path $destination -Algorithm SHA256 -ExpectedHash $expectedHash if (-not $hashMatches) { # Hash mismatch - get actual hash before deleting, then delete the file and write error $actualHash = (Get-FileHash -Path $destination -Algorithm SHA256).Hash Remove-Item -Path $destination -Force $errorMessage = $script:localizedData.Save_GitHubReleaseAsset_HashMismatch -f $asset.name, $expectedHash, $actualHash if ($ErrorActionPreference -eq 'Stop') { throw $errorMessage } else { Write-Error -Message $errorMessage -Category InvalidData -ErrorId 'SGRA0002' -TargetObject $asset.name } # Skip further processing for this asset continue } else { Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_HashVerified -f $asset.name) } } } } # Complete the progress bar Write-Progress -Activity $script:localizedData.Save_GitHubReleaseAsset_Progress_DownloadingAssets_Activity -Completed Write-Verbose -Message $script:localizedData.Save_GitHubReleaseAsset_DownloadsCompleted } } #EndRegion '.\Public\Save-GitHubReleaseAsset.ps1' 412 |