Private/_Helpers.ps1

function Set-ColourOption(
    [string]$value
) {
    if ($value -eq "False") { 
        return "White" 
    } 
    elseif ($value -eq "Error") {
        return "Red"
    }
    elseif ($value -eq "Optional") {
        return "Magenta"
    }
    else { return "Green" }
}

function Test-InsideDockerContainer {
    $DockerSvc = Get-Service -Name cexecsvc -ErrorAction SilentlyContinue
    if ($null -eq $DockerSvc ) {
        return $false
    }
    else {
        return $true
    }
}

function Update-Manifest {
    #.Synopsis
    # Update a PowerShell module manifest
    #.Description
    # By default Update-Manifest increments the ModuleVersion, but it can set any key in the Module Manifest, its PrivateData, or the PSData in PrivateData.
    #
    # NOTE: This cannot currently create new keys, or uncomment keys.
    #.Example
    # Update-Manifest .\Configuration.psd1
    #
    # Increments the Build part of the ModuleVersion in the Configuration.psd1 file
    #.Example
    # Update-Manifest .\Configuration.psd1 -Increment Major
    #
    # Increments the Major version part of the ModuleVersion in the Configuration.psd1 file
    #.Example
    # Update-Manifest .\Configuration.psd1 -Value '0.4'
    #
    # Sets the ModuleVersion in the Configuration.psd1 file to 0.4
    #.Example
    # Update-Manifest .\Configuration.psd1 -Property ReleaseNotes -Value 'Add the awesome Update-Manifest function!'
    #
    # Sets the PrivateData.PSData.ReleaseNotes value in the Configuration.psd1 file!
    [CmdletBinding()]
    param(
        # The path to the module manifest file
        [Parameter(ValueFromPipelineByPropertyName = "True", Position = 0)]
        [Alias("PSPath")]
        [string]$Manifest,

        # The property to be set in the manifest. It must already exist in the file (and not be commented out)
        # This searches the Manifest root properties, then the properties PrivateData, then the PSData
        [Parameter(ParameterSetName = "Overwrite")]
        [string]$PropertyName = 'ModuleVersion',

        # A new value for the property
        [Parameter(ParameterSetName = "Overwrite", Mandatory)]
        $Value,

        # By default Update-Manifest increments ModuleVersion; this controls which part of the version number is incremented
        [Parameter(ParameterSetName = "Increment")]
        [ValidateSet("Major", "Minor", "Build", "Revision")]
        [string]$Increment = "Build",

        # When set, and incrementing the ModuleVersion, output the new version number.
        [Parameter(ParameterSetName = "Increment")]
        [switch]$Passthru
    )

    $KeyValue = Get-ManifestValue $Manifest -PropertyName $PropertyName -Passthru

    if ($PSCmdlet.ParameterSetName -eq "Increment") {
        $Version = [Version]$KeyValue.SafeGetValue()

        $Version = switch ($Increment) {
            "Major" {
                [Version]::new($Version.Major + 1, 0)
            }
            "Minor" {
                $Minor = if ($Version.Minor -le 0) { 1 } else { $Version.Minor + 1 }
                [Version]::new($Version.Major, $Minor)
            }
            "Build" {
                $Build = if ($Version.Build -le 0) { 1 } else { $Version.Build + 1 }
                [Version]::new($Version.Major, $Version.Minor, $Build)
            }
            "Revision" {
                $Revision = if ($Version.Revision -le 0) { 1 } else { $Version.Revision + 1 }
                [Version]::new($Version.Major, $Version.Minor, $Version.Build, $Revision)
            }
        }

        $Value = $Version

        if ($Passthru) { $Value }
    }

    $Value = ConvertTo-Metadata $Value

    $Extent = $KeyValue.Extent
    while ($KeyValue.parent) { $KeyValue = $KeyValue.parent }

    $ManifestContent = $KeyValue.Extent.Text.Remove(
        $Extent.StartOffset, 
        ($Extent.EndOffset - $Extent.StartOffset)
    ).Insert($Extent.StartOffset, $Value)

    if (Test-Path $Manifest) {
        Set-Content $Manifest $ManifestContent
    }
    else {
        $ManifestContent
    }
}

