functions/Get-UdeDeveloperFile.ps1


<#
    .SYNOPSIS
        Gets UDE developer files for a specified environment.
         
    .DESCRIPTION
        This function retrieves UDE developer files for a specified environment.
         
    .PARAMETER EnvironmentId
        The ID of the environment that you want to work against.
         
        Supports wildcard patterns.
         
        Can be either the environment name or the environment GUID.
         
    .PARAMETER Path
        The path to the directory where the developer files will be saved.
         
        Defaults to "C:\Temp\d365bap.tools\UdeDeveloperFiles".
         
    .PARAMETER Files
        The types of developer files to retrieve.
         
        Can be one or more of the following values: "All", "SystemMetadata", "FinOpsVsix22", "TraceParser", "CrossReference".
         
        Defaults to "All".
         
    .PARAMETER Download
        Instructs the function to download the developer files to the specified path.
         
    .PARAMETER ClearSystemPackages
        Instructs the function to clear the existing PackagesLocalDirectory before extracting the SystemMetadata file.
         
        Use with caution as it will delete existing files.
         
        Can be useful when the extraction has failed previously and you want to ensure a clean state for the extraction.
         
    .EXAMPLE
        PS C:\> Get-UdeDeveloperFile -EnvironmentId "env-123"
         
        This will retrieve the UDE developer files for the specified environment ID without downloading them.
         
    .EXAMPLE
        PS C:\> Get-UdeDeveloperFile -EnvironmentId "env-123" -Download
         
        This will download the UDE developer files for the specified environment ID to the default path.
         
    .EXAMPLE
        PS C:\> Get-UdeDeveloperFile -EnvironmentId "env-123" -Download -Files "SystemMetadata","TraceParser"
         
        This will download only the SystemMetadata and TraceParser UDE developer files for the specified environment ID to the default path.
         
    .EXAMPLE
        PS C:\> Get-UdeDeveloperFile -EnvironmentId "env-123" -Download -ClearSystemPackages
         
        This will download the UDE developer files for the specified environment ID to the default path.
        It will clear the existing PackagesLocalDirectory before extracting the SystemMetadata file, ensuring a clean state for the extraction.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Get-UdeDeveloperFile {
    [CmdletBinding()]
    [OutputType('System.Object[]')]
    param (

        [Parameter (Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("PpacEnvId")]
        [string] $EnvironmentId,

        [string] $Path = "C:\Temp\d365bap.tools\UdeDeveloperFiles",

        [ValidateSet('All', 'SystemMetadata', 'FinOpsVsix22', 'TraceParser', 'CrossReference')]
        [string[]] $Files = 'All',

        [switch] $Download,

        [switch] $ClearSystemPackages
    )
    
    begin {
        Add-Type -AssemblyName System.IO.Compression.FileSystem

        $executable = Get-PSFConfigValue -FullName "d365bap.tools.path.azcopy"

        $endpoints = @("SystemMetadata", "FinOpsVsix22", "TraceParser", "CrossReference")
        $colFileTypes = @()
        
        if ($Files -eq 'All') {
            $colFileTypes = $endpoints
        }
        else {
            $colFileTypes = $Files
        }
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $envObj = Get-UnifiedEnvironment -EnvironmentId $EnvironmentId | Select-Object -First 1

        if ($null -eq $envObj) {
            $messageString = "Could not find environment with Id <c='em'>$EnvironmentId</c>. Please verify the Id and try again, or list available environments using <c='em'>Get-UnifiedEnvironment</c>. Consider using wildcards if needed."

            Write-PSFMessage -Level Important -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment was NOT found based on the id." `
                -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
            return
        }
        
        $build = $envObj.PpacProvApp

        if ($Download) {
            $downloadDir = "$Path\$($envObj.PpacProvApp)"
            New-Item -Path $downloadDir `
                -ItemType Directory `
                -Force `
                -WarningAction SilentlyContinue > $null
        }

        $baseUri = $envObj.PpacEnvUri + "/" #! Very important to have the trailing slash

        $secureToken = (Get-AzAccessToken -ResourceUrl $baseUri -AsSecureString).Token
        $tokenWebApiValue = ConvertFrom-SecureString -AsPlainText -SecureString $secureToken

        $headers = @{
            "Correlationid"           = [guid]::NewGuid().ToString()
            "Dataverseenvironmenturi" = $envObj.PpacEnvUri
            "Authorization"           = "Bearer $($tokenWebApiValue)"
            "Odata-Maxversion"        = "4.0"
            "Odata-Version"           = "4.0"
            "Accept"                  = "application/json"
        }

        $colFiles = @(
            foreach ($endpoint in $endpoints) {

                # Skip if not in requested file types
                if ($endpoint -notin $colFileTypes) { continue }

                $localUri = "https://developertools.powerplatform.microsoft.com/api/clientmetadata/$($endpoint.ToLower())?version=$($envObj.PpacProvApp.Replace(".", "-"))"

                [PsCustomObject][Ordered]@{
                    "Type"  = $endpoint
                    "Build" = $envObj.PpacProvApp
                    "Uri"   = $(Invoke-RestMethod -Method Get `
                            -Uri $localUri `
                            -Headers $headers)
                } | Select-PSFObject -TypeName "D365Bap.Tools.UdeDeveloperFile" `
                    -Property *
            }
        )

        if (-not $Download) {
            $colFiles
            return
        }

        Write-PSFMessage -Level Important -Message "Will start the download of the files. It will open a separate PowerShell window for each:"

        $retryCount = 0
        $maxRetries = 5

        do {
            $retryCount++
            $processes = @()

            # Start a new PowerShell window for each download to allow parallel downloads
            # Each window will remain open after download to allow user to validate the download
            foreach ($fileObj in $colFiles) {
                $uriQuery = Split-Path $fileObj.Uri -Leaf
                $fileName = $uriQuery.Split("?")[0]
                $outputPath = Join-Path $downloadDir $fileName

                $fileObj | Add-Member -NotePropertyName "Path" -NotePropertyValue $outputPath -Force

                if ([System.IO.Path]::Exists($outputPath)) {
                    Write-PSFMessage -Level Important -Message " - Skipping <c='em'>$fileName</c> as it already <c='em'>exists</c>"
                    continue
                }

                Write-PSFMessage -Level Important -Message " - <c='em'>$fileName</c>"

                # Command to run in new window: azcopy copy, then pause for validation
                $command = @"
$executable copy '$($fileObj.Uri)' '$outputPath';
if(-not [System.IO.File]::Exists('$outputPath')){
    Write-Host 'Download failed. Review the logs for more information.' -ForegroundColor Red
    Read-Host -Prompt 'Press Enter to close this window'
};
 
"@

                $process = Start-Process -FilePath "powershell.exe" `
                    -ArgumentList "-Command", $command `
                    -WindowStyle Normal `
                    -PassThru

                $processes += $process
            }

            Write-PSFMessage -Level Important -Message "Will await the completion of <c='em'>all</c> file downloads."

            if ($processes.Count -gt 0 ) {
                Wait-Process -Id $processes.Id > $null
            }
        } while (
            ($colFiles | `
                Where-Object { -not [System.IO.File]::Exists($_.Path) }) `
                -and $retryCount -lt $maxRetries
        )
        
        foreach ($fileObj in $colFiles) {
            if (-not [System.IO.File]::Exists($fileObj.Path)) {
                Write-PSFMessage -Level Important -Message "File <c='em'>$($fileObj.Path)</c> does not exist. It seems the download failed. Please review the logs in the respective PowerShell window for more information."
                Stop-PSFFunction -Message "Stopping because at least one file download failed." `
                    -Exception $([System.Exception]::new("File $($fileObj.Path) does not exist. Download failed. Please review the logs in the respective PowerShell window for more information."))
                continue
            }
            
            Unblock-File -Path $fileObj.Path
        }

        if (Test-PSFFunctionInterrupt) { return }
                    
        # Output the details to the user
        $colFiles
            
        # If we downloaded the SystemMetadata, we extract it - otherwise it is useless
        $zipFile = $colFiles | `
            Where-Object type -eq 'SystemMetadata' | `
            Select-Object -First 1 `
            -ExpandProperty Path

        $pathPackages = "$env:LOCALAPPDATA\Microsoft\Dynamics365\$build"

        if (-not [System.IO.Path]::Exists("$pathPackages\PackagesLocalDirectory")) {
            New-Item -Path "$pathPackages" `
                -ItemType Directory `
                -Force `
                -WarningAction SilentlyContinue > $null
        }
        elseif (-not $ClearSystemPackages) {
            $zipObj = [IO.Compression.ZipFile]::OpenRead($zipFile)
            $zipPackages = ($zipObj.Entries | `
                    Where-Object Length -eq 0 | `
                    Where-Object FullName -match '^[^/]+/$'
            ).Count

            $localPackages = (Get-ChildItem -Path "$pathPackages\PackagesLocalDirectory" -Directory).Count

            $zipObj.Dispose()
            [GC]::Collect()
            [GC]::WaitForPendingFinalizers()

            Write-PSFMessage -Level Important -Message "The PackagesLocalDirectory already exists. If you want to re-extract the file, please run this command again with the switch <c='em'>-ClearSystemPackages</c>."
            Write-PSFMessage -Level Important -Message "Current number of packages in the zip file: <c='em'>$zipPackages</c>. Current number of packages in the local directory: <c='em'>$localPackages</c>."
            Stop-PSFFunction -Message "Stopping as PackagesLocalDirectory already exists." `
                -Exception $([System.Exception]::new("PackagesLocalDirectory already exists. If you want to re-extract the file, please run this command again with the switch -ClearSystemPackages."))
            return
        }

        Write-PSFMessage -Level Important -Message "Will extract the <c='em'>PackagesLocalDirectory.zip</c> file. It will take some minutes..."
        [IO.Compression.ZipFile]::ExtractToDirectory($zipFile, "$pathPackages\PackagesLocalDirectory", $true)
        Write-PSFMessage -Level Important -Message "Extraction completed..."

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    end {
    }
}