commands.ps1


<#
    .SYNOPSIS
        Retrieves or creates a browser context using Microsoft Playwright.
         
    .DESCRIPTION
        The Get-BrowserContext function retrieves the current browser context or creates a new one using Microsoft Playwright.
        It supports creating a new session by closing and disposing of the existing browser context. The function also
        uses a storage state file (cookies) if available to maintain session persistence.
         
    .PARAMETER NewSession
        A switch parameter. If specified, a new browser session is created by closing and disposing of the existing browser context.
         
    .OUTPUTS
        [Microsoft.Playwright.IBrowserContext]
        Returns an instance of the browser context.
         
    .EXAMPLE
        PS C:\> Get-BrowserContext
         
        Retrieves the current browser context or creates a new one if none exists.
         
    .EXAMPLE
        PS C:\> Get-BrowserContext -NewSession
         
        Creates a new browser session by closing and disposing of the existing browser context.
         
    .NOTES
        - This function depends on Microsoft Playwright for browser automation.
        - Ensure that Microsoft Playwright is properly installed and configured in your environment.
        - The function uses the global variables $Script:Playwright, $Script:BrowserContext, and $Script:CookiesPath.
        - Author: Oleksandr Nikolaiev (@onikolaiev)
         
    .LINK
        https://playwright.dev
#>

function Get-BrowserContext {    [CmdletBinding()]
    [OutputType([Microsoft.Playwright.IBrowserContext])]
    param(
        [Parameter(Mandatory=$false)]
        [switch]$NewSession    )
    begin {
        Invoke-TimeSignal -Start
        if(-not $Script:Playwright) {
            $Script:Playwright = [Microsoft.Playwright.Playwright]::CreateAsync().GetAwaiter().GetResult()
        }
        
        $_browserContext = $Script:BrowserContext
        if($NewSession)
        {
            if($_browserContext) {
                $_browserContext.CloseAsync().GetAwaiter().GetResult() | Out-Null
                $_browserContext.DisposeAsync().GetAwaiter().GetResult() | Out-Null
            }
            
            $_browserContext = $null
        }
        $_headless =  Get-PSFConfigValue -FullName "fscps.lcs.settings.all.headless"
    }
    process {
        if (-not $_browserContext) {                        
            $browser = $Script:Playwright.Chromium.LaunchAsync([Microsoft.Playwright.BrowserTypeLaunchOptions]@{ Headless = $_headless }).Result
    
            if(Test-Path $Script:CookiesPath) {
                $_browserContext = $browser.NewContextAsync(@{
                    StorageStatePath = $Script:CookiesPath
                }).GetAwaiter().GetResult()
            }
            else {
                $_browserContext = $browser.NewContextAsync().GetAwaiter().GetResult()
            }
            
        }
        return $_browserContext
    }
    end {
        Invoke-TimeSignal -End
        $Script:BrowserContext = $_browserContext
    }    
}


<#
    .SYNOPSIS
        Creates a new browser page using Microsoft Playwright.
         
    .DESCRIPTION
        The Get-NewPage function creates a new browser page within the current browser context using Microsoft Playwright.
        If the browser context is not initialized or the browser is disconnected, it creates a new browser context session.
        It also ensures that any previously opened page is closed before creating a new one.
         
    .PARAMETER None
        This function does not take any parameters.
         
    .OUTPUTS
        [Microsoft.Playwright.IPage]
        Returns a new browser page object.
         
    .EXAMPLE
        PS C:\> Get-NewPage
         
        Creates a new browser page in the current browser context.
         
    .NOTES
        - This function depends on the Get-BrowserContext function to manage browser contexts.
        - Ensure that Microsoft Playwright is properly installed and configured in your environment.
        - The function uses global variables $Script:BrowserContext and $Script:CurrentPage to manage browser state.
         
    .LINK
        https://playwright.dev
#>

function Get-NewPage {

    [CmdletBinding()]
    [OutputType([Microsoft.Playwright.IPage])]
    param(
    )
    begin {
        Invoke-TimeSignal -Start

        if($Script:BrowserContext)
        {
            # Check if the browser context is null
            if(-not $Script:BrowserContext.Browser.IsConnected) {
                Get-BrowserContext -NewSession | Out-Null
            }
            if($Script:CurrentPage) {
                $Script:CurrentPage.CloseAsync().GetAwaiter().GetResult() | Out-Null
                $Script:CurrentPage = $null
            }
        }     
        else {
            Get-BrowserContext -NewSession | Out-Null
        }      
    }
    process {
        # Check if the browser context is null
        $_page = $Script:BrowserContext.NewPageAsync().Result
        return $_page
    }
    end {
        Invoke-TimeSignal -End
        $Script:CurrentPage = $_page
    }    
}


<#
    .SYNOPSIS
        Automates the login process to the LCS (Lifecycle Services) portal using Microsoft Playwright.
         
    .DESCRIPTION
        The Invoke-Login function automates the login process to the LCS (Lifecycle Services) portal.
        It retrieves the username and password from the PSFramework configuration, navigates to the login page,
        fills in the credentials, and saves the session cookies for future use. If the login is successful,
        it updates the browser context with a new session.
         
    .PARAMETER None
        This function does not take any parameters.
         
    .OUTPUTS
        None
         
    .EXAMPLE
        PS C:\> Invoke-Login
         
        Logs into the LCS portal using the credentials stored in the PSFramework configuration.
         
    .NOTES
        - This function depends on the Get-NewPage and Get-BrowserContext functions for browser management.
        - Ensure that Microsoft Playwright is properly installed and configured in your environment.
        - The username and password must be stored in the PSFramework configuration under the keys:
        "fscps.lcs.settings.all.lcsUsername" and "fscps.lcs.settings.all.lcsPassword".
        - The function uses the global variable $Script:CookiesPath to store session cookies.
        - Author: Oleksandr Nikolaiev (@onikolaiev)
         
    .LINK
        https://playwright.dev