function Get-ManifestValue {
    #.Synopsis
    # Reads a specific value from a module manifest
    #.Description
    # By default Get-ManifestValue gets the ModuleVersion, but it can read any key in the Module Manifest, including the PrivateData, or the PSData inside the PrivateData.
    #.Example
    # Get-ManifestValue .\Configuration.psd1
    #
    # Returns the module version number (as a string)
    #.Example
    # Get-ManifestValue .\Configuration.psd1 ReleaseNotes
    #
    # Returns the release notes!
    [CmdletBinding()]
    param(
        # The path to the module manifest file
        [Parameter(ValueFromPipelineByPropertyName = "True", Position = 0)]
        [Alias("PSPath")]
        [string]$Manifest,

        # The property to be read from the manifest. Get-ManifestValue searches the Manifest root properties, then the properties PrivateData, then the PSData
        [Parameter(ParameterSetName = "Overwrite", Position = 1)]
        [string]$PropertyName = 'ModuleVersion',

        [switch]$Passthru
    )
    $ErrorActionPreference = "Stop"

    if (Test-Path $Manifest) {
        $ManifestContent = Get-Content $Manifest -Raw
    }
    else { 
        $ManifestContent = $Manifest
    }

    $Tokens = $Null; $ParseErrors = $Null
    $AST = [System.Management.Automation.Language.Parser]::ParseInput( $ManifestContent, $Manifest, [ref]$Tokens, [ref]$ParseErrors )
    $ManifestHash = $AST.Find( { $args[0] -is [System.Management.Automation.Language.HashtableAst] }, $true )
    $KeyValue = $ManifestHash.KeyValuePairs.Where{ $_.Item1.Value -eq $PropertyName }.Item2

    # Recursively search for PropertyName in the PrivateData and PrivateData.PSData
    if (!$KeyValue) {
        $global:devops_PrivateData = $ManifestHash.KeyValuePairs.Where{ $_.Item1.Value -eq 'PrivateData' }.Item2.PipelineElements.Expression
        $KeyValue = $PrivateData.KeyValuePairs.Where{ $_.Item1.Value -eq $PropertyName }.Item2
        if (!$KeyValue) {
            $global:devops_PSData = $PrivateData.KeyValuePairs.Where{ $_.Item1.Value -eq 'PSData' }.Item2.PipelineElements.Expression
            $KeyValue = $PSData.KeyValuePairs.Where{ $(Write-Verbose "'$($_.Item1.Value)' -eq '$PropertyName'"); $_.Item1.Value -eq $PropertyName }.Item2
            if (!$KeyValue) {
                Write-Error "Couldn't find '$PropertyName' to update in '$(Convert-Path $ManifestPath)'"
                return
            }
        }
    }

    if ($Passthru) { $KeyValue } else { $KeyValue.SafeGetValue() }
}

function Invoke-Menu() {    
    Param(
        [Parameter(Mandatory = $True)][String]$MenuTitle,
        [Parameter(Mandatory = $True)][array]$MenuOptions
    )
    if ($MenuOptions.Count -gt 0) {
        $MaxValue = $MenuOptions.Count - 1 
    }
    else {
        $MaxValue = 0
    }    
    $Selection = 0
    $EnterPressed = $False
    $Filter = ""
    $MasterMenuList = $MenuOptions
    
    if (!$VerbosePreference -eq "Continue") {
        Clear-Host    
    }

    While ($EnterPressed -eq $False) { 
        Write-Host "$MenuTitle"
        if ($Filter.Length -gt 0) {
            $MenuOptions = $MasterMenuList | Where-Object { $_ -match $Filter }
        }
        else {
            $MenuOptions = $MasterMenuList
        }
        For ($i = 0; $i -le $MaxValue; $i++) {            
            If ($i -eq $Selection) {
                Write-Host -BackgroundColor Cyan -ForegroundColor Black "[ $($MenuOptions[$i]) ]"
            }
            Else {
                Write-Host " $($MenuOptions[$i]) "
            }
        }
        $KeyInput = $host.ui.rawui.readkey("NoEcho,IncludeKeyDown").virtualkeycode
        Switch ($KeyInput) {
            13 {
                $EnterPressed = $True
                $Selection = $MasterMenuList.IndexOf($MenuOptions[$selection])
                Return $Selection
                if (!$VerbosePreference -eq "Continue") {
                    Clear-Host    
                }
                break
            }
            38 {
                If ($Selection -eq 0) {
                    $Selection = $MaxValue
                }
                Else {
                    $Selection -= 1
                }
                if (!$VerbosePreference -eq "Continue") {
                    Clear-Host    
                }
                break
            }
            40 {
                If ($Selection -eq $MaxValue) {
                    $Selection = 0
                }
                Else {
                    $Selection += 1
                }
                if (!$VerbosePreference -eq "Continue") {
                    Clear-Host    
                }
                break
            }
            8 {
                if ($Filter.Length -gt 0) {
                    $Filter = $Filter.Substring(0, $Filter.Length - 1)
                    if (!$VerbosePreference -eq "Continue") {
                        Clear-Host    
                    }    
                }   
                else {
                    $Filter = ""
                    if (!$VerbosePreference -eq "Continue") {
                        Clear-Host    
                    }
                }             
            }
            Default {
                $Filter += [char]$KeyInput
                if (!$VerbosePreference -eq "Continue") {
                    Clear-Host    
                }
            }
        }
    }
}

function Update-ProjectFile() {
    $projectFileMaster = Get-Content (Join-Path (Get-Module -Name microsoft.powerplatform.devops).Path -ChildPath ..\Private\emptyProject.json) | ConvertFrom-Json
    if ($projectFileMaster) {
        $properties = Get-Member -InputObject $global:devops_projectFile -MemberType Properties
        $properties | ForEach-Object { 
            $hasProperty = (Get-Member -InputObject $projectFileMaster.$($_.Name) -MemberType Properties -ErrorAction SilentlyContinue)
            if ($hasProperty) {
                $projectFileMaster.$($_.Name) = $global:devops_projectFile.$($_.Name)     
            }
            
        }
        $global:devops_projectFile = $projectFileMaster
        $global:devops_projectFile | ConvertTo-Json | Set-Content ("$global:devops_projectLocation\$global:devops_gitRepo.json")              
    }
}

function Update-ArtifactIgnore() {
    $artifactFileMaster = Get-Content (Join-Path (Get-Module -Name microsoft.powerplatform.devops).Path -ChildPath ..\FrameworkTemplate\file.artifactignore)
    if ($artifactFileMaster) {
        $projectArtifact = Get-Content ("$global:devops_projectLocation\.artifactignore") 
        $artifactFileMaster + $projectArtifact | Sort-Object | Get-Unique | Set-Content ("$global:devops_projectLocation\.artifactignore") 
    }
}

function Get-AzureAccounts($reason) {
    try {
        $sel = $null
        $AZAccounts = az account list | ConvertFrom-Json
        [array]$AZoptions = "Login to a New Account"
        $AZoptions += $AZAccounts | ForEach-Object { "$($_.name) $($_.user.name) ($($_.tenantId))" }

        do {
            $sel = Invoke-Menu -MenuTitle "---- Please Select your Subscription ($reason) ------" -MenuOptions $AZoptions          
        } until ($sel -ge 0)
        if ($sel -eq 0) {
            if ($global:devops_isDocker) {
                az login --use-device-code --allow-no-subscriptions    
            }
            else {
                az login --allow-no-subscriptions
            }
            
            #Get Azure Accounts / Subscriptions
            $AZAccounts = az account list | ConvertFrom-Json
            Get-AzureAccounts($reason)
        }
        else {
            $global:devops_selectedSubscription = $AZAccounts[$sel - 1].id
            $global:devops_selectedSubscriptionName = $AZAccounts[$sel - 1].name
            az account set --subscription $global:devops_selectedSubscription
        }   
    }
    catch {
        Write-Error $_
        pause
    }
}

function Get-AzureLogins($reason) {
    [array]$options = "Login to a New Account"
    $AZCreds = az account list --query '[].{Name:user.name, Type:user.type, ID:id, Subscription:name, TenantID:tenantId}' --output json | ConvertFrom-Json
    $UniqueCreds = $AZCreds | Sort-Object -Property * -Unique 
    $options += $UniqueCreds | ForEach-Object { "$($_.Name) ($($_.Type))($($_.Subscription))($($_.TenantID))" }

    do {
        $sel = Invoke-Menu -MenuTitle "---- Please Select your Subscription ($reason) ------" -MenuOptions $options   
    } until ($sel -ge 0)
    if ($sel -eq 0) {
        if ($global:devops_isDocker) {
            az login --use-device-code --allow-no-subscriptions    
        }
        else {
            az login --allow-no-subscriptions
        }
        Get-AzureLogins($reason)
    }
    else {
        $global:devops_DataverseEmail = $UniqueCreds[$sel - 1].Name
        $global:devops_DataverseADSubscription = $UniqueCreds[$sel - 1].id
        $global:devops_DataverseCredType = $UniqueCreds[$sel - 1].Type
        $global:devops_HasDataverseLogin = $true  
    }
}