#>

function Invoke-Login {
    [CmdletBinding()]
    param(
    )
    begin {
        Invoke-TimeSignal -Start
        $_page = Get-NewPage
        $lcsUsername = Get-PSFConfigValue -FullName "fscps.lcs.settings.all.lcsUsername"
        $lcsPassword = Get-PSFConfigValue -FullName "fscps.lcs.settings.all.lcsPassword"
    }
    process {
        # Navigate to the login page
        $_page.GotoAsync("https://lcs.dynamics.com/Logon/ADLogon").Wait()
        if($_page.Url -ne "https://lcs.dynamics.com/V2") {
        # Fill in the username/email field
            $_page.FillAsync("input[type='email']", "$lcsUsername").Wait()            
            # Click the "Next" button
            $_page.ClickAsync("input[type='submit']").Wait()            
            # Wait for the password field to appear
            $_page.WaitForSelectorAsync("input[type='password']").Wait()            
            # Fill in the password field
            $_page.FillAsync("input[type='password']", "$lcsPassword").Wait()            
            # Click the "Sign in" button
            $_page.ClickAsync("input[type='submit']").Wait()            
            $_page.ClickAsync("input[type='submit']").Wait()            
            # Wait for the login process to complete (adjust selector as needed)
            #$page.WaitForNavigationAsync().Wait()
            $_page.Context.StorageStateAsync(@{
                Path = $Script:CookiesPath
            }).Wait()
            #$Script:BrowserContext = Get-BrowserContext -NewSession
        }
           
    }
    end {
        Invoke-TimeSignal -End
    }    
}


<#
    .SYNOPSIS
        Invoke a process
         
    .DESCRIPTION
        Invoke a process and pass the needed parameters to it
         
    .PARAMETER Path
        Path to the program / executable that you want to start
         
    .PARAMETER Params
        Array of string parameters that you want to pass to the executable
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-Process -Path "C:\AOSService\PackagesLocalDirectory\Bin\xppc.exe" -Params "-metadata=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-modelmodule=`"ApplicationSuite`"", "-output=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-referencefolder=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-log=`"C:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.$Module.xppc.log`"", "-xmlLog=`"C:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.ApplicationSuite.xppc.xml`"", "-verbose"
         
        This will invoke the "C:\AOSService\PackagesLocalDirectory\Bin\xppc.exe" executable.
        All parameters will be passed to it.
        The standard output will be redirected to a local variable.
        The error output will be redirected to a local variable.
        The standard output will be written to the verbose stream before exiting.
         
        If an error should occur, both the standard output and error output will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Invoke-Process -ShowOriginalProgress -Path "C:\AOSService\PackagesLocalDirectory\Bin\xppc.exe" -Params "-metadata=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-modelmodule=`"ApplicationSuite`"", "-output=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-referencefolder=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-log=`"C:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.$Module.xppc.log`"", "-xmlLog=`"C:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.ApplicationSuite.xppc.xml`"", "-verbose"
         
        This will invoke the "C:\AOSService\PackagesLocalDirectory\Bin\xppc.exe" executable.
        All parameters will be passed to it.
        The standard output will be outputted directly to the console / host.
        The error output will be outputted directly to the console / host.
         
    .NOTES
        This is refactored function from d365fo.tools
         
        Original Author: Mötz Jensen (@Splaxi)
        Author: Oleksandr Nikolaiev (@onikolaiev)
#>


function Invoke-Process {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true)]
        [Alias('Executable')]
        [string] $Path,

        [Parameter(Mandatory = $true)]
        [string[]] $Params,
        [Parameter(Mandatory = $false)]
        [string] $LogPath,

        [switch] $ShowOriginalProgress,
        
        [switch] $OutputCommandOnly,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }
    
    if (Test-PSFFunctionInterrupt) { return }

    $tool = Split-Path -Path $Path -Leaf

    $pinfo = New-Object System.Diagnostics.ProcessStartInfo
    $pinfo.FileName = "$Path"
    $pinfo.WorkingDirectory = Split-Path -Path $Path -Parent

    if (-not $ShowOriginalProgress) {
        Write-PSFMessage -Level Verbose "Output and Error streams will be redirected (silence mode)"

        $pinfo.RedirectStandardError = $true
        $pinfo.RedirectStandardOutput = $true
    }

    $pinfo.UseShellExecute = $false
    $pinfo.Arguments = "$($Params -join " ")"
    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $pinfo

    Write-PSFMessage -Level Verbose "Starting the $tool" -Target "$($params -join " ")"

    if ($OutputCommandOnly) {
        Write-PSFMessage -Level Host "$Path $($pinfo.Arguments)"
        return
    }
    
    $p.Start() | Out-Null
    
    if (-not $ShowOriginalProgress) {
        $outTask = $p.StandardOutput.ReadToEndAsync();
        $errTask = $p.StandardError.ReadToEndAsync();
    }

    Write-PSFMessage -Level Verbose "Waiting for the $tool to complete"
    $p.WaitForExit()

    if (-not $ShowOriginalProgress) {
        $stdout = $outTask.Result
        $stderr = $errTask.Result
    }

    if ($p.ExitCode -ne 0 -and (-not $ShowOriginalProgress)) {
        Write-PSFMessage -Level Host "Exit code from $tool indicated an error happened. Will output both standard stream and error stream."
        Write-PSFMessage -Level Host "Standard output was: \r\n $stdout"
        Write-PSFMessage -Level Host "Error output was: \r\n $stderr"

        $messageString = "Stopping because an Exit Code from $tool wasn't 0 (zero) like expected."
        Stop-PSFFunction -Message "Stopping because of Exit Code." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -StepsUpward 1
        return
    }
    else {
        Write-PSFMessage -Level Verbose "Standard output was: \r\n $stdout"
    }

    if ((-not $ShowOriginalProgress) -and (-not ([string]::IsNullOrEmpty($LogPath)))) {
        if (-not (Test-PathExists -Path $LogPath -Type Container -Create)) { return }

        $stdOutputPath = Join-Path -Path $LogPath -ChildPath "$tool`_StdOutput.log"
        $errOutputPath = Join-Path -Path $LogPath -ChildPath "$tool`_ErrOutput.log"

        $stdout | Out-File -FilePath $stdOutputPath -Encoding utf8 -Force
        $stderr | Out-File -FilePath $errOutputPath -Encoding utf8 -Force
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Measures the execution time of a command or function by marking its start and end points.
         
    .DESCRIPTION
        The Invoke-TimeSignal function is used to measure the time spent executing a specific command or function.
        It works by marking the start and end points of the execution and calculating the time difference between them.
        The function uses a global hashtable `$Script:TimeSignals` to store the start time for each command or function.
         
        When the `-Start` parameter is used, the function records the current time for the specified command or function.
        If the command is already being tracked, the start time is updated. When the `-End` parameter is used, the function
        calculates the elapsed time since the start and logs the result. If the command was not started, a message is logged.
         
    .PARAMETER Start
        Marks the start of the time measurement for the current command or function.
         
    .PARAMETER End
        Marks the end of the time measurement for the current command or function and calculates the elapsed time.
         
    .EXAMPLE
        Invoke-TimeSignal -Start
         
        This example marks the start of the time measurement for the current command or function.
         
    .EXAMPLE
        Invoke-TimeSignal -End
         
        This example marks the end of the time measurement for the current command or function and logs the elapsed time.
         
    .NOTES
        This function uses the PSFramework module for logging and message handling. Ensure the PSFramework module
        is installed and imported before using this function.
         
        The function relies on a global hashtable `$Script:TimeSignals` to track the start times of commands or functions.
         
        Author: Oleksandr Nikolaiev (@onikolaiev)
#>

function Invoke-TimeSignal {
    [CmdletBinding(DefaultParameterSetName = 'Start')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Start', Position = 1 )]
        [switch] $Start,
        
        [Parameter(Mandatory = $True, ParameterSetName = 'End', Position = 2 )]
        [switch] $End
    )

    $Time = (Get-Date)

    $Command = (Get-PSCallStack)[1].Command

    if ($Start) {
        if ($Script:TimeSignals.ContainsKey($Command)) {
            Write-PSFMessage -Level Verbose -Message "The command '$Command' was already taking part in time measurement. The entry has been update with current date and time."
            $Script:TimeSignals[$Command] = $Time
        }
        else {
            $Script:TimeSignals.Add($Command, $Time)
        }
    }
    else {
        if ($Script:TimeSignals.ContainsKey($Command)) {
            $TimeSpan = New-TimeSpan -End $Time -Start (($Script:TimeSignals)[$Command])

            Write-PSFMessage -Level Verbose -Message "Total time spent inside the function was $TimeSpan" -Target $TimeSpan -FunctionName $Command -Tag "TimeSignal"
            $null = $Script:TimeSignals.Remove($Command)
        }
        else {
            Write-PSFMessage -Level Verbose -Message "The command '$Command' was never started to take part in time measurement."
        }
    }
}


<#
    .SYNOPSIS
        Modify the PATH environment variable.
    .DESCRIPTION
        Set-PathVariable allows you to add or remove paths to your PATH variable at the specified scope with logic that prevents duplicates.
    .PARAMETER AddPath
        A path that you wish to add. Can be specified with or without a trailing slash.
    .PARAMETER RemovePath
        A path that you wish to remove. Can be specified with or without a trailing slash.
    .PARAMETER Scope
        The scope of the variable to edit. Either Process, User, or Machine.
         
        If you specify Machine, you must be running as administrator.
    .EXAMPLE
        Set-PathVariable -AddPath C:\tmp\bin -RemovePath C:\path\java
         
        This will add the C:\tmp\bin path and remove the C:\path\java path. The Scope will be set to Process, which is the default.
    .INPUTS
         
    .OUTPUTS
         
    .NOTES
        Author: ThePoShWolf
    .LINK
         
        Author: Oleksandr Nikolaiev (@onikolaiev)
#>

Function Set-PathVariable {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    param (
        [string]$AddPath,
        [string]$RemovePath,
        [ValidateSet('Process', 'User', 'Machine')]
        [string]$Scope = 'Process'
    )
    $regexPaths = @()
    if ($PSBoundParameters.Keys -contains 'AddPath') {
        $regexPaths += [regex]::Escape($AddPath)
    }

    if ($PSBoundParameters.Keys -contains 'RemovePath') {
        $regexPaths += [regex]::Escape($RemovePath)
    }
    
    $arrPath = [System.Environment]::GetEnvironmentVariable('PATH', $Scope) -split ';'
    foreach ($path in $regexPaths) {
        $arrPath = $arrPath | Where-Object { $_ -notMatch "^$path\\?" }
    }
    $value = ($arrPath + $addPath) -join ';'
    [System.Environment]::SetEnvironmentVariable('PATH', $value, $Scope)
}


<#
    .SYNOPSIS
        Test multiple paths
         
    .DESCRIPTION
        Easy way to test multiple paths for public functions and have the same error handling
         
    .PARAMETER Path
        Array of paths you want to test
         
        They have to be the same type, either file/leaf or folder/container
         
    .PARAMETER Type
        Type of path you want to test
         
        Either 'Leaf' or 'Container'
         
    .PARAMETER Create
        Instruct the cmdlet to create the directory if it doesn't exist
         
    .PARAMETER ShouldNotExist
        Instruct the cmdlet to return true if the file doesn't exists
         
    .EXAMPLE
        PS C:\> Test-PathExists "c:\temp","c:\temp\dir" -Type Container
         
        This will test if the mentioned paths (folders) exists and the current context has enough permission.
         
    .NOTES
        This is refactored function from d365fo.tools
         
        Original Author: Mötz Jensen (@Splaxi)
        Author: Oleksandr Nikolaiev (@onikolaiev)
         
#>

function Test-PathExists {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $True)]
        [AllowEmptyString()]
        [string[]] $Path,

        [ValidateSet('Leaf', 'Container')]
        [Parameter(Mandatory = $True)]
        [string] $Type,

        [switch] $Create,

        [switch] $ShouldNotExist
    )
    
    $res = $false

    $arrList = New-Object -TypeName "System.Collections.ArrayList"
         
    foreach ($item in $Path) {

        if ([string]::IsNullOrEmpty($item)) {
            Stop-PSFFunction -Message "Stopping because path was either null or empty string." -StepsUpward 1
            return
        }

        Write-PSFMessage -Level Debug -Message "Testing the path: $item" -Target $item
        $temp = Test-Path -Path $item -Type $Type

        if ((-not $temp) -and ($Create) -and ($Type -eq "Container")) {
            Write-PSFMessage -Level Debug -Message "Creating the path: $item" -Target $item
            $null = New-Item -Path $item -ItemType Directory -Force -ErrorAction Stop
            $temp = $true
        }
        elseif ($ShouldNotExist) {
            Write-PSFMessage -Level Debug -Message "The should NOT exists: $item" -Target $item
        }
        elseif ((-not $temp) -and ($WarningPreference -ne [System.Management.Automation.ActionPreference]::SilentlyContinue)) {
            Write-PSFMessage -Level Host -Message "The <c='em'>$item</c> path wasn't found. Please ensure the path <c='em'>exists</c> and you have enough <c='em'>permission</c> to access the path."
        }
        
        $null = $arrList.Add($temp)
    }

    if ($arrList.Contains($false) -and (-not $ShouldNotExist)) {
        # The $ErrorActionPreference variable determines the behavior we are after, but the "Stop-PSFFunction -WarningAction" is where we need to put in the value.
        Stop-PSFFunction -Message "Stopping because of missing paths." -StepsUpward 1 -WarningAction $ErrorActionPreference
        
    }
    elseif ($arrList.Contains($true) -and $ShouldNotExist) {
        # The $ErrorActionPreference variable determines the behavior we are after, but the "Stop-PSFFunction -WarningAction" is where we need to put in the value.
        Stop-PSFFunction -Message "Stopping because file exists." -StepsUpward 1 -WarningAction $ErrorActionPreference
    }
    else {
        $res = $true
    }

    $res
}