function Get-DataverseLogin {
    Param(
        [bool] [Parameter(Mandatory = $false)] $overrideSP = $false,
        [bool] [Parameter(Mandatory = $false)] $requiresManagementApi = $true
    )
    if (!$global:devops_HasDataverseLogin -or ($global:devops_projectFile.OverrideSPCheck -eq "True")) {
        $message = "Connecting to Dataverse Environment"
        Write-Host $message
    
        Write-Host ""

        [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12    
        if (($global:devops_ClientID -and $overrideSP -eq $false) -and !($global:devops_projectFile.OverrideSPCheck -eq "True" -and $requiresManagementApi -eq $true)) {
            $global:devops_DataverseCredType = "servicePrincipal"
        }
        else {
            Get-AzureLogins("Power Platform")
        }   

        if ($global:devops_DataverseCredType -eq "user") {
            [String]$userString = $global:devops_DataverseEmail
            Authenticate-PowerPlatform($userString, "https://management.azure.com/")  
            Authenticate-PowerPlatform($userString, "https://service.powerapps.com/")  
        }
        else {
            Write-Host "Using Service Principal"
            if ($global:devops_projectFile.ClientSecretAKVName) {
                Write-Host "Retrieving Secret from KeyVault"
                az account set --subscription $global:devops_projectFile.selectedSubscription
                $kvretrieve = az keyvault secret show --name $global:devops_projectFile.ClientSecretAKVName --vault-name $global:devops_projectFile.AzureKeyVaultName | ConvertFrom-Json
                $global:clientSecret = $kvretrieve.value   
                Write-Host "Secret Retrieved"         
            }
            else {            
                Write-Host "Retrieving Secret from Local Store"
                $clientKeySS = ($global:devops_configFile.Projects[$global:devops_projectConfigID].ClientSecret) | ConvertTo-SecureString
                $global:clientSecret = (New-Object PSCredential "user", $clientKeySS).GetNetworkCredential().Password
                Write-Host "Secret Retrieved"   
            }
            
            try {
                Add-PowerAppsAccount -ApplicationId $global:devops_ClientID -ClientSecret $global:clientSecret -TenantID $global:devops_TenantID 
                $ManagementApps = Get-PowerAppManagementApps 
                $SPAdminPortalAccess = $ManagementApps.value | Where-Object applicationId -eq $global:devops_ClientID
                $SPAdminPortalAccess = $SPAdminPortalAccess.applicationId
                Write-Host "Service Principal Check Override : $($global:devops_projectFile.OverrideSPCheck)"
                if (!$SPAdminPortalAccess -and ($global:devops_projectFile.OverrideSPCheck -eq "False")) {
                    $EnableAccess = Read-Host -Prompt "Service Principal $global:devops_ClientID does not have permissions to administer Power Platform, would you like to [e]nable access or login as another [u]ser ? [e/u]"
                    if ($EnableAccess.ToLower() -eq 'e') {
                        Write-Host "Please Login to Power Platform with a User who has access to Administer Environments"
                        $global:currentSession.loggedIn = $false
                        $tempClientID = $global:devops_ClientID
                        $global:devops_ClientID = $null
                        Get-DataverseLogin
                        $global:devops_ClientID = $tempClientID
                        $global:devops_DataverseCredType = "servicePrincipal"
                        $SPAdminPortalAccess = New-PowerAppManagementApp -ApplicationId $global:devops_ClientID
                        if ($SPAdminPortalAccess.StatusCode -eq "403") {
                            Throw $SPAdminPortalAccess
                        }
                        $global:currentSession.loggedIn = $false
                        Add-PowerAppsAccount -ApplicationId $global:devops_ClientID -ClientSecret $global:clientSecret -TenantID $global:devops_TenantID    
                        $global:devops_HasDataverseLogin = $true
                    }
                    elseif ($EnableAccess.ToLower() -eq 'u') {
                        Get-DataverseLogin -overrideSP $true
                    }
                    else {
                        Throw $SPAdminPortalAccess
                    }
                }
                else {
                    $global:devops_HasDataverseLogin = $true
                }
            }
            catch {
                $global:devops_HasDataverseLogin = $false
                Write-Host $_
                pause
            }            
        }
        
    }
}

function Authenticate-PowerPlatform() {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True)]$args       
    )
    $theUser = $args[0]
    if ($args[1]) {
        $theAudience = $args[1]        
    }
    else {
        $theAudience = "https://management.azure.com/"
    }
    $authBaseUri =
    switch ($Endpoint) {
        "usgovhigh" { "https://login.microsoftonline.us" }
        "dod" { "https://login.microsoftonline.us" }
        default { "https://login.windows.net" }
    };

    [string]$Endpoint = "prod"

    [string]$Audience = $theAudience
    
    [string]$TenantID = $null
    [string]$CertificateThumbprint = $null
    [string]$ClientSecret = $null

    #$azTokenCache = Get-Content $env:USERPROFILE\.azure\accessTokens.json | ConvertFrom-Json
    #$authResult = $azTokenCache | Where-Object resource -EQ $Audience | Where-Object userId -eq $theUser
    #if (!$authResult) {
    $getToken = az account get-access-token --resource $Audience -s $global:devops_DataVerseADSubscription
    if (!$getToken) {
        Write-Host "Re-Authenticating $theUser"
        if ($global:devops_isDocker) {
            az login --use-device-code --allow-no-subscriptions    
        }
        else {
            az login --allow-no-subscriptions
        }
        Authenticate-PowerPlatform($theUser, $Audience)
    }
    #$azTokenCache = Get-Content $env:USERPROFILE\.azure\accessTokens.json | ConvertFrom-Json
    $authResult = $getToken | ConvertFrom-Json
    #}

    $claims = Get-JwtTokenClaimsForPA -JwtToken $authResult.AccessToken

    $global:currentSession = @{
        loggedIn              = $true;
        idToken               = $authResult.IdToken;
        upn                   = $claims.upn;
        tenantId              = $claims.tid;
        userId                = $claims.oid;
        applicationId         = $claims.appid;
        certificateThumbprint = $CertificateThumbprint;
        clientSecret          = $ClientSecret;
        secureClientSecret    = $SecureClientSecret;
        refreshToken          = $authResult.RefreshToken;
        expiresOn             = (Get-Date).AddHours(8);
        resourceTokens        = @{
            $Audience = @{
                accessToken = $authResult.AccessToken;
                expiresOn   = $authResult.ExpiresOn;
            }
        };
        selectedEnvironment   = "~default";
        authBaseUri           = $authBaseUri;
        flowEndpoint          = 
        switch ($Endpoint) {
            "prod" { "api.flow.microsoft.com" }
            "usgov" { "gov.api.flow.microsoft.us" }
            "usgovhigh" { "high.api.flow.microsoft.us" }
            "dod" { "api.flow.appsplatform.us" }
            "china" { "api.powerautomate.cn" }
            "preview" { "preview.api.flow.microsoft.com" }
            "tip1" { "tip1.api.flow.microsoft.com" }
            "tip2" { "tip2.api.flow.microsoft.com" }
            default { throw "Unsupported endpoint '$Endpoint'" }
        };
        powerAppsEndpoint     = 
        switch ($Endpoint) {
            "prod" { "api.powerapps.com" }
            "usgov" { "gov.api.powerapps.us" }
            "usgovhigh" { "high.api.powerapps.us" }
            "dod" { "api.apps.appsplatform.us" }
            "china" { "api.powerapps.cn" }
            "preview" { "preview.api.powerapps.com" }
            "tip1" { "tip1.api.powerapps.com" }
            "tip2" { "tip2.api.powerapps.com" }
            default { throw "Unsupported endpoint '$Endpoint'" }
        };            
        bapEndpoint           = 
        switch ($Endpoint) {
            "prod" { "api.bap.microsoft.com" }
            "usgov" { "gov.api.bap.microsoft.us" }
            "usgovhigh" { "high.api.bap.microsoft.us" }
            "dod" { "api.bap.appsplatform.us" }
            "china" { "api.bap.partner.microsoftonline.cn" }
            "preview" { "preview.api.bap.microsoft.com" }
            "tip1" { "tip1.api.bap.microsoft.com" }
            "tip2" { "tip2.api.bap.microsoft.com" }
            default { throw "Unsupported endpoint '$Endpoint'" }
        };      
        graphEndpoint         = 
        switch ($Endpoint) {
            "prod" { "graph.windows.net" }
            "usgov" { "graph.windows.net" }
            "usgovhigh" { "graph.windows.net" }
            "dod" { "graph.windows.net" }
            "china" { "graph.windows.net" }
            "preview" { "graph.windows.net" }
            "tip1" { "graph.windows.net" }
            "tip2" { "graph.windows.net" }
            default { throw "Unsupported endpoint '$Endpoint'" }
        };
        cdsOneEndpoint        = 
        switch ($Endpoint) {
            "prod" { "api.cds.microsoft.com" }
            "usgov" { "gov.api.cds.microsoft.us" }
            "usgovhigh" { "high.api.cds.microsoft.us" }
            "dod" { "dod.gov.api.cds.microsoft.us" }
            "preview" { "preview.api.cds.microsoft.com" }
            "tip1" { "tip1.api.cds.microsoft.com" }
            "tip2" { "tip2.api.cds.microsoft.com" }
            default { throw "Unsupported endpoint '$Endpoint'" }
        };
    };
}