<#
    .SYNOPSIS
        Test if a given registry key exists or not
         
    .DESCRIPTION
        Test if a given registry key exists in the path specified
         
    .PARAMETER Path
        Path to the registry hive and sub directories you want to work against
         
    .PARAMETER Name
        Name of the registry key that you want to test for
         
    .EXAMPLE
        PS C:\> Test-RegistryValue -Path "HKLM:\SOFTWARE\Microsoft\Dynamics\Deployment\" -Name "InstallationInfoDirectory"
         
        This will query the LocalMachine hive and the sub directories "HKLM:\SOFTWARE\Microsoft\Dynamics\Deployment\" for a registry key with the name of "InstallationInfoDirectory".
         
    .NOTES
        This is refactored function from d365fo.tools
         
        Original Author: Mötz Jensen (@Splaxi)
        Author: Oleksandr Nikolaiev (@onikolaiev)
         
#>

Function Test-RegistryValue {
    [OutputType('System.Boolean')]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,
        
        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    if (Test-Path -Path $Path -PathType Any) {
        $null -ne (Get-ItemProperty $Path).$Name
    }
    else {
        $false
    }
}


<#
    .SYNOPSIS
        Retrieves details of a specific project from Microsoft Dynamics Lifecycle Services (LCS) using its project ID.
         
    .DESCRIPTION
        The Get-FSCPSLCSProject function uses Microsoft Playwright to interact with LCS and retrieves details of a specific project by its project ID.
        It constructs the API request URL dynamically, includes the required headers (such as __RequestVerificationToken), and sends a GET request to the LCS API endpoint.
        The function handles authentication, session management, and API requests to fetch the required project data.
         
    .PARAMETER ProjectId
        Specifies the ID of the project to retrieve. This parameter is mandatory.
         
    .EXAMPLE
        PS C:\> Get-FSCPSLCSProject -ProjectId "12345"
         
        Retrieves details of the project with ID "12345" from Microsoft Dynamics Lifecycle Services.
         
    .NOTES
        - This function uses Microsoft Playwright for browser automation.
        - Ensure that the Playwright environment is properly initialized before calling this function.
        - Author: [Your Name]