function Get-JwtTokenClaimsForPA {
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]$JwtToken
    )

    $tokenSplit = $JwtToken.Split(".")
    $claimsSegment = $tokenSplit[1].Replace(" ", "+").Replace("-", "+");
    
    $mod = $claimsSegment.Length % 4
    if ($mod -gt 0) {
        $paddingCount = 4 - $mod;
        for ($i = 0; $i -lt $paddingCount; $i++) {
            $claimsSegment += "="
        }
    }

    $decodedClaimsSegment = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($claimsSegment))

    return ConvertFrom-Json $decodedClaimsSegment
}

function Format-Json {
    <#
  .SYNOPSIS
      Prettifies JSON output.
  .DESCRIPTION
      Reformats a JSON string so the output looks better than what ConvertTo-Json outputs.
  .PARAMETER Json
      Required: [string] The JSON text to prettify.
  .PARAMETER Minify
      Optional: Returns the json string compressed.
  .PARAMETER Indentation
      Optional: The number of spaces (1..1024) to use for indentation. Defaults to 4.
  .PARAMETER AsArray
      Optional: If set, the output will be in the form of a string array, otherwise a single string is output.
  .EXAMPLE
      $json | ConvertTo-Json | Format-Json -Indentation 2
  #>

    [CmdletBinding(DefaultParameterSetName = 'Prettify')]
    Param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string]$Json,

        [Parameter(ParameterSetName = 'Minify')]
        [switch]$Minify,

        [Parameter(ParameterSetName = 'Prettify')]
        [ValidateRange(1, 1024)]
        [int]$Indentation = 4,

        [Parameter(ParameterSetName = 'Prettify')]
        [switch]$AsArray
    )

    if ($PSCmdlet.ParameterSetName -eq 'Minify') {
        return ($Json | ConvertFrom-Json) | ConvertTo-Json -Depth 100 -Compress
    }

    # If the input JSON text has been created with ConvertTo-Json -Compress
    # then we first need to reconvert it without compression
    if ($Json -notmatch '\r?\n') {
        $Json = ($Json | ConvertFrom-Json) | ConvertTo-Json -Depth 100
    }

    $indent = 0
    $regexUnlessQuoted = '(?=([^"]*"[^"]*")*[^"]*$)'

    $result = $Json -split '\r?\n' |
    ForEach-Object {
        # If the line contains a ] or } character,
        # we need to decrement the indentation level unless it is inside quotes.
        if ($_ -match "[}\]]$regexUnlessQuoted") {
            $indent = [Math]::Max($indent - $Indentation, 0)
        }

        # Replace all colon-space combinations by ": " unless it is inside quotes.
        $line = (' ' * $indent) + ($_.TrimStart() -replace ":\s+$regexUnlessQuoted", ': ')

        # If the line contains a [ or { character,
        # we need to increment the indentation level unless it is inside quotes.
        if ($_ -match "[\{\[]$regexUnlessQuoted") {
            $indent += $Indentation
        }

        $line
    }

    if ($AsArray) { return $result }
    return $result -Join [Environment]::NewLine
}

function Invoke-OpenSolution {
    . "$global:devops_projectLocation\$global:devops_gitRepo.sln"
}

function Invoke-OpenSolutionInVSCode {
    Start-Process code -ArgumentList  "$global:devops_projectLocation"
}

function Get-DataverseConnection {
    Param(
        [string] [Parameter(Mandatory = $true)] $DeployServerUrl
    )
    try {
        #Install-XrmModule
        Get-DataverseLogin -requiresManagementApi $false
        if ($global:devops_DataverseCredType -eq "user") {
            try {
                if ($global:devops_isDocker) {
                    Write-Host "Warning: It is recommended you configure and use a Service Principal instead of Username and Password (to prevent MFA related issues)" -ForegroundColor Yellow
                    Write-Host ""
                    $SecurePassword = Read-Host "Enter Password for $global:devops_DataverseEmail" -AsSecureString
                    $global:Password = (New-Object PSCredential "user", $SecurePassword).GetNetworkCredential().Password
                    [string]$CrmConnectionString = "AuthType=OAuth;Username=$global:devops_DataverseEmail;Password=$global:Password;Url=$DeployServerUrl;AppId=51f81489-12ee-4a9e-aaae-a2591f45987d;RedirectUri=app://58145B91-0C36-4500-8554-080854F2AC97;TokenCacheStorePath=$env:APPDATA\Microsoft.PowerPlatform.DevOps\dataverse_cache.data;LoginPrompt=Never"
                }
                else {
                    [string]$CrmConnectionString = "AuthType=OAuth;Username=$global:devops_DataverseEmail;Password=$global:Password;Url=$DeployServerUrl;AppId=51f81489-12ee-4a9e-aaae-a2591f45987d;RedirectUri=app://58145B91-0C36-4500-8554-080854F2AC97;TokenCacheStorePath=$env:APPDATA\Microsoft.PowerPlatform.DevOps\dataverse_cache.data;LoginPrompt=Auto"
                }
                $conn = Connect-CrmOnline -ConnectionString $CrmConnectionString -ConnectionTimeoutInSeconds 600
                return $conn
            }
            catch {
                Write-Host $_
                pause
            }
        }
        elseif ($global:devops_DataverseCredType -eq "servicePrincipal") {
            try {
                [string]$CrmConnectionString = "AuthType=ClientSecret;Url=$DeployServerUrl;ClientId=$global:devops_ClientID;ClientSecret=$global:clientSecret"
                $conn = Connect-CrmOnline -ConnectionString $CrmConnectionString -ConnectionTimeoutInSeconds 600  
                if (!$conn.IsReady -and $conn.LastCrmError.Contains("Invalid Login")) {
                    $AddSPAsSysAdmin = Read-Host -Prompt "The Service Principal $global:devops_ClientID does not have access to $DeployServerUrl, would you like to add access now ? [y/n]"
                    if ($AddSPAsSysAdmin.ToLower() -eq "y") {
                        try {
                            $global:currentSession.loggedIn = $false
                            Add-D365ApplicationUser -d365ResourceName $DeployServerUrl -servicePrincipal $global:devops_ClientID -roleNames "System Administrator"                            
                        }
                        catch {
                            Write-Host $_
                            pause
                        }                        
                        $conn = Connect-CrmOnline -ConnectionString $CrmConnectionString -ConnectionTimeoutInSeconds 600  
                    }
                    
                }
                return $conn                      
            }
            catch {
                Write-Host $_
                pause
            }
        }
        else {
            try {
                $conn = Connect-CrmOnline -ServerUrl $DeployServerUrl -ConnectionTimeoutInSeconds 600 -ForceOAuth   
                return $conn                    
            }
            catch {
                Write-Host $_
                pause
            }
        
        }

    
    }
    catch {
        Write-Host $_
        pause
    }
}