#>

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

    begin {
        Invoke-TimeSignal -Start
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        Invoke-Login
        $_lcsUri = Get-PSFConfigValue -FullName "fscps.lcs.settings.all.lcsUrl"
        $_apiUri = "RainierProject/GetProject/$ProjectId"+"?_=$(Get-Date -UFormat %s)"
        $_requestUri = "$($_lcsUri.TrimEnd('/'))/$($_apiUri.TrimStart('/'))"
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }

        try {            
            # Create the request options
            $requestOptions = Get-PWRequestOptions

            # Send the GET request
            $response = $Script:CurrentPage.APIRequest.GetAsync($_requestUri, $requestOptions).GetAwaiter().GetResult()

            # Ensure the request was successful
            if ($response.Status -eq [System.Net.HttpStatusCode]::OK) {
                $result = $response.TextAsync().GetAwaiter().GetResult() | ConvertFrom-Json
                return $result.Data | Select-PSFObject * 
            }
            else {
                throw "Failed to retrieve project. Status code: $($response.Status)"
            }
        } catch {
            Write-PSFMessage -Level Error -Message "An error occurred while retrieving the project: $_"
        }
    }

    end {
        Cleanup-Session
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Retrieves a list of all projects from D365 LCS with optional paging parameters.
         
    .DESCRIPTION
        The Get-FSCPSLCSProjectList function uses Microsoft Playwright to automate the login process to LCS (Lifecycle Services)
        and retrieves a list of all projects using a POST request. It handles authentication, session management,
        and API requests to fetch the required data.
         
    .PARAMETER StartPosition
        The starting position for paging. Defaults to 0.
         
    .PARAMETER ItemsRequested
        The number of items to retrieve. Defaults to 20.
         
    .EXAMPLE
        PS C:\> Get-FSCPSLCSProjectList -StartPosition 0 -ItemsRequested 20
         
        Retrieves the first 20 projects from D365 LCS starting at position 0.
         
    .NOTES
        - This function uses Microsoft Playwright for browser automation.
        - Author: Oleksandr Nikolaiev (@onikolaiev)
#>

function Get-FSCPSLCSProjectList {
    [CmdletBinding()]
    [OutputType()]
    param(
        [Parameter(Mandatory=$false)]
        [int]$StartPosition = 0,

        [Parameter(Mandatory=$false)]
        [int]$ItemsRequested = 50
    )
    begin {
        Invoke-TimeSignal -Start
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        Invoke-Login
        $_lcsUri = Get-PSFConfigValue -FullName "fscps.lcs.settings.all.lcsUrl"
        $_apiUri = "RainierProject/AllProjectsList"
        $_requestUri = "$($_lcsUri.TrimEnd('/'))/$($_apiUri.TrimStart('/'))"
    }
    PROCESS {
        if (Test-PSFFunctionInterrupt) { return }        
        
        try {            

            $body = @{
                DynamicPaging = @{
                    StartPosition = $StartPosition
                    ItemsRequested = $ItemsRequested
                }
                Filtering = $null
            }
            $jsonBody = $body | ConvertTo-Json -Depth 10

            # Create the request options
            $requestOptions = Get-PWRequestOptions
            $requestOptions.DataObject = $jsonBody
    
            $response = $Script:CurrentPage.APIRequest.PostAsync($_requestUri, $requestOptions).GetAwaiter().GetResult()
    
            # Ensure the request was successful
            if ($response.Status -eq [System.Net.HttpStatusCode]::OK) {
                $result = $response.TextAsync().GetAwaiter().GetResult() | ConvertFrom-Json
                return $result.Data | Select-PSFObject * 
            }
            else {
                throw "Failed to retrieve project. Status code: $($response.Status)"
            }
        }
        catch {            
            Write-PSFMessage -Level Error -Message "An error occurred while retrieving all projects: $_"
        }
       
    }
    END {
        Cleanup-Session
        Invoke-TimeSignal -End
    }    
}


<#
    .SYNOPSIS
        Get the FSCPS configuration details
         
    .DESCRIPTION
        Get the FSCPS configuration details from the configuration store
         
        All settings retrieved from this cmdlets is to be considered the default parameter values across the different cmdlets
         
    .PARAMETER SettingsJsonString
        String contains settings JSON
         
    .PARAMETER SettingsJsonPath
        String contains path to the settings.json
         
    .PARAMETER OutputAsHashtable
        Instruct the cmdlet to return a hashtable object
         
    .EXAMPLE
        PS C:\> Get-FSCPSLCSSettings
         
        This will output the current FSCPS configuration.
        The object returned will be a PSCustomObject.
         
    .EXAMPLE
        PS C:\> Get-FSCPSLCSSettings -OutputAsHashtable
         
        This will output the current FSCPS configuration.
        The object returned will be a Hashtable.
         
    .LINK
        Set-FSCPSLCSSettings
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, ClientId
         
        Author: Oleksandr Nikolaiev (@onikolaiev)
         
#>


function Get-FSCPSLCSSettings {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
    param (
        [string] $SettingsJsonString,
        [string] $SettingsJsonPath,
        [switch] $OutputAsHashtable
    )
    begin{
        Invoke-TimeSignal -Start   
        $helperPath = Join-Path -Path $($Script:ModuleRoot) -ChildPath "\internal\scripts\helpers.ps1" -Resolve
        . ($helperPath)    
        $res = [Ordered]@{}

        if((-not ($SettingsJsonString -eq "")) -and (-not ($SettingsJsonPath -eq "")))
        {
            throw "Both settings parameters should not be provided. Please provide only one of them."
        }

        if(-not ($SettingsJsonString -eq ""))
        {
            $tmpSettingsFilePath = "C:\temp\settings.json"
            $null = Test-PathExists -Path "C:\temp\" -Type Container -Create
            $null = Set-Content $tmpSettingsFilePath $SettingsJsonString -Force -PassThru
            $null = Set-FSCPSSettings -SettingsFilePath $tmpSettingsFilePath
        }

        if(-not ($SettingsJsonPath -eq ""))
        {
            $null = Set-FSCPSSettings -SettingsFilePath $SettingsJsonPath
        }        
    }
    process{         

        foreach ($config in Get-PSFConfig -FullName "fscps.lcs.settings.all.*") {
            $propertyName = $config.FullName.ToString().Replace("fscps.lcs.settings.all.", "")
            $res.$propertyName = $config.Value
        }
        if($Script:IsOnGitHub)# If GitHub context
        {
            foreach ($config in Get-PSFConfig -FullName "fscps.lcs.settings.github.*") {
                $propertyName = $config.FullName.ToString().Replace("fscps.lcs.settings.github.", "")
                $res.$propertyName = $config.Value
            }
        }
        if($Script:IsOnAzureDevOps)# If ADO context
        {
            foreach ($config in Get-PSFConfig -FullName "fscps.lcs.settings.ado.*") {
                $propertyName = $config.FullName.ToString().Replace("fscps.lcs.settings.ado.", "")
                $res.$propertyName = $config.Value
            }
        }
        if($Script:IsOnLocalhost)# If localhost context
        {
            foreach ($config in Get-PSFConfig -FullName "fscps.lcs.settings.localhost.*") {
                $propertyName = $config.FullName.ToString().Replace("fscps.lcs.settings.localhost.", "")
                $res.$propertyName = $config.Value
            }
        }
        if($OutputAsHashtable) {
            $res
        } else {
            [PSCustomObject]$res
        }   
       
    }
    end{
        Invoke-TimeSignal -End
    }

}


<#
    .SYNOPSIS
        Retrieves a list of shared assets from D365 LCS.
         
    .DESCRIPTION
        The Get-FSCPSLCSSharedAssetList function uses Microsoft Playwright to automate the login process to LCS (Lifecycle Services)
        and retrieves a list of shared assets based on the specified asset file type. It handles authentication, session management,
        and API requests to fetch the required data.
         
    .PARAMETER AssetFileType
        The type of asset file to retrieve. This parameter is mandatory and defaults to "SoftwareDeployablePackage".
         
    .EXAMPLE
        PS C:\> Get-FSCPSLCSSharedAssetList -AssetFileType SoftwareDeployablePackage
         
        Retrieves a list of shared assets of type "SoftwareDeployablePackage" from D365 LCS.
         
    .NOTES
        - This function uses Microsoft Playwright for browser automation.
        - Author: Oleksandr Nikolaiev (@onikolaiev)
#>

function Get-FSCPSLCSSharedAssetList {
    [CmdletBinding()]
    [OutputType()]
    param(
        [Parameter(Mandatory=$false)]
        [AssetFileType]$AssetFileType = [AssetFileType]::SoftwareDeployablePackage
    )
    begin {
        Invoke-TimeSignal -Start
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        Invoke-Login
        $_lcsUri = Get-PSFConfigValue -FullName "fscps.lcs.settings.all.lcsUrl"
        $_apiUri = "/FileAsset/GetSharedAssets/?assetKind=$([int]$AssetFileType)"
        $_requestUri = "$($_lcsUri.TrimEnd('/'))/$($_apiUri.TrimStart('/'))"
    }
    PROCESS {
        if (Test-PSFFunctionInterrupt) { return }        
        
        $requestResult = $Script:CurrentPage.APIRequest.GetAsync($_requestUri).GetAwaiter().GetResult()
        
        if ($requestResult.Status -ne [System.Net.HttpStatusCode]::OK) {
            throw "Failed to retrieve shared assets. Status code: $($requestResult.StatusCode)"
        }

        $result = $requestResult.JsonAsync().GetAwaiter().GetResult()
        $assetList = $result.ToString() | ConvertFrom-Json
        return $assetList.Data.Assets
    }
    END {
        Cleanup-Session
        Invoke-TimeSignal -End
    }    
}


<#
    .SYNOPSIS
        Set the FSCPS configuration details
         
    .DESCRIPTION
        Set the FSCPS configuration details from the configuration store
         
        All settings retrieved from this cmdlets is to be considered the default parameter values across the different cmdlets
         
    .PARAMETER SettingsJsonString
        String contains JSON with custom settings
         
    .PARAMETER SettingsFilePath
        Set path to the settings.json file
         
    .EXAMPLE
        PS C:\> Set-FSCPSSettings -SettingsFilePath "c:\temp\settings.json"
         
        This will output the current FSCPS configuration.
        The object returned will be a Hashtable.
         
    .LINK
        Get-FSCPSLCSSettings
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, Upload, ClientId, Settings
         
        Author: Oleksandr Nikolaiev (@onikolaiev)
         
#>


function Set-FSCPSLCSSettings {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param (
        [string] $SettingsFilePath,
        [string] $SettingsJsonString
    )
    begin{
        if((-not ($SettingsJsonString -eq "")) -and (-not ($SettingsFilePath -eq "")))
        {
            throw "Both settings parameters cannot be provided. Please provide only one of them."
        }

        if(-not ($SettingsJsonString -eq ""))
        {
            $SettingsFilePath = "C:\temp\settings.json"
            $null = Test-PathExists -Path "C:\temp\" -Type Container -Create
            $null = Set-Content $SettingsFilePath $SettingsJsonString -Force -PassThru
        }

        $fscpsFolderName = Get-PSFConfigValue -FullName "fscps.lcs.settings.all.fscpsFolder"
        $fscmSettingsFile = Get-PSFConfigValue -FullName "fscps.lcs.settings.all.fscpsSettingsFile"
        $fscmRepoSettingsFile = Get-PSFConfigValue -FullName "fscps.lcs.settings.all.fscpsRepoSettingsFile"
        Write-PSFMessage -Level Verbose -Message "fscpsFolderName is: $fscpsFolderName"
        Write-PSFMessage -Level Verbose -Message "fscmSettingsFile is: $fscmSettingsFile"
        Write-PSFMessage -Level Verbose -Message "fscmRepoSettingsFile is: $fscmRepoSettingsFile"
        $settingsFiles = @()
        $res = [Ordered]@{}

        $reposytoryName = ""
        $reposytoryOwner = ""
        $currentBranchName = ""

        
        if($Script:IsOnGitHub)# If GitHub context
        {
            Write-PSFMessage -Level Important -Message "Running on GitHub"
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.repoProvider' -Value 'GitHub'
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.repositoryRootPath' -Value "$env:GITHUB_WORKSPACE"            

            Set-PSFConfig -FullName 'fscps.lcs.settings.all.runId' -Value "$ENV:GITHUB_RUN_NUMBER"
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.workflowName' -Value "$ENV:GITHUB_WORKFLOW"

            if($SettingsFilePath -eq "")
            {
                $RepositoryRootPath = "$env:GITHUB_WORKSPACE"
                Write-PSFMessage -Level Verbose -Message "GITHUB_WORKSPACE is: $RepositoryRootPath"
                
                $settingsFiles += (Join-Path $fscpsFolderName $fscmSettingsFile)
            }
            else{
                $settingsFiles += $SettingsFilePath
            }

            $reposytoryOwner = "$env:GITHUB_REPOSITORY".Split("/")[0]
            $reposytoryName = "$env:GITHUB_REPOSITORY".Split("/")[1]
            Write-PSFMessage -Level Verbose -Message "GITHUB_REPOSITORY is: $reposytoryName"
            $branchName = "$env:GITHUB_REF"
            Write-PSFMessage -Level Verbose -Message "GITHUB_REF is: $branchName"
            $currentBranchName = [regex]::Replace($branchName.Replace("refs/heads/","").Replace("/","_"), '(?i)(?:^|-|_)(\p{L})', { $args[0].Groups[1].Value.ToUpper()})      
            $gitHubFolder = ".github"

            $workflowName = "$env:GITHUB_WORKFLOW"
            Write-PSFMessage -Level Verbose -Message "GITHUB_WORKFLOW is: $workflowName"
            $workflowName = ($workflowName.Split([System.IO.Path]::getInvalidFileNameChars()) -join "").Replace("(", "").Replace(")", "").Replace("/", "")

            $settingsFiles += (Join-Path $gitHubFolder $fscmRepoSettingsFile)            
            $settingsFiles += (Join-Path $gitHubFolder "$workflowName.settings.json")
            
        }
        elseif($Script:IsOnAzureDevOps)# If Azure DevOps context
        {
            Write-PSFMessage -Level Verbose -Message "Running on Azure"
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.repoProvider' -Value 'AzureDevOps'
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.repositoryRootPath' -Value "$env:PIPELINE_WORKSPACE"
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.runId' -Value "$ENV:Build_BuildNumber"
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.workflowName' -Value "$ENV:Build_DefinitionName" 
            if($SettingsFilePath -eq "")
            {
                $RepositoryRootPath = "$env:PIPELINE_WORKSPACE"
                Write-PSFMessage -Level Verbose -Message "RepositoryRootPath is: $RepositoryRootPath"
            }
            else{
                $settingsFiles += $SettingsFilePath
            }
            
            $reposytoryOwner = $($env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI.replace('https://dev.azure.com/', '').replace('/', '').replace('https:',''))
            $reposytoryName = "$env:SYSTEM_TEAMPROJECT"
            $branchName = "$env:BUILD_SOURCEBRANCH"
            $currentBranchName = [regex]::Replace($branchName.Replace("/Metadata","").Replace("$/$($reposytoryName)/","").Replace("$/$($reposytoryName)","").Replace("Trunk/","").Replace("/","_"), '(?i)(?:^|-|_)(\p{L})', { $args[0].Groups[1].Value.ToUpper() })   

            #$settingsFiles += (Join-Path $fscpsFolderName $fscmSettingsFile)

        }
        else { # If Desktop or other
            Write-PSFMessage -Level Verbose -Message "Running on desktop"
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.repoProvider' -Value 'Other'
            if($SettingsFilePath -eq "")
            {
                throw "SettingsFilePath variable should be passed if running on the cloud/personal computer"
            }
            $reposytoryName = "windows host"
            Set-PSFConfig -FullName 'fscps.lcs.settings.all.runId' -Value 1
            $currentBranchName = 'DEV'
            $settingsFiles += $SettingsFilePath
        }

        Set-PSFConfig -FullName 'fscps.lcs.settings.all.currentBranch' -Value $currentBranchName
        Set-PSFConfig -FullName 'fscps.lcs.settings.all.repoOwner' -Value $reposytoryOwner
        Set-PSFConfig -FullName 'fscps.lcs.settings.all.repoName' -Value $reposytoryName

        
        function MergeCustomObjectIntoOrderedDictionary {
            Param(
                [System.Collections.Specialized.OrderedDictionary] $dst,
                [PSCustomObject] $src
            )
        
            # Add missing properties in OrderedDictionary

            $src.PSObject.Properties.GetEnumerator() | ForEach-Object {
                $prop = $_.Name
                $srcProp = $src."$prop"
                $srcPropType = $srcProp.GetType().Name
                if (-not $dst.Contains($prop)) {
                    if ($srcPropType -eq "PSCustomObject") {
                        $dst.Add("$prop", [ordered]@{})
                    }
                    elseif ($srcPropType -eq "Object[]") {
                        $dst.Add("$prop", @())
                    }
                    else {
                        $dst.Add("$prop", $srcProp)
                    }
                }
            }
        
            @($dst.Keys) | ForEach-Object {
                $prop = $_
                if ($src.PSObject.Properties.Name -eq $prop) {
                    $dstProp = $dst."$prop"
                    $srcProp = $src."$prop"
                    $dstPropType = $dstProp.GetType().Name
                    $srcPropType = $srcProp.GetType().Name
                    if($dstPropType -eq 'Int32' -and $srcPropType -eq 'Int64')
                    {
                        $dstPropType = 'Int64'
                    }
                    
                    if ($srcPropType -eq "PSCustomObject" -and $dstPropType -eq "OrderedDictionary") {
                        MergeCustomObjectIntoOrderedDictionary -dst $dst."$prop".Value -src $srcProp
                    }
                    elseif ($dstPropType -ne $srcPropType) {
                        throw "property $prop should be of type $dstPropType, is $srcPropType."
                    }
                    else {
                        if ($srcProp -is [Object[]]) {
                            $srcProp | ForEach-Object {
                                $srcElm = $_
                                $srcElmType = $srcElm.GetType().Name
                                if ($srcElmType -eq "PSCustomObject") {
                                    $ht = [ordered]@{}
                                    $srcElm.PSObject.Properties | Sort-Object -Property Name -Culture "iv-iv" | ForEach-Object { $ht[$_.Name] = $_.Value }
                                    $dst."$prop" += @($ht)
                                }
                                else {
                                    $dst."$prop" += $srcElm
                                }
                            }
                        }
                        else {
                            Write-PSFMessage -Level Verbose -Message "Searching fscps.lcs.settings.*.$prop"
                            $setting = Get-PSFConfig -FullName "fscps.lcs.settings.*.$prop"
                            Write-PSFMessage -Level Verbose -Message "Found $setting"
                            if($setting)
                            {
                                Set-PSFConfig -FullName $setting.FullName -Value $srcProp
                            }
                            #$dst."$prop" = $srcProp
                        }
                    }
                }
            }
        }
    }
    process{
        Invoke-TimeSignal -Start    
        $res = Get-FSCPSSettings -OutputAsHashtable

        $settingsFiles | ForEach-Object {
            $settingsFile = $_
            if($RepositoryRootPath)
            {
                $settingsPath = Join-Path $RepositoryRootPath $settingsFile
            }
            else {
                $settingsPath = $SettingsFilePath
            }
            
            Write-PSFMessage -Level Verbose -Message "Settings file '$settingsFile' - $(If (Test-Path $settingsPath) {"exists. Processing..."} Else {"not exists. Skip."})"
            if (Test-Path $settingsPath) {
                try {
                    $settingsJson = Get-Content $settingsPath -Encoding UTF8 | ConvertFrom-Json
        
                    # check settingsJson.version and do modifications if needed
                    MergeCustomObjectIntoOrderedDictionary -dst $res -src $settingsJson
                }
                catch {
                    Write-PSFMessage -Level Host -Message "Settings file $settingsPath, is wrongly formatted." -Exception $PSItem.Exception
                    Stop-PSFFunction -Message "Stopping because of errors"
                    return
                    throw 
                }
            }
            Write-PSFMessage -Level Verbose -Message "Settings file '$settingsFile' - processed"
        }
        Write-PSFMessage -Level Host  -Message "Settings were updated succesfully."
        Invoke-TimeSignal -End
    }
    end{

    }

}