function Get-ConfigJSON($StartPath) {
    $global:devops_BaseConfig = Join-Path $StartPath -ChildPath "$SelectedSolution\Scripts\config.json"

    # Load and parse the JSON configuration file
    try {
        $global:devops_Config = Get-Content -Path $global:devops_BaseConfig -Raw -ErrorAction:SilentlyContinue -WarningAction:SilentlyContinue | ConvertFrom-Json -ErrorAction:SilentlyContinue -WarningAction:SilentlyContinue
    }
    catch {
        Write-Error "The Base configuration file is missing!" -Stop
    }

    # Check the configuration
    if (!($global:devops_Config)) {
        Write-Error "The Base configuration file is missing!" -Stop
    }

    $global:devops_ServerUrl = ($global:devops_Config.target.ServerUrl)
    $global:devops_SolutionName = ($global:devops_Config.target.SolutionName)

    Write-Host $global:devops_ServerUrl
    Write-Host $global:devops_SolutionName
}

#
# _Helpers.ps1
#

function Get-DeployEnvironments {
    try {
        $Environments = Get-Content -Path $global:devops_projectLocation\Environments.json | ConvertFrom-Json
        [array]$options = "[Go Back]"
        $options += $Environments | ForEach-Object { "$($_.EnvironmentName)" }

        do {
            $sel = Invoke-Menu -MenuTitle "---- Select Environment to Deploy To ------" -MenuOptions $options          
        } until ($sel -ge 0)
        if ($sel -eq 0) {
            return
        }
        else {

            #Check if Environment has Approver
            Write-Host "Checking if Environment $($Environments[$sel -1].EnvironmentName) requires approval"
            $AzureDevOpsAuthenicationHeader = @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$($env:AZURE_DEVOPS_EXT_PAT)")) }
            $UriOrganization = "https://dev.azure.com/$($global:devops_projectFile.OrgName)/"
            $uriEnvironments = $UriOrganization + "$($global:devops_projectFile.Project)/_apis/distributedtask/environments?api-version=6.1-preview.1"
            try {
                $EnvironmentsResult = Invoke-RestMethod -Uri $uriEnvironments -Method get -Headers $AzureDevOpsAuthenicationHeader
                $theEnvironment = $EnvironmentsResult.value | Where-Object name -eq $($Environments[$sel - 1].EnvironmentName)
                $body = @(
                    @{
                        type = "queue"
                        id   = "1"
                        name = "Default"
                    },
                    @{
                        type = "environment"
                        id   = "$($theEnvironment.id)"
                        name = "$($theEnvironment.name)"
                    }
                ) | ConvertTo-Json
                $uriEnvironmentChecks = $UriOrganization + "$($global:devops_projectFile.Project)/_apis/pipelines/checks/queryconfigurations?`$expand=settings&api-version=6.1-preview.1"
                $EnvironmentChecksResult = Invoke-RestMethod -Uri $uriEnvironmentChecks -Method Post -Body $body -Headers $AzureDevOpsAuthenicationHeader -ContentType application/json
                    
            }
            catch {
                #Do Nothing
            }

            if (!$EnvironmentChecksResult.count -gt 0) {
                if ($global:devops_DataverseCredType -eq "servicePrincipal") {
                    Write-Host "Using Service Principal"
                    Start-DeploySolution -DeployServerUrl $Environments[$sel - 1].EnvironmentURL -UserName $global:devops_ClientID -Password $global:clientSecret -PipelinePath $global:devops_projectLocation -UseClientSecret $true -EnvironmentName $Environments[$sel - 1].EnvironmentName -RunLocally $true   
                }
                else {
                    Write-Host "Using User Credentials"
                    Start-DeploySolution -DeployServerUrl $Environments[$sel - 1].EnvironmentURL -UserName $global:devops_DataverseEmail -Password "" -PipelinePath $global:devops_projectLocation -UseClientSecret $false -EnvironmentName $Environments[$sel - 1].EnvironmentName -RunLocally $true
                }
            }
            else {
                Write-Host "Environment $($Environments[$sel -1].EnvironmentName) requires approval, please run Deployment via the Pipeline"
            }
            pause
        }   
    }
    catch {
        Write-Error $_
        pause
    }
}

function Get-AccessToken {
    #Get Access Token
    Write-Host "Getting Updated Access Token"
    $azureDevopsResourceId = "499b84ac-1321-427f-aa17-267ca6975798"
    $token = az account get-access-token --resource $azureDevopsResourceId | ConvertFrom-Json
    $authValue = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(":" + $token.accessToken))    
    $env:AZURE_DEVOPS_EXT_PAT = $token.accessToken
   
    $REPO_URL = git remote get-url origin
    Write-Host "git URL is $REPO_URL"
   
    git config http.$REPO_URL.extraHeader "Authorization:Basic $authValue"
    Write-Host "Updated Auth Token"
}


function Get-PublishedModuleVersion($Name) {
    # access the main module page, and add a random number to trick proxies
    $url = "https://www.powershellgallery.com/packages/$Name/?dummy=$(Get-Random)"
    $request = [System.Net.WebRequest]::Create($url)
    # do not allow to redirect. The result is a "MovedPermanently"
    $request.AllowAutoRedirect = $false
    try {
        # send the request
        $response = $request.GetResponse()
        # get back the URL of the true destination page, and split off the version
        $response.GetResponseHeader("Location").Split("/")[-1] -as [Version]
        # make sure to clean up
        $response.Close()
        $response.Dispose()
    }
    catch {
        Write-Warning $_.Exception.Message
    }
}